Spaces:
Running
Running
| """ | |
| Unit tests for services/backtest_service.py | |
| Covers: | |
| #13 — HOLD signal net_return should be 0 (no trade) | |
| #14 — SELL signal PnL uses -actual_return (short position) | |
| #21 — backtest logic lives in service layer, stats are correct | |
| """ | |
| import pytest | |
| import inspect | |
| from services.backtest_service import ( | |
| compute_net_return, | |
| _build_stats, | |
| ROUND_TRIP_COST_PCT, | |
| BacktestError, | |
| walk_forward_backtest, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # #13: HOLD net_return = 0 | |
| # --------------------------------------------------------------------------- | |
| class TestHoldNetReturn: | |
| """#13 — HOLD means no trade, net_return must be 0.""" | |
| def test_hold_positive_market(self): | |
| """HOLD when market goes up: still 0.""" | |
| assert compute_net_return("HOLD", 5.0, ROUND_TRIP_COST_PCT) == 0.0 | |
| def test_hold_negative_market(self): | |
| """HOLD when market goes down: still 0.""" | |
| assert compute_net_return("HOLD", -3.0, ROUND_TRIP_COST_PCT) == 0.0 | |
| def test_hold_zero_market(self): | |
| """HOLD when market is flat: still 0.""" | |
| assert compute_net_return("HOLD", 0.0, ROUND_TRIP_COST_PCT) == 0.0 | |
| # --------------------------------------------------------------------------- | |
| # #14: SELL net_return = -actual_return - cost (short position) | |
| # --------------------------------------------------------------------------- | |
| class TestSellNetReturn: | |
| """#14 — SELL = short position, profits when price drops.""" | |
| def test_sell_price_drops(self): | |
| """SELL when price drops 3%: profit = +3% - cost.""" | |
| actual = -3.0 | |
| result = compute_net_return("SELL", actual, ROUND_TRIP_COST_PCT) | |
| expected = -actual - ROUND_TRIP_COST_PCT # 3.0 - 0.471 = 2.529 | |
| assert abs(result - expected) < 1e-10 | |
| def test_sell_price_rises(self): | |
| """SELL when price rises 2%: loss = -2% - cost.""" | |
| actual = 2.0 | |
| result = compute_net_return("SELL", actual, ROUND_TRIP_COST_PCT) | |
| expected = -actual - ROUND_TRIP_COST_PCT # -2.0 - 0.471 = -2.471 | |
| assert abs(result - expected) < 1e-10 | |
| def test_sell_price_flat(self): | |
| """SELL when price flat: loss = -cost only.""" | |
| result = compute_net_return("SELL", 0.0, ROUND_TRIP_COST_PCT) | |
| expected = -ROUND_TRIP_COST_PCT | |
| assert abs(result - expected) < 1e-10 | |
| # --------------------------------------------------------------------------- | |
| # BUY net_return (unchanged behaviour, regression test) | |
| # --------------------------------------------------------------------------- | |
| class TestBuyNetReturn: | |
| """BUY = long position, profits when price rises.""" | |
| def test_buy_price_rises(self): | |
| actual = 5.0 | |
| result = compute_net_return("BUY", actual, ROUND_TRIP_COST_PCT) | |
| expected = actual - ROUND_TRIP_COST_PCT | |
| assert abs(result - expected) < 1e-10 | |
| def test_buy_price_drops(self): | |
| actual = -2.0 | |
| result = compute_net_return("BUY", actual, ROUND_TRIP_COST_PCT) | |
| expected = actual - ROUND_TRIP_COST_PCT | |
| assert abs(result - expected) < 1e-10 | |
| # --------------------------------------------------------------------------- | |
| # #21: _build_stats aggregation | |
| # --------------------------------------------------------------------------- | |
| class TestBuildStats: | |
| """#21 — Stats aggregation logic (extracted to service layer).""" | |
| def sample_trades(self): | |
| """Mix of BUY / SELL / HOLD trades for stat verification.""" | |
| return [ | |
| { | |
| "date": "2025-01-02", | |
| "signal": "BUY", | |
| "buy_prob": 0.75, | |
| "close": 100.0, | |
| "future_close": 105.0, | |
| "actual_return_pct": 5.0, | |
| "net_return_pct": round(5.0 - ROUND_TRIP_COST_PCT, 2), | |
| "correct": True, | |
| }, | |
| { | |
| "date": "2025-01-03", | |
| "signal": "SELL", | |
| "buy_prob": 0.20, | |
| "close": 100.0, | |
| "future_close": 97.0, | |
| "actual_return_pct": -3.0, | |
| "net_return_pct": round(3.0 - ROUND_TRIP_COST_PCT, 2), | |
| "correct": True, | |
| }, | |
| { | |
| "date": "2025-01-04", | |
| "signal": "HOLD", | |
| "buy_prob": 0.50, | |
| "close": 100.0, | |
| "future_close": 102.0, | |
| "actual_return_pct": 2.0, | |
| "net_return_pct": 0.0, | |
| "correct": False, # HOLD 不算 correct/incorrect in non_holds | |
| }, | |
| { | |
| "date": "2025-01-05", | |
| "signal": "SELL", | |
| "buy_prob": 0.30, | |
| "close": 100.0, | |
| "future_close": 103.0, | |
| "actual_return_pct": 3.0, | |
| "net_return_pct": round(-3.0 - ROUND_TRIP_COST_PCT, 2), | |
| "correct": False, # predicted down, went up | |
| }, | |
| ] | |
| def test_signal_counts(self, sample_trades): | |
| result = _build_stats("TEST", 6, sample_trades) | |
| stats = result["stats"] | |
| assert stats["buy_signals"] == 1 | |
| assert stats["sell_signals"] == 2 | |
| assert stats["hold_signals"] == 1 | |
| assert stats["total_days"] == 4 | |
| def test_hold_not_in_total_return(self, sample_trades): | |
| """HOLD trades contribute 0 to total_return_if_followed.""" | |
| result = _build_stats("TEST", 6, sample_trades) | |
| non_hold_returns = sum( | |
| t["net_return_pct"] for t in sample_trades if t["signal"] != "HOLD" | |
| ) | |
| assert result["stats"]["total_return_if_followed"] == round(non_hold_returns, 2) | |
| def test_signal_accuracy_excludes_hold(self, sample_trades): | |
| """Signal accuracy only considers BUY + SELL, not HOLD.""" | |
| result = _build_stats("TEST", 6, sample_trades) | |
| # 3 non-hold trades: 2 correct (BUY correct, SELL#1 correct), 1 wrong (SELL#2) | |
| expected = round(2 / 3 * 100, 1) | |
| assert result["stats"]["signal_accuracy_overall"] == expected | |
| def test_empty_results(self): | |
| """Empty results should not crash.""" | |
| result = _build_stats("TEST", 6, []) | |
| assert result["stats"]["total_days"] == 0 | |
| assert result["stats"]["best_trade_pct"] == 0 | |
| assert result["stats"]["worst_trade_pct"] == 0 | |
| def test_metadata_fields(self, sample_trades): | |
| result = _build_stats("TEST", 6, sample_trades) | |
| assert result["stock_no"] == "TEST" | |
| assert result["months"] == 6 | |
| assert result["walk_forward"] is True | |
| assert "trading_cost_pct" in result | |
| assert "cost_breakdown" in result | |
| # --------------------------------------------------------------------------- | |
| # BacktestError (used by router to raise 404) | |
| # --------------------------------------------------------------------------- | |
| class TestBacktestError: | |
| def test_is_exception(self): | |
| with pytest.raises(BacktestError): | |
| raise BacktestError("No data") | |
| def test_message(self): | |
| err = BacktestError("No data found for 9999") | |
| assert "9999" in str(err) | |
| class TestBacktestLeakageGuards: | |
| def test_backtest_does_not_use_news_overlay(self): | |
| source = inspect.getsource(walk_forward_backtest).lower() | |
| assert "news" not in source | |