You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

286 lines
11 KiB

# -*- coding: utf-8 -*-
"""Unit tests for backtest engine."""
import unittest
from dataclasses import dataclass
from datetime import date, timedelta
from src.core.backtest_engine import BacktestEngine, EvaluationConfig
@dataclass
class Bar:
date: date
high: float
low: float
close: float
class BacktestEngineTestCase(unittest.TestCase):
def _bars(self, start: date, closes, highs=None, lows=None):
highs = highs or closes
lows = lows or closes
bars = []
for i, c in enumerate(closes):
bars.append(Bar(date=start + timedelta(days=i + 1), high=highs[i], low=lows[i], close=c))
return bars
def test_buy_win_when_up(self):
cfg = EvaluationConfig(eval_window_days=3, neutral_band_pct=2.0)
bars = self._bars(date(2024, 1, 1), [102, 104, 105], highs=[103, 105, 106], lows=[101, 103, 104])
res = BacktestEngine.evaluate_single(
operation_advice="买入",
analysis_date=date(2024, 1, 1),
start_price=100,
forward_bars=bars,
stop_loss=95,
take_profit=110,
config=cfg,
)
self.assertEqual(res["eval_status"], "completed")
self.assertEqual(res["outcome"], "win")
self.assertTrue(res["direction_correct"]) # up
def test_sell_win_when_down_cash(self):
cfg = EvaluationConfig(eval_window_days=3, neutral_band_pct=2.0)
bars = self._bars(date(2024, 1, 1), [98, 97, 96], highs=[99, 98, 97], lows=[97, 96, 95])
res = BacktestEngine.evaluate_single(
operation_advice="卖出",
analysis_date=date(2024, 1, 1),
start_price=100,
forward_bars=bars,
stop_loss=95,
take_profit=110,
config=cfg,
)
self.assertEqual(res["position_recommendation"], "cash")
self.assertEqual(res["outcome"], "win")
self.assertEqual(res["simulated_return_pct"], 0.0)
self.assertEqual(res["first_hit"], "not_applicable")
def test_wait_maps_to_cash_and_flat_direction(self):
cfg = EvaluationConfig(eval_window_days=3, neutral_band_pct=2.0)
# Stock drops ~5%: AI said wait (neutral), stock moved significantly → loss
bars = self._bars(date(2024, 1, 1), [98, 96, 95], highs=[99, 97, 96], lows=[97, 95, 94])
res = BacktestEngine.evaluate_single(
operation_advice="观望",
analysis_date=date(2024, 1, 1),
start_price=100,
forward_bars=bars,
stop_loss=95,
take_profit=110,
config=cfg,
)
self.assertEqual(res["position_recommendation"], "cash")
self.assertEqual(res["direction_expected"], "flat")
self.assertEqual(res["outcome"], "loss")
def test_hold_win_when_flat(self):
cfg = EvaluationConfig(eval_window_days=3, neutral_band_pct=2.0)
bars = self._bars(date(2024, 1, 1), [100.5, 100.2, 101], highs=[101, 101, 101], lows=[99.8, 99.9, 100])
res = BacktestEngine.evaluate_single(
operation_advice="持有",
analysis_date=date(2024, 1, 1),
start_price=100,
forward_bars=bars,
stop_loss=None,
take_profit=None,
config=cfg,
)
self.assertEqual(res["outcome"], "win")
def test_hold_win_when_up(self):
cfg = EvaluationConfig(eval_window_days=3, neutral_band_pct=2.0)
bars = self._bars(date(2024, 1, 1), [102, 103, 104], highs=[103, 104, 105], lows=[101, 102, 103])
res = BacktestEngine.evaluate_single(
operation_advice="持有",
analysis_date=date(2024, 1, 1),
start_price=100,
forward_bars=bars,
stop_loss=None,
take_profit=None,
config=cfg,
)
self.assertEqual(res["outcome"], "win")
def test_stop_loss_hit_first(self):
cfg = EvaluationConfig(eval_window_days=3, neutral_band_pct=2.0)
bars = self._bars(date(2024, 1, 1), [99, 98, 97], highs=[101, 100, 99], lows=[94, 97, 96])
res = BacktestEngine.evaluate_single(
operation_advice="买入",
analysis_date=date(2024, 1, 1),
start_price=100,
forward_bars=bars,
stop_loss=95,
take_profit=110,
config=cfg,
)
self.assertTrue(res["hit_stop_loss"])
self.assertEqual(res["first_hit"], "stop_loss")
self.assertEqual(res["simulated_exit_reason"], "stop_loss")
def test_take_profit_hit_first(self):
cfg = EvaluationConfig(eval_window_days=3, neutral_band_pct=2.0)
bars = self._bars(date(2024, 1, 1), [105, 106, 107], highs=[111, 107, 108], lows=[103, 105, 106])
res = BacktestEngine.evaluate_single(
operation_advice="买入",
analysis_date=date(2024, 1, 1),
start_price=100,
forward_bars=bars,
stop_loss=95,
take_profit=110,
config=cfg,
)
self.assertTrue(res["hit_take_profit"])
self.assertEqual(res["first_hit"], "take_profit")
self.assertEqual(res["simulated_exit_reason"], "take_profit")
def test_ambiguous_same_day(self):
cfg = EvaluationConfig(eval_window_days=2, neutral_band_pct=2.0)
bars = self._bars(date(2024, 1, 1), [100, 100], highs=[111, 100], lows=[94, 99])
res = BacktestEngine.evaluate_single(
operation_advice="买入",
analysis_date=date(2024, 1, 1),
start_price=100,
forward_bars=bars,
stop_loss=95,
take_profit=110,
config=cfg,
)
self.assertEqual(res["first_hit"], "ambiguous")
self.assertEqual(res["simulated_exit_reason"], "ambiguous_stop_loss")
def test_buy_loss_when_down(self):
cfg = EvaluationConfig(eval_window_days=3, neutral_band_pct=2.0)
bars = self._bars(date(2024, 1, 1), [98, 96, 95], highs=[99, 97, 96], lows=[97, 95, 94])
res = BacktestEngine.evaluate_single(
operation_advice="买入",
analysis_date=date(2024, 1, 1),
start_price=100,
forward_bars=bars,
stop_loss=93,
take_profit=110,
config=cfg,
)
self.assertEqual(res["eval_status"], "completed")
self.assertEqual(res["outcome"], "loss")
self.assertFalse(res["direction_correct"])
def test_hold_loss_when_down(self):
cfg = EvaluationConfig(eval_window_days=3, neutral_band_pct=2.0)
bars = self._bars(date(2024, 1, 1), [98, 96, 95], highs=[99, 97, 96], lows=[97, 95, 94])
res = BacktestEngine.evaluate_single(
operation_advice="持有",
analysis_date=date(2024, 1, 1),
start_price=100,
forward_bars=bars,
stop_loss=None,
take_profit=None,
config=cfg,
)
self.assertEqual(res["direction_expected"], "not_down")
self.assertEqual(res["outcome"], "loss")
self.assertFalse(res["direction_correct"])
def test_sell_loss_when_up(self):
cfg = EvaluationConfig(eval_window_days=3, neutral_band_pct=2.0)
bars = self._bars(date(2024, 1, 1), [102, 104, 106], highs=[103, 105, 107], lows=[101, 103, 105])
res = BacktestEngine.evaluate_single(
operation_advice="卖出",
analysis_date=date(2024, 1, 1),
start_price=100,
forward_bars=bars,
stop_loss=None,
take_profit=None,
config=cfg,
)
self.assertEqual(res["position_recommendation"], "cash")
self.assertEqual(res["direction_expected"], "down")
self.assertEqual(res["outcome"], "loss")
self.assertFalse(res["direction_correct"])
def test_neutral_outcome(self):
cfg = EvaluationConfig(eval_window_days=3, neutral_band_pct=2.0)
bars = self._bars(date(2024, 1, 1), [100.5, 100.2, 100.8], highs=[101, 101, 101], lows=[100, 100, 100])
res = BacktestEngine.evaluate_single(
operation_advice="买入",
analysis_date=date(2024, 1, 1),
start_price=100,
forward_bars=bars,
stop_loss=None,
take_profit=None,
config=cfg,
)
self.assertEqual(res["direction_expected"], "up")
self.assertEqual(res["outcome"], "neutral")
self.assertIsNone(res["direction_correct"])
def test_direction_correct_false_buy_down(self):
cfg = EvaluationConfig(eval_window_days=3, neutral_band_pct=2.0)
bars = self._bars(date(2024, 1, 1), [97, 95, 94], highs=[98, 96, 95], lows=[96, 94, 93])
res = BacktestEngine.evaluate_single(
operation_advice="buy",
analysis_date=date(2024, 1, 1),
start_price=100,
forward_bars=bars,
stop_loss=None,
take_profit=None,
config=cfg,
)
self.assertEqual(res["direction_expected"], "up")
self.assertEqual(res["outcome"], "loss")
self.assertFalse(res["direction_correct"])
def test_insufficient_data(self):
cfg = EvaluationConfig(eval_window_days=5, neutral_band_pct=2.0)
bars = self._bars(date(2024, 1, 1), [100, 101])
res = BacktestEngine.evaluate_single(
operation_advice="买入",
analysis_date=date(2024, 1, 1),
start_price=100,
forward_bars=bars,
stop_loss=None,
take_profit=None,
config=cfg,
)
self.assertEqual(res["eval_status"], "insufficient_data")
def test_unrecognized_advice_defaults_to_cash(self):
cfg = EvaluationConfig(eval_window_days=3, neutral_band_pct=2.0)
bars = self._bars(date(2024, 1, 1), [102, 104, 105], highs=[103, 105, 106], lows=[101, 103, 104])
res = BacktestEngine.evaluate_single(
operation_advice="some gibberish text",
analysis_date=date(2024, 1, 1),
start_price=100,
forward_bars=bars,
stop_loss=None,
take_profit=None,
config=cfg,
)
self.assertEqual(res["position_recommendation"], "cash")
self.assertEqual(res["direction_expected"], "flat")
def test_none_empty_advice_defaults_to_cash(self):
for advice in [None, "", " "]:
pos = BacktestEngine.infer_position_recommendation(advice)
direction = BacktestEngine.infer_direction_expected(advice)
self.assertEqual(pos, "cash", f"Expected cash for advice={advice!r}")
self.assertEqual(direction, "flat", f"Expected flat for advice={advice!r}")
def test_negated_sell_not_classified_bearish(self):
# "do not sell" negates "sell" — should NOT be direction=down
self.assertNotEqual(BacktestEngine.infer_direction_expected("do not sell"), "down")
def test_chinese_negated_sell_not_bearish(self):
# "不要卖出" = "don't sell" — should NOT be direction=down
self.assertNotEqual(BacktestEngine.infer_direction_expected("不要卖出"), "down")
def test_wait_then_buy_classified_as_cash(self):
# "wait" matches first in priority order → cash
pos = BacktestEngine.infer_position_recommendation("wait for a dip then buy")
self.assertEqual(pos, "cash")
if __name__ == "__main__":
unittest.main()