mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* fix(issue-1885): [bug]-评分建议过度归为持有,需收敛-hold/watch/buy/sell * fix(review-feedback-1887): 澄清/修正评分与 action 的契约一致性问题,并补齐高风险路径的验证说明 * fix(review-feedback-1887): 补一句排除说明,避免和 AGENTS 对外部依赖/运行时配置变更需说明兼容、迁移与回退路径的要求产生歧义 * fix(review-feedback-1887): CI 当前未通过:结构化事实显示 backend-gate:failure,PR 描述中的本地验证也写明 test:TIMEOUT
2436 lines
96 KiB
Python
2436 lines
96 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 data_provider.base import normalize_stock_code
|
|
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.storage import AnalysisHistory, BacktestResult, BacktestSummary, DatabaseManager, StockDaily
|
|
|
|
|
|
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=json.dumps(
|
|
{
|
|
"enhanced_context": {"date": "2024-01-01"},
|
|
"market_phase_summary": {
|
|
"phase": "premarket",
|
|
"market": "cn",
|
|
"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",
|
|
) -> 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=json.dumps(
|
|
{
|
|
"enhanced_context": {"date": analysis_date.isoformat()},
|
|
"market_phase_summary": {
|
|
"phase": phase,
|
|
"market": "cn",
|
|
"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 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_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_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='{"enhanced_context": {"date": "2024-02-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=json.dumps({"enhanced_context": {"date": "2024-02-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='{"enhanced_context": {"date": "2024-03-01"}}',
|
|
)
|
|
)
|
|
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='{"enhanced_context": {"date": "2024-04-01"}}',
|
|
)
|
|
)
|
|
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_build_market_code_variants_includes_compact_prefix_and_bj_forms(self) -> None:
|
|
sh_variants = BacktestRepository._build_market_code_variants("600519", "600519")
|
|
self.assertIn("SH600519", sh_variants)
|
|
self.assertIn("SH.600519", sh_variants)
|
|
self.assertIn("SS600519", sh_variants)
|
|
|
|
bj_variants = BacktestRepository._build_market_code_variants("920748", "920748")
|
|
self.assertIn("BJ920748", bj_variants)
|
|
self.assertIn("920748.BJ", bj_variants)
|
|
|
|
us_bare_variants = BacktestRepository._build_market_code_variants("AAPL", "AAPL")
|
|
self.assertIn("AAPL.US", us_bare_variants)
|
|
us_suffix_variants = BacktestRepository._build_market_code_variants("AAPL.US", "AAPL.US")
|
|
self.assertIn("AAPL", us_suffix_variants)
|
|
|
|
def test_daily_refill_codes_normalize_legacy_ss_aliases_once(self) -> None:
|
|
candidates = BacktestService._build_daily_code_candidates("605066.SH")
|
|
|
|
self.assertIn("SS605066", candidates)
|
|
self.assertEqual(normalize_stock_code("SS605066"), "605066")
|
|
for alias in ("605066.SH", "605066", "SH605066", "SH.605066", "605066.SS", "SS.605066"):
|
|
with self.subTest(alias=alias):
|
|
self.assertEqual(BacktestService._normalize_daily_refill_code(alias), "605066")
|
|
self.assertEqual(
|
|
BacktestService._ordered_daily_refill_codes(
|
|
code_candidates=candidates,
|
|
preferred_code="605066.SH",
|
|
),
|
|
["605066"],
|
|
)
|
|
|
|
def test_try_fill_daily_data_uses_normalized_a_share_code_for_legacy_ss_alias(self) -> None:
|
|
requested_codes = []
|
|
|
|
class FakeDataFetcherManager:
|
|
def get_daily_data(self, stock_code, start_date=None, end_date=None, days=30):
|
|
requested_codes.append(stock_code)
|
|
return (
|
|
pd.DataFrame(
|
|
[
|
|
{
|
|
"date": date(2024, 7, 1),
|
|
"open": 10.0,
|
|
"high": 11.0,
|
|
"low": 9.0,
|
|
"close": 10.5,
|
|
"volume": 1000,
|
|
}
|
|
]
|
|
),
|
|
"FakeFetcher",
|
|
)
|
|
|
|
service = BacktestService(self.db)
|
|
with patch("data_provider.base.DataFetcherManager", FakeDataFetcherManager):
|
|
service._try_fill_daily_data(
|
|
code="SS605066",
|
|
analysis_date=date(2024, 7, 1),
|
|
eval_window_days=1,
|
|
)
|
|
|
|
self.assertEqual(requested_codes, ["605066"])
|
|
self.assertEqual(
|
|
[row.code for row in self.db.get_data_range("605066", date(2024, 7, 1), date(2024, 7, 1))],
|
|
["605066"],
|
|
)
|
|
self.assertEqual(self.db.get_data_range("SS605066", date(2024, 7, 1), date(2024, 7, 1)), [])
|
|
|
|
def test_build_market_code_variants_rejects_hk_suffix_with_6_digit_base(self) -> None:
|
|
invalid_variants = BacktestRepository._build_market_code_variants("600519.HK", "600519.HK")
|
|
self.assertNotIn("600519", invalid_variants)
|
|
self.assertNotIn("600519.HK", invalid_variants)
|
|
|
|
valid_variants = BacktestRepository._build_market_code_variants("1810.HK", "01810")
|
|
self.assertIn("01810.HK", valid_variants)
|
|
self.assertIn("HK01810", valid_variants)
|
|
self.assertIn("HK.01810", valid_variants)
|
|
|
|
def test_build_market_code_variants_rejects_wrong_explicit_exchange_for_bse_code(self) -> None:
|
|
self.assertEqual(
|
|
BacktestRepository._build_market_code_variants("920748.SH", "920748"),
|
|
[],
|
|
)
|
|
self.assertEqual(
|
|
BacktestRepository._build_market_code_variants("SH920748", "920748"),
|
|
[],
|
|
)
|
|
|
|
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=json.dumps({"enhanced_context": {"date": "2024-02-05"}}),
|
|
)
|
|
)
|
|
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_uses_forward_bars_from_other_code_shape_when_start_daily_shape_differs(self) -> None:
|
|
with self.db.get_session() as session:
|
|
session.add(
|
|
AnalysisHistory(
|
|
query_id="q_shape_split",
|
|
code="600519",
|
|
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, 2, 10, 0, 0, 0),
|
|
context_snapshot='{"enhanced_context": {"date": "2024-02-10"}}',
|
|
)
|
|
)
|
|
session.add(
|
|
StockDaily(
|
|
code="600519.SH",
|
|
date=date(2024, 2, 10),
|
|
open=100.0,
|
|
high=100.0,
|
|
low=100.0,
|
|
close=100.0,
|
|
)
|
|
)
|
|
session.add_all(
|
|
[
|
|
StockDaily(code="600519", date=date(2024, 2, 11), high=106.0, low=99.0, close=105.0),
|
|
StockDaily(code="600519", date=date(2024, 2, 12), high=110.0, low=100.0, close=108.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, 10),
|
|
analysis_date_to=date(2024, 2, 10),
|
|
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").one()
|
|
self.assertEqual(result.analysis_date, date(2024, 2, 10))
|
|
self.assertEqual(result.start_price, 100.0)
|
|
self.assertEqual(result.end_close, 108.0)
|
|
|
|
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),
|
|
],
|
|
)
|
|
|
|
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_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),
|
|
],
|
|
)
|
|
|
|
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),
|
|
],
|
|
)
|
|
|
|
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.US")
|
|
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_matches_hk_different_code_shapes_in_analysis_history_and_daily(self) -> None:
|
|
with self.db.get_session() as session:
|
|
session.add(
|
|
AnalysisHistory(
|
|
query_id="q_hk_history_dot",
|
|
code="01810.HK",
|
|
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=json.dumps({"enhanced_context": {"date": "2024-01-01"}}),
|
|
)
|
|
)
|
|
session.add(
|
|
StockDaily(
|
|
code="HK01810",
|
|
date=date(2024, 1, 1),
|
|
open=100.0,
|
|
high=100.0,
|
|
low=100.0,
|
|
close=100.0,
|
|
)
|
|
)
|
|
session.add(
|
|
StockDaily(
|
|
code="HK01810",
|
|
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="1810.HK",
|
|
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="1810.HK", eval_window_days=1, limit=10, page=1)
|
|
self.assertEqual(data["total"], 1)
|
|
|
|
query_by_bare = service.get_recent_evaluations(code="01810", eval_window_days=1, limit=10, page=1)
|
|
self.assertEqual(query_by_bare["total"], 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=json.dumps({"enhanced_context": {"date": "2024-01-01"}}),
|
|
)
|
|
)
|
|
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=json.dumps({"enhanced_context": {"date": "2024-01-01"}}),
|
|
)
|
|
)
|
|
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='{"enhanced_context": {"date": "2024-01-01"}}',
|
|
)
|
|
)
|
|
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='{"enhanced_context": {"date": "2024-01-07"}}',
|
|
)
|
|
)
|
|
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='{"enhanced_context": {"date": "2024-01-01"}}',
|
|
)
|
|
)
|
|
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='{"enhanced_context": {"date": "2024-01-01"}}',
|
|
)
|
|
)
|
|
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()
|