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 逐类验证归档页)。
This commit is contained in:
Simon
2026-09-20 07:31:04 +08:00
parent 7e15b7251e
commit 23972e7063
112 changed files with 17908 additions and 3893 deletions
+41 -4
View File
@@ -21,7 +21,9 @@ from app.domain.repositories.jobs import ExperimentRepository, JobRepository
from app.domain.repositories.market import (
AdjustFactorRepository,
DailyBarRepository,
DailyBasicRepository,
FinancialRepository,
StockNameHistoryRepository,
StockRepository,
)
from app.domain.repositories.selection import SelectionRepository
@@ -39,7 +41,9 @@ from app.infrastructure.persistence.sqlalchemy.repositories.index_impl import (
from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import (
SqlAlchemyAdjustFactorRepository,
SqlAlchemyDailyBarRepository,
SqlAlchemyDailyBasicRepository,
SqlAlchemyFinancialRepository,
SqlAlchemyStockNameHistoryRepository,
SqlAlchemyStockRepository,
)
from app.infrastructure.persistence.sqlalchemy.repositories.selection_impl import (
@@ -74,6 +78,10 @@ def _adjust_repo_factory(session: DbSession) -> AdjustFactorRepository:
return SqlAlchemyAdjustFactorRepository(session)
def _daily_basic_repo_factory(session: DbSession) -> DailyBasicRepository:
return SqlAlchemyDailyBasicRepository(session)
def _chart_service_factory(
stock_repo: Annotated[StockRepository, Depends(_stock_repo_factory)],
daily_repo: Annotated[DailyBarRepository, Depends(_daily_repo_factory)],
@@ -82,6 +90,11 @@ def _chart_service_factory(
return ChartService(stock_repo, daily_repo, adj_repo)
def _name_repo_factory(session: DbSession) -> StockNameHistoryRepository:
"""名称变更历史仓储(StockNameHistoryRepository 实现)。"""
return SqlAlchemyStockNameHistoryRepository(session)
def _index_repo_factory(session: DbSession) -> IndexConstituentRepository:
return SqlAlchemyIndexConstituentRepository(session)
@@ -95,24 +108,39 @@ def _service_factory(
daily_repo: Annotated[DailyBarRepository, Depends(_daily_repo_factory)],
engine: Annotated[QuantEngine, Depends(_engine_factory)],
index_repo: Annotated[IndexConstituentRepository, Depends(_index_repo_factory)],
basic_repo: Annotated[DailyBasicRepository, Depends(_daily_basic_repo_factory)],
financial_repo: Annotated[FinancialRepository, Depends(_financial_repo_factory)],
name_repo: Annotated[
StockNameHistoryRepository, Depends(_name_repo_factory)
] = None,
) -> ResearchService:
return ResearchService(stock_repo, daily_repo, engine, index_repo)
return ResearchService(
stock_repo,
daily_repo,
engine,
index_repo,
basic_repo=basic_repo,
financial_repo=financial_repo,
name_repo=name_repo,
)
def _replay_service_factory(
stock_repo: Annotated[StockRepository, Depends(_stock_repo_factory)],
daily_repo: Annotated[DailyBarRepository, Depends(_daily_repo_factory)],
index_repo: Annotated[IndexConstituentRepository, Depends(_index_repo_factory)],
name_repo: Annotated[StockNameHistoryRepository, Depends(_name_repo_factory)],
) -> ReplayService:
return ReplayService(stock_repo, daily_repo, index_repo)
return ReplayService(stock_repo, daily_repo, index_repo, name_repo=name_repo)
def _signal_service_factory(
stock_repo: Annotated[StockRepository, Depends(_stock_repo_factory)],
daily_repo: Annotated[DailyBarRepository, Depends(_daily_repo_factory)],
index_repo: Annotated[IndexConstituentRepository, Depends(_index_repo_factory)],
name_repo: Annotated[StockNameHistoryRepository, Depends(_name_repo_factory)],
) -> SignalService:
return SignalService(stock_repo, daily_repo, index_repo)
return SignalService(stock_repo, daily_repo, index_repo, name_repo=name_repo)
def _selection_service_factory(
@@ -120,8 +148,17 @@ def _selection_service_factory(
daily_repo: Annotated[DailyBarRepository, Depends(_daily_repo_factory)],
financial_repo: Annotated[FinancialRepository, Depends(_financial_repo_factory)],
index_repo: Annotated[IndexConstituentRepository, Depends(_index_repo_factory)],
basic_repo: Annotated[DailyBasicRepository, Depends(_daily_basic_repo_factory)],
name_repo: Annotated[StockNameHistoryRepository, Depends(_name_repo_factory)],
) -> SelectionService:
return SelectionService(stock_repo, daily_repo, financial_repo, index_repo)
return SelectionService(
stock_repo,
daily_repo,
financial_repo,
index_repo,
basic_repo=basic_repo,
name_repo=name_repo,
)
def _selection_repo_factory(session: DbSession) -> SelectionRepository:
+66 -7
View File
@@ -1,25 +1,41 @@
"""Experiment API(Phase 4):列表 / 详情 / 一键复跑。"""
"""Experiment API(Phase 4):列表 / 详情 / 删除 / 一键复跑。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, HTTPException
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response
from app.api.deps import DbSession, ExperimentRepoDep, JobRepoDep
from app.application.services.job_executor import new_id, run_job_background
from app.domain.entities.research import (
ExperimentRecord,
ExperimentSummary,
JobRecord,
JobStatus,
)
router = APIRouter(prefix="/experiments", tags=["experiments"])
# 列表默认返回条数与上限:原实现硬编码 limit=50 且不暴露总数,>50 条时更老的
# 归档静默不可见(AGENT.md §7);现在默认 200、上限 1000,并用 X-Total-Count
# 暴露过滤后的真实总数,客户端可据此翻页(offset)。
LIST_DEFAULT_LIMIT = 200
LIST_MAX_LIMIT = 1000
def _experiment_meta(exp: ExperimentRecord) -> dict:
def _experiment_meta(exp: ExperimentRecord | ExperimentSummary) -> dict:
"""列表项视图(body 形状与旧版一致,仅**新增** data_version / job_id / result_bytes)。
`exp` 可以是完整实体(详情路径)或 `ExperimentSummary`(列表路径,不含
result_json):两条路径都只读元数据字段,无需把大字段拉回来算体积。
"""
spec = json.loads(exp.spec_json)
result_bytes = getattr(exp, "result_bytes", None)
if result_bytes is None: # 完整实体:result_json 已在内存,len() 零成本
result_bytes = len(exp.result_json or "")
return {
"id": exp.id,
"kind": exp.kind,
@@ -29,6 +45,9 @@ def _experiment_meta(exp: ExperimentRecord) -> dict:
"top_n": spec.get("selection", {}).get("top_n"),
"summary_text": exp.summary_text,
"code_version": exp.code_version,
"data_version": exp.data_version,
"job_id": exp.job_id,
"result_bytes": int(result_bytes),
"created_at": exp.created_at,
}
@@ -43,9 +62,27 @@ def _experiment_full(exp: ExperimentRecord) -> dict:
}
@router.get("", summary="Experiment 列表")
def list_experiments(experiment_repo: ExperimentRepoDep) -> list[dict]:
return [_experiment_meta(e) for e in experiment_repo.list_recent(limit=50)]
@router.get("", summary="Experiment 列表(过滤 + 分页,X-Total-Count 给总数)")
def list_experiments(
experiment_repo: ExperimentRepoDep,
response: Response,
kind: Annotated[str | None, Query(description="按类型精确过滤(backtest/factor_test/selection)")] = None,
q: Annotated[
str | None, Query(description="大小写不敏感模糊匹配 id / 因子名 / summary_text")
] = None,
limit: Annotated[int, Query(ge=1, le=LIST_MAX_LIMIT)] = LIST_DEFAULT_LIMIT,
offset: Annotated[int, Query(ge=0)] = 0,
) -> list[dict]:
"""归档列表。
- 过滤与分页在 SQL 层完成(仓储 `list_filtered`),不把全表拉回内存;
- 响应头 `X-Total-Count` = **过滤后**的归档总数(不受 limit/offset 影响),
客户端据此判断是否被截断并翻页(AGENT.md §7:不静默截断)。
"""
rows = experiment_repo.list_filtered(kind=kind, q=q, limit=limit, offset=offset)
total = experiment_repo.count_filtered(kind=kind, q=q)
response.headers["X-Total-Count"] = str(total)
return [_experiment_meta(e) for e in rows]
@router.get("/{experiment_id}", summary="Experiment 详情(含完整结果)")
@@ -56,6 +93,28 @@ def get_experiment(experiment_id: str, experiment_repo: ExperimentRepoDep) -> di
return _experiment_full(exp)
@router.delete("/{experiment_id}", summary="删除 Experiment 归档(不影响关联 Job 记录)")
def delete_experiment(
experiment_id: str,
session: DbSession,
experiment_repo: ExperimentRepoDep,
) -> dict:
"""删除归档本身,返回 `{"deleted": "<id>"}`。
语义(写清楚避免误解):
- **只删 experiment 行**。关联的 job 记录是「执行历史」,一律保留,
`GET /api/jobs/{id}` 仍可查到该 Job 的状态、阶段与错误信息。
- 注意:完整结果现在只存归档一份(见 experiment_archive / job_executor),
因此删除归档后 `GET /api/jobs/{id}` 的 `result` 会是 null,并附带
`result_unavailable_reason` 说明归档已被删除(如实暴露,不静默给空结果)。
- 删除不可恢复;如需长期保留结果,请勿删除对应归档。
"""
if not experiment_repo.delete(experiment_id):
raise HTTPException(status_code=404, detail=f"Experiment {experiment_id} 不存在")
session.commit()
return {"deleted": experiment_id}
@router.post("/{experiment_id}/rerun", summary="一键复跑(AGENT §21:历史实验可重放)")
def rerun_experiment(
experiment_id: str,
@@ -77,4 +136,4 @@ def rerun_experiment(
job_repo.create(job)
session.commit()
background.add_task(run_job_background, job.id)
return {"job_id": job.id, "status": job.status, "origin_experiment": exp.id}
return {"job_id": job.id, "status": job.status, "origin_experiment": exp.id}
+15 -3
View File
@@ -11,12 +11,24 @@ from fastapi import APIRouter
from app.api.deps import DbSession, FactorRepoDep
from app.application.services.factor_catalog import seed_registry_factors
from app.domain.entities.factor import FactorDefinition
from app.quant.factors import list_factors
router = APIRouter(prefix="/factors", tags=["factors"])
@router.get("", summary="因子目录(含元数据,来自 factor_definition 表)")
def list_factor_catalog(factor_repo: FactorRepoDep, session: DbSession) -> list[FactorDefinition]:
if not factor_repo.list():
seed_registry_factors(factor_repo, session) # 首次:从代码注册表 seed
return factor_repo.list()
"""读目录;**缺失的注册表因子当场补齐**(幂等),稳态下零写入。
历史 bug:原先只在「表为空」时 seed,于是表非空后**代码里新增的因子永远进不了目录**——
实测表内 9 条、注册表 11 条,`dividend_yield` / `dividend_yield_ttm` 长期缺失,
前端因子下拉选不到「股息率」、归档页也查不到它的方向与含义(违反 §7 不静默)。
现在按「注册表有、库里没有」的差集触发 upsert:既保证目录与可计算因子一致,
又不会覆盖用户登记的自定义因子元数据(只补不删)。
"""
existing = factor_repo.list()
missing = {d.name for d in list_factors()} - {f.name for f in existing}
if not existing or missing:
seed_registry_factors(factor_repo, session)
existing = factor_repo.list()
return existing
+34 -6
View File
@@ -18,7 +18,7 @@ from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from fastapi.responses import StreamingResponse
from app.api.deps import DbSession, JobRepoDep
from app.api.deps import DbSession, ExperimentRepoDep, JobRepoDep
from app.application.services.job_executor import new_id, run_job_background, terminate_active
from app.domain.entities.research import (
BacktestResult,
@@ -46,11 +46,36 @@ def _decode_result(kind: str, result_json: str | None):
return model.model_validate_json(result_json)
def _job_view(job: JobRecord) -> dict:
def _job_view(job: JobRecord, experiment_repo=None) -> dict:
"""Job 视图:`result` 契约不变(成功时内嵌**完整**结果)。
结果来源(2026-09 起完整结果只在 experiment 存一份,job.result_json 不再重复写):
1. `job.experiment_id` 有值且能读到归档 → 解码 experiment.result_json;
2. 否则回退解码 `job.result_json`(老记录 / 归档被删除前的历史数据);
3. 归档被删除且 job 侧无副本 → `result=None`,并给出
`result_unavailable_reason` 如实说明原因(AGENT §7:不静默给空结果)。
"""
view = job.model_dump()
view["result"] = _decode_result(job.kind, job.result_json)
view.pop("result_json", None)
view["spec"] = json.loads(job.spec_json)
result = None
source = None
if job.experiment_id and experiment_repo is not None:
exp = experiment_repo.get(job.experiment_id)
if exp is not None:
result = _decode_result(job.kind, exp.result_json)
source = "experiment"
else:
view["result_unavailable_reason"] = (
f"归档 {job.experiment_id} 已不存在(可能已被删除);"
"完整结果仅存于归档,Job 记录本身不再保存结果副本"
)
if result is None and job.result_json:
result = _decode_result(job.kind, job.result_json)
source = "job"
view["result"] = result
view["result_source"] = source
return view
@@ -77,10 +102,13 @@ def create_job(
@router.get("", summary="Job 列表")
def list_jobs(
job_repo: JobRepoDep,
experiment_repo: ExperimentRepoDep,
kind: Annotated[str | None, Query(description="按类型过滤(backtest/factor_test)")] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 20,
) -> list[dict]:
return [_job_view(j) for j in job_repo.list_recent(kind=kind, limit=limit)]
return [
_job_view(j, experiment_repo) for j in job_repo.list_recent(kind=kind, limit=limit)
]
@router.post("/{job_id}/cancel", summary="取消 Job(queued/running)")
@@ -99,11 +127,11 @@ def cancel_job(job_id: str, session: DbSession, job_repo: JobRepoDep) -> dict:
@router.get("/{job_id}", summary="查询 Job 状态与结果")
def get_job(job_id: str, job_repo: JobRepoDep) -> dict:
def get_job(job_id: str, job_repo: JobRepoDep, experiment_repo: ExperimentRepoDep) -> dict:
job = job_repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} 不存在")
return _job_view(job)
return _job_view(job, experiment_repo)
@router.get("/{job_id}/events", summary="Job 进度 SSE")
+71 -4
View File
@@ -1,17 +1,23 @@
"""研究执行 API:/api/factor-tests 与 /api/backtests。
Phase 3 为同步执行(样本有限);Phase 4 将改为 Job + SSE 异步(接口契约不变)。
最近一次结果在内存中可读,便于前端展示;持久化实验归档在 Phase 4。
**归档(2026-09 补齐)**:同步端点此前只把结果塞进进程内存 `_LAST_*`,重启即丢,
完全不落库 —— 与「研究可复现」(AGENT.md §21)矛盾。现改为:返回结果前调用
`archive_experiment` 落库(复用与异步 Job 完全相同的归档实现),并通过响应头
`X-Experiment-Id` 暴露归档 id。body 形状保持不变(前端与既有测试依赖它)。
"""
from __future__ import annotations
import logging
from datetime import date
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Response
from pydantic import BaseModel, Field
from app.api.deps import ResearchServiceDep
from app.api.deps import DbSession, ExperimentRepoDep, ResearchServiceDep
from app.application.services.experiment_archive import archive_experiment
from app.domain.entities.research import (
BacktestResult,
FactorCorrelationReport,
@@ -23,23 +29,73 @@ from app.domain.entities.research import (
)
from app.quant.factors import FactorError
logger = logging.getLogger(__name__)
router = APIRouter(tags=["research"])
# 内存中的最近结果(Phase 4 迁移到 Experiment 表)
# 内存中的最近结果(仅作「最近一次」便捷读取;持久化归档见 archive_experiment)
_LAST_BACKTEST: dict[str, BacktestResult] = {}
_LAST_FACTOR_TEST: dict[str, FactorTestReport] = {}
def _archive_or_expose(
response: Response,
*,
session,
kind: str,
spec_json: str,
result,
experiment_repo,
) -> None:
"""归档同步端点的计算结果,并把归档结果如实反映到响应头。
取舍(AGENT.md §7「不静默」+ §24):研究结果是用户真实等待数十秒得到的产出,
归档是副产物。若数据库故障导致归档失败,直接抛 500 会把**已经算出来的可用结果**
一并丢掉;故此处捕获异常、接口仍然 200 返回完整结果,同时:
- `logger.warning` 落盘(服务端可观测);
- 响应头 `X-Archive-Error: <Type>: <msg>` 如实暴露失败原因(客户端可判读)。
即「结果不丢 + 失败不静默」两者兼顾;成功时给 `X-Experiment-Id`。
"""
try:
experiment = archive_experiment(
session=session,
kind=kind,
spec_json=spec_json,
result=result,
experiment_repo=experiment_repo,
)
except Exception as exc: # noqa: BLE001 —— 归档失败不得吞掉计算结果
logger.warning("同步端点归档失败(kind=%s):%s: %s", kind, type(exc).__name__, exc)
# HTTP 头只能承载 latin-1:中文错误信息降级为 ASCII 转义,避免编码异常掩盖原因
reason = f"{type(exc).__name__}: {exc}"[:180]
response.headers["X-Archive-Error"] = reason.encode("ascii", "backslashreplace").decode(
"ascii"
)
return
response.headers["X-Experiment-Id"] = experiment.id
@router.post("/factor-tests", response_model=FactorTestReport, summary="运行单因子测试(同步)")
def run_factor_test(
spec: ResearchSpec,
service: ResearchServiceDep,
session: DbSession,
experiment_repo: ExperimentRepoDep,
response: Response,
) -> FactorTestReport:
try:
report = service.run_factor_test(spec)
except (ValueError, FactorError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_LAST_FACTOR_TEST["default"] = report
_archive_or_expose(
response,
session=session,
kind="factor_test",
spec_json=spec.model_dump_json(),
result=report,
experiment_repo=experiment_repo,
)
return report
@@ -47,12 +103,23 @@ def run_factor_test(
def run_backtest(
spec: ResearchSpec,
service: ResearchServiceDep,
session: DbSession,
experiment_repo: ExperimentRepoDep,
response: Response,
) -> BacktestResult:
try:
result = service.run_backtest(spec)
except (ValueError, FactorError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_LAST_BACKTEST["default"] = result
_archive_or_expose(
response,
session=session,
kind="backtest",
spec_json=spec.model_dump_json(),
result=result,
experiment_repo=experiment_repo,
)
return result
+11
View File
@@ -26,6 +26,17 @@ def list_stocks(
return stocks[offset : offset + limit]
@router.get("/names", response_model=dict[str, str], summary="全市场股票名称映射")
def list_stock_names(repo: StockRepoDep) -> dict[str, str]:
"""`{symbol: name}` 全市场名称缓存(前端启动时一次性拉取,避免表格 N+1 查询)。
契约固定为 dict(不是数组):前端已有调用方按 dict 形状消费。
路由**必须**声明在 `/{symbol}` 之前,否则会被路径参数吞掉("names" 被当作代码)。
名称缺失(空字符串)的标的直接略过 —— 前端按「名称未知」渲染,不塞空串假装有名称。
"""
return {s.symbol: s.name for s in repo.list() if s.name}
@router.get("/{symbol}", response_model=Stock, summary="按代码查询")
def get_stock(symbol: str, repo: StockRepoDep) -> Stock:
stock = repo.get_by_symbol(symbol)
+93 -6
View File
@@ -1,10 +1,16 @@
"""策略 API(M8.3):/api/strategies CRUD + 展开为 ResearchSpec。
"""策略 API(M8.3):/api/strategies CRUD + 展开为 ResearchSpec + 说明/公式生成。
POST /api/strategies 保存策略(name 唯一)
GET /api/strategies 列表
POST /api/strategies 保存策略(name 唯一;description 为空时自动补全)
POST /api/strategies/describe body: ResearchSpec → StrategyDoc(未保存的策略也能预览)
GET /api/strategies 列表
GET /api/strategies/{id}
PUT /api/strategies/{id} 原地更新(不新建、不刷新 created_at)
DELETE /api/strategies/{id}
POST /api/strategies/{id}/expand body: {period:[start,end], initial_capital?} → ResearchSpec
POST /api/strategies/{id}/expand body: {period:[start,end], initial_capital?} → ResearchSpec
GET /api/strategies/{id}/describe → StrategyDoc
路由顺序注意:`/describe` 这类**字面量路径**一律声明在 `/{strategy_id}` 之前 ——
否则会被路径参数吞掉(AGENT.md §17 的既有教训,/api/stocks/names 同源问题)。
"""
from __future__ import annotations
@@ -18,6 +24,7 @@ from app.api.deps import DbSession, StrategyRepoDep
from app.application.services.job_executor import new_id
from app.domain.entities.research import ResearchSpec
from app.domain.entities.strategy import StrategyDefinition
from app.quant.strategy_doc import StrategyDoc, describe_strategy
router = APIRouter(prefix="/strategies", tags=["strategies"])
@@ -27,6 +34,28 @@ class ExpandRequest(BaseModel):
initial_capital: float = Field(default=1_000_000.0, gt=0)
# strategy.description 列宽(StrategyModel.description = String(300))。
# 自动补全的说明必须落在列宽内,否则 MySQL 严格模式会直接报 Data too long(SQLite 不拦,
# 所以只在测试库上跑是发现不了的)。超长时按字符截断并加省略号 —— 显式标记有截断,
# 不做「悄悄改短」;完整说明始终可由 POST /describe 重新生成。
_DESCRIPTION_MAX_CHARS = 300
def _ensure_description(definition: StrategyDefinition) -> StrategyDefinition:
"""说明为空/纯空白时,用 `describe_strategy(...).summary` 补全(需求:策略必须有说明)。
说明由 spec **真实推导**(AGENT.md §24:不许编造),只在空值时补、不覆盖显式说明。
放在 API 层是因为这是「保存契约」的准入补全;Agent 的 create_strategy 工具走仓储
直写(description 非必填),因此不受影响(AGENT.md §28 工具链路保持可用)。
"""
if definition.description.strip():
return definition
summary = describe_strategy(definition).summary
if len(summary) > _DESCRIPTION_MAX_CHARS:
summary = summary[: _DESCRIPTION_MAX_CHARS - 1] + "…"
return definition.model_copy(update={"description": summary})
@router.post("", response_model=StrategyDefinition, summary="保存策略")
def create_strategy(
definition: StrategyDefinition,
@@ -34,11 +63,25 @@ def create_strategy(
session: DbSession,
) -> StrategyDefinition:
try:
saved = strategy_repo.save(definition.model_copy(update={"id": new_id("STG")}))
saved = strategy_repo.save(
_ensure_description(definition).model_copy(update={"id": new_id("STG")})
)
session.commit()
return saved
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# 回读持久化后的实体:仓储 save() 返回的是入参(created_at 为空),
# 直接返回会让 POST 响应缺创建时间、与 GET/列表不一致(前端展示依赖该字段)。
return strategy_repo.get(saved.id) or saved
@router.post("/describe", response_model=StrategyDoc, summary="按 ResearchSpec 生成策略说明与公式")
def describe_research_spec(spec: ResearchSpec) -> StrategyDoc:
"""回测页参数即时预览用:**未保存的策略**(只有 spec)也能生成说明/公式。
纯函数实现(app.quant.strategy_doc),无 IO/DB,因此不会因保存状态而失败。
路径与 `POST /api/strategies` 不冲突(字面量 /describe 优先于路径参数声明)。
"""
return describe_strategy(spec)
@router.get("", response_model=list[StrategyDefinition], summary="策略列表")
@@ -54,6 +97,50 @@ def get_strategy(strategy_id: str, strategy_repo: StrategyRepoDep) -> StrategyDe
return row
@router.get("/{strategy_id}/describe", response_model=StrategyDoc, summary="生成策略说明与公式")
def describe_saved_strategy(
strategy_id: str, strategy_repo: StrategyRepoDep
) -> StrategyDoc:
"""已保存策略的说明/公式(404 语义与 GET /{strategy_id} 一致)。
策略定义不含回测区间,说明里的区间为占位文本(`warnings` 中已如实标注),
展开回测后(/expand)再用该 ResearchSpec 调 `POST /describe` 即为确定区间的版本。
"""
row = strategy_repo.get(strategy_id)
if row is None:
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
return describe_strategy(row)
@router.put("/{strategy_id}", response_model=StrategyDefinition, summary="原地更新策略")
def update_strategy(
strategy_id: str,
definition: StrategyDefinition,
strategy_repo: StrategyRepoDep,
session: DbSession,
) -> StrategyDefinition:
"""原地更新(策略库「编辑」用):id 以**路径**为准,created_at 沿用库中已有值。
为什么必须显式带上 created_at:仓储 `save()` 只在 created_at 为空时才写 now()
(既有行不会覆盖该列),但返回值是**传入的实体**;若这里不带,响应里的创建时间
就会变成 None,而策略库按创建时间展示 —— 一改就丢时间同样会误导前端。
改名撞车由仓储 `save()` 抛 ValueError(策略名已存在:X),这里转 400。
"""
existing = strategy_repo.get(strategy_id)
if existing is None:
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
payload = definition.model_copy(
update={"id": strategy_id, "created_at": existing.created_at}
)
try:
saved = strategy_repo.save(_ensure_description(payload))
session.commit()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# 与 create 一致:回读持久化实体,保证响应 == GET 读回(含 description/created_at)
return strategy_repo.get(saved.id) or saved
@router.delete("/{strategy_id}", summary="删除策略")
def delete_strategy(
strategy_id: str,