Files
auto-stock/backend/tests/test_trade_analyzer_v2_20261009.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

120 lines
5.0 KiB
Python

"""trade_analyzer v2 (라운드트립 귀인 / 시스템 성과 분리 / 0 채움 Sharpe) 회귀 테스트. DB·네트워크 없음."""
from datetime import date, datetime, timedelta
from types import SimpleNamespace as NS
import pytest
from app.services import trade_analyzer as ta
def _t(i, code, otype, ts, qty, price=10000, net=None, rate=None, st="swing",
entry_reason=None, exit_reason=None, link=None):
return NS(id=i, stock_code=code, order_type=otype, created_at=ts,
executed_quantity=qty, order_quantity=qty, executed_price=price, order_price=price,
net_profit_loss=net, profit_loss_rate=rate, strategy_type=st,
entry_reason=entry_reason, exit_reason=exit_reason, entry_trade_id=link,
stock_name=None)
D0 = datetime(2026, 9, 1, 10, 0)
def test_split_sell_is_one_position_fifo_when_entry_trade_id_null():
buy = _t(1, "005930", "BUY", D0, 10, entry_reason="mean_reversion RSI")
s1 = _t(2, "005930", "SELL", D0 + timedelta(days=2), 5, 11000, net=4000, rate=10.0, exit_reason="익절")
s2 = _t(3, "005930", "SELL", D0 + timedelta(days=4), 5, 9000, net=-6000, rate=-10.0, exit_reason="손절")
m = ta._match_roundtrips_v2([buy], [s1, s2])
assert all(x["buy"].id == 1 for x in m)
pos = ta._build_positions_v2(m)
assert len(pos) == 1 and pos[0]["n_exits"] == 2
assert pos[0]["net_pnl"] == -2000
assert pos[0]["closed"] is True
st = ta._position_stats_v2(pos)
# SELL 단위 승률 50% 이지만 포지션 단위는 손실 1건 = 0%
assert st["positions"] == 1 and st["win_rate"] == 0.0
def test_open_partial_position_not_counted_as_win():
buy = _t(1, "A", "BUY", D0, 10)
s1 = _t(2, "A", "SELL", D0 + timedelta(days=1), 5, 11000, net=4000, rate=10.0)
pos = ta._build_positions_v2(ta._match_roundtrips_v2([buy], [s1]))
st = ta._position_stats_v2(pos)
assert st["positions"] == 0 and st["open_partial_positions"] == 1
def test_explicit_entry_trade_id_preferred():
b1 = _t(1, "A", "BUY", D0, 10)
b2 = _t(2, "A", "BUY", D0 + timedelta(hours=1), 10)
s = _t(3, "A", "SELL", D0 + timedelta(days=1), 10, link=2, net=1, rate=1.0)
m = ta._match_roundtrips_v2([b1, b2], [s])
assert m[0]["buy"].id == 2
def test_unmatched_sell_is_standalone_orphan():
s = _t(9, "Z", "SELL", D0, 3, net=100, rate=1.0)
pos = ta._build_positions_v2(ta._match_roundtrips_v2([], [s]))
assert pos[0]["entry_trade_id"] is None and pos[0]["closed"]
def test_manual_excluded_from_system_summary():
sells = [
_t(1, "A", "SELL", D0, 1, net=-1000, rate=-1.0, st="swing"),
_t(2, "B", "SELL", D0, 1, net=85000, rate=8.0, st="manual"),
_t(3, "C", "SELL", D0, 1, net=500, rate=0.5, st="daytrading"),
]
perf = ta._compose_performance_v2(sells, [], [D0.date()], None, 30)
assert perf["summary"]["total_trades"] == 2
assert perf["summary"]["net_pnl"] == -500
assert perf["summary_all"]["net_pnl"] == 84500
assert perf["external_summary"]["total_trades"] == 1
# build_insights 호환
assert isinstance(ta.build_insights(perf), list)
def _tdays(n):
out, d = [], date(2026, 8, 3)
while len(out) < n:
if d.weekday() < 5:
out.append(d)
d += timedelta(days=1)
return out
def test_zero_filled_daily_returns_and_real_capital_denominator():
days = _tdays(40)
pnl = {days[0]: 100_000.0, days[1]: -100_000.0}
r = ta._daily_returns_v2(pnl, days, {days[0]: 50_000_000.0})
assert len(r["returns"]) == 40 # 무거래일 포함
assert r["returns"][0] == pytest.approx(100_000 / 50_000_000)
assert r["capital_source"] == "session_starting_balance"
legacy = ta._daily_returns_v2(pnl, days, None)
assert legacy["capital_source"] == "fallback_fixed_capital"
def test_zero_fill_lowers_sharpe_vs_active_days_only():
days = _tdays(60)
pnl = {days[i]: (200_000.0 if i % 2 == 0 else -150_000.0) for i in range(0, 30)}
sells = [_t(i, "A", "SELL", datetime.combine(d, datetime.min.time()), 1, net=v, rate=1.0)
for i, (d, v) in enumerate(pnl.items())]
old = ta._calc_sharpe_ratio(ta._daily_pnl_to_returns(ta._build_daily_pnl_series(sells)))
new = ta._risk_metrics_v2(sells, days, None)
assert old is not None and new["sharpe_ratio"] is not None
assert new["sharpe_ratio"] != old
assert new["daily_returns_count"] == 60
assert new["active_trading_days"] == 30
assert new["sharpe_ci95"][0] < new["sharpe_ratio"] < new["sharpe_ci95"][1]
def test_low_sample_flag():
days = _tdays(25)
m = ta._risk_metrics_v2([], days, None)
assert m["low_sample"] is True and m["sharpe_ratio"] is None
def test_holding_period_attribution():
buy = _t(1, "A", "BUY", D0, 10, entry_reason="mean_reversion")
s = _t(2, "A", "SELL", D0 + timedelta(days=10), 10, 11000, net=9000, rate=9.0, exit_reason="익절")
perf = ta._compose_performance_v2([s], [buy], [D0.date()], None, 30)
assert "6-20d" in perf["by_holding_period"]
assert perf["roundtrip"]["matched_to_buy"] == 1