fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거

## 배경
"전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라
전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다.

## 파라미터 소스 단일화
- 우선순위가 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>
This commit is contained in:
2026-09-28 08:58:18 +09:00
parent b4608e5abf
commit 1b9257c4d2
71 changed files with 5510 additions and 3685 deletions

View File

@@ -1,39 +1,138 @@
"""감사 P1 수정 검증: MDD 수량 반영 / 전략명 명시 / 국내 DT 태깅."""
import inspect
import re
from unittest.mock import patch
import pytest
def test_mdd_reduction_reflects_quantities():
"""MDD 축소는 ActiveTrade·Portfolio·DT 인메모리 수량을 모두 줄여야 한다."""
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, "두 축소 경로 모두 반영 필요"
assert '"quantity": remaining_qty' in src
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)을 닫거나 덮어쓴다."""
"""전략명을 비우면 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
assert '"strategy_name": _at_strategy' in src # 트레일링 고점 upsert
assert '"strategy_name": _strategy or "swing"' in src # 분할매도 upsert
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 보유분까지 팔면 안 된다."""
"""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
# 분할매도 upsert 는 실제 ActiveTrade 행의 전략명(_at_name)을 사용한다
assert '"strategy_name": _at_name' 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():