Files
tick-stock-panel/backend/app/api/mining.py
T
shy3130 e0cd625ef4 feat(platform): 因子平台与因子↔策略双向联动 v0.2.3
- 因子平台: /factors 一级页(检验/因子库/编辑器/组合/挖掘), DSL 公式因子(25 算子点选、双语字段、我的因子模板、脏公式守卫), 版本与生命周期, 自动挖掘 L1 统计筛选
- 因子↔策略四条桥: 触发器 Zap 快建因子条件信号、因子一键生成排名策略、自定义信号 AI 提示词接入因子分组、策略回测因子归因(胜/败单入场信号日因子均值, 独立 tab, 双语因子名)
- 回测: 统计卡新增盈亏比(≥1 红/<1 绿), 蒙卡回撤合并为中位/95% 双值卡(自适应字号), 高级设置基础过滤与策略编辑器参数对齐(5 组区间)
- 信号库独立页 /signals(原设置 tab 迁出), 持仓提醒入导航; 挖掘并入因子页第 5 tab, /mining 旧链接重定向
- 研究线配套: 因子目录 61→77(评分/矩阵双内核), stats_v2(Newey-West/BH-FDR/DSR), enriched 管道与异动/报价服务配套调整
- 文档: README 导航与特性表、features.md 因子平台章节、操作说明书 9.2、factor-platform-plan 执行状态与 §5、二开文档桥接说明; 交流与支持节改版
- 版本 0.2.2 → 0.2.3; 后端全量 1625 passed(1 例环境性跳过), 前端 build 通过
2026-09-05 15:41:15 +08:00

867 lines
34 KiB
Python

