## 재기동 내구성 - scheduler: 복원 직후 reset_if_new_day()가 복원분을 전부 폐기하던 버그 (begin_restore()로 날짜 경계를 선확정. 복원 성공 로그는 뜨는데 실제로는 지워졌다) - 일일 안전장치(daily_pnl/loss_streak_today/closed_today) Redis 영속화 날짜를 키와 페이로드 양쪽에 실어 이중 게이트, 복원은 보수적 병합 (손익 min/연패 max/청산목록 합집합 — 복원이 안전장치를 약화시킬 수 없다) - db_worker 종료 drain이 insert_trade_history만 커밋하고 포지션 추적 행은 DLQ로 버리던 비대칭 제거 + DLQ 재처리 경로 신설(쓰기 전용이었다) - 장중 기동 시 ORB 소급 수집(backfill), 전일 캔들 혼입 차단 - swing/exit_service "보유 종목 없음" debug→info, 10회 연속 warning ## DT 진입 복구 - 워밍업 회귀 복구: 1b9257c(2026-09-28)가 조회 계층에 당일 필터를 넣어 5분봉 RSI(14)/BB(20) 워밍업이 죽고 VWAP/RSI_BB가 10:15/10:40 이전 발화 불가가 됐다. 필터를 삭제하지 않고 용도별로 분리 (get_minute_chart_full_day_v2 신설, 기존 메서드·3개 호출부 동작 무변경) - 거래량 게이트 비교 대상 교정: 형성 중 봉 → 완성봉 (5분봉·ORB 1분봉 양쪽) 임계값(1.3/1.5)은 불변, 고친 것은 비교 대상뿐 - 세션 경계 차단: _last_session_candles()로 거래량 비교가 전일 봉을 잡아 개장 직후 게이트가 자동 통과하는 것을 방지 - 스캔 선조회(동시성 4)·종목 타임아웃·시간예산·라운드로빈 + 잘린 종목 수 로그 - watchlist 30분 갱신 시 ORB 보유 종목 우선 유지 ## 유입 잔고 격리 - sync_portfolio()가 시스템 매수 근거 없는 KIS 잔고를 자기 스윙 포지션으로 편입해 자동 손절로 처분하던 문제 → strategy_type='external' 격리 (승격만 허용, 강등 없음. 기존 행은 수동 SQL 필요) - 격리분 손절선 이탈은 집행 없이 warning + summary 노출 - 수동 청산 경로에 record_trade_result 누락 보강, 전략 간 당일 쿨다운 ## 4% 상한 - market_data 표시 경로 클램프 누락 + confidence 배율 후 재클램프 - 회귀 방지 테스트(AST로 가드가 try 밖에 있는지 검증) ## 프론트 - 총자산 보정 가드(3%·종목수·가격결손·국내분 한정), 원인 미규명 명시 - cumulative_realized_pl이 /portfolio/combined에 없어 항상 0이던 문제 테스트 579 → 677 passed / 0 failed (소켓 차단 하 통과, 실호출 없음) 전략 임계값 변경 0건 ⚠️ 워밍업 복구의 효과 크기는 미확정 — KIS 분봉 API에 날짜 파라미터가 없어 미래 ref_time에 전일 데이터를 주는지가 미검증 전제다. 배포 후 "[분봉] 워밍업 5분봉 N봉 (당일 a + 전일 b)" 로그의 b로 확정할 것. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
157 lines
8.8 KiB
Python
157 lines
8.8 KiB
Python
"""감사 P1 수정 검증: MDD 수량 반영 / 전략명 명시 / 국내 DT 태깅."""
|
|
import inspect
|
|
import re
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
|
|
def _active_trade_payload_scopes(src: str) -> dict:
|
|
"""`enqueue_active_trade_upsert(<변수>)` 로 넘기는 payload 변수별 '전략명 한정 키' 집합.
|
|
|
|
불변식: ActiveTrade upsert 는 반드시 대상 행을 전략명으로 한정해야 한다.
|
|
- `strategy_name` : 전략명이 있는 행을 동등 매칭
|
|
- `strategy_name_not` : 전략명이 NULL 인 legacy/동기화 유입 행을 비-DT 조건으로 겨냥
|
|
둘 중 아무것도 없으면 db_worker 가 '가장 최근 active 행'을 잡아 같은 종목의
|
|
데이트레이딩 행을 덮어쓴다(= 이 가드가 막으려는 과거 버그).
|
|
|
|
[2026-09-27] 구현이 dict 리터럴(`{"strategy_name": x, ...}`) → 변수 조립
|
|
(`payload["strategy_name"] = x`) 로 바뀌면서 리터럴 문자열 매칭이 깨졌다.
|
|
변수명·조립 순서에 의존하지 않도록 payload 변수 단위로 키를 수집한다.
|
|
"""
|
|
scopes: dict = {}
|
|
for var in re.findall(r"enqueue_active_trade_upsert\(\s*(_\w+)\s*\)", src):
|
|
scopes[var] = set(
|
|
re.findall(
|
|
rf'{re.escape(var)}\[\s*"(strategy_name|strategy_name_not)"\s*\]\s*=',
|
|
src,
|
|
)
|
|
) | set(
|
|
# dict 리터럴/update() 로 넣는 경우도 함께 인정
|
|
re.findall(
|
|
rf'{re.escape(var)}(?:\s*=|\.update\()\s*\{{[^}}]*?"(strategy_name|strategy_name_not)"\s*:',
|
|
src,
|
|
re.S,
|
|
)
|
|
)
|
|
return scopes
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mdd_reduction_reflects_quantities():
|
|
"""MDD 축소는 ActiveTrade·Portfolio·DT 인메모리 수량을 모두 줄여야 한다.
|
|
|
|
과거 버그(P1 감사): 축소 매도가 체결됐는데 ActiveTrade.quantity 가 축소 전
|
|
값으로 남아, 다음 청산이 실제 보유량보다 많이 매도했다(과다매도 → 주문 거부
|
|
또는 미보유 매도). 그래서 '잔량이 payload 에 실리는지' 를 가드한다.
|
|
|
|
[2026-09-27] 구현이 dict 리터럴 `{"quantity": remaining_qty}` →
|
|
`_sync_active_trade_after_reduction` 내부 헬퍼(`_upsert`/`_upsert_non_dt`)의
|
|
payload 조립(`"quantity": qty`)으로 바뀌어 문자열 매칭이 깨졌다. 동작은 동일.
|
|
문자열 대신 **실제로 큐에 실리는 payload** 를 캡처해 검증하도록 갱신 —
|
|
리팩터링에는 견고하고, 잔량 반영이 빠지면 여전히 실패한다.
|
|
"""
|
|
from app.services import mdd_manager as m
|
|
|
|
captured: list = []
|
|
|
|
async def _cap(payload):
|
|
captured.append(payload)
|
|
|
|
# (1) DT 병존 없음 → 최신 active 행 1건에 잔량·배율 반영
|
|
with patch("app.services.db_worker.enqueue_active_trade_upsert", _cap), \
|
|
patch.object(m, "_dt_position_qty", lambda _code: 0):
|
|
await m._sync_active_trade_after_reduction("005930", quantity=10, remaining_qty=7)
|
|
assert len(captured) == 1, "축소 반영 payload 가 큐잉되지 않음"
|
|
assert captured[0].get("quantity") == 7, "ActiveTrade 잔량 미반영 → 다음 청산이 과다매도"
|
|
assert captured[0]["remaining_pct_multiply"] == pytest.approx(0.7)
|
|
|
|
# (2) DT 포지션 병존 → DT 행은 건드리지 않고(비-DT 필터) 스윙 잔량만 반영
|
|
captured.clear()
|
|
with patch("app.services.db_worker.enqueue_active_trade_upsert", _cap), \
|
|
patch.object(m, "_dt_position_qty", lambda _code: 3):
|
|
await m._sync_active_trade_after_reduction("005930", quantity=10, remaining_qty=7)
|
|
assert len(captured) == 1
|
|
assert captured[0].get("quantity") == 7, "DT 병존 경로에서 잔량 미반영"
|
|
assert captured[0].get("strategy_name_not"), "DT ActiveTrade 행 오염 방지 필터 누락"
|
|
|
|
# 두 축소 경로(REDUCE_AND_PAUSE / FORCE_REDUCE) 모두 인메모리 반영을 호출해야 한다
|
|
src = inspect.getsource(m)
|
|
assert src.count("_reflect_reduction(stock_code, sell_qty") == 2, "두 축소 경로 모두 반영 필요"
|
|
helper = inspect.getsource(m._reflect_reduction)
|
|
assert "reflect_sell" in helper and "day_trading_state" in helper
|
|
assert "remove_position" in helper and 'pos["quantity"] = _left' in helper
|
|
|
|
|
|
def test_swing_exit_always_passes_strategy_name():
|
|
"""전략명을 비우면 db_worker 가 최신 active 행(=DT)을 닫거나 덮어쓴다.
|
|
|
|
과거 버그(P1 감사): 스윙 청산이 전략명 없이 close/upsert 를 큐잉해, 같은 종목에
|
|
데이트레이딩 행이 있으면 DT 행이 닫히거나 잔량이 덮어써지고 스윙 행은 영구 잔존.
|
|
|
|
[2026-09-27] 전략명이 NULL 인 legacy/동기화 유입 행을 'swing' 으로 폴백하면
|
|
매칭 0건이 되는 문제 때문에 `_at_filter_not_dt` 분기가 추가됐다:
|
|
- 전략명 있음 → `payload["strategy_name"] = _at_strategy`
|
|
- 전략명 NULL → `payload["strategy_name_not"] = DT_STRATEGY_NAMES`
|
|
두 경로 모두 'DT 행을 건드리지 않는다' 는 불변식을 지킨다. 따라서 특정 리터럴
|
|
대신 **모든 upsert payload 가 전략명으로 한정되는지** 를 검사한다.
|
|
"""
|
|
from app.services.swing import exit_service as se
|
|
src = inspect.getsource(se)
|
|
assert "strategy_name=None" not in src, "close 호출에 전략명 누락"
|
|
assert 'strategy_name=_strategy or "swing"' in src # 전량청산 close 경로
|
|
scopes = _active_trade_payload_scopes(src)
|
|
# 트레일링 고점 upsert + 분할매도 upsert = 최소 2곳
|
|
assert len(scopes) >= 2, f"ActiveTrade upsert payload 검출 실패: {scopes}"
|
|
for var, keys in scopes.items():
|
|
assert keys, f"{var}: 전략명 한정 없이 upsert → DT ActiveTrade 행 오염"
|
|
# NULL 전략명 경로는 DT 제외 필터로 비-DT 행을 겨냥해야 한다
|
|
assert "DT_STRATEGY_NAMES" in src, "DT 제외 필터 근거 소실"
|
|
|
|
|
|
def test_executor_sell_excludes_daytrading_shares():
|
|
"""Portfolio 행은 스윙+DT 합계 → 스윙 청산이 DT 보유분까지 팔면 안 된다.
|
|
|
|
과거 버그(P1 감사): Portfolio 한 행에 스윙+DT 수량이 합산돼 있어 스윙 청산이
|
|
DT 보유분까지 매도했고, ActiveTrade 갱신도 DT 행을 집어 닫아버렸다.
|
|
|
|
[2026-09-27] 분할매도 upsert 가 dict 리터럴 → `_at_payload[...] = ...` 조립으로
|
|
바뀌고, 전략명 NULL 행을 위한 `strategy_name_not` 분기가 추가됐다(스윙 청산과
|
|
동일 패턴). 또 캐시 반영용 '행 합산 수량'(_row_total_qty)과 이 전략 보유분(_own)이
|
|
분리됐다. 검사 방식만 갱신 — 불변식(DT 수량 제외 + DT 행 비오염)은 그대로 본다.
|
|
"""
|
|
from app.services import trade_executor as te
|
|
src = inspect.getsource(te._execute_sell)
|
|
assert "day_trading_state" in src and "_dt_qty" in src
|
|
assert "owned_by_daytrading" in src
|
|
assert 'total_qty = _own' in src, "DT 수량 차감 후 매도 대상 축소 누락"
|
|
# 캐시/Portfolio 행 잔량은 행 합산 기준이어야 DT 보유분이 사라지지 않는다
|
|
assert "_row_total_qty" in src
|
|
# 분할매도 upsert 는 전략명(또는 비-DT 필터)으로 대상 행을 한정해야 한다
|
|
scopes = _active_trade_payload_scopes(src)
|
|
assert scopes, "ActiveTrade upsert payload 검출 실패 — 검사 대상 확인 필요"
|
|
for var, keys in scopes.items():
|
|
assert keys, f"{var}: 전략명 한정 없이 upsert → DT ActiveTrade 행 오염"
|
|
assert "DT_STRATEGY_NAMES" in src, "DT 제외 필터 근거 소실"
|
|
|
|
|
|
def test_sync_dt_tagging_applies_to_domestic_and_survives_missing_output1():
|
|
"""DT 태깅이 국내(신규·기존)·해외 4경로 모두에 적용되는지.
|
|
|
|
[2026-09-28] 유입(external) 격리 도입으로 **신규 행** 2곳의 판정이 인라인 비교에서
|
|
순수 함수 `_classify_holding_strategy_type`(= DT 전량 → daytrading, 시스템 매수
|
|
근거 없음 → external) 위임으로 바뀌었다. 인라인 비교는 기존 행 갱신부(국내·해외)
|
|
2곳에만 남는다. 불변식(4경로 전부 태깅 + `_dt_qty` 가 output1 블록보다 앞)은 동일.
|
|
"""
|
|
from app.api.endpoints import portfolio as pf
|
|
src = inspect.getsource(pf.sync_portfolio)
|
|
# 기존 행 갱신부(국내·해외) = 인라인 DT 비교 2곳
|
|
assert src.count('quantity <= _dt_qty.get(stock_code, 0)') == 2
|
|
# 신규 행(국내·해외) = 분류 함수 위임 2곳
|
|
assert src.count("_classify_strategy_type(stock_code, quantity)") == 2
|
|
# 분류 함수가 DT 전량 판정을 보유
|
|
pure_src = inspect.getsource(pf._classify_holding_strategy_type)
|
|
assert 'return "daytrading"' in pure_src and "quantity <= _dt_held" in pure_src
|
|
# 정의가 output1 블록보다 앞이어야 NameError 가 없다
|
|
assert src.index("_dt_codes = set(_dt_qty)") < src.index('if "output1" in balance_data')
|