Files
qlib/backend/tests/test_experiment_archive.py
T
Simon 23972e7063 feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

653 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Experiment 归档补齐(2026-09)测试。
覆盖五件事:
1. **同步端点归档**:`POST /api/backtests` / `POST /api/factor-tests` 落库(此前只写
进程内存),并用响应头 `X-Experiment-Id` 返回归档 id;归档失败时仍返回计算结果
并用 `X-Archive-Error` 如实暴露(AGENT §7 不静默)。
2. **data_version**:真实数据指纹(交易日 + 各表行数),格式与列宽(varchar(40))合规。
3. **列表过滤/分页**:`kind` / `q` / `limit` / `offset` + `X-Total-Count`(不静默截断)。
4. **DELETE**:200 → 再 GET 404 → 再 DELETE 404,且不触碰 job 记录。
5. **归档完整性**:默认不再按 60 只截断个股曲线;超体积预算时裁剪并留下
`archive_meta`(机器可读)+ `unimplemented`(人可读)证据;
完整结果只存 experiment 一份(`GET /api/jobs/{id}` 契约不变,老记录可回退)。
"""
from __future__ import annotations
import json
import re
from datetime import date, datetime
from types import SimpleNamespace
import pytest
from app.api import deps
from app.application.services import experiment_archive as ea
from app.domain.entities.market import Stock
from app.domain.entities.research import (
BacktestResult,
CostSpec,
ExperimentRecord,
ExperimentSummary,
FactorSpec,
JobRecord,
JobStatus,
ResearchSpec,
SelectionSpec,
UniverseSpec,
)
from app.infrastructure.persistence.sqlalchemy.base import Base
from app.infrastructure.persistence.sqlalchemy.models.jobs import ExperimentModel, JobModel
from app.infrastructure.persistence.sqlalchemy.models.market import StockDailyModel
from app.infrastructure.persistence.sqlalchemy.repositories.jobs_impl import (
SqlAlchemyExperimentRepository,
SqlAlchemyJobRepository,
)
from app.main import app
from app.quant.engine import LocalEngine
from app.quant.service import ResearchService
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from conftest_quant import bars_dataframe_to_daily_bars, synthetic_daily
_SYMS = ["60000" + str(i) + ".SH" for i in range(5)]
_BACKTEST_BODY = {
"type": "backtest",
"universe": {"exclude_st": False, "min_listing_days": 0},
"factors": [{"name": "momentum_20", "weight": 1.0}],
"selection": {"top_n": 2},
"rebalance": "monthly",
"period": ["2024-03-01", "2024-10-31"],
}
# data_version 格式:d<YYYYMMDD|->,后跟可选的行数段(MySQL 近似值带 ≈ 与 k)
_DATA_VERSION_RE = re.compile(
r"^d(\d{8}|-)(;n(≈\d+k|\d+))?(;a(≈\d+k|\d+))?(;b(≈\d+k|\d+))?$"
)
class _MemStockRepo:
def __init__(self, stocks: list[Stock]) -> None:
self._stocks = stocks
def get_by_symbol(self, symbol: str) -> Stock | None:
return next((s for s in self._stocks if s.symbol == symbol), None)
def list(self) -> list[Stock]:
return self._stocks
class _MemDailyRepo:
def __init__(self, bars) -> None:
self._bars = bars
def get_range_many(self, symbols, start, end, adjust="none"):
return [b for b in self._bars if b.symbol in symbols and start <= b.trade_date <= end]
def get_range(self, symbol, start, end):
return [b for b in self._bars if b.symbol == symbol and start <= b.trade_date <= end]
@pytest.fixture()
def api(tmp_path):
"""TestClient + 临时 SQLite(job/experiment/行情表)+ 内存行情仓储。"""
drifts = {sym: 0.003 - 0.0015 * i for i, sym in enumerate(_SYMS)}
bars = bars_dataframe_to_daily_bars(synthetic_daily(drifts, n=300))
stocks = [
Stock(symbol=sym, name=f"测试股份{i}", list_date=date(1999, 11, 10))
for i, sym in enumerate(_SYMS)
]
service = ResearchService(_MemStockRepo(stocks), _MemDailyRepo(bars), LocalEngine())
engine = create_engine(f"sqlite:///{tmp_path / 'archive.db'}", future=True)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, expire_on_commit=False)
def _session_override():
with Session() as s:
yield s
app.dependency_overrides[deps.get_session] = _session_override
app.dependency_overrides[deps._service_factory] = lambda: service
app.dependency_overrides[deps._stock_repo_factory] = lambda: _MemStockRepo(stocks)
with TestClient(app) as client:
yield client, Session
app.dependency_overrides.clear()
def _exp_by_id(Session, exp_id: str) -> ExperimentRecord | None:
with Session() as session:
return SqlAlchemyExperimentRepository(session).get(exp_id)
class TestSyncEndpointsArchive:
"""任务 1:同步端点必须落库,并通过 X-Experiment-Id 暴露归档 id。"""
def test_backtest_archives_to_db_and_returns_header(self, api) -> None:
client, Session = api
resp = client.post("/api/backtests", json=_BACKTEST_BODY)
assert resp.status_code == 200
body = resp.json()
assert body["equity_curve"], "body 形状不变:仍是完整 BacktestResult"
exp_id = resp.headers.get("X-Experiment-Id")
assert exp_id and exp_id.startswith("EXP-"), "必须用响应头返回归档 id"
exp = _exp_by_id(Session, exp_id)
assert exp is not None, "同步回测必须在 experiment 表落库(不再只写进程内存)"
assert exp.kind == "backtest"
assert json.loads(exp.spec_json)["factors"][0]["name"] == "momentum_20"
assert exp.summary_text and "总收益" in exp.summary_text
assert exp.code_version, "归档必须带代码版本"
assert exp.job_id is None, "同步端点没有 Job,job_id 应为 None"
# 归档结果可解码为完整 BacktestResult
archived = BacktestResult.model_validate_json(exp.result_json)
assert archived.summary.total_return_pct == body["summary"]["total_return_pct"]
# 详情接口能读回
detail = client.get(f"/api/experiments/{exp_id}")
assert detail.status_code == 200
assert detail.json()["result"]["summary"]["total_return_pct"] == (
body["summary"]["total_return_pct"]
)
def test_factor_test_archives_with_kind(self, api) -> None:
client, Session = api
payload = dict(_BACKTEST_BODY, type="factor_test")
resp = client.post("/api/factor-tests", json=payload)
assert resp.status_code == 200
assert resp.json()["factor_name"] == "momentum_20"
exp_id = resp.headers.get("X-Experiment-Id")
assert exp_id
exp = _exp_by_id(Session, exp_id)
assert exp is not None
assert exp.kind == "factor_test"
assert exp.result_json and json.loads(exp.result_json)["factor_name"] == "momentum_20"
def test_archive_failure_keeps_result_and_exposes_reason(self, api, monkeypatch) -> None:
"""归档失败不得吞掉计算结果,且失败必须如实暴露(AGENT §7 / §24)。"""
client, _Session = api
def _boom(self, experiment): # noqa: ANN001
raise RuntimeError("db down")
monkeypatch.setattr(SqlAlchemyExperimentRepository, "save", _boom)
resp = client.post("/api/backtests", json=_BACKTEST_BODY)
assert resp.status_code == 200, "归档失败也必须返回已算出的结果"
assert resp.json()["equity_curve"], "结果本身必须完整返回"
assert "X-Experiment-Id" not in resp.headers
err = resp.headers.get("X-Archive-Error")
assert err and "RuntimeError" in err and "db down" in err, (
f"归档失败原因必须如实暴露,实际:{err!r}"
)
class TestDataVersion:
"""任务 2:data_version 必须是真实、可比较、且塞得进 varchar(40) 的指纹。"""
def test_format_and_column_width(self, api) -> None:
client, Session = api
resp = client.post("/api/backtests", json=_BACKTEST_BODY)
exp = _exp_by_id(Session, resp.headers["X-Experiment-Id"])
assert exp is not None
assert exp.data_version, "data_version 不允许为空(此前永远是 NULL)"
assert _DATA_VERSION_RE.match(exp.data_version), exp.data_version
assert len(exp.data_version) <= 40, "experiment.data_version 是 varchar(40)"
def test_real_values_from_seeded_bars(self, api) -> None:
"""种子 3 行日线(SQLite 走精确 COUNT(*))→ 指纹必须是精确的真实值。"""
client, Session = api
days = [date(2024, 9, 26), date(2024, 9, 27), date(2024, 9, 30)]
with Session() as session:
session.execute(
StockDailyModel.__table__.insert(),
[
{
"symbol": f"60010{i}.SH",
"trade_date": d,
"source": "tushare",
"adjust": "none",
"open": 1.0,
"high": 1.0,
"low": 1.0,
"close": 1.0,
"volume": 1.0,
"amount": 1.0,
}
for i, d in enumerate(days)
],
)
session.commit()
resp = client.post("/api/backtests", json=_BACKTEST_BODY)
exp = _exp_by_id(Session, resp.headers["X-Experiment-Id"])
assert exp is not None
assert exp.data_version == "d20240930;n3;a0;b0", exp.data_version
def test_unavailable_when_tables_missing(self, tmp_path) -> None:
"""取不到数据口径时如实降级为 unavailable,绝不编造数字。"""
from app.infrastructure.persistence.sqlalchemy.data_version import compute_data_version
engine = create_engine(f"sqlite:///{tmp_path / 'empty.db'}", future=True)
with sessionmaker(bind=engine)() as session:
assert compute_data_version(session) == "unavailable"
class TestListFilters:
"""任务 3:列表过滤/分页在 SQL 层完成,并用 X-Total-Count 暴露总数。"""
@staticmethod
def _seed(Session) -> list[str]:
"""直接造 4 条归档:2 个 backtest(其一含 DIVIDEND_YIELD 因子)、1 个
factor_test、1 个 selection。"""
rows = [
("EXP-A1", "backtest", "DIVIDEND_YIELD", "总收益 12.00% · 年化 8.00% · 回撤 5.00%"),
("EXP-A2", "backtest", "momentum_20", "总收益 3.00% · 年化 2.00% · 回撤 1.00%"),
("EXP-A3", "factor_test", "momentum_20", "IC 0.0100 · RankIC 0.0200 · 样本 100 日"),
("EXP-A4", "selection", "momentum_60", "as_of 2024-10-31 · 选出 3 / 评估 100"),
]
with Session() as session:
repo = SqlAlchemyExperimentRepository(session)
for i, (exp_id, kind, factor, summary) in enumerate(rows):
repo.save(
ExperimentRecord(
id=exp_id,
kind=kind,
spec_json=json.dumps(
{
"factors": [{"name": factor, "weight": 1.0}],
"period": ["2024-03-01", "2024-10-31"],
"rebalance": "monthly",
"selection": {"top_n": i + 1},
}
),
result_json=json.dumps({"kind": kind, "n": i}),
summary_text=summary,
code_version="abc1234",
data_version="d20260904;n1;a2;b3",
created_at=datetime(2026, 9, 4, 10, i, 0),
)
)
session.commit()
return [r[0] for r in rows]
def test_kind_filter_and_total_count(self, api) -> None:
client, Session = api
self._seed(Session)
resp = client.get("/api/experiments", params={"kind": "backtest"})
assert resp.status_code == 200
body = resp.json()
assert {e["id"] for e in body} == {"EXP-A1", "EXP-A2"}
assert resp.headers["X-Total-Count"] == "2"
def test_q_is_case_insensitive_over_factor_name_and_summary(self, api) -> None:
client, Session = api
self._seed(Session)
# 因子名(在 spec_json 里,大小写不敏感)
r1 = client.get("/api/experiments", params={"q": "dividend_yield"})
assert [e["id"] for e in r1.json()] == ["EXP-A1"]
assert r1.headers["X-Total-Count"] == "1"
# id 片段
r2 = client.get("/api/experiments", params={"q": "exp-a"})
assert r2.headers["X-Total-Count"] == "4"
# summary_text(中文)
r3 = client.get("/api/experiments", params={"q": "选出 3"})
assert [e["id"] for e in r3.json()] == ["EXP-A4"]
# kind + q 组合
r4 = client.get("/api/experiments", params={"kind": "backtest", "q": "momentum"})
assert [e["id"] for e in r4.json()] == ["EXP-A2"]
def test_like_wildcards_and_injection_are_bound(self, api) -> None:
client, Session = api
self._seed(Session)
# `%` 是 LIKE 通配符,必须被转义成字面量:种子里只有 EXP-A1/A2 的摘要带
# 百分号,若未转义会返回全部 4 条
r = client.get("/api/experiments", params={"q": "%"})
assert {e["id"] for e in r.json()} == {"EXP-A1", "EXP-A2"}
assert r.headers["X-Total-Count"] == "2"
# 注入尝试必须是普通字符串(参数绑定),不得改变语义
inj = client.get("/api/experiments", params={"q": "' OR 1=1 --"})
assert inj.json() == [] and inj.headers["X-Total-Count"] == "0"
def test_limit_offset_and_total_count_not_silently_truncated(self, api) -> None:
client, Session = api
self._seed(Session)
page1 = client.get("/api/experiments", params={"limit": 2, "offset": 0})
assert len(page1.json()) == 2
assert page1.headers["X-Total-Count"] == "4", "总数必须暴露,客户端才知道被截断"
page2 = client.get("/api/experiments", params={"limit": 2, "offset": 2})
assert len(page2.json()) == 2
ids = {e["id"] for e in page1.json()} | {e["id"] for e in page2.json()}
assert len(ids) == 4, "分页不得重复/漏项(created_at 同秒时靠 id 次级排序稳定)"
# 默认 limit(200)远大于旧硬编码的 50
assert client.get("/api/experiments").status_code == 200
def test_meta_includes_data_version_job_id_and_result_bytes(self, api) -> None:
client, Session = api
seeded = self._seed(Session)
row = next(e for e in client.get("/api/experiments").json() if e["id"] == "EXP-A1")
assert row["data_version"] == "d20260904;n1;a2;b3", "列表必须暴露数据指纹"
assert row["job_id"] is None
assert row["result_bytes"] == len(json.dumps({"kind": "backtest", "n": 0}))
# 详情路径(result_json 在内存,len() 零成本)与列表口径必须一致
resp = client.post("/api/backtests", json=_BACKTEST_BODY)
exp_id = resp.headers["X-Experiment-Id"]
exp = _exp_by_id(Session, exp_id)
assert exp is not None
detail = client.get(f"/api/experiments/{exp_id}").json()
assert detail["result_bytes"] == len(exp.result_json)
assert detail["data_version"] == exp.data_version
assert detail["job_id"] is None
listed = next(e for e in client.get("/api/experiments").json() if e["id"] == exp_id)
assert listed["result_bytes"] == detail["result_bytes"], "列表与详情体积口径一致"
assert set(seeded) == {"EXP-A1", "EXP-A2", "EXP-A3", "EXP-A4"}
def test_repository_list_filtered_returns_summaries_without_result_json(self, api) -> None:
_client, Session = api
self._seed(Session)
with Session() as session:
repo = SqlAlchemyExperimentRepository(session)
rows = repo.list_filtered(kind="backtest", limit=10)
assert all(isinstance(r, ExperimentSummary) for r in rows)
assert not hasattr(rows[0], "result_json"), "列表不得拉取 MEDIUMTEXT 大字段"
assert repo.count_filtered() == 4
assert repo.count_filtered(kind="selection") == 1
class TestDelete:
"""任务 4:DELETE 只删归档,不删 Job 记录。"""
def test_delete_then_404(self, api) -> None:
client, Session = api
resp = client.post("/api/backtests", json=_BACKTEST_BODY)
exp_id = resp.headers["X-Experiment-Id"]
deleted = client.delete(f"/api/experiments/{exp_id}")
assert deleted.status_code == 200
assert deleted.json() == {"deleted": exp_id}
assert _exp_by_id(Session, exp_id) is None
assert client.get(f"/api/experiments/{exp_id}").status_code == 404
again = client.delete(f"/api/experiments/{exp_id}")
assert again.status_code == 404
assert "不存在" in again.json()["detail"]
def test_delete_keeps_job_record_and_reports_missing_result(self, api) -> None:
"""删除归档不触碰 job 表;job 的结果只剩归档一份,故如实说明原因而非静默 200 空值。"""
client, Session = api
resp = client.post("/api/backtests", json=_BACKTEST_BODY)
exp_id = resp.headers["X-Experiment-Id"]
with Session() as session:
job = JobRecord(
id="JOB-DEL-1",
kind="backtest",
spec_json=json.dumps(_BACKTEST_BODY),
status=JobStatus.SUCCESS,
experiment_id=exp_id,
created_at=datetime.now(),
)
SqlAlchemyJobRepository(session).create(job)
session.commit()
assert client.delete(f"/api/experiments/{exp_id}").status_code == 200
with Session() as session:
kept = SqlAlchemyJobRepository(session).get("JOB-DEL-1")
assert kept is not None, "job 记录是执行历史,DELETE 不得连带删除"
assert kept.experiment_id == exp_id
view = client.get("/api/jobs/JOB-DEL-1")
assert view.status_code == 200
assert view.json()["result"] is None
assert "已不存在" in view.json()["result_unavailable_reason"]
class TestCurveCompleteness:
"""P0:默认完整存档(不再按 60 只截断);超预算时裁剪并留证据。"""
@staticmethod
def _spec(top_n: int, symbols: list[str]) -> ResearchSpec:
return ResearchSpec(
type="backtest",
universe=UniverseSpec(
exclude_st=False, min_listing_days=0, symbols=list(symbols)
),
factors=[FactorSpec(name="momentum_20")],
selection=SelectionSpec(top_n=top_n, hold_top_x=top_n, allow_substitute=False, defer_buy=True),
rebalance="monthly",
selection_interval_months=6,
rebalance_interval_months=6,
period=(date(2024, 3, 1), date(2024, 6, 30)),
costs=CostSpec(
commission_rate=0.0, stamp_tax_rate=0.0, slippage_rate=0.0
),
)
def test_default_stores_every_held_symbol_beyond_60(self) -> None:
syms = [f"60{i:04d}.SH" for i in range(70)]
drifts = {s: 0.001 * (i % 4) + 0.0005 for i, s in enumerate(syms)}
daily = synthetic_daily(drifts, n=120)
res = LocalEngine().run_backtest(daily, self._spec(len(syms), syms))
held = {a.symbol for a in res.fills if a.symbol}
assert len(held) > 60, f"构造的样本应持有 60 只以上,实际 {len(held)}"
curve_syms = {c.symbol for c in res.symbol_curves}
assert held <= curve_syms, "期内持有的每只都必须有曲线(完整存档,默认不截断)"
assert not [n for n in res.unimplemented if "个股收益曲线" in n], "默认不得出现截断说明"
def test_configured_limit_truncates_and_notes_actual_limit(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""配置了上限就截断 —— 且 note 里写清「实际保留几只 / 共几只」。"""
from app.core import config as config_mod
from app.quant import local_engine as le
monkeypatch.setattr(
config_mod,
"get_settings",
lambda: SimpleNamespace(research_archive_curve_limit=3),
)
assert le._MAX_SYMBOL_CURVES is None, "默认代码路径不设上限(配置优先)"
syms = [f"60{i:04d}.SH" for i in range(10)]
drifts = {s: 0.001 * (i % 3) + 0.0005 for i, s in enumerate(syms)}
daily = synthetic_daily(drifts, n=90)
res = LocalEngine().run_backtest(daily, self._spec(len(syms), syms))
assert len(res.symbol_curves) == 3
note = [n for n in res.unimplemented if "个股收益曲线" in n]
assert note and "3 只" in note[0] and "共持有" in note[0]
def test_archive_budget_truncates_with_machine_readable_evidence(
self, api, monkeypatch
) -> None:
"""归档字节预算:超预算时裁剪曲线,并把证据同时写进 archive_meta 与 unimplemented。
预算不靠猜:先按默认预算归档一次拿到「完整归档的字节数」L,再把预算设为
L - 2,000 重新归档 —— 必然需要裁掉若干条曲线,且裁剪后必须真的落到预算内。
"""
client, Session = api
body = {
**_BACKTEST_BODY,
"selection": {"top_n": 5},
"universe": {**_BACKTEST_BODY["universe"], "symbols": _SYMS},
}
first = client.post("/api/backtests", json=body)
assert first.status_code == 200
assert first.json()["archive_meta"]["truncated"] is False, "默认预算下应完整存档"
full = _exp_by_id(Session, first.headers["X-Experiment-Id"])
assert full is not None
full_bytes = len(full.result_json.encode("utf-8"))
assert first.json()["archive_meta"]["curves_total"] >= 3, "样本需有足够曲线才能验证裁剪"
budget = full_bytes - 2_000
monkeypatch.setattr(ea, "_archive_budget_bytes", lambda: budget)
resp = client.post("/api/backtests", json=body)
assert resp.status_code == 200
meta = resp.json()["archive_meta"]
assert meta["truncated"] is True, f"预算 {budget} < 完整体积 {full_bytes},必须触发裁剪"
assert 0 < meta["curves_stored"] < meta["curves_total"], meta
assert meta["budget_bytes"] == budget
assert meta["over_budget"] is False
assert any("个股收益曲线" in n for n in resp.json()["unimplemented"])
exp = _exp_by_id(Session, resp.headers["X-Experiment-Id"])
assert exp is not None
assert len(exp.result_json.encode("utf-8")) <= budget, "裁剪后必须真的放进预算"
archived = json.loads(exp.result_json)
assert archived["archive_meta"]["truncated"] is True
assert archived["archive_meta"]["curves_stored"] == len(archived["symbol_curves"])
assert any("个股收益曲线" in n for n in archived["unimplemented"])
assert archived["archive_meta"]["result_bytes"] == len(exp.result_json.encode("utf-8"))
def test_archive_over_budget_is_labelled_not_hidden(self, api, monkeypatch) -> None:
"""预算小到连归档主体都放不下:必须标 over_budget + 说明,绝不假装达标。"""
client, Session = api
budget = 5_000
monkeypatch.setattr(ea, "_archive_budget_bytes", lambda: budget)
resp = client.post("/api/backtests", json=_BACKTEST_BODY)
assert resp.status_code == 200
body = resp.json()
meta = body["archive_meta"]
assert meta["curves_stored"] == 0 and meta["truncated"] is True
assert meta["over_budget"] is True, "主体超预算必须显式标注,而不是静默超标"
assert any("超过配置的体积预算" in n for n in body["unimplemented"])
exp = _exp_by_id(Session, resp.headers["X-Experiment-Id"])
assert exp is not None
archived = json.loads(exp.result_json)
assert archived["archive_meta"]["over_budget"] is True
assert any("超过配置的体积预算" in n for n in archived["unimplemented"])
# 如实记录「确实超标」这一事实(不掩盖,也不假装裁剪到位)
assert archived["archive_meta"]["result_bytes"] > budget
def test_archive_meta_complete_when_under_budget(self, api) -> None:
client, Session = api
resp = client.post("/api/backtests", json=_BACKTEST_BODY)
exp = _exp_by_id(Session, resp.headers["X-Experiment-Id"])
assert exp is not None
archived = json.loads(exp.result_json)
meta = archived["archive_meta"]
assert meta["truncated"] is False
assert meta["curves_stored"] == meta["curves_total"] == len(archived["symbol_curves"])
assert meta["result_bytes"] == len(exp.result_json.encode("utf-8"))
assert meta["result_chars"] == len(exp.result_json)
class TestSingleResultCopy:
"""P1:完整结果只存 experiment 一份;GET /api/jobs/{id} 契约与老记录兼容。"""
def test_submit_and_run_reads_through_in_memory_only(self, tmp_path) -> None:
"""`submit_and_run` 的返回对象仍带完整结果(既有调用方如
scripts/run_dividend_case.py 依赖它),但**数据库里不存第二份**。"""
from app.application.services.job_executor import submit_and_run
engine = create_engine(f"sqlite:///{tmp_path / 'submit.db'}", future=True)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, expire_on_commit=False)
drifts = {sym: 0.003 - 0.0015 * i for i, sym in enumerate(_SYMS)}
bars = bars_dataframe_to_daily_bars(synthetic_daily(drifts, n=300))
stocks = [
Stock(symbol=sym, name=f"测试股份{i}", list_date=date(1999, 11, 10))
for i, sym in enumerate(_SYMS)
]
spec = ResearchSpec.model_validate(_BACKTEST_BODY)
done = submit_and_run(
spec,
factories={
"session_factory": Session,
"job_repo_factory": lambda s: SqlAlchemyJobRepository(s),
"experiment_repo_factory": lambda s: SqlAlchemyExperimentRepository(s),
"stock_repo_factory": lambda s: _MemStockRepo(stocks),
"daily_repo_factory": lambda s: _MemDailyRepo(bars),
"engine": LocalEngine(),
},
)
assert done.status == JobStatus.SUCCESS
assert done.experiment_id
assert done.result_json, "返回对象必须带完整结果(内存读透,供既有调用方使用)"
assert BacktestResult.model_validate_json(done.result_json).symbol_curves
with Session() as session:
persisted = SqlAlchemyJobRepository(session).get(done.id)
exp = SqlAlchemyExperimentRepository(session).get(done.experiment_id)
assert persisted is not None and persisted.result_json is None, "数据库不得存第二份"
assert exp is not None and exp.result_json, "完整结果在 experiment 侧"
def test_job_view_reads_experiment_and_keeps_full_result(self, api) -> None:
from app.api.jobs import _job_view
client, Session = api
resp = client.post("/api/backtests", json=_BACKTEST_BODY)
exp_id = resp.headers["X-Experiment-Id"]
with Session() as session:
job = JobRecord(
id="JOB-VIEW-1",
kind="backtest",
spec_json=json.dumps(_BACKTEST_BODY),
status=JobStatus.SUCCESS,
result_json=None, # 新形态:job 侧不再保存结果副本
experiment_id=exp_id,
created_at=datetime.now(),
)
SqlAlchemyJobRepository(session).create(job)
session.commit()
view = _job_view(job, SqlAlchemyExperimentRepository(session))
assert view["result_source"] == "experiment"
assert view["result"].symbol_curves, "契约不变:GET /api/jobs/{id} 仍返回完整结果"
assert (
view["result"].summary.total_return_pct
== resp.json()["summary"]["total_return_pct"]
)
assert client.get("/api/jobs/JOB-VIEW-1").json()["result"]["symbol_curves"]
def test_legacy_job_record_still_decodes_from_result_json(self, api) -> None:
"""老形态(result_json 有值、experiment_id 为空)必须继续可用。"""
from app.api.jobs import _job_view
client, Session = api
resp = client.post("/api/backtests", json=_BACKTEST_BODY)
full = json.dumps(resp.json(), ensure_ascii=False)
with Session() as session:
job = JobRecord(
id="JOB-LEGACY-1",
kind="backtest",
spec_json=json.dumps(_BACKTEST_BODY),
status=JobStatus.SUCCESS,
result_json=full,
experiment_id=None,
created_at=datetime.now(),
)
SqlAlchemyJobRepository(session).create(job)
session.commit()
view = _job_view(job, SqlAlchemyExperimentRepository(session))
assert view["result_source"] == "job"
assert view["result"].symbol_curves
assert view["result"].summary.total_return_pct == resp.json()["summary"]["total_return_pct"]
def test_job_table_no_longer_duplicates_result(self, api) -> None:
"""json 列确实没有第二份结果(体积审计的机器可读证据)。"""
client, Session = api
resp = client.post("/api/backtests", json=_BACKTEST_BODY)
exp_id = resp.headers["X-Experiment-Id"]
with Session() as session:
job = JobRecord(
id="JOB-SIZE-1",
kind="backtest",
spec_json=json.dumps(_BACKTEST_BODY),
status=JobStatus.SUCCESS,
experiment_id=exp_id,
created_at=datetime.now(),
)
SqlAlchemyJobRepository(session).create(job)
session.commit()
with Session() as session:
row = session.execute(
select(JobModel.result_json).where(JobModel.id == "JOB-SIZE-1")
).scalar()
exp_row = session.execute(
select(ExperimentModel.result_json).where(ExperimentModel.id == exp_id)
).scalar()
assert row is None, "job 侧不得再存结果副本"
assert exp_row, "完整结果必须在 experiment 侧"
archived = _exp_by_id(Session, exp_id)
assert archived is not None
assert len(exp_row) == len(archived.result_json)