"""Persistent factor and strategy mining HTTP API."""
from __future__ import annotations
import asyncio
import json
import math
from collections.abc import AsyncIterator, Mapping, Sequence
from datetime import date
from typing import Annotated, Any, Literal
import polars as pl
from fastapi import APIRouter, Header, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from sse_starlette.sse import EventSourceResponse
from app.backtest.mining import (
MAX_BEAM_WIDTH,
MAX_COMBINATION_SIZE,
MAX_FINALISTS,
evaluate_candidate_gate,
)
from app.enriched_generation import EnrichedGenerationUnavailableError
from app.factors.registry import factor_columns_view
from app.services import preferences
from app.services.mining_jobs import (
RUN_STATUSES,
SUCCESS_RUN_STATUSES,
TERMINAL_RUN_STATUSES,
MiningRunStore,
MiningRunStoreError,
MiningRunValidationError,
)
from app.services.mining_preflight import (
enriched_partition_dates,
mining_availability,
require_mining_availability,
)
from app.services.mining_schedule import (
MINING_ALGORITHM_VERSION,
build_data_fingerprint,
)
router = APIRouter(prefix="/api/backtest/mining", tags=["backtest"])
# 校验时动态读取 (含运行期注册的自定义/复合因子)
def _known_factor_ids() -> frozenset[str]:
return frozenset(str(item["id"]) for item in factor_columns_view())
_MAX_ARTIFACT_BYTES = 64 * 1024 * 1024
_SSE_POLL_SECONDS = 0.5
_SSE_HEARTBEAT_SECONDS = 15.0
class MiningStartRequest(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
factor_names: list[str] = Field(min_length=1, max_length=48)
strategy_ids: list[str] = Field(default_factory=list, max_length=8)
symbols: list[str] | None = None
asset_type: Literal["stock", "etf"] = "stock"
start: date | None = None
end: date | None = None
budget_profile: Literal["exploratory", "balanced", "strict"] = "balanced"
commission_pct: float = Field(0.0002, ge=0.0, le=0.05, allow_inf_nan=False)
stamp_tax_pct: float = Field(0.0005, ge=0.0, le=0.05, allow_inf_nan=False)
slippage_bps: float = Field(5.0, ge=0.0, le=1000.0, allow_inf_nan=False)
correlation_threshold: float = Field(0.75, gt=0.0, le=1.0, allow_inf_nan=False)
max_combination_factors: int = Field(4, ge=1, le=MAX_COMBINATION_SIZE)
beam_width: int = Field(12, ge=1, le=MAX_BEAM_WIDTH)
max_finalists: int = Field(MAX_FINALISTS, ge=1, le=MAX_FINALISTS)
force: bool = False
@field_validator("start", "end", mode="before")
@classmethod
def _iso_dates(cls, value: Any) -> Any:
if isinstance(value, str):
try:
return date.fromisoformat(value)
except ValueError as exc:
raise ValueError("dates must use ISO YYYY-MM-DD format") from exc
return value
@field_validator("factor_names", "strategy_ids")
@classmethod
def _unique_ids(cls, values: list[str]) -> list[str]:
if any(not value or len(value) > 120 for value in values):
raise ValueError("IDs must contain 1 to 120 characters")
if len(set(values)) != len(values):
raise ValueError("IDs must be unique")
return values
@field_validator("factor_names")
@classmethod
def _known_factors(cls, values: list[str]) -> list[str]:
unknown = sorted(set(values) - _known_factor_ids())
if unknown:
raise ValueError(f"unknown mining factors: {unknown}")
return values
@field_validator("symbols")
@classmethod
def _symbols(cls, values: list[str] | None) -> list[str] | None:
if values is None:
return None
cleaned = [value for value in values if value]
if len(cleaned) > 10_000:
raise ValueError("symbols contains more than 10000 entries")
if len(set(cleaned)) != len(cleaned):
raise ValueError("symbols must be unique")
return cleaned or None
@model_validator(mode="after")
def _date_range(self) -> MiningStartRequest:
if self.start is not None and self.end is not None and self.start > self.end:
raise ValueError("start must not be after end")
return self
class MiningSchedulePatch(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
mining_schedule_enabled: bool | None = None
mining_schedule_weekday: int | None = Field(None, ge=0, le=4)
mining_budget_profile: Literal["balanced", "strict"] | None = None
class MiningAutoStartRequest(BaseModel):
"""自动挖掘: 因子池由 L1 统计筛选自动生成, 不接受手动指定。"""
model_config = ConfigDict(extra="forbid", strict=True)
asset_type: Literal["stock", "etf"] = "stock"
start: date | None = None
end: date | None = None
budget_profile: Literal["exploratory", "balanced", "strict"] = "balanced"
commission_pct: float = Field(0.0002, ge=0.0, le=0.05, allow_inf_nan=False)
stamp_tax_pct: float = Field(0.0005, ge=0.0, le=0.05, allow_inf_nan=False)
slippage_bps: float = Field(5.0, ge=0.0, le=1000.0, allow_inf_nan=False)
correlation_threshold: float = Field(0.75, gt=0.0, le=1.0, allow_inf_nan=False)
force: bool = False
@field_validator("start", "end", mode="before")
@classmethod
def _iso_dates(cls, value: Any) -> Any:
if isinstance(value, str):
try:
return date.fromisoformat(value)
except ValueError as exc:
raise ValueError("dates must use ISO YYYY-MM-DD format") from exc
return value
@model_validator(mode="after")
def _date_range(self) -> MiningAutoStartRequest:
if self.start is not None and self.end is not None and self.start > self.end:
raise ValueError("start must not be after end")
return self
@router.get("/availability")
def get_availability(
request: Request,
asset_type: Annotated[Literal["stock", "etf"], Query()] = "stock",
budget_profile: Annotated[
Literal["exploratory", "balanced", "strict"], Query()
] = "balanced",
start: Annotated[date | None, Query()] = None,
end: Annotated[date | None, Query()] = None,
) -> dict[str, Any]:
try:
return mining_availability(
request.app.state.repo.store.data_dir,
asset_type=asset_type,
budget_profile=budget_profile,
start=start,
end=end,
).to_dict()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/runs")
def list_runs(
request: Request,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
status: Annotated[list[str] | None, Query()] = None,
) -> dict[str, Any]:
manager = _manager(request)
statuses = None
if status:
unknown = sorted(set(status) - RUN_STATUSES)
if unknown:
raise HTTPException(status_code=400, detail=f"unsupported mining statuses: {unknown}")
statuses = status
try:
manifests = manager.store.list_runs(limit=limit, statuses=statuses)
return {"items": [_project_run(manager.store, item) for item in manifests]}
except MiningRunValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except MiningRunStoreError as exc:
raise HTTPException(status_code=500, detail="failed to read mining runs") from exc
@router.post("/runs")
def start_run(payload: MiningStartRequest, request: Request) -> dict[str, Any]:
manager = _manager(request)
worker_request = payload.model_dump(mode="json", exclude={"force"})
try:
_validate_selected_strategies(
request.app.state.strategy_engine,
payload.strategy_ids,
payload.asset_type,
)
require_mining_availability(
request.app.state.repo.store.data_dir,
asset_type=payload.asset_type,
budget_profile=payload.budget_profile,
start=payload.start,
end=payload.end,
)
fingerprint = build_data_fingerprint(
request.app.state.repo,
request.app.state,
worker_request,
)
existing = None
if not payload.force:
from app.services.mining_jobs import (
ACTIVE_RUN_STATUSES,
SUCCESS_RUN_STATUSES,
compute_run_signature,
)
signature = compute_run_signature(worker_request, fingerprint)
existing = manager.store.find_by_signature(
signature,
statuses=ACTIVE_RUN_STATUSES | SUCCESS_RUN_STATUSES,
)
manifest = manager.start(
worker_request,
fingerprint,
force=payload.force,
source="manual",
)
projected = _project_run(manager.store, manifest)
projected["reused"] = existing is not None
return projected
except (MiningRunValidationError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except EnrichedGenerationUnavailableError as exc:
# build_data_fingerprint 读世代时撞上正在进行的 enriched 发布 (如盘后更新):
# 映射为 400 带指引, 而不是 500 英文堆栈。
raise HTTPException(
status_code=400,
detail="行情数据正在更新(enriched 发布中), 请等数据更新完成后再开始挖掘",
) from exc
except MiningRunStoreError as exc:
raise HTTPException(status_code=500, detail="failed to persist mining run") from exc
@router.get("/runs/{run_id}")
def get_run(run_id: str, request: Request) -> dict[str, Any]:
store = _manager(request).store
return _project_run(store, _required_manifest(store, run_id))
@router.post("/runs/{run_id}/cancel")
def cancel_run(run_id: str, request: Request) -> dict[str, Any]:
manager = _manager(request)
try:
return _project_run(manager.store, manager.cancel(run_id))
except KeyError as exc:
raise HTTPException(status_code=404, detail="mining run not found") from exc
except MiningRunValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/auto")
def start_auto_run(payload: MiningAutoStartRequest, request: Request) -> dict[str, Any]:
"""自动挖掘: L1 统计筛选全量因子 → 达标池 → 复用挖掘任务管理启动嵌套样本外验证。
筛选结果随请求持久化 (request.auto_screening), 供结果页展示达标因子清单与
失败原因分布; 无达标因子时返回 started=false 而不是报错。
"""
from app.services.auto_mining import screen_all_factors
manager = _manager(request)
data_dir = request.app.state.repo.store.data_dir
try:
require_mining_availability(
data_dir,
asset_type=payload.asset_type,
budget_profile=payload.budget_profile,
start=payload.start,
end=payload.end,
)
engine = getattr(request.app.state, "backtest_engine", None)
if engine is None:
from app.backtest.engine import BacktestEngine
engine = BacktestEngine(request.app.state.repo)
request.app.state.backtest_engine = engine
all_dates = enriched_partition_dates(data_dir, payload.asset_type)
screen_end = payload.end or (all_dates[-1] if all_dates else date.today())
screening = screen_all_factors(
engine,
asset_type=payload.asset_type,
start=payload.start,
end=screen_end,
profile=payload.budget_profile,
)
except (MiningRunValidationError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except EnrichedGenerationUnavailableError as exc:
raise HTTPException(
status_code=400,
detail="行情数据正在更新(enriched 发布中), 请等数据更新完成后再开始挖掘",
) from exc
if not screening["pool"]:
return {"started": False, "reason": "no_qualified_factors", "screening": screening}
worker_request = {
"factor_names": screening["pool"],
"strategy_ids": [],
"symbols": None,
"asset_type": payload.asset_type,
"start": payload.start.isoformat() if payload.start else None,
"end": payload.end.isoformat() if payload.end else None,
"budget_profile": payload.budget_profile,
"commission_pct": payload.commission_pct,
"stamp_tax_pct": payload.stamp_tax_pct,
"slippage_bps": payload.slippage_bps,
"correlation_threshold": payload.correlation_threshold,
"max_combination_factors": 4,
"beam_width": 12,
"max_finalists": MAX_FINALISTS,
"auto": True,
"auto_screening": screening,
}
try:
fingerprint = build_data_fingerprint(
request.app.state.repo,
request.app.state,
worker_request,
)
manifest = manager.start(
worker_request,
fingerprint,
force=payload.force,
source="auto",
)
except (MiningRunValidationError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except EnrichedGenerationUnavailableError as exc:
raise HTTPException(
status_code=400,
detail="行情数据正在更新(enriched 发布中), 请等数据更新完成后再开始挖掘",
) from exc
except MiningRunStoreError as exc:
raise HTTPException(status_code=500, detail="failed to persist mining run") from exc
return {"started": True, "run": _project_run(manager.store, manifest), "screening": screening}
@router.get("/runs/{run_id}/result")
def get_result(run_id: str, request: Request) -> dict[str, Any]:
store = _manager(request).store
manifest = _required_manifest(store, run_id)
status = str(manifest["status"])
if status not in SUCCESS_RUN_STATUSES:
status_code = 409 if status not in TERMINAL_RUN_STATUSES else 422
raise HTTPException(
status_code=status_code,
detail=f"mining result is unavailable for status {status}",
)
try:
summary = store.read_summary(run_id)
frames = {
name: _read_registered_artifact(store, manifest, name)
for name in ("factors", "correlation", "candidates", "folds")
}
return _project_result(manifest, summary, frames)
except (
MiningRunStoreError,
OSError,
pl.exceptions.PolarsError,
ValueError,
) as exc:
raise HTTPException(
status_code=500,
detail="mining result artifacts are unavailable",
) from exc
@router.get("/runs/{run_id}/events")
def stream_events(
run_id: str,
request: Request,
last_event_id: str | None = Header(None, alias="Last-Event-ID"),
) -> EventSourceResponse:
store = _manager(request).store
_required_manifest(store, run_id)
cursor = _event_cursor(last_event_id)
async def generate() -> AsyncIterator[dict[str, str]]:
nonlocal cursor
last_emit = asyncio.get_running_loop().time()
terminal_sent = False
first_batch = True
while not await request.is_disconnected():
events = await asyncio.to_thread(store.read_events, run_id, after_id=cursor)
if first_batch and events and int(events[0]["id"]) > cursor + 1:
summary = await asyncio.to_thread(store.read_summary, run_id)
progress = summary.get("progress")
if isinstance(progress, Mapping):
yield {
"id": str(cursor),
"event": "progress",
"data": json.dumps(progress, ensure_ascii=False, allow_nan=False),
}
last_emit = asyncio.get_running_loop().time()
first_batch = False
for event in events:
cursor = int(event["id"])
event_type = "failed" if event.get("type") == "error" else str(event["type"])
payload = dict(event.get("payload") or {})
if event_type in TERMINAL_RUN_STATUSES:
payload.setdefault("status", event_type)
terminal_sent = True
yield {
"id": str(cursor),
"event": event_type,
"data": json.dumps(payload, ensure_ascii=False, allow_nan=False),
}
last_emit = asyncio.get_running_loop().time()
manifest = await asyncio.to_thread(store.get, run_id)
if manifest is None:
return
status = str(manifest["status"])
if status in TERMINAL_RUN_STATUSES:
if not terminal_sent:
event_type = "failed" if status == "failed" else status
yield {
"id": str(cursor),
"event": event_type,
"data": json.dumps(
{"status": status, "message": manifest.get("error")},
ensure_ascii=False,
),
}
return
now = asyncio.get_running_loop().time()
if now - last_emit >= _SSE_HEARTBEAT_SECONDS:
yield {"event": "heartbeat", "data": "{}"}
last_emit = now
await asyncio.sleep(_SSE_POLL_SECONDS)
return EventSourceResponse(generate(), ping=_SSE_HEARTBEAT_SECONDS)
@router.post("/runs/{run_id}/candidates/{signature}/promote")
def promote_candidate(run_id: str, signature: str, request: Request) -> dict[str, Any]:
service = _candidate_service(request)
try:
return service.promote(run_id, signature)
except KeyError as exc:
raise HTTPException(status_code=404, detail="mining run or candidate not found") from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("/runs/{run_id}/candidates/{signature}/publish")
def publish_candidate(run_id: str, signature: str, request: Request) -> dict[str, Any]:
service = _candidate_service(request)
try:
return service.publish(run_id, signature)
except KeyError as exc:
raise HTTPException(status_code=404, detail="mining run or candidate not found") from exc
except FileExistsError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.get("/config")
def get_config() -> dict[str, Any]:
return preferences.get_mining_schedule()
@router.patch("/config")
def update_config(payload: MiningSchedulePatch) -> dict[str, Any]:
current = preferences.get_mining_schedule()
updates = payload.model_dump(exclude_none=True)
if not updates:
raise HTTPException(status_code=400, detail="at least one mining config field is required")
merged = {**current, **updates}
try:
return preferences.set_mining_schedule(
merged["mining_schedule_enabled"],
merged["mining_schedule_weekday"],
merged["mining_budget_profile"],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _manager(request: Request):
manager = getattr(request.app.state, "mining_manager", None)
if manager is None:
raise HTTPException(status_code=503, detail="mining manager is unavailable")
return manager
def _candidate_service(request: Request):
service = getattr(request.app.state, "mining_candidate_service", None)
if service is not None:
return service
from app.backtest.candidates import CandidateStore
from app.services.mining_candidates import MiningCandidateService
manager = _manager(request)
data_dir = request.app.state.repo.store.data_dir
monitor_engine = getattr(request.app.state, "monitor_engine", None)
service = MiningCandidateService(
data_dir,
manager.store,
CandidateStore(data_dir),
request.app.state.strategy_engine,
monitor_state_invalidator=(
monitor_engine.invalidate_strategy_state
if monitor_engine is not None
else None
),
)
request.app.state.mining_candidate_service = service
return service
def _required_manifest(store: MiningRunStore, run_id: str) -> dict[str, Any]:
try:
manifest = store.get(run_id)
except MiningRunValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except MiningRunStoreError as exc:
raise HTTPException(status_code=500, detail="failed to read mining run") from exc
if manifest is None:
raise HTTPException(status_code=404, detail="mining run not found")
return manifest
def _project_run(store: MiningRunStore, manifest: Mapping[str, Any]) -> dict[str, Any]:
run_id = str(manifest["run_id"])
summary = store.read_summary(run_id)
events = store.read_events(run_id)
source = next(
(
event.get("payload", {}).get("source")
for event in events
if event.get("type") == "queued" and event.get("payload", {}).get("source")
),
None,
)
if source is None and isinstance(manifest.get("data_fingerprint"), Mapping):
source = manifest["data_fingerprint"].get("source")
compact = _summary_projection(summary) if manifest["status"] in SUCCESS_RUN_STATUSES else None
return {
"run_id": run_id,
"signature": manifest["run_signature"],
"status": manifest["status"],
"request": manifest.get("request") or {},
"source": source or "manual",
"created_at": manifest.get("created_at"),
"updated_at": manifest.get("updated_at"),
"started_at": manifest.get("started_at"),
"finished_at": manifest.get("finished_at"),
"data_as_of": summary.get("data_as_of"),
"progress": (
summary.get("progress")
if isinstance(summary.get("progress"), Mapping)
else None
),
"error": manifest.get("error"),
"summary": compact,
}
def _request_summary(manifest: Mapping[str, Any]) -> dict[str, Any]:
request = manifest.get("request") or {}
factor_names = request.get("factor_names")
strategy_ids = request.get("strategy_ids")
return {
"asset_type": request.get("asset_type") or "stock",
"budget_profile": request.get("budget_profile") or "balanced",
"start": request.get("start"),
"end": request.get("end"),
"factor_count": len(factor_names) if isinstance(factor_names, list) else 0,
"strategy_count": len(strategy_ids) if isinstance(strategy_ids, list) else 0,
"commission_pct": _finite(request.get("commission_pct")),
"stamp_tax_pct": _finite(request.get("stamp_tax_pct")),
"slippage_bps": _finite(request.get("slippage_bps")),
"correlation_threshold": _finite(request.get("correlation_threshold")),
}
def _summary_projection(summary: Mapping[str, Any]) -> dict[str, Any]:
worker = summary.get("worker") if isinstance(summary.get("worker"), Mapping) else {}
return {
"factor_count": int(summary.get("factor_count") or 0),
"selected_factor_count": int(summary.get("selected_factor_count") or 0),
"candidate_count": int(summary.get("candidate_count") or 0),
"valid_fold_count": int(summary.get("valid_fold_count") or 0),
"skipped_fold_count": int(summary.get("skipped_fold_count") or 0),
"confidence": summary.get("confidence") or "low",
"budget_exhausted": bool(summary.get("budget_exhausted", False)),
"elapsed_ms": _finite(summary.get("elapsed_ms")),
"peak_rss_bytes": _optional_int(worker.get("peak_rss_bytes")),
}
def _read_registered_artifact(
store: MiningRunStore,
manifest: Mapping[str, Any],
name: str,
) -> pl.DataFrame:
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, Mapping) or name not in artifacts:
raise ValueError(f"mining artifact is not registered: {name}")
raw_path = artifacts[name]
if not isinstance(raw_path, str):
raise ValueError(f"mining artifact registration is invalid: {name}")
run_dir = store.artifact_path(str(manifest["run_id"]), name).parent # type: ignore[arg-type]
registered = (run_dir / raw_path).resolve()
if not registered.is_relative_to(run_dir.resolve()):
raise ValueError(f"mining artifact escapes its run directory: {name}")
if registered.suffix.lower() != ".parquet" or not registered.is_file():
raise ValueError(f"mining artifact is unavailable: {name}")
if registered.stat().st_size > _MAX_ARTIFACT_BYTES:
raise ValueError(f"mining artifact exceeds size limit: {name}")
return pl.read_parquet(registered)
def _project_result(
manifest: Mapping[str, Any],
summary: Mapping[str, Any],
frames: Mapping[str, pl.DataFrame],
) -> dict[str, Any]:
factors = [_clean_record(row) for row in frames["factors"].to_dicts()]
correlation = _project_correlation(frames["correlation"])
fold_records = [_project_fold(row) for row in frames["folds"].to_dicts()]
candidates = _project_candidates(frames["candidates"], fold_records)
selected_signature = candidates[0]["signature"] if candidates else None
folds = [
_public_fold(row)
for row in fold_records
if row["regime_state"] == "overall"
and (selected_signature is None or row["candidate_signature"] == selected_signature)
]
regimes = _project_regimes(fold_records, selected_signature)
worker = summary.get("worker") if isinstance(summary.get("worker"), Mapping) else {}
threshold = _finite((manifest.get("request") or {}).get("correlation_threshold"))
correlation["threshold"] = threshold if threshold is not None else 0.75
return {
"run_id": manifest["run_id"],
"methodology_version": summary.get("methodology_version") or "factor_v2",
"algorithm_version": summary.get("algorithm_version") or MINING_ALGORITHM_VERSION,
"data_as_of": summary.get("data_as_of"),
"request_summary": _request_summary(manifest),
"summary": _summary_projection(summary),
"factors": factors,
"correlation": correlation,
"regimes": regimes,
"candidates": candidates,
"folds": folds,
"telemetry": {
"elapsed_ms": _finite(summary.get("elapsed_ms")),
"peak_rss_bytes": _optional_int(worker.get("peak_rss_bytes")),
"panel_scans": _optional_int(summary.get("panel_scans")),
"matrix_bytes": _optional_int(summary.get("matrix_bytes")),
"serialized_result_bytes": _optional_int(worker.get("serialized_result_bytes")),
"phase_ms": _finite_mapping(summary.get("phase_ms")),
},
}
def _project_correlation(frame: pl.DataFrame) -> dict[str, Any]:
required = {"factor_x", "factor_y", "rho", "pair_count"}
if not required.issubset(frame.columns):
raise ValueError("correlation artifact schema is invalid")
labels = sorted(set(frame["factor_x"].to_list()) | set(frame["factor_y"].to_list()))
positions = {str(label): index for index, label in enumerate(labels)}
matrix: list[list[float | None]] = [[None for _ in labels] for _ in labels]
counts: list[list[int | None]] = [[None for _ in labels] for _ in labels]
for row in frame.iter_rows(named=True):
left = positions[str(row["factor_x"])]
right = positions[str(row["factor_y"])]
matrix[left][right] = _finite(row["rho"])
counts[left][right] = _optional_int(row["pair_count"])
return {"labels": labels, "matrix": matrix, "pair_counts": counts}
def _project_fold(row: Mapping[str, Any]) -> dict[str, Any]:
projected = _clean_record(row)
projected["selected_factors"] = _json_string_list(row.get("selected_factors_json"))
projected["candidate_signature"] = row.get("candidate_signature")
projected["regime_state"] = str(row.get("regime_state") or "overall")
projected["n_dates"] = int(row.get("n_dates") or 0)
return projected
def _public_fold(row: Mapping[str, Any]) -> dict[str, Any]:
return {
key: row.get(key)
for key in (
"fold", "label", "train_start", "train_end", "test_start", "test_end",
"selected_factors", "total_return", "sharpe", "max_drawdown", "n_trades",
"skipped", "reason", "evaluation_kind",
)
}
def _project_candidates(
frame: pl.DataFrame,
folds: Sequence[Mapping[str, Any]],
) -> list[dict[str, Any]]:
required = {"signature", "name", "kind", "factor_names_json", "confidence"}
if not required.issubset(frame.columns):
raise ValueError("candidates artifact schema is invalid")
candidates = []
for row in frame.to_dicts():
candidate = _clean_record(row)
candidate.pop("definition_json", None)
candidate.pop("factor_names_json", None)
candidate["factor_names"] = _json_string_list(row.get("factor_names_json"))
signature = str(row["signature"])
candidate["folds"] = [
_public_fold(fold)
for fold in folds
if fold["regime_state"] == "overall"
and fold["candidate_signature"] == signature
]
gate = evaluate_candidate_gate(
confidence=row.get("confidence"),
valid_folds=row.get("valid_folds"),
positive_fold_ratio=row.get("oos_positive_fold_ratio"),
sharpe=row.get("oos_sharpe"),
max_drawdown=row.get("oos_max_drawdown"),
n_trades=row.get("oos_n_trades"),
)
candidate["gate"] = {
"qualified": gate.qualified,
"reasons": list(gate.reasons),
}
candidates.append(candidate)
candidates.sort(
key=lambda item: (
-(item.get("oos_sharpe") if item.get("oos_sharpe") is not None else -math.inf),
str(item["signature"]),
)
)
return candidates
def _project_regimes(
folds: Sequence[Mapping[str, Any]],
signature: str | None,
) -> list[dict[str, Any]]:
labels = {"overall": "整体", "strong": "强势", "range": "震荡", "weak": "弱势"}
result = []
for state in ("overall", "strong", "range", "weak"):
rows = [
row
for row in folds
if row["regime_state"] == state
and (signature is None or row["candidate_signature"] == signature)
and not row.get("skipped")
]
result.append({
"state": state,
"label": labels[state],
"n_dates": sum(int(row.get("n_dates") or 0) for row in rows),
"total_return": _mean(row.get("total_return") for row in rows),
"sharpe": _mean(row.get("sharpe") for row in rows),
"max_drawdown": _minimum(row.get("max_drawdown") for row in rows),
})
return result
def _validate_selected_strategies(
strategy_engine: Any,
strategy_ids: Sequence[str],
asset_type: str,
) -> None:
for strategy_id in strategy_ids:
strategy = strategy_engine.get(strategy_id)
if strategy.meta.get("research_only"):
raise ValueError(f"research template cannot be mined as existing: {strategy_id}")
if strategy.execution_backend != "matrix_native":
raise ValueError(f"mining strategy is not matrix-native: {strategy_id}")
if "1d" not in strategy.meta.get("timeframes", ["1d"]):
raise ValueError(f"mining strategy is not daily-compatible: {strategy_id}")
if asset_type not in strategy.meta.get("asset_types", ["stock"]):
raise ValueError(f"mining strategy does not support {asset_type}: {strategy_id}")
def _event_cursor(value: str | None) -> int:
if value in (None, ""):
return 0
try:
cursor = int(value)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Last-Event-ID must be an integer") from exc
if cursor < 0:
raise HTTPException(status_code=400, detail="Last-Event-ID must be non-negative")
return cursor
def _json_string_list(value: Any) -> list[str]:
if not isinstance(value, str):
return []
parsed = json.loads(value)
if not isinstance(parsed, list) or any(not isinstance(item, str) for item in parsed):
raise ValueError("artifact JSON list is invalid")
return parsed
def _clean_record(row: Mapping[str, Any]) -> dict[str, Any]:
return {
str(key): (_finite(value) if isinstance(value, float) else value)
for key, value in row.items()
}
def _finite(value: Any) -> float | None:
if value is None:
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) else None
def _optional_int(value: Any) -> int | None:
number = _finite(value)
return int(number) if number is not None else None
def _finite_mapping(value: Any) -> dict[str, float] | None:
if not isinstance(value, Mapping):
return None
return {
str(key): number
for key, item in value.items()
if (number := _finite(item)) is not None
}
def _mean(values: Sequence[Any] | Any) -> float | None:
finite = [number for value in values if (number := _finite(value)) is not None]
return sum(finite) / len(finite) if finite else None
def _minimum(values: Sequence[Any] | Any) -> float | None:
finite = [number for value in values if (number := _finite(value)) is not None]
return min(finite) if finite else None