Files
daily_stock_analysis/tests/test_backtest_service.py
ObVious55 03bae035a6 feat: add skill opinion outcome performance statistics (#2119)
* feat: add skill opinion outcome evaluation core

* feat: add skill opinion outcome evaluation core

* fix: unify expected-start resolution

* changelog

* fix: validate persisted daily start sessions

* fix: preserve legacy local backtest windows

* feat: add skill opinion outcome statistics

* docs: clarify outcome statistics stage boundary

* fix: clarify backtest-only legacy start fallback

* fix: prevent pending outcome retry starvation

* fix: preserve explicit backtest start contract

* fix: rotate failed outcome candidates
2026-07-30 22:12:57 +08:00

3241 lines
122 KiB
Python

# -*- coding: utf-8 -*-
"""Integration tests for backtest service and repository.
These tests run against a temporary SQLite DB (same approach as other tests)
and validate idempotency/force semantics, result field correctness,
summary creation, and query methods.
"""
import json
import os
import tempfile
import unittest
from datetime import date, datetime
from unittest.mock import patch
import pandas as pd
from src.config import Config
from src.core.backtest_engine import OVERALL_SENTINEL_CODE
from src.repositories.backtest_repo import BacktestRepository
from src.services.backtest_service import BacktestService
from src.services.stock_daily_start_resolver import (
DailyStockStartResolution,
resolve_stock_daily_start,
)
from src.storage import AnalysisHistory, BacktestResult, BacktestSummary, DatabaseManager, StockDaily
def _phase_snapshot(
analysis_date: date,
*,
phase: str,
market: str,
effective_date: date | str | None = None,
trigger_source: str | None = None,
) -> str:
summary = {"phase": phase, "market": market}
if effective_date is not None:
summary["effective_daily_bar_date"] = (
effective_date.isoformat()
if isinstance(effective_date, date)
else str(effective_date)
)
if trigger_source is not None:
summary["trigger_source"] = trigger_source
return json.dumps(
{
"enhanced_context": {"date": analysis_date.isoformat()},
"market_phase_summary": summary,
}
)
class BacktestServiceTestCase(unittest.TestCase):
def setUp(self) -> None:
self._temp_dir = tempfile.TemporaryDirectory()
self._db_path = os.path.join(self._temp_dir.name, "test_backtest_service.db")
self._original_env = {
key: os.environ.get(key)
for key in (
"ENV_FILE",
"DATABASE_PATH",
"BACKTEST_EVAL_WINDOW_DAYS",
)
}
self._env_path = os.path.join(self._temp_dir.name, ".env")
with open(self._env_path, "w", encoding="utf-8") as env_file:
env_file.write("STOCK_LIST=600519,000001\n")
os.environ["ENV_FILE"] = self._env_path
os.environ["DATABASE_PATH"] = self._db_path
os.environ["BACKTEST_EVAL_WINDOW_DAYS"] = "3"
Config._instance = None
DatabaseManager.reset_instance()
self.db = DatabaseManager.get_instance()
# Ensure analysis is old enough for default min_age_days=14
old_created_at = datetime(2024, 1, 1, 0, 0, 0)
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q1",
code="600519",
name="贵州茅台",
report_type="simple",
sentiment_score=80,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="test",
stop_loss=95.0,
take_profit=110.0,
created_at=old_created_at,
context_snapshot=_phase_snapshot(
date(2024, 1, 1),
phase="premarket",
market="cn",
effective_date=date(2024, 1, 1),
trigger_source="api",
),
)
)
# Analysis day close
session.add(
StockDaily(
code="600519",
date=date(2024, 1, 1),
open=100.0,
high=101.0,
low=99.0,
close=100.0,
)
)
# Forward bars (3 days) that hit take-profit on day1
session.add_all(
[
StockDaily(code="600519", date=date(2024, 1, 2), high=111.0, low=100.0, close=105.0),
StockDaily(code="600519", date=date(2024, 1, 3), high=108.0, low=103.0, close=106.0),
StockDaily(code="600519", date=date(2024, 1, 4), high=109.0, low=104.0, close=107.0),
]
)
session.commit()
def _seed_analysis(
self,
*,
query_id: str,
code: str = "600519",
analysis_date: date,
created_at: datetime,
operation_advice: str,
trend_prediction: str,
start_close: float,
forward_bars: list[StockDaily],
phase: str = "intraday",
market: str = "cn",
include_effective_date: bool = True,
) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id=query_id,
code=code,
name="贵州茅台",
report_type="simple",
sentiment_score=60,
operation_advice=operation_advice,
trend_prediction=trend_prediction,
analysis_summary="extra-test",
stop_loss=None,
take_profit=None,
created_at=created_at,
context_snapshot=_phase_snapshot(
analysis_date,
phase=phase,
market=market,
effective_date=analysis_date if include_effective_date else None,
trigger_source="api",
),
)
)
session.add(
StockDaily(
code=code,
date=analysis_date,
open=start_close,
high=start_close,
low=start_close,
close=start_close,
)
)
session.add_all([
StockDaily(
code=code,
date=bar.date,
open=bar.open,
high=bar.high,
low=bar.low,
close=bar.close,
) for bar in forward_bars
])
session.commit()
def _seed_legacy_offshore_analysis(
self,
*,
query_id: str,
code: str,
market: str,
analysis_date: date,
start_close: float,
forward_date: date,
end_close: float,
include_effective_date: bool = True,
) -> None:
self._seed_analysis(
query_id=query_id,
code=code,
analysis_date=analysis_date,
created_at=datetime.combine(analysis_date, datetime.min.time()),
operation_advice="买入",
trend_prediction="看多",
start_close=start_close,
forward_bars=[
StockDaily(
code=code,
date=forward_date,
open=end_close,
high=end_close + 1,
low=end_close - 1,
close=end_close,
)
],
phase="postmarket",
market=market,
include_effective_date=include_effective_date,
)
def tearDown(self) -> None:
Config._instance = None
DatabaseManager.reset_instance()
for key, value in self._original_env.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
self._temp_dir.cleanup()
def _count_results(self) -> int:
with self.db.get_session() as session:
return session.query(BacktestResult).count()
def _make_backtest_result(
self,
*,
analysis_history_id: int,
analysis_date: date,
eval_window_days: int = 1,
engine_version: str = "v1",
) -> BacktestResult:
return BacktestResult(
analysis_history_id=analysis_history_id,
code="600519",
analysis_date=analysis_date,
eval_window_days=eval_window_days,
engine_version=engine_version,
eval_status="completed",
evaluated_at=datetime(2024, 1, 20, 0, 0, 0),
operation_advice="买入",
position_recommendation="long",
start_price=100.0,
end_close=101.0,
stock_return_pct=1.0,
direction_expected="up",
direction_correct=True,
outcome="win",
simulated_return_pct=1.0,
)
def test_kr_suffix_filter_reaches_legacy_bare_history(self) -> None:
self._seed_legacy_offshore_analysis(
query_id="q_kr_legacy_bare_filter",
code="005930",
market="kr",
analysis_date=date(2024, 10, 2),
start_close=100.0,
forward_date=date(2024, 10, 4),
end_close=105.0,
)
candidates = BacktestRepository(self.db).get_candidates(
code="005930.KS",
min_age_days=0,
limit=10,
eval_window_days=1,
engine_version="v1",
force=True,
)
self.assertEqual(
[candidate.query_id for candidate in candidates],
["q_kr_legacy_bare_filter"],
)
def test_jp_suffix_filter_reaches_legacy_bare_history(self) -> None:
self._seed_legacy_offshore_analysis(
query_id="q_jp_legacy_bare_filter",
code="7203",
market="jp",
analysis_date=date(2024, 10, 1),
start_close=200.0,
forward_date=date(2024, 10, 2),
end_close=210.0,
)
candidates = BacktestRepository(self.db).get_candidates(
code="7203.T",
min_age_days=0,
limit=10,
eval_window_days=1,
engine_version="v1",
force=True,
)
self.assertEqual(
[candidate.query_id for candidate in candidates],
["q_jp_legacy_bare_filter"],
)
def test_unfiltered_rerun_uses_persisted_market_for_legacy_jp_kr_bare_codes(
self,
) -> None:
self._seed_legacy_offshore_analysis(
query_id="q_jp_legacy_bare_rerun",
code="7203",
market="jp",
analysis_date=date(2024, 10, 1),
start_close=200.0,
forward_date=date(2024, 10, 2),
end_close=210.0,
include_effective_date=False,
)
self._seed_legacy_offshore_analysis(
query_id="q_kr_legacy_bare_rerun",
code="005930",
market="kr",
analysis_date=date(2024, 10, 2),
start_close=100.0,
forward_date=date(2024, 10, 4),
end_close=105.0,
include_effective_date=False,
)
service = BacktestService(self.db)
with patch.object(service, "_try_fill_daily_data") as refill:
stats = service.run_backtest(
code=None,
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 10, 1),
analysis_date_to=date(2024, 10, 2),
limit=10,
)
refill.assert_not_called()
self.assertEqual(stats["processed"], 2)
self.assertEqual(stats["completed"], 2)
self.assertEqual(stats["insufficient"], 0)
with self.db.get_session() as session:
results = {
result.code: result
for result in session.query(BacktestResult)
.filter(BacktestResult.code.in_(["7203", "005930"]))
.all()
}
self.assertEqual(results["7203"].analysis_date, date(2024, 10, 1))
self.assertEqual(results["7203"].start_price, 200.0)
self.assertEqual(results["005930"].analysis_date, date(2024, 10, 2))
self.assertEqual(results["005930"].start_price, 100.0)
def test_unfiltered_rerun_rebuilds_legacy_cn_snapshot_for_indexed_bare_jp_code(
self,
) -> None:
self._seed_legacy_offshore_analysis(
query_id="q_jp_legacy_cn_bare_rerun",
code="7203",
market="cn",
analysis_date=date(2024, 10, 1),
start_close=200.0,
forward_date=date(2024, 10, 2),
end_close=210.0,
include_effective_date=False,
)
with self.db.get_session() as session:
analysis = (
session.query(AnalysisHistory)
.filter(AnalysisHistory.query_id == "q_jp_legacy_cn_bare_rerun")
.one()
)
snapshot = json.loads(analysis.context_snapshot)
snapshot["market_phase_summary"].update(
{
"market_local_time": "2024-10-01T16:00:00+08:00",
"session_date": "2024-10-01",
"effective_daily_bar_date": "2024-10-01",
}
)
analysis.context_snapshot = json.dumps(snapshot)
session.commit()
service = BacktestService(self.db)
with patch.object(service, "_try_fill_daily_data") as refill:
stats = service.run_backtest(
code=None,
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 10, 1),
analysis_date_to=date(2024, 10, 1),
limit=10,
)
refill.assert_not_called()
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["completed"], 1)
self.assertEqual(stats["insufficient"], 0)
with self.db.get_session() as session:
result = (
session.query(BacktestResult)
.filter(BacktestResult.code == "7203")
.one()
)
self.assertEqual(result.analysis_date, date(2024, 10, 1))
self.assertEqual(result.start_price, 200.0)
def test_canonical_hk_query_does_not_backtest_legacy_bare_jp_collision(
self,
) -> None:
self._seed_legacy_offshore_analysis(
query_id="q_jp_legacy_bare_hk_collision",
code="8035",
market="jp",
analysis_date=date(2024, 10, 1),
start_close=200.0,
forward_date=date(2024, 10, 2),
end_close=210.0,
)
repo_candidates = BacktestRepository(self.db).get_candidates(
code="08035.HK",
min_age_days=0,
limit=10,
eval_window_days=1,
engine_version="v1",
force=True,
)
stats = BacktestService(self.db).run_backtest(
code="08035.HK",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 10, 1),
analysis_date_to=date(2024, 10, 1),
limit=10,
)
self.assertEqual(repo_candidates, [])
self.assertEqual(stats["processed"], 0)
self.assertEqual(stats["saved"], 0)
with self.db.get_session() as session:
self.assertEqual(
session.query(BacktestResult)
.filter(BacktestResult.code == "8035")
.count(),
0,
)
def test_force_semantics(self) -> None:
service = BacktestService(self.db)
stats1 = service.run_backtest(code="600519", force=False, eval_window_days=3, min_age_days=0, limit=10)
self.assertEqual(stats1["saved"], 1)
self.assertEqual(self._count_results(), 1)
# Non-force should be idempotent
stats2 = service.run_backtest(code="600519", force=False, eval_window_days=3, min_age_days=0, limit=10)
self.assertEqual(stats2["saved"], 0)
self.assertEqual(self._count_results(), 1)
self.assertEqual(stats2["diagnostics"]["empty_reason"], "no_new_results")
self.assertIn("历史分析记录已存在", stats2["message"] or "")
# Force should replace existing result without unique constraint errors
stats3 = service.run_backtest(code="600519", force=True, eval_window_days=3, min_age_days=0, limit=10)
self.assertEqual(stats3["saved"], 1)
self.assertEqual(self._count_results(), 1)
def test_run_backtest_rejects_invalid_eval_window_days_at_entry(self) -> None:
service = BacktestService(self.db)
for value in (0, -1, 1.5, True, "1"):
with self.subTest(value=value):
with self.assertRaisesRegex(ValueError, "positive integer"):
service.run_backtest(
code="600519",
eval_window_days=value,
min_age_days=0,
)
def test_run_backtest_accepts_dotted_exchange_prefix_and_filters_analysis_date_range(self) -> None:
service = BacktestService(self.db)
stats = service.run_backtest(
code="SH.600519",
force=False,
eval_window_days=3,
min_age_days=0,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["completed"], 1)
self.assertIsNone(stats["message"])
self.assertEqual(stats["diagnostics"]["code"], "600519")
self.assertEqual(stats["diagnostics"]["analysis_date_from"], "2024-01-01")
self.assertEqual(stats["diagnostics"]["analysis_date_to"], "2024-01-01")
data = service.get_recent_evaluations(code="SH.600519", eval_window_days=3, limit=10, page=1)
self.assertEqual(data["total"], 1)
self.assertEqual(data["items"][0]["code"], "600519")
summary = service.get_summary(scope="stock", code="SH.600519", eval_window_days=3)
self.assertIsNotNone(summary)
self.assertEqual(summary["code"], "600519")
self.assertEqual(summary["completed_count"], 1)
def test_run_backtest_keeps_dotted_cn_code_match_when_analysis_history_is_dotted(self) -> None:
self._seed_analysis(
query_id="q_dot_cn",
code="600519.SH",
analysis_date=date(2024, 1, 2),
created_at=datetime(2024, 1, 2, 0, 0, 0),
operation_advice="买入",
trend_prediction="看多",
start_close=100.0,
forward_bars=[
StockDaily(code="600519.SH", date=date(2024, 1, 3), high=111.0, low=100.0, close=105.0),
StockDaily(code="600519.SH", date=date(2024, 1, 4), high=108.0, low=102.0, close=106.0),
StockDaily(code="600519.SH", date=date(2024, 1, 5), high=109.0, low=102.0, close=107.0),
],
)
service = BacktestService(self.db)
stats = service.run_backtest(
code="600519.SH",
force=False,
eval_window_days=3,
min_age_days=0,
analysis_date_from=date(2024, 1, 2),
analysis_date_to=date(2024, 1, 2),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["completed"], 1)
data = service.get_recent_evaluations(code="600519.SH", eval_window_days=3, limit=10, page=1)
self.assertEqual(data["items"][0]["code"], "600519.SH")
summary = service.get_summary(scope="stock", code="600519.SH", eval_window_days=3)
self.assertIsNotNone(summary)
self.assertEqual(summary["code"], "600519")
def test_run_backtest_matches_compact_prefixed_analysis_history_with_canonical_query(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q_compact_history_sh",
code="SH600519",
name="贵州茅台",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="compact history code with canonical query",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 2, 15, 0, 0, 0),
context_snapshot=_phase_snapshot(
date(2024, 2, 15),
phase="postmarket",
market="cn",
effective_date=date(2024, 2, 15),
),
)
)
session.add(
StockDaily(
code="SH600519",
date=date(2024, 2, 15),
open=100.0,
high=100.0,
low=100.0,
close=100.0,
)
)
session.add_all(
[
StockDaily(code="SH600519", date=date(2024, 2, 16), high=102.0, low=99.0, close=101.0),
StockDaily(code="SH600519", date=date(2024, 2, 17), high=104.0, low=100.0, close=103.0),
]
)
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(
code="600519",
force=False,
eval_window_days=2,
min_age_days=0,
analysis_date_from=date(2024, 2, 15),
analysis_date_to=date(2024, 2, 15),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["completed"], 1)
data = service.get_recent_evaluations(code="SH600519", eval_window_days=2, limit=10, page=1)
self.assertEqual(data["total"], 1)
def test_run_backtest_matches_compact_ss_alias_after_request_normalization(self) -> None:
self._seed_analysis(
query_id="q_compact_history_ss",
code="SS600519",
analysis_date=date(2024, 2, 20),
created_at=datetime(2024, 2, 20, 0, 0, 0),
operation_advice="买入",
trend_prediction="看多",
start_close=100.0,
forward_bars=[
StockDaily(code="SS600519", date=date(2024, 2, 21), high=102.0, low=99.0, close=101.0),
StockDaily(code="SS600519", date=date(2024, 2, 22), high=104.0, low=100.0, close=103.0),
],
)
service = BacktestService(self.db)
stats = service.run_backtest(
code="SS600519",
force=False,
eval_window_days=2,
min_age_days=0,
analysis_date_from=date(2024, 2, 20),
analysis_date_to=date(2024, 2, 20),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["completed"], 1)
data = service.get_recent_evaluations(code="SS600519", eval_window_days=2, limit=10, page=1)
self.assertEqual(data["total"], 1)
self.assertEqual(data["items"][0]["code"], "SS600519")
def test_run_backtest_uses_bare_daily_bars_and_summary_for_compact_ss_history(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q_compact_ss_history_bare_daily",
code="SS600519",
name="贵州茅台",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="compact ss history with bare daily data",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 2, 25, 0, 0, 0),
context_snapshot=_phase_snapshot(
date(2024, 2, 25),
phase="postmarket",
market="cn",
effective_date=date(2024, 2, 25),
),
)
)
session.add(
StockDaily(
code="600519",
date=date(2024, 2, 25),
open=100.0,
high=100.0,
low=100.0,
close=100.0,
)
)
session.add(
StockDaily(code="600519", date=date(2024, 2, 26), high=104.0, low=99.0, close=103.0)
)
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(
code="600519",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 2, 25),
limit=10,
)
self.assertEqual(stats["processed"], 2)
self.assertEqual(stats["saved"], 2)
self.assertEqual(stats["completed"], 2)
self.assertEqual(stats["insufficient"], 0)
summary = service.get_summary(scope="stock", code="600519", eval_window_days=1)
self.assertIsNotNone(summary)
self.assertEqual(summary["code"], "600519")
self.assertEqual(summary["total_evaluations"], 2)
matched = service.get_recent_evaluations(
code="600519",
eval_window_days=1,
limit=10,
page=1,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 2, 25),
)
self.assertEqual({row["code"] for row in matched["items"]}, {"600519", "SS600519"})
def test_run_backtest_uses_compact_forward_bars_when_analysis_history_is_bare_code(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q_compact_forward_sh",
code="600519",
name="贵州茅台",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="bare history with compact forward bars",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 3, 1, 0, 0, 0),
context_snapshot=_phase_snapshot(
date(2024, 3, 1),
phase="postmarket",
market="cn",
effective_date=date(2024, 3, 1),
),
)
)
session.add(
StockDaily(
code="SH600519",
date=date(2024, 3, 1),
open=110.0,
high=110.0,
low=110.0,
close=110.0,
)
)
session.add_all(
[
StockDaily(code="SH600519", date=date(2024, 3, 2), high=112.0, low=109.0, close=111.0),
StockDaily(code="SH600519", date=date(2024, 3, 3), high=113.0, low=110.0, close=112.0),
]
)
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(
code="600519",
force=False,
eval_window_days=2,
min_age_days=0,
analysis_date_from=date(2024, 3, 1),
analysis_date_to=date(2024, 3, 1),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["completed"], 1)
self.assertEqual(stats["insufficient"], 0)
def test_run_backtest_matches_compact_bj_code_shape_with_no_prefix_query(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q_compact_history_bj",
code="BJ920748",
name="可转债",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="BJ compact history code without query prefix",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 4, 1, 0, 0, 0),
context_snapshot=_phase_snapshot(
date(2024, 4, 1),
phase="postmarket",
market="cn",
effective_date=date(2024, 4, 1),
),
)
)
session.add(
StockDaily(
code="BJ920748",
date=date(2024, 4, 1),
open=200.0,
high=200.0,
low=200.0,
close=200.0,
)
)
session.add_all(
[
StockDaily(code="BJ920748", date=date(2024, 4, 2), high=210.0, low=198.0, close=205.0),
StockDaily(code="BJ920748", date=date(2024, 4, 3), high=215.0, low=202.0, close=210.0),
]
)
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(
code="920748",
force=False,
eval_window_days=2,
min_age_days=0,
analysis_date_from=date(2024, 4, 1),
analysis_date_to=date(2024, 4, 1),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["completed"], 1)
data = service.get_recent_evaluations(code="BJ920748", eval_window_days=2, limit=10, page=1)
self.assertEqual(data["total"], 1)
def test_get_candidates_does_not_match_invalid_a_share_hk_cross_input(self) -> None:
repo = BacktestRepository(self.db)
matches = repo.get_candidates(
code="600519.HK",
min_age_days=0,
limit=10,
eval_window_days=3,
engine_version="v1",
force=True,
)
self.assertEqual(len(matches), 0)
def test_get_candidates_does_not_match_explicit_wrong_a_share_market(self) -> None:
repo = BacktestRepository(self.db)
for invalid_code in ("600519.SZ", "SH000001", "000001.SH", "920748.SH", "SH920748"):
with self.subTest(invalid_code=invalid_code):
matches = repo.get_candidates(
code=invalid_code,
min_age_days=0,
limit=10,
eval_window_days=3,
engine_version="v1",
force=True,
)
self.assertEqual(matches, [])
def test_run_backtest_rejects_invalid_market_suffix_length_input(self) -> None:
service = BacktestService(self.db)
with self.assertRaisesRegex(ValueError, "非法股票代码格式"):
service.run_backtest(
code="600519.HK",
force=False,
eval_window_days=3,
min_age_days=0,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
limit=10,
)
def test_run_backtest_rejects_explicit_wrong_a_share_market(self) -> None:
service = BacktestService(self.db)
for invalid_code in ("600519.SZ", "SH000001", "000001.SH", "920748.SH", "SH920748"):
with self.subTest(invalid_code=invalid_code):
with self.assertRaisesRegex(ValueError, "非法股票代码格式"):
service.run_backtest(
code=invalid_code,
force=False,
eval_window_days=3,
min_age_days=0,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
limit=10,
)
def test_get_recent_evaluations_rejects_explicit_wrong_a_share_market(self) -> None:
service = BacktestService(self.db)
for invalid_code in ("600519.SZ", "SH000001", "000001.SH", "920748.SH", "SH920748"):
with self.subTest(invalid_code=invalid_code):
with self.assertRaisesRegex(ValueError, "非法股票代码格式"):
service.get_recent_evaluations(
code=invalid_code,
eval_window_days=3,
limit=10,
page=1,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
)
def test_get_summary_rejects_explicit_wrong_a_share_market(self) -> None:
service = BacktestService(self.db)
for invalid_code in ("600519.SZ", "SH000001", "000001.SH", "920748.SH", "SH920748"):
with self.subTest(invalid_code=invalid_code):
with self.assertRaisesRegex(ValueError, "非法股票代码格式"):
service.get_summary(
scope="stock",
code=invalid_code,
eval_window_days=3,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
)
def test_run_backtest_bare_code_query_matches_dotted_history_records(self) -> None:
self._seed_analysis(
query_id="q_match_dot",
code="600519.SH",
analysis_date=date(2024, 2, 1),
created_at=datetime(2024, 2, 1, 0, 0, 0),
operation_advice="买入",
trend_prediction="看多",
start_close=100.0,
forward_bars=[
StockDaily(code="600519.SH", date=date(2024, 2, 2), high=101.0, low=95.0, close=96.0),
],
)
service = BacktestService(self.db)
stats = service.run_backtest(
code="600519",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 2, 1),
limit=20,
)
self.assertEqual(stats["processed"], 2)
self.assertEqual(stats["saved"], 2)
self.assertEqual(stats["completed"], 2)
summary = service.get_summary(scope="stock", code="600519", eval_window_days=1)
self.assertIsNotNone(summary)
self.assertEqual(summary["code"], "600519")
self.assertEqual(summary["total_evaluations"], 2)
self.assertEqual(summary["completed_count"], 2)
matched = service.get_recent_evaluations(
code="600519",
eval_window_days=1,
limit=20,
page=1,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 2, 1),
)
self.assertEqual({row["code"] for row in matched["items"]}, {"600519", "600519.SH"})
def test_run_backtest_uses_bare_daily_bars_for_dotted_history_record(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q_dot_history_bare_daily",
code="600519.SH",
name="贵州茅台",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="dotted history with bare daily data",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 2, 5, 0, 0, 0),
context_snapshot=_phase_snapshot(
date(2024, 2, 5),
phase="postmarket",
market="cn",
effective_date=date(2024, 2, 5),
),
)
)
session.add(
StockDaily(
code="600519",
date=date(2024, 2, 5),
open=100.0,
high=100.0,
low=100.0,
close=100.0,
)
)
session.add(
StockDaily(code="600519", date=date(2024, 2, 6), high=106.0, low=99.0, close=105.0)
)
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(
code="600519",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 2, 5),
analysis_date_to=date(2024, 2, 5),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["completed"], 1)
self.assertEqual(stats["insufficient"], 0)
with self.db.get_session() as session:
result = session.query(BacktestResult).filter(BacktestResult.code == "600519.SH").one()
self.assertEqual(result.analysis_date, date(2024, 2, 5))
self.assertEqual(result.start_price, 100.0)
self.assertEqual(result.end_close, 105.0)
def test_run_backtest_does_not_mix_start_and_forward_bars_across_code_shapes(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q_shape_split",
code="600518",
name="贵州茅台",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="split code shape with start on dotted daily",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 1, 7, 0, 0, 0),
context_snapshot=_phase_snapshot(
date(2024, 1, 7),
phase="non_trading",
market="cn",
effective_date=date(2024, 1, 5),
),
)
)
session.add(
StockDaily(
code="600518.SH",
date=date(2024, 1, 5),
open=100.0,
high=100.0,
low=100.0,
close=100.0,
)
)
session.add_all(
[
StockDaily(code="600518", date=date(2024, 1, 8), high=106.0, low=99.0, close=105.0),
StockDaily(code="600518", date=date(2024, 1, 9), high=110.0, low=100.0, close=108.0),
]
)
session.commit()
service = BacktestService(self.db)
with patch.object(service, "_try_fill_daily_data"):
stats = service.run_backtest(
code="600518",
force=False,
eval_window_days=2,
min_age_days=0,
analysis_date_from=date(2024, 1, 7),
analysis_date_to=date(2024, 1, 7),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["completed"], 0)
self.assertEqual(stats["insufficient"], 1)
with self.db.get_session() as session:
result = session.query(BacktestResult).filter(BacktestResult.code == "600518").one()
self.assertEqual(result.analysis_date, date(2024, 1, 7))
self.assertIsNone(result.start_price)
self.assertIsNone(result.end_close)
def test_run_backtest_uses_exact_expected_start_across_code_aliases(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q_newest_complete_window",
code="600519.SH",
name="贵州茅台",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="prefer complete bare-code daily window",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 1, 7, 0, 0, 0),
context_snapshot=_phase_snapshot(
date(2024, 1, 7),
phase="non_trading",
market="cn",
effective_date=date(2024, 1, 5),
),
)
)
session.add_all(
[
StockDaily(
code="600519.SH",
date=date(2024, 1, 2),
open=50.0,
high=50.0,
low=50.0,
close=50.0,
),
StockDaily(
code="600519",
date=date(2024, 1, 5),
open=100.0,
high=100.0,
low=100.0,
close=100.0,
),
StockDaily(
code="600519",
date=date(2024, 1, 8),
open=105.0,
high=106.0,
low=99.0,
close=105.0,
),
]
)
session.commit()
service = BacktestService(self.db)
with patch(
"src.services.stock_daily_start_resolver.resolve_historical_daily_bar_date",
return_value=date(2024, 1, 5),
) as resolve_historical_date:
stats = service.run_backtest(
code="600519.SH",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 1, 7),
analysis_date_to=date(2024, 1, 7),
limit=10,
)
resolve_historical_date.assert_called_once_with(
"cn",
date(2024, 1, 5),
"postmarket",
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["completed"], 1)
self.assertEqual(stats["insufficient"], 0)
with self.db.get_session() as session:
result = (
session.query(BacktestResult)
.filter(BacktestResult.code == "600519.SH")
.one()
)
self.assertEqual(result.eval_status, "completed")
self.assertEqual(result.start_price, 100.0)
self.assertEqual(result.end_close, 105.0)
def test_run_backtest_replays_legacy_non_session_effective_date_from_local_bars(
self,
) -> None:
self._seed_analysis(
query_id="q_legacy_non_session_effective_date",
code="600520",
analysis_date=date(2024, 1, 1),
created_at=datetime(2024, 1, 1, 0, 0, 0),
operation_advice="买入",
trend_prediction="看多",
start_close=100.0,
forward_bars=[
StockDaily(
code="600520",
date=date(2024, 1, 2),
high=102.0,
low=99.0,
close=101.0,
),
StockDaily(
code="600520",
date=date(2024, 1, 3),
high=103.0,
low=100.0,
close=102.0,
),
StockDaily(
code="600520",
date=date(2024, 1, 4),
high=104.0,
low=101.0,
close=103.0,
),
],
phase="postmarket",
)
service = BacktestService(self.db)
with patch.object(service, "_try_fill_daily_data") as refill:
stats = service.run_backtest(
code="600520",
force=False,
eval_window_days=3,
min_age_days=0,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
limit=10,
)
refill.assert_not_called()
self.assertEqual(stats["completed"], 1)
self.assertEqual(stats["insufficient"], 0)
with self.db.get_session() as session:
result = (
session.query(BacktestResult)
.filter(BacktestResult.code == "600520")
.one()
)
self.assertEqual(result.eval_status, "completed")
self.assertEqual(result.start_price, 100.0)
self.assertEqual(result.end_close, 103.0)
def test_non_session_resolution_exposes_backtest_only_local_start(
self,
) -> None:
resolution = resolve_stock_daily_start(
stock_code="600520",
context_snapshot=_phase_snapshot(
date(2024, 1, 6),
phase="postmarket",
market="cn",
effective_date=date(2024, 1, 6),
trigger_source="api",
),
analysis_date=date(2024, 1, 6),
)
self.assertIsNone(resolution.expected_start_date)
self.assertEqual(
resolution.failure_reason,
"invalid_effective_daily_bar_date",
)
self.assertEqual(
resolution.backtest_start_date,
date(2024, 1, 6),
)
def test_run_backtest_consumes_explicit_backtest_only_start(self) -> None:
analysis_date = date(2024, 1, 6)
self._seed_analysis(
query_id="q_explicit_backtest_only_start",
code="600521",
analysis_date=analysis_date,
created_at=datetime(2024, 1, 6, 0, 0, 0),
operation_advice="买入",
trend_prediction="看多",
start_close=100.0,
forward_bars=[
StockDaily(
code="600521",
date=date(2024, 1, 8),
high=103.0,
low=99.0,
close=102.0,
)
],
phase="postmarket",
)
identity = resolve_stock_daily_start(
stock_code="600521",
context_snapshot=_phase_snapshot(
analysis_date,
phase="postmarket",
market="cn",
effective_date=analysis_date,
trigger_source="api",
),
analysis_date=analysis_date,
).identity
resolution = DailyStockStartResolution(
identity=identity,
expected_start_date=None,
failure_reason="invalid_effective_daily_bar_date",
legacy_backtest_start_date=analysis_date,
)
with patch(
"src.services.backtest_service.resolve_stock_daily_start",
return_value=resolution,
):
stats = BacktestService(self.db).run_backtest(
code="600521",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=analysis_date,
analysis_date_to=analysis_date,
limit=10,
)
self.assertEqual(stats["completed"], 1)
self.assertEqual(stats["insufficient"], 0)
def test_run_backtest_rebuilds_legacy_cn_snapshot_for_jp_history(self) -> None:
legacy_snapshot = json.dumps(
{
"enhanced_context": {"date": "2026-01-01"},
"market_phase_summary": {
"market": "cn",
"phase": "postmarket",
"market_local_time": "2026-01-01T10:00:00+08:00",
"session_date": "2026-01-01",
"effective_daily_bar_date": "2025-12-31",
"is_trading_day": True,
"is_market_open_now": False,
"is_partial_bar": False,
"trigger_source": "scheduled_job",
"analysis_intent": "postmarket",
"warnings": ["legacy_snapshot"],
},
}
)
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q_jp_legacy_cn_snapshot",
code="7203.T",
name="Toyota",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="legacy CN phase snapshot",
stop_loss=None,
take_profit=None,
created_at=datetime(2026, 1, 1, 0, 0, 0),
context_snapshot=legacy_snapshot,
)
)
session.add_all(
[
StockDaily(
code="7203.T",
date=date(2025, 12, 30),
open=100.0,
high=100.0,
low=100.0,
close=100.0,
),
StockDaily(
code="7203.T",
date=date(2026, 1, 5),
open=105.0,
high=106.0,
low=104.0,
close=105.0,
),
]
)
session.commit()
service = BacktestService(self.db)
with patch.object(service, "_try_fill_daily_data") as refill:
stats = service.run_backtest(
code="7203.T",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2026, 1, 1),
analysis_date_to=date(2026, 1, 1),
limit=10,
)
refill.assert_not_called()
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["completed"], 1)
self.assertEqual(stats["insufficient"], 0)
with self.db.get_session() as session:
result = (
session.query(BacktestResult)
.filter(BacktestResult.code == "7203.T")
.one()
)
self.assertEqual(result.eval_status, "completed")
self.assertEqual(result.analysis_date, date(2026, 1, 1))
self.assertEqual(result.start_price, 100.0)
self.assertEqual(result.end_close, 105.0)
def test_run_backtest_refills_from_authoritative_start_date(self) -> None:
requested = []
class ControlledDataFetcherManager:
def get_daily_data(
self,
stock_code,
start_date=None,
end_date=None,
days=30,
):
requested.append(
{
"stock_code": stock_code,
"start_date": start_date,
"end_date": end_date,
"days": days,
}
)
return (
pd.DataFrame(
[
{
"date": date(2024, 1, 5),
"open": 100.0,
"high": 100.0,
"low": 100.0,
"close": 100.0,
"volume": 1000,
},
{
"date": date(2024, 1, 8),
"open": 105.0,
"high": 106.0,
"low": 99.0,
"close": 105.0,
"volume": 1000,
},
]
),
"ControlledFetcher",
)
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q_refill_expected_start",
code="600514.SH",
name="Test Stock",
report_type="simple",
sentiment_score=60,
operation_advice="buy",
trend_prediction="bullish",
analysis_summary="refill from authoritative start",
created_at=datetime(2024, 1, 8, 8, 0, 0),
context_snapshot=_phase_snapshot(
date(2024, 1, 8),
phase="premarket",
market="cn",
effective_date=date(2024, 1, 5),
),
)
)
session.commit()
service = BacktestService(self.db)
with patch(
"data_provider.base.DataFetcherManager",
ControlledDataFetcherManager,
):
stats = service.run_backtest(
code="600514.SH",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 1, 8),
analysis_date_to=date(2024, 1, 8),
limit=10,
)
self.assertEqual(len(requested), 1)
self.assertEqual(requested[0]["stock_code"], "600514")
self.assertEqual(requested[0]["start_date"], "2024-01-05")
self.assertEqual(stats["completed"], 1)
with self.db.get_session() as session:
result = (
session.query(BacktestResult)
.filter(BacktestResult.code == "600514.SH")
.one()
)
self.assertEqual(result.eval_status, "completed")
self.assertEqual(result.start_price, 100.0)
self.assertEqual(result.end_close, 105.0)
def test_run_backtest_rejects_all_stale_daily_window_candidates(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q_all_stale_daily_windows",
code="600517.SH",
name="Test Stock",
report_type="simple",
sentiment_score=60,
operation_advice="buy",
trend_prediction="bullish",
analysis_summary="reject all stale daily windows",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 1, 7, 0, 0, 0),
context_snapshot=_phase_snapshot(
date(2024, 1, 7),
phase="non_trading",
market="cn",
effective_date=date(2024, 1, 5),
),
)
)
session.add_all(
[
StockDaily(
code="600517.SH",
date=date(2020, 1, 2),
open=50.0,
high=50.0,
low=50.0,
close=50.0,
),
StockDaily(
code="600517.SH",
date=date(2024, 1, 8),
open=55.0,
high=55.0,
low=55.0,
close=55.0,
),
StockDaily(
code="600517",
date=date(2021, 1, 4),
open=60.0,
high=60.0,
low=60.0,
close=60.0,
),
StockDaily(
code="600517",
date=date(2024, 1, 9),
open=65.0,
high=65.0,
low=65.0,
close=65.0,
),
]
)
session.commit()
service = BacktestService(self.db)
with patch.object(service, "_try_fill_daily_data"):
stats = service.run_backtest(
code="600517.SH",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 1, 7),
analysis_date_to=date(2024, 1, 7),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["completed"], 0)
self.assertEqual(stats["insufficient"], 1)
with self.db.get_session() as session:
result = (
session.query(BacktestResult)
.filter(BacktestResult.code == "600517.SH")
.one()
)
self.assertEqual(result.eval_status, "insufficient_data")
self.assertEqual(result.analysis_date, date(2024, 1, 7))
self.assertIsNone(result.start_price)
self.assertIsNone(result.end_close)
self.assertIsNone(result.stock_return_pct)
def test_run_backtest_supports_us_suffix_code_shape_when_run_with_suffix(self) -> None:
self._seed_analysis(
query_id="q_aapl",
code="AAPL.US",
analysis_date=date(2024, 1, 3),
created_at=datetime(2024, 1, 3, 0, 0, 0),
operation_advice="买入",
trend_prediction="看多",
start_close=100.0,
forward_bars=[
StockDaily(code="AAPL.US", date=date(2024, 1, 4), high=101.0, low=95.0, close=96.0),
],
market="us",
)
service = BacktestService(self.db)
stats = service.run_backtest(
code="AAPL.US",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 1, 3),
analysis_date_to=date(2024, 1, 3),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
data = service.get_recent_evaluations(code="AAPL.US", eval_window_days=1, limit=10, page=1)
self.assertEqual(data["items"][0]["code"], "AAPL.US")
self.assertEqual(data["items"][0]["analysis_date"], "2024-01-03")
def test_run_backtest_preserves_supported_us_index_identity(self) -> None:
self._seed_analysis(
query_id="q_nasdaq",
code="NASDAQ",
analysis_date=date(2024, 1, 5),
created_at=datetime(2024, 1, 5, 0, 0, 0),
operation_advice="买入",
trend_prediction="看多",
start_close=100.0,
forward_bars=[
StockDaily(
code="NASDAQ",
date=date(2024, 1, 8),
high=103.0,
low=99.0,
close=102.0,
),
],
phase="postmarket",
market="us",
)
service = BacktestService(self.db)
stats = service.run_backtest(
code="NASDAQ",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 1, 5),
analysis_date_to=date(2024, 1, 5),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["completed"], 1)
data = service.get_recent_evaluations(
code="NASDAQ",
eval_window_days=1,
limit=10,
page=1,
)
self.assertEqual(data["total"], 1)
self.assertEqual(data["items"][0]["code"], "NASDAQ")
def test_run_backtest_us_suffix_query_matches_bare_history_and_summary(self) -> None:
self._seed_analysis(
query_id="q_aapl_bare_history",
code="AAPL",
analysis_date=date(2024, 1, 6),
created_at=datetime(2024, 1, 6, 0, 0, 0),
operation_advice="买入",
trend_prediction="看多",
start_close=100.0,
forward_bars=[
StockDaily(code="AAPL", date=date(2024, 1, 7), high=104.0, low=99.0, close=103.0),
],
market="us",
)
service = BacktestService(self.db)
stats = service.run_backtest(
code="AAPL.US",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 1, 6),
analysis_date_to=date(2024, 1, 6),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["completed"], 1)
data = service.get_recent_evaluations(
code="AAPL.US",
eval_window_days=1,
limit=10,
page=1,
analysis_date_from=date(2024, 1, 6),
analysis_date_to=date(2024, 1, 6),
)
self.assertEqual(data["total"], 1)
self.assertEqual(data["items"][0]["code"], "AAPL")
summary = service.get_summary(scope="stock", code="AAPL.US", eval_window_days=1)
self.assertIsNotNone(summary)
assert summary is not None
self.assertEqual(summary["code"], "AAPL")
self.assertEqual(summary["completed_count"], 1)
def test_run_backtest_bare_us_query_matches_us_suffix_history_and_summary(self) -> None:
self._seed_analysis(
query_id="q_aapl_suffix_history",
code="AAPL.US",
analysis_date=date(2024, 1, 8),
created_at=datetime(2024, 1, 8, 0, 0, 0),
operation_advice="买入",
trend_prediction="看多",
start_close=100.0,
forward_bars=[
StockDaily(code="AAPL.US", date=date(2024, 1, 9), high=104.0, low=99.0, close=103.0),
],
market="us",
)
service = BacktestService(self.db)
stats = service.run_backtest(
code="AAPL",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 1, 8),
analysis_date_to=date(2024, 1, 8),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["completed"], 1)
data = service.get_recent_evaluations(
code="AAPL",
eval_window_days=1,
limit=10,
page=1,
analysis_date_from=date(2024, 1, 8),
analysis_date_to=date(2024, 1, 8),
)
self.assertEqual(data["total"], 1)
self.assertEqual(data["items"][0]["code"], "AAPL.US")
summary = service.get_summary(scope="stock", code="AAPL", eval_window_days=1)
self.assertIsNotNone(summary)
assert summary is not None
self.assertEqual(summary["code"], "AAPL.US")
self.assertEqual(summary["completed_count"], 1)
def test_us_code_queries_match_legacy_results_without_rerun(self) -> None:
with self.db.get_session() as session:
bare_history = AnalysisHistory(
query_id="q_legacy_aapl_bare",
code="AAPL",
name="Apple",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="legacy bare result",
created_at=datetime(2024, 1, 10, 0, 0, 0),
context_snapshot=json.dumps({"enhanced_context": {"date": "2024-01-10"}}),
)
suffix_history = AnalysisHistory(
query_id="q_legacy_aapl_suffix",
code="AAPL.US",
name="Apple",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="legacy suffix result",
created_at=datetime(2024, 1, 11, 0, 0, 0),
context_snapshot=json.dumps({"enhanced_context": {"date": "2024-01-11"}}),
)
session.add_all([bare_history, suffix_history])
session.flush()
session.add_all(
[
BacktestResult(
analysis_history_id=bare_history.id,
code="AAPL",
analysis_date=date(2024, 1, 10),
eval_window_days=1,
engine_version="v1",
eval_status="completed",
evaluated_at=datetime(2024, 1, 20, 0, 0, 0),
operation_advice="买入",
position_recommendation="long",
start_price=100.0,
end_close=103.0,
stock_return_pct=3.0,
direction_expected="up",
direction_correct=True,
outcome="win",
simulated_return_pct=3.0,
),
BacktestResult(
analysis_history_id=suffix_history.id,
code="AAPL.US",
analysis_date=date(2024, 1, 11),
eval_window_days=1,
engine_version="v1",
eval_status="completed",
evaluated_at=datetime(2024, 1, 21, 0, 0, 0),
operation_advice="买入",
position_recommendation="long",
start_price=103.0,
end_close=105.0,
stock_return_pct=1.94,
direction_expected="up",
direction_correct=True,
outcome="win",
simulated_return_pct=1.94,
),
]
)
session.commit()
service = BacktestService(self.db)
suffix_query = service.get_recent_evaluations(
code="AAPL.US",
eval_window_days=1,
limit=10,
page=1,
analysis_date_from=date(2024, 1, 10),
analysis_date_to=date(2024, 1, 10),
)
self.assertEqual(suffix_query["total"], 1)
self.assertEqual(suffix_query["items"][0]["code"], "AAPL")
suffix_summary = service.get_summary(
scope="stock",
code="AAPL.US",
eval_window_days=1,
analysis_date_from=date(2024, 1, 10),
analysis_date_to=date(2024, 1, 10),
)
self.assertIsNotNone(suffix_summary)
assert suffix_summary is not None
self.assertEqual(suffix_summary["code"], "AAPL")
self.assertEqual(suffix_summary["total_evaluations"], 1)
bare_query = service.get_recent_evaluations(
code="AAPL",
eval_window_days=1,
limit=10,
page=1,
analysis_date_from=date(2024, 1, 11),
analysis_date_to=date(2024, 1, 11),
)
self.assertEqual(bare_query["total"], 1)
self.assertEqual(bare_query["items"][0]["code"], "AAPL.US")
bare_summary = service.get_summary(
scope="stock",
code="AAPL",
eval_window_days=1,
analysis_date_from=date(2024, 1, 11),
analysis_date_to=date(2024, 1, 11),
)
self.assertIsNotNone(bare_summary)
assert bare_summary is not None
self.assertEqual(bare_summary["code"], "AAPL")
self.assertEqual(bare_summary["total_evaluations"], 1)
def test_run_backtest_reaches_legacy_bare_hk_rows_from_supported_aliases(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q_hk_history_dot",
code="1810",
name="恒生指数成份股",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="HK history is dotted, daily is canonical",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 1, 1, 0, 0, 0),
context_snapshot=_phase_snapshot(
date(2024, 1, 1),
phase="postmarket",
market="hk",
effective_date=date(2024, 1, 1),
),
)
)
session.add(
StockDaily(
code="1810",
date=date(2024, 1, 1),
open=100.0,
high=100.0,
low=100.0,
close=100.0,
)
)
session.add(
StockDaily(
code="1810",
date=date(2024, 1, 2),
high=102.0,
low=95.0,
close=101.0,
)
)
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(
code="01810",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["completed"], 1)
for alias in ("1810", "01810", "1810.HK", "HK.01810"):
data = service.get_recent_evaluations(
code=alias,
eval_window_days=1,
limit=10,
page=1,
)
self.assertEqual(data["total"], 1)
self.assertEqual(data["items"][0]["code"], "1810")
summary = service.get_summary(
scope="stock",
code=alias,
eval_window_days=1,
)
self.assertIsNotNone(summary)
assert summary is not None
self.assertEqual(summary["completed_count"], 1)
def test_run_backtest_matches_hk_daily_shape_variants_for_prefixed_history(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q_hk_history_prefixed",
code="HK01810",
name="恒生指数成份股",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="HK history is prefixed, daily is dotted",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 1, 1, 0, 0, 0),
context_snapshot=_phase_snapshot(
date(2024, 1, 1),
phase="postmarket",
market="hk",
effective_date=date(2024, 1, 1),
),
)
)
session.add(
StockDaily(
code="01810.HK",
date=date(2024, 1, 1),
open=120.0,
high=120.0,
low=120.0,
close=120.0,
)
)
session.add(
StockDaily(
code="01810.HK",
date=date(2024, 1, 2),
high=122.0,
low=118.0,
close=121.0,
)
)
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(
code="01810",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["completed"], 1)
data = service.get_recent_evaluations(code="01810.HK", eval_window_days=1, limit=10, page=1)
self.assertEqual(data["total"], 1)
query_by_prefixed = service.get_recent_evaluations(code="HK01810", eval_window_days=1, limit=10, page=1)
self.assertEqual(query_by_prefixed["total"], 1)
def test_run_backtest_supports_dotted_hk_prefix_query_shape(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q_hk_query_dot",
code="HK01810",
name="恒生指数成份股",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="HK prefixed history with dotted query",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 1, 1, 0, 0, 0),
context_snapshot=_phase_snapshot(
date(2024, 1, 1),
phase="postmarket",
market="hk",
effective_date=date(2024, 1, 1),
),
)
)
session.add(
StockDaily(
code="HK01810",
date=date(2024, 1, 1),
open=90.0,
high=90.0,
low=90.0,
close=90.0,
)
)
session.add_all(
[
StockDaily(
code="HK01810",
date=date(2024, 1, 2),
high=92.0,
low=88.0,
close=91.0,
),
]
)
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(
code="HK.01810",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["completed"], 1)
results = service.get_recent_evaluations(
code="HK.01810",
eval_window_days=1,
limit=10,
page=1,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
)
self.assertEqual(results["total"], 1)
summary = service.get_summary(
scope="stock",
code="HK.01810",
eval_window_days=1,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
)
self.assertIsNotNone(summary)
self.assertEqual(summary["code"], "01810")
def test_run_backtest_filters_by_snapshot_analysis_date_not_created_at(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q-created-at-mismatch",
code="000003",
name="测试股票",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="created_at differs from analysis date",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 1, 10, 0, 0, 0),
context_snapshot=_phase_snapshot(
date(2024, 1, 1),
phase="postmarket",
market="cn",
effective_date=date(2024, 1, 1),
),
)
)
session.add(
StockDaily(code="000003", date=date(2024, 1, 1), open=10.0, high=10.0, low=10.0, close=10.0)
)
session.add_all(
[
StockDaily(code="000003", date=date(2024, 1, 2), high=10.5, low=9.8, close=10.2),
StockDaily(code="000003", date=date(2024, 1, 3), high=10.8, low=10.1, close=10.5),
StockDaily(code="000003", date=date(2024, 1, 4), high=11.0, low=10.4, close=10.8),
]
)
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(
code="000003",
force=False,
eval_window_days=3,
min_age_days=0,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["completed"], 1)
with self.db.get_session() as session:
result = session.query(BacktestResult).filter(BacktestResult.code == "000003").one()
self.assertEqual(result.analysis_date, date(2024, 1, 1))
def test_run_backtest_persists_snapshot_date_when_start_daily_falls_back(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q-non-trading-analysis-date",
code="000004",
name="测试股票",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="snapshot date is a non-trading day",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 1, 7, 0, 0, 0),
context_snapshot=_phase_snapshot(
date(2024, 1, 7),
phase="non_trading",
market="cn",
),
)
)
session.add(
StockDaily(code="000004", date=date(2024, 1, 5), open=10.0, high=10.0, low=10.0, close=10.0)
)
session.add(
StockDaily(code="000004", date=date(2024, 1, 8), open=10.0, high=10.7, low=9.8, close=10.5)
)
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(
code="000004",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 1, 7),
analysis_date_to=date(2024, 1, 7),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["completed"], 1)
with self.db.get_session() as session:
result = session.query(BacktestResult).filter(BacktestResult.code == "000004").one()
self.assertEqual(result.analysis_date, date(2024, 1, 7))
self.assertAlmostEqual(result.start_price, 10.0)
self.assertAlmostEqual(result.end_close, 10.5)
data = service.get_recent_evaluations(
code="000004",
eval_window_days=1,
limit=10,
page=1,
analysis_date_from=date(2024, 1, 7),
analysis_date_to=date(2024, 1, 7),
)
self.assertEqual(data["total"], 1)
self.assertEqual(data["items"][0]["analysis_date"], "2024-01-07")
summary = service.get_summary(
scope="stock",
code="000004",
eval_window_days=1,
analysis_date_from=date(2024, 1, 7),
analysis_date_to=date(2024, 1, 7),
)
self.assertIsNotNone(summary)
assert summary is not None
self.assertEqual(summary["total_evaluations"], 1)
self.assertEqual(summary["completed_count"], 1)
def test_date_filtered_rerun_aligns_legacy_result_date_to_snapshot_date(self) -> None:
with self.db.get_session() as session:
history = AnalysisHistory(
query_id="q-legacy-result-date",
code="000005",
name="测试股票",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="legacy result stores fallback trading date",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 1, 7, 0, 0, 0),
context_snapshot='{"enhanced_context": {"date": "2024-01-07"}}',
)
session.add(history)
session.flush()
session.add(
BacktestResult(
analysis_history_id=history.id,
code="000005",
analysis_date=date(2024, 1, 5),
eval_window_days=1,
engine_version="v1",
eval_status="completed",
evaluated_at=datetime(2024, 1, 8, 0, 0, 0),
operation_advice="买入",
position_recommendation="long",
start_price=10.0,
end_close=10.5,
stock_return_pct=5.0,
direction_expected="up",
direction_correct=True,
outcome="win",
simulated_return_pct=5.0,
)
)
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(
code="000005",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 1, 7),
analysis_date_to=date(2024, 1, 7),
limit=10,
)
self.assertEqual(stats["processed"], 0)
self.assertEqual(stats["saved"], 0)
self.assertEqual(stats["diagnostics"]["empty_reason"], "no_new_results")
self.assertEqual(stats["diagnostics"]["aligned_existing_result_dates"], 1)
with self.db.get_session() as session:
result = session.query(BacktestResult).filter(BacktestResult.code == "000005").one()
self.assertEqual(result.analysis_date, date(2024, 1, 7))
data = service.get_recent_evaluations(
code="000005",
eval_window_days=1,
limit=10,
page=1,
analysis_date_from=date(2024, 1, 7),
analysis_date_to=date(2024, 1, 7),
)
self.assertEqual(data["total"], 1)
self.assertEqual(data["items"][0]["analysis_date"], "2024-01-07")
def test_date_filtered_rerun_aligns_legacy_result_date_when_new_candidates_process(self) -> None:
with self.db.get_session() as session:
legacy_history = AnalysisHistory(
query_id="q-legacy-result-date-mixed",
code="000006",
name="测试股票",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="legacy result stores fallback trading date in mixed rerun",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 1, 7, 0, 0, 0),
context_snapshot='{"enhanced_context": {"date": "2024-01-07"}}',
)
new_history = AnalysisHistory(
query_id="q-new-result-date-mixed",
code="000006",
name="测试股票",
report_type="simple",
sentiment_score=62,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="new matching analysis should not prevent legacy alignment",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 1, 7, 1, 0, 0),
context_snapshot='{"enhanced_context": {"date": "2024-01-07"}}',
)
session.add_all([legacy_history, new_history])
session.flush()
legacy_history_id = legacy_history.id
session.add(
BacktestResult(
analysis_history_id=legacy_history_id,
code="000006",
analysis_date=date(2024, 1, 5),
eval_window_days=1,
engine_version="v1",
eval_status="completed",
evaluated_at=datetime(2024, 1, 8, 0, 0, 0),
operation_advice="买入",
position_recommendation="long",
start_price=10.0,
end_close=10.5,
stock_return_pct=5.0,
direction_expected="up",
direction_correct=True,
outcome="win",
simulated_return_pct=5.0,
)
)
session.add_all(
[
StockDaily(
code="000006",
date=date(2024, 1, 7),
open=10.0,
high=10.0,
low=10.0,
close=10.0,
),
StockDaily(code="000006", date=date(2024, 1, 8), high=10.6, low=9.9, close=10.5),
]
)
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(
code="000006",
force=False,
eval_window_days=1,
min_age_days=0,
analysis_date_from=date(2024, 1, 7),
analysis_date_to=date(2024, 1, 7),
limit=10,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["diagnostics"]["aligned_existing_result_dates"], 1)
with self.db.get_session() as session:
legacy_result = (
session.query(BacktestResult)
.filter(BacktestResult.analysis_history_id == legacy_history_id)
.one()
)
self.assertEqual(legacy_result.analysis_date, date(2024, 1, 7))
data = service.get_recent_evaluations(
code="000006",
eval_window_days=1,
limit=10,
page=1,
analysis_date_from=date(2024, 1, 7),
analysis_date_to=date(2024, 1, 7),
)
self.assertEqual(data["total"], 2)
self.assertEqual({item["analysis_date"] for item in data["items"]}, {"2024-01-07"})
def test_run_backtest_pages_candidates_before_analysis_date_filter(self) -> None:
with self.db.get_session() as session:
for index in range(5):
session.add(
AnalysisHistory(
query_id=f"q-newer-outside-date-{index}",
code=f"00010{index}",
name="测试股票",
report_type="simple",
sentiment_score=50,
operation_advice="持有",
trend_prediction="震荡",
analysis_summary="newer created_at but outside analysis date range",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 2, 10, index, 0, 0),
context_snapshot=json.dumps({"enhanced_context": {"date": "2024-02-01"}}),
)
)
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(
code=None,
force=False,
eval_window_days=3,
min_age_days=0,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
limit=1,
)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["completed"], 1)
self.assertIsNone(stats["message"])
with self.db.get_session() as session:
result = session.query(BacktestResult).one()
self.assertEqual(result.code, "600519")
self.assertEqual(result.analysis_date, date(2024, 1, 1))
def test_run_backtest_reports_no_matching_candidates(self) -> None:
service = BacktestService(self.db)
stats = service.run_backtest(
code="600519",
force=False,
eval_window_days=3,
min_age_days=0,
analysis_date_from=date(2024, 2, 1),
analysis_date_to=date(2024, 2, 2),
limit=10,
)
self.assertEqual(stats["processed"], 0)
self.assertEqual(stats["saved"], 0)
self.assertEqual(stats["diagnostics"]["empty_reason"], "no_matching_analysis")
self.assertIn("未找到符合条件的历史分析记录", stats["message"])
def test_run_backtest_reports_insufficient_daily_data(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q-insufficient",
code="000002",
name="万科A",
report_type="simple",
sentiment_score=60,
operation_advice="买入",
trend_prediction="看多",
analysis_summary="insufficient daily bars",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 1, 1, 0, 0, 0),
context_snapshot=_phase_snapshot(
date(2024, 1, 1),
phase="postmarket",
market="cn",
effective_date=date(2024, 1, 1),
),
)
)
session.add(
StockDaily(code="000002", date=date(2024, 1, 1), open=10.0, high=10.0, low=10.0, close=10.0)
)
session.commit()
service = BacktestService(self.db)
with patch.object(BacktestService, "_try_fill_daily_data", return_value=None):
stats = service.run_backtest(code="000002", force=False, eval_window_days=3, min_age_days=0, limit=10)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(stats["completed"], 0)
self.assertEqual(stats["insufficient"], 1)
self.assertEqual(stats["diagnostics"]["empty_reason"], "insufficient_daily_data")
self.assertIn("可用日线行情不足", stats["message"])
def _run_and_get_result(self) -> BacktestResult:
"""Helper: run backtest and return the single BacktestResult row."""
service = BacktestService(self.db)
service.run_backtest(code="600519", force=False, eval_window_days=3, min_age_days=0, limit=10)
with self.db.get_session() as session:
return session.query(BacktestResult).one()
def test_result_fields_correct(self) -> None:
"""Verify BacktestResult row contains correct evaluation values."""
result = self._run_and_get_result()
self.assertEqual(result.eval_status, "completed")
self.assertEqual(result.code, "600519")
self.assertEqual(result.analysis_date, date(2024, 1, 1))
self.assertEqual(result.operation_advice, "买入")
self.assertEqual(result.position_recommendation, "long")
self.assertEqual(result.direction_expected, "up")
# Prices
self.assertAlmostEqual(result.start_price, 100.0)
self.assertAlmostEqual(result.end_close, 107.0)
self.assertAlmostEqual(result.stock_return_pct, 7.0)
# Direction & outcome
self.assertEqual(result.outcome, "win")
self.assertTrue(result.direction_correct)
# Target hits -- day2 high=111 >= take_profit=110
self.assertTrue(result.hit_take_profit)
self.assertFalse(result.hit_stop_loss)
self.assertEqual(result.first_hit, "take_profit")
self.assertEqual(result.first_hit_trading_days, 1)
self.assertEqual(result.first_hit_date, date(2024, 1, 2))
# Simulated execution
self.assertAlmostEqual(result.simulated_entry_price, 100.0)
self.assertAlmostEqual(result.simulated_exit_price, 110.0)
self.assertEqual(result.simulated_exit_reason, "take_profit")
self.assertAlmostEqual(result.simulated_return_pct, 10.0)
def test_summaries_created_after_run(self) -> None:
"""Verify both overall and per-stock BacktestSummary rows are created."""
service = BacktestService(self.db)
service.run_backtest(code="600519", force=False, eval_window_days=3, min_age_days=0, limit=10)
with self.db.get_session() as session:
# Overall summary uses sentinel code
overall = session.query(BacktestSummary).filter(
BacktestSummary.scope == "overall",
BacktestSummary.code == OVERALL_SENTINEL_CODE,
).first()
self.assertIsNotNone(overall)
self.assertEqual(overall.total_evaluations, 1)
self.assertEqual(overall.completed_count, 1)
self.assertEqual(overall.win_count, 1)
self.assertEqual(overall.loss_count, 0)
self.assertAlmostEqual(overall.win_rate_pct, 100.0)
# Stock-level summary
stock = session.query(BacktestSummary).filter(
BacktestSummary.scope == "stock",
BacktestSummary.code == "600519",
).first()
self.assertIsNotNone(stock)
self.assertEqual(stock.total_evaluations, 1)
self.assertEqual(stock.completed_count, 1)
self.assertEqual(stock.win_count, 1)
def test_get_summary_overall_returns_sentinel_as_none(self) -> None:
"""Verify get_summary translates __overall__ sentinel back to None."""
service = BacktestService(self.db)
service.run_backtest(code="600519", force=False, eval_window_days=3, min_age_days=0, limit=10)
summary = service.get_summary(scope="overall", code=None)
self.assertIsNotNone(summary)
self.assertIsNone(summary["code"])
self.assertEqual(summary["scope"], "overall")
self.assertEqual(summary["win_count"], 1)
def test_agent_learning_summary_helpers_keep_skill_rollups_neutral_until_supported(self) -> None:
service = BacktestService(self.db)
service.run_backtest(code="600519", force=False, eval_window_days=3, min_age_days=0, limit=10)
global_summary = service.get_global_summary(eval_window_days=3)
stock_summary = service.get_stock_summary("600519", eval_window_days=3)
skill_summary = service.get_skill_summary("bull_trend", eval_window_days=3)
strategy_summary = service.get_strategy_summary("bull_trend", eval_window_days=3)
self.assertIsNotNone(global_summary)
self.assertEqual(global_summary["total_evaluations"], 1)
self.assertAlmostEqual(global_summary["win_rate"], 1.0)
self.assertAlmostEqual(global_summary["direction_accuracy"], 1.0)
self.assertAlmostEqual(global_summary["avg_return"], 0.10)
self.assertIsNotNone(stock_summary)
self.assertEqual(stock_summary["code"], "600519")
self.assertAlmostEqual(stock_summary["win_rate"], 1.0)
self.assertIsNone(skill_summary)
self.assertIsNone(strategy_summary)
def test_get_recent_evaluations(self) -> None:
"""Verify get_recent_evaluations returns correct paginated results."""
service = BacktestService(self.db)
service.run_backtest(code="600519", force=False, eval_window_days=3, min_age_days=0, limit=10)
data = service.get_recent_evaluations(code="600519", limit=10, page=1)
self.assertEqual(data["total"], 1)
self.assertEqual(data["page"], 1)
self.assertEqual(data["limit"], 10)
self.assertEqual(len(data["items"]), 1)
item = data["items"][0]
self.assertEqual(item["code"], "600519")
self.assertEqual(item["outcome"], "win")
self.assertEqual(item["direction_expected"], "up")
self.assertTrue(item["direction_correct"])
def test_get_recent_evaluations_aligns_neutral_advice_with_score(self) -> None:
service = BacktestService(self.db)
with self.db.get_session() as session:
history = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == "q1").one()
history.operation_advice = "持有"
history.sentiment_score = 78
history.raw_result = None
result = self._make_backtest_result(
analysis_history_id=history.id,
analysis_date=date(2024, 1, 1),
eval_window_days=1,
)
result.operation_advice = "持有"
session.add(result)
session.commit()
data = service.get_recent_evaluations(
code="600519",
eval_window_days=1,
limit=10,
page=1,
)
self.assertEqual(data["total"], 1)
item = data["items"][0]
self.assertEqual(item["operation_advice"], "持有")
self.assertEqual(item["action"], "buy")
self.assertEqual(item["action_label"], "买入")
def test_get_recent_evaluations_prefers_persisted_raw_action(self) -> None:
service = BacktestService(self.db)
with self.db.get_session() as session:
history = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == "q1").one()
history.operation_advice = "持有观察"
history.raw_result = json.dumps(
{
"operation_advice": "持有观察",
"action": "watch",
"action_label": "观望",
"guardrail_reason": "市场风险较高,建议观望",
},
ensure_ascii=False,
)
result = self._make_backtest_result(
analysis_history_id=history.id,
analysis_date=date(2024, 1, 1),
eval_window_days=1,
)
result.operation_advice = "持有观察"
result.position_recommendation = "long"
session.add(result)
session.commit()
data = service.get_recent_evaluations(
code="600519",
eval_window_days=1,
limit=10,
page=1,
)
self.assertEqual(data["total"], 1)
item = data["items"][0]
self.assertEqual(item["operation_advice"], "持有观察")
self.assertEqual(item["action"], "watch")
self.assertEqual(item["action_label"], "观望")
self.assertEqual(item["position_recommendation"], "long")
def test_get_recent_evaluations_supports_tracking_fields_and_analysis_date_filters(self) -> None:
self._seed_analysis(
query_id="q2",
analysis_date=date(2024, 1, 10),
created_at=datetime(2024, 1, 10, 0, 0, 0),
operation_advice="买入",
trend_prediction="看多",
start_close=100.0,
forward_bars=[
StockDaily(code="600519", date=date(2024, 1, 11), high=101.0, low=95.0, close=96.0),
],
)
service = BacktestService(self.db)
service.run_backtest(code="600519", force=False, eval_window_days=1, min_age_days=0, limit=20)
data = service.get_recent_evaluations(
code="600519",
eval_window_days=1,
limit=10,
page=1,
analysis_date_from=date(2024, 1, 10),
analysis_date_to=date(2024, 1, 10),
)
self.assertEqual(data["total"], 1)
item = data["items"][0]
self.assertEqual(item["stock_name"], "贵州茅台")
self.assertEqual(item["trend_prediction"], "看多")
self.assertEqual(item["actual_movement"], "down")
self.assertAlmostEqual(item["actual_return_pct"], -4.0)
self.assertFalse(item["direction_correct"])
def test_get_summary_supports_analysis_date_range(self) -> None:
self._seed_analysis(
query_id="q2",
analysis_date=date(2024, 1, 10),
created_at=datetime(2024, 1, 10, 0, 0, 0),
operation_advice="买入",
trend_prediction="看多",
start_close=100.0,
forward_bars=[
StockDaily(code="600519", date=date(2024, 1, 11), high=101.0, low=95.0, close=96.0),
],
)
service = BacktestService(self.db)
service.run_backtest(code="600519", force=False, eval_window_days=1, min_age_days=0, limit=20)
summary = service.get_summary(
scope="stock",
code="600519",
eval_window_days=1,
analysis_date_from=date(2024, 1, 10),
analysis_date_to=date(2024, 1, 10),
)
self.assertIsNotNone(summary)
assert summary is not None
self.assertEqual(summary["total_evaluations"], 1)
self.assertEqual(summary["completed_count"], 1)
self.assertEqual(summary["win_count"], 0)
self.assertEqual(summary["loss_count"], 1)
self.assertAlmostEqual(summary["direction_accuracy_pct"], 0.0)
def test_get_summary_date_range_filters_to_single_window_and_engine(self) -> None:
service = BacktestService(self.db)
service.run_backtest(code="600519", force=False, eval_window_days=3, min_age_days=0, limit=10)
with self.db.get_session() as session:
base_result = session.query(BacktestResult).filter(
BacktestResult.code == "600519",
BacktestResult.eval_window_days == 3,
BacktestResult.engine_version == "v1",
).one()
session.add_all([
BacktestResult(
analysis_history_id=base_result.analysis_history_id,
code=base_result.code,
analysis_date=base_result.analysis_date,
eval_window_days=1,
engine_version="v1",
eval_status="completed",
evaluated_at=datetime(2024, 1, 5, 0, 0, 0),
operation_advice="买入",
position_recommendation="long",
start_price=100.0,
end_close=96.0,
stock_return_pct=-4.0,
direction_expected="up",
direction_correct=False,
outcome="loss",
simulated_return_pct=-4.0,
),
BacktestResult(
analysis_history_id=base_result.analysis_history_id,
code=base_result.code,
analysis_date=base_result.analysis_date,
eval_window_days=3,
engine_version="v2",
eval_status="completed",
evaluated_at=datetime(2024, 1, 6, 0, 0, 0),
operation_advice="买入",
position_recommendation="long",
start_price=100.0,
end_close=96.0,
stock_return_pct=-4.0,
direction_expected="up",
direction_correct=False,
outcome="loss",
simulated_return_pct=-4.0,
),
])
session.commit()
rows = service.repo.list_results(
code="600519",
eval_window_days=3,
engine_version="v1",
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
)
self.assertEqual(len(rows), 1)
evaluations = service.get_recent_evaluations(
code="600519",
eval_window_days=3,
limit=10,
page=1,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
)
self.assertEqual(evaluations["total"], 1)
self.assertEqual(len(evaluations["items"]), 1)
self.assertEqual(evaluations["items"][0]["engine_version"], "v1")
self.assertEqual(evaluations["items"][0]["operation_advice"], "买入")
self.assertEqual(evaluations["items"][0]["action"], "buy")
self.assertEqual(evaluations["items"][0]["action_label"], "买入")
self.assertEqual(evaluations["items"][0]["position_recommendation"], "long")
# Without explicit eval_window_days, summary infers the smallest
# window from matched rows (window=1 in this dataset) instead of
# falling back to the config default.
summary_inferred = service.get_summary(
scope="stock",
code="600519",
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
)
self.assertIsNotNone(summary_inferred)
assert summary_inferred is not None
self.assertEqual(summary_inferred["eval_window_days"], 1)
self.assertEqual(summary_inferred["engine_version"], "v1")
self.assertEqual(summary_inferred["total_evaluations"], 1)
self.assertEqual(summary_inferred["completed_count"], 1)
self.assertEqual(summary_inferred["win_count"], 0)
self.assertEqual(summary_inferred["loss_count"], 1)
self.assertAlmostEqual(summary_inferred["direction_accuracy_pct"], 0.0)
# With explicit eval_window_days=3, summary filters to that window only.
summary_explicit = service.get_summary(
scope="stock",
code="600519",
eval_window_days=3,
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
)
self.assertIsNotNone(summary_explicit)
assert summary_explicit is not None
self.assertEqual(summary_explicit["eval_window_days"], 3)
self.assertEqual(summary_explicit["engine_version"], "v1")
self.assertEqual(summary_explicit["total_evaluations"], 1)
self.assertEqual(summary_explicit["completed_count"], 1)
self.assertEqual(summary_explicit["win_count"], 1)
self.assertEqual(summary_explicit["loss_count"], 0)
self.assertAlmostEqual(summary_explicit["direction_accuracy_pct"], 100.0)
def test_get_summary_date_range_rejects_excessive_row_counts(self) -> None:
service = BacktestService(self.db)
service.run_backtest(code="600519", force=False, eval_window_days=3, min_age_days=0, limit=10)
with patch.object(BacktestService, "MAX_DYNAMIC_SUMMARY_ROWS", 0):
with self.assertRaisesRegex(ValueError, "Date-filtered summary matches too many rows"):
service.get_summary(
scope="stock",
code="600519",
analysis_date_from=date(2024, 1, 1),
analysis_date_to=date(2024, 1, 1),
)
def test_get_summary_phase_filter_cap_uses_phase_candidate_message(self) -> None:
service = BacktestService(self.db)
service.run_backtest(code="600519", force=False, eval_window_days=3, min_age_days=0, limit=10)
with patch.object(BacktestService, "MAX_DYNAMIC_SUMMARY_ROWS", 0):
with self.assertRaisesRegex(ValueError, "Phase-filtered summary candidate set matches too many rows"):
service.get_summary(
scope="stock",
code="600519",
analysis_phase="intraday",
)
def test_phase_filter_results_allows_exact_dynamic_cap(self) -> None:
service = BacktestService(self.db)
phase_snapshot = json.dumps({"market_phase_summary": {"phase": "intraday", "market": "cn"}})
raw_result = json.dumps(
{
"operation_advice": "持有观察",
"action": "watch",
"action_label": "观望",
"guardrail_reason": "模型判定观望,保留原始动作",
},
ensure_ascii=False,
)
rows = [
(
self._make_backtest_result(analysis_history_id=idx + 1, analysis_date=date(2024, 1, idx + 1)),
"贵州茅台",
"看多",
datetime(2024, 1, idx + 1, 0, 0, 0),
phase_snapshot,
raw_result,
"simple",
78,
)
for idx in range(2)
]
class RepoStub:
def get_results_with_context_batch(self, **kwargs):
offset = int(kwargs["offset"])
limit = int(kwargs["limit"])
return rows[offset: offset + limit]
service.repo = RepoStub()
with patch.object(BacktestService, "MAX_DYNAMIC_SUMMARY_ROWS", 2):
data = service.get_recent_evaluations(
code="600519",
eval_window_days=1,
limit=10,
page=1,
analysis_phase="intraday",
)
self.assertEqual(data["total"], 2)
self.assertEqual(len(data["items"]), 2)
self.assertEqual(data["items"][0]["action"], "watch")
self.assertEqual(data["items"][0]["action_label"], "观望")
def test_phase_filter_without_window_matches_summary_window(self) -> None:
service = BacktestService(self.db)
service.run_backtest(code="600519", force=False, eval_window_days=3, min_age_days=0, limit=10)
with self.db.get_session() as session:
base_result = session.query(BacktestResult).filter(
BacktestResult.code == "600519",
BacktestResult.eval_window_days == 3,
BacktestResult.engine_version == "v1",
).one()
session.add(
BacktestResult(
analysis_history_id=base_result.analysis_history_id,
code=base_result.code,
analysis_date=base_result.analysis_date,
eval_window_days=1,
engine_version="v1",
eval_status="completed",
evaluated_at=datetime(2024, 1, 5, 0, 0, 0),
operation_advice="买入",
position_recommendation="long",
start_price=100.0,
end_close=96.0,
stock_return_pct=-4.0,
direction_expected="up",
direction_correct=False,
outcome="loss",
simulated_return_pct=-4.0,
)
)
session.commit()
evaluations = service.get_recent_evaluations(
code="600519",
limit=10,
page=1,
analysis_phase="premarket",
)
self.assertEqual(evaluations["total"], 1)
self.assertEqual(evaluations["items"][0]["eval_window_days"], 1)
summary = service.get_summary(
scope="stock",
code="600519",
analysis_phase="premarket",
)
self.assertIsNotNone(summary)
assert summary is not None
self.assertEqual(summary["eval_window_days"], 1)
self.assertEqual(summary["total_evaluations"], 1)
def test_phase_filter_overfetches_before_pagination_and_updates_summary_breakdown(self) -> None:
self._seed_analysis(
query_id="q2",
analysis_date=date(2024, 1, 10),
created_at=datetime(2024, 1, 10, 0, 0, 0),
operation_advice="买入",
trend_prediction="看多",
start_close=100.0,
forward_bars=[
StockDaily(code="600519", date=date(2024, 1, 11), high=101.0, low=95.0, close=96.0),
],
phase="intraday",
)
service = BacktestService(self.db)
service.run_backtest(code="600519", force=False, eval_window_days=1, min_age_days=0, limit=20)
intraday = service.get_recent_evaluations(
code="600519",
eval_window_days=1,
limit=1,
page=1,
analysis_phase="intraday",
)
self.assertEqual(intraday["total"], 1)
self.assertEqual(len(intraday["items"]), 1)
self.assertEqual(intraday["items"][0]["market_phase"], "intraday")
self.assertEqual(intraday["items"][0]["market_phase_summary"]["phase"], "intraday")
premarket = service.get_recent_evaluations(
code="600519",
eval_window_days=1,
limit=1,
page=1,
analysis_phase="premarket",
)
self.assertEqual(premarket["total"], 1)
self.assertEqual(premarket["items"][0]["market_phase"], "premarket")
summary = service.get_summary(
scope="stock",
code="600519",
eval_window_days=1,
analysis_phase="intraday",
)
self.assertIsNotNone(summary)
assert summary is not None
self.assertEqual(summary["total_evaluations"], 1)
self.assertEqual(summary["diagnostics"]["phase_breakdown"]["intraday"], 1)
self.assertEqual(summary["diagnostics"]["phase_breakdown"]["premarket"], 0)
self.assertNotIn("premarket", summary["diagnostics"]["raw_phase_counts"])
self.assertEqual(summary["diagnostics"]["raw_phase_counts"]["intraday"], 1)
def test_phase_filter_buckets_detailed_internal_phases(self) -> None:
self._seed_analysis(
query_id="q2",
analysis_date=date(2024, 1, 10),
created_at=datetime(2024, 1, 10, 0, 0, 0),
operation_advice="买入",
trend_prediction="看多",
start_close=100.0,
forward_bars=[
StockDaily(code="600519", date=date(2024, 1, 11), high=101.0, low=95.0, close=96.0),
],
phase="lunch_break",
)
self._seed_analysis(
query_id="q3",
analysis_date=date(2024, 1, 12),
created_at=datetime(2024, 1, 12, 0, 0, 0),
operation_advice="买入",
trend_prediction="看多",
start_close=100.0,
forward_bars=[
StockDaily(code="600519", date=date(2024, 1, 13), high=101.0, low=95.0, close=96.0),
],
phase="closing_auction",
)
self._seed_analysis(
query_id="q4",
analysis_date=date(2024, 1, 14),
created_at=datetime(2024, 1, 14, 0, 0, 0),
operation_advice="买入",
trend_prediction="看多",
start_close=100.0,
forward_bars=[
StockDaily(code="600519", date=date(2024, 1, 15), high=101.0, low=95.0, close=96.0),
],
phase="non_trading",
)
service = BacktestService(self.db)
service.run_backtest(code="600519", force=False, eval_window_days=1, min_age_days=0, limit=20)
intraday = service.get_recent_evaluations(
code="600519",
eval_window_days=1,
limit=10,
page=1,
analysis_phase="intraday",
)
self.assertEqual(intraday["total"], 2)
self.assertEqual(
{item["market_phase_summary"]["phase"] for item in intraday["items"]},
{"lunch_break", "closing_auction"},
)
self.assertTrue(all(item["market_phase"] == "intraday" for item in intraday["items"]))
unknown = service.get_recent_evaluations(
code="600519",
eval_window_days=1,
limit=10,
page=1,
analysis_phase="unknown",
)
self.assertEqual(unknown["total"], 1)
self.assertEqual(unknown["items"][0]["market_phase"], "unknown")
self.assertEqual(unknown["items"][0]["market_phase_summary"]["phase"], "non_trading")
def test_phase_filter_rejects_values_outside_public_query_contract(self) -> None:
service = BacktestService(self.db)
with self.assertRaisesRegex(ValueError, "analysis_phase must be one of"):
service.get_recent_evaluations(code=None, analysis_phase="lunch_break")
with self.assertRaisesRegex(ValueError, "analysis_phase must be one of"):
service.get_summary(code=None, scope="overall", analysis_phase="banana")
def test_multi_stock_summaries(self) -> None:
"""Verify separate summaries for multiple stocks + correct overall aggregate."""
old_created_at = datetime(2024, 1, 1, 0, 0, 0)
with self.db.get_session() as session:
# Second stock with sell advice -- price drops (win for cash/down)
session.add(
AnalysisHistory(
query_id="q2",
code="000001",
name="平安银行",
report_type="simple",
sentiment_score=30,
operation_advice="卖出",
trend_prediction="看空",
analysis_summary="test2",
stop_loss=None,
take_profit=None,
created_at=old_created_at,
context_snapshot=_phase_snapshot(
date(2024, 1, 1),
phase="postmarket",
market="cn",
effective_date=date(2024, 1, 1),
),
)
)
session.add(
StockDaily(code="000001", date=date(2024, 1, 1), open=10.0, high=10.2, low=9.8, close=10.0)
)
session.add_all([
StockDaily(code="000001", date=date(2024, 1, 2), high=10.0, low=9.5, close=9.6),
StockDaily(code="000001", date=date(2024, 1, 3), high=9.7, low=9.3, close=9.4),
StockDaily(code="000001", date=date(2024, 1, 4), high=9.5, low=9.0, close=9.1),
])
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(code=None, force=False, eval_window_days=3, min_age_days=0, limit=10)
self.assertEqual(stats["saved"], 2)
self.assertEqual(stats["completed"], 2)
with self.db.get_session() as session:
# Each stock has its own summary
s1 = session.query(BacktestSummary).filter(
BacktestSummary.scope == "stock", BacktestSummary.code == "600519"
).first()
s2 = session.query(BacktestSummary).filter(
BacktestSummary.scope == "stock", BacktestSummary.code == "000001"
).first()
self.assertIsNotNone(s1)
self.assertIsNotNone(s2)
self.assertEqual(s1.win_count, 1)
self.assertEqual(s2.win_count, 1)
# Overall aggregates both
overall = session.query(BacktestSummary).filter(
BacktestSummary.scope == "overall",
BacktestSummary.code == OVERALL_SENTINEL_CODE,
).first()
self.assertIsNotNone(overall)
self.assertEqual(overall.total_evaluations, 2)
self.assertEqual(overall.completed_count, 2)
self.assertEqual(overall.win_count, 2)
def test_run_backtest_excludes_market_review_records(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q-market-review",
code="MARKET",
name="大盘复盘",
report_type="market_review",
sentiment_score=50,
operation_advice="查看复盘",
trend_prediction="大盘复盘",
analysis_summary="market review summary",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 1, 3, 0, 0, 0),
context_snapshot='{"enhanced_context": {"date": "2024-01-03"}}',
)
)
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(code=None, force=False, eval_window_days=3, min_age_days=0, limit=10)
self.assertEqual(stats["processed"], 1)
self.assertEqual(stats["saved"], 1)
self.assertEqual(self._count_results(), 1)
with self.db.get_session() as session:
self.assertEqual(
session.query(BacktestResult).filter(BacktestResult.code == "MARKET").count(),
0,
)
def test_run_backtest_includes_null_report_type_records(self) -> None:
with self.db.get_session() as session:
session.add(
AnalysisHistory(
query_id="q-null-report-type",
code="000858",
name="五粮液",
report_type=None,
sentiment_score=60,
operation_advice="持有",
trend_prediction="震荡",
analysis_summary="legacy null report_type row",
stop_loss=None,
take_profit=None,
created_at=datetime(2024, 1, 3, 0, 0, 0),
context_snapshot='{"enhanced_context": {"date": "2024-01-03"}}',
)
)
session.add_all(
[
StockDaily(code="000858", date=date(2024, 1, 3), open=12.0, high=12.8, low=11.5, close=12.2),
StockDaily(code="000858", date=date(2024, 1, 4), open=12.2, high=13.0, low=12.0, close=12.6),
StockDaily(code="000858", date=date(2024, 1, 5), open=12.6, high=12.9, low=11.9, close=12.4),
]
)
session.commit()
service = BacktestService(self.db)
stats = service.run_backtest(code=None, force=False, eval_window_days=3, min_age_days=0, limit=10)
self.assertGreaterEqual(stats["processed"], 2)
self.assertGreaterEqual(stats["saved"], 2)
with self.db.get_session() as session:
self.assertEqual(
session.query(BacktestResult).filter(BacktestResult.code == "000858").count(),
1,
)
if __name__ == "__main__":
unittest.main()