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:
+41
-4
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user