"""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