- 스윙 체결가 확인 전 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
120 lines
5.0 KiB
Python
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
|