## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인 config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%). - DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신 (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등) ## 백테스트를 실전 로직에 정합 - calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트 `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향 - 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치 - us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명) - backend/backtest_results/ 25건은 폐기 대상 (§8.4) ## 리스크 가드 복원 - 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로 - 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다) - 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동 - MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용) - 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정 ## 죽은 코드 제거 (2,695줄) - short_term_strategy, kosdaq_scanner, app/strategy/ 패키지 - app/trading/ + korea_investment_api_adapter (real_market_api 정본은 korea_investment_api.py:1482, adapter 는 import 0건) ## 지표·사이징 단일화 (수치 무변경 증명) - indicators_core.py: 3,274건 비교 최대 오차 0.000e+00 - sizing_core.py: 42,105건 비교 불일치 0 - 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교) ## 테스트 안전 (실 KIS 호출 차단) - 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이 close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로 바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤 참조 누수로 실제 잔고 조회를 타 141초를 소비했다. - backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단. 예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게), 클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제 - 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed) ## 신규 (둘 다 플래그 OFF, 백테스트 미검증) - range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선 - check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%) ## 문서 - CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약, §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거, §8.5 미해결 19항목 - 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
145 lines
7.9 KiB
Python
145 lines
7.9 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():
|
|
from app.api.endpoints import portfolio as pf
|
|
src = inspect.getsource(pf.sync_portfolio)
|
|
# 국내 신규/기존 행 + 해외 = 태깅 지점 4곳
|
|
assert src.count('quantity <= _dt_qty.get(stock_code, 0)') == 4
|
|
# 정의가 output1 블록보다 앞이어야 NameError 가 없다
|
|
assert src.index("_dt_codes = set(_dt_qty)") < src.index('if "output1" in balance_data')
|