feat(v0.2): 市场阶段与主线识别 + 因子挖掘全链路 + 数据层完善

- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动,
  EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合,
  可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存
- 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档),
  周度调度默认关闭且永不自动发布
- 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益,
  信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错)
- 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复
- 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
This commit is contained in:
shy3130
2026-08-16 23:39:07 +08:00
parent eb869c7ad2
commit 697c27bb02
129 changed files with 20324 additions and 602 deletions
+2 -1
View File
@@ -54,7 +54,7 @@
| :--------------- | :--------------------------------------------------------------------- | :-------------------------------- |
| 🔍 **选股引擎** | 18 个内置策略 + 自定义信号 + AI 生成 + 代码迁移,Polars 毫秒级扫全 A 股 | [strategy.md](./docs/strategy.md) |
| 📊 **指标流水线** | MA/EMA/MACD/RSI/KDJ/布林/量比等,一次扫表落盘 enriched Parquet | [features.md](./docs/features.md) |
| 🧪 **回测引擎** | 三种模式(个股/策略组合/自由信号),T+1/手续费/滑点/止损,SSE 流式进度 | [features.md](./docs/features.md) |
| 🧪 **回测研究** | 因子/策略回测 + 嵌套样本外挖掘,T+1/费用/滑点约束,SSE 持久任务 | [mining.md](./docs/mining.md) |
| 📡 **监控中心** | 四类监控(策略/个股信号/价格/异动),多条件 AND/OR + 语音播报 + 飞书推送 | [features.md](./docs/features.md) |
| 📈 **个股分析** | 9 类关键价位 + AI 四维分析(技术/基本面/财务/消息面) | [features.md](./docs/features.md) |
| 🏆 **连板梯队** | 连板层级统计 + 概念涨幅轮动 + 盘后 AI 复盘 + 炸板/翘板预警 | [features.md](./docs/features.md) |
@@ -238,6 +238,7 @@ PORT=3018 # 服务端口
| [docs/features.md](./docs/features.md) | 各功能模块详细说明(选股/指标/回测/监控/个股分析/数据扩展) |
| [docs/custom-data-source.md](./docs/custom-data-source.md) | 自定义数据源接入、YAML 配置与 mock 联调示例 |
| [docs/strategy.md](./docs/strategy.md) | 策略体系(18 内置策略 + 三种扩展方式 + 文件结构) |
| [docs/mining.md](./docs/mining.md) | 因子与策略挖掘口径、防泄漏、任务隔离和发布边界 |
| [docs/secondary-development.md](./docs/secondary-development.md) | 代码二次开发、前端插槽、后端策略接口与 AI 开发模板 |
| [backend/app/strategy/prompts/strategy-guide.md](./backend/app/strategy/prompts/strategy-guide.md) | 策略开发完整规范(AI 生成与手写) |
+55 -28
View File
@@ -381,7 +381,10 @@ def strategy_run(req: StrategyBacktestRequest, request: Request):
regime_filter=req.regime_filter,
)
task = make_worker_task("backtest", settings.data_dir, cfg)
return run_worker_task(task)
from app.services.heavy_job_limiter import shared_heavy_job_limiter
with shared_heavy_job_limiter.slot("normal"):
return run_worker_task(task)
# ── SSE 流式回测 (实时进度 + 可取消 + 支持重连) ───────────────────
@@ -409,10 +412,6 @@ _running_jobs: dict[str, _BacktestJob] = {}
_jobs_lock = threading.Lock()
_JOB_TTL = 300 # 完成后保留 5 分钟
# 并发回测上限: 多个重回测同时跑会 OOM (服务器内存约 1.8GB)。用信号量限并发,
# 超出的任务在 _run_backtest 里排队, SSE 连接照常保持, run 一开始就有进度。
_backtest_semaphore = threading.Semaphore(2)
def _cleanup_stale_jobs():
"""清理过期任务 (完成超过 TTL 的)。全程持 _jobs_lock: 迭代+pop 与其他访问互斥。"""
@@ -588,21 +587,27 @@ async def strategy_stream(
)
def _run_backtest():
# 信号量限并发: 超额任务在此阻塞排队, 不并发吃满内存 (等待期间 cancel_event
# 仍可置位, svc.run 会据此提前返回 cancelled)。持槽跑完在 finally 释放。
_backtest_semaphore.acquire()
from app.services.heavy_job_limiter import (
HeavyJobCancelledError,
shared_heavy_job_limiter,
)
try:
task = make_worker_task("backtest", settings.data_dir, cfg)
result = run_worker_task(
task,
lambda d: job.progress.append(d),
job.cancel_event,
)
with shared_heavy_job_limiter.slot(
"normal",
cancel_event=job.cancel_event,
):
task = make_worker_task("backtest", settings.data_dir, cfg)
result = run_worker_task(
task,
lambda d: job.progress.append(d),
job.cancel_event,
)
_finish_job(job, result=result)
except HeavyJobCancelledError:
_finish_job(job, error="回测已取消")
except Exception as e:
_finish_job(job, error=str(e))
finally:
_backtest_semaphore.release()
# 启动后台线程 (不阻塞事件循环)
threading.Thread(target=_run_backtest, daemon=True).start()
@@ -886,14 +891,25 @@ async def optimize_stream(
)
def _run_opt():
from app.services.heavy_job_limiter import (
HeavyJobCancelledError,
shared_heavy_job_limiter,
)
try:
task = make_worker_task("optimize", settings.data_dir, ocfg)
result = run_worker_task(
task,
lambda d: job.progress.append(d),
job.cancel_event,
)
with shared_heavy_job_limiter.slot(
"normal",
cancel_event=job.cancel_event,
):
task = make_worker_task("optimize", settings.data_dir, ocfg)
result = run_worker_task(
task,
lambda d: job.progress.append(d),
job.cancel_event,
)
_finish_job(job, result=result)
except HeavyJobCancelledError:
_finish_job(job, error="优化已取消")
except Exception as e:
_finish_job(job, error=str(e))
@@ -1101,14 +1117,25 @@ async def walkforward_stream(
)
def _run_wf():
from app.services.heavy_job_limiter import (
HeavyJobCancelledError,
shared_heavy_job_limiter,
)
try:
task = make_worker_task("walkforward", settings.data_dir, wf_cfg)
result = run_worker_task(
task,
lambda d: job.progress.append(d),
job.cancel_event,
)
with shared_heavy_job_limiter.slot(
"normal",
cancel_event=job.cancel_event,
):
task = make_worker_task("walkforward", settings.data_dir, wf_cfg)
result = run_worker_task(
task,
lambda d: job.progress.append(d),
job.cancel_event,
)
_finish_job(job, result=result)
except HeavyJobCancelledError:
_finish_job(job, error="walk-forward 已取消")
except Exception as e:
_finish_job(job, error=str(e))
+24 -12
View File
@@ -11,6 +11,7 @@ from typing import Any, Callable
from fastapi import APIRouter, Request
from app.enriched_generation import EnrichedPublication
from app.indicators.pipeline import ENRICHED_COLUMNS
logger = logging.getLogger(__name__)
@@ -504,26 +505,21 @@ def _compute_storage(data_dir: Path) -> dict:
stats[f"{key}_files"] = fc
stats[f"{key}_size_mb"] = sz
# total: 再加上其他零散文件(pools, financials, capabilities.json 等)
other_dirs = ["pools", "financials", "backtest_results", "screener_results", "ai_cache"]
# total: 再加上其他零散目录 (financials 有下方专属明细统计, 不在此列)
other_dirs = ["pools", "backtest_results", "screener_results", "ai_cache"]
for name in other_dirs:
d = data_dir / name
if d.exists():
_, s = _scan_dir_stats(d)
total_size += s
# financials 单独统计
# financials 单独统计 (明细与 total 各计入一次, 不得与其他目录循环重复累加)
fin_dir = data_dir / "financials"
if fin_dir.exists():
fc, sz = _scan_dir_stats(fin_dir)
stats["financials_files"] = fc
stats["financials_size_mb"] = sz
total_size += sz
for name in other_dirs:
d = data_dir / name
if d.exists():
_, s = _scan_dir_stats(d)
total_size += s
# 根目录散文件
for entry in os.scandir(data_dir):
if entry.is_file(follow_symlinks=False):
@@ -626,6 +622,10 @@ def clear_data(request: Request):
repo = request.app.state.repo
data_dir = repo.store.data_dir
deleted = 0
publications = {
"kline_daily_enriched": EnrichedPublication(data_dir, "stock"),
"kline_etf_enriched": EnrichedPublication(data_dir, "etf"),
}
for sub in (
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched",
@@ -634,15 +634,27 @@ def clear_data(request: Request):
"backtest_results", "screener_results", "ai_cache",
):
d = data_dir / sub
if d.exists():
# 先删所有 parquet 文件
for f in d.rglob("*.parquet"):
if not d.exists():
continue
publication = publications.get(sub)
parquet_files = list(d.rglob("*.parquet"))
if publication is not None and parquet_files:
publication.begin()
try:
for f in parquet_files:
f.unlink()
deleted += 1
# 再删除空的日期分区子目录(date=YYYY-MM-DD 等)
if publication is not None:
publication.mark_changed()
for child in list(d.iterdir()):
if child.is_dir():
shutil.rmtree(child, ignore_errors=True)
if publication is not None:
publication.commit()
except BaseException:
if publication is not None:
publication.abandon()
raise
# 清除同步历史(内存 + 磁盘 job_store/ 文件夹)
from app.services.pipeline_jobs import job_store
+44 -56
View File
@@ -12,7 +12,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
from app.indicators.pipeline import compute_enriched, compute_enriched_single
from app.market_time import cn_now, cn_today
from app.price_limits import is_risk_warning_name, price_limit_pct
from app.db_safe import is_valid_ext_ident, quote_ident
from app.db_safe import is_valid_ext_ident
from app.services import kline_sync
logger = logging.getLogger(__name__)
@@ -175,21 +175,28 @@ def instruments_names(request: Request, symbols: list[str]):
def _get_stock_info(repo, symbol: str) -> dict:
"""从 instruments 视图查标的名称 + 股本。"""
"""从 instruments 内存缓存查标的名称 + 股本。
该接口在个股弹窗打开时每秒被调用 (SSE invalidate 触发重拉), 走
repo.get_instruments() 的 Polars 内存缓存按 symbol 过滤, 不再每请求
DuckDB 扫 instruments parquet。列缺失时返回空 dict, 与旧 SQL 报错路径一致。
"""
import polars as pl
try:
row = repo.execute_one(
"SELECT name, total_shares, float_shares FROM instruments WHERE symbol = ? LIMIT 1",
[symbol],
)
df = repo.get_instruments()
needed = ("symbol", "name", "total_shares", "float_shares")
if df.is_empty() or not all(c in df.columns for c in needed):
return {}
hit = df.filter(pl.col("symbol") == symbol).head(1)
if hit.is_empty():
return {}
return {
"name": hit["name"][0],
"total_shares": hit["total_shares"][0],
"float_shares": hit["float_shares"][0],
}
except Exception: # noqa: BLE001
return {}
if not row:
return {}
return {
"name": row[0],
"total_shares": row[1],
"float_shares": row[2],
}
def _get_asset_info(repo, symbol: str, asset_type: str) -> dict:
@@ -377,7 +384,8 @@ def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> di
"""按 ext_columns 规格为单只股票 LEFT JOIN 扩展数据,平铺到 stock_info['ext']。
key 形如 "{config_id}__{field_name}",与自选列表 enriched 接口保持一致。
JOIN 逻辑参考 watchlist.watchlist_enriched;任何 ext 表/字段缺失都静默跳过。
委托 screener._load_ext_value_maps 取值: 复用其 (路径,mtime) 签名缓存,
个股弹窗每秒重拉时不再重复读 ext parquet; 任何 ext 表/字段缺失都静默跳过。
"""
if not ext_columns or not ext_columns.strip():
return resp
@@ -394,43 +402,17 @@ def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> di
if not specs:
return resp
import polars as pl
data_dir = repo.store.data_dir
try:
from app.services.ext_data import ExtConfigStore
from app.api.ext_data import _read_ext_dataframe
ext_store = ExtConfigStore(data_dir)
configs = {c.id: c for c in ext_store.load_all()}
from app.api.screener import _load_ext_value_maps
value_maps = _load_ext_value_maps(repo, ext_columns)
except Exception: # noqa: BLE001
configs = {}
value_maps = {}
ext_values: dict = {}
for config_id, field_name in specs:
ext_col_name = f"{config_id}__{field_name}"
value = None
try:
cfg = configs.get(config_id)
if cfg:
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
else:
ext_df = pl.from_arrow(
repo.store.db.query(
f"SELECT symbol, {quote_ident(field_name)} FROM ext_{config_id}"
).arrow()
)
if not ext_df.is_empty() and "symbol" in ext_df.columns and field_name in ext_df.columns:
# 时序表取最新分区,避免一个 symbol 多行
row = (
ext_df
.select(["symbol", field_name])
.unique(subset=["symbol"], keep="last")
.filter(pl.col("symbol") == symbol)
)
if not row.is_empty():
value = row[field_name][0]
except Exception as e: # noqa: BLE001
logger.debug("kline ext join failed for %s.%s: %s", config_id, field_name, e)
ext_values[ext_col_name] = value
vmap = value_maps.get(ext_col_name) or {}
ext_values[ext_col_name] = vmap.get(symbol)
stock_info = dict(resp.get("stock_info") or {})
stock_info["ext"] = ext_values
@@ -575,12 +557,13 @@ def get_daily_batch(request: Request, body: dict):
return {"data": {}}
df = pl.concat(frames, how="diagonal_relaxed")
# 按 symbol 分组, 每只取最近 N 条
# 按 symbol 分组, 每只取最近 N 条
# partition_by 一次切分, 避免 N 只自选时对同一批数据做 N 次全帧过滤。
result: dict[str, list[dict]] = {}
for sym in symbols:
sub = df.filter(pl.col("symbol") == sym).sort("date").tail(days)
for part in df.partition_by("symbol", maintain_order=True):
sub = part.sort("date").tail(days)
if not sub.is_empty():
result[sym] = sub.to_dicts()
result[sub["symbol"][0]] = sub.to_dicts()
return {"data": result}
@@ -662,14 +645,15 @@ def get_minute_batch(request: Request, body: dict):
else:
expected = 240
# 按 symbol 分组, 判定哪些不完整需要补拉
# 按 symbol 分组, 判定哪些不完整需要补拉 (partition_by 一次切分, 同 daily-batch)
result: dict[str, list[dict]] = {}
incomplete: list[str] = []
local_parts: dict[str, pl.DataFrame] = {}
if not df_local.is_empty():
for part in df_local.partition_by("symbol", maintain_order=True):
local_parts[part["symbol"][0]] = part.sort("datetime")
for sym in symbols:
if df_local.is_empty():
sub = pl.DataFrame()
else:
sub = df_local.filter(pl.col("symbol") == sym).sort("datetime")
sub = local_parts.get(sym, pl.DataFrame())
if expected > 0 and (sub.is_empty() or len(sub) < expected * 0.9):
incomplete.append(sym)
elif not sub.is_empty():
@@ -712,9 +696,13 @@ def get_minute_batch(request: Request, body: dict):
live_parts.append(df_e)
if live_parts:
live_df = pl.concat(live_parts, how="diagonal_relaxed")
live_map: dict[str, pl.DataFrame] = {
part["symbol"][0]: part.sort("datetime")
for part in live_df.partition_by("symbol", maintain_order=True)
}
for sym in incomplete:
sub = live_df.filter(pl.col("symbol") == sym).sort("datetime")
if not sub.is_empty():
sub = live_map.get(sym)
if sub is not None and not sub.is_empty():
result[sym] = sub.to_dicts()
return {"data": result}
+736
View File
@@ -0,0 +1,736 @@
"""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.factor import FACTOR_COLUMNS
from app.backtest.mining import (
MAX_BEAM_WIDTH,
MAX_COMBINATION_SIZE,
MAX_FINALISTS,
evaluate_candidate_gate,
)
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 (
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"])
_FACTOR_IDS = frozenset(str(item["id"]) for item in FACTOR_COLUMNS)
_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) - _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
@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 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.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
+201 -2
View File
@@ -7,8 +7,9 @@ from __future__ import annotations
import threading
import time
from datetime import date
from typing import Any
from typing import Annotated, Any
import polars as pl
from fastapi import APIRouter, Query, Request
from app.services import regime_builder
@@ -142,6 +143,8 @@ def regime_recompute(request: Request, start: date | None = None, end: date | No
与 daily_pipeline 的增量补差(compute_regime_incremental)不同 —— 此接口面向
人工「我要重新算一遍」的预期, 必须真正重算而非增量补缺口。
- 传 start: 仅重算 [start, end] 区间。
- 重算后统一重标情绪周期阶段(refresh_phase_labels)并回填主线
(概念+行业, 概念成分为当前快照回看历史, 早年有归属漂移)。
"""
repo = request.app.state.repo
data_dir = _data_dir(request)
@@ -156,5 +159,201 @@ def regime_recompute(request: Request, start: date | None = None, end: date | No
new_rows = regime_builder.run_regime_batch(repo, start=start, end=end)
if not new_rows.is_empty():
regime_builder.upsert_regime_history(data_dir, new_rows)
phase_days = regime_builder.refresh_phase_labels(data_dir)
from app.services import market_mainline
mainline_rows = 0
for kind in ("concept", "industry"):
rows = market_mainline.compute_mainline_range(repo, data_dir, start, end, kind=kind)
if not rows.is_empty():
market_mainline.upsert_mainline_history(data_dir, rows)
mainline_rows += rows.height
invalidate_regime_cache()
return {"ok": True, "computed": new_rows.height if not new_rows.is_empty() else 0}
return {
"ok": True,
"computed": new_rows.height if not new_rows.is_empty() else 0,
"phase_days": phase_days,
"mainline_rows": mainline_rows,
}
@router.get("/phases")
def regime_phases(
request: Request,
start: date | None = None,
end: date | None = None,
):
"""情绪周期阶段段列表: 连续同阶段合段, 附段内均值指标与主导主线。
直接回答「什么阶段走什么主升」: 主升/高潮段的主导主线即该段行情主线。
主线按段内进入当日 top5 的天数与累计分排序, 取前 3。
"""
from app.services.market_mainline import load_mainline_history
from app.services.market_phase import PHASE_LABELS
data_dir = _data_dir(request)
df = regime_builder.load_regime_history(data_dir)
if df.is_empty() or "phase" not in df.columns:
return {"segments": [], "total": 0}
if start:
df = df.filter(pl_col_date(df, ">=", start))
if end:
df = df.filter(pl_col_date(df, "<=", end))
df = df.sort("date")
if df.is_empty():
return {"segments": [], "total": 0}
mainline = load_mainline_history(data_dir, "concept")
segments: list[dict] = []
cur: dict | None = None
for r in df.iter_rows(named=True):
phase = r.get("phase")
if cur is None or cur["phase"] != phase:
cur = {
"phase": phase,
"label": PHASE_LABELS.get(phase, phase),
"start": str(r["date"]),
"end": str(r["date"]),
"days": 0,
"_height": 0.0,
"_first_board": 0.0,
"_ge2": 0.0,
"_promo_sum": 0.0,
"_promo_n": 0,
"_seal": 0.0,
}
segments.append(cur)
cur["end"] = str(r["date"])
cur["days"] += 1
cur["_height"] += float(r.get("max_consecutive") or 0)
cur["_first_board"] += float(r.get("first_board") or 0)
cur["_ge2"] += float(r.get("ge2_count") or 0)
promo = r.get("promo_rate")
if promo is not None:
cur["_promo_sum"] += float(promo)
cur["_promo_n"] += 1
cur["_seal"] += float(r.get("seal_rate") or 0)
for seg in segments:
n = seg["days"]
seg["avg_height"] = round(seg.pop("_height") / n, 1)
seg["avg_first_board"] = round(seg.pop("_first_board") / n, 1)
seg["avg_ge2"] = round(seg.pop("_ge2") / n, 1)
seg["avg_promo"] = (
round(seg.pop("_promo_sum") / seg["_promo_n"], 3) if seg["_promo_n"] else None
)
seg.pop("_promo_n")
seg["avg_seal_rate"] = round(seg.pop("_seal") / n, 3)
seg["top_mainlines"] = _segment_mainlines(
mainline, date.fromisoformat(seg["start"]), date.fromisoformat(seg["end"])
)
return {"segments": segments, "total": len(segments)}
def _segment_mainlines(mainline: pl.DataFrame, start: date, end: date, top: int = 3) -> list[dict]:
"""段内主导主线: 按进入当日 top5 的天数与累计分排序。"""
if mainline.is_empty():
return []
seg = mainline.filter(
(pl.col("date") >= start) & (pl.col("date") <= end) & (pl.col("rank") <= 5)
)
if seg.is_empty():
return []
ranked = (
seg.group_by("member")
.agg(
pl.col("date").n_unique().alias("top5_days"),
pl.col("score").sum().alias("score_sum"),
pl.col("max_boards").max().alias("max_boards"),
pl.col("leader_symbol").first().alias("leader_symbol"),
)
.sort(["top5_days", "score_sum"], descending=[True, True])
.head(top)
)
return [
{
"member": r["member"],
"top5_days": r["top5_days"],
"score_sum": round(r["score_sum"], 1),
"max_boards": r["max_boards"],
"leader_symbol": r["leader_symbol"],
}
for r in ranked.to_dicts()
]
@router.post("/mainline/recompute")
def mainline_recompute(request: Request):
"""全量重算主线(概念+行业), 应用当前过滤配置。窄扫描, 秒级。
修改过滤配置(preferences mainline-filter)后调用本接口生效,
无需触发较重的 regime 全量重算。
"""
from app.services import market_mainline
repo = request.app.state.repo
data_dir = _data_dir(request)
earliest = regime_builder.earliest_enriched_date(repo)
if earliest is None:
return {"ok": True, "rows": 0}
rows = 0
for kind in ("concept", "industry"):
computed = market_mainline.compute_mainline_range(
repo, data_dir, earliest, date.today(), kind=kind
)
if not computed.is_empty():
market_mainline.upsert_mainline_history(data_dir, computed)
rows += computed.height
return {"ok": True, "rows": rows}
@router.get("/mainline")
def regime_mainline(
request: Request,
start: date | None = None,
end: date | None = None,
top: Annotated[int, Query(ge=1, le=30)] = 10,
kind: Annotated[str, Query(pattern="^(concept|industry)$")] = "concept",
):
"""每日主线排行(截 rank<=top) + 窗口内持续性汇总。
membership_note 说明概念成分口径(当前快照回看历史)。
"""
from app.services.market_mainline import MEMBERSHIP_NOTE, load_mainline_history
try:
from app.services import preferences
filter_cfg = preferences.get_mainline_filter_config()
except Exception:
filter_cfg = {"min_members": 4, "max_members": 600, "blacklist": []}
df = load_mainline_history(_data_dir(request), kind)
if df.is_empty():
return {"rows": [], "leaders": [], "membership_note": MEMBERSHIP_NOTE, "filter": filter_cfg}
if start:
df = df.filter(pl_col_date(df, ">=", start))
if end:
df = df.filter(pl_col_date(df, "<=", end))
df = df.sort(["date", "rank"])
rows_df = df.filter(pl.col("rank") <= top)
leaders = (
df.filter(pl.col("rank") == 1)
.group_by("member")
.agg(
pl.col("date").n_unique().alias("top1_days"),
pl.col("score").mean().round(1).alias("avg_score"),
pl.col("max_boards").max().alias("max_boards"),
)
.sort(["top1_days", "avg_score"], descending=[True, True])
.head(10)
)
return {
"rows": _df_to_records(rows_df),
"leaders": leaders.to_dicts(),
"membership_note": MEMBERSHIP_NOTE,
"filter": filter_cfg,
}
+14 -3
View File
@@ -238,6 +238,8 @@ def strategies(
raise HTTPException(status_code=503, detail="策略引擎未初始化")
presets = []
for meta in engine.list_strategies():
if meta.get("research_only"):
continue
if asset_type not in meta.get("asset_types", ["stock"]):
continue
if timeframe not in meta.get("timeframes", ["1d"]):
@@ -292,6 +294,8 @@ def run_preset(req: PresetRequest, request: Request):
try:
if not engine.has(req.strategy_id):
raise ValueError(f"unknown strategy: {req.strategy_id}")
if engine.get(req.strategy_id).meta.get("research_only"):
raise ValueError(f"unknown strategy: {req.strategy_id}")
params = dict(overrides.get("params") or {})
context = svc.build_strategy_context(
engine,
@@ -520,14 +524,19 @@ def run_all(request: Request, body: Optional[dict] = None):
requested_ids = body.get("strategy_ids")
if requested_ids and isinstance(requested_ids, list):
all_ids = [str(sid) for sid in requested_ids]
unknown = [sid for sid in all_ids if not engine.has(sid)]
unknown = [
sid
for sid in all_ids
if not engine.has(sid) or engine.get(sid).meta.get("research_only")
]
if unknown:
raise HTTPException(status_code=404, detail=f"unknown strategies: {unknown}")
else:
all_ids = [
meta["id"]
for meta in engine.list_strategies()
if asset_type in meta.get("asset_types", ["stock"])
if not meta.get("research_only")
and asset_type in meta.get("asset_types", ["stock"])
and timeframe in meta.get("timeframes", ["1d"])
]
@@ -707,7 +716,9 @@ def limit_ladder(
sealed_ready = False
sealed_age: float | None = None
if depth_svc:
sealed_map = depth_svc.get_sealed_map(as_of, is_down=is_down)
# 复用上方双方向计数已读取的 sealed map: 同一请求、同一 as_of、同一对象,
# 不再第三次读取 (内存路径含全量浅拷贝, parquet 路径含整文件读)。
sealed_map = down_map if is_down else up_map
sealed_ready = bool(sealed_map) and depth_svc.is_sealed_ready(as_of)
sealed_age = depth_svc.get_sealed_age(as_of) if sealed_ready else None
+40 -1
View File
@@ -6,9 +6,10 @@ from __future__ import annotations
import logging
import time
from typing import Literal
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from app import secrets_store
from app.data_providers.custom.config import MAX_TIMEOUT
@@ -427,6 +428,14 @@ class CustomSourceTestIn(BaseModel):
config: CustomSourceIn | None = None
class MiningSchedulePrefs(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
mining_schedule_enabled: bool
mining_schedule_weekday: int = Field(ge=0, le=4)
mining_budget_profile: Literal["balanced", "strict"]
@router.get("/preferences")
def get_preferences() -> dict:
"""返回用户偏好设置。"""
@@ -485,6 +494,7 @@ def get_preferences() -> dict:
"depth_finalize_time": preferences.get_depth_finalize_time(),
"review_schedule": preferences.get_review_schedule(),
"review_push_channels": preferences.get_review_push_channels(),
**preferences.get_mining_schedule(),
}
@@ -657,6 +667,18 @@ def update_data_source_job_timeouts(req: DataSourceJobTimeoutPrefs) -> dict:
return req.model_dump()
@router.put("/preferences/mining-schedule")
def update_mining_schedule(req: MiningSchedulePrefs) -> dict:
"""一次更新周度自动 mining 配置。"""
from app.services import preferences
return preferences.set_mining_schedule(
req.mining_schedule_enabled,
req.mining_schedule_weekday,
req.mining_budget_profile,
)
@router.get("/preferences/watchlist-columns")
def get_watchlist_columns() -> dict:
"""返回自选列表列配置。"""
@@ -927,6 +949,23 @@ class PipelineIndexSymbolsIn(BaseModel):
symbols: str = ""
class MainlineFilterIn(BaseModel):
"""市场主线过滤配置(宽基/风格标签按成员数过滤 + 名称黑名单)。"""
min_members: int | None = None
max_members: int | None = None
blacklist: list[str] | str | None = None
@router.put("/preferences/mainline-filter")
def update_mainline_filter(req: MainlineFilterIn) -> dict:
"""更新市场主线过滤配置。部分更新; 修改后需重算主线(POST /api/regime/mainline/recompute)生效。"""
from app.services import preferences
payload = req.model_dump()
return preferences.set_mainline_filter_config(payload)
@router.put("/preferences/pipeline-index-symbols")
def update_pipeline_index_symbols(req: PipelineIndexSymbolsIn) -> dict:
"""保存指数自定义拉取代码。"""
+25 -20
View File
@@ -43,6 +43,16 @@ def _get_engine(request: Request) -> StrategyEngine:
return engine
def _get_public_strategy(engine: StrategyEngine, strategy_id: str) -> StrategyDef:
try:
strategy = engine.get(strategy_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
if strategy.meta.get("research_only"):
raise HTTPException(status_code=404, detail=f"unknown strategy: {strategy_id}")
return strategy
def _get_monitor(request: Request) -> StrategyMonitorService:
mon = getattr(request.app.state, "strategy_monitor", None)
if not mon:
@@ -288,6 +298,8 @@ def list_strategies(
result = []
for meta in engine.list_strategies():
if meta.get("research_only"):
continue
if asset_type and asset_type not in meta.get("asset_types", ["stock"]):
continue
if timeframe and timeframe not in meta.get("timeframes", ["1d"]):
@@ -302,10 +314,7 @@ def list_strategies(
@router.get("/{strategy_id}")
def get_strategy(strategy_id: str, request: Request):
engine = _get_engine(request)
try:
s = engine.get(strategy_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
s = _get_public_strategy(engine, strategy_id)
overrides = strategy_config.load_override(_data_dir(request), strategy_id)
return _strategy_detail(s, overrides or None, engine)
@@ -316,6 +325,7 @@ def get_strategy(strategy_id: str, request: Request):
@router.post("/run")
def run_strategy(req: RunRequest, request: Request):
engine = _get_engine(request)
_get_public_strategy(engine, req.strategy_id)
data_dir = _data_dir(request)
# 读取用户覆盖配置
@@ -377,7 +387,8 @@ def run_all(req: RunAllRequest, request: Request):
strategy_ids = [
meta["id"]
for meta in engine.list_strategies()
if req.asset_type in meta.get("asset_types", ["stock"])
if not meta.get("research_only")
and req.asset_type in meta.get("asset_types", ["stock"])
and req.timeframe in meta.get("timeframes", ["1d"])
]
from app.services.screener import ScreenerService
@@ -412,8 +423,7 @@ def run_all(req: RunAllRequest, request: Request):
@router.post("/config")
def save_config(req: SaveConfigRequest, request: Request):
engine = _get_engine(request)
if not engine.has(req.strategy_id):
raise HTTPException(status_code=404, detail=f"策略 {req.strategy_id} 不存在")
_get_public_strategy(engine, req.strategy_id)
_validate_scoring_config(req.overrides)
# 剥离与策略默认值相同的字段,只保存用户真正修改过的值
@@ -426,8 +436,7 @@ def save_config(req: SaveConfigRequest, request: Request):
@router.patch("/config")
def patch_config(req: SaveConfigRequest, request: Request):
engine = _get_engine(request)
if not engine.has(req.strategy_id):
raise HTTPException(status_code=404, detail=f"策略 {req.strategy_id} 不存在")
_get_public_strategy(engine, req.strategy_id)
data_dir = _data_dir(request)
overrides = strategy_config.load_override(data_dir, req.strategy_id)
overrides.update(req.overrides)
@@ -491,6 +500,7 @@ def _strip_defaults(strategy_id: str, overrides: dict, engine) -> dict:
@router.delete("/config/{strategy_id}")
def reset_config(strategy_id: str, request: Request):
_get_public_strategy(_get_engine(request), strategy_id)
strategy_config.delete_override(_data_dir(request), strategy_id)
return {"ok": True}
@@ -755,10 +765,7 @@ def get_strategy_source(strategy_id: str, request: Request):
# 先查 StrategyEngine 获取文件路径
engine = _get_engine(request)
try:
s = engine.get(strategy_id)
except ValueError:
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
s = _get_public_strategy(engine, strategy_id)
path = s.file_path
if not path or not path.exists():
@@ -964,14 +971,12 @@ def _save_composite_strategy(req: StrategyCompositeSaveRequest, request: Request
children = [{"strategy_id": c.strategy_id, "weight": c.weight} for c in req.children]
# 子策略存在性预检(给出清晰错误, 而非等到 reload 后孤儿移除的笼统报错)。
for c in children:
if not engine.has(c["strategy_id"]):
raise ValueError(f"子策略 {c['strategy_id']!r} 不存在")
try:
child_def = engine.get(c["strategy_id"])
if child_def.execution_backend == "composite":
raise ValueError(f"子策略 {c['strategy_id']!r} 也是叠加策略; 首版禁止嵌套叠加")
except ValueError:
raise
child_def = _get_public_strategy(engine, c["strategy_id"])
except HTTPException as exc:
raise ValueError(f"子策略 {c['strategy_id']!r} 不存在") from exc
if child_def.execution_backend == "composite":
raise ValueError(f"子策略 {c['strategy_id']!r} 也是叠加策略; 首版禁止嵌套叠加")
code = _render_composite_code(
sid, req.name, req.description, children, req.merge_mode, req.min_confirm
+1 -1
View File
@@ -212,7 +212,7 @@ def clear_all():
# 自选页需要的列
_WATCHLIST_COLS = [
"symbol", "close", "change_pct", "change_amount", "amount",
"symbol", "close", "open", "high", "low", "change_pct", "change_amount", "amount",
"turnover_rate",
"amplitude", "annual_vol_20d",
"vol_ratio_5d",
+170 -21
View File
@@ -1,4 +1,5 @@
"""量化研究候选方案的轻量本地存储。"""
from __future__ import annotations
import json
@@ -17,28 +18,108 @@ MAX_NAME_LENGTH = 80
MAX_PAYLOAD_BYTES = 32 * 1024
MAX_FILE_BYTES = 2 * 1024 * 1024
_MINING_SOURCE_CONFIG_FIELDS = frozenset(
{
"origin_run_id",
"candidate_signature",
"regime_state",
"algorithm_version",
"methodology_version",
}
)
_CONFIG_FIELDS: dict[str, frozenset[str]] = {
"factor": frozenset({
"factor_name", "symbols", "start", "end", "n_groups", "rebalance", "weight",
"fees_pct", "slippage_bps", "asset_type",
}),
"strategy": frozenset({
"strategy_id", "symbols", "start", "end", "params", "overrides", "matching",
"entry_fill", "exit_fill", "fees_pct", "commission_pct", "stamp_tax_pct",
"slippage_bps", "max_positions", "max_exposure_pct", "initial_capital",
"position_sizing", "mode", "holding_days", "asset_type", "minute_fill",
"regime_filter",
}),
"factor": frozenset(
{
"factor_name",
"symbols",
"start",
"end",
"n_groups",
"rebalance",
"weight",
"fees_pct",
"slippage_bps",
"asset_type",
}
)
| _MINING_SOURCE_CONFIG_FIELDS,
"strategy": frozenset(
{
"strategy_id",
"symbols",
"start",
"end",
"params",
"overrides",
"matching",
"entry_fill",
"exit_fill",
"fees_pct",
"commission_pct",
"stamp_tax_pct",
"slippage_bps",
"max_positions",
"max_exposure_pct",
"initial_capital",
"position_sizing",
"mode",
"holding_days",
"asset_type",
"minute_fill",
"regime_filter",
"factor_names",
"directions",
"weights",
}
)
| _MINING_SOURCE_CONFIG_FIELDS,
}
_MINING_METRIC_FIELDS = frozenset(
{
"oos_sharpe",
"oos_return",
"oos_max_drawdown",
"oos_positive_fold_ratio",
"oos_n_trades",
"valid_folds",
"skipped_folds",
"confidence",
"coverage",
"turnover",
"long_short_sharpe",
}
)
_METRIC_FIELDS: dict[str, frozenset[str]] = {
"factor": frozenset({
"ic_mean", "ic_std", "ir", "ic_win_rate", "long_short_return",
"long_short_max_drawdown", "n_symbols", "n_dates", "elapsed_ms",
}),
"strategy": frozenset({
"total_return", "annual_return", "max_drawdown", "sharpe", "sortino", "win_rate",
"n_trades", "profit_factor", "avg_return", "median_return", "elapsed_ms",
}),
"factor": frozenset(
{
"ic_mean",
"ic_std",
"ir",
"ic_win_rate",
"long_short_return",
"long_short_max_drawdown",
"n_symbols",
"n_dates",
"elapsed_ms",
}
)
| _MINING_METRIC_FIELDS,
"strategy": frozenset(
{
"total_return",
"annual_return",
"max_drawdown",
"sharpe",
"sortino",
"win_rate",
"n_trades",
"profit_factor",
"avg_return",
"median_return",
"elapsed_ms",
}
)
| _MINING_METRIC_FIELDS,
}
_lock = threading.RLock()
@@ -98,6 +179,70 @@ class CandidateStore:
self._write(items)
return item
def create_or_get_by_provenance(
self,
*,
origin_run_id: str,
candidate_signature: str,
kind: CandidateKind,
name: str,
source_id: str,
config: dict[str, Any],
metrics: dict[str, Any],
data_as_of: str | None,
status: CandidateStatus = "pending",
) -> dict[str, Any]:
"""Atomically return or create one item for a mining run candidate."""
clean_name = self._validate_name(name)
clean_source_id = source_id.strip()
if not clean_source_id or len(clean_source_id) > 120:
raise CandidateValidationError("候选来源标识不能为空且不能超过 120 个字符")
clean_config = self._validate_config(kind, config)
clean_metrics = self._validate_metrics(kind, metrics)
if (
clean_config.get("origin_run_id") != origin_run_id
or clean_config.get("candidate_signature") != candidate_signature
):
raise CandidateValidationError("候选来源与配置中的挖掘溯源不一致")
with _lock:
items = self._load()
for item in items:
item_config = item.get("config") or {}
if (
item_config.get("origin_run_id") == origin_run_id
and item_config.get("candidate_signature") == candidate_signature
):
if (
item.get("kind") == kind
and item.get("source_id") == clean_source_id
and item_config == clean_config
and item.get("metrics") == clean_metrics
and item.get("data_as_of") == data_as_of
):
return item
raise CandidateValidationError(
"相同挖掘溯源的候选内容冲突, 已停止覆盖"
)
if len(items) >= MAX_CANDIDATES:
raise CandidateValidationError(f"候选方案最多保存 {MAX_CANDIDATES}")
now = datetime.now(UTC).isoformat()
item = {
"id": uuid.uuid4().hex,
"kind": kind,
"name": clean_name,
"source_id": clean_source_id,
"config": clean_config,
"metrics": clean_metrics,
"data_as_of": data_as_of,
"status": status,
"created_at": now,
"updated_at": now,
}
items.insert(0, item)
self._write(items)
return item
def update(
self,
candidate_id: str,
@@ -203,7 +348,9 @@ class CandidateStore:
def _validate_config(kind: CandidateKind, config: dict[str, Any]) -> dict[str, Any]:
unknown = set(config) - _CONFIG_FIELDS[kind]
if unknown:
raise CandidateValidationError(f"候选配置包含不允许的字段: {', '.join(sorted(unknown))}")
raise CandidateValidationError(
f"候选配置包含不允许的字段: {', '.join(sorted(unknown))}"
)
CandidateStore._check_json_size(config)
return config
@@ -211,7 +358,9 @@ class CandidateStore:
def _validate_metrics(kind: CandidateKind, metrics: dict[str, Any]) -> dict[str, Any]:
unknown = set(metrics) - _METRIC_FIELDS[kind]
if unknown:
raise CandidateValidationError(f"候选指标包含不允许的字段: {', '.join(sorted(unknown))}")
raise CandidateValidationError(
f"候选指标包含不允许的字段: {', '.join(sorted(unknown))}"
)
if any(isinstance(value, (dict, list)) for value in metrics.values()):
raise CandidateValidationError("候选指标只允许保存标量摘要")
CandidateStore._check_json_size(metrics)
+135 -27
View File
@@ -26,6 +26,7 @@ from app.backtest.matrix import (
load_market_data_matrix_from_parquet,
)
from app.config import settings
from app.enriched_generation import EnrichedGenerationUnavailableError
from app.parquet import scan_enriched_parquet
from app.tickflow.repository import KlineRepository
@@ -213,8 +214,11 @@ class PanelCache:
columns: list[str] | None,
compute_fn,
asset_type: str = "stock",
generation: str | None = None,
) -> pl.DataFrame:
key = self._make_key(symbols, start, end, columns, asset_type)
key = self._make_key(
symbols, start, end, columns, asset_type, generation
)
now = time.monotonic()
with self._lock:
@@ -280,13 +284,20 @@ class PanelCache:
self._cache.clear()
@staticmethod
def _make_key(symbols: list[str] | None, start: date, end: date, columns: list[str] | None, asset_type: str = "stock") -> str:
def _make_key(
symbols: list[str] | None,
start: date,
end: date,
columns: list[str] | None,
asset_type: str = "stock",
generation: str | None = None,
) -> str:
if symbols is None:
h = "all"
else:
h = hashlib.md5(",".join(sorted(symbols)).encode()).hexdigest()[:12]
cols = "all" if columns is None else hashlib.md5(",".join(sorted(columns)).encode()).hexdigest()[:8]
return f"{asset_type}:{h}:{start}:{end}:{cols}"
return f"{asset_type}:{generation or 'unmanaged'}:{h}:{start}:{end}:{cols}"
# ================================================================
@@ -302,6 +313,23 @@ class BacktestEngine:
# ── 数据加载 ──────────────────────────────────────
def data_generation(self, asset_type: str = "stock") -> str | None:
loader = getattr(self.repo, "get_matrix_data_generation", None)
return loader(asset_type) if callable(loader) else None
def assert_data_generation(
self,
asset_type: str,
expected: str | None,
) -> None:
if expected is None:
return
current = self.data_generation(asset_type)
if current != expected:
raise EnrichedGenerationUnavailableError(
"enriched data changed while the snapshot was being read"
)
def load_panel(
self,
symbols: list[str] | None,
@@ -309,9 +337,36 @@ class BacktestEngine:
end: date,
columns: list[str] | None = None,
asset_type: str = "stock",
*,
expected_generation: str | None = None,
) -> pl.DataFrame:
"""加载 enriched 数据面板,带缓存。asset_type='etf' 时读 ETF enriched。"""
return self._cache.get_or_compute(symbols, start, end, columns, self._load_panel_inner, asset_type=asset_type)
attempts = 1 if expected_generation is not None else 2
for attempt in range(attempts):
generation = (
expected_generation
if expected_generation is not None
else self.data_generation(asset_type)
)
panel = self._cache.get_or_compute(
symbols,
start,
end,
columns,
self._load_panel_inner,
asset_type=asset_type,
generation=generation,
)
try:
self.assert_data_generation(asset_type, generation)
except EnrichedGenerationUnavailableError:
if attempt + 1 >= attempts:
raise
continue
return panel
raise EnrichedGenerationUnavailableError(
"unable to read a stable enriched data snapshot"
)
def load_panel_for_backtest(
self,
@@ -338,6 +393,25 @@ class BacktestEngine:
if df.is_empty():
return df
from app.backtest.fundamentals import (
attach_fundamental_factors,
load_fundamental_snapshot,
)
fundamental_names = sorted(
getattr(feature_plan, "fundamental_columns", frozenset())
or frozenset()
)
if fundamental_names:
# 财务因子列不落 enriched 存储, 在加载口按公告日门控并入。
df = attach_fundamental_factors(
df,
load_fundamental_snapshot(
self.repo.store.data_dir if self.repo is not None else None
),
fundamental_names,
)
instruments = (
self.repo.get_instruments_asset(asset_type)
if self.repo is not None
@@ -402,6 +476,8 @@ class BacktestEngine:
cache_profile: MatrixCacheProfile | None = None,
coverage_start: date | None = None,
coverage_end: date | None = None,
expected_generation: str | None = None,
cancel_event: threading.Event | None = None,
) -> MarketDataMatrix:
"""Load a matrix-native backtest directly from projected parquet batches."""
if feature_plan.execution_backend != "matrix_native":
@@ -432,35 +508,67 @@ class BacktestEngine:
)
generation_loader = getattr(self.repo, "get_matrix_data_generation", None)
source_generation = (
generation_loader(asset_type)
if cache_root is not None and callable(generation_loader)
else None
)
try:
return load_market_data_matrix_from_parquet(
parquet_root,
start,
end,
field_columns=field_columns,
symbols=symbols,
instruments=instruments,
cache_root=cache_root,
coverage_start=coverage_start,
coverage_end=coverage_end,
cache_field_columns=cache_fields,
cache_max_bytes=cache_max_bytes,
profile_generation=(
cache_profile.generation if cache_profile is not None else "request"
),
source_generation=source_generation,
expected_generation
if expected_generation is not None
else (
generation_loader(asset_type)
if callable(generation_loader)
else None
)
except pa.ArrowException as exc:
raise ValueError(f"direct market matrix parquet scan failed: {exc}") from exc
)
attempts = 1 if expected_generation is not None else 2
for attempt in range(attempts):
try:
market = load_market_data_matrix_from_parquet(
parquet_root,
start,
end,
field_columns=field_columns,
symbols=symbols,
instruments=instruments,
cache_root=cache_root,
coverage_start=coverage_start,
coverage_end=coverage_end,
cache_field_columns=cache_fields,
cache_max_bytes=cache_max_bytes,
profile_generation=(
cache_profile.generation if cache_profile is not None else "request"
),
source_generation=source_generation,
cancel_event=cancel_event,
)
self.assert_data_generation(asset_type, source_generation)
from app.backtest.fundamentals import attach_matrix_fundamental_fields
fundamental_names = sorted(
getattr(feature_plan, "fundamental_columns", frozenset())
or frozenset()
)
if fundamental_names:
# 财务因子不落 enriched 存储: 矩阵加载后按公告日门控附加字段。
market = attach_matrix_fundamental_fields(
market,
self.repo.store.data_dir if self.repo is not None else None,
fundamental_names,
)
return market
except EnrichedGenerationUnavailableError:
if attempt + 1 >= attempts:
raise
source_generation = self.data_generation(asset_type)
except pa.ArrowException as exc:
raise ValueError(f"direct market matrix parquet scan failed: {exc}") from exc
raise EnrichedGenerationUnavailableError(
"unable to read a stable enriched matrix snapshot"
)
def cache_stats(self) -> dict:
"""暴露 PanelCache 遥测快照 (扫盘耗时/次数/命中/复用), 供上层量化 IO 占比。"""
return self._cache.stats()
def clear_panel_cache(self) -> None:
self._cache.invalidate()
def _load_panel_inner(
self,
symbols: list[str] | None,
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
"""财务因子: 基于本地财务快照的点时 (point-in-time) 无未来函数接入。
数据契约:
- 输入为 data/financials/metrics/part.parquet, 每行一份报告期指标;
- ``announce_date`` 是公告日因子只在 **严格晚于公告日的交易日** 才有值
(公告多在盘后发布, 保守取 T+1 生效), 此前保持 null;
- 财报历史按 (symbol, period_end) 累积 ( services/financial_sync.py),
同一期以最新公告为准;
- 无财务数据的标的/日期一律为 null, 绝不填 0 ( 0 会污染截面排名,
例如资产负债率 0 会被当成最优杠杆)下游 IC/分层/评分对 null 自动剔除
性能:
- 财务表约数千行, join_asof symbol 分组回填, 对百万行面板的代价是
毫秒级; 矩阵路径每个因子只物化一张 float32 TxN 矩阵 (T~900, N~5500
20MB), 且仅在策略/挖掘请求该因子时才构建
"""
from __future__ import annotations
import logging
from pathlib import Path
from types import MappingProxyType
from typing import Any
import numpy as np
import polars as pl
logger = logging.getLogger(__name__)
# 财务因子名 -> (metrics 表列名, 是否需要除以收盘价)
# pb_latest 单列声明为 bps 倒数口径: 因子值 = close / bps。
FUNDAMENTAL_FACTORS: dict[str, dict[str, Any]] = {
"pb_latest": {"column": "bps", "price_ratio": True},
"roe_latest": {"column": "roe", "price_ratio": False},
"gross_margin_latest": {"column": "gross_margin", "price_ratio": False},
"net_margin_latest": {"column": "net_margin", "price_ratio": False},
"revenue_yoy_latest": {"column": "revenue_yoy", "price_ratio": False},
"net_income_yoy_latest": {"column": "net_income_yoy", "price_ratio": False},
"debt_ratio_latest": {"column": "debt_to_asset_ratio", "price_ratio": False},
}
FUNDAMENTAL_FACTOR_NAMES = frozenset(FUNDAMENTAL_FACTORS)
def load_fundamental_snapshot(data_dir: Path | None) -> pl.DataFrame | None:
"""读取财务指标快照; 文件缺失或无有效行时返回 None。
返回列: symbol, _announce (Date), 以及各因子对应的 metrics
"""
if data_dir is None:
return None
path = data_dir / "financials" / "metrics" / "part.parquet"
if not path.exists():
return None
try:
frame = pl.read_parquet(path)
except Exception as exc:
logger.warning("读取财务指标快照失败: %s", exc)
return None
needed = {"symbol", "announce_date"} | {
spec["column"] for spec in FUNDAMENTAL_FACTORS.values()
}
if not needed.issubset(frame.columns):
logger.warning("财务指标快照缺少列: %s", sorted(needed - set(frame.columns)))
return None
snapshot = (
frame.select(sorted(needed))
.filter(
pl.col("symbol").is_not_null()
& pl.col("announce_date").is_not_null()
)
.with_columns(
pl.col("announce_date").cast(pl.Utf8).str.slice(0, 10).str.to_date().alias("_announce")
)
.sort(["symbol", "_announce"])
)
if snapshot.is_empty():
return None
return snapshot
def attach_fundamental_factors(
panel: pl.DataFrame,
snapshot: pl.DataFrame | None,
names: Any,
) -> pl.DataFrame:
"""把财务因子列按公告日门控地并入日频面板。
- snapshot None (本地无财务数据): 产出全 null , 保持面板形状,
由上层决定是否报"无财务数据"错误;
- 面板必须已按 (symbol, date) 排序 (存储与挖掘路径均满足)
"""
requested = [str(name) for name in names if str(name) in FUNDAMENTAL_FACTOR_NAMES]
missing_columns = [name for name in requested if name not in panel.columns]
if not missing_columns:
return panel
if snapshot is None:
return panel.with_columns([
pl.lit(None, dtype=pl.Float64).alias(name)
for name in missing_columns
])
columns = sorted(
{FUNDAMENTAL_FACTORS[name]["column"] for name in missing_columns}
)
right = snapshot.select(["symbol", "_announce", *columns]).sort(["symbol", "_announce"])
joined = panel.join_asof(
right,
left_on="date",
right_on="_announce",
by="symbol",
strategy="backward",
check_sortedness=False, # 双侧均已按 (symbol, key) 排序, 免除逐组检查开销
)
announced = pl.col("_announce").is_not_null() & (pl.col("date") > pl.col("_announce"))
expressions = []
for name in missing_columns:
spec = FUNDAMENTAL_FACTORS[name]
source = pl.col(spec["column"])
if spec["price_ratio"]:
value = (
pl.when(source > 0)
.then(pl.col("close") / source)
.otherwise(None)
)
else:
value = source
expressions.append(
pl.when(announced).then(value).otherwise(None).alias(name)
)
return joined.with_columns(expressions)
def build_fundamental_matrices(
market: Any,
snapshot: pl.DataFrame | None,
names: Any,
) -> dict[str, np.ndarray]:
"""为 MarketDataMatrix 构建财务因子 TxN float32 字段。
attach_fundamental_factors 同一口径: 公告日次一交易日起前向填充,
无数据为 NaNpb 类因子在矩阵侧用 close / bps 现算
"""
requested = [str(name) for name in names if str(name) in FUNDAMENTAL_FACTOR_NAMES]
if not requested:
return {}
shape = market.shape
result: dict[str, np.ndarray] = {}
if snapshot is None:
for name in requested:
result[name] = np.full(shape, np.nan, dtype=np.float32)
return result
asset_index = {symbol: index for index, symbol in enumerate(market.symbols)}
labels = market.timestamp_labels
label_dates = np.array([label[:10] for label in labels], dtype="datetime64[D]")
raw_columns = {
FUNDAMENTAL_FACTORS[name]["column"]: np.full(shape, np.nan, dtype=np.float32)
for name in requested
}
announce_text = snapshot["announce_date"].str.slice(0, 10)
for row_index, symbol in enumerate(snapshot["symbol"].to_list()):
column_index = asset_index.get(symbol)
if column_index is None:
continue
announce = announce_text[row_index]
if announce is None:
continue
# 公告日之后 (严格大于) 的首个时间行索引
start = int(np.searchsorted(label_dates, np.datetime64(announce, "D"), side="right"))
if start >= shape[0]:
continue
for column, target in raw_columns.items():
value = snapshot[column][row_index]
if value is None or not np.isfinite(float(value)):
continue
target[start:, column_index] = float(value)
for name in requested:
spec = FUNDAMENTAL_FACTORS[name]
source = raw_columns[spec["column"]]
if spec["price_ratio"]:
with np.errstate(divide="ignore", invalid="ignore"):
matrix = (market.close / source).astype(np.float32)
matrix[~(source > 0)] = np.nan
matrix[np.isinf(matrix)] = np.nan
else:
matrix = source
result[name] = matrix
return result
def attach_matrix_fundamental_fields(market: Any, data_dir: Path | None, names: Any) -> Any:
"""把财务因子作为 matrix fields 附加到 (frozen) MarketDataMatrix 副本。"""
import dataclasses
requested = [str(name) for name in names if str(name) in FUNDAMENTAL_FACTOR_NAMES]
if not requested:
return market
snapshot = load_fundamental_snapshot(data_dir)
extra = build_fundamental_matrices(market, snapshot, requested)
if not extra:
return market
merged = {**dict(market.fields), **extra}
for array in extra.values():
array.flags.writeable = False
return dataclasses.replace(market, fields=MappingProxyType(merged))
+168
View File
@@ -59,6 +59,17 @@ _ROLLING_MATERIALIZED_WINDOW_BUDGET_BYTES = 32 * 1024 * 1024
_MATRIX_DISK_CACHE_DEFAULT_MAX_BYTES = 512 * 1024 * 1024
logger = logging.getLogger(__name__)
class MatrixPrewarmCancelledError(RuntimeError):
"""A matrix cache prewarm was cancelled during application shutdown."""
def _raise_if_matrix_cancelled(cancel_event: threading.Event | None) -> None:
if cancel_event is not None and cancel_event.is_set():
raise MatrixPrewarmCancelledError("matrix cache prewarm cancelled")
_MATRIX_DISK_CACHE_LOCK = threading.RLock()
_MATRIX_DISK_CACHE_LEASES: dict[str, int] = {}
_MATRIX_DISK_CACHE_PENDING_DELETE: set[str] = set()
@@ -679,8 +690,10 @@ def load_market_data_matrix_from_parquet(
cache_max_bytes: int = _MATRIX_DISK_CACHE_DEFAULT_MAX_BYTES,
profile_generation: str = "default",
source_generation: str | None = None,
cancel_event: threading.Event | None = None,
) -> MarketDataMatrix:
"""Load a daily market matrix, reusing a covering read-only mmap when possible."""
_raise_if_matrix_cancelled(cancel_event)
if start > end:
raise ValueError("matrix parquet range start must not exceed end")
root = Path(parquet_root)
@@ -729,6 +742,7 @@ def load_market_data_matrix_from_parquet(
instruments,
batch_size=batch_size,
cache_status="disabled",
cancel_event=cancel_event,
)
cache_dir = Path(cache_root)
@@ -807,7 +821,9 @@ def load_market_data_matrix_from_parquet(
source_generation,
batch_size=batch_size,
axis_cache_root=cache_dir,
cancel_event=cancel_event,
)
_raise_if_matrix_cancelled(cancel_event)
_prune_matrix_disk_cache(
cache_dir,
keep=cache_path,
@@ -916,12 +932,15 @@ def _build_market_data_matrix_from_dataset(
*,
batch_size: int,
cache_status: str,
cancel_event: threading.Event | None = None,
) -> MarketDataMatrix:
_raise_if_matrix_cancelled(cancel_event)
filter_expr = _matrix_filter_expression(start, end, symbols)
actual_dates, actual_symbols = _collect_parquet_axes(
dataset,
filter_expr,
batch_size=batch_size,
cancel_event=cancel_event,
)
if not actual_dates or not actual_symbols:
raise ValueError("matrix parquet range contains no market data")
@@ -953,7 +972,9 @@ def _build_market_data_matrix_from_dataset(
parquet_fields,
seen,
batch_size=batch_size,
cancel_event=cancel_event,
)
_raise_if_matrix_cancelled(cancel_event)
names, latest_limits = _populate_matrix_derived_arrays(
actual_symbols,
arrays,
@@ -1037,7 +1058,9 @@ def _build_market_data_matrix_cache_from_dataset(
*,
batch_size: int,
axis_cache_root: Path,
cancel_event: threading.Event | None = None,
) -> None:
_raise_if_matrix_cancelled(cancel_event)
build_started = time.perf_counter()
timing_ms: dict[str, float] = {}
cache_path.parent.mkdir(parents=True, exist_ok=True)
@@ -1057,7 +1080,9 @@ def _build_market_data_matrix_cache_from_dataset(
filter_expr,
batch_size=batch_size,
cache_root=axis_cache_root,
cancel_event=cancel_event,
)
_raise_if_matrix_cancelled(cancel_event)
if not actual_dates or not actual_symbols:
raise ValueError("matrix parquet range contains no market data")
timing_ms["axes"] = round((time.perf_counter() - stage_started) * 1000, 1)
@@ -1101,7 +1126,9 @@ def _build_market_data_matrix_cache_from_dataset(
parquet_fields,
seen,
batch_size=batch_size,
cancel_event=cancel_event,
)
_raise_if_matrix_cancelled(cancel_event)
if not seen.any():
raise ValueError("matrix parquet range contains no requested market data")
timing_ms["scan"] = round((time.perf_counter() - stage_started) * 1000, 1)
@@ -1118,6 +1145,7 @@ def _build_market_data_matrix_cache_from_dataset(
vector_fields=vector_fields,
)
_mask_unseen_staging_fields(fields, seen)
_raise_if_matrix_cancelled(cancel_event)
if "price_limit_pct" in fields:
write_numpy_price_limit_matrix(
fields["price_limit_pct"],
@@ -1147,6 +1175,7 @@ def _build_market_data_matrix_cache_from_dataset(
apply_latest_limits=actual_dates[-1] == _latest_partition_date(root),
)
timing_ms["derived"] = round((time.perf_counter() - stage_started) * 1000, 1)
_raise_if_matrix_cancelled(cancel_event)
stage_started = time.perf_counter()
for values in mapped:
values.flush()
@@ -1181,6 +1210,7 @@ def _build_market_data_matrix_cache_from_dataset(
json.dumps(manifest, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
_raise_if_matrix_cancelled(cancel_event)
try:
os.replace(temporary, cache_path)
except OSError:
@@ -1313,6 +1343,7 @@ def _scan_matrix_values(
seen: np.ndarray,
*,
batch_size: int,
cancel_event: threading.Event | None = None,
) -> None:
date_to_id = {value: index for index, value in enumerate(actual_dates)}
symbol_to_id = {value: index for index, value in enumerate(actual_symbols)}
@@ -1343,6 +1374,7 @@ def _scan_matrix_values(
**{name: fields[name] for name in parquet_fields},
}
for batch in scanner.to_batches():
_raise_if_matrix_cancelled(cancel_event)
time_ids = _arrow_axis_ids(_batch_column(batch, "date"), date_to_id)
asset_ids = _arrow_axis_ids(_batch_column(batch, "symbol"), symbol_to_id)
flat_ids = time_ids.astype(np.int64) * asset_count + asset_ids
@@ -1898,7 +1930,9 @@ def _load_or_build_matrix_axes(
*,
batch_size: int,
cache_root: Path,
cancel_event: threading.Event | None = None,
) -> tuple[list[date], list[str]]:
_raise_if_matrix_cancelled(cancel_event)
path = _matrix_axis_cache_path(cache_root, parquet_root, start, end, symbols)
previous: dict[str, Any] | None = None
if path.exists():
@@ -1931,6 +1965,7 @@ def _load_or_build_matrix_axes(
dataset,
filter_expr,
batch_size=batch_size,
cancel_event=cancel_event,
)
changed_labels = set()
retained_dates = {value.isoformat() for value in actual_dates}
@@ -1956,6 +1991,7 @@ def _load_or_build_matrix_axes(
)
symbols_set = set(actual_symbols)
for batch in scanner.to_batches():
_raise_if_matrix_cancelled(cancel_event)
retained_dates.update(
value.isoformat()
for value in pc.unique(_batch_column(batch, "date")).to_pylist()
@@ -1971,8 +2007,10 @@ def _load_or_build_matrix_axes(
dataset,
filter_expr,
batch_size=batch_size,
cancel_event=cancel_event,
)
_raise_if_matrix_cancelled(cancel_event)
payload = {
"version": _MATRIX_AXIS_INDEX_VERSION,
"source_partitions": dict(source_partitions),
@@ -1993,6 +2031,7 @@ def _collect_parquet_axes(
filter_expr,
*,
batch_size: int,
cancel_event: threading.Event | None = None,
) -> tuple[list[date], list[str]]:
dates: set[date] = set()
symbols: set[str] = set()
@@ -2003,6 +2042,7 @@ def _collect_parquet_axes(
use_threads=True,
)
for batch in scanner.to_batches():
_raise_if_matrix_cancelled(cancel_event)
dates.update(pc.unique(_batch_column(batch, "date")).to_pylist())
symbols.update(
str(value)
@@ -2998,6 +3038,7 @@ _VALID_REDUCE_MIN = 0
_VALID_REDUCE_MAX = 1
_VALID_REDUCE_MEAN = 2
_VALID_REDUCE_STD = 3
_VALID_REDUCE_SUM = 4
@njit(cache=True, nogil=True, parallel=True)
@@ -3049,6 +3090,8 @@ def _valid_rolling_kernel(
mean = total / window_value
if operation == _VALID_REDUCE_MEAN:
out[row, asset_id] = mean
elif operation == _VALID_REDUCE_SUM:
out[row, asset_id] = total
else:
squared = 0.0
for offset in range(window):
@@ -3187,6 +3230,30 @@ def valid_rolling_std(
)
def valid_rolling_sum(
values: np.ndarray,
valid_mask: np.ndarray,
window: int,
*,
bar_index: ValidBarIndex | None = None,
) -> np.ndarray:
source = np.asarray(values, dtype=np.float32)
valid = np.asarray(valid_mask, dtype=bool) & np.isfinite(source)
index = _resolve_valid_bar_index(source, valid, bar_index)
return _cached_matrix_operation(
"valid_rolling_sum",
(source, valid, index.offsets, index.rows),
{"window": int(window)},
lambda: _valid_rolling_reduce(
source,
valid,
window,
_VALID_REDUCE_SUM,
bar_index=index,
),
)
def rolling_quantile(values: np.ndarray, window: int, quantile: float) -> np.ndarray:
source = np.asarray(values, dtype=np.float32)
q = float(quantile)
@@ -3675,6 +3742,10 @@ _MATRIX_COMPUTED_FEATURES = frozenset({
"turnover_ratio_5d", "log_amount", "amount_ratio_5d",
"gap_return", "intraday_return", "close_position",
"distance_to_high_60d", "distance_from_low_60d",
"max_ret_20d", "ret_skew_20d", "up_days_20d",
"amihud_20d", "turnover_z_60d", "vol_price_corr_20d",
"vwap_bias", "vol_trend_5_60",
"limit_up_count_20d", "limit_up_count_60d",
})
@@ -3872,9 +3943,106 @@ def _compute_matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray:
return _matrix_relative(market.close, matrix_feature(market, "high_60d"))
if name == "distance_from_low_60d":
return _matrix_relative(market.close, matrix_feature(market, "low_60d"))
if name == "max_ret_20d":
daily = matrix_feature(market, "change_pct")
return valid_rolling_max(daily, np.isfinite(daily), 20)
if name == "ret_skew_20d":
return _matrix_rolling_skew(matrix_feature(market, "change_pct"), 20)
if name == "up_days_20d":
daily = matrix_feature(market, "change_pct")
up = np.where(daily > 0, np.float32(1.0), np.float32(0.0)).astype(np.float32)
up[~np.isfinite(daily)] = np.nan
return valid_rolling_sum(up, np.isfinite(up), 20)
if name == "amihud_20d":
daily = matrix_feature(market, "change_pct")
amount = market.field("amount")
amount_yi = amount / np.float32(1e8)
illiquidity = _matrix_ratio(np.abs(daily), amount_yi)
return valid_rolling_mean(
illiquidity,
close_valid & np.isfinite(illiquidity),
20,
)
if name == "turnover_z_60d":
turnover = market.field("turnover_rate")
valid = close_valid & np.isfinite(turnover)
previous = valid_shift(turnover, 1, valid)
baseline_valid = np.isfinite(previous)
mean = valid_rolling_mean(previous, baseline_valid, 60)
std = valid_rolling_std(previous, baseline_valid, 60, ddof=1)
deviation = _matrix_ratio(turnover - mean, std)
deviation[np.isfinite(std) & (std <= 0)] = np.nan
return deviation
if name == "vol_price_corr_20d":
daily = matrix_feature(market, "change_pct")
return _matrix_rolling_corr(daily, market.volume, close_valid, 20)
if name == "vwap_bias":
amount = market.field("amount")
shares = market.volume * np.float32(100.0)
valid = close_valid & np.isfinite(amount) & (market.volume > 0) & (amount > 0)
vwap = np.full(market.shape, np.nan, dtype=np.float32)
np.divide(amount, shares, out=vwap, where=valid)
return _matrix_relative(market.close, vwap)
if name == "vol_trend_5_60":
volume_valid = close_valid & np.isfinite(market.volume)
fast = valid_rolling_mean(market.volume, volume_valid, 5)
slow = valid_rolling_mean(market.volume, volume_valid, 60)
return _matrix_relative(fast, slow)
if name in {"limit_up_count_20d", "limit_up_count_60d"}:
window = 20 if name == "limit_up_count_20d" else 60
consecutive = market.field("consecutive_limit_ups")
hits = np.where(np.isfinite(consecutive) & (consecutive > 0), np.float32(1.0), np.float32(0.0))
hits = hits.astype(np.float32)
return valid_rolling_sum(hits, close_valid, window)
raise ValueError(f"unsupported matrix feature: {name}")
def _matrix_rolling_skew(values: np.ndarray, window: int) -> np.ndarray:
valid = np.isfinite(values)
first = valid_rolling_mean(values, valid, window)
second = valid_rolling_mean(np.square(values, dtype=np.float32), valid, window)
third = valid_rolling_mean(
(values * values * values).astype(np.float32), valid, window
)
variance = second - np.square(first, dtype=np.float32)
central_third = (
third
- np.float32(3.0) * first * second
+ np.float32(2.0) * np.power(first, 3)
)
out = _matrix_ratio(central_third, np.sqrt(np.power(variance, 3)))
out[np.isfinite(variance) & (variance <= 0)] = np.nan
return out
def _matrix_rolling_corr(
left: np.ndarray, right: np.ndarray, valid_mask: np.ndarray, window: int
) -> np.ndarray:
valid = valid_mask & np.isfinite(left) & np.isfinite(right)
product = (left * right).astype(np.float32)
mean_left = valid_rolling_mean(left, valid, window)
mean_right = valid_rolling_mean(right, valid, window)
mean_product = valid_rolling_mean(product, valid, window)
mean_left_sq = valid_rolling_mean(
np.square(left, dtype=np.float32), valid, window
)
mean_right_sq = valid_rolling_mean(
np.square(right, dtype=np.float32), valid, window
)
covariance = mean_product - mean_left * mean_right
variance_left = mean_left_sq - np.square(mean_left, dtype=np.float32)
variance_right = mean_right_sq - np.square(mean_right, dtype=np.float32)
denominator = np.sqrt(variance_left * variance_right)
out = _matrix_ratio(covariance, denominator)
degenerate = (
np.isfinite(variance_left)
& np.isfinite(variance_right)
& ((variance_left <= 0) | (variance_right <= 0))
)
out[degenerate] = np.nan
return out
def _matrix_ema(values: np.ndarray, valid: np.ndarray, period: int) -> np.ndarray:
return valid_ewm_adjust_false(values, valid, alpha=2.0 / (period + 1.0))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import date
from typing import Any
import numpy as np
RegimePoint = tuple[str, float]
REGIME_THREE_LEVEL_MAP = {
"strong": "strong",
"lean_strong": "strong",
"range": "range",
"lean_weak": "weak",
"weak": "weak",
}
def three_level_regime(state: str) -> str:
return REGIME_THREE_LEVEL_MAP.get(state, state)
def _date_text(value: object) -> str:
return str(value)[:10]
def _normalize_regime_point(value: Any) -> RegimePoint:
if isinstance(value, Mapping):
state = str(value.get("state", ""))
score = float(value.get("score", 0) or 0)
return state, score
if isinstance(value, (tuple, list)) and len(value) >= 2:
return str(value[0]), float(value[1] or 0)
raise ValueError("市场环境数据格式无效")
def align_regime_t_minus_one(
labels: Sequence[str],
regime_by_date: Mapping[object, Any],
required_start: date | None,
required_end: date | None,
) -> list[RegimePoint | None]:
"""Align each label with the preceding label's regime without any I/O."""
regime_map = {
_date_text(key): _normalize_regime_point(value)
for key, value in regime_by_date.items()
}
if not regime_map:
raise ValueError("市场环境数据为空, 请先在数据页完成市场环境计算后再回测")
aligned: list[RegimePoint | None] = [None] * len(labels)
required_start_text = str(required_start) if required_start is not None else None
required_end_text = str(required_end) if required_end is not None else None
missing_dates: list[str] = []
if labels and required_start_text is not None:
first_label = _date_text(labels[0])
if first_label >= required_start_text and (
required_end_text is None or first_label <= required_end_text
):
raise ValueError(
f"市场环境数据覆盖不完整: 正式首日 {first_label} 缺少前一交易日环境, "
"请把前一交易日行情包含在预热区间"
)
for index in range(1, len(labels)):
current_label = _date_text(labels[index])
previous_label = _date_text(labels[index - 1])
point = regime_map.get(previous_label)
if point is not None:
aligned[index] = point
continue
required = (
(required_start_text is None or current_label >= required_start_text)
and (required_end_text is None or current_label <= required_end_text)
)
if required:
missing_dates.append(previous_label)
if missing_dates:
first_missing = missing_dates[0]
suffix = f"{len(missing_dates)}" if len(missing_dates) > 1 else ""
raise ValueError(
f"市场环境数据覆盖不完整: 缺少前一交易日环境 {first_missing}{suffix}, "
"请先补算对应区间"
)
return aligned
def build_regime_filter_mask(
labels: Sequence[str],
regime_filter: Mapping[str, Any] | None,
regime_by_date: Mapping[object, Any],
*,
required_start: date | None = None,
required_end: date | None = None,
) -> np.ndarray | None:
"""Build a T-1 regime filter mask from caller-supplied regime data.
States are matched against the raw five-level labels, so each regime
level can be filtered on its own. Callers that want the aggregated
three-level view must list the raw states explicitly, e.g.
``["strong", "lean_strong"]`` for the strong bucket.
"""
if not regime_filter:
return None
allowed_states = {
str(state)
for state in (regime_filter.get("states") or [])
}
min_score = regime_filter.get("min_score")
if not allowed_states and min_score is None:
return None
aligned = align_regime_t_minus_one(
labels,
regime_by_date,
required_start,
required_end,
)
mask = np.ones(len(labels), dtype=bool)
for index, point in enumerate(aligned):
if point is None:
continue
state, score = point
mask[index] = (
(not allowed_states or state in allowed_states)
and (min_score is None or score >= float(min_score))
)
return mask
+47 -53
View File
@@ -20,11 +20,13 @@ import numpy as np
import polars as pl
from app.backtest.engine import BacktestEngine, MatcherConfig, SimResult, SimulationOptions
from app.backtest.fundamentals import FUNDAMENTAL_FACTOR_NAMES
from app.backtest.matrix import (
MarketDataMatrix,
MatrixCacheProfile,
MatrixComputeCache,
MatrixPipelineConfig,
MatrixPrewarmCancelledError,
MatrixStrategyPipeline,
apply_time_masks,
build_market_matrix,
@@ -59,7 +61,7 @@ _EXECUTION_COLUMNS = frozenset({
"symbol", "date", "open", "high", "low", "close", "volume",
"name", "score", "signal_limit_up", "signal_limit_down",
})
_LIMIT_BASE_COLUMNS = frozenset({"raw_close", "raw_high"})
_LIMIT_BASE_COLUMNS = frozenset({"raw_close", "raw_high", "raw_low"})
_INSTRUMENT_COLUMNS = frozenset({"name", "total_shares", "float_shares"})
@@ -81,6 +83,8 @@ class ResolvedFeaturePlan:
warmup_bars: int
full_feature_fallback: bool = False
execution_backend: str = "polars_expr"
# 财务因子列不落 enriched 存储, 由 engine 在加载口按公告日门控附加。
fundamental_columns: frozenset[str] = frozenset()
def _merge_resolved_feature_plans(
@@ -108,6 +112,7 @@ def _merge_resolved_feature_plans(
warmup_bars=max(plan.warmup_bars for plan in plans),
full_feature_fallback=any(plan.full_feature_fallback for plan in plans),
execution_backend="matrix_native",
fundamental_columns=_union("fundamental_columns"),
)
@@ -206,6 +211,9 @@ class StrategyDependencyResolver:
warmup_bars=plan.warmup_bars,
full_feature_fallback=full_fallback,
execution_backend=strategy.execution_backend,
fundamental_columns=frozenset(
required_features & FUNDAMENTAL_FACTOR_NAMES
),
)
@staticmethod
@@ -224,6 +232,18 @@ class StrategyDependencyResolver:
required_features = set(strategy.required_features)
required_features.update(strategy.matrix_strategy.required_fields())
parameter_fields = getattr(
strategy.matrix_strategy,
"required_fields_for_params",
None,
)
parameter_scoring: dict[str, float] = {}
if callable(parameter_fields):
parameter_scoring = {
str(name): 1.0
for name in parameter_fields(params)
}
required_features.update(scoring_dependencies(parameter_scoring))
required_features.update(_basic_filter_dependencies(basic_filter))
scoring = effective_scoring(strategy.meta.get("scoring"), overrides)
required_features.update(scoring_dependencies(scoring))
@@ -239,6 +259,7 @@ class StrategyDependencyResolver:
60,
int(strategy.matrix_strategy.required_warmup_bars(params)),
scoring_warmup_bars(scoring),
scoring_warmup_bars(parameter_scoring),
)
matrix_columns = set(base_columns) | set(instrument_columns) | {
"signal_limit_up",
@@ -254,6 +275,9 @@ class StrategyDependencyResolver:
warmup_bars=warmup_bars,
full_feature_fallback=False,
execution_backend="matrix_native",
fundamental_columns=frozenset(
required_features & FUNDAMENTAL_FACTOR_NAMES
),
)
@@ -346,10 +370,13 @@ def prewarm_matrix_cache(
asset_type: str,
latest_date: date,
years: int = 5,
cancel_event: threading.Event | None = None,
) -> dict[str, object]:
"""Build the shared full-universe mmap outside a user backtest request."""
if years <= 0:
raise ValueError("matrix cache prewarm years must be positive")
if cancel_event is not None and cancel_event.is_set():
raise MatrixPrewarmCancelledError("matrix cache prewarm cancelled")
profile = build_matrix_cache_profile(
strategy_engine,
asset_type,
@@ -390,6 +417,7 @@ def prewarm_matrix_cache(
cache_profile=profile,
coverage_start=coverage_start,
coverage_end=latest_date,
cancel_event=cancel_event,
)
result = {
"asset_type": asset_type,
@@ -1696,14 +1724,7 @@ class StrategyBacktestService:
required_start: date | None = None,
required_end: date | None = None,
) -> np.ndarray | None:
"""构造逐日 regime mask。强制 T-1 防未来函数: regime[T-1] 决定 entry[T]。
timestamp_labels[i] 的入场资格 = 它的"前一交易日" regime 是否满足条件
"前一交易日" timestamp_labels 自身的顺序确定(回测时间轴上的前一天)
边界: 首日无前一日环境 默认允许(不阻断)
regime_filter None 时返回 None(不过滤)启用过滤后缺少正式区间所需的
环境数据则 fail-closed, 避免界面显示已过滤但实际静默放行
"""
"""构造逐日 T-1 regime mask, 保留历史静态入口兼容调用方。"""
if not regime_filter:
return None
allowed_states = set(regime_filter.get("states") or [])
@@ -1713,52 +1734,25 @@ class StrategyBacktestService:
if data_dir is None:
raise ValueError("市场环境过滤不可用: 未找到环境数据目录")
from app.backtest.regime_alignment import build_regime_filter_mask
from app.services import regime_builder
regime_df = regime_builder.load_regime_history(data_dir)
if regime_df.is_empty():
raise ValueError("市场环境数据为空, 请先在数据页完成市场环境计算后再回测")
# 构建 date(ISO) → (state, score) 映射
regime_map: dict[str, tuple[str, int]] = {}
for r in regime_df.iter_rows(named=True):
d = r.get("date")
ds = str(d)[:10] if d is not None else None
if ds:
regime_map[ds] = (str(r.get("state", "")), int(r.get("score", 0) or 0))
# 对每个 label, 找它的前一交易日的 regime(timestamp_labels 顺序里的前一天)
n = len(timestamp_labels)
mask = np.ones(n, dtype=bool) # 默认允许
required_start_text = str(required_start) if required_start is not None else None
required_end_text = str(required_end) if required_end is not None else None
missing_dates: list[str] = []
for i in range(1, n):
current_label = timestamp_labels[i][:10]
prev_label = timestamp_labels[i - 1][:10]
entry = regime_map.get(prev_label)
if entry is None:
required = (
(required_start_text is None or current_label >= required_start_text)
and (required_end_text is None or current_label <= required_end_text)
)
if required:
missing_dates.append(prev_label)
continue
state, score = entry
ok = True
if allowed_states and state not in allowed_states:
ok = False
if min_score is not None and score < min_score:
ok = False
mask[i] = ok
if missing_dates:
first_missing = missing_dates[0]
suffix = f"{len(missing_dates)}" if len(missing_dates) > 1 else ""
raise ValueError(
f"市场环境数据覆盖不完整: 缺少前一交易日环境 {first_missing}{suffix}, "
"请先补算对应区间"
)
return mask
regime_by_date = {
row["date"]: {
"state": row.get("state", ""),
"score": row.get("score", 0),
}
for row in regime_df.iter_rows(named=True)
if row.get("date") is not None
}
return build_regime_filter_mask(
timestamp_labels,
regime_filter,
regime_by_date,
required_start=required_start,
required_end=required_end,
)
def _build_candidate_filter_mask(
self,
+29 -1
View File
@@ -22,6 +22,9 @@ class BacktestWorkerError(RuntimeError):
"""Raised when a spawned worker fails before returning a task result."""
_CANCEL_GRACE_SECONDS = 5.0
class _PeakRssSampler:
"""Track whole-task and resettable phase RSS peaks with one sampling thread."""
@@ -136,6 +139,10 @@ def make_worker_task(kind: str, data_dir: Path, config) -> dict[str, Any]:
encoded = asdict(config)
encoded["start"] = config.start.isoformat()
encoded["end"] = config.end.isoformat()
elif kind == "mining":
if not isinstance(config, dict):
raise TypeError("mining worker config must be a dict")
encoded = dict(config)
else:
raise ValueError(f"unsupported worker task kind: {kind}")
return {
@@ -165,8 +172,8 @@ def _worker_entry(task: dict[str, Any], event_queue, cancel_event) -> None:
from app.backtest.engine import BacktestEngine
from app.backtest.optimizer import StrategyOptimizer
from app.backtest.strategy import StrategyBacktestService
from app.strategy.engine import StrategyEngine
from app.strategy import config as strategy_config
from app.strategy.engine import StrategyEngine
from app.tickflow.repository import DataStore, KlineRepository
data_dir = Path(task["data_dir"])
@@ -201,6 +208,18 @@ def _worker_entry(task: dict[str, Any], event_queue, cancel_event) -> None:
optimizer = StrategyOptimizer(service, strategy_engine)
walkforward = WalkForwardService(optimizer, service, strategy_engine)
result = walkforward.run(config, _progress, cancel_event)
elif kind == "mining":
from app.backtest.mining_runtime import run_mining_runtime
result = run_mining_runtime(
task["config"],
data_dir=data_dir,
service=service,
strategy_engine=strategy_engine,
progress_cb=_progress,
cancel_check=cancel_event,
rss_sampler=sampler,
)
else:
raise ValueError(f"unsupported worker task kind: {kind}")
@@ -261,11 +280,20 @@ def run_worker_task(
result: dict[str, Any] | None = None
failure: dict[str, Any] | None = None
ipc_started = time.perf_counter()
cancel_started: float | None = None
try:
while result is None and failure is None:
if cancel_event is not None and cancel_event.is_set():
process_cancel.set()
if cancel_started is None:
cancel_started = time.monotonic()
elif time.monotonic() - cancel_started >= _CANCEL_GRACE_SECONDS:
process.terminate()
process.join(timeout=5.0)
raise BacktestWorkerError(
"backtest worker did not stop within 5 seconds after cancellation"
)
try:
message = events.get(timeout=0.1)
except queue.Empty:
+319
View File
@@ -0,0 +1,319 @@
from __future__ import annotations
import json
import os
import threading
import time
import uuid
import weakref
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any, BinaryIO
import polars as pl
class EnrichedGenerationUnavailableError(RuntimeError):
"""The enriched dataset has no stable generation available for readers."""
_WRITER_LOCKS_GUARD = threading.Lock()
_WRITER_LOCKS: dict[tuple[str, str], threading.RLock] = {}
_ACTIVE_PUBLICATIONS: weakref.WeakValueDictionary[str, EnrichedPublication] = (
weakref.WeakValueDictionary()
)
def _marker_path(data_dir: Path, asset_type: str) -> Path:
return Path(data_dir) / f".matrix_generation_{asset_type}.json"
def _writer_lock(data_dir: Path, asset_type: str) -> threading.RLock:
key = (str(Path(data_dir).resolve()), asset_type)
with _WRITER_LOCKS_GUARD:
return _WRITER_LOCKS.setdefault(key, threading.RLock())
def _read_marker(path: Path) -> dict[str, Any] | None:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise EnrichedGenerationUnavailableError(
"enriched data generation marker is invalid"
) from exc
if not isinstance(payload, dict):
raise EnrichedGenerationUnavailableError(
"enriched data generation marker is invalid"
)
return payload
def _fsync_directory(path: Path) -> None:
if os.name == "nt":
return
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _write_marker(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
try:
with temporary.open("x", encoding="utf-8", newline="\n") as stream:
json.dump(payload, stream, separators=(",", ":"))
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
_fsync_directory(path.parent)
finally:
temporary.unlink(missing_ok=True)
def _unlock_file(stream: BinaryIO) -> None:
if os.name == "nt":
import msvcrt
stream.seek(0)
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
return
import fcntl
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
def _try_lock_file(stream: BinaryIO) -> None:
if os.name == "nt":
import msvcrt
stream.seek(0)
try:
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
except OSError as exc:
raise EnrichedGenerationUnavailableError(
"another enriched publication is active"
) from exc
return
import fcntl
try:
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
raise EnrichedGenerationUnavailableError(
"another enriched publication is active"
) from exc
@contextmanager
def _exclusive_generation_lock(data_dir: Path, asset_type: str) -> Iterator[None]:
lock_path = Path(data_dir) / f".matrix_generation_{asset_type}.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
with (
_writer_lock(data_dir, asset_type),
lock_path.open("a+b") as stream,
):
stream.seek(0, os.SEEK_END)
if stream.tell() == 0:
stream.write(b"0")
stream.flush()
_try_lock_file(stream)
try:
yield
finally:
_unlock_file(stream)
def _process_is_alive(pid: Any) -> bool:
if not isinstance(pid, int) or pid <= 0:
return False
if pid == os.getpid():
return True
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except (OSError, PermissionError):
return True
return True
def _ready_payload(generation: str) -> dict[str, Any]:
return {
"state": "ready",
"generation": generation,
"updated_at_ns": time.time_ns(),
}
def get_enriched_generation(
data_dir: Path,
asset_type: str = "stock",
*,
initialize: bool = True,
) -> str:
path = _marker_path(data_dir, asset_type)
payload = _read_marker(path)
if payload is None:
if not initialize:
raise EnrichedGenerationUnavailableError(
"enriched data generation marker is unavailable"
)
with _exclusive_generation_lock(data_dir, asset_type):
payload = _read_marker(path)
if payload is None:
generation = uuid.uuid4().hex
_write_marker(path, _ready_payload(generation))
return generation
state = payload.get("state", "ready")
generation = payload.get("generation")
if state != "ready" or not isinstance(generation, str) or not generation:
raise EnrichedGenerationUnavailableError(
"enriched data is being published; retry after the update finishes"
)
return generation
def enriched_publication_incomplete(
data_dir: Path,
asset_type: str = "stock",
) -> bool:
try:
payload = _read_marker(_marker_path(data_dir, asset_type))
except EnrichedGenerationUnavailableError:
return True
if payload is None:
return False
return (
payload.get("state", "ready") != "ready"
or not isinstance(payload.get("generation"), str)
or not payload["generation"]
)
def bump_enriched_generation(data_dir: Path, asset_type: str = "stock") -> str:
path = _marker_path(data_dir, asset_type)
with _exclusive_generation_lock(data_dir, asset_type):
current = _read_marker(path)
if current is not None and current.get("state", "ready") != "ready":
raise EnrichedGenerationUnavailableError(
"cannot bump an incomplete enriched publication"
)
generation = uuid.uuid4().hex
_write_marker(path, _ready_payload(generation))
return generation
class EnrichedPublication:
"""Publish one logical enriched write batch under a stable generation token."""
def __init__(
self,
data_dir: Path,
asset_type: str = "stock",
*,
recover: bool = False,
) -> None:
self.data_dir = Path(data_dir)
self.asset_type = asset_type
self.recover = recover
self._publishing = False
self._changed = False
self._base_generation: str | None = None
self._publication_id = uuid.uuid4().hex
def begin(self) -> None:
with _exclusive_generation_lock(self.data_dir, self.asset_type):
self._claim_or_verify()
def mark_changed(self) -> None:
if not self._publishing:
raise RuntimeError("enriched publication has not started")
self._changed = True
def abandon(self) -> None:
if not self._publishing or self._changed:
return
path = _marker_path(self.data_dir, self.asset_type)
with _exclusive_generation_lock(self.data_dir, self.asset_type):
current = _read_marker(path)
if current is not None and current.get("publication_id") == self._publication_id:
_write_marker(path, _ready_payload(str(self._base_generation)))
self._publishing = False
def write_parquet(self, df: pl.DataFrame, out: Path) -> None:
out.parent.mkdir(parents=True, exist_ok=True)
temporary = out.with_name(f".{out.name}.{uuid.uuid4().hex}.tmp")
try:
df.write_parquet(temporary)
with temporary.open("r+b") as stream:
stream.flush()
os.fsync(stream.fileno())
with _exclusive_generation_lock(self.data_dir, self.asset_type):
self._claim_or_verify()
os.replace(temporary, out)
_fsync_directory(out.parent)
self._changed = True
finally:
temporary.unlink(missing_ok=True)
def commit(self) -> str | None:
if not self._changed:
return None
path = _marker_path(self.data_dir, self.asset_type)
with _exclusive_generation_lock(self.data_dir, self.asset_type):
current = _read_marker(path)
if current is None or current.get("publication_id") != self._publication_id:
raise EnrichedGenerationUnavailableError(
"enriched publication ownership was lost"
)
generation = uuid.uuid4().hex
_write_marker(path, _ready_payload(generation))
self._publishing = False
return generation
def _claim_or_verify(self) -> None:
path = _marker_path(self.data_dir, self.asset_type)
try:
current = _read_marker(path)
except EnrichedGenerationUnavailableError:
if not self.recover:
raise
current = None
if self._publishing:
if current is None or current.get("publication_id") != self._publication_id:
raise EnrichedGenerationUnavailableError(
"enriched publication ownership was lost"
)
return
_ACTIVE_PUBLICATIONS[self._publication_id] = self
if current is not None and current.get("state", "ready") != "ready":
current_id = current.get("publication_id")
current_owner = _ACTIVE_PUBLICATIONS.get(str(current_id))
owner_pid = current.get("owner_pid")
if current_owner is not None or (
owner_pid != os.getpid() and _process_is_alive(owner_pid)
):
raise EnrichedGenerationUnavailableError(
"another enriched publication is active"
)
if not self.recover:
raise EnrichedGenerationUnavailableError(
"another enriched publication is incomplete"
)
generation = None if current is None else current.get("generation")
if not isinstance(generation, str) or not generation:
generation = uuid.uuid4().hex
self._base_generation = generation
_write_marker(path, {
"state": "publishing",
"generation": generation,
"publication_id": self._publication_id,
"owner_pid": os.getpid(),
"updated_at_ns": time.time_ns(),
})
self._publishing = True
+32 -11
View File
@@ -22,6 +22,10 @@ from pathlib import Path
import polars as pl
from app.config import settings
from app.enriched_generation import (
EnrichedPublication,
enriched_publication_incomplete,
)
from app.market_time import cn_today
from app.parquet import scan_daily_parquet, scan_enriched_parquet, scan_parquet_compat
from app.price_limits import (
@@ -350,7 +354,12 @@ def _resolve_needed(needed: set[str] | None) -> set[str]:
return want
def compute_indicators(df: pl.DataFrame, needed: set[str] | None = None) -> pl.DataFrame:
def compute_indicators(
df: pl.DataFrame,
needed: set[str] | None = None,
*,
assume_sorted: bool = False,
) -> pl.DataFrame:
"""从 OHLCV 数据计算全套技术指标。
输入必须包含: symbol, date, open, high, low, close, volume
@@ -370,7 +379,7 @@ def compute_indicators(df: pl.DataFrame, needed: set[str] | None = None) -> pl.D
want = _resolve_needed(needed)
df = df.sort(["symbol", "date"])
df = df if assume_sorted else df.sort(["symbol", "date"])
# Pass 1: 均线 + EMA + MACD 基础 + BOLL 基础 + KDJ 基础 + ATR 基础 + 量价 + 极值
prev_close = pl.col("close").shift(1).over("symbol")
@@ -670,7 +679,7 @@ def compute_limit_signals(
signal_limit_down_recovery (跌停翘板)
signal_broken_limit_up (炸板: 最高价触及涨停价但收盘未封住)
输入必须包含: symbol, date, raw_close, raw_high, open, high, low, close,
输入必须包含: symbol, date, raw_close, raw_high, raw_low, open, high, low, close,
change_pct, vol_ratio_5d
"""
if df.is_empty():
@@ -879,9 +888,10 @@ def compute_limit_signals(
pl.when(
pl.col("_prev_raw_close").is_not_null()
& (pl.col("_prev_raw_close") > 0)
& (pl.col("raw_low") > 0)
).then(
(~pl.col("signal_limit_down").fill_null(False)) # 最终没跌停
& (pl.col("low") <= pl.col("_effective_limit_down") + 0.005) # 曾触及跌停
& (pl.col("raw_low") <= pl.col("_effective_limit_down") + 0.005) # 曾触及跌停(原始价口径, 跌停价为原始价基准)
& (pl.col("close") > pl.col("open")) # 收阳
).otherwise(None).cast(pl.Boolean)
.alias("signal_limit_down_recovery")
@@ -1045,6 +1055,11 @@ def run_pipeline(data_dir: Path | None = None,
t0 = _t.perf_counter()
d = Path(data_dir or settings.data_dir)
if enriched_publication_incomplete(d, "stock"):
logger.warning("检测到未完成的 enriched 发布,改为全量重建")
symbols = None
new_dates_only = False
publication = EnrichedPublication(d, "stock", recover=True)
daily_dir = d / "kline_daily"
enriched_base = d / "kline_daily_enriched"
factor_path = d / "adj_factor" / "all.parquet"
@@ -1134,7 +1149,7 @@ def run_pipeline(data_dir: Path | None = None,
out = enriched_base / f"date={ds}" / "part.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
date_df = _select_storage_cols(date_df).sort(["symbol"])
date_df.write_parquet(out)
publication.write_parquet(date_df, out)
written += date_df.height
t_write_new = _t.perf_counter()
logger.info("增量写入: %.2fs, %d", t_write_new - t_new, written)
@@ -1166,10 +1181,11 @@ def run_pipeline(data_dir: Path | None = None,
existing = existing.filter(~pl.col("symbol").is_in(list(sym_set)))
date_df_storage = pl.concat([existing, date_df_storage], how="diagonal_relaxed")
date_df_storage = date_df_storage.sort(["symbol"])
date_df_storage.write_parquet(out)
publication.write_parquet(date_df_storage, out)
written += date_df.height
logger.info("除权重算: %d 只, 共写入 %d", len(sym_set), written)
publication.commit()
t_done = _t.perf_counter()
logger.info("增量管道完成: %.2fs, %d", t_done - t0, written)
return written
@@ -1267,7 +1283,7 @@ def run_pipeline(data_dir: Path | None = None,
existing = existing.filter(~pl.col("symbol").is_in(batch_syms))
date_df_storage = pl.concat([existing, date_df_storage], how="diagonal_relaxed")
date_df_storage = date_df_storage.sort(["symbol"])
date_df_storage.write_parquet(out)
publication.write_parquet(date_df_storage, out)
written += date_df_storage.height
else:
# 全量模式: 缓冲到 date_buffers, 最后一次性写入
@@ -1308,11 +1324,12 @@ def run_pipeline(data_dir: Path | None = None,
out = base / f"date={ds}" / "part.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
merged = pl.concat(dfs, how="diagonal_relaxed").sort(["symbol"])
merged.write_parquet(out)
publication.write_parquet(merged, out)
date_buffers.clear()
gc.collect()
publication.commit()
t_done = _t.perf_counter()
adj_label = "含复权" if not factors.is_empty() else "无复权"
logger.info("enriched 完成 [%s]: %.2fs, 共 %d 行, %s",
@@ -1339,9 +1356,13 @@ def _load_recent_history(enriched_base: Path, symbols: list[str], days: int) ->
from datetime import date, timedelta
cutoff = date.today() - timedelta(days=days + 30) # 多读 30 天余量
cast_options = pl.ScanCastOptions(integer_cast="allow-float")
try:
lf = (
scan_enriched_parquet(str(enriched_base / "**" / "*.parquet"), cast_options=_cast)
scan_enriched_parquet(
str(enriched_base / "**" / "*.parquet"),
cast_options=cast_options,
)
.filter(
(pl.col("symbol").is_in(symbols))
& (pl.col("date") >= cutoff)
@@ -1814,10 +1835,10 @@ def _compute_limit_signals_today(df: pl.DataFrame, instruments: pl.DataFrame) ->
# 跌停翘板
pl.when(no_price_limit)
.then(False)
.when(valid_prev_raw | has_authoritative_down)
.when((valid_prev_raw | has_authoritative_down) & (pl.col("raw_low") > 0))
.then(
(~is_limit_down.fill_null(True))
& (pl.col("low") <= effective_limit_down + 0.005)
& (pl.col("raw_low") <= effective_limit_down + 0.005)
& (pl.col("close") > pl.col("open"))
).otherwise(None).cast(pl.Boolean)
.alias("signal_limit_down_recovery"),
+44 -4
View File
@@ -542,6 +542,28 @@ def run_now(
stage_errors.append(f"compute_regime: {e}")
skipped.append("regime")
# Step 2.7: 市场主线(概念/行业涨停梯队聚合) 增量计算 — regime 同开关。
# 只窄扫连板 >=1 的行, 增量通常 1 天, 开销可忽略。软失败: 不阻断主管道。
mainline_rows = 0
if not _prefs_regime.get_pipeline_regime_enabled():
skipped.append("mainline")
else:
try:
emit("compute_mainline", 93, "计算市场主线…")
from app.services import market_mainline
for _kind in ("concept", "industry"):
rows = market_mainline.compute_mainline_incremental(
repo, repo.store.data_dir, kind=_kind
)
mainline_rows += rows.height if not rows.is_empty() else 0
if mainline_rows:
logger.info("compute_mainline: %d rows", mainline_rows)
emit("compute_mainline", 94, f"市场主线 {mainline_rows}")
except Exception as e:
logger.warning("compute_mainline failed (soft): %s", e)
stage_errors.append(f"compute_mainline: {e}")
skipped.append("mainline")
# Step 3: 刷新视图
emit("refresh_views", 95, "刷新 DuckDB 视图…")
_refresh_views(repo)
@@ -561,6 +583,7 @@ def run_now(
"etf_adj_factor_symbols": etf_adj_symbols,
"minute_rows": written_minute,
"regime_days": regime_days,
"mainline_rows": mainline_rows,
"lagging_symbols": len(lagging_symbols),
"skipped_stages": skipped,
"stage_errors": stage_errors,
@@ -626,37 +649,54 @@ def _refresh_instruments_view(repo: KlineRepository) -> None:
logger.warning("refresh instruments view failed: %s", e)
def _run_tracked(fn, job_label: str) -> None:
def _run_tracked(fn, job_label: str) -> bool:
"""调度触发时包装 JobStore 跟踪,确保同步历史有记录。
单飞: 若已有活跃(pendingrunning)任务(手动同步中), 本次调度直接跳过, 不并发
重任务执行槽: 再挡一层僵尸并发(reap 后线程仍活时不得并行写 parquet)
返回 True 仅表示任务已成功并且执行槽已释放
"""
from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot
job_id, is_new = job_store.create()
if not is_new:
logger.info("scheduled %s 跳过: 已有活跃任务在运行 (job_id=%s)", job_label, job_id)
return
return False
if not try_acquire_run_slot():
logger.warning("scheduled %s 跳过: 重任务执行槽被占用(疑似上次任务卡死)", job_label)
job_store.fail(job_id, f"scheduled {job_label} skipped: 已有数据任务在运行")
return
return False
def progress(stage: str, pct: int, msg: str, stage_pct: int | None = None,
skip_log: bool = False) -> None:
job_store.progress(job_id, stage, pct, msg, stage_pct=stage_pct, skip_log=skip_log)
succeeded = False
try:
job_store.start(job_id)
result = fn(on_progress=progress)
job_store.succeed(job_id, result)
succeeded = True
logger.info("scheduled %s completed: job_id=%s", job_label, job_id)
except Exception:
logger.exception("scheduled %s failed: job_id=%s", job_label, job_id)
job_store.fail(job_id, f"scheduled {job_label} failed")
finally:
release_run_slot()
return succeeded
def _scheduled_pipeline_task(pipeline_fn) -> None:
"""Run weekly mining only after the tracked daily pipeline has fully succeeded."""
if not _run_tracked(pipeline_fn, "daily_pipeline"):
return
try:
from app.services.mining_schedule import run_weekly_mining
result = run_weekly_mining(_get_app_state())
logger.info("scheduled mining result: %s", result)
except Exception:
logger.exception("scheduled mining enqueue failed; daily pipeline remains succeeded")
# ================================================================
@@ -916,7 +956,7 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
return result
scheduler.add_job(
lambda: _run_tracked(_pipeline_then_refresh, "daily_pipeline"),
lambda: _scheduled_pipeline_task(_pipeline_then_refresh),
trigger=CronTrigger(day_of_week="mon-fri",
hour=sched["hour"], minute=sched["minute"],
timezone="Asia/Shanghai"),
+103 -49
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import logging
import sys
import threading
from contextlib import asynccontextmanager
from pathlib import Path
@@ -13,15 +12,42 @@ from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from app import __version__
from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, regime, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
from app.api import (
alerts,
analysis,
backtest,
data,
ext_data,
financials,
indices,
intraday,
kline,
market_recap,
mining,
monitor_rules,
overview,
pipeline,
regime,
rps,
screener,
signals,
stock_analysis,
strategy,
watchlist,
)
from app.api import auth as auth_api
from app.api import settings as settings_api
from app.api.routes import router as core_router
from app.config import settings
from app.enriched_generation import EnrichedGenerationUnavailableError
from app.extensions.loader import (
configure_backend_extensions,
current_extension_context,
start_backend_extensions,
)
from app.jobs import daily_pipeline
from app.services.matrix_prewarm_owner import MatrixCachePrewarmOwner
from app.services.mining_process_lock import MiningProcessLock
from app.services.quote_service import QuoteService
from app.tickflow import client as tf_client
from app.tickflow.policy import detect_capabilities
@@ -57,7 +83,7 @@ if not getattr(sys, "frozen", False):
@asynccontextmanager
async def lifespan(app: FastAPI):
async def _application_lifespan(app: FastAPI):
logger.info(
"Tick Stock Panel v%s starting (mode=%s)",
__version__, tf_client.current_mode(),
@@ -76,9 +102,19 @@ async def lifespan(app: FastAPI):
repo = KlineRepository(store)
app.state.datastore = store
app.state.repo = repo
from app.services.mining_manager import MiningJobManager
mining_manager = MiningJobManager(store.data_dir)
recovered_mining_runs = mining_manager.recover_interrupted()
app.state.mining_manager = mining_manager
if recovered_mining_runs:
logger.warning("recovered %d interrupted mining runs", recovered_mining_runs)
# 在接受回测请求前固定 managed generation,避免首批并发 worker 各自创建版本。
if settings.backtest_matrix_disk_cache_enabled:
repo.get_matrix_data_generation("stock")
try:
repo.get_matrix_data_generation("stock")
except EnrichedGenerationUnavailableError as exc:
logger.warning("enriched generation requires a full rebuild: %s", exc)
# 指标异步预热标志: enriched 缓存在后台线程构建, 完成后置 True
app.state.indicators_ready = False
repo._on_warmup_done = lambda: setattr(app.state, "indicators_ready", True) # noqa: SLF001
@@ -188,51 +224,50 @@ async def lifespan(app: FastAPI):
app.state.strategy_engine = strategy_engine
logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies()))
matrix_prewarm_lock = threading.Lock()
matrix_prewarm_running = False
matrix_prewarm_owner = MatrixCachePrewarmOwner()
def _schedule_matrix_cache_prewarm() -> None:
nonlocal matrix_prewarm_running
if (
not settings.backtest_matrix_disk_cache_enabled
or not settings.backtest_matrix_cache_prewarm
):
return
with matrix_prewarm_lock:
if matrix_prewarm_running:
logger.info("matrix cache prewarm already in progress, skip")
return
matrix_prewarm_running = True
def _prewarm() -> None:
nonlocal matrix_prewarm_running
from app.backtest.engine import BacktestEngine
from app.backtest.matrix import MatrixPrewarmCancelledError
from app.backtest.strategy import prewarm_matrix_cache
from app.services.heavy_job_limiter import (
HeavyJobCancelledError,
shared_heavy_job_limiter,
)
try:
latest = repo.latest_enriched_date("stock")
if latest is None:
logger.info("matrix cache prewarm skipped: no stock enriched data")
return
from app.backtest.engine import BacktestEngine
from app.backtest.strategy import prewarm_matrix_cache
result = prewarm_matrix_cache(
BacktestEngine(repo),
strategy_engine,
asset_type="stock",
latest_date=latest,
years=settings.backtest_matrix_cache_prewarm_years,
)
with shared_heavy_job_limiter.slot(
"normal",
cancel_event=matrix_prewarm_owner.cancel_event,
):
result = prewarm_matrix_cache(
BacktestEngine(repo),
strategy_engine,
asset_type="stock",
latest_date=latest,
years=settings.backtest_matrix_cache_prewarm_years,
cancel_event=matrix_prewarm_owner.cancel_event,
)
logger.info("matrix cache prewarm done: %s", result)
except (HeavyJobCancelledError, MatrixPrewarmCancelledError):
logger.info("matrix cache prewarm cancelled")
except Exception: # noqa: BLE001
logger.exception("matrix cache prewarm failed")
finally:
with matrix_prewarm_lock:
matrix_prewarm_running = False
threading.Thread(
target=_prewarm,
name="matrix-cache-prewarm",
daemon=True,
).start()
if not matrix_prewarm_owner.schedule(_prewarm):
logger.info("matrix cache prewarm already running or shutting down, skip")
repo._on_refresh_done = _schedule_matrix_cache_prewarm # noqa: SLF001
if repo.enriched_ready:
@@ -281,26 +316,44 @@ async def lifespan(app: FastAPI):
extension_registry,
)
yield
try:
yield
finally:
repo._on_refresh_done = None # noqa: SLF001
if not matrix_prewarm_owner.shutdown(timeout=5.0):
logger.warning("matrix cache prewarm did not stop within 5 seconds")
mmanager = getattr(app.state, "mining_manager", None)
if mmanager:
mmanager.shutdown()
if app.state.scheduler:
app.state.scheduler.shutdown(wait=False)
ps = getattr(app.state, "pull_scheduler", None)
if ps:
ps.stop()
fsc = getattr(app.state, "financial_scheduler", None)
if fsc:
fsc.stop()
qs = getattr(app.state, "quote_service", None)
if qs:
qs.stop()
dsvc = getattr(app.state, "depth_service", None)
if dsvc:
dsvc.stop_polling()
wbot = getattr(app.state, "wecom_bot_service", None)
if wbot:
wbot.stop()
logger.info("shutdown")
if app.state.scheduler:
app.state.scheduler.shutdown(wait=False)
ps = getattr(app.state, "pull_scheduler", None)
if ps:
ps.stop()
fsc = getattr(app.state, "financial_scheduler", None)
if fsc:
fsc.stop()
qs = getattr(app.state, "quote_service", None)
if qs:
qs.stop()
dsvc = getattr(app.state, "depth_service", None)
if dsvc:
dsvc.stop_polling()
wbot = getattr(app.state, "wecom_bot_service", None)
if wbot:
wbot.stop()
logger.info("shutdown")
@asynccontextmanager
async def lifespan(app: FastAPI):
mining_process_lock = MiningProcessLock(settings.data_dir)
mining_process_lock.acquire()
try:
async with _application_lifespan(app):
yield
finally:
mining_process_lock.release()
app = FastAPI(
@@ -374,6 +427,7 @@ app.include_router(kline.router)
app.include_router(watchlist.router)
app.include_router(screener.router)
app.include_router(backtest.router)
app.include_router(mining.router)
app.include_router(intraday.router)
app.include_router(indices.router)
app.include_router(overview.router)
+43 -2
View File
@@ -1,6 +1,7 @@
"""扩展数据服务 — 配置管理 + 文件解析 + Parquet 存储。"""
from __future__ import annotations
import copy
import json
import logging
import re
@@ -187,17 +188,48 @@ class ExtConfig:
# 配置持久化
# ---------------------------------------------------------------------------
# load_all 进程内缓存: kline/screener/watchlist 等热路径每请求调用, 每次都
# iterdir + 逐 config.json read_text+parse 纯重复; 以配置目录的
# (目录名, mtime_ns, size) 签名失效 (新增/编辑/删除配置都会改变签名)。
_load_all_cache: dict[str, tuple[tuple, list[ExtConfig]]] = {}
def _ext_config_dir_signature(base: Path) -> tuple | None:
"""配置目录下所有 config.json 的 (目录名, mtime_ns, size) 签名; 出错返回 None (禁用缓存)。"""
try:
sig = []
for d in sorted(base.iterdir()):
cp = d / "config.json"
if d.is_dir() and cp.exists():
st = cp.stat()
sig.append((d.name, st.st_mtime_ns, st.st_size))
return tuple(sig)
except Exception: # noqa: BLE001
return None
class ExtConfigStore:
"""扩展数据配置文件读写 — 每个表独立目录 data/ext/{config_id}/config.json。"""
# 与创建端点 CreateExtReq.id 的 pattern 一致; load_all 之外的 config_id
# 来自 URL path 参数, 必须先过白名单再拼路径, 防止 ../ 穿越删除。
_VALID_ID = re.compile(r"^[a-zA-Z0-9_]+$")
def __init__(self, data_dir: Path) -> None:
self._base = data_dir / "ext_data"
def _config_path(self, config_id: str) -> Path:
if not self._VALID_ID.match(config_id):
raise ValueError(f"非法 config_id: {config_id!r}")
return self._base / config_id / "config.json"
def load_all(self) -> list[ExtConfig]:
# 兼容旧版: 如果目录为空且旧配置文件存在则迁移
sig = _ext_config_dir_signature(self._base)
if sig is not None:
cached = _load_all_cache.get(str(self._base))
if cached is not None and cached[0] == sig:
return copy.deepcopy(cached[1])
if not self._base.exists() or not any(self._base.iterdir()):
old = self._base.parent / "ext_configs.json"
if not old.exists():
@@ -215,10 +247,16 @@ class ExtConfigStore:
configs.append(ExtConfig.from_dict(raw))
except Exception as e:
logger.warning("扩展表配置解析失败 %s: %s", cp, e)
if sig is not None and configs:
# 缓存存私有副本, 命中时返回深拷贝, 调用方改配置对象不会污染缓存。
_load_all_cache[str(self._base)] = (sig, copy.deepcopy(configs))
return configs
def get(self, config_id: str) -> ExtConfig | None:
cp = self._config_path(config_id)
try:
cp = self._config_path(config_id)
except ValueError:
return None
if not cp.exists():
return None
try:
@@ -238,7 +276,10 @@ class ExtConfigStore:
def delete(self, config_id: str) -> bool:
import shutil
cp = self._config_path(config_id)
try:
cp = self._config_path(config_id)
except ValueError:
return False
if not cp.exists():
return False
shutil.rmtree(cp.parent, ignore_errors=True)
+34 -29
View File
@@ -154,7 +154,7 @@ def _sync_table(
)
def _merge_share_history(*frames: pl.DataFrame) -> pl.DataFrame:
def _merge_report_history(*frames: pl.DataFrame) -> pl.DataFrame:
valid = [
frame
for frame in frames
@@ -162,65 +162,74 @@ def _merge_share_history(*frames: pl.DataFrame) -> pl.DataFrame:
]
if not valid:
return pl.DataFrame()
return (
merged = (
pl.concat(valid, how="diagonal_relaxed")
.filter(pl.col("symbol").is_not_null() & pl.col("period_end").is_not_null())
.unique(subset=["symbol", "period_end"], keep="last")
.sort(["symbol", "period_end"])
)
# 同一 (symbol, period_end) 多条时保留 announce_date 最新一条 (业绩修正以最新公告为准)。
if "announce_date" in merged.columns:
merged = merged.sort(["symbol", "period_end", "announce_date"], nulls_last=True)
return merged.unique(subset=["symbol", "period_end"], keep="last").sort(
["symbol", "period_end"]
)
def _sync_shares_for_symbols(
def _sync_history_table_for_symbols(
table: str,
symbols: list[str],
data_dir: Path,
capset: CapabilitySet,
) -> int:
"""首次拉全量股本历史,后续更新最新记录并补齐新增标的历史。"""
existing = get_financial_df(data_dir, "shares")
"""历史累积同步: 保留已有各期记录, 仅拉最新期 + 为新标的补全量历史。
shares 同一模式若改为 latest_only 全量覆盖, 历史各期会在每次同步时
被冲掉, 财务因子将永远只有单期快照, 任何回测都是未来函数
"""
existing = get_financial_df(data_dir, table)
if existing.is_empty() or not {"symbol", "period_end"} <= set(existing.columns):
return _sync_table("shares", symbols, data_dir, capset, latest_only=False)
return _sync_table(table, symbols, data_dir, capset, latest_only=False)
existing_symbols = set(existing["symbol"].drop_nulls().to_list())
missing_symbols = [symbol for symbol in symbols if symbol not in existing_symbols]
missing_history = (
_fetch_table("shares", missing_symbols, capset, latest_only=False)
_fetch_table(table, missing_symbols, capset, latest_only=False)
if missing_symbols
else pl.DataFrame()
)
current_symbols = [symbol for symbol in symbols if symbol in existing_symbols]
latest = _fetch_table("shares", current_symbols, capset, latest_only=True)
merged = _merge_share_history(existing, missing_history, latest)
return _write_table("shares", merged, data_dir)
latest = _fetch_table(table, current_symbols, capset, latest_only=True)
merged = _merge_report_history(existing, missing_history, latest)
return _write_table(table, merged, data_dir)
def sync_metrics(data_dir: Path, capset: CapabilitySet) -> int:
"""同步核心财务指标 (metrics)。"""
"""同步核心财务指标 (metrics), 历史各期累积保留"""
symbols = _get_symbols(data_dir)
return _sync_table("metrics", symbols, data_dir, capset, latest_only=True)
return _sync_history_table_for_symbols("metrics", symbols, data_dir, capset)
def sync_income(data_dir: Path, capset: CapabilitySet) -> int:
"""同步利润表。"""
"""同步利润表, 历史各期累积保留"""
symbols = _get_symbols(data_dir)
return _sync_table("income", symbols, data_dir, capset, latest_only=True)
return _sync_history_table_for_symbols("income", symbols, data_dir, capset)
def sync_balance_sheet(data_dir: Path, capset: CapabilitySet) -> int:
"""同步资产负债表。"""
"""同步资产负债表, 历史各期累积保留"""
symbols = _get_symbols(data_dir)
return _sync_table("balance_sheet", symbols, data_dir, capset, latest_only=True)
return _sync_history_table_for_symbols("balance_sheet", symbols, data_dir, capset)
def sync_cash_flow(data_dir: Path, capset: CapabilitySet) -> int:
"""同步现金流量表。"""
"""同步现金流量表, 历史各期累积保留"""
symbols = _get_symbols(data_dir)
return _sync_table("cash_flow", symbols, data_dir, capset, latest_only=True)
return _sync_history_table_for_symbols("cash_flow", symbols, data_dir, capset)
def sync_shares(data_dir: Path, capset: CapabilitySet) -> int:
"""同步历史股本表。"""
symbols = _get_symbols(data_dir)
return _sync_shares_for_symbols(symbols, data_dir, capset)
return _sync_history_table_for_symbols("shares", symbols, data_dir, capset)
def sync_all(data_dir: Path, capset: CapabilitySet) -> dict[str, int]:
@@ -232,10 +241,8 @@ def sync_all(data_dir: Path, capset: CapabilitySet) -> dict[str, int]:
symbols = _get_symbols(data_dir)
results: dict[str, int] = {}
for table in FINANCIAL_TABLES:
results[table] = (
_sync_shares_for_symbols(symbols, data_dir, capset)
if table == "shares"
else _sync_table(table, symbols, data_dir, capset, latest_only=True)
results[table] = _sync_history_table_for_symbols(
table, symbols, data_dir, capset
)
# 同步完成后注册 DuckDB 视图
@@ -428,10 +435,8 @@ class FinancialScheduler:
symbols = _get_symbols(self._data_dir)
result: dict[str, int] = {}
for t in FINANCIAL_TABLES:
result[t] = (
_sync_shares_for_symbols(symbols, self._data_dir, self._capset)
if t == "shares"
else _sync_table(t, symbols, self._data_dir, self._capset, latest_only=True)
result[t] = _sync_history_table_for_symbols(
t, symbols, self._data_dir, self._capset
)
self._record_sync(t)
_refresh_financials_views(self._data_dir)
+121
View File
@@ -0,0 +1,121 @@
"""Weighted process-local limiter for memory-heavy jobs."""
from __future__ import annotations
import threading
import time
from collections.abc import Iterator
from contextlib import contextmanager
from typing import ClassVar, Literal
HeavyJobKind = Literal["normal", "mining"]
class HeavyJobLimitTimeoutError(TimeoutError):
"""Raised when a heavy-job slot cannot be acquired before its deadline."""
class HeavyJobCancelledError(RuntimeError):
"""Raised when slot acquisition is cancelled while waiting."""
class HeavyJobLimiter:
"""A weighted limiter where normal jobs cost one slot and mining costs two."""
_WEIGHTS: ClassVar[dict[HeavyJobKind, int]] = {"normal": 1, "mining": 2}
def __init__(self, capacity: int = 2, *, cancel_poll_interval: float = 0.05) -> None:
if capacity <= 0:
raise ValueError("capacity must be positive")
if cancel_poll_interval <= 0:
raise ValueError("cancel_poll_interval must be positive")
self.capacity = capacity
self._cancel_poll_interval = cancel_poll_interval
self._used = 0
self._acquired = {"normal": 0, "mining": 0}
self._condition = threading.Condition()
@property
def in_use(self) -> int:
with self._condition:
return self._used
@property
def available(self) -> int:
with self._condition:
return self.capacity - self._used
def acquire(
self,
kind: HeavyJobKind = "normal",
*,
timeout: float | None = None,
cancel_event: threading.Event | None = None,
) -> bool:
"""Wait for capacity and return ``False`` on cancellation or timeout."""
weight = self._weight(kind)
if weight > self.capacity:
raise ValueError(f"{kind} requires {weight} slots, capacity is {self.capacity}")
if timeout is not None and timeout < 0:
raise ValueError("timeout must be non-negative")
deadline = None if timeout is None else time.monotonic() + timeout
with self._condition:
while True:
if cancel_event is not None and cancel_event.is_set():
return False
if self._used + weight <= self.capacity:
self._used += weight
self._acquired[kind] += 1
return True
remaining = None if deadline is None else deadline - time.monotonic()
if remaining is not None and remaining <= 0:
return False
wait_for = remaining
if cancel_event is not None:
wait_for = self._cancel_poll_interval
if remaining is not None:
wait_for = min(wait_for, remaining)
self._condition.wait(wait_for)
def release(self, kind: HeavyJobKind = "normal") -> None:
"""Return capacity previously acquired for ``kind``."""
weight = self._weight(kind)
with self._condition:
if self._acquired[kind] == 0:
raise RuntimeError(f"cannot release unacquired {kind} capacity")
self._acquired[kind] -= 1
self._used -= weight
self._condition.notify_all()
@contextmanager
def slot(
self,
kind: HeavyJobKind = "normal",
*,
timeout: float | None = None,
cancel_event: threading.Event | None = None,
) -> Iterator[HeavyJobLimiter]:
"""Acquire weighted capacity for the duration of a ``with`` block."""
acquired = self.acquire(kind, timeout=timeout, cancel_event=cancel_event)
if not acquired:
if cancel_event is not None and cancel_event.is_set():
raise HeavyJobCancelledError(f"{kind} job was cancelled while waiting")
raise HeavyJobLimitTimeoutError(f"timed out waiting for {kind} job capacity")
try:
yield self
finally:
self.release(kind)
@classmethod
def _weight(cls, kind: HeavyJobKind) -> int:
try:
return cls._WEIGHTS[kind]
except KeyError as exc:
raise ValueError(f"unsupported heavy job kind: {kind!r}") from exc
shared_heavy_job_limiter = HeavyJobLimiter(capacity=2)
# Short alias for entry points that prefer the existing module-singleton naming style.
heavy_job_limiter = shared_heavy_job_limiter
+8 -4
View File
@@ -15,7 +15,7 @@ import polars as pl
from app.data_providers.base import AssetType
from app.indicators.pipeline import filter_halt_days
from app.market_time import cn_now
from app.market_time import CN_TZ, cn_now, cn_today
from app.services import preferences
from app.tickflow.capabilities import Cap, CapabilitySet
from app.tickflow.client import get_client
@@ -266,7 +266,9 @@ def sync_daily_by_quotes(repo: KlineRepository) -> int:
if df.is_empty():
return 0
today = _date.today()
# 分区日期用北京交易日 (与 quote_service._build_daily 的 cn_today 一致),
# 避免 UTC 服务器在盘中把日分区写成服务器本地日期。
today = cn_today()
daily_df = df.with_columns(pl.lit(today).cast(pl.Date).alias("date"))
# 过滤停牌 (open/high 为 0; close 可能被填充为前收盘价, 不能用全零判断)
@@ -833,8 +835,10 @@ def fetch_minute_single(
) -> pl.DataFrame:
"""实时拉取单股单日分钟 K(不写入本地)。优先自定义分钟源, 回退 TickFlow。"""
from datetime import datetime
start_time = datetime(trade_date.year, trade_date.month, trade_date.day, 9, 25, 0)
end_time = datetime(trade_date.year, trade_date.month, trade_date.day, 15, 5, 0)
# 北京时间窗口必须带时区: naive datetime 会被 .timestamp() 按服务器本地时区解释,
# UTC 容器上窗口整体偏移 8 小时, 分时补拉必然为空。
start_time = datetime(trade_date.year, trade_date.month, trade_date.day, 9, 25, 0, tzinfo=CN_TZ)
end_time = datetime(trade_date.year, trade_date.month, trade_date.day, 15, 5, 0, tzinfo=CN_TZ)
# 自定义数据源分流: 与 sync_minute_batch 一致, 配了自定义分钟源时走 custom provider,
# 避免无 TickFlow Pro+ 权限的用户分时图首次打开(本地无数据)时补拉失败返回空。
+248
View File
@@ -0,0 +1,248 @@
"""市场主线(板块/概念)识别 — 基于涨停梯队的历史聚合。
用户判据的量化: 主升阶段的主线 = 同一概念内涨停家数多最高连板高
梯队档位填得满(2 板到最高板之间不断层)对每个交易日按概念聚合涨停梯队,
截面 rank 归一后加权成主线分, 持久化为日频时序, 供市场环境页展示
"什么阶段走什么主升"
口径限制(重要): 概念成分来自 ext_gn_ths 快照(本地自 2026-07 起留存, 无历史
版本)历史主线是把"今天的成分"回看历史 早年存在归属漂移(新概念不会
出现在旧时段成分调整会错归属)MEMBERSHIP_NOTE API 返回给前端展示
性能: 全量回填只窄扫 enriched 4 列并先过滤连板 >=1(全历史 ~10 万行),
join 概念映射后 group_by, 峰值内存 <100MB
"""
from __future__ import annotations
import logging
from datetime import date
from pathlib import Path
import polars as pl
from app.services.rps_rotation import _load_concept_map_df
logger = logging.getLogger(__name__)
MEMBERSHIP_NOTE = (
"概念成分为当前快照回看历史(本地自 2026-07 起留存, 无历史版本), "
"早年主线存在归属漂移, 越近越准"
)
MAINLINE_DIR = "mainline_history"
_TOP_PER_DAY = 30 # 每日持久化的主线数(按分数截断)
_INDUSTRY_LEVEL = 2 # 行业主线取前两级(如 计算机-软件开发)
_MIN_LIMIT_UP = 3 # 单概念当日最少涨停家数(低于此不参与排名)
# 主线分权重: 概念内涨停家数 / 最高连板 / 梯队档位数 / 二板以上家数
_SCORE_WEIGHTS = {
"limit_up_count": 0.35,
"max_boards": 0.25,
"rungs_filled": 0.25,
"ge2_count": 0.15,
}
def _resolve_filter_config(filter_cfg: dict | None) -> dict:
"""解析过滤配置; None 时读用户偏好(宽基/风格标签过滤, 见 preferences 文档)。"""
if filter_cfg is not None:
return {
"min_members": int(filter_cfg.get("min_members", 4)),
"max_members": int(filter_cfg.get("max_members", 600)),
"blacklist": {str(x) for x in filter_cfg.get("blacklist") or []},
}
try:
from app.services import preferences
cfg = preferences.get_mainline_filter_config()
return {
"min_members": int(cfg["min_members"]),
"max_members": int(cfg["max_members"]),
"blacklist": set(cfg["blacklist"]),
}
except Exception:
return {"min_members": 4, "max_members": 600, "blacklist": set()}
def mainline_path(data_dir: Path) -> Path:
return data_dir / MAINLINE_DIR / "part.parquet"
def load_mainline_history(data_dir: Path, kind: str = "concept") -> pl.DataFrame:
"""读取主线时序(全部 kind), 不存在返回空 DataFrame。"""
p = mainline_path(data_dir)
if not p.exists():
return pl.DataFrame()
try:
df = pl.read_parquet(p)
except Exception as e:
logger.warning("load_mainline_history failed: %s", e)
return pl.DataFrame()
if df.is_empty() or "kind" not in df.columns:
return df
return df.filter(pl.col("kind") == kind)
def _industry_member(member: str, kind: str) -> str:
"""行业维度取前 _INDUSTRY_LEVEL 级; 概念原样返回。"""
if kind != "industry":
return member
return "-".join(member.split("-")[:_INDUSTRY_LEVEL])
def compute_mainline_range(repo, data_dir: Path, start: date, end: date,
kind: str = "concept",
filter_cfg: dict | None = None) -> pl.DataFrame:
"""计算 [start, end] 每日主线排行(按 _SCORE_WEIGHTS 加权截面分)。
filter_cfg: {"min_members", "max_members", "blacklist"}; None 时读用户偏好
宽基/风格标签(融资融券/沪深股通等数千成分)按成员数上限过滤,
用户黑名单按名称过滤(不论大小)修改配置后重算主线生效
返回列: date, kind, member, limit_up_count, ge2_count, max_boards,
boards_sum, rungs_filled, leader_symbol, score, rank空数据返回空表
"""
if start > end:
return pl.DataFrame()
enriched_dir = repo.store.data_dir / "kline_daily_enriched"
if not enriched_dir.exists():
return pl.DataFrame()
map_df, _ = _load_concept_map_df(repo, kind)
if map_df.is_empty():
return pl.DataFrame()
cfg = _resolve_filter_config(filter_cfg)
if cfg["min_members"] > 1 or cfg["max_members"] < 5000 or cfg["blacklist"]:
member_counts = map_df.group_by(kind).len().rename({"len": "_members"})
member_counts = member_counts.filter(
pl.col("_members").ge(cfg["min_members"])
& pl.col("_members").le(cfg["max_members"])
& ~pl.col(kind).is_in(sorted(cfg["blacklist"]))
)
allowed = member_counts.select(kind)
map_df = map_df.join(allowed, on=kind, how="semi")
if map_df.is_empty():
return pl.DataFrame()
limit_rows = (
pl.scan_parquet(enriched_dir / "**" / "*.parquet")
.select(["date", "symbol", "consecutive_limit_ups", "amount"])
.filter(
(pl.col("date") >= start) & (pl.col("date") <= end)
& (pl.col("consecutive_limit_ups") >= 1)
)
.collect()
)
if limit_rows.is_empty():
return pl.DataFrame()
limit_rows = limit_rows.with_columns(pl.col("symbol").str.to_uppercase().alias("_sym_up"))
joined = limit_rows.join(map_df, on="_sym_up", how="inner")
if joined.is_empty():
return pl.DataFrame()
joined = joined.with_columns(
pl.col(kind).map_elements(
lambda m: _industry_member(str(m), kind),
return_dtype=pl.Utf8,
).alias("member")
)
agg = (
joined.group_by(["date", "member"])
.agg(
pl.len().alias("limit_up_count"),
(pl.col("consecutive_limit_ups") >= 2).sum().alias("ge2_count"),
pl.col("consecutive_limit_ups").max().alias("max_boards"),
pl.col("consecutive_limit_ups").sum().alias("boards_sum"),
pl.col("consecutive_limit_ups")
.filter(pl.col("consecutive_limit_ups") >= 2)
.n_unique()
.alias("rungs_filled"),
pl.col("symbol")
.sort_by(
pl.col("consecutive_limit_ups"), pl.col("amount"),
descending=[True, True],
)
.first()
.alias("leader_symbol"),
)
)
# 截面 rank 归一(0-1) → 加权主线分(0-100)。分母 max(n-1,1) 保证单概念日不除零。
agg = agg.filter(pl.col("limit_up_count") >= _MIN_LIMIT_UP)
norm_exprs = []
for col in _SCORE_WEIGHTS:
norm_exprs.append(
((pl.col(col).rank(method="average") - 1.0)
/ pl.max_horizontal(pl.len().over("date") - 1, 1)).over("date").alias(f"_{col}_r")
)
agg = agg.with_columns(norm_exprs)
agg = agg.with_columns(
(
100.0 * sum(
_SCORE_WEIGHTS[col] * pl.col(f"_{col}_r") for col in _SCORE_WEIGHTS
)
).alias("score")
)
agg = agg.with_columns(
pl.col("score").rank(method="ordinal", descending=True).over("date").alias("rank")
)
result = (
agg.filter(pl.col("rank") <= _TOP_PER_DAY)
.drop([f"_{col}_r" for col in _SCORE_WEIGHTS])
.with_columns(pl.lit(kind).alias("kind"))
.select([
"date", "kind", "member", "limit_up_count", "ge2_count",
"max_boards", "boards_sum", "rungs_filled", "leader_symbol",
"score", "rank",
])
.sort(["date", "rank"])
)
return result
def upsert_mainline_history(data_dir: Path, new_rows: pl.DataFrame) -> None:
"""按 (date, kind) 整日覆盖 upsert; schema 以 new_rows 为权威(同 regime 模式)。"""
if new_rows.is_empty() or "date" not in new_rows.columns:
return
p = mainline_path(data_dir)
p.parent.mkdir(parents=True, exist_ok=True)
old = pl.read_parquet(p) if p.exists() else pl.DataFrame()
if old.is_empty():
combined = new_rows
else:
# 按 (date, kind) 整日覆盖: anti-join 掉本次重算的 (日, 维度) 组合
kept = old.join(
new_rows.select(["date", "kind"]).unique(),
on=["date", "kind"],
how="anti",
)
target_cols = new_rows.columns
keep_exprs = [
pl.col(c) if c in kept.columns else pl.lit(None).alias(c)
for c in target_cols
]
kept = kept.select(keep_exprs)
combined = pl.concat([kept, new_rows.select(target_cols)], how="vertical_relaxed")
combined = combined.sort(["date", "kind", "rank"])
combined.write_parquet(p)
def compute_mainline_incremental(repo, data_dir: Path, *, today: date | None = None,
kind: str = "concept") -> pl.DataFrame:
"""增量补算主线(供 daily_pipeline / 手动触发): 补 enriched 已有而主线缺失的日。"""
today = today or date.today()
from app.services.regime_builder import enriched_date_set
enriched_dates = enriched_date_set(repo)
existing = load_mainline_history(data_dir, kind)
existing_dates = set(existing["date"].to_list()) if not existing.is_empty() else set()
missing = sorted(d for d in enriched_dates if d not in existing_dates and d <= today)
if not missing:
return pl.DataFrame()
logger.info("mainline incremental(%s): compute %d days", kind, len(missing))
new_rows = compute_mainline_range(repo, data_dir, missing[0], missing[-1], kind=kind)
if not new_rows.is_empty():
upsert_mainline_history(data_dir, new_rows)
return new_rows
+255
View File
@@ -0,0 +1,255 @@
"""市场情绪周期阶段(冰点/启动/主升/高潮/退潮/修复) — 纯函数模块。
regime_builder 5 state(强势/偏强/震荡/偏弱/弱势)并存:
- state: 综合情绪分(赚钱/投机/抗跌/趋势 4 维加权), 回测环境过滤与挖掘在用, 不动
- phase: 基于"连板梯队"的阶段(用户判据: 高度宽度晋级率梯队完整度),
刻画情绪周期位置(冰点启动主升高潮退潮), 供市场环境页分析与主线识别
驱动量(全部可从已存储的 consecutive_limit_ups 派生, 2020-08 起全历史可回算):
- height 高度: 当日最高连板数
- first_board 首板宽度: 首板(1 连板)家数
- ge2/ge3/ge5 宽度: N 板以上家数
- promo 晋级率: 昨日连板池今日继续封板的比例 ( <10 家记 null)
- seal_rate 封板率: regime 已有列
- ladder_completeness 梯队完整度: 2..height 档位中非空占比
阈值标定: 2020-08~2026-08 全市场 1454 个交易日的 p10/p60/p90 分位数
(标定脚本一次性运行, 不提交); 关键异常段抽查(2024-09/10 rallyclimaxebb,
2024-01/02 微盘退潮)人工核过归属阈值集中在下方, 调整只需改这里
"""
from __future__ import annotations
import logging
import polars as pl
logger = logging.getLogger(__name__)
# ───────────────────────── 阶段词汇 ─────────────────────────
PHASE_ICE = "ice"
PHASE_IGNITE = "ignite"
PHASE_RALLY = "rally"
PHASE_CLIMAX = "climax"
PHASE_EBB = "ebb"
PHASE_REPAIR = "repair"
PHASE_LABELS = {
PHASE_ICE: "冰点",
PHASE_IGNITE: "启动",
PHASE_RALLY: "主升",
PHASE_CLIMAX: "高潮",
PHASE_EBB: "退潮",
PHASE_REPAIR: "修复",
}
# 规则判定优先级: 高潮 > 主升 > 退潮 > 启动 > 冰点 > 修复(兜底)
_PHASE_PRIORITY = (PHASE_CLIMAX, PHASE_RALLY, PHASE_EBB, PHASE_IGNITE, PHASE_ICE)
# ───────────────────────── 阈值(标定自 2020-08~2026-08 分位数) ─────────────────────────
# 高潮: 情绪极端宣泄 — 二板以上宽度或首板数达到 p90 的 ~2 倍以上(历史 <2% 天数)
CLIMAX_GE2 = 50 # p90(25) 的 2 倍
CLIMAX_FIRST_BOARD = 220 # p90(88) 的 2.5 倍
# 主升: 高度/宽度/晋级率同时高于中位 (p60), 或晋级率极强 (p85+)
RALLY_HEIGHT = 7 # p60
RALLY_GE2 = 15 # p60
RALLY_PROMO = 0.23 # p60
RALLY_PROMO_ALT = 0.30 # p85+
RALLY_GE2_ALT = 12
RALLY_HEIGHT_ALT = 5
# 退潮: 晋级率崩至 p20 以下且宽度自近期高位回落; 或晋级率/封板率双弱
EBB_PROMO = 0.15 # p20
EBB_PROMO_STRICT = 0.13
EBB_SEAL = 0.57 # ~p10-p15
EBB_RECENT_GE2 = 12 # 5 日前 ge2 高于此才认定"自高位退潮"
EBB_RECENT_HEIGHT = 6
# 启动: 宽度/高度自低位扩张且晋级率恢复
IGNITE_GE2_DELTA = 3 # ge2 较 5 日前增加量
IGNITE_GE2 = 8 # p20-p40
IGNITE_PROMO = 0.20 # ~p55
IGNITE_HEIGHT_DELTA = 1 # height 较 5 日前抬升
IGNITE_HEIGHT = 5 # p40
IGNITE_PROMO_SOFT = 0.19
# 冰点: 高度/宽度/首板同时贴地 (p10)
ICE_HEIGHT = 4 # p10-p20
ICE_GE2 = 6 # p10
ICE_FIRST_BOARD = 24 # p10
# 晋级率最小池(家数), 低于此记 null(小样本噪声)
PROMO_MIN_POOL = 10
# 平滑与持续性: EMA alpha≈1/3 (约 5 日), 阶段切换需连续 CONFIRM_DAYS 日同标签
_EMA_ALPHA = 1.0 / 3.0
_CONFIRM_DAYS = 2
# 大盘弱档否决: 正面阶段(主升/高潮/启动)不允许出现在 5 档 state 为弱势/偏弱的日子。
# 涨停梯队可能与大盘背离(如 2024-01 微盘崩期间中字头涨停生态走强), 该否决
# 保证"主升"标签在大盘层面也成立; state 列缺失时(单元测试)不启用否决。
_POSITIVE_PHASES = frozenset({PHASE_CLIMAX, PHASE_RALLY, PHASE_IGNITE})
_VETO_STATES = frozenset({"weak", "lean_weak"})
def with_prev_consecutive(df: pl.DataFrame) -> pl.DataFrame:
"""按 symbol 追加昨日连板数列 _prev_consec (供晋级率)。
df 需含 symbol/date/consecutive_limit_ups; 输入应覆盖前一交易日
(调用方保证 warmup 或直接传全量), 每个符号首行 _prev_consec null
"""
if "_prev_consec" in df.columns:
return df
return (
df.sort(["symbol", "date"])
.with_columns(
pl.col("consecutive_limit_ups").shift(1).over("symbol").alias("_prev_consec")
)
)
def ladder_daily_aggs() -> list[pl.Expr]:
"""group_by("date").agg(...) 可直接拼接的梯队聚合表达式。
要求 df consecutive_limit_ups; _prev_consec 时附带晋级率分子/分母
"""
consec = pl.col("consecutive_limit_ups")
exprs = [
consec.eq(1).sum().alias("first_board"),
consec.ge(2).sum().alias("ge2_count"),
consec.ge(3).sum().alias("ge3_count"),
consec.ge(5).sum().alias("ge5_count"),
consec.filter(consec.ge(2)).n_unique().alias("rungs_filled"),
]
return exprs
def ladder_promo_aggs() -> list[pl.Expr]:
"""晋级率聚合(分子/分母); 要求 df 已含 _prev_consec 列。"""
prev = pl.col("_prev_consec")
consec = pl.col("consecutive_limit_ups")
return [
prev.ge(1).sum().alias("promo_pool"),
(prev.ge(1) & consec.eq(prev + 1)).sum().alias("promo_ok"),
]
def finalize_ladder_row(r: dict) -> dict:
"""把聚合行的梯队原始值整理为持久化字段(晋级率/ladder_completeness)。"""
height = int(r.get("max_consecutive") or 0)
rungs = int(r.get("rungs_filled") or 0)
completeness = (rungs / (height - 1)) if height >= 3 else 0.0
pool = int(r.get("promo_pool") or 0)
ok = int(r.get("promo_ok") or 0)
promo = (ok / pool) if pool >= PROMO_MIN_POOL else None
return {
"first_board": int(r.get("first_board") or 0),
"ge2_count": int(r.get("ge2_count") or 0),
"ge3_count": int(r.get("ge3_count") or 0),
"ge5_count": int(r.get("ge5_count") or 0),
"ladder_completeness": round(completeness, 4),
"promo_pool": pool,
"promo_rate": round(promo, 4) if promo is not None else None,
}
def _ema(values: list[float], alpha: float = _EMA_ALPHA) -> list[float]:
out: list[float] = []
cur = None
for v in values:
if v is None or v != v: # None 或 NaN
if cur is None:
out.append(None)
continue
out.append(cur) # ffill: 缺失沿用上一平滑值
continue
cur = v if cur is None else cur + alpha * (v - cur)
out.append(cur)
# 前向回填: 序列开头缺失用首个有效值
first_valid = next((i for i, x in enumerate(out) if x is not None), None)
if first_valid is not None:
for i in range(first_valid):
out[i] = out[first_valid]
else:
out = [0.0] * len(values)
return out
def classify_phase_series(daily: pl.DataFrame) -> pl.DataFrame:
"""对完整日序打阶段标签, 追加 phase 列。
输入列: date, max_consecutive, first_board, ge2_count, promo_rate, seal_rate
(promo_rate 允许 null)处理: promo 前向填充 各驱动 EMA 平滑
逐日规则判定(按优先级) 连续 _CONFIRM_DAYS 日同标签才切换(持续性)
"""
required = {"date", "max_consecutive", "first_board", "ge2_count", "promo_rate", "seal_rate"}
missing = required - set(daily.columns)
if missing:
raise ValueError(f"classify_phase_series 缺少列: {sorted(missing)}")
rows = daily.sort("date")
n = rows.height
states = rows["state"].to_list() if "state" in rows.columns else None
height_s = _ema([float(v) if v is not None else None for v in rows["max_consecutive"].to_list()])
first_s = _ema([float(v) if v is not None else None for v in rows["first_board"].to_list()])
ge2_s = _ema([float(v) if v is not None else None for v in rows["ge2_count"].to_list()])
promo_s = _ema([float(v) if v is not None else None for v in rows["promo_rate"].to_list()])
seal_s = _ema([float(v) if v is not None else None for v in rows["seal_rate"].to_list()])
def raw_label(i: int) -> str:
h, fb, g2, pr, sr = height_s[i], first_s[i], ge2_s[i], promo_s[i], seal_s[i]
g2_prev = ge2_s[max(0, i - 5)]
h_prev = height_s[max(0, i - 5)]
# 高潮
if g2 >= CLIMAX_GE2 or fb >= CLIMAX_FIRST_BOARD:
return PHASE_CLIMAX
# 主升
if h >= RALLY_HEIGHT and g2 >= RALLY_GE2 and pr >= RALLY_PROMO:
return PHASE_RALLY
if pr >= RALLY_PROMO_ALT and g2 >= RALLY_GE2_ALT and h >= RALLY_HEIGHT_ALT:
return PHASE_RALLY
# 冰点: 高度/宽度/首板同时贴地 — 优先于退潮(持续死寂的市场是"冰点"
# 而非"自高位退潮"; 退潮的规则 B 不带 from_high 条件, 顺序反了会把
# 长期冰点误标成退潮)
if h <= ICE_HEIGHT and g2 <= ICE_GE2 and fb <= ICE_FIRST_BOARD:
return PHASE_ICE
# 退潮: 自高位回落 + 晋级率坍塌, 或晋级/封板双弱
from_high = g2_prev >= EBB_RECENT_GE2 or h_prev >= EBB_RECENT_HEIGHT
if from_high and (pr <= EBB_PROMO and g2 < g2_prev):
return PHASE_EBB
if pr <= EBB_PROMO_STRICT and sr <= EBB_SEAL:
return PHASE_EBB
# 启动: 自低位扩张
if g2 - g2_prev >= IGNITE_GE2_DELTA and g2 >= IGNITE_GE2 and pr >= IGNITE_PROMO:
return PHASE_IGNITE
if h - h_prev >= IGNITE_HEIGHT_DELTA and h >= IGNITE_HEIGHT and pr >= IGNITE_PROMO_SOFT:
return PHASE_IGNITE
return PHASE_REPAIR
labels: list[str] = []
current = None
pending: str | None = None
pending_run = 0
for i in range(n):
raw = raw_label(i)
if (
states is not None
and raw in _POSITIVE_PHASES
and states[i] in _VETO_STATES
):
raw = PHASE_REPAIR
if current is None:
current = raw
labels.append(raw)
continue
if raw == current:
labels.append(current)
pending, pending_run = None, 0
continue
if raw == pending:
pending_run += 1
else:
pending, pending_run = raw, 1
if pending_run >= _CONFIRM_DAYS:
current = raw
labels.append(current)
pending, pending_run = None, 0
else:
labels.append(current)
return daily.with_columns(
pl.Series("phase", labels, dtype=pl.Utf8).alias("phase")
).sort("date")
@@ -0,0 +1,48 @@
from __future__ import annotations
import threading
from collections.abc import Callable
class MatrixCachePrewarmOwner:
def __init__(self) -> None:
self._lock = threading.Lock()
self._cancel_event = threading.Event()
self._thread: threading.Thread | None = None
@property
def cancel_event(self) -> threading.Event:
return self._cancel_event
def schedule(self, target: Callable[[], None]) -> bool:
with self._lock:
if self._cancel_event.is_set():
return False
if self._thread is not None and self._thread.is_alive():
return False
thread = threading.Thread(
target=self._run,
args=(target,),
name="matrix-cache-prewarm",
daemon=True,
)
self._thread = thread
thread.start()
return True
def shutdown(self, timeout: float = 5.0) -> bool:
self._cancel_event.set()
with self._lock:
thread = self._thread
if thread is None:
return True
thread.join(timeout=max(0.0, timeout))
return not thread.is_alive()
def _run(self, target: Callable[[], None]) -> None:
try:
target()
finally:
with self._lock:
if self._thread is threading.current_thread():
self._thread = None
+793
View File
@@ -0,0 +1,793 @@
"""Trusted promotion and explicit publication for persisted mining candidates.
Concurrency protection is process-local; V1 remains a single-process service.
"""
from __future__ import annotations
import hashlib
import json
import math
import os
import re
import threading
import uuid
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any
import polars as pl
import pyarrow.parquet as pq
from app.backtest.candidates import CandidateStore
from app.backtest.factor import FACTOR_COLUMNS
from app.backtest.mining import compute_candidate_signature, evaluate_candidate_gate
from app.services.mining_jobs import SUCCESS_RUN_STATUSES, MiningRunStore
from app.strategy.ai_generator import AIStrategyGenerator
from app.strategy.engine import StrategyEngine
_MAX_ARTIFACT_BYTES = 8 * 1024 * 1024
_MAX_ARTIFACT_ROWS = 32
_MAX_UNCOMPRESSED_BYTES = 32 * 1024 * 1024
_MAX_DEFINITION_BYTES = 16 * 1024
_FACTOR_IDS = frozenset(str(item["id"]) for item in FACTOR_COLUMNS)
_FACTOR_DEFINITION_FIELDS = frozenset({"kind", "factor_names", "scoring", "directions"})
_EXISTING_DEFINITION_FIELDS = frozenset({"kind", "strategy_id"})
_BACKLINK_FIELDS = frozenset({"promoted_candidate_id", "published_strategy_id"})
_PUBLISHED_PREFIX = "mined_factor_"
_STRATEGY_ID_PATTERN = re.compile(r"^mined_factor_[A-Za-z0-9_-]{1,49}$")
_ARTIFACT_SCHEMA = {
"signature": pl.String,
"name": pl.String,
"kind": pl.String,
"factor_names_json": pl.String,
"strategy_id": pl.String,
"definition_json": pl.String,
"regime_state": pl.String,
"score": pl.Float64,
"oos_return": pl.Float64,
"oos_sharpe": pl.Float64,
"oos_max_drawdown": pl.Float64,
"oos_positive_fold_ratio": pl.Float64,
"oos_n_trades": pl.Int64,
"confidence": pl.String,
"valid_folds": pl.Int64,
"skipped_folds": pl.Int64,
"promoted_candidate_id": pl.String,
"published_strategy_id": pl.String,
}
_LOCK = threading.RLock()
class MiningCandidateService:
def __init__(
self,
data_dir: Path | str,
run_store: MiningRunStore,
candidate_store: CandidateStore,
strategy_engine: StrategyEngine,
*,
strategy_cache_invalidator: Callable[[Path], None] | None = None,
monitor_state_invalidator: Callable[[], None] | None = None,
) -> None:
self.data_dir = Path(data_dir).resolve()
self.run_store = run_store
self.candidate_store = candidate_store
self.strategy_engine = strategy_engine
if strategy_cache_invalidator is None:
from app.services.strategy_cache import clear_cache
strategy_cache_invalidator = clear_cache
self._strategy_cache_invalidator = strategy_cache_invalidator
self._monitor_state_invalidator = monitor_state_invalidator
def promote(self, run_id: str, signature: str) -> dict[str, Any]:
with _LOCK:
manifest, summary, path, frame, row, definition = self._load_candidate(
run_id, signature
)
kind, name, source_id, config = self._promotion_config(
manifest, summary, row, definition
)
item = self.candidate_store.create_or_get_by_provenance(
origin_run_id=run_id,
candidate_signature=signature,
kind=kind,
name=name,
source_id=source_id,
config=config,
metrics=self._candidate_metrics(row),
data_as_of=_optional_string(summary.get("data_as_of")),
status="pending",
)
if row.get("promoted_candidate_id") != item["id"]:
self._write_backlink(
path,
frame,
signature,
"promoted_candidate_id",
item["id"],
)
return item
def publish(self, run_id: str, signature: str) -> dict[str, Any]:
with _LOCK:
manifest, summary, path, frame, row, definition = self._load_candidate(
run_id, 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"),
)
if not gate.qualified:
raise ValueError(
"candidate does not meet the promotion gate: "
+ "; ".join(gate.reasons)
)
asset_type = self._asset_type(manifest)
if definition["kind"] == "existing_strategy":
published_id = str(definition["strategy_id"])
self._validate_publication_backlink(row, published_id)
self._verify_public_strategy(published_id, asset_type)
if row.get("published_strategy_id") != published_id:
self._write_backlink(
path,
frame,
signature,
"published_strategy_id",
published_id,
)
return {"ok": True, "strategy_id": published_id}
published_id = self._validate_published_id(
_published_strategy_id(run_id, signature)
)
self._validate_publication_backlink(row, published_id)
source = self._render_factor_strategy(
manifest, summary, row, definition, published_id
)
target = self._custom_strategy_path(published_id)
created = self._publish_or_verify_source(
target,
source,
published_id,
run_id,
signature,
asset_type,
)
try:
self._strategy_cache_invalidator(self.data_dir)
if self._monitor_state_invalidator is not None:
self._monitor_state_invalidator()
except Exception as exc:
rollback_error = (
self._rollback_created_source(target, source) if created else None
)
message = f"strategy runtime invalidation failed: {exc}"
if rollback_error is not None:
message += f"; strategy rollback failed: {rollback_error}"
raise RuntimeError(message) from exc
if row.get("published_strategy_id") != published_id:
self._write_backlink(
path,
frame,
signature,
"published_strategy_id",
published_id,
)
return {"ok": True, "strategy_id": published_id}
def _load_candidate(
self,
run_id: str,
signature: str,
) -> tuple[
dict[str, Any],
dict[str, Any],
Path,
pl.DataFrame,
dict[str, Any],
dict[str, Any],
]:
if not isinstance(signature, str) or not signature:
raise ValueError("candidate signature must not be empty")
manifest = self.run_store.get(run_id)
if manifest is None:
raise KeyError(run_id)
if manifest.get("status") not in SUCCESS_RUN_STATUSES:
raise ValueError("mining candidates require a successful run")
path = self._registered_candidates_path(manifest)
frame = self._read_artifact(path)
matches = frame.filter(pl.col("signature") == signature)
if matches.height == 0:
raise KeyError(signature)
if matches.height != 1:
raise ValueError("mining candidates artifact contains a duplicate signature")
row = matches.row(0, named=True)
self._asset_type(manifest)
definition = self._validate_definition(manifest, row, signature)
summary = self._validated_summary(self.run_store.read_summary(run_id))
return manifest, summary, path, frame, row, definition
def _registered_candidates_path(self, manifest: Mapping[str, Any]) -> Path:
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, Mapping):
raise ValueError("mining candidates artifact is not registered")
raw_path = artifacts.get("candidates")
if raw_path != "candidates.parquet":
raise ValueError(
"only the registered candidates.parquet artifact can be used"
)
path = self.run_store.artifact_path(
str(manifest["run_id"]), "candidates"
)
if path.is_symlink() or not path.is_file():
raise ValueError("mining candidates artifact is unavailable")
return path
@staticmethod
def _read_artifact(path: Path) -> pl.DataFrame:
try:
size = path.stat().st_size
except OSError as exc:
raise RuntimeError("failed to read mining candidates artifact") from exc
if size <= 0 or size > _MAX_ARTIFACT_BYTES:
raise ValueError("mining candidates artifact exceeds its size limit")
try:
parquet = pq.ParquetFile(path)
metadata = parquet.metadata
schema = pl.read_parquet_schema(path)
except Exception as exc:
raise RuntimeError("failed to read mining candidates artifact") from exc
if metadata is None or metadata.num_rows > _MAX_ARTIFACT_ROWS:
raise ValueError("mining candidates artifact exceeds its row limit")
uncompressed = sum(
metadata.row_group(index).total_byte_size
for index in range(metadata.num_row_groups)
)
if uncompressed > _MAX_UNCOMPRESSED_BYTES:
raise ValueError(
"mining candidates artifact exceeds its uncompressed size limit"
)
missing = sorted(set(_ARTIFACT_SCHEMA) - set(schema))
if missing:
raise ValueError(f"mining candidates artifact schema is invalid: {missing}")
invalid_types = [
name for name, dtype in _ARTIFACT_SCHEMA.items()
if schema[name] != dtype
]
if invalid_types:
raise ValueError(
"mining candidates artifact column types are invalid: "
f"{invalid_types}"
)
try:
return pl.read_parquet(path, columns=list(_ARTIFACT_SCHEMA))
except Exception as exc:
raise RuntimeError("failed to read mining candidates artifact") from exc
def _validate_definition(
self,
manifest: Mapping[str, Any],
row: Mapping[str, Any],
signature: str,
) -> dict[str, Any]:
raw_definition = row.get("definition_json")
if (
not isinstance(raw_definition, str)
or len(raw_definition.encode("utf-8")) > _MAX_DEFINITION_BYTES
):
raise ValueError("mining candidate definition is unavailable")
try:
definition = json.loads(raw_definition)
except json.JSONDecodeError as exc:
raise ValueError("mining candidate definition is invalid") from exc
if not isinstance(definition, dict):
raise ValueError("mining candidate definition must be an object")
kind = definition.get("kind")
if kind == "factor_rank":
if set(definition) != _FACTOR_DEFINITION_FIELDS:
raise ValueError("factor candidate definition contains unsupported fields")
expected_kind = "factor_combination"
elif kind == "existing_strategy":
if set(definition) != _EXISTING_DEFINITION_FIELDS:
raise ValueError("existing candidate definition contains unsupported fields")
expected_kind = "existing_strategy"
else:
raise ValueError(f"unsupported mining candidate kind: {kind!r}")
if row.get("kind") != expected_kind:
raise ValueError("mining candidate kind does not match its definition")
if (
not isinstance(row.get("name"), str)
or not row["name"]
or len(row["name"]) > 80
or row.get("regime_state") != "overall"
):
raise ValueError("mining candidate row metadata is invalid")
if kind == "factor_rank":
self._validate_factor_definition(manifest, row, definition)
else:
self._validate_existing_definition(manifest, row, definition)
computed = compute_candidate_signature(definition)
if row.get("signature") != signature or computed != signature:
raise ValueError("mining candidate signature does not match its definition")
score = row.get("score")
if (
score is not None
and (
isinstance(score, bool)
or not isinstance(score, (int, float))
or not math.isfinite(float(score))
)
):
raise ValueError("candidate score must be finite")
return definition
@staticmethod
def _request(manifest: Mapping[str, Any]) -> Mapping[str, Any]:
request = manifest.get("request")
if not isinstance(request, Mapping):
raise ValueError("mining origin request is unavailable")
return request
def _validate_factor_definition(
self,
manifest: Mapping[str, Any],
row: Mapping[str, Any],
definition: Mapping[str, Any],
) -> None:
factor_names = definition.get("factor_names")
if (
not isinstance(factor_names, list)
or not 1 <= len(factor_names) <= 4
or len(set(factor_names)) != len(factor_names)
or any(not isinstance(name, str) or not name for name in factor_names)
):
raise ValueError("factor candidate must contain 1 to 4 unique factors")
scoring = definition.get("scoring")
directions = definition.get("directions")
if not isinstance(scoring, Mapping) or set(scoring) != set(factor_names):
raise ValueError("factor scoring keys must exactly match factor names")
if not isinstance(directions, Mapping) or set(directions) != set(factor_names):
raise ValueError("factor direction keys must exactly match factor names")
for factor_name in factor_names:
weight = scoring[factor_name]
if (
isinstance(weight, bool)
or not isinstance(weight, (int, float))
or not math.isfinite(float(weight))
or float(weight) <= 0.0
):
raise ValueError("factor weights must be finite and positive")
if directions[factor_name] not in {"high", "low"}:
raise ValueError("factor directions must be high or low")
unknown = sorted(set(factor_names) - _FACTOR_IDS)
if unknown:
raise ValueError(f"factor candidate contains unknown factors: {unknown}")
selected = self._request(manifest).get("factor_names")
if not isinstance(selected, list) or not set(factor_names) <= set(selected):
raise ValueError("factor candidate contains factors absent from its origin request")
try:
persisted_names = json.loads(str(row.get("factor_names_json")))
except json.JSONDecodeError as exc:
raise ValueError("factor candidate factor list is invalid") from exc
if persisted_names != factor_names or row.get("strategy_id") is not None:
raise ValueError("factor candidate columns do not match its definition")
def _validate_existing_definition(
self,
manifest: Mapping[str, Any],
row: Mapping[str, Any],
definition: Mapping[str, Any],
) -> None:
strategy_id = definition.get("strategy_id")
if not isinstance(strategy_id, str) or not strategy_id:
raise ValueError("existing candidate strategy ID is invalid")
selected = self._request(manifest).get("strategy_ids")
if not isinstance(selected, list) or strategy_id not in selected:
raise ValueError("existing candidate was absent from its origin request")
if row.get("strategy_id") != strategy_id:
raise ValueError("existing candidate strategy ID does not match its definition")
asset_type = str(self._request(manifest).get("asset_type") or "stock")
self._verify_public_strategy(strategy_id, asset_type)
def _promotion_config(
self,
manifest: Mapping[str, Any],
summary: Mapping[str, Any],
row: Mapping[str, Any],
definition: Mapping[str, Any],
) -> tuple[str, str, str, dict[str, Any]]:
request = self._request(manifest)
signature = str(row["signature"])
provenance = {
"origin_run_id": str(manifest["run_id"]),
"candidate_signature": signature,
"regime_state": str(row.get("regime_state") or "overall"),
"algorithm_version": str(summary.get("algorithm_version") or "mining-v1"),
"methodology_version": str(summary.get("methodology_version") or "factor_v2"),
}
common = {
"start": request.get("start"),
"end": request.get("end"),
"asset_type": request.get("asset_type") or "stock",
"matching": "open_t+1",
"entry_fill": "open_t+1",
"exit_fill": "open_t+1",
"commission_pct": request.get("commission_pct", 0.0002),
"stamp_tax_pct": request.get("stamp_tax_pct", 0.0005),
"slippage_bps": request.get("slippage_bps", 5.0),
"mode": "position",
"minute_fill": False,
**provenance,
}
name = str(row.get("name") or signature)[:80]
if definition["kind"] == "existing_strategy":
strategy_id = str(definition["strategy_id"])
return "strategy", name, strategy_id, {
**common,
"strategy_id": strategy_id,
}
factor_names = list(definition["factor_names"])
source_id = _published_strategy_id(str(manifest["run_id"]), signature)
return "strategy", name, source_id, {
**common,
"strategy_id": source_id,
"factor_names": factor_names,
"directions": [definition["directions"][name] for name in factor_names],
"weights": [float(definition["scoring"][name]) for name in factor_names],
}
@staticmethod
def _candidate_metrics(row: Mapping[str, Any]) -> dict[str, Any]:
fields = (
"oos_sharpe",
"oos_return",
"oos_max_drawdown",
"oos_positive_fold_ratio",
"oos_n_trades",
"valid_folds",
"skipped_folds",
"confidence",
)
result: dict[str, Any] = {}
for field in fields:
value = row.get(field)
if value is None:
continue
if field == "confidence":
if not isinstance(value, str) or not value or len(value) > 32:
raise ValueError("candidate confidence is invalid")
elif (
isinstance(value, bool)
or not isinstance(value, (int, float))
or not math.isfinite(float(value))
):
raise ValueError(f"candidate metric {field} must be finite")
result[field] = value
return result
@staticmethod
def _validated_summary(summary: Mapping[str, Any]) -> dict[str, str]:
result: dict[str, str] = {}
for field in ("data_as_of", "algorithm_version", "methodology_version"):
value = summary.get(field)
if not isinstance(value, str) or not value or len(value) > 120:
raise ValueError(f"mining summary {field} is invalid")
result[field] = value
return result
@staticmethod
def _asset_type(manifest: Mapping[str, Any]) -> str:
asset_type = MiningCandidateService._request(manifest).get("asset_type")
if asset_type not in {"stock", "etf"}:
raise ValueError("mining origin asset_type is invalid")
return str(asset_type)
def _verify_public_strategy(
self,
strategy_id: str,
asset_type: str,
*,
path: Path | None = None,
run_id: str | None = None,
signature: str | None = None,
) -> None:
strategy = self.strategy_engine.get(strategy_id)
if strategy.meta.get("research_only"):
raise ValueError("research-only strategy cannot be published")
if strategy.execution_backend != "matrix_native":
raise ValueError("published strategy must be matrix-native")
if "1d" not in strategy.meta.get("timeframes", []):
raise ValueError("published strategy must support 1d")
if asset_type not in strategy.meta.get("asset_types", []):
raise ValueError("published strategy does not support the run asset type")
public_ids = {
str(meta.get("id")) for meta in self.strategy_engine.list_strategies()
}
if strategy_id not in public_ids:
raise ValueError("published strategy is not publicly discoverable")
if path is not None and (
strategy.source != "custom"
or strategy.file_path is None
or strategy.file_path.resolve() != path.resolve()
or strategy.meta.get("origin_run_id") != run_id
or strategy.meta.get("candidate_signature") != signature
):
raise ValueError("published strategy provenance is invalid")
@staticmethod
def _validate_published_id(strategy_id: str) -> str:
if not _STRATEGY_ID_PATTERN.fullmatch(strategy_id):
raise ValueError(
f"strategy_id must use {_PUBLISHED_PREFIX!r} and safe characters"
)
return strategy_id
@staticmethod
def _validate_publication_backlink(
row: Mapping[str, Any],
strategy_id: str,
) -> None:
existing = _optional_string(row.get("published_strategy_id"))
if existing is not None and existing != strategy_id:
raise ValueError("candidate publication backlink is inconsistent")
def _custom_strategy_path(self, strategy_id: str) -> Path:
unresolved_root = self.data_dir / "strategies" / "custom"
unresolved_root.mkdir(parents=True, exist_ok=True)
if unresolved_root.is_symlink():
raise ValueError("custom strategy directory must not be a symlink")
root = unresolved_root.resolve()
if not root.is_relative_to(self.data_dir):
raise ValueError("custom strategy directory escapes data_dir")
path = (root / f"{strategy_id}.py").resolve(strict=False)
if path.parent != root:
raise ValueError("strategy publication path escapes custom directory")
return path
def _render_factor_strategy(
self,
manifest: Mapping[str, Any],
summary: Mapping[str, Any],
row: Mapping[str, Any],
definition: Mapping[str, Any],
strategy_id: str,
) -> str:
asset_type = self._asset_type(manifest)
factor_names = list(definition["factor_names"])
scoring = {
name: float(definition["scoring"][name]) for name in factor_names
}
directions = {name: definition["directions"][name] for name in factor_names}
meta = {
"id": strategy_id,
"name": row["name"],
"description": "Published mining factor-rank candidate",
"tags": ["mining", "factor-rank"],
"asset_types": [asset_type],
"timeframes": ["1d"],
"research_only": False,
"origin_run_id": manifest["run_id"],
"candidate_signature": row["signature"],
"mining_algorithm_version": summary["algorithm_version"],
"factor_methodology_version": summary["methodology_version"],
"params": [
{
"id": "entry_score",
"label": "Entry minimum score",
"type": "float",
"default": 70.0,
"min": 0.0,
"max": 100.0,
"step": 5.0,
},
{
"id": "exit_score",
"label": "Exit maximum score",
"type": "float",
"default": 40.0,
"min": 0.0,
"max": 100.0,
"step": 5.0,
},
{
"id": "top_rank",
"label": "Daily selection limit",
"type": "int",
"default": 20,
"min": 1,
"max": 100,
"step": 1,
},
],
"scoring": {},
"order_by": "score",
"descending": True,
"limit": 100,
}
return (
'"""Trusted factor-rank strategy published from a mining run."""\n'
"from app.strategy.builtin.factor_rank_research import "
"FactorRankResearchMatrixStrategy\n\n"
f"META = {meta!r}\n\n"
'EXECUTION_BACKEND = "matrix_native"\n'
'ENTRY_SIGNALS = ["signal_factor_rank_entry"]\n'
'EXIT_SIGNALS = ["signal_factor_rank_exit"]\n'
"STOP_LOSS = -0.08\n"
"MAX_HOLD_DAYS = 30\n\n"
f"SCORING = {scoring!r}\n"
f"DIRECTIONS = {directions!r}\n"
"MATRIX_STRATEGY = FactorRankResearchMatrixStrategy(SCORING, DIRECTIONS)\n"
)
def _publish_or_verify_source(
self,
path: Path,
source: str,
strategy_id: str,
run_id: str,
signature: str,
asset_type: str,
) -> bool:
validation = AIStrategyGenerator().validate_code(source)
if not validation.get("valid"):
raise ValueError(
f"rendered strategy failed validation: {validation.get('error')}"
)
if validation.get("meta", {}).get("id") != strategy_id:
raise ValueError("rendered strategy META id is invalid")
if path.exists() or path.is_symlink():
self._verify_existing_source(
path, source, strategy_id, run_id, signature, asset_type
)
return False
if self.strategy_engine.has(strategy_id):
raise ValueError(f"strategy ID already exists: {strategy_id}")
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
linked = False
try:
with temporary.open("x", encoding="utf-8", newline="\n") as stream:
stream.write(source)
stream.flush()
os.fsync(stream.fileno())
try:
os.link(temporary, path)
except FileExistsError as exc:
raise ValueError(f"strategy path already exists: {strategy_id}") from exc
except OSError as exc:
raise RuntimeError("failed to create strategy source") from exc
linked = True
self._fsync_directory(path.parent)
try:
self.strategy_engine.reload()
self._verify_public_strategy(
strategy_id,
asset_type,
path=path,
run_id=run_id,
signature=signature,
)
except Exception as exc:
rollback_error = self._rollback_publication(path, temporary)
message = f"strategy publication failed: {exc}"
if rollback_error is not None:
message += f"; registry rollback failed: {rollback_error}"
raise RuntimeError(message) from exc
finally:
temporary.unlink(missing_ok=True)
if linked:
self._fsync_directory(path.parent)
return True
def _verify_existing_source(
self,
path: Path,
source: str,
strategy_id: str,
run_id: str,
signature: str,
asset_type: str,
) -> None:
if path.is_symlink() or not path.is_file():
raise ValueError("strategy publication target is not a regular file")
try:
existing_source = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise ValueError("existing strategy source is unreadable") from exc
if existing_source != source:
raise ValueError(f"strategy ID collision: {strategy_id}")
try:
self._verify_public_strategy(
strategy_id,
asset_type,
path=path,
run_id=run_id,
signature=signature,
)
except ValueError:
self.strategy_engine.reload()
self._verify_public_strategy(
strategy_id,
asset_type,
path=path,
run_id=run_id,
signature=signature,
)
def _rollback_publication(self, path: Path, temporary: Path) -> Exception | None:
try:
if path.exists() and temporary.exists() and os.path.samefile(path, temporary):
path.unlink()
self._fsync_directory(path.parent)
self.strategy_engine.reload()
except Exception as exc:
return exc
return None
def _rollback_created_source(self, path: Path, source: str) -> Exception | None:
try:
if path.is_file() and not path.is_symlink():
if path.read_text(encoding="utf-8") != source:
return RuntimeError("published strategy source changed before rollback")
path.unlink()
self._fsync_directory(path.parent)
self.strategy_engine.reload()
except Exception as exc:
return exc
return None
@staticmethod
def _fsync_directory(path: Path) -> None:
if os.name == "nt":
return
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
@staticmethod
def _write_backlink(
path: Path,
frame: pl.DataFrame,
signature: str,
field: str,
value: str,
) -> None:
if field not in _BACKLINK_FIELDS:
raise ValueError("unsupported mining candidate backlink")
matches = frame.filter(pl.col("signature") == signature)
if matches.height != 1:
raise ValueError("mining candidate backlink target is no longer unique")
updated = frame.with_columns(
pl.when(pl.col("signature") == signature)
.then(pl.lit(value))
.otherwise(pl.col(field))
.alias(field)
)
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
try:
updated.write_parquet(temporary)
with temporary.open("r+b") as stream:
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
except Exception as exc:
temporary.unlink(missing_ok=True)
raise RuntimeError("failed to update mining candidate artifact") from exc
def _published_strategy_id(run_id: str, signature: str) -> str:
payload = f"{run_id}\0{signature}".encode()
digest = hashlib.blake2b(payload, digest_size=10).hexdigest()
return f"{_PUBLISHED_PREFIX}{digest}"
def _optional_string(value: Any) -> str | None:
return value if isinstance(value, str) and value else None
+620
View File
@@ -0,0 +1,620 @@
"""Persistent metadata and bounded event storage for mining runs."""
from __future__ import annotations
import json
import math
import os
import re
import threading
import uuid
from collections.abc import Collection, Mapping
from datetime import UTC, date, datetime
from enum import Enum
from pathlib import Path
from typing import Any, Literal, cast
MiningRunStatus = Literal[
"queued",
"running",
"cancelling",
"succeeded",
"succeeded_with_budget_exhausted",
"failed",
"cancelled",
"interrupted",
"skipped_prerequisite",
]
ArtifactName = Literal["factors", "correlation", "candidates", "folds"]
RUN_STATUSES: frozenset[str] = frozenset(
{
"queued",
"running",
"cancelling",
"succeeded",
"succeeded_with_budget_exhausted",
"failed",
"cancelled",
"interrupted",
"skipped_prerequisite",
}
)
ACTIVE_RUN_STATUSES: frozenset[str] = frozenset({"queued", "running", "cancelling"})
SUCCESS_RUN_STATUSES: frozenset[str] = frozenset({"succeeded", "succeeded_with_budget_exhausted"})
TERMINAL_RUN_STATUSES: frozenset[str] = RUN_STATUSES - ACTIVE_RUN_STATUSES
ARTIFACT_NAMES: frozenset[str] = frozenset({"factors", "correlation", "candidates", "folds"})
MAX_EVENTS = 256
MAX_EVENT_PAYLOAD_BYTES = 16 * 1024
_SCHEMA_VERSION = 1
_RUN_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
_STORE_LOCK = threading.RLock()
_ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = {
"queued": frozenset(
{"running", "cancelling", "cancelled", "failed", "interrupted", "skipped_prerequisite"}
),
"running": frozenset(
{
"cancelling",
"succeeded",
"succeeded_with_budget_exhausted",
"failed",
"cancelled",
"interrupted",
"skipped_prerequisite",
}
),
"cancelling": frozenset(
{
"succeeded",
"succeeded_with_budget_exhausted",
"failed",
"cancelled",
"interrupted",
}
),
"succeeded": frozenset(),
"succeeded_with_budget_exhausted": frozenset(),
"failed": frozenset(),
"cancelled": frozenset(),
"interrupted": frozenset(),
"skipped_prerequisite": frozenset(),
}
class MiningRunStoreError(RuntimeError):
pass
class MiningRunValidationError(MiningRunStoreError, ValueError):
pass
class InvalidMiningStatusTransitionError(MiningRunStoreError):
pass
def canonicalize_request(request: Mapping[str, Any]) -> dict[str, Any]:
"""Return a JSON-safe request whose mapping order cannot affect its signature."""
if not isinstance(request, Mapping):
raise MiningRunValidationError("request must be a mapping")
return cast(dict[str, Any], _canonicalize_json_value(request))
def compute_run_signature(request: Mapping[str, Any], data_fingerprint: Any) -> str:
"""Hash every request dimension and the data fingerprint using BLAKE2b."""
import hashlib
signature_input = {
"request": canonicalize_request(request),
"data_fingerprint": _canonicalize_json_value(data_fingerprint),
}
payload = json.dumps(
signature_input,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.blake2b(payload, digest_size=32).hexdigest()
class MiningRunStore:
"""Store one manifest, summary, artifact registry, and bounded event log per run."""
def __init__(self, data_dir: Path | str | None = None) -> None:
if data_dir is None:
from app.config import settings
data_dir = settings.data_dir
self.runs_root = (Path(data_dir).resolve() / "research" / "mining" / "runs").resolve()
self.runs_root.mkdir(parents=True, exist_ok=True)
def create(
self,
request: Mapping[str, Any],
data_fingerprint: Any,
*,
run_id: str | None = None,
) -> dict[str, Any]:
"""Create a queued run and its initial on-disk files."""
safe_run_id = self._validate_run_id(uuid.uuid4().hex if run_id is None else run_id)
canonical_request = canonicalize_request(request)
canonical_fingerprint = _canonicalize_json_value(data_fingerprint)
now = _now_iso()
manifest = {
"schema_version": _SCHEMA_VERSION,
"run_id": safe_run_id,
"status": "queued",
"request": canonical_request,
"data_fingerprint": canonical_fingerprint,
"run_signature": compute_run_signature(canonical_request, canonical_fingerprint),
"artifacts": {},
"created_at": now,
"updated_at": now,
"started_at": None,
"finished_at": None,
"cancellation_requested_at": None,
"error": None,
}
run_dir = self._run_dir(safe_run_id)
with _STORE_LOCK:
if run_dir.exists():
raise MiningRunValidationError(f"run already exists: {safe_run_id}")
run_dir.mkdir(parents=False)
_atomic_write_json(run_dir / "summary.json", {})
_atomic_write_text(run_dir / "events.jsonl", "")
_atomic_write_json(run_dir / "manifest.json", manifest)
return manifest
def get(self, run_id: str) -> dict[str, Any] | None:
"""Read a manifest, filling defaults for manifests written by older versions."""
safe_run_id = self._validate_run_id(run_id)
with _STORE_LOCK:
return self._read_manifest_path(
self._run_dir(safe_run_id) / "manifest.json", safe_run_id
)
def transition_status(
self,
run_id: str,
status: MiningRunStatus,
*,
error: str | None = None,
) -> dict[str, Any]:
"""Apply a validated state transition and atomically replace the manifest."""
if status not in RUN_STATUSES:
raise MiningRunValidationError(f"unsupported mining run status: {status!r}")
safe_run_id = self._validate_run_id(run_id)
with _STORE_LOCK:
manifest = self._required_manifest(safe_run_id)
return self._transition_locked(manifest, status, error=error)
def write_summary(self, run_id: str, summary: Mapping[str, Any]) -> dict[str, Any]:
"""Atomically replace a run's scalar or compact aggregate summary."""
if not isinstance(summary, Mapping):
raise MiningRunValidationError("summary must be a mapping")
safe_run_id = self._validate_run_id(run_id)
clean_summary = cast(dict[str, Any], _canonicalize_json_value(summary))
with _STORE_LOCK:
self._required_manifest(safe_run_id)
_atomic_write_json(self._run_dir(safe_run_id) / "summary.json", clean_summary)
return clean_summary
def read_summary(self, run_id: str) -> dict[str, Any]:
safe_run_id = self._validate_run_id(run_id)
with _STORE_LOCK:
self._required_manifest(safe_run_id)
path = self._run_dir(safe_run_id) / "summary.json"
if not path.exists():
return {}
value = _read_json(path)
if not isinstance(value, dict):
raise MiningRunStoreError(f"invalid summary for run {safe_run_id}")
return value
def artifact_path(self, run_id: str, name: ArtifactName) -> Path:
"""Return the safe default Parquet path for an artifact."""
safe_run_id = self._validate_run_id(run_id)
self._validate_artifact_name(name)
return self._safe_artifact_path(safe_run_id, Path(f"{name}.parquet"))
def register_artifact(
self,
run_id: str,
name: ArtifactName,
path: Path | str | None = None,
) -> dict[str, Any]:
"""Record a Parquet artifact path relative to its owning run directory."""
safe_run_id = self._validate_run_id(run_id)
self._validate_artifact_name(name)
artifact_path = self._safe_artifact_path(
safe_run_id,
Path(path) if path is not None else Path(f"{name}.parquet"),
)
if artifact_path.suffix.lower() != ".parquet":
raise MiningRunValidationError("mining artifacts must use the .parquet suffix")
run_dir = self._run_dir(safe_run_id)
relative_path = artifact_path.relative_to(run_dir).as_posix()
with _STORE_LOCK:
manifest = self._required_manifest(safe_run_id)
artifacts = dict(manifest.get("artifacts") or {})
artifacts[name] = relative_path
manifest["artifacts"] = artifacts
manifest["updated_at"] = _now_iso()
_atomic_write_json(run_dir / "manifest.json", manifest)
return manifest
def append_event(
self,
run_id: str,
event_type: str,
payload: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Append a compact event, retaining only the most recent ``MAX_EVENTS`` entries."""
safe_run_id = self._validate_run_id(run_id)
if not isinstance(event_type, str):
raise MiningRunValidationError("event_type must be a string")
clean_event_type = event_type.strip()
if not clean_event_type or len(clean_event_type) > 64:
raise MiningRunValidationError("event_type must contain 1 to 64 characters")
raw_payload: Mapping[str, Any] | Any = {} if payload is None else payload
if not isinstance(raw_payload, Mapping):
raise MiningRunValidationError("event payload must be a mapping")
clean_payload = cast(dict[str, Any], _canonicalize_json_value(raw_payload))
encoded_payload = json.dumps(
clean_payload,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
if len(encoded_payload) > MAX_EVENT_PAYLOAD_BYTES:
raise MiningRunValidationError(
f"event payload exceeds {MAX_EVENT_PAYLOAD_BYTES} byte limit"
)
with _STORE_LOCK:
self._required_manifest(safe_run_id)
path = self._run_dir(safe_run_id) / "events.jsonl"
events = self._read_events_path(path)
next_id = max((event["id"] for event in events), default=0) + 1
event = {
"id": next_id,
"timestamp": _now_iso(),
"type": clean_event_type,
"payload": clean_payload,
}
events.append(event)
events = events[-MAX_EVENTS:]
text = "".join(
json.dumps(item, ensure_ascii=False, allow_nan=False, separators=(",", ":")) + "\n"
for item in events
)
_atomic_write_text(path, text)
return event
def read_events(self, run_id: str, *, after_id: int = 0) -> list[dict[str, Any]]:
"""Read retained events whose monotonically increasing ID is greater than ``after_id``."""
if isinstance(after_id, bool) or not isinstance(after_id, int) or after_id < 0:
raise MiningRunValidationError("after_id must be a non-negative integer")
safe_run_id = self._validate_run_id(run_id)
with _STORE_LOCK:
self._required_manifest(safe_run_id)
events = self._read_events_path(self._run_dir(safe_run_id) / "events.jsonl")
return [event for event in events if event["id"] > after_id]
def list_runs(
self,
*,
limit: int = 50,
statuses: Collection[MiningRunStatus] | None = None,
) -> list[dict[str, Any]]:
"""Return recent valid manifests without exposing store paths to API callers."""
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 200:
raise MiningRunValidationError("limit must be between 1 and 200")
allowed_statuses = None if statuses is None else set(statuses)
if allowed_statuses is not None and not allowed_statuses <= RUN_STATUSES:
raise MiningRunValidationError("statuses contains an unsupported mining run status")
try:
paths = list(self.runs_root.glob("*/manifest.json"))
except OSError as exc:
raise MiningRunStoreError("failed to scan mining run manifests") from exc
manifests: list[dict[str, Any]] = []
for path in paths:
run_id = path.parent.name
if not _RUN_ID_PATTERN.fullmatch(run_id):
continue
try:
manifest = self._read_manifest_path(
self._run_dir(run_id) / "manifest.json",
run_id,
)
except MiningRunStoreError:
continue
if manifest is None:
continue
if allowed_statuses is not None and manifest.get("status") not in allowed_statuses:
continue
manifests.append(manifest)
manifests.sort(key=_manifest_sort_key, reverse=True)
return manifests[:limit]
def find_by_signature(
self,
run_signature: str,
*,
statuses: Collection[MiningRunStatus] | None = None,
) -> dict[str, Any] | None:
"""Find the newest run with a signature, optionally restricted to selected statuses."""
if not isinstance(run_signature, str) or not run_signature:
raise MiningRunValidationError("run_signature must not be empty")
allowed_statuses = None if statuses is None else set(statuses)
if allowed_statuses is not None and not allowed_statuses <= RUN_STATUSES:
raise MiningRunValidationError("statuses contains an unsupported mining run status")
# Directory enumeration and manifest reads stay outside the write lock. Atomic replacements
# make each individual read coherent while avoiding a lock around a potentially slow scan.
try:
paths = list(self.runs_root.glob("*/manifest.json"))
except OSError as exc:
raise MiningRunStoreError("failed to scan mining run manifests") from exc
matches: list[dict[str, Any]] = []
for path in paths:
run_id = path.parent.name
if not _RUN_ID_PATTERN.fullmatch(run_id):
continue
try:
manifest_path = self._run_dir(run_id) / "manifest.json"
manifest = self._read_manifest_path(manifest_path, run_id)
except MiningRunStoreError:
continue
if manifest is None or manifest.get("run_signature") != run_signature:
continue
if allowed_statuses is not None and manifest.get("status") not in allowed_statuses:
continue
matches.append(manifest)
return max(matches, key=_manifest_sort_key, default=None)
def recover_interrupted(self) -> int:
"""Mark runs without a live in-process worker as interrupted at startup."""
try:
paths = list(self.runs_root.glob("*/manifest.json"))
except OSError as exc:
raise MiningRunStoreError("failed to scan mining run manifests") from exc
candidates: list[str] = []
for path in paths:
run_id = path.parent.name
if not _RUN_ID_PATTERN.fullmatch(run_id):
continue
try:
manifest_path = self._run_dir(run_id) / "manifest.json"
manifest = self._read_manifest_path(manifest_path, run_id)
except MiningRunStoreError:
continue
if manifest is not None and manifest.get("status") in ACTIVE_RUN_STATUSES:
candidates.append(run_id)
recovered = 0
for run_id in candidates:
with _STORE_LOCK:
manifest = self._required_manifest(run_id)
if manifest["status"] not in ACTIVE_RUN_STATUSES:
continue
self._transition_locked(manifest, "interrupted", error=None)
recovered += 1
return recovered
def _transition_locked(
self,
manifest: dict[str, Any],
status: MiningRunStatus,
*,
error: str | None,
) -> dict[str, Any]:
previous = manifest["status"]
if previous == status:
return manifest
if status not in _ALLOWED_TRANSITIONS[previous]:
raise InvalidMiningStatusTransitionError(
f"cannot transition from {previous} to {status}"
)
now = _now_iso()
manifest["status"] = status
manifest["updated_at"] = now
if status == "running" and not manifest.get("started_at"):
manifest["started_at"] = now
if status == "cancelling":
manifest["cancellation_requested_at"] = now
if status in TERMINAL_RUN_STATUSES:
manifest["finished_at"] = now
if error is not None:
manifest["error"] = str(error)
_atomic_write_json(self._run_dir(manifest["run_id"]) / "manifest.json", manifest)
return manifest
def _required_manifest(self, run_id: str) -> dict[str, Any]:
manifest = self._read_manifest_path(self._run_dir(run_id) / "manifest.json", run_id)
if manifest is None:
raise KeyError(run_id)
return manifest
def _read_manifest_path(self, path: Path, run_id: str) -> dict[str, Any] | None:
if not path.exists():
return None
value = _read_json(path)
if not isinstance(value, dict):
raise MiningRunStoreError(f"invalid manifest for run {run_id}")
return self._normalize_manifest(value, run_id)
def _normalize_manifest(self, value: dict[str, Any], run_id: str) -> dict[str, Any]:
status = value.get("status", "queued")
if status not in RUN_STATUSES:
raise MiningRunStoreError(f"invalid status in manifest for run {run_id}")
raw_request = value.get("request") if isinstance(value.get("request"), dict) else {}
data_fingerprint = value.get("data_fingerprint")
signature = value.get("run_signature")
if not isinstance(signature, str) or not signature:
signature = compute_run_signature(raw_request, data_fingerprint)
raw_artifacts = value.get("artifacts") if isinstance(value.get("artifacts"), dict) else {}
artifacts: dict[str, str] = {}
run_dir = self._run_dir(run_id)
for name, raw_path in raw_artifacts.items():
if name not in ARTIFACT_NAMES or not isinstance(raw_path, str):
continue
try:
safe_path = self._safe_artifact_path(run_id, Path(raw_path))
except MiningRunValidationError:
continue
if safe_path.suffix.lower() == ".parquet":
artifacts[name] = safe_path.relative_to(run_dir).as_posix()
normalized = dict(value)
normalized.update(
{
"schema_version": value.get("schema_version", 0),
"run_id": run_id,
"status": status,
"request": raw_request,
"data_fingerprint": data_fingerprint,
"run_signature": signature,
"artifacts": artifacts,
"created_at": value.get("created_at"),
"updated_at": value.get("updated_at") or value.get("created_at"),
"started_at": value.get("started_at"),
"finished_at": value.get("finished_at"),
"cancellation_requested_at": value.get("cancellation_requested_at"),
"error": value.get("error"),
}
)
return normalized
@staticmethod
def _read_events_path(path: Path) -> list[dict[str, Any]]:
if not path.exists():
return []
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as exc:
raise MiningRunStoreError(f"failed to read events file: {path}") from exc
events: list[dict[str, Any]] = []
for line in lines:
if not line.strip():
continue
try:
event = json.loads(line)
except json.JSONDecodeError as exc:
raise MiningRunStoreError(f"invalid events file: {path}") from exc
if (
not isinstance(event, dict)
or isinstance(event.get("id"), bool)
or not isinstance(event.get("id"), int)
or event["id"] <= 0
):
raise MiningRunStoreError(f"invalid event record: {path}")
if events and event["id"] <= events[-1]["id"]:
raise MiningRunStoreError(f"non-monotonic event IDs: {path}")
events.append(event)
return events[-MAX_EVENTS:]
def _run_dir(self, run_id: str) -> Path:
safe_run_id = self._validate_run_id(run_id)
candidate = (self.runs_root / safe_run_id).resolve()
if not candidate.is_relative_to(self.runs_root):
raise MiningRunValidationError("run path escapes mining runs root")
return candidate
def _safe_artifact_path(self, run_id: str, path: Path) -> Path:
run_dir = self._run_dir(run_id)
candidate = path if path.is_absolute() else run_dir / path
resolved = candidate.resolve()
if not resolved.is_relative_to(run_dir):
raise MiningRunValidationError("artifact path escapes its mining run directory")
return resolved
@staticmethod
def _validate_run_id(run_id: str) -> str:
if not isinstance(run_id, str) or not _RUN_ID_PATTERN.fullmatch(run_id):
raise MiningRunValidationError("run_id contains unsafe characters")
return run_id
@staticmethod
def _validate_artifact_name(name: str) -> None:
if name not in ARTIFACT_NAMES:
raise MiningRunValidationError(f"unsupported artifact name: {name!r}")
def _canonicalize_json_value(value: Any) -> Any:
if value is None or isinstance(value, (str, bool, int)):
return value
if isinstance(value, float):
if not math.isfinite(value):
raise MiningRunValidationError("non-finite numbers are not supported")
return value
if isinstance(value, Enum):
return _canonicalize_json_value(value.value)
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Path):
return str(value)
if isinstance(value, Mapping):
result: dict[str, Any] = {}
for key, item in value.items():
if not isinstance(key, str):
raise MiningRunValidationError("JSON mapping keys must be strings")
result[key] = _canonicalize_json_value(item)
return {key: result[key] for key in sorted(result)}
if isinstance(value, (list, tuple)):
return [_canonicalize_json_value(item) for item in value]
if isinstance(value, (set, frozenset)):
items = [_canonicalize_json_value(item) for item in value]
return sorted(
items,
key=lambda item: json.dumps(
item, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":")
),
)
raise MiningRunValidationError(f"value is not JSON serializable: {type(value).__name__}")
def _read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise MiningRunStoreError(f"failed to read JSON file: {path}") from exc
def _atomic_write_json(path: Path, value: Any) -> None:
try:
text = json.dumps(value, ensure_ascii=False, allow_nan=False, indent=2) + "\n"
except (TypeError, ValueError) as exc:
raise MiningRunValidationError("value is not JSON serializable") from exc
_atomic_write_text(path, text)
def _atomic_write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
try:
with temporary.open("w", encoding="utf-8", newline="\n") as stream:
stream.write(text)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
except OSError as exc:
temporary.unlink(missing_ok=True)
raise MiningRunStoreError(f"failed to write file: {path}") from exc
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _manifest_sort_key(manifest: dict[str, Any]) -> tuple[str, str]:
return (
str(manifest.get("updated_at") or manifest.get("created_at") or ""),
str(manifest.get("run_id") or ""),
)
+263
View File
@@ -0,0 +1,263 @@
"""Threaded orchestration for persistent mining jobs."""
from __future__ import annotations
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
from app.backtest.worker import make_worker_task, run_worker_task
from app.services.heavy_job_limiter import (
HeavyJobCancelledError,
shared_heavy_job_limiter,
)
from app.services.mining_jobs import (
ACTIVE_RUN_STATUSES,
SUCCESS_RUN_STATUSES,
TERMINAL_RUN_STATUSES,
MiningRunStore,
MiningRunValidationError,
compute_run_signature,
)
WorkerRunner = Callable[
[dict[str, Any], Callable[[dict[str, Any]], None], threading.Event],
dict[str, Any],
]
TaskFactory = Callable[[str, Path, dict[str, Any]], dict[str, Any]]
_SUCCESS_STATUSES = {"succeeded", "succeeded_with_budget_exhausted"}
_SHUTDOWN_JOIN_SECONDS = 1.0
class MiningJobManager:
"""Coordinate mining persistence, capacity, cancellation, and worker threads."""
def __init__(
self,
data_dir: Path | str,
worker_runner: WorkerRunner = run_worker_task,
task_factory: TaskFactory = make_worker_task,
) -> None:
self._data_dir = Path(data_dir).resolve()
self._store = MiningRunStore(self._data_dir)
self._worker_runner = worker_runner
self._task_factory = task_factory
self._lock = threading.RLock()
self._threads: dict[str, threading.Thread] = {}
self._cancel_events: dict[str, threading.Event] = {}
self._shutdown = False
@property
def store(self) -> MiningRunStore:
return self._store
def start(
self,
request: dict[str, Any],
data_fingerprint: Any,
force: bool = False,
source: str = "manual",
run_id: str | None = None,
) -> dict[str, Any]:
signature = compute_run_signature(request, data_fingerprint)
with self._lock:
if self._shutdown:
raise RuntimeError("mining job manager is shut down")
if not force:
active = self._store.find_by_signature(
signature,
statuses=ACTIVE_RUN_STATUSES,
)
if active is not None:
return active
succeeded = self._store.find_by_signature(
signature,
statuses=SUCCESS_RUN_STATUSES,
)
if succeeded is not None:
return succeeded
try:
manifest = self._store.create(
request,
data_fingerprint,
run_id=run_id,
)
except MiningRunValidationError:
if run_id is None:
raise
existing = self._store.get(run_id)
if existing is None:
raise
return existing
run_id = manifest["run_id"]
self._store.append_event(
run_id,
"queued",
{"status": "queued", "source": source},
)
self._start_thread_locked(run_id, source)
return manifest
def cancel(self, run_id: str) -> dict[str, Any]:
with self._lock:
manifest = self._store.get(run_id)
if manifest is None:
raise KeyError(run_id)
if manifest["status"] in TERMINAL_RUN_STATUSES:
return manifest
cancel_event = self._cancel_events.get(run_id)
if cancel_event is None:
cancelled = self._store.transition_status(run_id, "cancelled")
self._store.append_event(run_id, "cancelled", {"status": "cancelled"})
return cancelled
cancel_event.set()
if manifest["status"] != "cancelling":
manifest = self._store.transition_status(run_id, "cancelling")
self._store.append_event(run_id, "cancelling", {"status": "cancelling"})
return manifest
def shutdown(self) -> None:
with self._lock:
self._shutdown = True
run_ids = list(self._threads)
for run_id in run_ids:
self.cancel(run_id)
deadline = time.monotonic() + _SHUTDOWN_JOIN_SECONDS
current = threading.current_thread()
for run_id in run_ids:
with self._lock:
thread = self._threads.get(run_id)
if thread is None or thread is current:
continue
remaining = deadline - time.monotonic()
if remaining <= 0:
break
thread.join(timeout=remaining)
def recover_interrupted(self) -> int:
return self._store.recover_interrupted()
def _start_thread_locked(self, run_id: str, source: str) -> None:
if run_id in self._threads:
return
cancel_event = threading.Event()
thread = threading.Thread(
target=self._run_job,
args=(run_id, source, cancel_event),
name=f"mining-{run_id}",
daemon=True,
)
self._cancel_events[run_id] = cancel_event
self._threads[run_id] = thread
thread.start()
def _run_job(
self,
run_id: str,
source: str,
cancel_event: threading.Event,
) -> None:
try:
with shared_heavy_job_limiter.slot("mining", cancel_event=cancel_event):
if not self._mark_running(run_id, cancel_event):
return
manifest = self._store.get(run_id)
if manifest is None:
raise KeyError(run_id)
payload = {
"run_id": run_id,
"request": manifest["request"],
"data_fingerprint": manifest["data_fingerprint"],
"source": source,
}
task = self._task_factory("mining", self._data_dir, payload)
result = self._worker_runner(
task,
lambda progress: self._record_progress(run_id, progress, cancel_event),
cancel_event,
)
if not isinstance(result, dict):
raise TypeError("mining worker result must be a compact dict")
self._finish_success(run_id, result, cancel_event)
except HeavyJobCancelledError:
self._finish_cancelled(run_id)
except Exception as exc:
if cancel_event.is_set():
self._finish_cancelled(run_id)
else:
self._finish_failed(run_id, exc)
finally:
with self._lock:
self._threads.pop(run_id, None)
self._cancel_events.pop(run_id, None)
def _mark_running(self, run_id: str, cancel_event: threading.Event) -> bool:
with self._lock:
if cancel_event.is_set():
self._finish_cancelled_locked(run_id)
return False
self._store.transition_status(run_id, "running")
self._store.append_event(run_id, "running", {"status": "running"})
return True
def _record_progress(
self,
run_id: str,
progress: dict[str, Any],
cancel_event: threading.Event,
) -> None:
if not isinstance(progress, dict):
raise TypeError("mining progress must be a compact dict")
with self._lock:
if cancel_event.is_set():
return
self._store.append_event(run_id, "progress", progress)
self._store.write_summary(run_id, {"progress": progress})
def _finish_success(
self,
run_id: str,
result: dict[str, Any],
cancel_event: threading.Event,
) -> None:
status = result.get("status", "succeeded")
if status not in _SUCCESS_STATUSES:
raise ValueError(f"unsupported mining worker status: {status!r}")
with self._lock:
if cancel_event.is_set():
self._finish_cancelled_locked(run_id)
return
self._store.write_summary(run_id, result)
self._store.transition_status(run_id, status)
self._store.append_event(run_id, status, {"status": status})
def _finish_cancelled(self, run_id: str) -> None:
with self._lock:
self._finish_cancelled_locked(run_id)
def _finish_cancelled_locked(self, run_id: str) -> None:
manifest = self._store.get(run_id)
if manifest is None or manifest["status"] in TERMINAL_RUN_STATUSES:
return
self._store.transition_status(run_id, "cancelled")
self._store.append_event(run_id, "cancelled", {"status": "cancelled"})
def _finish_failed(self, run_id: str, exc: Exception) -> None:
message = str(exc)[:2000]
with self._lock:
manifest = self._store.get(run_id)
if manifest is None or manifest["status"] in TERMINAL_RUN_STATUSES:
return
self._store.transition_status(run_id, "failed", error=message)
self._store.append_event(
run_id,
"error",
{"status": "failed", "message": message},
)
+139
View File
@@ -0,0 +1,139 @@
"""Lightweight mining date availability checks shared by API and workers."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import date
from pathlib import Path
from typing import Any
from app.backtest.mining import (
nested_fold_count,
required_outer_folds,
required_trading_bars,
validation_config_for_profile,
)
from app.tickflow.repository import enriched_dirname
@dataclass(frozen=True)
class MiningAvailability:
asset_type: str
budget_profile: str
trading_bars: int
required_bars: int
outer_folds: int
required_outer_folds: int
eligible: bool
available_start: date | None
available_end: date | None
effective_start: date | None
effective_end: date | None
suggested_start: date | None
def to_dict(self) -> dict[str, Any]:
return {
key: value.isoformat() if isinstance(value, date) else value
for key, value in asdict(self).items()
}
def enriched_partition_dates(
data_dir: Path,
asset_type: str,
start: date | None = None,
end: date | None = None,
) -> list[date]:
root = data_dir / enriched_dirname(asset_type)
values: set[date] = set()
for partition in root.glob("date=*"):
try:
value = date.fromisoformat(partition.name.removeprefix("date="))
except ValueError:
continue
if start is not None and value < start:
continue
if end is not None and value > end:
continue
if (partition / "part.parquet").is_file():
values.add(value)
return sorted(values)
def mining_availability(
data_dir: Path,
*,
asset_type: str,
budget_profile: str,
start: date | None = None,
end: date | None = None,
) -> MiningAvailability:
if asset_type not in {"stock", "etf"}:
raise ValueError(f"unsupported mining asset type: {asset_type}")
if start is not None and end is not None and start > end:
raise ValueError("mining start must not be after end")
config = validation_config_for_profile(budget_profile)
required_folds = required_outer_folds(budget_profile)
required_bars = required_trading_bars(config, required_folds)
all_dates = enriched_partition_dates(data_dir, asset_type)
scoped = [
value
for value in all_dates
if (start is None or value >= start) and (end is None or value <= end)
]
dates_through_end = [
value for value in all_dates if end is None or value <= end
]
suggested_start = (
dates_through_end[-required_bars]
if len(dates_through_end) >= required_bars
else None
)
trading_bars = len(scoped)
return MiningAvailability(
asset_type=asset_type,
budget_profile=budget_profile,
trading_bars=trading_bars,
required_bars=required_bars,
outer_folds=nested_fold_count(trading_bars, config),
required_outer_folds=required_folds,
eligible=trading_bars >= required_bars,
available_start=all_dates[0] if all_dates else None,
available_end=all_dates[-1] if all_dates else None,
effective_start=scoped[0] if scoped else None,
effective_end=scoped[-1] if scoped else None,
suggested_start=suggested_start,
)
def require_mining_availability(
data_dir: Path,
*,
asset_type: str,
budget_profile: str,
start: date | None = None,
end: date | None = None,
) -> MiningAvailability:
availability = mining_availability(
data_dir,
asset_type=asset_type,
budget_profile=budget_profile,
start=start,
end=end,
)
if availability.eligible:
return availability
if availability.effective_start is None:
effective_range = "contains no enriched data"
else:
effective_range = (
f"{availability.effective_start.isoformat()} to "
f"{availability.effective_end.isoformat()}"
)
fold_label = "outer fold" if availability.required_outer_folds == 1 else "outer folds"
raise ValueError(
f"{budget_profile} mining requires at least {availability.required_bars} "
f"enriched trading bars for {availability.required_outer_folds} {fold_label}; "
f"effective range {effective_range} has {availability.trading_bars}"
)
@@ -0,0 +1,78 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import BinaryIO
class MiningProcessLockError(RuntimeError):
"""Another application process owns mining for this data directory."""
class MiningProcessLock:
def __init__(self, data_dir: Path) -> None:
self._path = Path(data_dir) / ".mining_process.lock"
self._stream: BinaryIO | None = None
def acquire(self) -> None:
if self._stream is not None:
return
self._path.parent.mkdir(parents=True, exist_ok=True)
stream = self._path.open("a+b")
try:
stream.seek(0, os.SEEK_END)
if stream.tell() == 0:
stream.write(b"0")
stream.flush()
os.set_inheritable(stream.fileno(), False)
_try_lock_file(stream)
except BaseException:
stream.close()
raise
self._stream = stream
def release(self) -> None:
stream = self._stream
if stream is None:
return
self._stream = None
try:
_unlock_file(stream)
finally:
stream.close()
def _try_lock_file(stream: BinaryIO) -> None:
if os.name == "nt":
import msvcrt
stream.seek(0)
try:
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
except OSError as exc:
raise MiningProcessLockError(
"another application process already owns mining for this data directory"
) from exc
return
import fcntl
try:
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
raise MiningProcessLockError(
"another application process already owns mining for this data directory"
) from exc
def _unlock_file(stream: BinaryIO) -> None:
if os.name == "nt":
import msvcrt
stream.seek(0)
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
return
import fcntl
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
+391
View File
@@ -0,0 +1,391 @@
"""Weekly scheduled mining orchestration and deterministic data claims."""
from __future__ import annotations
import hashlib
import json
import logging
import threading
from datetime import date, datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import polars as pl
from app.backtest.factor import FACTOR_COLUMNS, FACTOR_METHODOLOGY_VERSION
from app.backtest.mining import (
required_outer_folds,
required_trading_bars,
validation_config_for_profile,
)
from app.services import preferences
from app.services.mining_preflight import enriched_partition_dates
from app.services.regime_builder import load_regime_history, regime_path
logger = logging.getLogger(__name__)
BEIJING_TZ = ZoneInfo("Asia/Shanghai")
MINING_ALGORITHM_VERSION = "mining-v2"
FINGERPRINT_VERSION = "weekly-mining-data-v2"
_PROFILES = frozenset({"balanced", "strict"})
_CLAIM_LOCK = threading.Lock()
def beijing_now(now: datetime | None = None) -> datetime:
"""Return an aware Beijing datetime without depending on the server timezone."""
if now is None:
return datetime.now(BEIJING_TZ)
if now.tzinfo is None:
return now.replace(tzinfo=BEIJING_TZ)
return now.astimezone(BEIJING_TZ)
def beijing_date(now: datetime | None = None) -> date:
return beijing_now(now).date()
def iso_week(value: date) -> tuple[int, int]:
iso_year, week, _ = value.isocalendar()
return iso_year, week
def build_default_request(repo: Any, profile: str) -> dict[str, Any]:
"""Build the bounded V1 stock/full-market request used by the scheduler."""
if profile not in _PROFILES:
raise ValueError(f"unsupported mining profile: {profile}")
latest = repo.latest_enriched_date("stock")
end = latest.isoformat() if latest is not None else None
return {
"factor_names": [item["id"] for item in FACTOR_COLUMNS[:48]],
"strategy_ids": [],
"symbols": None,
"asset_type": "stock",
"start": None,
"end": end,
"budget_profile": profile,
"require_regime": True,
}
def build_data_fingerprint(
repo: Any,
app_state: Any,
request: dict[str, Any],
) -> dict[str, Any]:
"""Hash one stable managed generation plus source metadata."""
for _attempt in range(2):
fingerprint = _build_data_fingerprint_once(repo, app_state, request)
if repo.get_matrix_data_generation(fingerprint["asset_type"]) == fingerprint["generation"]:
return fingerprint
raise ValueError("enriched data changed while building the mining fingerprint")
def _build_data_fingerprint_once(
repo: Any,
app_state: Any,
request: dict[str, Any],
) -> dict[str, Any]:
data_dir = Path(repo.store.data_dir)
asset_type = str(request.get("asset_type") or "stock")
enriched_root = (
data_dir / "kline_daily_enriched"
if asset_type == "stock"
else data_dir / f"kline_{asset_type}_enriched"
)
module_root = Path(__file__).resolve().parents[1]
components = {
"version": FINGERPRINT_VERSION,
"asset_type": asset_type,
"generation": repo.get_matrix_data_generation(asset_type),
"latest_enriched_date": _iso_or_none(repo.latest_enriched_date(asset_type)),
"enriched": _enriched_metadata(enriched_root),
"instruments": _instrument_metadata(repo, asset_type),
"regime": _path_metadata(regime_path(data_dir), root=data_dir),
"algorithm_version": MINING_ALGORITHM_VERSION,
"methodology_version": FACTOR_METHODOLOGY_VERSION,
"implementation": _implementation_metadata(module_root),
"strategies": _selected_strategy_metadata(
app_state,
request.get("strategy_ids") or [],
data_dir,
),
}
payload = _canonical_json(components)
return {
**components,
"digest": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
}
def schedule_claim(day: date) -> str:
iso_year, week = iso_week(day)
return f"weekly-{iso_year}-W{week:02d}"
def run_weekly_mining(app_state: Any, *, now: datetime | None = None) -> dict[str, Any]:
"""Check the weekly gate and enqueue mining; never perform mining synchronously."""
config = preferences.get_mining_schedule()
day = beijing_date(now)
if not config["mining_schedule_enabled"]:
return {"status": "disabled"}
weekday = day.weekday()
if weekday > 4 or weekday < config["mining_schedule_weekday"]:
return {"status": "weekday_mismatch"}
manager = getattr(app_state, "mining_manager", None)
repo = getattr(app_state, "repo", None)
if manager is None or repo is None:
raise RuntimeError("scheduled mining dependencies are not initialized")
store = getattr(manager, "store", None)
if store is None:
raise RuntimeError("scheduled mining manager has no run store")
request = build_default_request(repo, config["mining_budget_profile"])
fingerprint = build_data_fingerprint(repo, app_state, request)
claim = schedule_claim(day)
fingerprint = {**fingerprint, "source": "scheduled", "source_claim": claim}
with _CLAIM_LOCK:
existing = store.get(claim)
if existing is not None:
return {"status": "already_claimed", "run_id": claim}
prerequisite_error = _prerequisite_error(repo, request)
if prerequisite_error is not None:
_record_skipped_prerequisite(store, claim, request, fingerprint, prerequisite_error)
return {
"status": "skipped_prerequisite",
"run_id": claim,
"error": prerequisite_error,
}
run = manager.start(
request,
fingerprint,
force=False,
source="scheduled",
run_id=claim,
)
run_id = run.get("run_id") if isinstance(run, dict) else getattr(run, "run_id", None)
return {"status": "enqueued", "run_id": run_id or claim}
def _prerequisite_error(repo: Any, request: dict[str, Any]) -> str | None:
data_dir = Path(repo.store.data_dir)
end = request.get("end")
if end is None:
return "stock enriched data is unavailable"
regime = regime_path(data_dir)
try:
if not regime.is_file() or regime.stat().st_size <= 0:
return "regime data is unavailable"
except OSError:
return "regime data is unavailable"
start = request.get("start")
start_date = date.fromisoformat(start) if start is not None else None
end_date = date.fromisoformat(end)
partitions = enriched_partition_dates(
data_dir,
"stock",
start_date,
end_date,
)
covered = [value.isoformat() for value in partitions]
profile = request["budget_profile"]
validation = validation_config_for_profile(profile)
required = required_trading_bars(
validation,
required_outer_folds(profile),
)
if len(covered) < required:
return (
"insufficient enriched trading dates: "
f"need {required}, got {len(covered)}"
)
regime_history = load_regime_history(data_dir)
if regime_history.is_empty() or "date" not in regime_history.columns:
return "regime data is unavailable"
regime_dates = set(
regime_history.select(
pl.col("date").cast(pl.Utf8).str.slice(0, 10)
).to_series().to_list()
)
required_predecessors = set(covered[:-1])
missing_regime = required_predecessors - regime_dates
if missing_regime:
return (
"regime coverage is incomplete for T-1 alignment: "
f"missing {len(missing_regime)} trading dates"
)
return None
def _record_skipped_prerequisite(
store: Any,
claim: str,
request: dict[str, Any],
fingerprint: dict[str, Any],
error: str,
) -> None:
try:
store.create(request, fingerprint, run_id=claim)
except Exception:
if store.get(claim) is not None:
return
raise
store.append_event(
claim,
"skipped_prerequisite",
{"source": "scheduled", "reason": error},
)
store.transition_status(claim, "skipped_prerequisite", error=error)
def _instrument_metadata(repo: Any, asset_type: str) -> dict[str, Any]:
instruments = repo.get_instruments_asset(asset_type)
if instruments is None or instruments.is_empty() or "symbol" not in instruments.columns:
return {"rows": 0, "digest": "no-instruments"}
columns = [
name
for name in (
"symbol",
"name",
"total_shares",
"float_shares",
"limit_up",
"limit_down",
)
if name in instruments.columns
]
payload = instruments.select(columns).sort("symbol").to_dicts()
digest = hashlib.blake2b(
json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8"),
digest_size=20,
).hexdigest()
return {"rows": instruments.height, "columns": columns, "digest": digest}
def _enriched_metadata(root: Path) -> dict[str, Any]:
records: list[dict[str, Any]] = []
for partition in sorted(root.glob("date=*"), key=lambda item: item.name):
try:
date.fromisoformat(partition.name.removeprefix("date="))
except ValueError:
continue
records.append(
{
"partition": partition.name,
"file": _path_metadata(partition / "part.parquet", root=root),
}
)
return {
"partition_count": len(records),
"first_partition": records[0]["partition"] if records else None,
"last_partition": records[-1]["partition"] if records else None,
"metadata_digest": hashlib.sha256(_canonical_json(records).encode("utf-8")).hexdigest(),
}
def _selected_strategy_metadata(
app_state: Any,
strategy_ids: list[str],
data_dir: Path,
) -> list[dict[str, Any]]:
if not strategy_ids:
return []
engine = getattr(app_state, "strategy_engine", None)
if engine is None:
raise RuntimeError("strategy engine is unavailable for scheduled mining fingerprint")
metadata: list[dict[str, Any]] = []
for strategy_id in sorted(strategy_ids):
strategy = engine.get(strategy_id)
if strategy.execution_backend != "matrix_native":
raise ValueError(f"scheduled mining strategy is not matrix-native: {strategy_id}")
source_path = Path(strategy.file_path) if strategy.file_path is not None else None
override_path = data_dir / "user_data" / "strategy_overrides" / f"{strategy_id}.json"
metadata.append(
{
"strategy_id": strategy_id,
"source": _content_metadata(
source_path, root=source_path.parent if source_path else data_dir
),
"source_tree": (
_implementation_metadata(source_path.parent)
if source_path is not None
else None
),
"override": _content_metadata(override_path, root=data_dir),
}
)
return metadata
def _path_metadata(path: Path | None, *, root: Path) -> dict[str, Any] | None:
if path is None:
return None
try:
stat = path.stat()
except OSError:
return {"path": _relative_path(path, root), "exists": False}
return {
"path": _relative_path(path, root),
"exists": True,
"size": stat.st_size,
"mtime_ns": stat.st_mtime_ns,
}
def _content_metadata(path: Path | None, *, root: Path) -> dict[str, Any] | None:
if path is None:
return None
try:
content = path.read_bytes()
except OSError:
return {"path": _relative_path(path, root), "exists": False}
return {
"path": _relative_path(path, root),
"exists": True,
"size": len(content),
"sha256": hashlib.sha256(content).hexdigest(),
}
def _implementation_metadata(module_root: Path) -> dict[str, Any]:
records = []
for path in sorted(module_root.rglob("*.py"), key=lambda item: item.as_posix()):
try:
content = path.read_bytes()
except OSError:
continue
records.append({
"path": path.relative_to(module_root).as_posix(),
"sha256": hashlib.sha256(content).hexdigest(),
})
return {
"file_count": len(records),
"digest": hashlib.sha256(_canonical_json(records).encode("utf-8")).hexdigest(),
}
def _relative_path(path: Path, root: Path) -> str:
try:
return path.resolve().relative_to(root.resolve()).as_posix()
except ValueError:
return path.name
def _iso_or_none(value: date | None) -> str | None:
return value.isoformat() if value is not None else None
def _canonical_json(value: Any) -> str:
return json.dumps(
value,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
)
+138 -6
View File
@@ -5,12 +5,19 @@
"""
from __future__ import annotations
import copy
import json
import logging
import re
from pathlib import Path
logger = logging.getLogger(__name__)
# 进程内缓存: 行情轮询线程一轮会调用 8~12 次 getter, 每次读盘+parse 是纯重复;
# 文件仅在用户改设置时变化, 以 (mtime_ns, size) 签名判断是否重读。
_cache: dict | None = None
_cache_sig: tuple[int, int] | None = None
def _path() -> Path:
from app.config import settings
@@ -19,14 +26,32 @@ def _path() -> Path:
return p
def _invalidate_cache() -> None:
global _cache, _cache_sig
_cache = None
_cache_sig = None
def load() -> dict:
"""读取 preferences.json (带 mtime 签名缓存)。返回深拷贝, 调用方可自由修改。"""
global _cache, _cache_sig
p = _path()
if p.exists():
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001
logger.warning("preferences.json malformed: %s", e)
return {}
try:
sig = (p.stat().st_mtime_ns, p.stat().st_size)
except OSError:
return {}
if _cache is not None and sig == _cache_sig:
return copy.deepcopy(_cache)
try:
data = json.loads(p.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
except Exception as e:
logger.warning("preferences.json malformed: %s", e)
return {}
_cache = data
_cache_sig = sig
return copy.deepcopy(_cache)
def save(updates: dict) -> dict:
@@ -36,6 +61,7 @@ def save(updates: dict) -> dict:
_path().write_text(
json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8",
)
_invalidate_cache()
return current
@@ -88,6 +114,7 @@ def set_realtime_quote_interval(interval: float) -> float:
_path().write_text(
json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8",
)
_invalidate_cache()
return interval
@@ -316,6 +343,74 @@ def get_regime_warmup_days() -> int:
return 40
# ── 市场主线(概念/行业涨停梯队)过滤 ──
# 宽基/风格标签(融资融券 ~7700 成分、深股通/沪股通 ~3300-3700、国企改革 ~2900)
# 会按"家数"霸占主线榜首, 但它们不是可操作的题材主线。默认按成分股数上限过滤。
# 标定(2026-08 THS 概念): 成员 >600 的 55 个概念几乎全是此类风格标签,
# 真实题材(华为概念 2006/人工智能 2166/固态电池等)均在 600 以下或可自行调整。
_MAINLINE_MAX_MEMBERS_MIN = 50
_MAINLINE_MAX_MEMBERS_MAX = 5000
_MAINLINE_MIN_MEMBERS_MIN = 1
_MAINLINE_MIN_MEMBERS_MAX = 200
def get_mainline_max_members() -> int:
"""主线维度成员数上限, 超过视为宽基/风格标签被过滤。默认 600。"""
v = load().get("mainline_max_members", 600)
try:
return max(_MAINLINE_MAX_MEMBERS_MIN, min(_MAINLINE_MAX_MEMBERS_MAX, int(v)))
except (TypeError, ValueError):
return 600
def get_mainline_min_members() -> int:
"""主线维度成员数下限, 过滤微型标签。默认 4。"""
v = load().get("mainline_min_members", 4)
try:
return max(_MAINLINE_MIN_MEMBERS_MIN, min(_MAINLINE_MIN_MEMBERS_MAX, int(v)))
except (TypeError, ValueError):
return 4
def get_mainline_blacklist() -> list[str]:
"""用户自定义屏蔽的维度成员名(不论成员数大小)。默认空。
保存时接受 list 或逗号/顿号/分号/空白分隔的字符串
"""
v = load().get("mainline_blacklist", [])
if isinstance(v, str):
v = [part for part in re.split(r"[,,、;\s]+", v) if part] # noqa: RUF001
if not isinstance(v, list):
return []
return [str(x).strip() for x in v if str(x).strip()]
def get_mainline_filter_config() -> dict:
"""主线过滤配置汇总(供 API 返回与计算读取)。"""
return {
"min_members": get_mainline_min_members(),
"max_members": get_mainline_max_members(),
"blacklist": get_mainline_blacklist(),
}
def set_mainline_filter_config(cfg: dict) -> dict:
"""保存主线过滤配置(白名单字段, 部分更新)。修改后需重算主线生效。"""
updates: dict = {}
if "min_members" in cfg and cfg["min_members"] is not None:
updates["mainline_min_members"] = cfg["min_members"]
if "max_members" in cfg and cfg["max_members"] is not None:
updates["mainline_max_members"] = cfg["max_members"]
if "blacklist" in cfg and cfg["blacklist"] is not None:
raw = cfg["blacklist"]
if isinstance(raw, str):
raw = [part for part in re.split(r"[,,、;\s]+", raw) if part] # noqa: RUF001
updates["mainline_blacklist"] = [str(x).strip() for x in (raw or []) if str(x).strip()]
if updates:
save(updates)
return get_mainline_filter_config()
_PIPELINE_PULL_KEYS = ("pipeline_pull_etf", "pipeline_pull_index")
@@ -475,6 +570,43 @@ def set_review_schedule(enabled: bool, hour: int, minute: int) -> dict:
return {"enabled": bool(enabled), "hour": h, "minute": m}
MINING_BUDGET_PROFILES = frozenset({"balanced", "strict"})
def get_mining_schedule() -> dict:
"""返回周度自动 mining 配置。历史配置缺字段时默认关闭。"""
data = load()
weekday = data.get("mining_schedule_weekday", 4)
if isinstance(weekday, bool) or not isinstance(weekday, int) or not 0 <= weekday <= 4:
weekday = 4
profile = data.get("mining_budget_profile", "balanced")
if not isinstance(profile, str) or profile not in MINING_BUDGET_PROFILES:
profile = "balanced"
enabled = data.get("mining_schedule_enabled", False)
if not isinstance(enabled, bool):
enabled = False
return {
"mining_schedule_enabled": enabled,
"mining_schedule_weekday": weekday,
"mining_budget_profile": profile,
}
def set_mining_schedule(enabled: bool, weekday: int, profile: str) -> dict:
"""校验并一次写入周度自动 mining 的整组配置。"""
if isinstance(weekday, bool) or not isinstance(weekday, int) or not 0 <= weekday <= 4:
raise ValueError("mining schedule weekday must be between 0 and 4")
if profile not in MINING_BUDGET_PROFILES:
raise ValueError("mining budget profile must be balanced or strict")
result = {
"mining_schedule_enabled": bool(enabled),
"mining_schedule_weekday": weekday,
"mining_budget_profile": profile,
}
save(result)
return result
def get_review_push_channels() -> list[str]:
"""复盘推送渠道(多选) — 选定的外部工具列表, 复盘归档后逐个推送。
+23 -21
View File
@@ -130,18 +130,38 @@ class QuoteSubscriber:
self._event.set()
# 落盘节流间隔: last_fetch_ms 仅在进程重启后用于显示"最后获取时间"(运行中读内存值),
# 每 30s 持久化一次足够, 避免 expert 档每秒一轮的全量 preferences 重写磁盘。
_LAST_FETCH_WRITE_INTERVAL_MS = 30_000.0
_last_fetch_written_at_ms: float = 0.0
def _persist_last_fetch(fetched_at_ms: float) -> None:
""""最后获取"时间戳持久化到 preferences, 使进程重启后仍可显示。
放在锁外调用 (IO); 失败不影响主流程 (内存值已更新, 下次 fetch 再写)
距上次成功落盘不足 30s 时跳过 (节流只影响落盘频率, 内存值不受影响)
"""
global _last_fetch_written_at_ms
if (fetched_at_ms - _last_fetch_written_at_ms) < _LAST_FETCH_WRITE_INTERVAL_MS:
return
try:
from app.services import preferences
preferences.save({"last_fetch_ms": round(fetched_at_ms, 0)})
_last_fetch_written_at_ms = fetched_at_ms
except Exception as e: # noqa: BLE001
logger.debug("last_fetch_ms 持久化失败 (不影响行情): %s", e)
def _monitor_name_map(repo) -> dict[str, str]:
"""监控回填用的 symbol → name 映射 (股票 + ETF + 指数, 股票优先)。
repo.get_name_map() 的进程内 memo (三份 instruments 维表刷新时失效),
避免每轮监控对 ~7000 行维表 iter_rows 重建过滤空名称与旧行为一致
"""
return {s: n for s, n in repo.get_name_map().items() if n}
class QuoteService:
"""全局实时行情服务 — 单例。"""
@@ -1081,28 +1101,10 @@ class QuoteService:
engine = getattr(self._app_state, "monitor_engine", None)
if engine and engine.rule_count > 0:
# 预构建 symbol → name 映射 (enriched 已 drop name 列, 引擎触发时回填用)。
# 股票 + ETF 维表, 保证 ETF 监控告警也能回填名称。
# 股票 + ETF + 指数三表合并走 _monitor_name_map -> repo.get_name_map()
# 的进程内 memo, 避免每轮监控对 ~7000 行维表 iter_rows 重建。
try:
name_map: dict[str, str] = {}
inst_df = self._app_state.repo.get_instruments()
if not inst_df.is_empty() and "symbol" in inst_df.columns and "name" in inst_df.columns:
for row in inst_df.select(["symbol", "name"]).iter_rows(named=True):
if row.get("name"):
name_map[row["symbol"]] = row["name"]
# 仅当存在 ETF 规则时补 ETF 维表 (股票名优先, setdefault 不覆盖股票)
if engine.has_asset_rules("etf"):
etf_inst = self._app_state.repo.get_etf_instruments()
if not etf_inst.is_empty() and "symbol" in etf_inst.columns and "name" in etf_inst.columns:
for row in etf_inst.select(["symbol", "name"]).iter_rows(named=True):
if row.get("name"):
name_map.setdefault(row["symbol"], row["name"])
# 仅当存在指数规则时补指数维表 (setdefault 不覆盖股票/ETF)
if engine.has_asset_rules("index"):
idx_inst = self._app_state.repo.get_instruments_asset("index")
if not idx_inst.is_empty() and "symbol" in idx_inst.columns and "name" in idx_inst.columns:
for row in idx_inst.select(["symbol", "name"]).iter_rows(named=True):
if row.get("name"):
name_map.setdefault(row["symbol"], row["name"])
name_map = _monitor_name_map(self._app_state.repo)
if name_map:
engine.set_name_map(name_map)
except Exception as e: # noqa: BLE001
+50
View File
@@ -150,7 +150,16 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl.
polars 聚合, 不重算指标(假设 df 已含 signal_*/change_pct/ma20 等列)
index_pct_map: {date: 指数涨幅} 可选, 由调用方从指数数据预先算好
梯队指标(首板/N板宽度/晋级率) market_phase 提供; phase 列不在此算
(需要完整日序做平滑), refresh_phase_labels upsert 后统一重标
"""
from app.services.market_phase import (
finalize_ladder_row,
ladder_daily_aggs,
ladder_promo_aggs,
with_prev_consecutive,
)
needed = ["date", "change_pct", "amount", "signal_limit_up",
"signal_limit_down", "signal_broken_limit_up",
"consecutive_limit_ups", "close", "ma20"]
@@ -158,6 +167,9 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl.
if "date" not in avail or "change_pct" not in avail:
return pl.DataFrame()
if "consecutive_limit_ups" in avail and "symbol" in df.columns:
df = with_prev_consecutive(df)
# 基础聚合 — 全部用 group_by 一次性向量化算出, 避免逐日 filter 扫全表(OOM/超时元凶)。
has_ma20 = "close" in avail and "ma20" in avail
grouped = df.group_by("date").agg(
@@ -216,6 +228,15 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl.
]
if has_ma20 else []
),
# 梯队指标(阶段判定所需): 首板/N板宽度/非空档位数; 晋级率需 _prev_consec
*(
ladder_daily_aggs()
if "consecutive_limit_ups" in avail else []
),
*(
ladder_promo_aggs()
if "consecutive_limit_ups" in avail and "_prev_consec" in df.columns else []
),
).sort("date")
# 转成 dict 列表做分类(规则引擎需逐日算, 但只扫 grouped 行数=天数, 不再回扫全表)
@@ -289,6 +310,8 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl.
"speculation_score": round(sub["speculation"]),
"resilience_score": round(sub["resilience"]),
"trend_score": round(sub["trend"]),
# 梯队指标(阶段判定所需); phase 由 refresh_phase_labels 统一重标
**finalize_ladder_row(r),
})
return pl.DataFrame(rows) if rows else pl.DataFrame()
@@ -325,6 +348,10 @@ def _compute_batch(repo, enriched_dir, instruments, historical_shares,
needed={"signal_limit_up", "signal_limit_down", "signal_broken_limit_up"},
historical_shares=historical_shares,
)
# 晋级率需要昨日连板数: 在裁掉 warmup 之前先按 symbol 平移,
# 保证每批首日的 _prev_consec 来自 warmup 的最后一个交易日而非 null。
from app.services.market_phase import with_prev_consecutive
df = with_prev_consecutive(df)
# 丢弃 warmup 行, 只留目标区间
return df.filter((pl.col("date") >= batch_start) & (pl.col("date") <= batch_end))
@@ -446,6 +473,28 @@ def load_regime_history(data_dir: Path) -> pl.DataFrame:
return pl.DataFrame()
def refresh_phase_labels(data_dir: Path) -> int:
"""对全量 regime 时序重标情绪周期阶段(冰点/启动/主升/高潮/退潮/修复)。
阶段判定需要完整日序(EMA 平滑 + 持续性确认), 不能在单批内完成,
因此每次 upsert 后调用本函数整体重标并写回行数为天数(千级), 开销可忽略
返回标注的天数; 阶段列缺失所需指标( schema 未重算)时返回 0
"""
from app.services.market_phase import classify_phase_series
df = load_regime_history(data_dir)
required = {"date", "max_consecutive", "first_board", "ge2_count", "promo_rate", "seal_rate"}
if df.is_empty() or not required.issubset(df.columns):
return 0
try:
labeled = classify_phase_series(df)
except Exception as e:
logger.warning("refresh_phase_labels failed: %s", e)
return 0
labeled.write_parquet(regime_path(data_dir))
return labeled.height
def upsert_regime_history(data_dir: Path, new_rows: pl.DataFrame) -> None:
"""按 date 覆盖(upsert): 重算的天覆盖旧行, 新天追加。
@@ -553,6 +602,7 @@ def compute_regime_incremental(repo, data_dir: Path, *, today: date | None = Non
new_rows = run_regime_batch(repo, start=to_compute[0], end=to_compute[-1])
if not new_rows.is_empty():
upsert_regime_history(data_dir, new_rows)
refresh_phase_labels(data_dir)
return new_rows
+1
View File
@@ -343,6 +343,7 @@ META = {{...}}{entrypoint_requirement}。只输出完整 Python 代码。
"polars",
"numpy",
"app.backtest.matrix",
"app.strategy.builtin.factor_rank_research",
"datetime",
"__future__",
})
@@ -0,0 +1,209 @@
"""Fixed matrix-native strategy for controlled factor-rank research."""
from __future__ import annotations
import numpy as np
from app.backtest.matrix import (
MarketDataMatrix,
SignalMatrix,
build_matrix_score,
make_signal_matrix,
)
META = {
"id": "factor_rank_research",
"name": "因子排名研究",
"description": "受控多因子截面评分、阈值与排名选股策略",
"tags": ["因子", "研究", "截面排名"],
"asset_types": ["stock", "etf"],
"timeframes": ["1d"],
"research_only": True,
"params": [
{
"id": "entry_score",
"label": "入场最低分",
"type": "float",
"default": 70.0,
"min": 0.0,
"max": 100.0,
"step": 5.0,
},
{
"id": "exit_score",
"label": "离场最高分",
"type": "float",
"default": 40.0,
"min": 0.0,
"max": 100.0,
"step": 5.0,
},
{
"id": "top_rank",
"label": "每日最多入选",
"type": "int",
"default": 20,
"min": 1,
"max": 100,
"step": 1,
},
],
# Research-generated scoring is supplied in params. Keeping META scoring
# empty prevents the framework pipeline from replacing the strategy score.
"scoring": {},
"order_by": "score",
"descending": True,
"limit": 100,
}
EXECUTION_BACKEND = "matrix_native"
ENTRY_SIGNALS = ["signal_factor_rank_entry"]
EXIT_SIGNALS = ["signal_factor_rank_exit"]
STOP_LOSS = -0.08
MAX_HOLD_DAYS = 30
_MAX_FACTORS = 4
_VALID_DIRECTIONS = {"high", "low"}
class FactorRankResearchMatrixStrategy:
def __init__(
self,
scoring: dict[str, float] | None = None,
directions: dict[str, str] | None = None,
) -> None:
self._scoring = _validated_scoring(scoring) if scoring is not None else None
self._directions = (
_validated_directions(directions, self._scoring)
if directions is not None and self._scoring is not None
else None
)
def required_fields(self) -> frozenset[str]:
return frozenset({
"open",
"high",
"low",
"close",
"volume",
"amount",
"turnover_rate",
})
def required_warmup_bars(self, params: dict) -> int:
del params
return 60
def required_fields_for_params(self, params: dict) -> frozenset[str]:
if self._scoring is not None:
return frozenset(self._scoring)
raw = params.get("scoring")
if raw is None:
return frozenset()
return frozenset(_validated_scoring(raw))
def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix:
scoring = self._scoring or _validated_scoring(params.get("scoring"))
directions = self._directions or _validated_directions(
params.get("directions"), scoring
)
entry_score = _bounded_float(params.get("entry_score", 70.0), "entry_score")
exit_score = _bounded_float(params.get("exit_score", 40.0), "exit_score")
top_rank = int(params.get("top_rank", 20))
if not 1 <= top_rank <= 100:
raise ValueError("top_rank must be between 1 and 100")
if exit_score > entry_score:
raise ValueError("exit_score must not exceed entry_score")
universe = np.isfinite(market.close)
score = build_matrix_score(
market,
universe,
scoring,
"score",
True,
fallback=np.zeros(market.shape, dtype=np.float32),
directions=directions,
)
entry = universe & (score >= np.float32(entry_score))
entry = _limit_top_rank(entry, score, top_rank)
exit_ = universe & (score <= np.float32(exit_score))
return make_signal_matrix(
market.shape,
entry=entry.astype(np.uint8),
exit=exit_.astype(np.uint8),
score=score,
entry_signal_code=np.where(entry, 0, -1).astype(np.int16),
exit_signal_code=np.where(exit_, 0, -1).astype(np.int16),
entry_signal_ids=("signal_factor_rank_entry",),
exit_signal_ids=("signal_factor_rank_exit",),
)
def _validated_scoring(raw: object) -> dict[str, float]:
if not isinstance(raw, dict) or not raw:
raise ValueError("factor-rank research requires a non-empty scoring mapping")
if len(raw) > _MAX_FACTORS:
raise ValueError(f"factor-rank research supports at most {_MAX_FACTORS} factors")
scoring: dict[str, float] = {}
for name, value in raw.items():
if not isinstance(name, str) or not name:
raise ValueError("scoring factor names must be non-empty strings")
try:
weight = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"scoring weight for {name!r} must be numeric") from exc
if not np.isfinite(weight) or weight <= 0.0:
raise ValueError(f"scoring weight for {name!r} must be finite and positive")
scoring[name] = weight
return scoring
def _validated_directions(
raw: object,
scoring: dict[str, float],
) -> dict[str, str]:
if raw is None:
return {}
if not isinstance(raw, dict):
raise ValueError("directions must be a mapping")
unknown = sorted(set(raw) - set(scoring))
if unknown:
raise ValueError(f"directions contain factors absent from scoring: {unknown}")
directions: dict[str, str] = {}
for name, value in raw.items():
if value not in _VALID_DIRECTIONS:
raise ValueError(
f"direction for {name!r} must be one of {sorted(_VALID_DIRECTIONS)}"
)
directions[str(name)] = str(value)
return directions
def _bounded_float(value: object, name: str) -> float:
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{name} must be numeric") from exc
if not np.isfinite(number) or not 0.0 <= number <= 100.0:
raise ValueError(f"{name} must be between 0 and 100")
return number
def _limit_top_rank(
eligible: np.ndarray,
score: np.ndarray,
top_rank: int,
) -> np.ndarray:
result = np.zeros(eligible.shape, dtype=bool)
for time_id in range(eligible.shape[0]):
asset_ids = np.flatnonzero(eligible[time_id])
if asset_ids.size <= top_rank:
result[time_id, asset_ids] = True
continue
# mergesort preserves asset-axis order for equal scores.
order = np.argsort(-score[time_id, asset_ids], kind="stable")[:top_rank]
result[time_id, asset_ids[order]] = True
return result
MATRIX_STRATEGY = FactorRankResearchMatrixStrategy()
+37 -6
View File
@@ -6,12 +6,25 @@
"""
from __future__ import annotations
import copy
import json
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
# 进程内缓存: 监控引擎每轮对每条策略规则调用 load_override, 每次读盘+parse 纯重复;
# override 仅在用户编辑时变化, 以 (mtime_ns, size) 签名判断是否重读。
# 键为 override 文件路径 (进程内可能有多个 data_dir, 如测试)。
_override_cache: dict[str, dict] = {}
_override_cache_sig: dict[str, tuple[int, int]] = {}
def _invalidate_override_cache(path: Path) -> None:
key = str(path)
_override_cache.pop(key, None)
_override_cache_sig.pop(key, None)
def _overrides_dir(data_dir: Path) -> Path:
d = data_dir / "user_data" / "strategy_overrides"
@@ -19,15 +32,29 @@ def _overrides_dir(data_dir: Path) -> Path:
return d
def _path(data_dir: Path, strategy_id: str) -> Path:
return _overrides_dir(data_dir) / f"{strategy_id}.json"
def _path(data_dir: Path, strategy_id: str, *, ensure_dir: bool = True) -> Path:
# ensure_dir=False 供热路径读取: mkdir 系统调用在 Windows 上 ~0.07ms,
# 读缓存命中时跳过它 (目录由写路径保证存在)。
if ensure_dir:
d = _overrides_dir(data_dir)
else:
d = data_dir / "user_data" / "strategy_overrides"
return d / f"{strategy_id}.json"
def load_override(data_dir: Path, strategy_id: str) -> dict:
"""读取策略的用户覆盖配置,不存在返回空 dict"""
p = _path(data_dir, strategy_id)
if not p.exists():
"""读取策略的用户覆盖配置,不存在返回空 dict (带 mtime 签名缓存, 返回深拷贝)"""
p = _path(data_dir, strategy_id, ensure_dir=False)
key = str(p)
try:
st = p.stat()
sig = (st.st_mtime_ns, st.st_size)
except OSError:
_invalidate_override_cache(p)
return {}
cached = _override_cache.get(key)
if cached is not None and sig == _override_cache_sig.get(key):
return copy.deepcopy(cached)
try:
data = json.loads(p.read_text(encoding="utf-8"))
# 清理 basic_filter 中值为 None/空的键(避免固化无意义的空值)
@@ -38,7 +65,9 @@ def load_override(data_dir: Path, strategy_id: str) -> dict:
data["basic_filter"] = cleaned
else:
del data["basic_filter"]
return data
_override_cache[key] = data
_override_cache_sig[key] = sig
return copy.deepcopy(data)
except Exception as e:
logger.warning("load override %s failed: %s", strategy_id, e)
return {}
@@ -49,11 +78,13 @@ def save_override(data_dir: Path, strategy_id: str, overrides: dict) -> None:
p = _path(data_dir, strategy_id)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(overrides, ensure_ascii=False, indent=2), encoding="utf-8")
_invalidate_override_cache(p)
def delete_override(data_dir: Path, strategy_id: str) -> None:
"""删除策略的用户覆盖配置(重置为默认值)"""
p = _path(data_dir, strategy_id)
_invalidate_override_cache(p)
if p.exists():
p.unlink()
+34 -6
View File
@@ -554,10 +554,12 @@ class StrategyEngine:
# 查询
# ================================================================
def list_strategies(self) -> list[dict]:
"""返回所有策略的元信息"""
def list_strategies(self, *, include_research: bool = False) -> list[dict]:
"""Return public strategy metadata unless research templates are requested."""
result = []
for s in self._strategies.values():
if s.meta.get("research_only") and not include_research:
continue
result.append({
**s.meta,
"source": s.source,
@@ -732,7 +734,11 @@ class StrategyEngine:
),
)
field_columns.update(
self._matrix_field_columns(strategy, overrides_map.get(strategy_id))
self._matrix_field_columns(
strategy,
overrides_map.get(strategy_id),
params,
)
)
if not matrix_ids:
return None
@@ -1027,7 +1033,11 @@ class StrategyEngine:
field_columns: set[str] = set()
for sid, strategy in matrix_strats:
field_columns.update(
self._matrix_field_columns(strategy, overrides_map.get(sid))
self._matrix_field_columns(
strategy,
overrides_map.get(sid),
params_map.get(sid),
)
)
shared_matrix = build_market_data_matrix(
shared_history,
@@ -1052,8 +1062,26 @@ class StrategyEngine:
return results
@staticmethod
def _matrix_field_columns(strategy: StrategyDef, overrides: dict | None = None) -> set[str]:
def _matrix_field_columns(
strategy: StrategyDef,
overrides: dict | None = None,
params: dict | None = None,
) -> set[str]:
fields = set(strategy.matrix_strategy.required_fields())
# 参数评分字段 (如挖掘策略的因子组合) 需展开为实际数据依赖,
# 与 backtest._resolve_matrix_native 保持同一语义, 否则虚拟因子
# (limit_up_count_* -> consecutive_limit_ups) 在矩阵里缺字段。
parameter_fields = getattr(
strategy.matrix_strategy,
"required_fields_for_params",
None,
)
if callable(parameter_fields):
fields.update(
scoring_dependencies(
{str(name): 1.0 for name in parameter_fields(params or {})}
)
)
basic_filter = dict(strategy.basic_filter or {})
if (overrides or {}).get("basic_filter"):
basic_filter.update(overrides["basic_filter"])
@@ -1102,7 +1130,7 @@ class StrategyEngine:
return StrategyResult(as_of=as_of, strategy_id=strategy_id)
market = build_market_data_matrix(
source_panel,
field_columns=self._matrix_field_columns(strategy, overrides),
field_columns=self._matrix_field_columns(strategy, overrides, params),
)
if source_panel is None or source_panel.is_empty():
+83
View File
@@ -35,12 +35,31 @@ VIRTUAL_SCORING_DEPENDENCIES: dict[str, frozenset[str]] = {
"close_position": frozenset({"high", "low", "close"}),
"distance_to_high_60d": frozenset({"close", "high_60d"}),
"distance_from_low_60d": frozenset({"close", "low_60d"}),
"max_ret_20d": frozenset({"close"}),
"ret_skew_20d": frozenset({"close"}),
"up_days_20d": frozenset({"close"}),
"amihud_20d": frozenset({"close", "amount"}),
"turnover_z_60d": frozenset({"turnover_rate"}),
"vol_price_corr_20d": frozenset({"close", "volume"}),
"vwap_bias": frozenset({"close", "volume", "amount"}),
"vol_trend_5_60": frozenset({"volume"}),
"limit_up_count_20d": frozenset({"consecutive_limit_ups"}),
"limit_up_count_60d": frozenset({"consecutive_limit_ups"}),
}
_ROLLING_SCORING_WARMUP: dict[str, int] = {
"vol_ratio_10d": 11,
"turnover_ratio_5d": 6,
"amount_ratio_5d": 6,
"max_ret_20d": 21,
"ret_skew_20d": 21,
"up_days_20d": 21,
"amihud_20d": 21,
"turnover_z_60d": 61,
"vol_price_corr_20d": 21,
"vol_trend_5_60": 60,
"limit_up_count_20d": 21,
"limit_up_count_60d": 61,
}
@@ -143,9 +162,73 @@ def scoring_value_expr(columns: Collection[str], name: str) -> pl.Expr | None:
return _relative(pl.col("close"), pl.col("high_60d"))
if name == "distance_from_low_60d":
return _relative(pl.col("close"), pl.col("low_60d"))
if name in {
"max_ret_20d", "ret_skew_20d", "up_days_20d",
"amihud_20d", "vol_price_corr_20d",
}:
change = _daily_change_expr()
if name == "max_ret_20d":
return change.rolling_max(20, min_samples=20).over("symbol")
if name == "ret_skew_20d":
return change.rolling_skew(20, bias=True).over("symbol")
if name == "up_days_20d":
return (
(change > 0).cast(pl.Float64)
.rolling_sum(20, min_samples=20).over("symbol")
)
if name == "amihud_20d":
illiquidity = _ratio(change.abs(), pl.col("amount") / 1e8)
return illiquidity.rolling_mean(20, min_samples=20).over("symbol")
volume = pl.col("volume")
product = change * volume
return _rolling_corr_expr(change, volume, product, 20).over("symbol")
if name == "turnover_z_60d":
baseline = pl.col("turnover_rate").shift(1)
mean = baseline.rolling_mean(60, min_samples=60)
std = baseline.rolling_std(60, min_samples=60)
return (
pl.when(std > 0).then((pl.col("turnover_rate") - mean) / std)
.otherwise(None)
.over("symbol")
)
if name == "vwap_bias":
vwap = _ratio(pl.col("amount"), pl.col("volume") * 100.0)
return _relative(pl.col("close"), vwap)
if name == "vol_trend_5_60":
fast = pl.col("volume").rolling_mean(5)
slow = pl.col("volume").rolling_mean(60)
return _relative(fast, slow).over("symbol")
if name in {"limit_up_count_20d", "limit_up_count_60d"}:
window = 20 if name == "limit_up_count_20d" else 60
hit = (pl.col("consecutive_limit_ups").fill_null(0) > 0).cast(pl.Float64)
return hit.rolling_sum(window, min_samples=window).over("symbol")
return None
def _daily_change_expr() -> pl.Expr:
previous = pl.col("close").shift(1)
return _ratio(pl.col("close"), previous) - 1.0
def _rolling_corr_expr(
left: pl.Expr, right: pl.Expr, product: pl.Expr, window: int
) -> pl.Expr:
"""Pearson correlation over a rolling window, matching the matrix kernel formula."""
mean_left = left.rolling_mean(window, min_samples=window)
mean_right = right.rolling_mean(window, min_samples=window)
mean_product = product.rolling_mean(window, min_samples=window)
mean_left_sq = (left * left).rolling_mean(window, min_samples=window)
mean_right_sq = (right * right).rolling_mean(window, min_samples=window)
covariance = mean_product - mean_left * mean_right
variance_left = mean_left_sq - mean_left * mean_left
variance_right = mean_right_sq - mean_right * mean_right
return pl.when(
(variance_left > 0) & (variance_right > 0)
).then(
covariance / (variance_left * variance_right).sqrt()
).otherwise(None)
def materialize_scoring_columns(
frame: pl.DataFrame,
names: Collection[str],
+147 -65
View File
@@ -26,6 +26,13 @@ import duckdb
import polars as pl
from app.config import settings
from app.enriched_generation import (
EnrichedGenerationUnavailableError,
EnrichedPublication,
bump_enriched_generation,
get_enriched_generation,
)
from app.market_time import cn_today
from app.parquet import scan_enriched_parquet
logger = logging.getLogger(__name__)
@@ -318,6 +325,7 @@ class KlineRepository:
# 完整 enriched 历史 (含所有指标, 供 filter_history 策略使用)
self._enriched_history_cache: pl.DataFrame | None = None # ~100万行
self._enriched_history_start: date | None = None
self._enriched_history_generation: str | None = None
self._index_instruments_cache: pl.DataFrame | None = None
self._etf_enriched_cache: pl.DataFrame | None = None
self._etf_enriched_cache_date: date | None = None
@@ -485,6 +493,7 @@ class KlineRepository:
self._enriched_cache_date = None
self._enriched_history_cache = None
self._enriched_history_start = None
self._enriched_history_generation = None
self._live_agg_cache = None
self._live_agg_cache_date = None
self._live_agg_check_date = None
@@ -497,6 +506,7 @@ class KlineRepository:
self._etf_instruments_cache = None
self._index_symbol_set_cache = None
self._etf_symbol_set_cache = None
self._name_map_cache = None
self._index_enriched_cache = None
self._index_enriched_cache_date = None
@@ -510,6 +520,7 @@ class KlineRepository:
"""
try:
started = time.perf_counter()
refresh_generation = self.get_matrix_data_generation("stock")
logger.info("enriched refresh start")
step = time.perf_counter()
@@ -596,8 +607,13 @@ class KlineRepository:
logger.info("enriched refresh step done: join instruments (%.2fs)", time.perf_counter() - step)
# 缓存完整历史 (含指标+必要基础信息) 供 filter_history/backtest 直接复用
if self.get_matrix_data_generation("stock") != refresh_generation:
raise EnrichedGenerationUnavailableError(
"enriched data changed while refreshing its history cache"
)
self._enriched_history_cache = df_full
self._enriched_history_start = df_full["date"].min()
self._enriched_history_generation = refresh_generation
logger.info("enriched 历史缓存: %d rows, %s ~ %s",
len(df_full), self._enriched_history_start, latest)
@@ -626,6 +642,8 @@ class KlineRepository:
logger.info("enriched 缓存已计算: %d 只, 日期 %s (即时计算)", len(df_today), latest)
logger.info("enriched refresh done (%.2fs)", time.perf_counter() - started)
return
except EnrichedGenerationUnavailableError:
raise
except Exception as e: # noqa: BLE001
logger.warning("enriched 即时计算失败, 使用原始 14 列缓存: %s", e)
@@ -908,7 +926,7 @@ class KlineRepository:
def _live_agg_baseline_date(self, latest: date) -> date:
"""盘中递推基准日期。当天实时分区存在时使用上一可用交易日。"""
if latest != date.today():
if latest != cn_today():
return latest
try:
row = self.execute_one(
@@ -1051,6 +1069,7 @@ class KlineRepository:
df = pl.scan_parquet(self._inst_glob).collect()
if not df.is_empty():
self._instruments_cache = df
self._name_map_cache = None
logger.info("instruments 缓存已加载: %d", len(df))
except Exception as e: # noqa: BLE001
logger.warning("instruments 缓存刷新失败: %s", e)
@@ -1062,6 +1081,7 @@ class KlineRepository:
if not df.is_empty():
self._index_instruments_cache = df
self._index_symbol_set_cache = None
self._name_map_cache = None
logger.info("index instruments 缓存已加载: %d", len(df))
except Exception as e: # noqa: BLE001
logger.debug("index instruments 缓存刷新跳过: %s", e)
@@ -1087,6 +1107,7 @@ class KlineRepository:
df_all = pl.concat(parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol")
self._etf_instruments_cache = df_all
self._etf_symbol_set_cache = None
self._name_map_cache = None
logger.info("ETF instruments 缓存已加载: %d", len(df_all))
def get_enriched_latest(self) -> tuple[pl.DataFrame, date | None]:
@@ -1164,8 +1185,21 @@ class KlineRepository:
) -> pl.DataFrame | None:
"""从预计算 enriched 历史缓存返回完整区间;缓存不覆盖时返回 None。"""
if self._enriched_history_cache is None:
if self._enriched_warming:
# 后台预热中: 返回 None (缓存不覆盖), 调用方各自走慢路径;
# 否则请求线程会与预热线程并发重复 300 天全量重算
# (同 get_enriched_latest 的守卫语义)。
return None
self._refresh_enriched()
cache = self._enriched_history_cache
data_dir = getattr(getattr(self, "store", None), "data_dir", None)
if data_dir is not None:
try:
current_generation = self.get_matrix_data_generation("stock")
except EnrichedGenerationUnavailableError:
return None
if self._enriched_history_generation != current_generation:
return None
if cache is None or cache.is_empty() or "date" not in cache.columns:
return None
@@ -1204,9 +1238,9 @@ class KlineRepository:
# 后台预热中: 返回空表, 不触发同步重算 (同 get_enriched_latest 守卫)
return pl.DataFrame()
self._refresh_enriched()
self._live_agg_check_date = date.today() # 刚建过, 当天不必再查磁盘
self._live_agg_check_date = cn_today() # 刚建过, 当天不必再查磁盘
else:
today = date.today()
today = cn_today()
if self._live_agg_check_date != today:
# today 翻天了 (次日开盘首次轮询): 校验基准日是否需要前移重建。
# 同一天内多次调用直接跳过, 避免每轮都扫 parquet。
@@ -1306,16 +1340,28 @@ class KlineRepository:
自选列表/名称批查等场景的统一名称解析入口, 避免各调用方自行合并两份缓存
symbols None 时只返回命中的条目
全量结果缓存在 _name_map_cache (随三份 instruments 维表刷新失效),
避免每请求对 ~7000 行维表做 iter_rows 重建
"""
if self._name_map_cache is not None:
if symbols is None:
return dict(self._name_map_cache)
wanted = set(symbols)
return {s: n for s, n in self._name_map_cache.items() if s in wanted}
# 只构建并缓存全量映射; symbols 过滤只作用于返回值。
# 若把过滤后的结果写入缓存, 后续不同 symbols 的查询会命中残缺缓存,
# 导致新加入自选的标的查不到名称。
name_map: dict[str, str] = {}
for df in (self.get_instruments(), self.get_etf_instruments(), self.get_instruments_asset("index")):
if df.is_empty() or "symbol" not in df.columns or "name" not in df.columns:
continue
if symbols is not None:
df = df.filter(pl.col("symbol").is_in(symbols))
for symbol, name in df.select(["symbol", "name"]).iter_rows():
name_map.setdefault(symbol, name)
return name_map
self._name_map_cache = name_map
if symbols is None:
return dict(name_map)
wanted = set(symbols)
return {s: n for s, n in name_map.items() if s in wanted}
def enriched_latest_date(self) -> date | None:
"""返回缓存中的 enriched 最新日期。"""
@@ -1355,10 +1401,26 @@ class KlineRepository:
# 扩展范围用于指标预热 (MA60 需要 ~60 交易日 ≈ 120 日历日)
warmup_start = start - timedelta(days=150)
# 扫描14列 parquet
df = self._scan_daily_symbol(symbol, warmup_start, end, None)
if not df.is_empty():
df = self._compute_enriched_range(df)
# 优先复用预计算 enriched 历史缓存 (300 天全指标, 与回测引擎同源):
# 个股对话框打开时本接口每个行情 tick 被调一次, 逐请求 150 天扫描 + 全套
# 指标重算是热路径上最大的重复计算。缓存最新日可能不含当日实时行,
# 由下方 get_enriched_latest 覆盖逻辑补齐; 覆盖不足时回退单股计算路径。
df = pl.DataFrame()
hist = self._enriched_history_cache
if hist is not None and not hist.is_empty() and "date" in hist.columns:
hist_min = self._enriched_history_start
hist_max = hist["date"].max()
if hist_min is not None and hist_min <= start and hist_max >= start:
df = hist.filter(
(pl.col("symbol") == symbol)
& (pl.col("date") >= start)
& (pl.col("date") <= end)
)
if df.is_empty():
# 扫描14列 parquet
df = self._scan_daily_symbol(symbol, warmup_start, end, None)
if not df.is_empty():
df = self._compute_enriched_range(df)
# 尝试用缓存数据覆盖最新日 (盘中更准确)
cached, cache_date = self.get_enriched_latest()
@@ -1829,30 +1891,11 @@ class KlineRepository:
return latest
def get_matrix_data_generation(self, asset_type: str = "stock") -> str:
"""Return a persistent generation bumped by every managed enriched write."""
path = self.store.data_dir / f".matrix_generation_{asset_type}.json"
try:
payload = json.loads(path.read_text(encoding="utf-8"))
generation = str(payload.get("generation") or "")
if generation:
return generation
except (OSError, TypeError, ValueError, json.JSONDecodeError):
pass
return self._bump_matrix_data_generation(asset_type)
"""Return the stable generation for managed enriched readers."""
return get_enriched_generation(self.store.data_dir, asset_type)
def _bump_matrix_data_generation(self, asset_type: str) -> str:
generation = uuid.uuid4().hex
path = self.store.data_dir / f".matrix_generation_{asset_type}.json"
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
temporary.write_text(
json.dumps({
"generation": generation,
"updated_at_ns": time.time_ns(),
}, separators=(",", ":")),
encoding="utf-8",
)
temporary.replace(path)
return generation
return bump_enriched_generation(self.store.data_dir, asset_type)
def symbols_lagging(self, reference_date: date, min_gap_days: int = 3) -> list[str]:
"""返回日K覆盖落后的标的: 其最新 bar 早于 reference_date - min_gap_days。
@@ -1962,6 +2005,7 @@ class KlineRepository:
self._atomic_write_parquet(df.unique(subset=["symbol"], keep="last").sort("symbol"), out)
self._index_instruments_cache = None
self._etf_instruments_cache = None
self._name_map_cache = None
self._refresh_index_instruments()
def save_etf_instruments(self, df: pl.DataFrame) -> None:
@@ -1974,6 +2018,7 @@ class KlineRepository:
out.parent.mkdir(parents=True, exist_ok=True)
self._atomic_write_parquet(df.unique(subset=["symbol"], keep="last").sort("symbol"), out)
self._etf_instruments_cache = None
self._name_map_cache = None
self._refresh_etf_instruments()
def refresh_index_views(self) -> None:
@@ -2052,25 +2097,36 @@ class KlineRepository:
def _write_daily_partition(self, df: pl.DataFrame, table: str) -> None:
"""按 date 分区写入 parquet,每个日期一个文件,支持 merge-upsert。"""
base = self.store.data_dir / table
generation_asset = {
"kline_daily_enriched": "stock",
"kline_etf_enriched": "etf",
}.get(table)
publication = (
EnrichedPublication(self.store.data_dir, generation_asset)
if generation_asset is not None
else None
)
with self._write_lock:
for date_df in df.partition_by("date"):
dt = date_df["date"][0]
ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
out = base / f"date={ds}" / "part.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
existing = pl.DataFrame()
if out.exists():
existing = pl.read_parquet(out)
date_df = pl.concat([existing, date_df], how="diagonal_relaxed").unique(
subset=["symbol", "date"], keep="last"
)
date_df = date_df.sort(["symbol", "date"])
self._atomic_write_parquet(date_df, out)
generation_asset = {
"kline_daily_enriched": "stock",
"kline_etf_enriched": "etf",
}.get(table)
if generation_asset is not None:
self._bump_matrix_data_generation(generation_asset)
if not existing.is_empty() and existing.equals(date_df):
continue
if publication is None:
self._atomic_write_parquet(date_df, out)
else:
publication.write_parquet(date_df, out)
if publication is not None:
publication.commit()
def merge_live_daily_asset(self, asset_type: str, df: pl.DataFrame) -> None:
"""按 symbol 合并当天指定资产日K分区。用于少量自选实时,不覆盖全市场。"""
@@ -2139,6 +2195,35 @@ class KlineRepository:
subset=["symbol", "date"], keep="last"
)
merged_cache = merged_cache.sort(["symbol"])
from app.indicators.pipeline import ENRICHED_STORAGE_COLS
storage_cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns]
df_storage = df.select(storage_cols).sort(["symbol"])
base = self.store.data_dir / table
ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
out = base / f"date={ds}" / "part.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
publication = (
EnrichedPublication(self.store.data_dir, asset_type)
if asset_type in {"stock", "etf"}
else None
)
with self._write_lock:
existing = pl.DataFrame()
if out.exists():
existing = pl.read_parquet(out)
df_storage = pl.concat([existing, df_storage], how="diagonal_relaxed").unique(
subset=["symbol", "date"], keep="last"
)
df_storage = df_storage.sort(["symbol"])
if existing.is_empty() or not existing.equals(df_storage):
if publication is None:
self._atomic_write_parquet(df_storage, out)
else:
publication.write_parquet(df_storage, out)
if publication is not None:
publication.commit()
if asset_type == "stock":
self._enriched_cache = merged_cache
self._enriched_cache_date = dt
@@ -2149,23 +2234,6 @@ class KlineRepository:
self._index_enriched_cache = merged_cache
self._index_enriched_cache_date = dt
from app.indicators.pipeline import ENRICHED_STORAGE_COLS
storage_cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns]
df_storage = df.select(storage_cols).sort(["symbol"])
base = self.store.data_dir / table
ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
out = base / f"date={ds}" / "part.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
with self._write_lock:
if out.exists():
existing = pl.read_parquet(out)
df_storage = pl.concat([existing, df_storage], how="diagonal_relaxed").unique(
subset=["symbol", "date"], keep="last"
)
self._atomic_write_parquet(df_storage.sort(["symbol"]), out)
if asset_type in {"stock", "etf"}:
self._bump_matrix_data_generation(asset_type)
def flush_live_daily(self, df: pl.DataFrame) -> None:
"""覆写当天 kline_daily 分区 (实时行情落盘, 非merge)。"""
if df.is_empty() or "date" not in df.columns:
@@ -2205,16 +2273,10 @@ class KlineRepository:
dt = df["date"][0]
cache_df = self._with_instrument_metadata(asset_type, df).sort(["symbol"])
if asset_type == "stock":
self._enriched_cache = cache_df
self._enriched_cache_date = dt
table = "kline_daily_enriched"
elif asset_type == "etf":
self._etf_enriched_cache = cache_df
self._etf_enriched_cache_date = dt
table = "kline_etf_enriched"
elif asset_type == "index":
self._index_enriched_cache = cache_df
self._index_enriched_cache_date = dt
table = "kline_index_enriched"
else:
return
@@ -2226,7 +2288,27 @@ class KlineRepository:
ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
out = base / f"date={ds}" / "part.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
publication = (
EnrichedPublication(self.store.data_dir, asset_type)
if asset_type in {"stock", "etf"}
else None
)
with self._write_lock:
self._atomic_write_parquet(df_storage, out)
if asset_type in {"stock", "etf"}:
self._bump_matrix_data_generation(asset_type)
existing = pl.read_parquet(out) if out.exists() else pl.DataFrame()
if existing.is_empty() or not existing.equals(df_storage):
if publication is None:
self._atomic_write_parquet(df_storage, out)
else:
publication.write_parquet(df_storage, out)
if publication is not None:
publication.commit()
if asset_type == "stock":
self._enriched_cache = cache_df
self._enriched_cache_date = dt
elif asset_type == "etf":
self._etf_enriched_cache = cache_df
self._etf_enriched_cache_date = dt
elif asset_type == "index":
self._index_enriched_cache = cache_df
self._index_enriched_cache_date = dt
+72 -1
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import types
from datetime import date, timedelta
import polars as pl
from app.backtest.strategy import StrategyDependencyResolver
@@ -38,11 +41,79 @@ def test_resolver_merges_signals_scoring_filter_and_execution_columns():
assert {"macd_dif", "macd_dea", "ma20", "momentum_20d", "rsi_14"} <= set(plan.indicator_columns)
assert {"signal_macd_golden", "signal_ma20_breakdown", "signal_limit_up", "signal_limit_down"} <= set(plan.signal_columns)
assert {"symbol", "date", "open", "high", "low", "close", "volume", "raw_close", "raw_high"} <= set(plan.base_columns)
assert "raw_low" not in plan.base_columns
# 涨跌停信号族统一加载不复权三价 (翘板用 raw_low 判定"曾触及跌停")
assert "raw_low" in plan.base_columns
assert "rsi_6" not in plan.indicator_columns
assert plan.full_feature_fallback is False
def test_resolver_includes_raw_low_for_limit_signal_family():
"""回归: 翘板信号 (signal_limit_down_recovery) 依赖不复权 raw_low。
bug: 涨跌停基础列只声明 raw_close/raw_high, panel 加载缺 raw_low,
因子用到翘板信号时 compute_limit_signals
"unable to find column raw_low" 回测特征准备失败
"""
plan = StrategyDependencyResolver().resolve(
_strategy(),
params={"rsi_max": 30},
basic_filter={"enabled": False},
entry_signals=["signal_limit_down_recovery"],
exit_signals=[],
)
assert "signal_limit_down_recovery" in plan.signal_columns
assert {"raw_close", "raw_high", "raw_low"} <= set(plan.base_columns)
def test_load_panel_for_backtest_supplies_raw_low_for_recovery(monkeypatch, tmp_path):
"""回归 (端到端): resolver → load_panel_for_backtest → compute_limit_signals 全链路。
bug: 涨跌停基础列漏 raw_low, 因子用到翘板信号时回测特征准备抛
"unable to find column raw_low"
"""
from app.backtest.engine import BacktestEngine
n_days = 40
start = date(2024, 1, 1)
dates = [start + timedelta(days=i) for i in range(n_days)]
px = [10.0 + 0.01 * i for i in range(n_days)]
panel_lf = pl.LazyFrame({
"symbol": ["600001.SH"] * n_days,
"date": dates,
"open": px, "high": px, "low": px, "close": px,
"volume": [100_000.0] * n_days,
"amount": [1_000_000.0] * n_days,
"raw_close": px, "raw_high": px, "raw_low": px,
})
monkeypatch.setattr("app.backtest.engine.pl.scan_parquet", lambda path, *a, **k: panel_lf)
instruments = pl.DataFrame({
"symbol": ["600001.SH"], "name": ["普通股"],
"limit_up": [11.0], "limit_down": [9.0],
})
repo = types.SimpleNamespace(
store=types.SimpleNamespace(data_dir=tmp_path),
get_enriched_range=lambda *a, **k: None,
get_instruments_asset=lambda at: instruments,
get_historical_shares=lambda: pl.DataFrame(),
)
plan = StrategyDependencyResolver().resolve(
_strategy(),
params={"rsi_max": 30},
basic_filter={"enabled": False},
entry_signals=["signal_limit_down_recovery"],
exit_signals=[],
)
df = BacktestEngine(repo).load_panel_for_backtest(
["600001.SH"], start, dates[-1], plan, asset_type="stock",
)
assert "raw_low" in df.columns
assert "signal_limit_down_recovery" in df.columns
def test_resolver_expands_virtual_scoring_dependencies():
strategy = _strategy(meta={
"id": "deps",
+16 -2
View File
@@ -79,10 +79,10 @@ def test_batch_isolates_a_single_factor_failure(monkeypatch):
service = FactorBacktestService(engine)
original = service._evaluate_panel
def evaluate(panel, config, run_id, started_at):
def evaluate(panel, config, run_id, started_at, **kwargs):
if config.factor_name == "turnover_rate":
raise ValueError("broken factor")
return original(panel, config, run_id, started_at)
return original(panel, config, run_id, started_at, **kwargs)
monkeypatch.setattr(service, "_evaluate_panel", evaluate)
result = service.run_batch(_batch_config(["change_pct", "turnover_rate"]))
@@ -135,6 +135,20 @@ def test_factor_catalog_covers_normalized_indicator_families():
"log_amount",
"gap_return",
"distance_to_high_60d",
"max_ret_20d",
"ret_skew_20d",
"up_days_20d",
"amihud_20d",
"turnover_z_60d",
"vol_price_corr_20d",
"vwap_bias",
"vol_trend_5_60",
"limit_up_count_20d",
"limit_up_count_60d",
"pb_latest",
"roe_latest",
"revenue_yoy_latest",
"debt_ratio_latest",
} <= set(factor_ids)
assert set(DERIVED_FACTOR_DEPENDENCIES) <= set(factor_ids)
@@ -0,0 +1,391 @@
from __future__ import annotations
from datetime import date, timedelta
from types import SimpleNamespace
import polars as pl
import pytest
from app.backtest.factor import (
FactorBacktestService,
FactorBatchConfig,
FactorBatchItem,
FactorConfig,
FactorResult,
)
from app.backtest.regime_alignment import (
align_regime_t_minus_one,
build_regime_filter_mask,
)
class _Engine:
def __init__(self, panel: pl.DataFrame, data_dir=None) -> None:
self.panel = panel
self.calls = 0
self.repo = (
SimpleNamespace(store=SimpleNamespace(data_dir=data_dir))
if data_dir is not None
else None
)
def load_panel(self, symbols, start, end, columns, asset_type):
self.calls += 1
selected = [column for column in columns if column in self.panel.columns]
return self.panel.select(selected)
def _config(
factor_name: str = "turnover_rate",
**overrides,
) -> FactorConfig:
values = {
"factor_name": factor_name,
"symbols": None,
"start": date(2026, 1, 5),
"end": date(2026, 1, 12),
"n_groups": 2,
"rebalance": "daily",
"fees_pct": 0.0,
"slippage_bps": 0.0,
}
values.update(overrides)
return FactorConfig(**values)
def _batch_config(**overrides) -> FactorBatchConfig:
values = {
"factor_names": ["turnover_rate"],
"symbols": None,
"start": date(2026, 1, 5),
"end": date(2026, 1, 12),
"n_groups": 2,
"rebalance": "daily",
"fees_pct": 0.0,
"slippage_bps": 0.0,
}
values.update(overrides)
return FactorBatchConfig(**values)
def _daily_panel(days: int = 8, symbols: int = 4) -> pl.DataFrame:
rows = []
start = date(2026, 1, 5)
for day in range(days):
for index in range(symbols):
rows.append({
"symbol": f"S{index}",
"date": start + timedelta(days=day),
"open": 10.0 + index,
"high": 10.5 + index,
"low": 9.5 + index,
"close": (10.0 + index) * (1.0 + (index - 1) * day * 0.01),
"volume": 1_000.0,
"amount": 10_000.0,
"turnover_rate": float(index + 1),
})
return pl.DataFrame(rows)
def test_single_and_batch_use_same_full_price_axis_with_internal_factor_null():
panel = _daily_panel(days=5, symbols=3).with_columns(
pl.when((pl.col("symbol") == "S2") & (pl.col("date") == date(2026, 1, 6)))
.then(None)
.otherwise(pl.col("turnover_rate"))
.alias("turnover_rate")
)
engine = _Engine(panel)
service = FactorBacktestService(engine)
single = service.run(_config(end=date(2026, 1, 9)))
batch = service.run_batch(_batch_config(end=date(2026, 1, 9)))
assert single.error is None
assert batch.results[0].error is None
assert single.ic_mean == batch.results[0].ic_mean
assert single.ir == batch.results[0].ir
assert single.long_short_stats["total_return"] == batch.results[0].long_short_return
def test_daily_forward_returns_join_exact_global_trading_dates_on_suspension():
dates = [date(2026, 1, 5) + timedelta(days=offset) for offset in range(6)]
rows = [
{"symbol": "B", "date": current, "close": 20.0 + index}
for index, current in enumerate(dates)
]
rows.extend(
{"symbol": "A", "date": current, "close": 10.0 + index}
for index, current in enumerate(dates)
if index != 3
)
panel = pl.DataFrame(rows)
prepared = FactorBacktestService._attach_shared_next_return(
panel,
_batch_config(end=dates[-1]),
)
first = prepared.filter(
(pl.col("symbol") == "A") & (pl.col("date") == dates[0])
).row(0, named=True)
second = prepared.filter(
(pl.col("symbol") == "A") & (pl.col("date") == dates[1])
).row(0, named=True)
assert first["_forward_return_1d"] == pytest.approx(11.0 / 10.0 - 1.0)
assert first["_forward_return_3d"] is None
assert first["_forward_return_5d"] == pytest.approx(15.0 / 10.0 - 1.0)
assert second["_forward_return_3d"] == pytest.approx(14.0 / 11.0 - 1.0)
def test_service_forward_axis_uses_market_partitions_when_selected_universe_has_gap(
tmp_path,
):
dates = [date(2026, 1, 5) + timedelta(days=offset) for offset in range(3)]
panel = pl.DataFrame({
"symbol": ["A", "A"],
"date": [dates[0], dates[2]],
"open": [10.0, 12.0],
"high": [10.0, 12.0],
"low": [10.0, 12.0],
"close": [10.0, 12.0],
"volume": [1_000.0, 1_000.0],
"amount": [10_000.0, 12_000.0],
"turnover_rate": [1.0, 2.0],
})
for current in dates:
partition = tmp_path / "kline_daily_enriched" / f"date={current.isoformat()}"
partition.mkdir(parents=True)
pl.DataFrame({"date": [current]}).write_parquet(partition / "part.parquet")
service = FactorBacktestService(_Engine(panel, tmp_path))
config = _batch_config(start=dates[0], end=dates[-1])
loaded = service._load_factor_panel(config, ["turnover_rate"])
prepared = service._attach_shared_next_return(
loaded,
config,
trading_dates=service._global_trading_dates(config),
)
first = prepared.filter(pl.col("date") == dates[0]).row(0, named=True)
assert first["_forward_return_1d"] is None
assert first["_forward_return_3d"] is None
def test_tie_aware_groups_do_not_split_constant_factor_by_symbol_order():
panel = pl.DataFrame({
"symbol": ["C", "A", "D", "B"],
"date": [date(2026, 1, 5)] * 4,
"factor": [7.0] * 4,
})
first = FactorBacktestService._add_groups(panel, "factor", 5).sort("symbol")
second = FactorBacktestService._add_groups(
panel.reverse(), "factor", 5
).sort("symbol")
assert first["_group"].n_unique() == 1
assert first["_group"].to_list() == second["_group"].to_list()
assert first["_factor_strength"].to_list() == second["_factor_strength"].to_list()
def test_weekly_uses_first_actual_trading_day_not_monday():
panel = pl.DataFrame({
"symbol": ["A", "A", "A", "A"],
"date": [
date(2026, 1, 6),
date(2026, 1, 7),
date(2026, 1, 13),
date(2026, 1, 14),
],
"close": [10.0, 11.0, 12.0, 13.0],
})
result = FactorBacktestService._calc_period_return(panel, "weekly")
assert result.filter(pl.col("date") == date(2026, 1, 6))["_next_return"][0] == pytest.approx(0.2)
assert result.filter(pl.col("date") == date(2026, 1, 7))["_next_return"][0] is None
def test_monthly_uses_first_actual_trading_day():
panel = pl.DataFrame({
"symbol": ["A", "A", "A", "A"],
"date": [
date(2026, 1, 6),
date(2026, 1, 7),
date(2026, 2, 3),
date(2026, 2, 4),
],
"close": [10.0, 11.0, 12.0, 13.0],
})
result = FactorBacktestService._calc_period_return(panel, "monthly")
assert result.filter(pl.col("date") == date(2026, 1, 6))["_next_return"][0] == pytest.approx(0.2)
assert result.filter(pl.col("date") == date(2026, 1, 7))["_next_return"][0] is None
def test_factor_weight_and_decomposed_costs_change_group_results():
panel = _daily_panel(days=2, symbols=4).with_columns(
pl.when(pl.col("date") == date(2026, 1, 6))
.then(
pl.when(pl.col("symbol") == "S0").then(9.0)
.when(pl.col("symbol") == "S1").then(10.0)
.when(pl.col("symbol") == "S2").then(14.3)
.otherwise(16.9)
)
.otherwise(pl.col("close"))
.alias("close")
)
service = FactorBacktestService(_Engine(panel))
equal = service.run(_config(end=date(2026, 1, 6), weight="equal"))
weighted = service.run(_config(end=date(2026, 1, 6), weight="factor_weight"))
costly = service.run(_config(
end=date(2026, 1, 6),
weight="factor_weight",
fees_pct=0.009,
commission_pct=0.001,
stamp_tax_pct=0.002,
slippage_bps=10.0,
))
equal_q2 = next(item for item in equal.group_stats if item["label"] == "Q2")
weighted_q2 = next(item for item in weighted.group_stats if item["label"] == "Q2")
costly_q2 = next(item for item in costly.group_stats if item["label"] == "Q2")
assert weighted_q2["total_return"] != equal_q2["total_return"]
assert weighted_q2["total_return"] - costly_q2["total_return"] == pytest.approx(0.006)
assert costly.long_short_stats["total_return"] < weighted.long_short_stats["total_return"]
assert costly.config["commission_pct"] == 0.001
assert costly.config["stamp_tax_pct"] == 0.002
def test_factor_v2_metrics_and_defaults_are_backward_compatible():
default_result = FactorResult(run_id="r", config={})
default_item = FactorBatchItem(factor_name="f", label="F", group="G")
assert default_result.methodology_version == "factor_v2"
assert default_result.yearly_ic == []
assert default_result.ic_decay == []
assert default_result.regime_stats == []
assert default_item.methodology_version == "factor_v2"
assert default_item.yearly_ic == []
result = FactorBacktestService(_Engine(_daily_panel())).run(_config())
assert result.methodology_version == "factor_v2"
assert result.coverage == 1.0
assert result.turnover is not None
assert result.long_short_sharpe is not None
assert [item["horizon"] for item in result.ic_decay] == [1, 3, 5]
assert result.yearly_ic[0]["year"] == 2026
assert result.long_short_stats["portfolio_type"] == "theoretical_factor_spread"
assert result.long_short_stats["executable_short"] is False
def test_factor_regime_stats_accept_injected_t_minus_one_mapping(tmp_path):
panel = _daily_panel(days=5)
market_dates = [date(2026, 1, 2), *panel["date"].unique().sort().to_list()]
for current in market_dates:
partition = tmp_path / "kline_daily_enriched" / f"date={current.isoformat()}"
partition.mkdir(parents=True)
pl.DataFrame({"date": [current]}).write_parquet(partition / "part.parquet")
regimes = {
date(2026, 1, 2): {"state": "range", "score": 50},
date(2026, 1, 5): {"state": "weak", "score": 20},
date(2026, 1, 6): {"state": "strong", "score": 80},
date(2026, 1, 7): {"state": "strong", "score": 85},
date(2026, 1, 8): {"state": "range", "score": 50},
}
result = FactorBacktestService(_Engine(panel, tmp_path)).run(
_config(end=date(2026, 1, 9)),
regime_by_date=regimes,
)
assert result.error is None
assert {item["state"] for item in result.regime_stats} == {"range", "strong", "weak"}
def test_factor_regime_stats_reject_missing_actual_market_predecessor(tmp_path):
panel = _daily_panel(days=3)
market_dates = [date(2026, 1, 2), *panel["date"].unique().sort().to_list()]
for current in market_dates:
partition = tmp_path / "kline_daily_enriched" / f"date={current.isoformat()}"
partition.mkdir(parents=True)
pl.DataFrame({"date": [current]}).write_parquet(partition / "part.parquet")
regimes = {
date(2026, 1, 1): {"state": "range", "score": 50},
date(2026, 1, 5): {"state": "weak", "score": 20},
date(2026, 1, 6): {"state": "strong", "score": 80},
}
with pytest.raises(ValueError, match="2026-01-02"):
FactorBacktestService(_Engine(panel, tmp_path)).run(
_config(end=date(2026, 1, 7)),
regime_by_date=regimes,
)
def test_align_regime_t_minus_one_is_pure_and_fail_closed_in_required_range():
labels = ("2026-01-05", "2026-01-06", "2026-01-07")
regimes = {
"2026-01-05": ("weak", 20),
"2026-01-06": {"state": "strong", "score": 80},
}
aligned = align_regime_t_minus_one(
labels,
regimes,
required_start=date(2026, 1, 6),
required_end=date(2026, 1, 7),
)
mask = build_regime_filter_mask(
labels,
{"states": ["strong"], "min_score": 60},
regimes,
required_start=date(2026, 1, 6),
required_end=date(2026, 1, 7),
)
assert aligned == [None, ("weak", 20.0), ("strong", 80.0)]
assert mask is not None
assert mask.tolist() == [True, False, True]
with pytest.raises(ValueError, match="正式首日"):
align_regime_t_minus_one(
labels,
regimes,
required_start=date(2026, 1, 5),
required_end=date(2026, 1, 7),
)
lean_regimes = {
"2026-01-05": ("lean_strong", 60),
"2026-01-06": ("range", 50),
}
strong_only_mask = build_regime_filter_mask(
labels,
{"states": ["strong"]},
lean_regimes,
required_start=date(2026, 1, 6),
required_end=date(2026, 1, 7),
)
assert strong_only_mask is not None
assert strong_only_mask.tolist() == [True, False, False]
aggregated_mask = build_regime_filter_mask(
labels,
{"states": ["strong", "lean_strong"]},
lean_regimes,
required_start=date(2026, 1, 6),
required_end=date(2026, 1, 7),
)
assert aggregated_mask is not None
assert aggregated_mask.tolist() == [True, True, False]
with pytest.raises(ValueError, match="缺少前一交易日环境"):
align_regime_t_minus_one(
labels,
{"2026-01-05": ("weak", 20)},
required_start=date(2026, 1, 6),
required_end=date(2026, 1, 7),
)
@@ -0,0 +1,218 @@
from __future__ import annotations
from datetime import date
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import polars as pl
import pytest
from fastapi import HTTPException
from app.api import screener as screener_api
from app.api import strategy as strategy_api
from app.backtest.matrix import build_market_data_matrix, validate_signal_matrix
from app.backtest.optimizer import expand_param_grid
from app.backtest.strategy import StrategyDependencyResolver
from app.strategy.engine import StrategyEngine
STRATEGY_PATH = (
Path(__file__).resolve().parents[2]
/ "app"
/ "strategy"
/ "builtin"
/ "factor_rank_research.py"
)
def _market():
panel = pl.DataFrame({
"symbol": ["000001.SZ", "000002.SZ", "000003.SZ", "000004.SZ"] * 2,
"date": [date(2024, 1, 2)] * 4 + [date(2024, 1, 3)] * 4,
"open": [10.0] * 8,
"high": [10.5] * 8,
"low": [9.5] * 8,
"close": [10.0] * 8,
"volume": [1_000.0] * 8,
"amount": [1.0, 2.0, 3.0, 4.0, 4.0, 3.0, 2.0, 1.0],
"turnover_rate": [4.0, 3.0, 2.0, 1.0, 1.0, 2.0, 3.0, 4.0],
})
return build_market_data_matrix(
panel,
field_columns={"amount", "turnover_rate"},
)
def test_strategy_loads_as_builtin_matrix_native_and_grid_params_validate():
strategy = StrategyEngine._load_file(STRATEGY_PATH)
assert strategy.meta["id"] == "factor_rank_research"
assert strategy.meta["research_only"] is True
assert strategy.execution_backend == "matrix_native"
assert strategy.matrix_strategy is not None
assert strategy.meta["scoring"] == {}
combos = expand_param_grid(
strategy.meta["params"],
{
"entry_score": [50.0, 75.0],
"exit_score": [20.0],
"top_rank": [1, 2],
},
)
assert len(combos) == 4
assert strategy.matrix_strategy.required_warmup_bars({}) == 60
assert {"amount", "turnover_rate", "close"}.issubset(
strategy.matrix_strategy.required_fields()
)
def test_research_template_is_hidden_from_ordinary_strategy_apis(tmp_path):
engine = StrategyEngine(strategy_dirs=[STRATEGY_PATH.parent])
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(strategy_engine=engine, repo=repo))
)
screener_payload = screener_api.strategies(request)
strategy_payload = strategy_api.list_strategies(request)
assert engine.has("factor_rank_research")
assert "factor_rank_research" in {
item["id"] for item in engine.list_strategies(include_research=True)
}
assert "factor_rank_research" not in {
item["id"] for item in screener_payload["presets"]
}
assert "factor_rank_research" not in {
item["id"] for item in strategy_payload["strategies"]
}
with pytest.raises(HTTPException) as screener_error:
screener_api.run_preset(
screener_api.PresetRequest(
strategy_id="factor_rank_research",
as_of=date(2024, 1, 2),
),
request,
)
assert screener_error.value.status_code == 404
with pytest.raises(HTTPException) as strategy_error:
strategy_api.run_strategy(
strategy_api.RunRequest(
strategy_id="factor_rank_research",
as_of=date(2024, 1, 2),
),
request,
)
assert strategy_error.value.status_code == 404
def test_dependency_resolver_includes_parameter_scoring_fields():
strategy = StrategyEngine._load_file(STRATEGY_PATH)
plan = StrategyDependencyResolver().resolve(
strategy,
params={"scoring": {"amount": 1.0, "ma20_bias": 1.0}},
basic_filter={"enabled": False},
entry_signals=strategy.entry_signals,
exit_signals=strategy.exit_signals,
)
assert {"amount", "close"}.issubset(plan.base_columns)
assert plan.indicator_columns == frozenset()
assert {"amount", "close"}.issubset(plan.matrix_columns)
def test_strategy_uses_controlled_scoring_directions_thresholds_and_top_rank():
strategy = StrategyEngine._load_file(STRATEGY_PATH).matrix_strategy
market = _market()
signals = strategy.compute_signals(
market,
{
"scoring": {"amount": 1.0, "turnover_rate": 1.0},
"directions": {"amount": "high", "turnover_rate": "low"},
"entry_score": 60.0,
"exit_score": 25.0,
"top_rank": 1,
},
)
validate_signal_matrix(signals, market.shape)
assert signals.entry.sum(axis=1).tolist() == [1, 1]
assert signals.entry.tolist() == [[0, 0, 0, 1], [1, 0, 0, 0]]
assert signals.exit.tolist() == [[1, 0, 0, 0], [0, 0, 0, 1]]
assert signals.entry_signal_ids == ("signal_factor_rank_entry",)
assert signals.exit_signal_ids == ("signal_factor_rank_exit",)
assert not signals.score.flags.writeable
def test_strategy_direction_changes_score_without_dynamic_formula_execution():
strategy = StrategyEngine._load_file(STRATEGY_PATH).matrix_strategy
market = _market()
high = strategy.compute_signals(
market,
{
"scoring": {"amount": 1.0},
"directions": {"amount": "high"},
"entry_score": 0.0,
"exit_score": 0.0,
"top_rank": 4,
},
)
low = strategy.compute_signals(
market,
{
"scoring": {"amount": 1.0},
"directions": {"amount": "low"},
"entry_score": 0.0,
"exit_score": 0.0,
"top_rank": 4,
},
)
np.testing.assert_allclose(high.score + low.score, 100.0)
with pytest.raises(ValueError, match="unsupported matrix feature"):
strategy.compute_signals(
market,
{
"scoring": {"__import__('os').system('bad')": 1.0},
"entry_score": 50.0,
"exit_score": 20.0,
"top_rank": 1,
},
)
@pytest.mark.parametrize(
("params", "message"),
[
({"scoring": {}}, "non-empty scoring"),
(
{"scoring": {f"factor_{index}": 1.0 for index in range(5)}},
"at most 4 factors",
),
(
{
"scoring": {"amount": 1.0},
"directions": {"turnover_rate": "low"},
},
"absent from scoring",
),
(
{
"scoring": {"amount": 1.0},
"entry_score": 20.0,
"exit_score": 30.0,
},
"exit_score must not exceed",
),
],
)
def test_strategy_rejects_uncontrolled_or_invalid_research_params(params, message):
strategy = StrategyEngine._load_file(STRATEGY_PATH).matrix_strategy
with pytest.raises(ValueError, match=message):
strategy.compute_signals(_market(), params)
+83 -5
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import gc
import threading
from dataclasses import replace
from datetime import date, timedelta
from pathlib import Path
@@ -87,6 +88,7 @@ def test_research_factor_catalog_matches_matrix_features():
for offset in range(120):
for asset_id, symbol in enumerate(("000001.SZ", "600000.SH")):
close = 10.0 + asset_id * 5.0 + offset * (0.02 + asset_id * 0.01) + np.sin(offset / 5.0)
volume = 1000.0 + asset_id * 250.0 + (offset % 9) * 80.0
rows.append({
"symbol": symbol,
"date": start + timedelta(days=offset),
@@ -94,14 +96,35 @@ def test_research_factor_catalog_matches_matrix_features():
"high": close + 0.35 + asset_id * 0.03,
"low": close - 0.3,
"close": close,
"volume": 1000.0 + asset_id * 250.0 + (offset % 9) * 80.0,
"amount": (1000.0 + asset_id * 250.0 + (offset % 9) * 80.0) * close,
"volume": volume,
"amount": volume * close,
"turnover_rate": 1.0 + asset_id * 0.2 + (offset % 7) * 0.05,
"consecutive_limit_ups": (
(offset % 17) + 1 if (asset_id == 0 and offset % 17 == 0)
else (2 if (asset_id == 1 and offset % 23 == 0) else 0)
),
})
panel = pl.DataFrame(rows)
factor_names = {item["id"] for item in FACTOR_COLUMNS}
expected = FactorBacktestService._compute_missing_factors(panel, factor_names)
market = build_market_data_matrix(panel, field_columns={"amount", "turnover_rate"})
from app.backtest.fundamentals import (
FUNDAMENTAL_FACTOR_NAMES,
attach_matrix_fundamental_fields,
)
fundamental_names = sorted(factor_names & FUNDAMENTAL_FACTOR_NAMES)
if fundamental_names:
# 夹具无财务数据: 两条路径都应得到全 null/NaN 列, 而不是报错。
expected = expected.with_columns([
pl.lit(None, dtype=pl.Float64).alias(name)
for name in fundamental_names
if name not in expected.columns
])
market = build_market_data_matrix(
panel,
field_columns={"amount", "turnover_rate", "consecutive_limit_ups"},
)
market = attach_matrix_fundamental_fields(market, None, fundamental_names)
for name in sorted(factor_names):
expected_values = expected.sort(["date", "symbol"])[name].to_numpy().reshape(market.shape)
@@ -296,7 +319,7 @@ def test_builtin_matrix_strategies_use_their_declared_formula_modules():
path for path in strategy_dir.glob("*.py") if path.name != "__init__.py"
)
assert len(strategy_files) == 18
assert len(strategy_files) == 19
for strategy_path in strategy_files:
strategy = StrategyEngine._load_file(strategy_path)
assert strategy.execution_backend == "matrix_native"
@@ -603,6 +626,61 @@ def test_matrix_cache_can_be_disabled(tmp_path):
assert not isinstance(market.close, np.memmap)
def test_matrix_cache_cancellation_removes_staging_output(tmp_path, monkeypatch):
market_root = tmp_path / "kline_daily_enriched"
current = date(2024, 3, 2)
partition = market_root / f"date={current.isoformat()}"
partition.mkdir(parents=True)
pl.DataFrame({
"symbol": ["000001.SZ"],
"date": [current],
"open": [10.0],
"high": [10.0],
"low": [10.0],
"close": [10.0],
"volume": [1_000.0],
}).write_parquet(partition / "part.parquet")
cache_root = tmp_path / "matrix_cache"
cancel_event = threading.Event()
original_scan = matrix_module._scan_matrix_values
def cancel_after_scan(*args, **kwargs):
original_scan(*args, **kwargs)
cancel_event.set()
monkeypatch.setattr(matrix_module, "_scan_matrix_values", cancel_after_scan)
with pytest.raises(
matrix_module.MatrixPrewarmCancelledError,
match="prewarm cancelled",
):
load_market_data_matrix_from_parquet(
market_root,
current,
current,
field_columns=set(),
cache_root=cache_root,
cancel_event=cancel_event,
)
assert list(cache_root.glob("v*-*")) == []
assert list(cache_root.glob(".*.tmp")) == []
def test_matrix_cache_can_be_cancelled_before_scan(tmp_path):
cancel_event = threading.Event()
cancel_event.set()
with pytest.raises(matrix_module.MatrixPrewarmCancelledError):
load_market_data_matrix_from_parquet(
tmp_path / "missing",
date(2024, 3, 1),
date(2024, 3, 1),
field_columns=set(),
cancel_event=cancel_event,
)
def test_matrix_cache_prunes_by_bytes_and_leaves_no_staging_directory(tmp_path):
market_root = tmp_path / "kline_daily_enriched"
current = date(2024, 3, 4)
@@ -708,7 +786,7 @@ def test_registered_builtin_matrix_strategies_share_one_cache_profile():
profile = build_matrix_cache_profile(engine, "stock")
strategies = engine.strategy_definitions()
assert len(strategies) == 18
assert len(strategies) == 19
assert all(strategy.execution_backend == "matrix_native" for strategy in strategies)
assert profile.warmup_bars > 0
assert profile.forward_bars == max(int(strategy.max_hold_days or 0) for strategy in strategies)
+941
View File
@@ -0,0 +1,941 @@
from __future__ import annotations
import json
from dataclasses import asdict, replace
from datetime import date, timedelta
import numpy as np
import polars as pl
import pytest
import app.backtest.mining as mining_module
from app.backtest.mining import (
CandidateEvaluation,
CorrelationResult,
FactorMetric,
MiningBudget,
MiningRequest,
MiningService,
NestedValidationConfig,
_searchable_factors,
beam_search_factor_combinations,
compute_rank_correlation,
generate_nested_folds,
nested_fold_count,
prune_correlated_factors,
required_outer_folds,
required_trading_bars,
validation_config_for_profile,
)
def _panel(days: int = 12, assets: int = 6) -> pl.DataFrame:
rows = []
start = date(2024, 1, 2)
for day_id in range(days):
current = start + timedelta(days=day_id * 2)
for asset_id in range(assets):
target = float(asset_id + (day_id % 2) * 0.1)
rows.append({
"symbol": f"{asset_id:06d}.SZ",
"date": current,
"good": target,
"inverse": -target,
"copy": target * 10.0,
"noise": float((asset_id * 7 + day_id * 3) % assets),
"_next_return": target,
})
return pl.DataFrame(rows)
def _metrics() -> tuple[FactorMetric, ...]:
return (
FactorMetric("good", 1.0, 1.0, 1.0, 0.2, 1.0),
FactorMetric("copy", 0.9, 0.9, 1.0, 0.1, 1.0),
FactorMetric("inverse", 0.8, 0.8, 1.0, 0.1, -1.0),
FactorMetric("noise", 0.2, 0.1, 0.9, 0.5, 0.0),
)
def test_profiles_enforce_hard_limits_and_are_json_serializable():
request = MiningRequest.for_profile("exploratory", ["good", "noise"])
assert request.budget.beam_width == 8
assert request.validation.outer_train_bars == 126
assert request.validation.outer_test_bars == 63
assert NestedValidationConfig.balanced().outer_train_bars == 504
assert NestedValidationConfig.strict().outer_train_bars == 756
assert json.loads(request.to_json())["factor_names"] == ["good", "noise"]
assert asdict(request)["validation"]["purge_bars"] == 30
with pytest.raises(ValueError, match="max_factors"):
MiningBudget(max_factors=49)
with pytest.raises(ValueError, match="beam_width"):
MiningBudget(beam_width=33)
with pytest.raises(ValueError, match="max_trials"):
MiningBudget(max_trials=257)
with pytest.raises(ValueError, match="existing strategy count"):
MiningRequest(
factor_names=("good",),
existing_strategy_ids=tuple(str(index) for index in range(9)),
)
@pytest.mark.parametrize(
("profile", "required_bars", "folds"),
[
("exploratory", 219, 1),
("balanced", 786, 3),
("strict", 1164, 3),
],
)
def test_profile_trading_bar_requirements(profile, required_bars, folds):
config = validation_config_for_profile(profile)
assert required_outer_folds(profile) == folds
assert required_trading_bars(config, folds) == required_bars
assert nested_fold_count(required_bars - 1, config) == folds - 1
assert nested_fold_count(required_bars, config) == folds
def test_rank_correlation_is_pairwise_finite_symmetric_and_average_ranked():
panel = pl.DataFrame({
"date": [date(2024, 1, 2)] * 5 + [date(2024, 1, 3)] * 5,
"a": [1.0, 1.0, 3.0, np.nan, 5.0, 5.0, 4.0, 3.0, 2.0, 1.0],
"b": [2.0, 2.0, 6.0, 8.0, np.inf, 1.0, 2.0, 3.0, 4.0, 5.0],
"c": [1.0, None, 2.0, 3.0, 4.0, None, None, None, None, None],
"constant": [7.0] * 10,
})
result = compute_rank_correlation(
panel,
["a", "b", "c", "constant"],
date(2024, 1, 2),
date(2024, 1, 3),
)
matrix = np.asarray(result.matrix)
counts = np.asarray(result.pair_counts)
np.testing.assert_allclose(matrix, matrix.T)
np.testing.assert_array_equal(np.diag(matrix), np.ones(4))
assert counts[0, 1] == 2
assert counts[0, 2] == 1
assert counts[2, 2] == 1
assert counts[3, 3] == 2
assert counts[0, 3] == 0
assert np.isnan(matrix[0, 3])
assert result.n_dates == 2
assert set(result.timing_ms) == {"filter", "rank_accumulate", "finalize", "total"}
daily_correlations = []
for daily in panel.partition_by("date"):
valid = daily.filter(pl.col("a").is_finite() & pl.col("b").is_finite())
daily_correlations.append(np.corrcoef(
valid["a"].rank(method="average").to_numpy(),
valid["b"].rank(method="average").to_numpy(),
)[0, 1])
assert daily_correlations == pytest.approx([1.0, -1.0])
assert matrix[0, 1] == pytest.approx(np.mean(daily_correlations))
def test_rank_correlation_reranks_after_pairwise_finite_intersection():
panel = pl.DataFrame({
"date": [date(2024, 1, 2)] * 4,
"a": [1.0, 2.0, 3.0, 4.0],
"b": [10.0, None, 20.0, 30.0],
"c": [None, 10.0, 20.0, 30.0],
})
result = compute_rank_correlation(panel, ["a", "b", "c"])
matrix = np.asarray(result.matrix)
assert matrix[0, 1] == pytest.approx(1.0)
assert matrix[0, 2] == pytest.approx(1.0)
assert matrix[1, 2] == pytest.approx(1.0)
def test_pruning_uses_deterministic_metric_order_and_reports_representative():
correlation = CorrelationResult(
factor_names=("a", "b", "c"),
matrix=((1.0, 0.9, 0.1), (0.9, 1.0, 0.2), (0.1, 0.2, 1.0)),
pair_counts=((10, 10, 10), (10, 10, 10), (10, 10, 10)),
elapsed_ms=1.0,
n_dates=2,
n_rows=10,
)
metrics = (
FactorMetric("b", 1.0, 2.0, 0.8, 0.1),
FactorMetric("c", 0.5, 1.0, 1.0, 0.1),
FactorMetric("a", 1.0, 2.0, 0.8, 0.2),
)
result = prune_correlated_factors(metrics, correlation, 0.8)
assert result.selected == ("b", "c")
assert result.excluded[0].factor_id == "a"
assert result.excluded[0].representative == "b"
assert result.excluded[0].rho == pytest.approx(0.9)
def test_pruning_ignores_unestimable_factor_pairs():
correlation = CorrelationResult(
factor_names=("a", "b"),
matrix=((1.0, float("nan")), (float("nan"), 1.0)),
pair_counts=((10, 0), (0, 10)),
elapsed_ms=1.0,
n_dates=2,
n_rows=10,
)
metrics = (
FactorMetric("a", 1.0, 1.0, 1.0, 0.1),
FactorMetric("b", 0.9, 0.9, 1.0, 0.1),
)
result = prune_correlated_factors(metrics, correlation, 0.8)
assert result.selected == ("a", "b")
assert result.excluded == ()
def test_beam_search_learns_direction_from_train_and_honors_real_proxy_budget():
panel = _panel()
first = beam_search_factor_combinations(
panel,
["noise", "inverse", "good"],
max_combination_size=4,
beam_width=32,
max_trials=7,
)
second = beam_search_factor_combinations(
panel,
["good", "noise", "inverse"],
max_combination_size=4,
beam_width=32,
max_trials=7,
)
reversed_rows = beam_search_factor_combinations(
panel.reverse(),
["good", "noise", "inverse"],
max_combination_size=4,
beam_width=32,
max_trials=7,
)
assert first.trials_used == 7
assert first.budget_exhausted is True
assert first.candidates == second.candidates == reversed_rows.candidates
inverse = next(
candidate
for candidate in first.candidates
if candidate.factor_names == ("inverse",)
)
assert inverse.directions == (-1,)
assert all(len(candidate.factor_names) <= 4 for candidate in first.candidates)
assert all(
set(candidate.weights) <= {1.0, 2.0}
and sum(weight == 2.0 for weight in candidate.weights) <= 1
for candidate in first.candidates
)
def test_beam_search_does_not_access_rows_outside_explicit_train_range():
panel = _panel(days=10)
train_end = sorted(panel["date"].unique().to_list())[5]
changed = panel.with_columns(
pl.when(pl.col("date") > train_end)
.then(-pl.col("_next_return") * 1_000.0)
.otherwise(pl.col("_next_return"))
.alias("_next_return")
)
original_result = beam_search_factor_combinations(
panel,
["good", "inverse", "noise"],
train_end=train_end,
max_trials=30,
)
changed_result = beam_search_factor_combinations(
changed,
["good", "inverse", "noise"],
train_end=train_end,
max_trials=30,
)
assert original_result.candidates == changed_result.candidates
def test_nested_folds_use_trading_labels_with_explicit_purge_and_embargo():
labels = [f"T{index:02d}" for index in range(22)]
config = NestedValidationConfig(
outer_train_bars=12,
outer_test_bars=4,
outer_step_bars=4,
inner_train_bars=5,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=1,
embargo_bars=2,
min_train_bars=5,
)
folds = generate_nested_folds(labels, config)
assert folds[0].outer.train_labels == tuple(labels[:12])
assert folds[0].outer.purge_labels == ("T12",)
assert folds[0].outer.test_labels == tuple(labels[13:17])
assert folds[0].outer.embargo_labels == ("T17", "T18")
assert folds[0].inner[0].train_labels == tuple(labels[:5])
assert folds[0].inner[0].purge_labels == ("T05",)
assert folds[0].inner[0].test_labels == ("T06", "T07")
assert set(folds[0].inner[-1].test_labels).issubset(folds[0].outer.train_labels)
with pytest.raises(ValueError, match="insufficient trading bars"):
generate_nested_folds(labels[:10], config)
def _frame_labels(frame) -> tuple[str, ...]:
return tuple(
frame.select(pl.col("date").cast(pl.Utf8).str.slice(0, 10).unique().sort())
.to_series()
.to_list()
)
class _Evaluator:
def __init__(self) -> None:
self.calls: list[tuple[tuple[str, ...], tuple[str, ...], dict]] = []
def evaluate_candidate(self, train, test, definition):
train_labels = _frame_labels(train)
test_labels = _frame_labels(test)
self.calls.append((train_labels, test_labels, dict(definition)))
return {"score": len(definition.get("factor_names", ())) or 0.1}
class _AlternatingWinnerEvaluator(_Evaluator):
"""Prefer the good factor only in windows before 2024-01-25; every later window prefers the runner-up.
With a 20-day panel (dates spaced two calendar days apart) and a
non-overlapping outer step, every inner and outer window of the first
fold falls in January while every window of the second fold falls after
the boundary, so the per-fold winner flips and cross-fold evaluation
has two distinct definitions to score.
"""
def evaluate_candidate(self, train, test, definition):
self.calls.append((_frame_labels(train), _frame_labels(test), dict(definition)))
prefer_good = _frame_labels(test)[0] < "2024-01-25"
names = definition.get("factor_names") or ()
is_good = bool(names) and names[0] == "good"
return {"score": 2.0 if is_good == prefer_good else 1.0}
class _LabelEvaluator(_Evaluator):
def __init__(self) -> None:
super().__init__()
self.label_calls: list[tuple[tuple[str, ...], tuple[str, ...], dict]] = []
def evaluate_candidate(self, train, test, definition):
raise AssertionError("label evaluator must not receive fold DataFrames")
def evaluate_candidate_labels(self, train_labels, test_labels, definition):
self.label_calls.append((
tuple(train_labels),
tuple(test_labels),
dict(definition),
))
return {"score": len(definition.get("factor_names", ())) or 0.1}
@pytest.mark.parametrize("evaluator_type", [_Evaluator, _LabelEvaluator])
def test_mining_service_reselects_per_inner_fold_and_keeps_tests_out_of_selection(
evaluator_type,
):
panel = _panel(days=10)
validation = NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=2,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
)
request = MiningRequest(
factor_names=("good", "copy", "inverse", "noise"),
correlation_threshold=0.95,
budget=MiningBudget(
max_combination_size=2,
beam_width=4,
max_proxy_trials=18,
max_trials=3,
),
validation=validation,
profile="exploratory",
)
folds = generate_nested_folds(
sorted(str(value) for value in panel["date"].unique().to_list()),
validation,
)
assert len(folds) == 1
metric_calls: list[tuple[str, ...]] = []
def metric_provider(train, factor_names):
assert tuple(factor_names) == request.factor_names
labels = tuple(
train.select(pl.col("date").cast(pl.Utf8).str.slice(0, 10).unique().sort())
.to_series()
.to_list()
)
metric_calls.append(labels)
return _metrics()
evaluator = evaluator_type()
result = MiningService().run(
panel,
request,
metric_provider=metric_provider,
evaluator=evaluator,
)
nested = folds[0]
expected_selection_labels = [
*(inner.train_labels for inner in nested.inner),
nested.outer.train_labels,
]
assert metric_calls == expected_selection_labels
assert result.trials_used == request.budget.max_trials
assert result.proxy_trials_used <= request.budget.max_proxy_trials
evaluator_calls = (
evaluator.label_calls
if isinstance(evaluator, _LabelEvaluator)
else evaluator.calls
)
assert len(evaluator_calls) == len(nested.inner) + 1
for call, inner in zip(evaluator_calls[:-1], nested.inner, strict=True):
train_labels, test_labels, _ = call
assert train_labels == inner.train_labels
assert test_labels == inner.test_labels
assert not set(nested.outer.test_labels).intersection(train_labels + test_labels)
outer_train, outer_test, outer_definition = evaluator_calls[-1]
assert outer_train == nested.outer.train_labels
assert outer_test == nested.outer.test_labels
assert result.folds[0].selected_candidate_id is not None
selected = next(
candidate
for candidate in result.folds[0].candidates
if candidate.candidate_id == result.folds[0].selected_candidate_id
)
assert outer_definition == selected.definition()
changed = panel.with_columns(
pl.when(
pl.col("date")
.cast(pl.Utf8)
.str.slice(0, 10)
.is_in(nested.outer.test_labels)
)
.then(-pl.col("good") * 999.0)
.otherwise(pl.col("good"))
.alias("good")
)
without_evaluation = MiningService().run(
panel,
request,
factor_metrics=_metrics(),
)
changed_result = MiningService().run(
changed,
request,
factor_metrics=_metrics(),
)
assert without_evaluation.folds[0].selected_factors == (
changed_result.folds[0].selected_factors
)
assert without_evaluation.folds[0].candidates == changed_result.folds[0].candidates
with pytest.raises(ValueError, match="requires at least 3 outer folds"):
MiningService().run(
panel,
replace(request, profile="balanced"),
factor_metrics=_metrics(),
)
def test_mining_service_requires_fold_local_metrics_with_evaluator():
panel = _panel(days=10)
request = MiningRequest(
factor_names=("good",),
budget=MiningBudget(max_combination_size=1, max_proxy_trials=12, max_trials=3),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=2,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
with pytest.raises(ValueError, match="metric_provider is required"):
MiningService().run(panel, request, evaluator=_Evaluator())
with pytest.raises(ValueError, match="factor_metrics must not be supplied"):
MiningService().run(
panel,
request,
factor_metrics=(FactorMetric("good", 1.0, 1.0, 1.0, 0.1),),
metric_provider=lambda _train, _names: (
FactorMetric("good", 1.0, 1.0, 1.0, 0.1),
),
evaluator=_Evaluator(),
)
def test_target_endpoint_is_removed_from_every_train_phase_but_not_tests(monkeypatch):
panel = _panel(days=10)
labels = sorted(panel["date"].unique().to_list())
target_dates = pl.DataFrame({
"date": labels,
"_target_date": [*labels[1:], labels[-1] + timedelta(days=2)],
})
panel = panel.join(target_dates, on="date", how="left")
validation = NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=2,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
)
request = MiningRequest(
factor_names=("good",),
budget=MiningBudget(max_combination_size=1, max_proxy_trials=12, max_trials=3),
validation=validation,
profile="exploratory",
)
nested = generate_nested_folds([str(label) for label in labels], validation)[0]
train_ends = [*(inner.train_end for inner in nested.inner), nested.outer.train_end]
metric_frames = []
correlation_frames = []
beam_frames = []
def assert_target_bounded(frame, train_end):
assert frame.filter(
pl.col("_target_date").cast(pl.Utf8).str.slice(0, 10) > train_end
).is_empty()
def metric_provider(train, factor_names):
train_end = train_ends[len(metric_frames)]
assert tuple(factor_names) == request.factor_names
assert_target_bounded(train, train_end)
metric_frames.append(train)
return (FactorMetric("good", 1.0, 1.0, 1.0, 0.1),)
original_correlation = mining_module.compute_rank_correlation
original_beam = mining_module.beam_search_factor_combinations
def checked_correlation(frame, *args, **kwargs):
train_end = train_ends[len(correlation_frames)]
assert_target_bounded(frame, train_end)
correlation_frames.append(frame)
return original_correlation(frame, *args, **kwargs)
def checked_beam(frame, *args, **kwargs):
train_end = train_ends[len(beam_frames)]
assert_target_bounded(frame, train_end)
beam_frames.append(frame)
return original_beam(frame, *args, **kwargs)
class EndpointEvaluator(_Evaluator):
def __init__(self):
super().__init__()
self.test_frames = []
def evaluate_candidate(self, train, test, definition):
self.test_frames.append(test)
return super().evaluate_candidate(train, test, definition)
monkeypatch.setattr(mining_module, "compute_rank_correlation", checked_correlation)
monkeypatch.setattr(mining_module, "beam_search_factor_combinations", checked_beam)
evaluator = EndpointEvaluator()
result = MiningService().run(
panel,
request,
metric_provider=metric_provider,
evaluator=evaluator,
)
assert result.folds[0].error is None
assert len(metric_frames) == len(correlation_frames) == len(beam_frames) == 3
evaluation_folds = [*nested.inner, nested.outer]
assert len(evaluator.calls) == len(evaluation_folds)
for (train_labels, test_labels, _), test_frame, fold in zip(
evaluator.calls,
evaluator.test_frames,
evaluation_folds,
strict=True,
):
assert train_labels == fold.train_labels[:-1]
assert test_labels == fold.test_labels
assert test_frame.height == len(fold.test_labels) * 6
assert not test_frame.filter(
pl.col("_target_date").cast(pl.Utf8).str.slice(0, 10) > fold.test_end
).is_empty()
def test_outer_refit_fails_when_selected_factor_structure_is_missing():
panel = _panel(days=10)
request = MiningRequest(
factor_names=("good", "copy"),
correlation_threshold=0.8,
budget=MiningBudget(max_combination_size=1, max_proxy_trials=24, max_trials=8),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=2,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
def metric_provider(train, _factor_names):
if train["date"].n_unique() < 8:
return (
FactorMetric("good", 1.0, 1.0, 1.0, 0.1),
FactorMetric("copy", 0.5, 0.5, 1.0, 0.1),
)
return (
FactorMetric("good", 0.5, 0.5, 1.0, 0.1),
FactorMetric("copy", 1.0, 1.0, 1.0, 0.1),
)
evaluator = _Evaluator()
result = MiningService().run(
panel,
request,
metric_provider=metric_provider,
evaluator=evaluator,
)
fold = result.folds[0]
assert len(evaluator.calls) == 2
assert fold.selected_candidate_id is None
assert fold.outer_evaluation is None
assert fold.error == "outer retraining did not reproduce selected candidate structure"
assert {candidate.factor_names for candidate in fold.candidates} == {("copy",)}
def test_candidate_evaluation_dataclass_score_must_be_finite():
panel = _panel(days=10)
request = MiningRequest(
factor_names=("good",),
budget=MiningBudget(max_combination_size=1, max_proxy_trials=12, max_trials=8),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=2,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
class NonFiniteEvaluator:
def evaluate_candidate(self, train, test, definition):
return CandidateEvaluation(score=float("nan"), metrics={"source": "test"})
result = MiningService().run(
panel,
request,
metric_provider=lambda _train, _names: (
FactorMetric("good", 1.0, 1.0, 1.0, 0.1),
),
evaluator=NonFiniteEvaluator(),
)
assert result.folds[0].selected_candidate_id is None
assert result.folds[0].error == "no candidate completed inner validation within budget"
def test_beam_search_skips_factors_without_sufficient_valid_observations():
panel = _panel().with_columns(
pl.lit(None).cast(pl.Float64).alias("all_null"),
pl.lit(7.0).alias("constant"),
pl.col("inverse").alias("valid_inverse"),
pl.when(pl.col("symbol").is_in(["000000.SZ", "000001.SZ"]))
.then(pl.col("good"))
.otherwise(None)
.alias("sparse"),
)
result = beam_search_factor_combinations(
panel,
["all_null", "constant", "good", "sparse", "valid_inverse"],
max_combination_size=2,
max_trials=30,
)
invalid_only = beam_search_factor_combinations(
panel,
["all_null", "constant", "sparse"],
max_combination_size=2,
max_trials=30,
)
assert result.candidates
assert all(
set(candidate.factor_names) <= {"good", "valid_inverse"}
for candidate in result.candidates
)
assert {candidate.factor_names for candidate in result.candidates} >= {
("good",),
("valid_inverse",),
("good", "valid_inverse"),
}
assert all(candidate.dates > 0 and candidate.observations >= 3 for candidate in result.candidates)
assert invalid_only.candidates == ()
def test_small_real_allowance_evaluates_factor_and_finalists_stay_capped():
panel = _panel(days=10)
request = MiningRequest(
factor_names=("good", "noise"),
existing_strategy_ids=tuple(f"existing-{index}" for index in range(8)),
budget=MiningBudget(max_combination_size=1, max_proxy_trials=12, max_trials=1),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=2,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
evaluator = _Evaluator()
result = MiningService().run(
panel,
request,
metric_provider=lambda _train, _names: (
FactorMetric("good", 1.0, 1.0, 1.0, 0.1),
FactorMetric("noise", 0.5, 0.5, 1.0, 0.1),
),
evaluator=evaluator,
)
assert len(evaluator.calls) == 1
assert evaluator.calls[0][2]["kind"] == "factor_rank"
finalists = result.folds[0].candidates
assert all(candidate.kind == "factor_rank" for candidate in finalists)
assert len(finalists) == 2
assert result.folds[0].selected_candidate_id is not None
def test_existing_strategies_are_benchmarked_on_every_outer_fold():
panel = _panel(days=18)
request = MiningRequest(
factor_names=("good",),
existing_strategy_ids=("alpha", "beta"),
budget=MiningBudget(max_combination_size=1, max_proxy_trials=24, max_trials=64),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=4,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
evaluator = _Evaluator()
result = MiningService().run(
panel,
request,
metric_provider=lambda _train, _names: (FactorMetric("good", 1.0, 1.0, 1.0, 0.1),),
evaluator=evaluator,
)
assert len(result.folds) == 3
for fold in result.folds:
assert all(candidate.kind == "factor_rank" for candidate in fold.candidates)
signatures = [candidate_id for candidate_id, _ in fold.benchmark_evaluations]
assert signatures == ["strategy:alpha", "strategy:beta"]
for _, evaluation in fold.benchmark_evaluations:
assert evaluation.error is None
evaluated_strategy_kinds = [
definition["kind"]
for *_, definition in evaluator.calls
if definition["kind"] == "existing_strategy"
]
assert len(evaluated_strategy_kinds) == 6
def test_winner_definitions_are_cross_evaluated_on_all_outer_folds():
panel = _panel(days=20)
request = MiningRequest(
factor_names=("good", "noise"),
budget=MiningBudget(max_combination_size=1, max_proxy_trials=24, max_trials=64),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=10,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
evaluator = _AlternatingWinnerEvaluator()
result = MiningService().run(
panel,
request,
metric_provider=lambda _train, _names: (
FactorMetric("good", 1.0, 1.0, 1.0, 0.1),
FactorMetric("noise", 0.9, 0.9, 1.0, 0.1),
),
evaluator=evaluator,
)
assert len(result.folds) == 2
winners = {fold.selected_candidate_id for fold in result.folds}
assert len(winners) == 2
for fold in result.folds:
cross_ids = [candidate_id for candidate_id, _ in fold.cross_evaluations]
assert cross_ids == sorted(winners - {fold.selected_candidate_id})
for _, evaluation in fold.cross_evaluations:
assert evaluation.error is None
def test_benchmarks_run_even_when_factor_track_fails_on_a_fold():
panel = _panel(days=18)
request = MiningRequest(
factor_names=("good",),
existing_strategy_ids=("alpha",),
budget=MiningBudget(max_combination_size=1, max_proxy_trials=24, max_trials=64),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=4,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
class _FailingEvaluator(_Evaluator):
def evaluate_candidate(self, train, test, definition):
raise RuntimeError("backtest exploded")
result = MiningService().run(
panel,
request,
metric_provider=lambda _train, _names: (FactorMetric("good", 1.0, 1.0, 1.0, 0.1),),
evaluator=_FailingEvaluator(),
)
assert len(result.folds) == 1
fold = result.folds[0]
assert fold.error == "no candidate completed inner validation within budget"
assert fold.selected_candidate_id is None
assert [candidate_id for candidate_id, _ in fold.benchmark_evaluations] == [
"strategy:alpha"
]
benchmark = fold.benchmark_evaluations[0][1]
assert benchmark.error == "backtest exploded"
def test_searchable_factors_are_capped_by_beam_width():
budget = MiningBudget(beam_width=3)
assert _searchable_factors(("a", "b", "c", "d", "e"), budget) == ("a", "b", "c")
assert _searchable_factors(("a",), MiningBudget(beam_width=8)) == ("a",)
def test_beam_search_only_receives_capped_factor_inputs():
names = tuple(f"factor_{index:02d}" for index in range(20))
rows = []
start = date(2024, 1, 2)
for day_id in range(12):
for asset_id in range(6):
row = {
"symbol": f"{asset_id:06d}.SZ",
"date": start + timedelta(days=day_id),
"_next_return": float(asset_id),
}
for factor_id, name in enumerate(names):
row[name] = float((asset_id * (factor_id + 1)) % 7) + factor_id * 0.5
rows.append(row)
panel = pl.DataFrame(rows)
request = MiningRequest(
factor_names=names,
correlation_threshold=1.0,
budget=MiningBudget(
max_combination_size=2,
beam_width=4,
max_proxy_trials=96,
max_trials=8,
),
validation=NestedValidationConfig(
outer_train_bars=8,
outer_test_bars=2,
outer_step_bars=4,
inner_train_bars=4,
inner_test_bars=2,
inner_step_bars=2,
purge_bars=0,
embargo_bars=0,
min_train_bars=4,
),
profile="exploratory",
)
evaluator = _Evaluator()
result = MiningService().run(
panel,
request,
metric_provider=lambda _train, factor_names: tuple(
FactorMetric(name, 1.0 - index * 0.01, 1.0, 1.0, 0.1)
for index, name in enumerate(factor_names)
),
evaluator=evaluator,
)
searched = {
factor_name
for candidate in result.folds[0].candidates
for factor_name in candidate.factor_names
}
assert searched <= set(names[:4])
assert any(len(candidate.factor_names) == 2 for candidate in result.folds[0].candidates)
@@ -0,0 +1,314 @@
from __future__ import annotations
from datetime import date, timedelta
from types import SimpleNamespace
import polars as pl
import pytest
from app.backtest.mining import MiningCandidate
from app.backtest.mining_runtime import (
TrainingMetricProvider,
_decode_runtime_request,
_load_compact_factor_panel,
_prepare_base_market,
_rank_artifact_candidates,
_regime_date_count,
attach_single_forward_return,
)
from app.services import regime_builder
def test_runtime_rejects_insufficient_balanced_range_before_loading_panel(
tmp_path,
) -> None:
first = date(2023, 10, 13)
dates = [first + timedelta(days=offset) for offset in range(690)]
for value in dates:
partition = tmp_path / "kline_daily_enriched" / f"date={value.isoformat()}"
partition.mkdir(parents=True)
(partition / "part.parquet").touch()
payload = {
"run_id": "insufficient-balanced",
"request": {
"factor_names": ["turnover_rate"],
"strategy_ids": [],
"asset_type": "stock",
"budget_profile": "balanced",
"start": dates[0].isoformat(),
"end": dates[-1].isoformat(),
},
}
with pytest.raises(
ValueError,
match=(
r"balanced mining requires at least 786 enriched trading bars for "
r"3 outer folds; effective range .* has 690"
),
):
_decode_runtime_request(payload, tmp_path, SimpleNamespace())
def test_single_forward_label_uses_global_trading_axis_without_jump() -> None:
first = date(2024, 1, 2)
missing = first + timedelta(days=1)
resumed = first + timedelta(days=2)
panel = pl.DataFrame({
"symbol": ["000001.SZ", "000001.SZ"],
"date": [first, resumed],
"close": [10.0, 12.0],
"turnover_rate": [1.0, 2.0],
"unused_factor": [9.0, 10.0],
})
result = attach_single_forward_return(
panel,
start=first,
end=resumed,
horizon=1,
trading_dates=[first, missing, resumed],
factor_names=["turnover_rate"],
)
first_row = result.filter(pl.col("date") == first).row(0, named=True)
assert first_row["_target_date"] == missing
assert first_row["_next_return"] is None
assert "_forward_return_1d" not in result.columns
assert "close" not in result.columns
assert "unused_factor" not in result.columns
assert result.columns == [
"symbol",
"date",
"turnover_rate",
"_next_return",
"_target_date",
]
assert result.schema["_next_return"] == pl.Float32
assert result.schema["turnover_rate"] == pl.Float32
fast = attach_single_forward_return(
panel.sort(["date", "symbol"]),
start=first,
end=resumed,
horizon=1,
trading_dates=[first, missing, resumed],
factor_names=["turnover_rate"],
assume_unique_symbol_date=True,
)
assert fast.equals(result)
def test_compact_factor_panel_matches_full_symbol_independent_calculation(
monkeypatch,
) -> None:
first = date(2024, 1, 2)
rows = []
for symbol, offset in (("a", 0.0), ("b", 2.0), ("c", 4.0)):
for day in range(70):
close = 10.0 + offset + day * 0.1
rows.append({
"symbol": symbol,
"date": first + timedelta(days=day),
"open": close - 0.1,
"high": close + 0.2,
"low": close - 0.2,
"close": close,
"volume": 1000.0 + day,
"amount": close * (1000.0 + day),
"turnover_rate": 1.0 + day / 100.0,
})
raw = pl.DataFrame(rows).sort(["symbol", "date"])
class Engine:
def load_panel(self, *_args, **_kwargs):
return raw
engine = Engine()
from app.backtest.factor import FactorBacktestService
factor_service = FactorBacktestService(engine)
config = SimpleNamespace(
symbols=None,
start=first,
end=first + timedelta(days=69),
asset_type="stock",
)
names = ("momentum_20d", "rsi_14", "ma20_bias")
full = factor_service._compute_missing_factors(
raw,
set(names),
assume_sorted=True,
).select(["symbol", "date", "close", *names]).with_columns([
pl.col(name).cast(pl.Float32) for name in names
]).sort(["date", "symbol"])
monkeypatch.setattr("app.backtest.mining_runtime._SYMBOL_BATCH_SIZE", 1)
compact = _load_compact_factor_panel(
factor_service,
config,
names,
expected_generation="generation",
cancel_check=None,
)
assert compact.equals(full)
def test_compact_factor_panel_rejects_noncanonical_symbol_date_keys() -> None:
first = date(2024, 1, 2)
canonical = pl.DataFrame({
"symbol": ["a", "a", "b"],
"date": [first, first + timedelta(days=1), first],
"open": [1.0, 1.0, 1.0],
"high": [1.0, 1.0, 1.0],
"low": [1.0, 1.0, 1.0],
"close": [1.0, 1.0, 1.0],
"volume": [1.0, 1.0, 1.0],
"amount": [1.0, 1.0, 1.0],
"turnover_rate": [1.0, 1.0, 1.0],
})
config = SimpleNamespace(
symbols=None,
start=first,
end=first + timedelta(days=1),
asset_type="stock",
)
class Engine:
def __init__(self, panel):
self.panel = panel
def load_panel(self, *_args, **_kwargs):
return self.panel
from app.backtest.factor import FactorBacktestService
for invalid in (
canonical.with_columns(pl.Series(
"date",
[first + timedelta(days=1), first, first],
)),
pl.concat([canonical.slice(0, 1), canonical]),
):
with pytest.raises(ValueError, match="unique symbol/date"):
_load_compact_factor_panel(
FactorBacktestService(Engine(invalid)),
config,
("turnover_rate",),
expected_generation="generation",
cancel_check=None,
)
def test_artifact_finalists_are_truncated_by_oos_sharpe_before_signature() -> None:
low = MiningCandidate(candidate_id="a-low", kind="existing_strategy", strategy_id="low")
high = MiningCandidate(candidate_id="z-high", kind="existing_strategy", strategy_id="high")
rows = [
{"candidate_signature": "a-low", "sharpe": 0.2, "skipped": False},
{"candidate_signature": "z-high", "sharpe": 1.4, "skipped": False},
]
assert _rank_artifact_candidates([low, high], rows, limit=1) == [high]
def test_prepare_base_market_forwards_cancel_event(monkeypatch, tmp_path) -> None:
cancel_event = object()
captured = {}
plan = SimpleNamespace(
base_columns=frozenset(),
intermediate_columns=frozenset(),
indicator_columns=frozenset(),
signal_columns=frozenset(),
matrix_columns=frozenset(),
instrument_columns=frozenset(),
warmup_bars=1,
full_feature_fallback=False,
execution_backend="matrix_native",
fundamental_columns=frozenset(),
)
research = SimpleNamespace(entry_signals=[], exit_signals=[])
strategy_engine = SimpleNamespace(get=lambda _strategy_id: research)
service = SimpleNamespace(
_effective_basic_filter=lambda *_args: {},
engine=SimpleNamespace(),
)
request = SimpleNamespace(
factor_names=("turnover_rate",),
strategy_ids=(),
asset_type="stock",
forward_horizon=1,
start=date(2024, 1, 2),
end=date(2024, 1, 3),
symbols=None,
)
monkeypatch.setattr(
"app.backtest.mining_runtime.StrategyDependencyResolver.resolve",
lambda *_args, **_kwargs: plan,
)
monkeypatch.setattr(
"app.backtest.mining_runtime.build_matrix_cache_profile",
lambda *_args, **_kwargs: SimpleNamespace(),
)
def load_matrix(*_args, **kwargs):
captured.update(kwargs)
return "market"
service.engine.load_market_data_matrix_for_backtest = load_matrix
result = _prepare_base_market(
service,
strategy_engine,
tmp_path,
request,
expected_generation="generation",
cancel_check=cancel_event,
)
assert result == "market"
assert captured["cancel_event"] is cancel_event
def test_training_metric_provider_uses_only_supplied_fold() -> None:
start = date(2024, 1, 2)
rows = []
for day_offset in range(3):
for asset_id in range(4):
rows.append({
"symbol": f"{asset_id:06d}.SZ",
"date": start + timedelta(days=day_offset),
"factor": float(asset_id),
"_next_return": (
float(asset_id) if day_offset < 2 else float(-asset_id)
),
})
panel = pl.DataFrame(rows)
train = panel.filter(pl.col("date") < start + timedelta(days=2))
provider = TrainingMetricProvider("_next_return")
metric = provider(train, ["factor"])[0]
assert metric.rank_ic == pytest.approx(1.0)
assert metric.coverage == pytest.approx(1.0)
assert provider.calls[0]["end"] == (start + timedelta(days=1)).isoformat()
assert provider.calls[0]["rows"] == 8
def test_regime_date_count_uses_t_minus_one_market_labels(tmp_path) -> None:
labels = [date(2024, 1, 2) + timedelta(days=offset) for offset in range(4)]
panel = pl.DataFrame({"date": labels})
regime_builder.upsert_regime_history(tmp_path, pl.DataFrame({
"date": labels[:3],
"state": ["weak", "strong", "lean_strong"],
"score": [20, 80, 70],
}))
fold = SimpleNamespace(
test_start=labels[1].isoformat(),
test_end=labels[3].isoformat(),
)
assert _regime_date_count(panel, fold, "strong", tmp_path) == 2
assert _regime_date_count(panel, fold, "weak", tmp_path) == 1
@@ -38,6 +38,38 @@ def test_candidate_crud_and_atomic_file(tmp_path):
assert store.list() == []
def test_candidate_mining_provenance_is_idempotent_and_conflict_safe(tmp_path):
store = CandidateStore(tmp_path)
kwargs = {
"origin_run_id": "mining-run-idempotent",
"candidate_signature": "factor-signature",
"kind": "strategy",
"name": "挖掘组合候选",
"source_id": "mined_factor_example",
"config": {
"strategy_id": "mined_factor_example",
"origin_run_id": "mining-run-idempotent",
"candidate_signature": "factor-signature",
"factor_names": ["turnover_rate"],
"directions": ["high"],
"weights": [1.0],
},
"metrics": {"oos_sharpe": 0.9},
"data_as_of": "2026-08-11",
}
first = store.create_or_get_by_provenance(**kwargs)
second = store.create_or_get_by_provenance(**kwargs)
assert second == first
assert len(store.list()) == 1
with pytest.raises(CandidateValidationError, match="冲突"):
store.create_or_get_by_provenance(
**{**kwargs, "metrics": {"oos_sharpe": 1.1}}
)
assert store.list() == [first]
def test_candidate_rejects_full_result_fields(tmp_path):
store = CandidateStore(tmp_path)
@@ -52,6 +84,111 @@ def test_candidate_rejects_full_result_fields(tmp_path):
)
def test_candidate_stores_factor_mining_summary(tmp_path):
store = CandidateStore(tmp_path)
config = {
"factor_name": "momentum_20d",
"origin_run_id": "mining-run-001",
"candidate_signature": "factor-signature",
"regime_state": "bull",
"algorithm_version": "mining-v1",
"methodology_version": "factor-v2",
}
metrics = {
"oos_sharpe": 1.24,
"oos_return": 0.18,
"oos_max_drawdown": -0.09,
"oos_positive_fold_ratio": 0.75,
"oos_n_trades": 48,
"valid_folds": 4,
"skipped_folds": 1,
"confidence": 0.9,
"coverage": 0.82,
"turnover": 0.36,
"long_short_sharpe": 1.11,
}
created = store.create(
kind="factor",
name="挖掘因子候选",
source_id="momentum_20d",
config=config,
metrics=metrics,
data_as_of="2026-08-11",
)
assert created["config"] == config
assert created["metrics"] == metrics
assert store.list()[0]["metrics"] == metrics
def test_candidate_stores_strategy_mining_factor_combination(tmp_path):
store = CandidateStore(tmp_path)
config = {
"strategy_id": "mined-factor-combination",
"origin_run_id": "mining-run-002",
"candidate_signature": "strategy-signature",
"regime_state": "sideways",
"algorithm_version": "mining-v1",
"methodology_version": "strategy-v1",
"factor_names": ["momentum_20d", "rsi_14"],
"directions": ["high", "low"],
"weights": [0.6, 0.4],
}
metrics = {
"oos_sharpe": 0.98,
"oos_return": 0.12,
"oos_max_drawdown": -0.07,
"oos_positive_fold_ratio": 0.8,
"oos_n_trades": 31,
"valid_folds": 5,
"skipped_folds": 0,
"confidence": 0.86,
}
created = store.create(
kind="strategy",
name="挖掘组合候选",
source_id="mined-factor-combination",
config=config,
metrics=metrics,
data_as_of="2026-08-11",
)
assert created["config"] == config
assert created["metrics"] == metrics
assert store.list()[0]["config"] == config
@pytest.mark.parametrize("nested_value", [{"fold_1": 1.2}, [1.2, 0.8]])
def test_candidate_rejects_nested_mining_metrics(tmp_path, nested_value):
store = CandidateStore(tmp_path)
with pytest.raises(CandidateValidationError, match="只允许保存标量"):
store.create(
kind="factor",
name="挖掘因子候选",
source_id="momentum_20d",
config={"factor_name": "momentum_20d"},
metrics={"oos_sharpe": nested_value},
data_as_of=None,
)
def test_candidate_rejects_unknown_metric_field(tmp_path):
store = CandidateStore(tmp_path)
with pytest.raises(CandidateValidationError, match="不允许的字段"):
store.create(
kind="factor",
name="挖掘因子候选",
source_id="momentum_20d",
config={"factor_name": "momentum_20d"},
metrics={"fold_metrics": 1.0},
data_as_of=None,
)
def test_candidate_rejects_non_json_config(tmp_path):
store = CandidateStore(tmp_path)
@@ -69,13 +206,20 @@ def test_candidate_rejects_non_json_config(tmp_path):
def test_candidate_loads_legacy_missing_optional_fields(tmp_path):
path = tmp_path / "user_data" / "research_candidates.json"
path.parent.mkdir(parents=True)
path.write_text(json.dumps([{
"id": "legacy",
"kind": "factor",
"name": "旧候选",
"config": {"factor_name": "rsi_14", "equity_curve": [1, 2]},
"metrics": {"ic_mean": 0.03, "trades": [{"symbol": "000001.SZ"}]},
}]), encoding="utf-8")
path.write_text(
json.dumps(
[
{
"id": "legacy",
"kind": "factor",
"name": "旧候选",
"config": {"factor_name": "rsi_14", "equity_curve": [1, 2]},
"metrics": {"ic_mean": 0.03, "trades": [{"symbol": "000001.SZ"}]},
}
]
),
encoding="utf-8",
)
item = CandidateStore(tmp_path).list()[0]
assert item["source_id"] == "rsi_14"
@@ -181,12 +181,17 @@ def test_non_matrix_strategy_applies_regime_filter_and_reports_config(tmp_path):
"signal_limit_up": False,
"signal_limit_down": False,
}
for offset in range(3)
for offset in range(-1, 3)
]).sort(["symbol", "date"])
regime_builder.upsert_regime_history(tmp_path, pl.DataFrame({
"date": [start, start + timedelta(days=1)],
"state": ["weak", "strong"],
"score": [10, 85],
"date": [
start - timedelta(days=1),
start,
start + timedelta(days=1),
start + timedelta(days=2),
],
"state": ["weak", "weak", "strong", "strong"],
"score": [10, 10, 85, 85],
}))
engine = _EngineStub(panel, data_dir=tmp_path)
service = StrategyBacktestService(engine=engine, strategy_engine=_StrategyEngineStub(_strategy()))
@@ -204,16 +209,70 @@ def test_non_matrix_strategy_applies_regime_filter_and_reports_config(tmp_path):
assert result.error is None
assert engine.sim_matrix is not None
assert engine.sim_matrix.entry[:, 0].tolist() == [1, 0, 1]
assert engine.sim_matrix.entry[:, 0].tolist() == [0, 0, 1]
assert result.config["regime_filter"] == regime_filter
assert result.stats["selection"] == {
"strategy_matches": 2,
"entry_candidates": 2,
"strategy_matches": 1,
"entry_candidates": 1,
"entry_trigger_filtered": 0,
"entry_trigger_enabled": False,
}
def test_regime_filter_matches_raw_five_level_states(tmp_path):
start = date(2024, 1, 1)
panel = pl.DataFrame([
{
"symbol": "A",
"name": "A",
"date": start + timedelta(days=offset),
"open": 10.0,
"high": 10.0,
"low": 10.0,
"close": 10.0,
"volume": 1000.0,
"amount": 1000.0,
"signal_limit_up": False,
"signal_limit_down": False,
}
for offset in range(-1, 3)
]).sort(["symbol", "date"])
regime_builder.upsert_regime_history(tmp_path, pl.DataFrame({
"date": [
start - timedelta(days=1),
start,
start + timedelta(days=1),
start + timedelta(days=2),
],
"state": ["weak", "lean_strong", "strong", "strong"],
"score": [10, 60, 85, 85],
}))
def run_with(states: list[str]):
engine = _EngineStub(panel, data_dir=tmp_path)
service = StrategyBacktestService(
engine=engine,
strategy_engine=_StrategyEngineStub(_strategy()),
)
result = service.run(StrategyBacktestConfig(
strategy_id="test",
symbols=None,
start=start,
end=start + timedelta(days=2),
matching="close_t",
mode="position",
regime_filter={"states": states},
))
assert result.error is None
assert engine.sim_matrix is not None
return engine.sim_matrix.entry[:, 0].tolist()
# 强势与偏强是两个独立档位; 只选强势时偏强日不入场
assert run_with(["strong"]) == [0, 0, 1]
assert run_with(["strong", "lean_strong"]) == [0, 1, 1]
assert run_with(["lean_strong"]) == [0, 1, 0]
def test_selection_stats_explain_entry_trigger_filtering():
start = date(2024, 1, 1)
panel = pl.DataFrame([
+262 -1
View File
@@ -1,13 +1,21 @@
from __future__ import annotations
import queue
import threading
from datetime import date, timedelta
from types import SimpleNamespace
import polars as pl
import pytest
from app.backtest import worker as worker_module
from app.backtest.mining import benchmark_candidate
from app.backtest.optimizer import OptimizeConfig
from app.backtest.strategy import StrategyBacktestConfig
from app.backtest.walkforward import WalkForwardConfig
from app.backtest.worker import make_worker_task, run_worker_task
from app.backtest.worker import BacktestWorkerError, make_worker_task, run_worker_task
from app.enriched_generation import bump_enriched_generation, get_enriched_generation
from app.services.mining_jobs import MiningRunStore
def _write_worker_strategy(data_dir) -> None:
@@ -88,6 +96,49 @@ def _write_market_data(data_dir, start: date, days: int = 3) -> None:
}).write_parquet(instruments_dir / "part.parquet")
def _write_mining_market_data(
data_dir,
start: date,
*,
days: int = 219,
assets: int = 4,
) -> None:
symbols = [f"60000{asset}.SH" for asset in range(assets)]
for offset in range(days):
current = start + timedelta(days=offset)
rows = []
for asset_id, symbol in enumerate(symbols):
close = 10.0 + asset_id + offset * (0.01 + asset_id * 0.002)
rows.append({
"symbol": symbol,
"date": current,
"open": close,
"high": close * 1.01,
"low": close * 0.99,
"close": close,
"volume": 1000.0 + asset_id * 100.0,
"amount": close * (100000.0 + asset_id * 1000.0),
"raw_close": close,
"raw_high": close * 1.01,
"raw_low": close * 0.99,
"turnover_rate": 1.0 + asset_id * 0.5 + offset * 0.001,
"consecutive_limit_ups": 0,
"consecutive_limit_downs": 0,
})
partition = data_dir / "kline_daily_enriched" / f"date={current.isoformat()}"
partition.mkdir(parents=True)
pl.DataFrame(rows).write_parquet(partition / "part.parquet")
instruments_dir = data_dir / "instruments"
instruments_dir.mkdir(parents=True)
pl.DataFrame({
"symbol": symbols,
"name": [f"测试{asset}" for asset in range(assets)],
"total_shares": [1_000_000_000.0] * assets,
"float_shares": [1_000_000_000.0] * assets,
}).write_parquet(instruments_dir / "part.parquet")
def test_spawn_worker_returns_compact_result_and_memory_metrics(tmp_path):
start = date(2024, 1, 1)
data_dir = tmp_path / "data"
@@ -187,6 +238,216 @@ def test_spawn_walkforward_reuses_shared_matrix_across_folds(tmp_path):
assert result["worker"]["worker_exitcode"] == 0
def test_spawn_mining_writes_four_artifacts_and_returns_compact_summary(tmp_path):
start = date(2023, 1, 2)
data_dir = tmp_path / "data"
_write_mining_market_data(data_dir, start)
store = MiningRunStore(data_dir)
manifest = store.create(
{
"factor_names": ["turnover_rate"],
"strategy_ids": [],
"symbols": None,
"asset_type": "stock",
"start": (start - timedelta(days=7)).isoformat(),
"end": (start + timedelta(days=225)).isoformat(),
"budget_profile": "exploratory",
"forward_horizon": 1,
"commission_pct": 0.0,
"stamp_tax_pct": 0.0,
"slippage_bps": 0.0,
"correlation_threshold": 0.75,
"max_combination_factors": 1,
"beam_width": 2,
"max_finalists": 2,
"require_regime": False,
},
{"generation": get_enriched_generation(data_dir, "stock")},
run_id="spawn_mining",
)
payload = {
"run_id": manifest["run_id"],
"request": manifest["request"],
"data_fingerprint": manifest["data_fingerprint"],
"source": "manual",
}
result = run_worker_task(make_worker_task("mining", data_dir, payload))
assert result["status"] in {"succeeded", "succeeded_with_budget_exhausted"}
assert result["factor_count"] == 1
assert result["data_as_of"] == (start + timedelta(days=218)).isoformat()
assert result["panel_scans"] == 1
assert result["matrix_bytes"] > 0
assert result["worker"]["worker_exitcode"] == 0
assert result["worker"]["serialized_result_bytes"] < 100_000
registered = store.get("spawn_mining")["artifacts"] # type: ignore[index]
assert set(registered) == {"factors", "correlation", "candidates", "folds"}
for name in registered:
artifact = store.artifact_path("spawn_mining", name)
assert artifact.is_file()
frame = pl.read_parquet(artifact)
assert frame.columns
if name == "folds":
assert "n_dates" in frame.columns
assert frame.filter(pl.col("regime_state") == "overall")["n_dates"].min() > 0
def test_spawn_mining_benchmarks_strategy_on_every_outer_fold(tmp_path):
start = date(2023, 1, 2)
data_dir = tmp_path / "data"
_write_mining_market_data(data_dir, start)
store = MiningRunStore(data_dir)
manifest = store.create(
{
"factor_names": ["turnover_rate"],
"strategy_ids": ["low_volatility_leader"],
"symbols": None,
"asset_type": "stock",
"start": (start - timedelta(days=7)).isoformat(),
"end": (start + timedelta(days=225)).isoformat(),
"budget_profile": "exploratory",
"forward_horizon": 1,
"commission_pct": 0.0,
"stamp_tax_pct": 0.0,
"slippage_bps": 0.0,
"correlation_threshold": 0.75,
"max_combination_factors": 1,
"beam_width": 2,
"max_finalists": 2,
"require_regime": False,
},
{"generation": get_enriched_generation(data_dir, "stock")},
run_id="spawn_mining_benchmark",
)
payload = {
"run_id": manifest["run_id"],
"request": manifest["request"],
"data_fingerprint": manifest["data_fingerprint"],
"source": "manual",
}
result = run_worker_task(make_worker_task("mining", data_dir, payload))
assert result["status"] in {"succeeded", "succeeded_with_budget_exhausted"}
folds = pl.read_parquet(store.artifact_path("spawn_mining_benchmark", "folds"))
benchmark_signature = benchmark_candidate("low_volatility_leader").candidate_id
benchmark_rows = folds.filter(
(pl.col("evaluation_kind") == "benchmark")
& (pl.col("candidate_signature") == benchmark_signature)
& (pl.col("regime_state") == "overall")
)
outer_folds = result["valid_fold_count"] + result["skipped_fold_count"]
assert outer_folds >= 1
assert benchmark_rows.height == outer_folds
selected_rows = folds.filter(
(pl.col("evaluation_kind") == "selected")
& (pl.col("regime_state") == "overall")
)
assert selected_rows.height == outer_folds
candidates = pl.read_parquet(store.artifact_path("spawn_mining_benchmark", "candidates"))
assert benchmark_signature in candidates["signature"].to_list()
assert "existing_strategy" in candidates["kind"].to_list()
def test_spawn_mining_rejects_generation_change_after_queue(tmp_path):
start = date(2023, 1, 2)
data_dir = tmp_path / "data"
_write_mining_market_data(data_dir, start)
store = MiningRunStore(data_dir)
queued_generation = get_enriched_generation(data_dir, "stock")
manifest = store.create(
{
"factor_names": ["turnover_rate"],
"strategy_ids": [],
"symbols": None,
"asset_type": "stock",
"start": (start - timedelta(days=7)).isoformat(),
"end": (start + timedelta(days=225)).isoformat(),
"budget_profile": "exploratory",
"forward_horizon": 1,
"commission_pct": 0.0,
"stamp_tax_pct": 0.0,
"slippage_bps": 0.0,
"correlation_threshold": 0.75,
"max_combination_factors": 1,
"beam_width": 2,
"max_finalists": 2,
"require_regime": False,
},
{"generation": queued_generation},
run_id="stale_generation_mining",
)
bump_enriched_generation(data_dir, "stock")
payload = {
"run_id": manifest["run_id"],
"request": manifest["request"],
"data_fingerprint": manifest["data_fingerprint"],
"source": "manual",
}
with pytest.raises(
BacktestWorkerError,
match="changed after the run was queued",
):
run_worker_task(make_worker_task("mining", data_dir, payload))
assert store.get("stale_generation_mining")["artifacts"] == {} # type: ignore[index]
def test_worker_terminates_child_after_cancel_grace(monkeypatch, tmp_path):
class FakeQueue:
def get(self, timeout):
raise queue.Empty
def close(self):
pass
def join_thread(self):
pass
class FakeEvent:
def set(self):
pass
class FakeProcess:
def __init__(self):
self.alive = True
self.exitcode = None
def start(self):
pass
def is_alive(self):
return self.alive
def join(self, timeout=None):
pass
def terminate(self):
self.alive = False
self.exitcode = -15
process = FakeProcess()
context = SimpleNamespace(
Queue=FakeQueue,
Event=FakeEvent,
Process=lambda **_kwargs: process,
)
clock = iter([0.0, 0.0, 0.0, 6.0])
monkeypatch.setattr(worker_module.mp, "get_context", lambda _method: context)
monkeypatch.setattr(worker_module.time, "monotonic", lambda: next(clock))
cancel_event = threading.Event()
cancel_event.set()
with pytest.raises(BacktestWorkerError, match="after cancellation"):
run_worker_task(
{"kind": "mining", "data_dir": str(tmp_path), "config": {}},
cancel_event=cancel_event,
)
assert process.exitcode == -15
def test_spawn_walkforward_skips_folds_before_available_matrix_data(tmp_path):
configured_start = date(2024, 1, 1)
market_start = configured_start + timedelta(days=4)
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
from types import SimpleNamespace
import polars as pl
import pytest
from app.api import data as data_api
from app.backtest.engine import PanelCache
from app.enriched_generation import (
EnrichedGenerationUnavailableError,
get_enriched_generation,
)
class _RepoStub:
def __init__(self, data_dir: Path) -> None:
self.store = SimpleNamespace(data_dir=data_dir)
self.calls: list[str] = []
def clear_cache(self) -> None:
self.calls.append("clear_cache")
def refresh_cache(self) -> None:
self.calls.append("refresh_cache")
def rebuild_views(self) -> None:
self.calls.append("rebuild_views")
def _request(repo: _RepoStub) -> SimpleNamespace:
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo)))
def _write_parquet_placeholder(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"parquet-placeholder")
def _stub_clear_side_effects(monkeypatch: pytest.MonkeyPatch) -> None:
from app.api import overview
from app.services import alert_store
from app.services.pipeline_jobs import job_store
from app.services.screener import ScreenerService
monkeypatch.setattr(job_store, "clear", lambda: None)
monkeypatch.setattr(alert_store, "clear", lambda _data_dir: None)
monkeypatch.setattr(ScreenerService, "clear_history_cache", lambda: None)
monkeypatch.setattr(overview, "invalidate_overview_cache", lambda: None)
monkeypatch.setattr(data_api, "invalidate_data_cache", lambda _table=None: None)
def test_clear_data_bumps_enriched_generations_and_invalidates_panel_cache(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_clear_side_effects(monkeypatch)
repo = _RepoStub(tmp_path)
stock_file = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet"
etf_file = tmp_path / "kline_etf_enriched" / "date=2026-08-14" / "part.parquet"
_write_parquet_placeholder(stock_file)
_write_parquet_placeholder(etf_file)
stock_before = get_enriched_generation(tmp_path, "stock")
etf_before = get_enriched_generation(tmp_path, "etf")
cache = PanelCache()
cache_args = (["000001.SZ"], date(2026, 8, 14), date(2026, 8, 14), None)
computes: list[int] = []
def compute(*_args) -> pl.DataFrame:
computes.append(len(computes) + 1)
return pl.DataFrame({"value": [computes[-1]]})
cache.get_or_compute(*cache_args, compute, "stock", stock_before)
result = data_api.clear_data(_request(repo))
stock_after = get_enriched_generation(tmp_path, "stock")
etf_after = get_enriched_generation(tmp_path, "etf")
cached_after = cache.get_or_compute(*cache_args, compute, "stock", stock_after)
assert result == {"deleted_files": 2}
assert not stock_file.exists()
assert not etf_file.exists()
assert stock_after != stock_before
assert etf_after != etf_before
assert cached_after["value"].item() == 2
assert cache.stats()["compute_count"] == 2
assert repo.calls == ["clear_cache", "refresh_cache", "rebuild_views"]
def test_clear_data_restores_ready_generation_when_first_delete_fails(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repo = _RepoStub(tmp_path)
target = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet"
_write_parquet_placeholder(target)
generation_before = get_enriched_generation(tmp_path, "stock")
original_unlink = Path.unlink
def fail_target(path: Path, *args, **kwargs) -> None:
if path == target:
raise PermissionError("injected delete failure")
original_unlink(path, *args, **kwargs)
monkeypatch.setattr(Path, "unlink", fail_target)
with pytest.raises(PermissionError, match="injected delete failure"):
data_api.clear_data(_request(repo))
assert target.is_file()
assert get_enriched_generation(tmp_path, "stock") == generation_before
marker = json.loads(
(tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8")
)
assert marker["state"] == "ready"
def test_clear_data_keeps_generation_publishing_after_partial_delete(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repo = _RepoStub(tmp_path)
enriched_dir = tmp_path / "kline_daily_enriched"
first = enriched_dir / "date=2026-08-13" / "part.parquet"
second = enriched_dir / "date=2026-08-14" / "part.parquet"
_write_parquet_placeholder(first)
_write_parquet_placeholder(second)
get_enriched_generation(tmp_path, "stock")
original_unlink = Path.unlink
parquet_unlinks = 0
def fail_second_parquet(path: Path, *args, **kwargs) -> None:
nonlocal parquet_unlinks
if path.suffix == ".parquet" and enriched_dir in path.parents:
parquet_unlinks += 1
if parquet_unlinks == 2:
raise PermissionError("injected partial delete failure")
original_unlink(path, *args, **kwargs)
monkeypatch.setattr(Path, "unlink", fail_second_parquet)
with pytest.raises(PermissionError, match="injected partial delete failure"):
data_api.clear_data(_request(repo))
assert sum(path.exists() for path in (first, second)) == 1
marker = json.loads(
(tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8")
)
assert marker["state"] == "publishing"
with pytest.raises(EnrichedGenerationUnavailableError, match="being published"):
get_enriched_generation(tmp_path, "stock")
+61
View File
@@ -0,0 +1,61 @@
"""storage 统计求和不变量测试 — total_size_mb 必须等于各部分之和, 不得重复累加。
历史 bug: other_dirs 循环被复制了两遍, financials 既在 other_dirs 里又有专属
统计块 financials 被计入 3 pools/backtest_results/screener_results/ai_cache
各计入 2 , total_size_mb 虚高此测试用精确整 MB 文件锁定总和不变量
"""
from __future__ import annotations
from pathlib import Path
from app.api.data import _compute_storage
MB = 1024 * 1024
def _write_mb(path: Path, mb: float) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"\0" * int(mb * MB))
def _build_tree(data_dir: Path) -> None:
_write_mb(data_dir / "kline_daily" / "part.parquet", 1.0)
_write_mb(data_dir / "financials" / "metrics" / "part.parquet", 2.0)
_write_mb(data_dir / "pools" / "p.json", 1.0)
_write_mb(data_dir / "backtest_results" / "r.json", 1.0)
_write_mb(data_dir / "screener_results" / "s.json", 1.0)
_write_mb(data_dir / "ai_cache" / "c.json", 1.0)
_write_mb(data_dir / "capabilities.json", 0.5) # 根目录散文件
def test_total_equals_sum_of_parts(tmp_path):
_build_tree(tmp_path)
stats = _compute_storage(tmp_path)
parts_mb = (
stats["daily_size_mb"] # 1.0 (subdirs 表)
+ stats["financials_size_mb"] # 2.0 (专属统计块)
+ 1.0 + 1.0 + 1.0 + 1.0 # pools/backtest/screener/ai_cache (单次循环)
+ 0.5 # 根目录散文件
)
assert stats["total_size_mb"] == parts_mb == 7.5
def test_financials_counted_once_not_three_times(tmp_path):
_build_tree(tmp_path)
stats = _compute_storage(tmp_path)
assert stats["financials_files"] == 1
assert stats["financials_size_mb"] == 2.0
# 旧 bug: financials 计 3 次 (6MB) + 其余 4 目录各计 2 次 (8MB) → 15.5
assert stats["total_size_mb"] != 15.5
def test_missing_dirs_contribute_zero(tmp_path):
_write_mb(tmp_path / "kline_daily" / "part.parquet", 1.0)
stats = _compute_storage(tmp_path)
# financials 目录不存在时不含其明细键 (既有行为), 且不贡献任何体积
assert stats.get("financials_files", 0) == 0
assert stats.get("financials_size_mb", 0.0) == 0.0
assert stats["total_size_mb"] == 1.0
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import json
from datetime import date
from types import SimpleNamespace
import polars as pl
import pytest
from app.backtest.engine import BacktestEngine, PanelCache
from app.enriched_generation import (
EnrichedGenerationUnavailableError,
EnrichedPublication,
get_enriched_generation,
)
from app.tickflow.repository import DataStore, KlineRepository
def _frame(value: float = 10.0) -> pl.DataFrame:
return pl.DataFrame({
"symbol": ["000001.SZ"],
"date": [date(2026, 8, 14)],
"open": [value],
"high": [value],
"low": [value],
"close": [value],
"volume": [1_000.0],
})
def test_repository_enriched_noop_does_not_bump_generation(tmp_path) -> None:
repo = KlineRepository(DataStore(tmp_path))
frame = _frame()
repo.append_enriched(frame)
first = repo.get_matrix_data_generation("stock")
repo.append_enriched(frame)
assert repo.get_matrix_data_generation("stock") == first
def test_failed_multi_partition_publication_remains_fail_closed(
tmp_path,
monkeypatch,
) -> None:
publication = EnrichedPublication(tmp_path, recover=True)
first = tmp_path / "kline_daily_enriched" / "date=2026-08-13" / "part.parquet"
second = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet"
publication.write_parquet(_frame(10.0), first)
original_write = pl.DataFrame.write_parquet
def fail_second(self, path, *args, **kwargs):
if "2026-08-14" in str(path):
raise OSError("injected write failure")
return original_write(self, path, *args, **kwargs)
monkeypatch.setattr(pl.DataFrame, "write_parquet", fail_second)
with pytest.raises(OSError, match="injected"):
publication.write_parquet(_frame(11.0), second)
marker = json.loads(
(tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8")
)
assert marker["state"] == "publishing"
assert first.is_file()
assert not second.is_file()
with pytest.raises(EnrichedGenerationUnavailableError, match="being published"):
get_enriched_generation(tmp_path, "stock")
def test_recovery_replaces_stale_publication_but_not_active_owner(tmp_path) -> None:
first = EnrichedPublication(tmp_path, recover=True)
out = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet"
first.write_parquet(_frame(10.0), out)
with pytest.raises(EnrichedGenerationUnavailableError, match="active"):
EnrichedPublication(tmp_path, recover=True).write_parquet(_frame(11.0), out)
del first
recovered = EnrichedPublication(tmp_path, recover=True)
recovered.write_parquet(_frame(12.0), out)
recovered_generation = recovered.commit()
assert recovered_generation == get_enriched_generation(tmp_path, "stock")
assert pl.read_parquet(out)["close"].item() == pytest.approx(12.0)
def test_panel_cache_generation_change_forces_recompute() -> None:
cache = PanelCache()
calls: list[int] = []
args = (["000001.SZ"], date(2026, 8, 13), date(2026, 8, 14), None)
def compute(*_args):
calls.append(len(calls) + 1)
return pl.DataFrame({"value": [calls[-1]]})
first = cache.get_or_compute(*args, compute, "stock", "generation-a")
second = cache.get_or_compute(*args, compute, "stock", "generation-b")
assert first["value"].item() == 1
assert second["value"].item() == 2
assert cache.stats()["compute_count"] == 2
def test_panel_reader_retries_when_generation_changes_during_scan(tmp_path) -> None:
generations = iter(["generation-a", "generation-b", "generation-b", "generation-b"])
repo = SimpleNamespace(
store=SimpleNamespace(data_dir=tmp_path),
get_matrix_data_generation=lambda _asset_type: next(generations),
)
engine = BacktestEngine(repo)
calls: list[str] = []
def load(*_args):
calls.append("scan")
return _frame(float(len(calls)))
engine._load_panel_inner = load
panel = engine.load_panel(
None,
date(2026, 8, 14),
date(2026, 8, 14),
columns=["symbol", "date", "close"],
)
assert calls == ["scan", "scan"]
assert panel["close"].item() == pytest.approx(2.0)
def test_matrix_reader_retries_when_generation_changes_during_build(
tmp_path,
monkeypatch,
) -> None:
generations = iter(["generation-a", "generation-b", "generation-b", "generation-b"])
repo = SimpleNamespace(
store=SimpleNamespace(data_dir=tmp_path),
get_matrix_data_generation=lambda _asset_type: next(generations),
get_instruments_asset=lambda _asset_type: pl.DataFrame(),
)
engine = BacktestEngine(repo)
calls: list[str | None] = []
expected = SimpleNamespace(
execution_backend="matrix_native",
base_columns={"open", "high", "low", "close", "volume"},
instrument_columns=set(),
matrix_columns=set(),
)
market = SimpleNamespace()
def load_matrix(*_args, source_generation=None, **_kwargs):
calls.append(source_generation)
return market
monkeypatch.setattr("app.backtest.engine.load_market_data_matrix_from_parquet", load_matrix)
result = engine.load_market_data_matrix_for_backtest(
None,
date(2026, 8, 14),
date(2026, 8, 14),
expected,
)
assert result is market
assert calls == ["generation-a", "generation-b"]
@@ -0,0 +1,56 @@
"""get_enriched_range 的预热守卫测试 — 预热期间不得触发同步全量重算。
启动后台预热线程正在 _refresh_enriched (300 scan + compute, 低配机 50s+)
, 请求线程进入 get_enriched_range 应返回 None (缓存不覆盖语义),
不能在请求线程里并发跑第二次全量重算 get_enriched_latest 守卫对齐
"""
from __future__ import annotations
from datetime import date
import polars as pl
from app.tickflow.repository import KlineRepository
def _bare_repo() -> KlineRepository:
"""跳过 __init__ (避免 DataStore/目录依赖), 只装配守卫涉及的属性。"""
repo = KlineRepository.__new__(KlineRepository)
repo._enriched_history_cache = None
repo._enriched_warming = True
return repo
def test_get_enriched_range_returns_none_while_warming():
repo = _bare_repo()
refresh_calls: list[int] = []
def _spy_refresh():
refresh_calls.append(1)
repo._refresh_enriched = _spy_refresh # type: ignore[method-assign]
result = repo.get_enriched_range(date(2026, 1, 1), date(2026, 8, 14))
assert result is None
assert refresh_calls == [], "预热期间不得触发 _refresh_enriched"
def test_get_enriched_range_rebuilds_when_cold_and_not_warming():
repo = _bare_repo()
repo._enriched_warming = False
built = pl.DataFrame({
"symbol": ["600000.SH", "600000.SH"],
"date": [date(2026, 1, 1), date(2026, 8, 14)],
})
def _fake_refresh():
repo._enriched_history_cache = built
repo._refresh_enriched = _fake_refresh # type: ignore[method-assign]
result = repo.get_enriched_range(date(2026, 1, 1), date(2026, 8, 14))
assert result is not None
assert result.height == 2
assert result["symbol"].unique().to_list() == ["600000.SH"]
@@ -0,0 +1,99 @@
"""ExtConfigStore.load_all 签名缓存测试 — 配置未变不重复读盘, 变更后立即可见, 返回副本。"""
from __future__ import annotations
import json
import pytest
from app.services import ext_data
from app.services.ext_data import ExtConfig, ExtConfigStore, ExtField
@pytest.fixture(autouse=True)
def _clean_cache():
ext_data._load_all_cache.clear()
yield
ext_data._load_all_cache.clear()
def _patched_loads(monkeypatch, counter: dict):
real_loads = json.loads
def _counting(text):
counter["loads"] += 1
return real_loads(text)
monkeypatch.setattr(ext_data.json, "loads", _counting)
def _config(cid: str, label: str) -> ExtConfig:
return ExtConfig(id=cid, label=label, mode="snapshot",
fields=[ExtField(name="score", dtype="float")])
def test_second_load_all_hits_cache_without_disk_parse(tmp_path, monkeypatch):
store = ExtConfigStore(tmp_path)
store.upsert(_config("cfg_a", "A"))
store.upsert(_config("cfg_b", "B"))
counter = {"loads": 0}
_patched_loads(monkeypatch, counter)
first = store.load_all()
assert counter["loads"] == 2, "缓存为空时逐 config.json parse"
again = store.load_all()
assert counter["loads"] == 2, "签名未变时不得重复读盘+parse"
assert [c.id for c in first] == [c.id for c in again] == ["cfg_a", "cfg_b"]
def test_upsert_edit_invalidates_cache(tmp_path):
store = ExtConfigStore(tmp_path)
store.upsert(_config("cfg_a", "old"))
assert store.load_all()[0].label == "old"
store.upsert(_config("cfg_a", "new"))
assert store.load_all()[0].label == "new"
def test_delete_invalidates_cache(tmp_path):
store = ExtConfigStore(tmp_path)
store.upsert(_config("cfg_a", "A"))
store.upsert(_config("cfg_b", "B"))
assert {c.id for c in store.load_all()} == {"cfg_a", "cfg_b"}
assert store.delete("cfg_a") is True
assert {c.id for c in store.load_all()} == {"cfg_b"}
def test_external_config_change_visible(tmp_path):
import os
store = ExtConfigStore(tmp_path)
store.upsert(_config("cfg_a", "A"))
assert store.load_all()[0].label == "A"
cp = tmp_path / "ext_data" / "cfg_a" / "config.json"
raw = json.loads(cp.read_text(encoding="utf-8"))
raw["label"] = "externally-edited"
cp.write_text(json.dumps(raw), encoding="utf-8")
st = cp.stat()
os.utime(cp, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000))
assert store.load_all()[0].label == "externally-edited"
def test_load_all_returns_copy_not_cached_object(tmp_path):
store = ExtConfigStore(tmp_path)
store.upsert(_config("cfg_a", "A"))
first = store.load_all()
first[0].label = "mutated"
first[0].fields.append(ExtField(name="junk", dtype="string"))
again = store.load_all()
assert again[0].label == "A"
assert [f.name for f in again[0].fields] == ["score"]
def test_load_all_without_config_dir_returns_empty(tmp_path):
store = ExtConfigStore(tmp_path)
assert store.load_all() == []
assert store.load_all() == []
@@ -0,0 +1,43 @@
"""ExtConfigStore 的 config_id 安全校验 — 拒绝路径穿越等非法 id (fail-closed)。
删除端点的 config_id 来自 URL path 参数, 不经创建端点的 pattern 校验;
若直接拼接路径, `../victim` 可让 rmtree 删除 ext_data 之外的目录
"""
from __future__ import annotations
import json
from app.services.ext_data import ExtConfigStore
def _write_config(data_dir, config_id: str) -> None:
d = data_dir / "ext_data" / config_id
d.mkdir(parents=True, exist_ok=True)
(d / "config.json").write_text(
json.dumps({
"id": config_id,
"label": "测试",
"mode": "snapshot",
"fields": [{"name": "score"}],
}),
encoding="utf-8",
)
def test_delete_rejects_path_traversal_ids(tmp_path):
store = ExtConfigStore(tmp_path)
_write_config(tmp_path, "ok_config")
# 穿越目标: ext_data 之外、含 config.json 的目录 (满足旧实现 rmtree 的前置条件)
victim = tmp_path / "victim"
victim.mkdir()
(victim / "config.json").write_text("{}", encoding="utf-8")
for bad in ("../victim", "..\\victim", "a/b", "", "."):
assert store.delete(bad) is False, bad
assert store.get(bad) is None, bad
assert (victim / "config.json").exists(), "穿越删除必须被拒绝"
# 合法 id 不受影响
assert store.get("ok_config") is not None
assert store.delete("ok_config") is True
+173
View File
@@ -0,0 +1,173 @@
"""新增因子维度的解析正确性测试 (收益形态/流动性/涨停基因/量价扩展)。
金标准路径一致性由 test_matrix_strategy.py::test_research_factor_catalog_matches_matrix_features
覆盖; 这里用可手算的小样本验证公式本身
"""
from __future__ import annotations
import math
from datetime import date, timedelta
import numpy as np
import polars as pl
from app.backtest.matrix import build_market_data_matrix, matrix_feature
from app.strategy.scoring import materialize_scoring_columns
def _panel(rows: list[dict]) -> pl.DataFrame:
return pl.DataFrame(rows).sort(["symbol", "date"])
def _single_symbol_panel(n_days: int = 70) -> pl.DataFrame:
start = date(2025, 1, 1)
rows = []
for offset in range(n_days):
change = [0.0, 0.01, -0.02, 0.03, 0.05, -0.01, 0.02, -0.03, 0.04, 0.01][offset % 10]
close = 10.0 if offset == 0 else rows[-1]["close"] * (1 + change)
volume = 1000.0 + (offset % 5) * 200
consecutive = 0
if offset % 15 == 0:
consecutive = 1 + (offset // 15) % 3 if offset % 30 == 0 else 1
rows.append({
"symbol": "000001.SZ",
"date": start + timedelta(days=offset),
"open": close * 0.99,
"high": close * 1.02,
"low": close * 0.97,
"close": close,
"volume": volume,
"amount": volume * 100.0 * close,
"turnover_rate": 1.0 + (offset % 7) * 0.3,
"consecutive_limit_ups": consecutive,
})
return _panel(rows)
def _tail_value(frame: pl.DataFrame, name: str) -> float:
return frame.tail(1).to_dicts()[0][name]
def test_max_ret_up_days_and_limit_up_counts():
panel = _single_symbol_panel()
frame = materialize_scoring_columns(panel, {
"max_ret_20d", "up_days_20d",
"limit_up_count_20d", "limit_up_count_60d",
})
changes = [
panel["close"][i] / panel["close"][i - 1] - 1
for i in range(1, panel.height)
]
window = changes[-20:]
assert math.isclose(_tail_value(frame, "max_ret_20d"), max(window), rel_tol=1e-9)
assert math.isclose(
_tail_value(frame, "up_days_20d"),
float(sum(1 for value in window if value > 0)),
rel_tol=1e-9,
)
hits = [
1 if (row["consecutive_limit_ups"] or 0) > 0 else 0
for row in panel.iter_rows(named=True)
]
assert math.isclose(_tail_value(frame, "limit_up_count_20d"), float(sum(hits[-20:])), rel_tol=1e-9)
assert math.isclose(_tail_value(frame, "limit_up_count_60d"), float(sum(hits[-60:])), rel_tol=1e-9)
def test_amihud_and_turnover_z():
panel = _single_symbol_panel()
frame = materialize_scoring_columns(panel, {"amihud_20d", "turnover_z_60d"})
rows = panel.to_dicts()
illiq = [
abs(rows[i]["close"] / rows[i - 1]["close"] - 1) / (rows[i]["amount"] / 1e8)
for i in range(1, panel.height)
]
assert math.isclose(_tail_value(frame, "amihud_20d"), sum(illiq[-20:]) / 20, rel_tol=1e-6)
baseline = [rows[i]["turnover_rate"] for i in range(panel.height - 61, panel.height - 1)]
mean = sum(baseline) / 60
variance = sum((value - mean) ** 2 for value in baseline) / 59
std = variance ** 0.5
expected_z = (rows[-1]["turnover_rate"] - mean) / std
assert math.isclose(_tail_value(frame, "turnover_z_60d"), expected_z, rel_tol=1e-6)
def test_vwap_bias_and_vol_trend():
panel = _single_symbol_panel()
frame = materialize_scoring_columns(panel, {"vwap_bias", "vol_trend_5_60"})
last = panel.tail(1).to_dicts()[0]
vwap = last["amount"] / (last["volume"] * 100.0)
assert math.isclose(_tail_value(frame, "vwap_bias"), last["close"] / vwap - 1, rel_tol=1e-9)
volumes = panel["volume"].to_list()
fast = sum(volumes[-5:]) / 5
slow = sum(volumes[-60:]) / 60
assert math.isclose(_tail_value(frame, "vol_trend_5_60"), fast / slow - 1, rel_tol=1e-9)
def test_ret_skew_matches_population_skew():
# 周期夹具的 20 日窗口恰含两个完整周期, 偏度恒为 0; 用不对称收益验证公式。
start = date(2025, 3, 1)
changes = [0.01, -0.005, -0.004, 0.09, -0.006, 0.002, -0.003, -0.002, -0.005, 0.003] * 7
rows = []
close = 10.0
for offset, change in enumerate(changes):
close = close * (1 + change)
rows.append({
"symbol": "000001.SZ",
"date": start + timedelta(days=offset),
"open": close * 0.99,
"high": close * 1.02,
"low": close * 0.97,
"close": close,
"volume": 1000.0 + offset,
"amount": (1000.0 + offset) * close,
"turnover_rate": 1.0,
"consecutive_limit_ups": 0,
})
panel = _panel(rows)
frame = materialize_scoring_columns(panel, {"ret_skew_20d"})
window = changes[-20:]
mean = sum(window) / 20
central_second = sum((value - mean) ** 2 for value in window) / 20
central_third = sum((value - mean) ** 3 for value in window) / 20
expected = central_third / central_second ** 1.5
assert abs(expected) > 0.1
assert math.isclose(_tail_value(frame, "ret_skew_20d"), expected, rel_tol=1e-6)
def test_vol_price_corr_matches_pearson():
panel = _single_symbol_panel()
frame = materialize_scoring_columns(panel, {"vol_price_corr_20d"})
closes = panel["close"].to_list()
changes = [closes[i] / closes[i - 1] - 1 for i in range(panel.height - 20, panel.height)]
volumes = panel["volume"].to_list()[-20:]
n = 20
mean_x = sum(changes) / n
mean_y = sum(volumes) / n
cov = sum((x - mean_x) * (y - mean_y) for x, y in zip(changes, volumes, strict=True)) / n
var_x = sum((x - mean_x) ** 2 for x in changes) / n
var_y = sum((y - mean_y) ** 2 for y in volumes) / n
expected = cov / (var_x * var_y) ** 0.5
assert math.isclose(_tail_value(frame, "vol_price_corr_20d"), expected, rel_tol=1e-6)
def test_matrix_limit_up_counts_use_consecutive_field():
panel = _single_symbol_panel()
frame = materialize_scoring_columns(panel, {"limit_up_count_20d"})
market = build_market_data_matrix(
panel,
field_columns={"amount", "turnover_rate", "consecutive_limit_ups"},
)
np.testing.assert_allclose(
matrix_feature(market, "limit_up_count_20d")[:, 0],
frame["limit_up_count_20d"].to_numpy(),
rtol=1e-6,
atol=1e-6,
equal_nan=True,
)
+193
View File
@@ -0,0 +1,193 @@
"""财务因子点时接入测试: 公告日门控 / 无数据 null 安全 / 双路径一致 / 历史累积同步。"""
from __future__ import annotations
from datetime import date, timedelta
from pathlib import Path
import numpy as np
import polars as pl
from app.backtest.fundamentals import (
FUNDAMENTAL_FACTOR_NAMES,
attach_fundamental_factors,
build_fundamental_matrices,
load_fundamental_snapshot,
)
from app.backtest.matrix import build_market_data_matrix
def _snapshot_frame(rows: list[dict]) -> pl.DataFrame:
frame = pl.DataFrame({
"period_end": ["2026-03-31"] * len(rows),
"symbol": [r["symbol"] for r in rows],
"announce_date": [r["announce"] for r in rows],
"roe": [r.get("roe", 10.0) for r in rows],
"bps": [r.get("bps", 5.0) for r in rows],
"revenue_yoy": [r.get("revenue_yoy", 8.0) for r in rows],
})
return (
frame.with_columns(
pl.col("announce_date").str.slice(0, 10).str.to_date().alias("_announce")
)
.sort(["symbol", "_announce"])
)
def _daily_panel(start: date, days: int, symbols: tuple[str, ...]) -> pl.DataFrame:
rows = []
for offset in range(days):
for symbol in symbols:
rows.append({
"symbol": symbol,
"date": start + timedelta(days=offset),
"open": 10.0,
"high": 10.5,
"low": 9.5,
"close": 10.0 + offset * 0.1,
"volume": 1000.0,
})
return pl.DataFrame(rows).sort(["symbol", "date"])
def test_attach_gates_on_announce_date_strictly():
panel = _daily_panel(date(2026, 4, 1), 10, ("600000.SH", "000001.SZ"))
snapshot = _snapshot_frame([
# 公告日 4-5: 4-5 当天不可用, 4-6 起 roe=20
{"symbol": "600000.SH", "announce": "2026-04-05", "roe": 20.0, "bps": 4.0},
# 无财务数据的标的
])
attached = attach_fundamental_factors(panel, snapshot, ["roe_latest", "pb_latest"])
values = attached.filter(pl.col("symbol") == "600000.SH").sort("date")
roe = values["roe_latest"].to_list()
assert roe[:5] == [None] * 5 # 4-1 ~ 4-5 均不可用 (含公告当日)
assert roe[5:] == [20.0] * 5 # 4-6 起生效
# 无财务数据标的全 null, 绝不填 0
other = attached.filter(pl.col("symbol") == "000001.SZ")["roe_latest"]
assert other.null_count() == other.len()
# pb = close / bps, 公告前 null
pb = values["pb_latest"].to_list()
assert pb[:5] == [None] * 5
close_6 = values.filter(pl.col("date") == date(2026, 4, 6))["close"].item()
assert abs(pb[5] - close_6 / 4.0) < 1e-12
def test_attach_replaces_with_newer_announcement():
panel = _daily_panel(date(2026, 4, 1), 20, ("600000.SH",))
snapshot = _snapshot_frame([
{"symbol": "600000.SH", "announce": "2026-04-05", "roe": 20.0},
{"symbol": "600000.SH", "announce": "2026-04-15", "roe": 33.0},
])
attached = attach_fundamental_factors(panel, snapshot, ["roe_latest"]).sort("date")
roe = attached["roe_latest"].to_list()
assert roe[4] is None # 4-5 公告日
assert roe[5] == 20.0 # 4-6 起 20.0
assert roe[13] == 20.0 # 4-14
assert roe[14] is None # 4-15 二次公告日, 当天仍不可用 (严格大于)
assert roe[15] == 33.0 # 4-16 起新公告生效
def test_attach_without_snapshot_keeps_null_columns():
panel = _daily_panel(date(2026, 4, 1), 5, ("600000.SH",))
attached = attach_fundamental_factors(panel, None, ["roe_latest", "pb_latest"])
for name in ("roe_latest", "pb_latest"):
assert name in attached.columns
assert attached[name].null_count() == attached.height
def test_matrix_field_matches_polars_attach():
panel = _daily_panel(date(2026, 4, 1), 12, ("600000.SH", "000001.SZ"))
snapshot = _snapshot_frame([
{"symbol": "600000.SH", "announce": "2026-04-05", "roe": 20.0, "bps": 4.0},
{"symbol": "000001.SZ", "announce": "2026-04-08", "roe": -5.0, "bps": -1.0},
])
attached = attach_fundamental_factors(panel, snapshot, ["roe_latest", "pb_latest"])
market = build_market_data_matrix(panel)
matrices = build_fundamental_matrices(market, snapshot, ["roe_latest", "pb_latest"])
symbols = {s: i for i, s in enumerate(market.symbols)}
dates = {str(d): t for t, d in enumerate(
sorted({row["date"] for row in panel.iter_rows(named=True)})
)}
for name in ("roe_latest", "pb_latest"):
matrix = matrices[name]
for row in attached.iter_rows(named=True):
expected = row[name]
actual = matrix[dates[str(row["date"])], symbols[row["symbol"]]]
if expected is None:
assert np.isnan(actual), (name, row["date"], row["symbol"], actual)
else:
np.testing.assert_allclose(actual, expected, rtol=1e-6)
def test_bps_nonpositive_gives_null_pb():
panel = _daily_panel(date(2026, 4, 1), 8, ("000001.SZ",))
snapshot = _snapshot_frame([
{"symbol": "000001.SZ", "announce": "2026-04-02", "bps": -1.0},
])
attached = attach_fundamental_factors(panel, snapshot, ["pb_latest"])
assert attached["pb_latest"].null_count() == attached.height
def test_load_snapshot_from_missing_dir_returns_none(tmp_path: Path):
assert load_fundamental_snapshot(tmp_path) is None
assert load_fundamental_snapshot(None) is None
def test_snapshot_requires_announce_date(tmp_path: Path):
out = tmp_path / "financials" / "metrics"
out.mkdir(parents=True)
pl.DataFrame({
"symbol": ["600000.SH"],
"announce_date": [None],
"roe": [10.0],
"gross_margin": [30.0],
"net_margin": [5.0],
"revenue_yoy": [8.0],
"net_income_yoy": [6.0],
"debt_to_asset_ratio": [40.0],
"bps": [5.0],
}).write_parquet(out / "part.parquet")
# 公告日缺失的行无法做点时门控, 视为无有效快照
assert load_fundamental_snapshot(tmp_path) is None
def test_fundamental_factor_names_are_catalogued():
from app.backtest.factor import FACTOR_COLUMNS
catalog_ids = {item["id"] for item in FACTOR_COLUMNS}
assert catalog_ids >= FUNDAMENTAL_FACTOR_NAMES
def test_financial_sync_merges_history(tmp_path: Path):
from app.services import financial_sync as fs
old = pl.DataFrame({
"symbol": ["600000.SH", "600000.SH"],
"period_end": ["2025-09-30", "2025-12-31"],
"announce_date": ["2025-10-28", "2026-01-20"],
"roe": [8.0, 9.0],
})
latest = pl.DataFrame({
"symbol": ["600000.SH", "000001.SZ"],
"period_end": ["2026-03-31", "2026-03-31"],
"announce_date": ["2026-04-25", "2026-04-24"],
"roe": [10.0, 5.0],
})
merged = fs._merge_report_history(old, latest)
assert merged.height == 4 # 旧各期保留 + 新一期并入
# 同期修正: 旧 2025-12-31 公告 2026-01-20 vs 更晚的修正公告
revised = pl.DataFrame({
"symbol": ["600000.SH"],
"period_end": ["2025-12-31"],
"announce_date": ["2026-02-01"],
"roe": [9.5],
})
merged2 = fs._merge_report_history(old, revised)
row = merged2.filter(
(pl.col("symbol") == "600000.SH") & (pl.col("period_end") == "2025-12-31")
)
assert row.height == 1
assert row["roe"].item() == 9.5
assert merged2.height == 2 # 修正不增加行数
+106
View File
@@ -0,0 +1,106 @@
"""get_daily 复用 enriched 历史缓存的等价性测试。
个股对话框打开时 /api/kline/daily 每个行情 tick 调用一次; 旧路径每次
150 天扫描 + 全套指标重算, 新路径优先从预计算历史缓存裁剪
本测试证明: 同一份数据下两条路径输出逐列一致, 且缓存命中时不触发扫描
"""
from __future__ import annotations
from datetime import date, timedelta
import polars as pl
from polars.testing import assert_frame_equal
from app.tickflow.repository import KlineRepository
SYM = "600001.SH"
def _raw_frame(days: int = 80) -> pl.DataFrame:
"""构造 ~57 个交易日的 14 列形态数据 (复权价与原始价一致, 无除权)。"""
base = date(2026, 4, 1)
rows = []
price = 10.0
d = base
while len(rows) < days:
if d.weekday() < 5:
open_ = price * (1 + ((len(rows) % 7) - 3) * 0.004)
close = price * (1 + ((len(rows) % 5) - 2) * 0.006)
high = max(open_, close) * 1.01
low = min(open_, close) * 0.99
price = close
rows.append({
"symbol": SYM, "date": d,
"open": round(open_, 4), "high": round(high, 4),
"low": round(low, 4), "close": round(close, 4),
"volume": 10000.0 + (len(rows) % 10) * 500.0,
"amount": 1.0e7,
"raw_close": round(close, 4), "raw_high": round(high, 4),
"raw_low": round(low, 4),
})
d += timedelta(days=1)
return pl.DataFrame(rows).sort(["symbol", "date"])
def _bare_repo(raw: pl.DataFrame) -> tuple[KlineRepository, dict]:
repo = KlineRepository.__new__(KlineRepository)
repo._enriched_history_cache = None
repo._enriched_history_start = None
repo._enriched_cache = None
repo._enriched_cache_date = None
repo.get_instruments = lambda: pl.DataFrame() # type: ignore[method-assign]
repo.get_historical_shares = lambda: pl.DataFrame() # type: ignore[method-assign]
repo.get_enriched_latest = lambda: (pl.DataFrame(), None) # type: ignore[method-assign]
calls = {"scan": 0}
def _scan(symbol, start, end, columns):
calls["scan"] += 1
return raw.filter((pl.col("date") >= start) & (pl.col("date") <= end))
repo._scan_daily_symbol = _scan # type: ignore[method-assign]
return repo, calls
def test_get_daily_cache_path_matches_scan_path():
raw = _raw_frame()
dates = raw["date"].to_list()
start, end = dates[30], dates[-1]
# 旧路径: 无历史缓存 → 扫描 + 即时计算
repo_old, calls_old = _bare_repo(raw)
result_old = repo_old.get_daily(SYM, start, end)
assert calls_old["scan"] == 1
# 新路径: 预计算历史缓存 (同一真实计算栈 _compute_enriched_range 构建)
repo_new, calls_new = _bare_repo(raw)
hist = repo_new._compute_enriched_range(raw)
repo_new._enriched_history_cache = hist
repo_new._enriched_history_start = hist["date"].min()
result_new = repo_new.get_daily(SYM, start, end)
assert calls_new["scan"] == 0, "缓存命中时不得回退到扫描路径"
assert result_new.height == result_old.height
common = sorted(set(result_old.columns) & set(result_new.columns))
assert {"open", "close", "ma5", "ma20", "ma60", "rsi_14"} <= set(common)
assert_frame_equal(
result_old.sort("date").select(common),
result_new.sort("date").select(common),
check_exact=False,
rel_tol=1e-9,
)
def test_get_daily_falls_back_to_scan_when_cache_does_not_cover_start():
raw = _raw_frame()
dates = raw["date"].to_list()
repo, calls = _bare_repo(raw)
hist = repo._compute_enriched_range(raw)
repo._enriched_history_cache = hist
repo._enriched_history_start = hist["date"].min()
# 请求起点早于缓存覆盖 → 必须回退扫描路径
result = repo.get_daily(SYM, dates[0] - timedelta(days=5), dates[-1])
assert calls["scan"] == 1
assert not result.is_empty()
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import threading
import time
import pytest
from app.services.heavy_job_limiter import (
HeavyJobCancelledError,
HeavyJobLimiter,
HeavyJobLimitTimeoutError,
heavy_job_limiter,
shared_heavy_job_limiter,
)
def test_weighted_capacity_and_timeout() -> None:
limiter = HeavyJobLimiter(capacity=2)
assert limiter.acquire("normal", timeout=0)
assert limiter.acquire("normal", timeout=0)
assert limiter.available == 0
assert not limiter.acquire("normal", timeout=0.01)
limiter.release("normal")
assert not limiter.acquire("mining", timeout=0)
limiter.release("normal")
assert limiter.acquire("mining", timeout=0)
assert limiter.in_use == 2
limiter.release("mining")
def test_waiting_acquire_can_be_cancelled() -> None:
limiter = HeavyJobLimiter(capacity=2, cancel_poll_interval=0.01)
cancel_event = threading.Event()
assert limiter.acquire("mining", timeout=0)
result: list[bool] = []
waiter = threading.Thread(
target=lambda: result.append(
limiter.acquire("normal", timeout=1, cancel_event=cancel_event)
)
)
waiter.start()
time.sleep(0.03)
cancel_event.set()
waiter.join(timeout=1)
assert not waiter.is_alive()
assert result == [False]
assert limiter.in_use == 2
limiter.release("mining")
def test_context_manager_releases_after_body_error() -> None:
limiter = HeavyJobLimiter(capacity=2)
with pytest.raises(ValueError, match="body failed"), limiter.slot("mining", timeout=0):
raise ValueError("body failed")
assert limiter.in_use == 0
assert limiter.acquire("mining", timeout=0)
limiter.release("mining")
def test_context_manager_distinguishes_timeout_and_cancellation() -> None:
limiter = HeavyJobLimiter(capacity=2)
assert limiter.acquire("mining", timeout=0)
with pytest.raises(HeavyJobLimitTimeoutError), limiter.slot("normal", timeout=0.01):
pytest.fail("unreachable")
cancelled = threading.Event()
cancelled.set()
with pytest.raises(HeavyJobCancelledError), limiter.slot("normal", cancel_event=cancelled):
pytest.fail("unreachable")
limiter.release("mining")
def test_invalid_release_does_not_overfill_capacity() -> None:
limiter = HeavyJobLimiter(capacity=2)
with pytest.raises(RuntimeError):
limiter.release("normal")
assert limiter.available == 2
assert limiter.acquire("normal", timeout=0)
with pytest.raises(RuntimeError):
limiter.release("mining")
assert limiter.in_use == 1
limiter.release("normal")
def test_module_aliases_share_the_capacity_two_singleton() -> None:
assert heavy_job_limiter is shared_heavy_job_limiter
assert shared_heavy_job_limiter.capacity == 2
+13
View File
@@ -28,6 +28,19 @@ def _bars(n: int = 90) -> pl.DataFrame:
return pl.DataFrame(rows)
def test_compute_indicators_assume_sorted_matches_default_values():
bars = _bars().sort(["symbol", "date"])
default = compute_indicators(bars, needed={"macd_hist", "rsi_14", "momentum_20d"})
fast = compute_indicators(
bars,
needed={"macd_hist", "rsi_14", "momentum_20d"},
assume_sorted=True,
)
assert fast.equals(default)
def test_compute_signals_subset_matches_full_values():
indicators = compute_indicators(_bars())
full = compute_signals(indicators)
+155
View File
@@ -0,0 +1,155 @@
"""kline 每秒热路径测试 — _get_stock_info 走内存缓存, _attach_ext 复用签名缓存 value_map。
契约等值: 输出与旧的 DuckDB 扫描 / 逐配置读 parquet 路径一致
"""
from __future__ import annotations
from types import SimpleNamespace
import polars as pl
from app.api import screener
from app.api.kline import _attach_ext, _get_stock_info
from app.services.ext_data import ExtConfig, ExtConfigStore, ExtField
class _FakeRepo:
"""最小 repo 替身: _get_stock_info/_load_ext_value_maps 只用到这些成员。"""
def __init__(self, instruments: pl.DataFrame, data_dir) -> None:
self._instruments = instruments
self.store = SimpleNamespace(data_dir=data_dir, db=None)
self.execute_one_calls = 0
def get_instruments(self) -> pl.DataFrame:
return self._instruments
def execute_one(self, *args, **kwargs):
self.execute_one_calls += 1
raise AssertionError("热路径不得再走 DuckDB 扫 instruments parquet")
def _instruments_df() -> pl.DataFrame:
return pl.DataFrame({
"symbol": ["600000.SH", "000001.SZ", "510300.SH"],
"name": ["浦发银行", "平安银行", "沪深300ETF"],
"total_shares": [1.0e9, 2.0e9, None],
"float_shares": [5.0e8, 1.0e9, None],
})
# ---------------------------------------------------------------- _get_stock_info
def test_get_stock_info_reads_memory_cache():
repo = _FakeRepo(_instruments_df(), None)
assert _get_stock_info(repo, "600000.SH") == {
"name": "浦发银行", "total_shares": 1.0e9, "float_shares": 5.0e8,
}
assert repo.execute_one_calls == 0
def test_get_stock_info_null_shares_become_none():
repo = _FakeRepo(_instruments_df(), None)
info = _get_stock_info(repo, "510300.SH")
assert info["name"] == "沪深300ETF"
assert info["total_shares"] is None
assert info["float_shares"] is None
def test_get_stock_info_missing_symbol_returns_empty():
repo = _FakeRepo(_instruments_df(), None)
assert _get_stock_info(repo, "999999.SH") == {}
def test_get_stock_info_missing_columns_returns_empty_like_sql_error():
df = pl.DataFrame({"symbol": ["600000.SH"], "name": ["浦发银行"]})
repo = _FakeRepo(df, None)
assert _get_stock_info(repo, "600000.SH") == {}
def test_get_stock_info_repo_failure_returns_empty():
repo = SimpleNamespace()
repo.get_instruments = lambda: (_ for _ in ()).throw(RuntimeError("boom"))
assert _get_stock_info(repo, "600000.SH") == {}
# ---------------------------------------------------------------- _attach_ext
def test_attach_ext_empty_columns_untouched():
resp = {"symbol": "600000.SH", "stock_info": {"name": "x"}, "rows": []}
repo = _FakeRepo(_instruments_df(), None)
out = _attach_ext(resp, repo, "600000.SH", None)
assert out is resp
assert "ext" not in out["stock_info"]
out = _attach_ext(resp, repo, "600000.SH", " ")
assert "ext" not in out["stock_info"]
def test_attach_ext_invalid_spec_untouched():
resp = {"symbol": "600000.SH", "stock_info": {"name": "x"}, "rows": []}
repo = _FakeRepo(_instruments_df(), None)
out = _attach_ext(resp, repo, "600000.SH", "foo_bar") # 无 '.' 分隔
assert out is resp
assert "ext" not in out["stock_info"]
def test_attach_ext_maps_values_from_loader(monkeypatch):
resp = {"symbol": "600000.SH", "stock_info": {"name": "x"}, "rows": []}
original_info = resp["stock_info"]
repo = _FakeRepo(_instruments_df(), None)
monkeypatch.setattr(
screener, "_load_ext_value_maps",
lambda r, cols: {"cfg1__score": {"600000.SH": 88.5}, "cfg1__note": {}},
)
out = _attach_ext(resp, repo, "600000.SH", "cfg1.score,cfg1.note")
assert out["stock_info"]["ext"] == {"cfg1__score": 88.5, "cfg1__note": None}
# 原地更新 resp 是该函数契约; 但原 stock_info 字典对象不被修改 (与旧实现一致)
assert out is resp
assert "ext" not in original_info
assert out["stock_info"] is not original_info
def test_attach_ext_loader_failure_yields_none_values(monkeypatch):
resp = {"symbol": "600000.SH", "stock_info": {}, "rows": []}
repo = _FakeRepo(_instruments_df(), None)
def _boom(r, cols):
raise RuntimeError("boom")
monkeypatch.setattr(screener, "_load_ext_value_maps", _boom)
out = _attach_ext(resp, repo, "600000.SH", "cfg1.score")
assert out["stock_info"]["ext"] == {"cfg1__score": None}
# ------------------------------------------- _attach_ext 与真实 _load_ext_value_maps 集成
def _setup_ext_snapshot(tmp_path):
store = ExtConfigStore(tmp_path)
store.upsert(ExtConfig(
id="benchx", label="基准扩展", mode="snapshot",
fields=[ExtField(name="score", dtype="float")],
))
cfg_dir = tmp_path / "ext_data" / "benchx"
cfg_dir.mkdir(parents=True, exist_ok=True)
pl.DataFrame({
"symbol": [f"{600000 + i}.SH" for i in range(50)],
"score": [float(i) for i in range(50)],
}).write_parquet(cfg_dir / "part.parquet")
return store
def test_attach_ext_real_loader_returns_parquet_values(tmp_path):
_setup_ext_snapshot(tmp_path)
repo = _FakeRepo(_instruments_df(), tmp_path)
resp = {"symbol": "600005.SH", "stock_info": {"name": "x"}, "rows": []}
out = _attach_ext(resp, repo, "600005.SH", "benchx.score")
assert out["stock_info"]["ext"] == {"benchx__score": 5.0}
assert isinstance(out["stock_info"]["ext"]["benchx__score"], float)
def test_attach_ext_real_loader_unknown_symbol_is_none(tmp_path):
_setup_ext_snapshot(tmp_path)
repo = _FakeRepo(_instruments_df(), tmp_path)
resp = {"symbol": "999999.SH", "stock_info": {}, "rows": []}
out = _attach_ext(resp, repo, "999999.SH", "benchx.score")
assert out["stock_info"]["ext"] == {"benchx__score": None}
+38
View File
@@ -0,0 +1,38 @@
"""时区契约测试 — 分时拉取窗口必须按北京时间解释, 与服务器本地时区无关。
fetch_minute_single 构造的 naive datetime 会被 _datetime_to_ms .timestamp()
按服务器本地时区解释: UTC 容器 (Docker 默认) 上窗口偏移 8 小时, 补拉必为空
"""
from __future__ import annotations
from datetime import date, datetime
from app.market_time import CN_TZ
from app.services import kline_sync
def test_fetch_minute_single_window_is_beijing_wall_clock(monkeypatch):
captured: dict[str, int] = {}
def _fake_try_custom_minute(*args, **kwargs):
return (None, True) # 未配自定义源 → 走 TickFlow 分支
class _FakeKlines:
@staticmethod
def batch(symbols, period, start_time, end_time, **kwargs):
captured["start_ms"] = start_time
captured["end_ms"] = end_time
return []
class _FakeClient:
klines = _FakeKlines
monkeypatch.setattr(kline_sync, "_try_custom_minute", _fake_try_custom_minute)
monkeypatch.setattr(kline_sync, "get_client", lambda: _FakeClient())
kline_sync.fetch_minute_single("600000.SH", date(2026, 8, 14))
start = datetime.fromtimestamp(captured["start_ms"] / 1000, tz=CN_TZ)
end = datetime.fromtimestamp(captured["end_ms"] / 1000, tz=CN_TZ)
assert (start.date(), start.hour, start.minute) == (date(2026, 8, 14), 9, 25)
assert (end.date(), end.hour, end.minute) == (date(2026, 8, 14), 15, 5)
+64
View File
@@ -0,0 +1,64 @@
"""last_fetch 落盘节流测试 — 30s 内只写一次盘, 内存值路径不受影响。"""
from __future__ import annotations
import pytest
from app.services import preferences, quote_service
@pytest.fixture(autouse=True)
def _reset_throttle_state(monkeypatch):
monkeypatch.setattr(quote_service, "_last_fetch_written_at_ms", 0.0)
yield
quote_service._last_fetch_written_at_ms = 0.0
@pytest.fixture()
def save_counter(monkeypatch):
counter = {"saves": 0, "values": []}
def _counting(updates: dict) -> dict:
counter["saves"] += 1
counter["values"].append(updates)
return {}
monkeypatch.setattr(preferences, "save", _counting)
return counter
def test_first_call_after_start_writes(save_counter):
quote_service._persist_last_fetch(1_700_000_000_000.0)
assert save_counter["saves"] == 1
assert save_counter["values"][0] == {"last_fetch_ms": 1_700_000_000_000.0}
def test_writes_within_window_are_skipped(save_counter):
t0 = 1_700_000_000_000.0
quote_service._persist_last_fetch(t0)
quote_service._persist_last_fetch(t0 + 10_000.0) # +10s: 跳过
quote_service._persist_last_fetch(t0 + 29_999.0) # 仍不足 30s: 跳过
assert save_counter["saves"] == 1
def test_write_resumes_after_window(save_counter):
t0 = 1_700_000_000_000.0
quote_service._persist_last_fetch(t0)
quote_service._persist_last_fetch(t0 + 30_000.0) # 恰好 30s: 恢复写盘
assert save_counter["saves"] == 2
assert save_counter["values"][1] == {"last_fetch_ms": 1_700_000_030_000.0}
def test_write_failure_does_not_propagate_and_retries(monkeypatch):
calls = {"n": 0}
def _flaky(updates: dict) -> dict:
calls["n"] += 1
if calls["n"] == 1:
raise OSError("disk full")
return {}
monkeypatch.setattr(preferences, "save", _flaky)
t0 = 1_700_000_000_000.0
quote_service._persist_last_fetch(t0) # 失败但不抛出
quote_service._persist_last_fetch(t0 + 1_000.0) # 未成功过 -> 立即重试
assert calls["n"] == 2
@@ -0,0 +1,83 @@
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from types import SimpleNamespace
import pytest
import app.main as main_module
def test_lifespan_holds_mining_process_lock_around_application(monkeypatch) -> None:
events: list[str] = []
class LockStub:
def __init__(self, data_dir) -> None:
del data_dir
events.append("lock_created")
def acquire(self) -> None:
events.append("lock_acquired")
def release(self) -> None:
events.append("lock_released")
@asynccontextmanager
async def application_lifespan(_app):
events.append("application_started")
try:
yield
finally:
events.append("application_stopped")
monkeypatch.setattr(main_module, "MiningProcessLock", LockStub)
monkeypatch.setattr(main_module, "_application_lifespan", application_lifespan)
async def exercise() -> None:
async with main_module.lifespan(SimpleNamespace()):
events.append("request_serving")
asyncio.run(exercise())
assert events == [
"lock_created",
"lock_acquired",
"application_started",
"request_serving",
"application_stopped",
"lock_released",
]
def test_lifespan_releases_lock_when_application_shutdown_raises(monkeypatch) -> None:
events: list[str] = []
class LockStub:
def __init__(self, data_dir) -> None:
del data_dir
def acquire(self) -> None:
events.append("acquired")
def release(self) -> None:
events.append("released")
@asynccontextmanager
async def application_lifespan(_app):
try:
yield
finally:
raise RuntimeError("shutdown failed")
monkeypatch.setattr(main_module, "MiningProcessLock", LockStub)
monkeypatch.setattr(main_module, "_application_lifespan", application_lifespan)
async def exercise() -> None:
async with main_module.lifespan(SimpleNamespace()):
pass
with pytest.raises(RuntimeError, match="shutdown failed"):
asyncio.run(exercise())
assert events == ["acquired", "released"]
+162
View File
@@ -0,0 +1,162 @@
"""市场主线(market_mainline)与过滤配置单元测试。"""
from __future__ import annotations
from datetime import date
import polars as pl
from app.services import market_mainline, preferences
def _write_enriched(root, rows: list[dict]) -> None:
enriched = root / "kline_daily_enriched"
by_date: dict[date, list[dict]] = {}
for r in rows:
by_date.setdefault(r["date"], []).append(r)
for d, day_rows in by_date.items():
part = enriched / f"date={d.isoformat()}" / "part.parquet"
part.parent.mkdir(parents=True, exist_ok=True)
pl.DataFrame(day_rows).write_parquet(part)
def _fake_repo(tmp_path):
import types
return types.SimpleNamespace(store=types.SimpleNamespace(data_dir=tmp_path))
def _patch_map(monkeypatch, mapping: dict[str, list[str]], kind: str = "concept") -> None:
map_df = pl.DataFrame(
{"_sym_up": [s for s, ms in mapping.items() for _ in ms],
kind: [m for _, ms in mapping.items() for m in ms]},
schema={"_sym_up": pl.Utf8, kind: pl.Utf8},
).unique()
def fake_load(repo, k="concept"):
return (map_df, map_df[kind].n_unique()) if k == kind else (pl.DataFrame(), 0)
monkeypatch.setattr(market_mainline, "_load_concept_map_df", fake_load)
def _mk_rows(d: date, spec: list[tuple[str, int, float]]) -> list[dict]:
return [
{"symbol": sym, "date": d, "consecutive_limit_ups": consec, "amount": amt}
for sym, consec, amt in spec
]
class TestComputeMainline:
def _setup(self, tmp_path, monkeypatch):
d1, d2 = date(2024, 1, 2), date(2024, 1, 3)
# 概念 X: d1 三个涨停(2,1,1), d2 三个涨停(3,2,1); 概念 Y: 单股 2 板
# S5 无概念映射; 大概念 BIG 成员 700 家但只有 5 家涨停(数据里只写 5 行)
rows = _mk_rows(d1, [("S1.SH", 2, 5e8), ("S2.SH", 1, 1e8), ("S3.SH", 1, 2e8),
("S4.SH", 2, 3e8), ("S5.SH", 1, 1e8),
("B1.SH", 1, 1e8), ("B2.SH", 1, 1e8)])
rows += _mk_rows(d2, [("S1.SH", 3, 6e8), ("S2.SH", 2, 2e8), ("S3.SH", 0, 1e8),
("S4.SH", 3, 4e8), ("S5.SH", 1, 1e8),
("B1.SH", 2, 1e8), ("B2.SH", 0, 1e8)])
_write_enriched(tmp_path, rows)
mapping = {
"S1.SH": ["X"], "S2.SH": ["X"], "S3.SH": ["X"],
"S4.SH": ["X", "Y"], "S5.SH": [],
"B1.SH": ["BIG"], "B2.SH": ["BIG"],
**{f"F{i}.SH": ["BIG"] for i in range(700)}, # BIG 成员 702 → 超 600 上限
}
_patch_map(monkeypatch, mapping)
return _fake_repo(tmp_path), d1, d2
def test_aggregation_and_big_concept_filter(self, tmp_path, monkeypatch):
repo, d1, d2 = self._setup(tmp_path, monkeypatch)
out = market_mainline.compute_mainline_range(
repo, tmp_path, d1, d2, kind="concept",
filter_cfg={"min_members": 4, "max_members": 600, "blacklist": []},
)
members = set(out["member"].to_list())
assert "BIG" not in members # 成员数超上限被过滤
assert "X" in members
x_d2 = out.filter((pl.col("date") == d2) & (pl.col("member") == "X")).to_dicts()[0]
assert x_d2["limit_up_count"] == 3 # S1,S2,S4
assert x_d2["ge2_count"] == 3
assert x_d2["max_boards"] == 3
assert x_d2["rungs_filled"] == 2 # 档位 {2,3}
assert x_d2["leader_symbol"] == "S1.SH" # 最高板且成交额大
assert x_d2["rank"] == 1
def test_blacklist_and_min_limit_up(self, tmp_path, monkeypatch):
repo, d1, d2 = self._setup(tmp_path, monkeypatch)
out = market_mainline.compute_mainline_range(
repo, tmp_path, d1, d2, kind="concept",
filter_cfg={"min_members": 1, "max_members": 5000, "blacklist": ["X"]},
)
# X 被黑名单; BIG 只有 2-3 家涨停 < _MIN_LIMIT_UP=3 也不参与 → 只剩空/无 X
assert "X" not in set(out["member"].to_list())
def test_upsert_replaces_same_day_kind(self, tmp_path, monkeypatch):
repo, d1, d2 = self._setup(tmp_path, monkeypatch)
cfg = {"min_members": 4, "max_members": 600, "blacklist": []}
first = market_mainline.compute_mainline_range(repo, tmp_path, d1, d1, kind="concept", filter_cfg=cfg)
market_mainline.upsert_mainline_history(tmp_path, first)
both = market_mainline.compute_mainline_range(repo, tmp_path, d1, d2, kind="concept", filter_cfg=cfg)
market_mainline.upsert_mainline_history(tmp_path, both)
stored = pl.read_parquet(market_mainline.mainline_path(tmp_path))
assert set(stored["date"].to_list()) == {d1, d2}
# 同日重算不产生重复行
assert stored.filter(pl.col("date") == d1).height == first.height
def test_incremental_fills_missing_days(self, tmp_path, monkeypatch):
repo, d1, d2 = self._setup(tmp_path, monkeypatch)
cfg = {"min_members": 4, "max_members": 600, "blacklist": []}
first = market_mainline.compute_mainline_range(repo, tmp_path, d1, d1, kind="concept", filter_cfg=cfg)
market_mainline.upsert_mainline_history(tmp_path, first)
new = market_mainline.compute_mainline_incremental(repo, tmp_path, kind="concept")
assert not new.is_empty()
assert set(new["date"].to_list()) == {d2}
def test_industry_level_truncation(self, tmp_path, monkeypatch):
d1 = date(2024, 1, 2)
rows = _mk_rows(d1, [("S1.SH", 2, 5e8), ("S2.SH", 1, 1e8),
("S3.SH", 1, 2e8), ("S4.SH", 3, 4e8)])
_write_enriched(tmp_path, rows)
_patch_map(
monkeypatch,
{"S1.SH": ["计算机-软件开发-垂直应用软件"],
"S2.SH": ["计算机-软件开发-垂直应用软件"],
"S3.SH": ["计算机-IT服务-IT服务Ⅲ"],
"S4.SH": ["计算机-软件开发-垂直应用软件"]},
kind="industry",
)
out = market_mainline.compute_mainline_range(
_fake_repo(tmp_path), tmp_path, d1, d1, kind="industry",
filter_cfg={"min_members": 1, "max_members": 5000, "blacklist": []},
)
members = set(out["member"].to_list())
assert "计算机-软件开发" in members
assert all(m.count("-") <= 1 for m in members)
sw = out.filter(pl.col("member") == "计算机-软件开发").to_dicts()[0]
assert sw["limit_up_count"] == 3
assert sw["max_boards"] == 3
class TestMainlineFilterPreferences:
def test_blacklist_string_parsing_and_clamp(self, tmp_path, monkeypatch):
path = tmp_path / "preferences.json"
monkeypatch.setattr(preferences, "_path", lambda: path)
got = preferences.set_mainline_filter_config({
"max_members": 99999, # 超上限被夹到 5000
"min_members": 0, # 低于下限被夹到 1
"blacklist": "融资融券, 沪股通;深股通", # noqa: RUF001
})
assert got["max_members"] == 5000
assert got["min_members"] == 1
assert set(got["blacklist"]) == {"融资融券", "沪股通", "深股通"}
# 部分更新: 只改黑名单, 其他保持
got2 = preferences.set_mainline_filter_config({"blacklist": ["ST板块"]})
assert got2["blacklist"] == ["ST板块"]
assert got2["max_members"] == 5000
def test_defaults(self, tmp_path, monkeypatch):
path = tmp_path / "preferences.json"
monkeypatch.setattr(preferences, "_path", lambda: path)
cfg = preferences.get_mainline_filter_config()
assert cfg == {"min_members": 4, "max_members": 600, "blacklist": []}
+200
View File
@@ -0,0 +1,200 @@
"""市场情绪周期阶段(market_phase)单元测试 — 梯队指标与阶段规则引擎。"""
from __future__ import annotations
from datetime import date, timedelta
from itertools import pairwise
import polars as pl
from app.services.market_phase import (
CLIMAX_GE2,
EBB_PROMO,
ICE_FIRST_BOARD,
ICE_GE2,
ICE_HEIGHT,
PHASE_CLIMAX,
PHASE_EBB,
PHASE_ICE,
PHASE_IGNITE,
PHASE_RALLY,
PHASE_REPAIR,
classify_phase_series,
finalize_ladder_row,
with_prev_consecutive,
)
from app.services.regime_builder import _aggregate_daily, refresh_phase_labels, regime_path
def _days(n: int, start: str = "2024-01-01") -> list[date]:
d0 = date.fromisoformat(start)
out, cur = [], d0
while len(out) < n:
if cur.weekday() < 5:
out.append(cur)
cur += timedelta(days=1)
return out
def _frame(rows: list[dict]) -> pl.DataFrame:
base = {
"change_pct": 0.01, "amount": 1e8, "close": 10.0, "ma20": 9.5,
"signal_limit_up": True, "signal_limit_down": False,
"signal_broken_limit_up": False,
}
return pl.DataFrame([{**base, **r} for r in rows])
class TestLadderMetrics:
def test_prev_consecutive_and_counts(self):
d1, d2, d3 = _days(3)
df = _frame([
{"symbol": "A", "date": d1, "consecutive_limit_ups": 1},
{"symbol": "A", "date": d2, "consecutive_limit_ups": 2},
{"symbol": "A", "date": d3, "consecutive_limit_ups": 0},
{"symbol": "B", "date": d1, "consecutive_limit_ups": 1},
{"symbol": "B", "date": d2, "consecutive_limit_ups": 0},
{"symbol": "C", "date": d2, "consecutive_limit_ups": 3},
])
out = with_prev_consecutive(df)
prev = {r["symbol"] + str(r["date"]): r["_prev_consec"] for r in out.iter_rows(named=True)}
assert prev["A" + str(d1)] is None
assert prev["A" + str(d2)] == 1
assert prev["B" + str(d2)] == 1
agg = _aggregate_daily(df)
by_date = {r["date"]: r for r in agg.iter_rows(named=True)}
assert by_date[d1]["first_board"] == 2
assert by_date[d1]["ge2_count"] == 0
# d2: A=2板(晋级), B=断板(昨1今0), C=3板(新面孔); pool=2(A,B) <10 → promo null
assert by_date[d2]["ge2_count"] == 2
assert by_date[d2]["promo_pool"] == 2
assert by_date[d2]["promo_rate"] is None
assert by_date[d2]["ladder_completeness"] == 1.0 # 档位 {2,3}, height=3 → 2/2
assert by_date[d3]["first_board"] == 0
def test_promo_rate_with_sufficient_pool(self):
d1, d2 = _days(2)
rows = []
for i in range(12):
rows.append({"symbol": f"S{i}", "date": d1, "consecutive_limit_ups": 1})
rows.append({
"symbol": f"S{i}", "date": d2,
"consecutive_limit_ups": 2 if i < 4 else 0, # 4 晋级, 8 断板
})
agg = _aggregate_daily(_frame(rows))
d2_row = {r["date"]: r for r in agg.iter_rows(named=True)}[d2]
assert d2_row["promo_pool"] == 12
assert d2_row["promo_rate"] == round(4 / 12, 4)
def test_promo_small_pool_is_null(self):
row = {"promo_pool": 5, "promo_ok": 5, "max_consecutive": 3, "rungs_filled": 2}
assert finalize_ladder_row(row)["promo_rate"] is None
row2 = {"promo_pool": 20, "promo_ok": 8, "max_consecutive": 3, "rungs_filled": 2}
assert finalize_ladder_row(row2)["promo_rate"] == 0.4
def test_completeness_gap(self):
# height=5, 只有 2板和5板 → rungs {2,5} → 2/4
row = {"max_consecutive": 5, "rungs_filled": 2, "promo_pool": 0, "promo_ok": 0}
assert finalize_ladder_row(row)["ladder_completeness"] == 0.5
def _series(specs: list[dict]) -> pl.DataFrame:
"""specs: [{days, height, first, ge2, promo, seal, state}] 逐段展开成日序。"""
rows = []
for sp in specs:
for _ in range(sp["days"]):
rows.append({
"date": None, # 由调用方生成后填充
"max_consecutive": sp["height"],
"first_board": sp["first"],
"ge2_count": sp["ge2"],
"promo_rate": sp["promo"],
"seal_rate": sp["seal"],
**({"state": sp.get("state", "range")} if sp.get("state") else {}),
})
dates = _days(len(rows))
for r, d in zip(rows, dates, strict=True):
r["date"] = d
return pl.DataFrame(rows)
class TestClassifyPhaseSeries:
def _labels(self, specs):
df = _series(specs)
return classify_phase_series(df)["phase"].to_list()
def test_climax_and_persistence(self):
labels = self._labels([
{"days": 6, "height": 5, "first": 40, "ge2": 12, "promo": 0.2, "seal": 0.6, "state": "strong"},
{"days": 5, "height": 12, "first": 300, "ge2": CLIMAX_GE2 + 40, "promo": 0.5, "seal": 0.7, "state": "strong"},
{"days": 8, "height": 5, "first": 40, "ge2": 12, "promo": 0.2, "seal": 0.6, "state": "range"},
])
assert PHASE_CLIMAX in labels
assert labels[-1] == PHASE_REPAIR
# EMA 平滑 + 2 日确认: 阶段切换总数有限, 不出现 1 日翻转噪声
switches = sum(1 for a, b in pairwise(labels) if a != b)
assert switches <= 4
def test_rally_positive_state(self):
labels = self._labels([
{"days": 8, "height": 8, "first": 60, "ge2": 18, "promo": 0.3, "seal": 0.7, "state": "strong"},
])
assert PHASE_RALLY in labels
def test_rally_vetoed_in_weak_state(self):
labels = self._labels([
{"days": 8, "height": 8, "first": 60, "ge2": 18, "promo": 0.3, "seal": 0.7, "state": "weak"},
])
assert PHASE_RALLY not in labels
assert all(p == PHASE_REPAIR for p in labels)
def test_ice(self):
labels = self._labels([
{"days": 6, "height": ICE_HEIGHT - 1, "first": ICE_FIRST_BOARD - 1,
"ge2": ICE_GE2 - 1, "promo": 0.1, "seal": 0.5, "state": "range"},
])
assert PHASE_ICE in labels
def test_ebb_from_high(self):
labels = self._labels([
{"days": 8, "height": 9, "first": 60, "ge2": 20, "promo": 0.3, "seal": 0.7, "state": "strong"},
{"days": 6, "height": 6, "first": 30, "ge2": 6, "promo": EBB_PROMO - 0.03, "seal": 0.5, "state": "range"},
])
assert PHASE_EBB in labels
def test_ignite_expansion(self):
labels = self._labels([
{"days": 6, "height": 4, "first": 25, "ge2": 5, "promo": 0.15, "seal": 0.6, "state": "range"},
{"days": 8, "height": 6, "first": 50, "ge2": 14, "promo": 0.24, "seal": 0.68, "state": "lean_strong"},
])
assert PHASE_IGNITE in labels
def test_promo_null_leading_days_ffilled(self):
specs = [
{"days": 4, "height": 5, "first": 40, "ge2": 10, "promo": None, "seal": 0.6, "state": "range"},
{"days": 4, "height": 6, "first": 50, "ge2": 14, "promo": 0.25, "seal": 0.68, "state": "strong"},
]
df = _series(specs)
out = classify_phase_series(df)
assert out["phase"].null_count() == 0
class TestRefreshPhaseLabels:
def test_roundtrip_writes_phase_keeps_state(self, tmp_path):
specs = [
{"days": 10, "height": 8, "first": 60, "ge2": 18, "promo": 0.3, "seal": 0.7, "state": "strong"},
]
df = _series(specs)
regime_path(tmp_path).parent.mkdir(parents=True)
df.write_parquet(regime_path(tmp_path))
n = refresh_phase_labels(tmp_path)
assert n == 10
out = pl.read_parquet(regime_path(tmp_path))
assert "phase" in out.columns
assert PHASE_RALLY in set(out["phase"].to_list())
assert set(out["state"].to_list()) == {"strong"}
def test_missing_columns_returns_zero(self, tmp_path):
regime_path(tmp_path).parent.mkdir(parents=True)
pl.DataFrame({"date": _days(3), "state": ["range"] * 3}).write_parquet(regime_path(tmp_path))
assert refresh_phase_labels(tmp_path) == 0
@@ -0,0 +1,65 @@
"""每日信号路径的字段依赖展开回归测试。
挖掘发布的 FactorRankResearchMatrixStrategy 把因子权重放在类级 SCORING,
META["scoring"] 为空每日信号/实时矩阵路径通过 _matrix_field_columns 决定
矩阵字段, 必须展开 required_fields_for_params 的虚拟因子依赖
(limit_up_count_* -> consecutive_limit_ups), 否则 compute_signals
"MarketDataMatrix missing field: consecutive_limit_ups"
"""
from __future__ import annotations
import types
from datetime import date, timedelta
import polars as pl
from app.backtest.matrix import build_market_data_matrix
from app.strategy.builtin.factor_rank_research import FactorRankResearchMatrixStrategy
from app.strategy.engine import StrategyEngine
def _mined_limit_up_strategy() -> types.SimpleNamespace:
strategy = FactorRankResearchMatrixStrategy(
{"amplitude": 2.0, "limit_up_count_60d": 1.0},
{"amplitude": "low", "limit_up_count_60d": "low"},
)
return types.SimpleNamespace(
matrix_strategy=strategy,
basic_filter=None,
meta={"scoring": {}},
)
def test_matrix_field_columns_expand_parameter_scoring_dependencies():
strategy = _mined_limit_up_strategy()
fields = StrategyEngine._matrix_field_columns(strategy, None, {})
assert "consecutive_limit_ups" in fields
assert "amplitude" in fields
def test_mined_limit_up_strategy_signals_build_from_panel_fields():
rows = []
start = date(2024, 1, 1)
for offset in range(80):
close = 10.0 + offset * 0.04
rows.append({
"symbol": "000001.SZ",
"date": start + timedelta(days=offset),
"open": close - 0.05,
"high": close + 0.15,
"low": close - 0.15,
"close": close,
"volume": 1000.0 + offset * 5.0,
"amount": 100000.0,
"amplitude": 1.5,
"turnover_rate": 5.0,
"consecutive_limit_ups": (offset % 17) + 1 if offset % 17 == 0 else 0,
})
panel = pl.DataFrame(rows)
strategy = _mined_limit_up_strategy()
market = build_market_data_matrix(
panel,
field_columns=StrategyEngine._matrix_field_columns(strategy, None, {}),
)
signals = strategy.matrix_strategy.compute_signals(market, {})
assert signals.shape == market.shape
@@ -0,0 +1,75 @@
from __future__ import annotations
import threading
import time
from app.services.heavy_job_limiter import HeavyJobCancelledError, HeavyJobLimiter
from app.services.matrix_prewarm_owner import MatrixCachePrewarmOwner
def test_owner_deduplicates_running_work_and_reuses_after_completion() -> None:
owner = MatrixCachePrewarmOwner()
started = threading.Event()
release = threading.Event()
calls: list[str] = []
def target() -> None:
calls.append("run")
started.set()
assert release.wait(2)
assert owner.schedule(target)
assert started.wait(1)
assert not owner.schedule(target)
release.set()
deadline = time.monotonic() + 1
while time.monotonic() < deadline and len(calls) < 2:
if owner.schedule(lambda: calls.append("run")):
break
time.sleep(0.005)
assert calls == ["run", "run"]
assert owner.shutdown(timeout=1)
def test_shutdown_cancels_limiter_wait_and_rejects_new_work() -> None:
limiter = HeavyJobLimiter(capacity=2, cancel_poll_interval=0.005)
owner = MatrixCachePrewarmOwner()
waiting = threading.Event()
cancelled = threading.Event()
assert limiter.acquire("mining", timeout=0)
def target() -> None:
waiting.set()
try:
with limiter.slot("normal", cancel_event=owner.cancel_event):
raise AssertionError("cancelled prewarm must not acquire capacity")
except HeavyJobCancelledError:
cancelled.set()
assert owner.schedule(target)
assert waiting.wait(1)
assert owner.shutdown(timeout=1)
assert cancelled.is_set()
assert not owner.schedule(target)
limiter.release("mining")
def test_shutdown_join_is_bounded_for_uncooperative_target() -> None:
owner = MatrixCachePrewarmOwner()
release = threading.Event()
started = threading.Event()
def target() -> None:
started.set()
release.wait(2)
assert owner.schedule(target)
assert started.wait(1)
before = time.monotonic()
assert not owner.shutdown(timeout=0.02)
assert time.monotonic() - before < 0.2
release.set()
assert owner.shutdown(timeout=1)
+606
View File
@@ -0,0 +1,606 @@
from __future__ import annotations
import json
from datetime import date, timedelta
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import quote
import polars as pl
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.mining import router
from app.backtest.mining import compute_candidate_signature
from app.services.mining_jobs import MiningRunStore
from app.strategy.engine import StrategyEngine
_FACTOR_DEFINITION = {
"kind": "factor_rank",
"factor_names": ["turnover_rate"],
"scoring": {"turnover_rate": 1.0},
"directions": {"turnover_rate": "high"},
}
_FACTOR_SIGNATURE = compute_candidate_signature(_FACTOR_DEFINITION)
class _Repo:
def __init__(self, data_dir) -> None:
self.store = SimpleNamespace(data_dir=data_dir)
@staticmethod
def get_matrix_data_generation(asset_type="stock"):
return f"generation-{asset_type}"
@staticmethod
def latest_enriched_date(asset_type="stock"):
del asset_type
return date(2026, 1, 9)
@staticmethod
def get_instruments_asset(asset_type):
del asset_type
return pl.DataFrame({"symbol": ["000001.SZ"], "name": ["测试"]})
class _Manager:
def __init__(self, data_dir) -> None:
self.store = MiningRunStore(data_dir)
def start(self, request, fingerprint, force=False, source="manual", run_id=None):
del force
manifest = self.store.create(request, fingerprint, run_id=run_id)
self.store.append_event(
manifest["run_id"],
"queued",
{"status": "queued", "source": source},
)
return manifest
def cancel(self, run_id):
manifest = self.store.get(run_id)
if manifest is None:
raise KeyError(run_id)
if manifest["status"] == "queued":
manifest = self.store.transition_status(run_id, "cancelled")
self.store.append_event(run_id, "cancelled", {"status": "cancelled"})
return manifest
def _write_enriched_dates(
data_dir: Path,
count: int,
*,
asset_type: str = "stock",
first: date = date(2020, 1, 1),
) -> list[date]:
dirname = "kline_etf_enriched" if asset_type == "etf" else "kline_daily_enriched"
values = [first + timedelta(days=index) for index in range(count)]
for value in values:
partition = data_dir / dirname / f"date={value.isoformat()}"
partition.mkdir(parents=True, exist_ok=True)
(partition / "part.parquet").touch()
return values
def _client(tmp_path):
_write_enriched_dates(tmp_path, 219, first=date(2022, 8, 15))
app = FastAPI()
app.include_router(router)
app.state.repo = _Repo(tmp_path)
app.state.mining_manager = _Manager(tmp_path)
app.state.strategy_engine = SimpleNamespace()
return TestClient(app), app.state.mining_manager.store
def _successful_run(store: MiningRunStore, run_id: str = "result-run"):
manifest = store.create(
{
"factor_names": ["turnover_rate"],
"strategy_ids": [],
"asset_type": "stock",
"budget_profile": "exploratory",
"correlation_threshold": 0.75,
},
{"generation": "test"},
run_id=run_id,
)
store.append_event(run_id, "queued", {"status": "queued", "source": "manual"})
store.transition_status(run_id, "running")
store.append_event(run_id, "running", {"status": "running"})
frames = {
"factors": pl.DataFrame({
"factor_name": ["turnover_rate"],
"label": ["换手率"],
"direction": [1],
"score": [1.2],
"ic_mean": [0.1],
"ir": [0.8],
"coverage": [1.0],
"turnover": [0.2],
"spread_return": [None],
"spread_sharpe": [None],
"selected": [True],
"excluded_reason": [None],
}),
"correlation": pl.DataFrame({
"factor_x": ["turnover_rate"],
"factor_y": ["turnover_rate"],
"rho": [1.0],
"pair_count": [3],
}),
"candidates": pl.DataFrame({
"signature": [_FACTOR_SIGNATURE],
"name": ["因子组合 · 换手率"],
"kind": ["factor_combination"],
"factor_names_json": ['["turnover_rate"]'],
"strategy_id": [None],
"definition_json": [json.dumps(_FACTOR_DEFINITION, sort_keys=True)],
"regime_state": ["overall"],
"score": [0.9],
"oos_return": [0.08],
"oos_sharpe": [0.9],
"oos_max_drawdown": [-0.12],
"oos_positive_fold_ratio": [1.0],
"oos_n_trades": [70],
"confidence": ["standard"],
"valid_folds": [3],
"skipped_folds": [0],
"promoted_candidate_id": [None],
"published_strategy_id": [None],
}, schema_overrides={
"strategy_id": pl.String,
"promoted_candidate_id": pl.String,
"published_strategy_id": pl.String,
}),
"folds": pl.DataFrame({
"candidate_signature": [_FACTOR_SIGNATURE, _FACTOR_SIGNATURE],
"fold": [0, 0],
"label": ["OOS 1", "OOS 1"],
"regime_state": ["overall", "strong"],
"n_dates": [3, 2],
"train_start": ["2025-01-01", "2025-01-01"],
"train_end": ["2025-12-31", "2025-12-31"],
"test_start": ["2026-01-05", "2026-01-05"],
"test_end": ["2026-01-09", "2026-01-09"],
"selected_factors_json": ['["turnover_rate"]'] * 2,
"total_return": [0.08, 0.1],
"sharpe": [0.9, 1.1],
"max_drawdown": [-0.12, -0.08],
"n_trades": [70, 40],
"skipped": [False, False],
"reason": [None, None],
}),
}
for name, frame in frames.items():
frame.write_parquet(store.artifact_path(run_id, name))
store.register_artifact(run_id, name)
store.write_summary(run_id, {
"status": "succeeded",
"factor_count": 1,
"selected_factor_count": 1,
"candidate_count": 1,
"valid_fold_count": 1,
"skipped_fold_count": 0,
"confidence": "low",
"budget_exhausted": False,
"elapsed_ms": 123.4,
"data_as_of": "2026-01-09",
"methodology_version": "factor_v2",
"algorithm_version": "mining-v1",
"panel_scans": 1,
"matrix_bytes": 4096,
"phase_ms": {"panel": 1.0, "total": 123.4},
"worker": {"peak_rss_bytes": 1000, "serialized_result_bytes": 2000},
})
store.transition_status(run_id, "succeeded")
store.append_event(run_id, "succeeded", {"status": "succeeded"})
return manifest
def test_start_is_strict_and_projects_server_signature(tmp_path):
client, store = _client(tmp_path)
payload = {
"factor_names": ["turnover_rate"],
"budget_profile": "exploratory",
"force": False,
}
response = client.post("/api/backtest/mining/runs", json=payload)
assert response.status_code == 200
body = response.json()
assert body["status"] == "queued"
assert body["signature"] == store.get(body["run_id"])["run_signature"]
assert body["request"]["factor_names"] == ["turnover_rate"]
assert "force" not in body["request"]
assert body["source"] == "manual"
assert client.post(
"/api/backtest/mining/runs",
json={**payload, "matching": "close_t"},
).status_code == 422
assert client.post(
"/api/backtest/mining/runs",
json={"factor_names": ["unknown_factor"]},
).status_code == 422
def test_start_accepts_iso_dates_and_persists_json_safe_request(tmp_path):
client, store = _client(tmp_path)
response = client.post(
"/api/backtest/mining/runs",
json={
"factor_names": ["turnover_rate"],
"budget_profile": "exploratory",
"start": "2022-08-15",
"end": "2026-08-15",
},
)
assert response.status_code == 200
run_id = response.json()["run_id"]
manifest = store.get(run_id)
assert manifest is not None
assert manifest["request"]["start"] == "2022-08-15"
assert manifest["request"]["end"] == "2026-08-15"
assert client.post(
"/api/backtest/mining/runs",
json={
"factor_names": ["turnover_rate"],
"start": "2022/08/15",
},
).status_code == 422
@pytest.mark.parametrize(
("profile", "required_bars"),
[("exploratory", 219), ("balanced", 786), ("strict", 1164)],
)
def test_availability_enforces_exact_profile_boundaries(
tmp_path,
profile,
required_bars,
):
client, _store = _client(tmp_path)
dates = _write_enriched_dates(
tmp_path,
required_bars,
first=date(2015, 1, 1),
)
eligible = client.get(
"/api/backtest/mining/availability",
params={
"asset_type": "stock",
"budget_profile": profile,
"start": dates[0].isoformat(),
"end": dates[-1].isoformat(),
},
)
insufficient = client.get(
"/api/backtest/mining/availability",
params={
"asset_type": "stock",
"budget_profile": profile,
"start": dates[1].isoformat(),
"end": dates[-1].isoformat(),
},
)
assert eligible.status_code == 200
assert eligible.json() == {
"asset_type": "stock",
"budget_profile": profile,
"trading_bars": required_bars,
"required_bars": required_bars,
"outer_folds": 1 if profile == "exploratory" else 3,
"required_outer_folds": 1 if profile == "exploratory" else 3,
"eligible": True,
"available_start": dates[0].isoformat(),
"available_end": "2023-03-21",
"effective_start": dates[0].isoformat(),
"effective_end": dates[-1].isoformat(),
"suggested_start": dates[0].isoformat(),
}
assert insufficient.status_code == 200
assert insufficient.json()["trading_bars"] == required_bars - 1
assert insufficient.json()["eligible"] is False
assert insufficient.json()["suggested_start"] == dates[0].isoformat()
def test_start_rejects_balanced_625_bar_range_before_creating_run(tmp_path):
client, store = _client(tmp_path)
dates = _write_enriched_dates(tmp_path, 625, first=date(2024, 1, 15))
response = client.post(
"/api/backtest/mining/runs",
json={
"factor_names": ["turnover_rate"],
"budget_profile": "balanced",
"start": dates[0].isoformat(),
"end": dates[-1].isoformat(),
"force": True,
},
)
assert response.status_code == 400
assert response.json()["detail"] == (
"balanced mining requires at least 786 enriched trading bars for 3 outer "
"folds; effective range 2024-01-15 to 2025-09-30 has 625"
)
assert store.list_runs() == []
def test_availability_uses_asset_specific_valid_partitions(tmp_path):
client, _store = _client(tmp_path)
etf_dates = _write_enriched_dates(
tmp_path,
219,
asset_type="etf",
first=date(2024, 1, 1),
)
malformed = tmp_path / "kline_etf_enriched" / "date=not-a-date"
malformed.mkdir(parents=True)
(malformed / "part.parquet").touch()
(tmp_path / "kline_etf_enriched" / "date=2023-12-31").mkdir(parents=True)
response = client.get(
"/api/backtest/mining/availability",
params={
"asset_type": "etf",
"budget_profile": "exploratory",
"start": etf_dates[0].isoformat(),
"end": etf_dates[-1].isoformat(),
},
)
assert response.status_code == 200
assert response.json()["trading_bars"] == 219
assert response.json()["eligible"] is True
def test_result_reconstructs_artifacts_without_exposing_definition(tmp_path):
client, store = _client(tmp_path)
_successful_run(store)
response = client.get("/api/backtest/mining/runs/result-run/result")
assert response.status_code == 200
body = response.json()
assert body["summary"]["peak_rss_bytes"] == 1000
assert body["correlation"]["matrix"] == [[1.0]]
assert body["candidates"][0]["factor_names"] == ["turnover_rate"]
assert "definition_json" not in body["candidates"][0]
assert body["candidates"][0]["folds"][0]["selected_factors"] == ["turnover_rate"]
assert body["candidates"][0]["gate"] == {"qualified": True, "reasons": []}
assert body["request_summary"] == {
"asset_type": "stock",
"budget_profile": "exploratory",
"start": None,
"end": None,
"factor_count": 1,
"strategy_count": 0,
"commission_pct": None,
"stamp_tax_pct": None,
"slippage_bps": None,
"correlation_threshold": 0.75,
}
regimes = {row["state"]: row for row in body["regimes"]}
assert regimes["overall"]["n_dates"] == 3
assert regimes["strong"]["n_dates"] == 2
assert regimes["range"]["total_return"] is None
assert body["telemetry"]["panel_scans"] == 1
def test_result_marks_legacy_fold_rows_without_evaluation_kind(tmp_path):
client, store = _client(tmp_path)
_successful_run(store)
response = client.get("/api/backtest/mining/runs/result-run/result")
assert response.status_code == 200
fold = response.json()["candidates"][0]["folds"][0]
assert fold["evaluation_kind"] is None
def test_publish_endpoint_rejects_candidate_below_evidence_gate(tmp_path):
client, store = _client(tmp_path)
_successful_run(store)
path = store.artifact_path("result-run", "candidates")
pl.read_parquet(path).with_columns(
pl.lit("low").alias("confidence"),
).write_parquet(path)
response = client.post(
f"/api/backtest/mining/runs/result-run/candidates/"
f"{quote(_FACTOR_SIGNATURE, safe='')}/publish",
)
assert response.status_code == 400
assert "exploratory results can only be saved" in response.json()["detail"]
def test_promote_endpoint_uses_persisted_definition_only(tmp_path):
client, store = _client(tmp_path)
_successful_run(store)
response = client.post(
f"/api/backtest/mining/runs/result-run/candidates/"
f"{quote(_FACTOR_SIGNATURE, safe='')}/promote"
)
assert response.status_code == 200
body = response.json()
assert body["status"] == "pending"
assert body["config"]["origin_run_id"] == "result-run"
assert body["config"]["candidate_signature"] == _FACTOR_SIGNATURE
assert body["config"]["factor_names"] == ["turnover_rate"]
assert "definition_json" not in body
persisted = pl.read_parquet(store.artifact_path("result-run", "candidates"))
assert persisted["promoted_candidate_id"][0] == body["id"]
def test_publish_endpoint_uses_persisted_definition_and_invalidates_runtime(
tmp_path,
) -> None:
client, store = _client(tmp_path)
_successful_run(store)
builtin_dir = Path(__file__).resolve().parents[1] / "app" / "strategy" / "builtin"
custom_dir = tmp_path / "strategies" / "custom"
engine = StrategyEngine(strategy_dirs=[builtin_dir, custom_dir])
invalidations: list[str] = []
client.app.state.strategy_engine = engine
client.app.state.monitor_engine = SimpleNamespace(
invalidate_strategy_state=lambda: invalidations.append("monitor")
)
response = client.post(
f"/api/backtest/mining/runs/result-run/candidates/"
f"{quote(_FACTOR_SIGNATURE, safe='')}/publish",
json={
"strategy_id": "caller-selected",
"scoring": {"rsi_14": 999.0},
},
)
assert response.status_code == 200
strategy_id = response.json()["strategy_id"]
strategy = engine.get(strategy_id)
assert strategy.meta["origin_run_id"] == "result-run"
assert strategy.meta["candidate_signature"] == _FACTOR_SIGNATURE
assert strategy.matrix_strategy._scoring == {"turnover_rate": 1.0}
assert strategy.matrix_strategy._directions == {"turnover_rate": "high"}
assert invalidations == ["monitor"]
persisted = pl.read_parquet(store.artifact_path("result-run", "candidates"))
assert persisted["published_strategy_id"][0] == strategy_id
def test_result_fails_closed_when_artifact_is_missing(tmp_path):
client, store = _client(tmp_path)
_successful_run(store)
store.artifact_path("result-run", "folds").unlink()
response = client.get("/api/backtest/mining/runs/result-run/result")
assert response.status_code == 500
assert response.json()["detail"] == "mining result artifacts are unavailable"
def test_sse_maps_failed_event_and_honors_last_event_id(tmp_path):
client, store = _client(tmp_path)
store.create(
{"factor_names": ["turnover_rate"]},
{"generation": "test"},
run_id="failed-run",
)
queued = store.append_event(
"failed-run", "queued", {"status": "queued", "source": "manual"}
)
store.transition_status("failed-run", "failed", error="worker failed")
failed = store.append_event(
"failed-run", "error", {"status": "failed", "message": "worker failed"}
)
response = client.get(
"/api/backtest/mining/runs/failed-run/events",
headers={"Last-Event-ID": str(queued["id"])},
)
assert response.status_code == 200
assert f"id: {failed['id']}" in response.text
assert "event: failed" in response.text
assert "event: error" not in response.text
assert "worker failed" in response.text
def test_sse_recovers_progress_snapshot_when_history_is_truncated(tmp_path):
client, store = _client(tmp_path)
store.create(
{"factor_names": ["turnover_rate"]},
{"generation": "test"},
run_id="truncated-run",
)
store.write_summary(
"truncated-run",
{"progress": {"phase": "search", "done": 7, "total": 10}},
)
for index in range(260):
store.append_event(
"truncated-run",
"progress",
{"phase": "search", "done": index, "total": 260},
)
store.transition_status("truncated-run", "failed", error="worker failed")
store.append_event(
"truncated-run",
"error",
{"status": "failed", "message": "worker failed"},
)
response = client.get("/api/backtest/mining/runs/truncated-run/events")
assert response.status_code == 200
assert '"done": 7' in response.text
assert "event: failed" in response.text
def test_start_rejects_incompatible_strategy_before_creating_run(tmp_path):
client, store = _client(tmp_path)
client.app.state.strategy_engine = SimpleNamespace(
get=lambda _strategy_id: SimpleNamespace(
meta={
"research_only": True,
"asset_types": ["stock"],
"timeframes": ["1d"],
},
execution_backend="matrix_native",
)
)
response = client.post(
"/api/backtest/mining/runs",
json={
"factor_names": ["turnover_rate"],
"strategy_ids": ["factor_rank_research"],
"budget_profile": "exploratory",
},
)
assert response.status_code == 400
assert store.list_runs() == []
def test_config_patch_merges_current_values(tmp_path, monkeypatch):
client, _store = _client(tmp_path)
current = {
"mining_schedule_enabled": False,
"mining_schedule_weekday": 4,
"mining_budget_profile": "balanced",
}
saved = []
monkeypatch.setattr(
"app.api.mining.preferences.get_mining_schedule",
lambda: dict(current),
)
def set_schedule(enabled, weekday, profile):
saved.append((enabled, weekday, profile))
return {
"mining_schedule_enabled": enabled,
"mining_schedule_weekday": weekday,
"mining_budget_profile": profile,
}
monkeypatch.setattr("app.api.mining.preferences.set_mining_schedule", set_schedule)
response = client.patch(
"/api/backtest/mining/config",
json={"mining_schedule_enabled": True},
)
assert response.status_code == 200
assert saved == [(True, 4, "balanced")]
assert client.patch("/api/backtest/mining/config", json={}).status_code == 400
+679
View File
@@ -0,0 +1,679 @@
from __future__ import annotations
import json
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from types import SimpleNamespace
import polars as pl
import pytest
from app.backtest.candidates import CandidateStore, CandidateValidationError
from app.backtest.mining import compute_candidate_signature
from app.services.mining_candidates import (
MiningCandidateService,
_published_strategy_id,
)
from app.services.mining_jobs import MiningRunStore
from app.strategy import config as strategy_config
from app.strategy.engine import StrategyEngine
class _StrategyEngine:
def __init__(self) -> None:
self.strategies = {
"existing_daily": SimpleNamespace(
id="existing_daily",
execution_backend="matrix_native",
meta={
"id": "existing_daily",
"research_only": False,
"timeframes": ["1d"],
"asset_types": ["stock"],
},
)
}
def get(self, strategy_id: str):
if strategy_id not in self.strategies:
raise KeyError(strategy_id)
return self.strategies[strategy_id]
def list_strategies(self):
return [strategy.meta for strategy in self.strategies.values()]
def _factor_definition() -> dict:
return {
"kind": "factor_rank",
"factor_names": ["turnover_rate", "rsi_14"],
"scoring": {"turnover_rate": 1.0, "rsi_14": 2.0},
"directions": {"turnover_rate": "high", "rsi_14": "low"},
}
def _create_run(
tmp_path,
*,
definition: dict | None = None,
status: str = "succeeded",
run_id: str = "mining-run",
) -> tuple[MiningRunStore, str, str]:
definition = definition or _factor_definition()
signature = compute_candidate_signature(definition)
store = MiningRunStore(tmp_path)
store.create(
{
"factor_names": ["turnover_rate", "rsi_14"],
"strategy_ids": ["existing_daily", "ma_golden_cross"],
"asset_type": "stock",
"start": "2025-01-01",
"end": "2026-01-09",
"budget_profile": "exploratory",
"commission_pct": 0.0002,
"stamp_tax_pct": 0.0005,
"slippage_bps": 5.0,
},
{"generation": "test"},
run_id=run_id,
)
frame = pl.DataFrame({
"signature": [signature],
"name": ["因子组合候选"],
"kind": [
"existing_strategy"
if definition["kind"] == "existing_strategy"
else "factor_combination"
],
"factor_names_json": [json.dumps(definition.get("factor_names", []))],
"strategy_id": [definition.get("strategy_id")],
"definition_json": [json.dumps(definition, sort_keys=True)],
"regime_state": ["overall"],
"score": [0.9],
"oos_return": [0.08],
"oos_sharpe": [0.9],
"oos_max_drawdown": [-0.12],
"oos_positive_fold_ratio": [1.0],
"oos_n_trades": [70],
"confidence": ["standard"],
"valid_folds": [3],
"skipped_folds": [0],
"promoted_candidate_id": [None],
"published_strategy_id": [None],
}, schema_overrides={
"strategy_id": pl.String,
"promoted_candidate_id": pl.String,
"published_strategy_id": pl.String,
})
frame.write_parquet(store.artifact_path(run_id, "candidates"))
store.register_artifact(run_id, "candidates")
store.write_summary(run_id, {
"data_as_of": "2026-01-09",
"algorithm_version": "mining-v1",
"methodology_version": "factor_v2",
})
if status != "queued":
store.transition_status(run_id, "running")
store.transition_status(run_id, status)
return store, run_id, signature
def _service(tmp_path, store: MiningRunStore) -> MiningCandidateService:
return MiningCandidateService(
tmp_path,
store,
CandidateStore(tmp_path),
_StrategyEngine(),
strategy_cache_invalidator=lambda _data_dir: None,
)
def _real_service(
tmp_path,
store: MiningRunStore,
*,
cache_invalidator=None,
monitor_invalidator=None,
) -> tuple[MiningCandidateService, StrategyEngine]:
builtin_dir = Path(__file__).resolve().parents[1] / "app" / "strategy" / "builtin"
custom_dir = tmp_path / "strategies" / "custom"
engine = StrategyEngine(strategy_dirs=[builtin_dir, custom_dir])
service = MiningCandidateService(
tmp_path,
store,
CandidateStore(tmp_path),
engine,
strategy_cache_invalidator=cache_invalidator or (lambda _data_dir: None),
monitor_state_invalidator=monitor_invalidator,
)
return service, engine
def test_promote_rereads_artifact_and_repairs_backlink_idempotently(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
service = _service(tmp_path, store)
first = service.promote(run_id, signature)
second = service.promote(run_id, signature)
assert second == first
assert first["kind"] == "strategy"
assert first["source_id"].startswith("mined_factor_")
assert first["status"] == "pending"
assert first["config"]["origin_run_id"] == run_id
assert first["config"]["candidate_signature"] == signature
assert first["config"]["factor_names"] == ["turnover_rate", "rsi_14"]
assert first["config"]["directions"] == ["high", "low"]
assert first["config"]["weights"] == [1.0, 2.0]
assert first["metrics"]["oos_sharpe"] == pytest.approx(0.9)
persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True)
assert persisted["promoted_candidate_id"] == first["id"]
assert len(CandidateStore(tmp_path).list()) == 1
def test_promote_repairs_backlink_after_partial_failure(tmp_path, monkeypatch) -> None:
store, run_id, signature = _create_run(tmp_path)
service = _service(tmp_path, store)
original = service._write_backlink
calls = 0
def fail_once(*args, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
raise RuntimeError("injected backlink failure")
return original(*args, **kwargs)
monkeypatch.setattr(service, "_write_backlink", fail_once)
with pytest.raises(RuntimeError, match="injected"):
service.promote(run_id, signature)
created = CandidateStore(tmp_path).list()
assert len(created) == 1
recovered = service.promote(run_id, signature)
assert recovered["id"] == created[0]["id"]
persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True)
assert persisted["promoted_candidate_id"] == created[0]["id"]
assert len(CandidateStore(tmp_path).list()) == 1
def test_promote_rejects_non_successful_run(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path, status="queued")
with pytest.raises(ValueError, match="successful"):
_service(tmp_path, store).promote(run_id, signature)
assert CandidateStore(tmp_path).list() == []
def test_promote_rejects_tampered_definition_before_candidate_write(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
path = store.artifact_path(run_id, "candidates")
frame = pl.read_parquet(path).with_columns(
pl.lit(json.dumps({
"kind": "factor_rank",
"factor_names": ["turnover_rate"],
"scoring": {"turnover_rate": 1.0},
"directions": {"turnover_rate": "high"},
})).alias("definition_json")
)
frame.write_parquet(path)
with pytest.raises(ValueError, match=r"signature|definition"):
_service(tmp_path, store).promote(run_id, signature)
assert CandidateStore(tmp_path).list() == []
def test_promote_rejects_factor_not_selected_in_origin_request(tmp_path) -> None:
definition = {
"kind": "factor_rank",
"factor_names": ["momentum_20d"],
"scoring": {"momentum_20d": 1.0},
"directions": {"momentum_20d": "high"},
}
store, run_id, signature = _create_run(tmp_path, definition=definition)
with pytest.raises(ValueError, match="origin request"):
_service(tmp_path, store).promote(run_id, signature)
def test_promote_existing_strategy_revalidates_current_engine_contract(tmp_path) -> None:
definition = {"kind": "existing_strategy", "strategy_id": "existing_daily"}
store, run_id, signature = _create_run(tmp_path, definition=definition)
promoted = _service(tmp_path, store).promote(run_id, signature)
assert promoted["source_id"] == "existing_daily"
assert promoted["config"]["strategy_id"] == "existing_daily"
def test_publish_existing_strategy_returns_verified_id_and_repairs_backlink(tmp_path) -> None:
definition = {"kind": "existing_strategy", "strategy_id": "existing_daily"}
store, run_id, signature = _create_run(tmp_path, definition=definition)
service = _service(tmp_path, store)
first = service.publish(run_id, signature)
second = service.publish(run_id, signature)
with pytest.raises(TypeError):
service.publish(run_id, signature, "existing_daily")
assert first == second == {"ok": True, "strategy_id": "existing_daily"}
persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True)
assert persisted["published_strategy_id"] == "existing_daily"
assert CandidateStore(tmp_path).list() == []
def test_promote_requires_unique_artifact_signature(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
path = store.artifact_path(run_id, "candidates")
frame = pl.read_parquet(path)
pl.concat([frame, frame]).write_parquet(path)
with pytest.raises(ValueError, match="duplicate"):
_service(tmp_path, store).promote(run_id, signature)
def test_promote_concurrent_calls_create_one_authoritative_candidate(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
service = _service(tmp_path, store)
with ThreadPoolExecutor(max_workers=8) as executor:
items = list(executor.map(
lambda _index: service.promote(run_id, signature),
range(16),
))
assert len({item["id"] for item in items}) == 1
assert len(CandidateStore(tmp_path).list()) == 1
def test_promote_rejects_artifact_conflicting_with_existing_store_record(
tmp_path,
) -> None:
store, run_id, signature = _create_run(tmp_path)
service = _service(tmp_path, store)
original = service.promote(run_id, signature)
path = store.artifact_path(run_id, "candidates")
pl.read_parquet(path).with_columns(
pl.lit(1.1).alias("oos_sharpe"),
pl.lit(None, dtype=pl.String).alias("promoted_candidate_id"),
).write_parquet(path)
with pytest.raises(CandidateValidationError, match="冲突"):
service.promote(run_id, signature)
persisted = pl.read_parquet(path).row(0, named=True)
assert persisted["promoted_candidate_id"] is None
stored = CandidateStore(tmp_path).list()[0]
assert stored == original
assert stored["metrics"]["oos_sharpe"] == pytest.approx(0.9)
@pytest.mark.parametrize(
("definition", "message"),
[
(
{
"kind": "factor_rank",
"factor_names": ["turnover_rate"],
"scoring": {"turnover_rate": 0.0},
"directions": {"turnover_rate": "high"},
},
"positive",
),
(
{
"kind": "factor_rank",
"factor_names": ["turnover_rate"],
"scoring": {"turnover_rate": float("inf")},
"directions": {"turnover_rate": "high"},
},
"finite",
),
(
{
"kind": "factor_rank",
"factor_names": ["turnover_rate"],
"scoring": {"turnover_rate": 1.0},
"directions": {"turnover_rate": "sideways"},
},
"directions",
),
(
{
"kind": "factor_rank",
"factor_names": ["not_a_factor"],
"scoring": {"not_a_factor": 1.0},
"directions": {"not_a_factor": "high"},
},
"unknown factors",
),
(
{
"kind": "factor_rank",
"factor_names": [
"momentum_5d",
"momentum_10d",
"momentum_20d",
"momentum_30d",
"momentum_60d",
],
"scoring": {
"momentum_5d": 1.0,
"momentum_10d": 1.0,
"momentum_20d": 1.0,
"momentum_30d": 1.0,
"momentum_60d": 1.0,
},
"directions": {
"momentum_5d": "high",
"momentum_10d": "high",
"momentum_20d": "high",
"momentum_30d": "high",
"momentum_60d": "high",
},
},
"1 to 4",
),
],
)
def test_promote_rejects_invalid_factor_contract(tmp_path, definition, message) -> None:
store, run_id, signature = _create_run(tmp_path)
path = store.artifact_path(run_id, "candidates")
frame = pl.read_parquet(path).with_columns(
pl.lit(signature).alias("signature"),
pl.lit(json.dumps(definition)).alias("definition_json"),
pl.lit(json.dumps(definition["factor_names"])).alias("factor_names_json"),
)
frame.write_parquet(path)
with pytest.raises(ValueError, match=message):
_service(tmp_path, store).promote(run_id, signature)
@pytest.mark.parametrize("column", ["oos_sharpe", "oos_return", "score"])
def test_promote_rejects_nonfinite_artifact_values(tmp_path, column) -> None:
store, run_id, signature = _create_run(tmp_path)
path = store.artifact_path(run_id, "candidates")
pl.read_parquet(path).with_columns(pl.lit(float("nan")).alias(column)).write_parquet(path)
with pytest.raises(ValueError, match="finite"):
_service(tmp_path, store).promote(run_id, signature)
def test_promote_rejects_corrupt_or_missing_schema_artifact(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
path = store.artifact_path(run_id, "candidates")
path.write_bytes(b"not parquet")
with pytest.raises(RuntimeError, match="failed to read"):
_service(tmp_path, store).promote(run_id, signature)
path.unlink()
pl.DataFrame({"signature": [signature]}).write_parquet(path)
with pytest.raises(ValueError, match="schema"):
_service(tmp_path, store).promote(run_id, signature)
def test_publish_factor_discovers_public_strategy_and_repairs_runtime_state(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
invalidations: list[object] = []
service, engine = _real_service(
tmp_path,
store,
cache_invalidator=lambda data_dir: invalidations.append(data_dir),
monitor_invalidator=lambda: invalidations.append("monitor"),
)
strategy_config.save_override(
tmp_path,
"factor_rank_research",
{"params": {"entry_score": 99.0}},
)
result = service.publish(run_id, signature)
repeated = service.publish(run_id, signature)
assert repeated == result
strategy = engine.get(result["strategy_id"])
assert strategy.source == "custom"
assert strategy.execution_backend == "matrix_native"
assert strategy.meta["origin_run_id"] == run_id
assert strategy.meta["candidate_signature"] == signature
assert strategy.meta["research_only"] is False
assert strategy.meta["asset_types"] == ["stock"]
assert strategy.matrix_strategy._scoring == {"turnover_rate": 1.0, "rsi_14": 2.0}
assert strategy.matrix_strategy._directions == {
"turnover_rate": "high",
"rsi_14": "low",
}
assert result["strategy_id"] in {
meta["id"] for meta in engine.list_strategies()
}
assert strategy_config.load_override(
tmp_path, "factor_rank_research"
) == {"params": {"entry_score": 99.0}}
assert not (tmp_path / "user_data" / "strategy_overrides" / f"{result['strategy_id']}.json").exists()
persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True)
assert persisted["published_strategy_id"] == result["strategy_id"]
assert invalidations == [tmp_path, "monitor", tmp_path, "monitor"]
def test_publish_factor_repairs_backlink_after_source_was_verified(
tmp_path,
monkeypatch,
) -> None:
store, run_id, signature = _create_run(tmp_path)
service, _engine = _real_service(tmp_path, store)
original = service._write_backlink
calls = 0
def fail_once(*args, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
raise RuntimeError("injected publication backlink failure")
return original(*args, **kwargs)
monkeypatch.setattr(service, "_write_backlink", fail_once)
with pytest.raises(RuntimeError, match="injected publication"):
service.publish(run_id, signature)
result = service.publish(run_id, signature)
strategy_path = tmp_path / "strategies" / "custom" / f"{result['strategy_id']}.py"
assert strategy_path.is_file()
persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True)
assert persisted["published_strategy_id"] == result["strategy_id"]
def test_publish_factor_uses_server_run_scoped_id_and_refuses_collision(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
service, _engine = _real_service(tmp_path, store)
expected_id = _published_strategy_id(run_id, signature)
with pytest.raises(TypeError):
service.publish(run_id, signature, "mined_factor_caller_selected")
target = tmp_path / "strategies" / "custom" / f"{expected_id}.py"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("user owned", encoding="utf-8")
with pytest.raises(ValueError, match="collision"):
service.publish(run_id, signature)
assert target.read_text(encoding="utf-8") == "user owned"
def test_publish_factor_same_signature_in_different_runs_has_independent_ids(
tmp_path,
) -> None:
store, first_run_id, signature = _create_run(tmp_path, run_id="mining-run-one")
_other_store, second_run_id, second_signature = _create_run(
tmp_path,
run_id="mining-run-two",
)
service, engine = _real_service(tmp_path, store)
first = service.publish(first_run_id, signature)
second = service.publish(second_run_id, second_signature)
assert signature == second_signature
assert first["strategy_id"] == _published_strategy_id(first_run_id, signature)
assert second["strategy_id"] == _published_strategy_id(
second_run_id,
second_signature,
)
assert first["strategy_id"] != second["strategy_id"]
assert engine.has(first["strategy_id"])
assert engine.has(second["strategy_id"])
def test_publish_factor_rejects_inconsistent_backlink(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
path = store.artifact_path(run_id, "candidates")
pl.read_parquet(path).with_columns(
pl.lit("mined_factor_other").alias("published_strategy_id")
).write_parquet(path)
service, engine = _real_service(tmp_path, store)
with pytest.raises(ValueError, match="backlink is inconsistent"):
service.publish(run_id, signature)
expected_id = _published_strategy_id(run_id, signature)
assert not engine.has(expected_id)
assert not (tmp_path / "strategies" / "custom" / f"{expected_id}.py").exists()
def test_publish_factor_rolls_back_file_and_skips_invalidations_on_reload_failure(
tmp_path,
monkeypatch,
) -> None:
store, run_id, signature = _create_run(tmp_path)
invalidations: list[str] = []
service, engine = _real_service(
tmp_path,
store,
cache_invalidator=lambda _data_dir: invalidations.append("cache"),
monitor_invalidator=lambda: invalidations.append("monitor"),
)
real_reload = engine.reload
calls = 0
def fail_once() -> None:
nonlocal calls
calls += 1
if calls == 1:
raise ValueError("injected reload failure")
real_reload()
monkeypatch.setattr(engine, "reload", fail_once)
expected_id = _published_strategy_id(run_id, signature)
with pytest.raises(RuntimeError, match="injected reload failure"):
service.publish(run_id, signature)
assert not (tmp_path / "strategies" / "custom" / f"{expected_id}.py").exists()
assert not engine.has(expected_id)
assert invalidations == []
persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True)
assert persisted["published_strategy_id"] is None
def test_publish_factor_rolls_back_on_runtime_invalidation_failure(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
invalidations: list[str] = []
def fail_monitor() -> None:
invalidations.append("monitor")
raise ValueError("injected monitor invalidation failure")
service, engine = _real_service(
tmp_path,
store,
cache_invalidator=lambda _data_dir: invalidations.append("cache"),
monitor_invalidator=fail_monitor,
)
expected_id = _published_strategy_id(run_id, signature)
with pytest.raises(RuntimeError, match="injected monitor invalidation failure"):
service.publish(run_id, signature)
assert invalidations == ["cache", "monitor"]
assert not (tmp_path / "strategies" / "custom" / f"{expected_id}.py").exists()
assert not engine.has(expected_id)
persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True)
assert persisted["published_strategy_id"] is None
def test_publish_existing_does_not_reload_or_invalidate(tmp_path) -> None:
definition = {"kind": "existing_strategy", "strategy_id": "ma_golden_cross"}
store, run_id, signature = _create_run(tmp_path, definition=definition)
invalidations: list[str] = []
service, engine = _real_service(
tmp_path,
store,
cache_invalidator=lambda _data_dir: invalidations.append("cache"),
monitor_invalidator=lambda: invalidations.append("monitor"),
)
original = engine.get("ma_golden_cross")
result = service.publish(run_id, signature)
assert result == {"ok": True, "strategy_id": "ma_golden_cross"}
assert engine.get("ma_golden_cross") is original
assert invalidations == []
def _rewrite_candidate_metric(
store: MiningRunStore,
run_id: str,
column: str,
value,
) -> None:
path = store.artifact_path(run_id, "candidates")
frame = pl.read_parquet(path)
frame.with_columns(
pl.lit(value, dtype=frame.schema[column]).alias(column),
).write_parquet(path)
def test_publish_rejects_low_confidence_exploratory_candidate(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
_rewrite_candidate_metric(store, run_id, "confidence", "low")
service = _service(tmp_path, store)
with pytest.raises(ValueError, match="exploratory results can only be saved"):
service.publish(run_id, signature)
def test_publish_rejects_candidate_below_evidence_gate(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
_rewrite_candidate_metric(store, run_id, "oos_sharpe", 0.2)
service = _service(tmp_path, store)
with pytest.raises(ValueError, match=r"OOS Sharpe of at least 0.5"):
service.publish(run_id, signature)
def test_publish_rejects_candidate_with_insufficient_folds(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
_rewrite_candidate_metric(store, run_id, "valid_folds", 1)
service = _service(tmp_path, store)
with pytest.raises(ValueError, match="at least 2 valid outer folds"):
service.publish(run_id, signature)
def test_publish_rejects_candidate_missing_required_metrics(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
_rewrite_candidate_metric(store, run_id, "oos_n_trades", None)
service = _service(tmp_path, store)
with pytest.raises(ValueError, match="does not meet the promotion gate"):
service.publish(run_id, signature)
def test_promote_still_allowed_for_gated_candidates(tmp_path) -> None:
store, run_id, signature = _create_run(tmp_path)
_rewrite_candidate_metric(store, run_id, "confidence", "low")
_rewrite_candidate_metric(store, run_id, "oos_sharpe", 0.1)
service = _service(tmp_path, store)
result = service.promote(run_id, signature)
assert result["status"] == "pending"
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
import json
import threading
from datetime import date
from pathlib import Path
import pytest
from app.services.mining_jobs import (
ACTIVE_RUN_STATUSES,
MAX_EVENT_PAYLOAD_BYTES,
SUCCESS_RUN_STATUSES,
InvalidMiningStatusTransitionError,
MiningRunStore,
MiningRunValidationError,
canonicalize_request,
compute_run_signature,
)
def _store(tmp_path: Path) -> MiningRunStore:
return MiningRunStore(tmp_path)
def test_create_and_atomic_summary_reads(tmp_path: Path) -> None:
store = _store(tmp_path)
manifest = store.create(
{"symbols": ["000001.SZ"], "start": date(2025, 1, 1)},
{"daily_generation": 7},
run_id="run_atomic",
)
run_dir = tmp_path / "research" / "mining" / "runs" / "run_atomic"
assert manifest["status"] == "queued"
assert manifest["request"]["start"] == "2025-01-01"
assert json.loads((run_dir / "manifest.json").read_text(encoding="utf-8")) == manifest
assert store.read_summary("run_atomic") == {}
failures: list[Exception] = []
stop = threading.Event()
def reader() -> None:
while not stop.is_set():
try:
value = store.read_summary("run_atomic")
assert isinstance(value.get("iteration"), int)
except Exception as exc:
failures.append(exc)
return
store.write_summary("run_atomic", {"iteration": -1})
thread = threading.Thread(target=reader)
thread.start()
for iteration in range(50):
store.write_summary("run_atomic", {"iteration": iteration})
stop.set()
thread.join(timeout=1)
assert failures == []
assert store.read_summary("run_atomic") == {"iteration": 49}
assert not list(run_dir.glob("*.tmp"))
assert not list(run_dir.glob(".*.tmp"))
def test_signature_is_canonical_and_covers_request_dimensions() -> None:
first = {
"symbols": ["000001.SZ", "600000.SH"],
"window": {"start": "2025-01-01", "end": "2025-06-30"},
"budget": 100,
}
reordered = {
"budget": 100,
"window": {"end": "2025-06-30", "start": "2025-01-01"},
"symbols": ["000001.SZ", "600000.SH"],
}
assert canonicalize_request(first) == canonicalize_request(reordered)
signature = compute_run_signature(first, {"daily": "v7", "enriched": "v2"})
assert signature == compute_run_signature(reordered, {"enriched": "v2", "daily": "v7"})
assert len(signature) == 64
assert signature != compute_run_signature(
{**first, "budget": 101}, {"daily": "v7", "enriched": "v2"}
)
assert signature != compute_run_signature(first, {"daily": "v8", "enriched": "v2"})
assert signature != compute_run_signature(
{**first, "symbols": list(reversed(first["symbols"]))},
{"daily": "v7", "enriched": "v2"},
)
def test_events_are_bounded_monotonic_and_support_after_id(tmp_path: Path) -> None:
store = _store(tmp_path)
store.create({}, "data-v1", run_id="event_run")
for index in range(300):
event = store.append_event("event_run", "progress", {"step": index})
assert event["id"] == index + 1
retained = store.read_events("event_run")
assert len(retained) == 256
assert [event["id"] for event in retained] == list(range(45, 301))
assert [event["id"] for event in store.read_events("event_run", after_id=295)] == list(
range(296, 301)
)
with pytest.raises(MiningRunValidationError, match="payload exceeds"):
store.append_event(
"event_run",
"result",
{"large_result": "x" * (MAX_EVENT_PAYLOAD_BYTES + 1)},
)
def test_status_transitions_summary_and_artifact_registration(tmp_path: Path) -> None:
store = _store(tmp_path)
store.create({"budget": 10}, "data-v1", run_id="lifecycle")
running = store.transition_status("lifecycle", "running")
assert running["started_at"] is not None
cancelling = store.transition_status("lifecycle", "cancelling")
assert cancelling["cancellation_requested_at"] is not None
cancelled = store.transition_status("lifecycle", "cancelled")
assert cancelled["finished_at"] is not None
with pytest.raises(InvalidMiningStatusTransitionError):
store.transition_status("lifecycle", "running")
store.create({}, "data-v1", run_id="artifacts")
artifact = store.artifact_path("artifacts", "factors")
manifest = store.register_artifact("artifacts", "factors", artifact)
assert artifact.name == "factors.parquet"
assert manifest["artifacts"] == {"factors": "factors.parquet"}
assert store.write_summary("artifacts", {"candidate_count": 3}) == {"candidate_count": 3}
with pytest.raises(MiningRunValidationError, match="escapes"):
store.register_artifact("artifacts", "folds", "../other/folds.parquet")
def test_historical_manifest_defaults_and_startup_recovery(tmp_path: Path) -> None:
store = _store(tmp_path)
runs_root = store.runs_root
old_dir = runs_root / "old_running"
old_dir.mkdir()
(old_dir / "manifest.json").write_text(
json.dumps(
{
"run_id": "old_running",
"status": "running",
"request": {"budget": 10},
"data_fingerprint": "v1",
"created_at": "2025-01-01T00:00:00+00:00",
"artifacts": {
"factors": "factors.parquet",
"folds": "../escaped/folds.parquet",
"unknown": "unknown.parquet",
},
}
),
encoding="utf-8",
)
store.create({}, "v1", run_id="was_cancelling")
store.transition_status("was_cancelling", "cancelling")
store.create({}, "v1", run_id="still_queued")
historical = store.get("old_running")
assert historical is not None
assert historical["artifacts"] == {"factors": "factors.parquet"}
assert historical["finished_at"] is None
assert historical["run_signature"] == compute_run_signature({"budget": 10}, "v1")
assert store.recover_interrupted() == 3
assert store.get("old_running")["status"] == "interrupted" # type: ignore[index]
assert store.get("was_cancelling")["status"] == "interrupted" # type: ignore[index]
assert store.get("still_queued")["status"] == "interrupted" # type: ignore[index]
assert store.recover_interrupted() == 0
def test_run_ids_and_paths_are_restricted_to_runs_root(tmp_path: Path) -> None:
store = _store(tmp_path)
for run_id in ["", ".", "..", "../escape", "a/b", "a\\b", "with space"]:
with pytest.raises(MiningRunValidationError):
store.get(run_id)
with pytest.raises(MiningRunValidationError):
store.create({}, "v1", run_id="../escape")
with pytest.raises(MiningRunValidationError):
store.create({}, "v1", run_id="")
assert not (tmp_path / "research" / "mining" / "escape").exists()
def test_find_by_signature_can_filter_active_and_success_runs(tmp_path: Path) -> None:
store = _store(tmp_path)
queued = store.create({"budget": 1}, "v1", run_id="queued_match")
store.create({"budget": 1}, "v1", run_id="failed_match")
store.transition_status("failed_match", "failed", error="failed")
store.create({"budget": 2}, "v1", run_id="success_match")
store.transition_status("success_match", "running")
store.transition_status("success_match", "succeeded_with_budget_exhausted")
assert (
store.find_by_signature(queued["run_signature"], statuses=ACTIVE_RUN_STATUSES)["run_id"]
== "queued_match"
) # type: ignore[index]
assert store.find_by_signature(queued["run_signature"], statuses=SUCCESS_RUN_STATUSES) is None
success = store.get("success_match")
assert success is not None
assert (
store.find_by_signature(success["run_signature"], statuses=SUCCESS_RUN_STATUSES)["run_id"]
== "success_match"
) # type: ignore[index]
def test_list_runs_is_bounded_sorted_and_filters_status(tmp_path: Path) -> None:
store = _store(tmp_path)
store.create({"budget": 1}, "v1", run_id="first")
store.create({"budget": 2}, "v1", run_id="second")
store.transition_status("second", "running")
store.transition_status("second", "succeeded")
runs = store.list_runs(limit=1)
assert [run["run_id"] for run in runs] == ["second"]
assert [run["run_id"] for run in store.list_runs(statuses={"queued"})] == ["first"]
with pytest.raises(MiningRunValidationError, match="limit"):
store.list_runs(limit=0)
with pytest.raises(MiningRunValidationError, match="statuses"):
store.list_runs(statuses={"unknown"}) # type: ignore[arg-type]
def test_concurrent_event_appends_have_unique_contiguous_ids(tmp_path: Path) -> None:
first_store = _store(tmp_path)
second_store = _store(tmp_path)
first_store.create({}, "v1", run_id="concurrent")
barrier = threading.Barrier(5)
failures: list[Exception] = []
def append_batch(store: MiningRunStore, worker: int) -> None:
try:
barrier.wait()
for index in range(30):
store.append_event("concurrent", "progress", {"worker": worker, "index": index})
except Exception as exc:
failures.append(exc)
threads = [
threading.Thread(
target=append_batch,
args=(first_store if worker % 2 == 0 else second_store, worker),
)
for worker in range(4)
]
for thread in threads:
thread.start()
barrier.wait()
for thread in threads:
thread.join(timeout=3)
assert all(not thread.is_alive() for thread in threads)
assert failures == []
ids = [event["id"] for event in first_store.read_events("concurrent")]
assert ids == list(range(1, 121))
+352
View File
@@ -0,0 +1,352 @@
from __future__ import annotations
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
import app.services.mining_manager as mining_manager_module
from app.services.heavy_job_limiter import HeavyJobLimiter
from app.services.mining_manager import MiningJobManager
def _task_factory(kind: str, data_dir: Path, payload: dict[str, Any]) -> dict[str, Any]:
return {
"kind": kind,
"data_dir": str(data_dir),
"payload": payload,
}
def _wait_for_status(
manager: MiningJobManager,
run_id: str,
status: str,
*,
timeout: float = 2.0,
) -> dict[str, Any]:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
manifest = manager.store.get(run_id)
assert manifest is not None
if manifest["status"] == status:
return manifest
time.sleep(0.005)
pytest.fail(f"run {run_id} did not reach {status}")
@pytest.fixture
def isolated_limiter(monkeypatch: pytest.MonkeyPatch) -> HeavyJobLimiter:
limiter = HeavyJobLimiter(capacity=2, cancel_poll_interval=0.005)
monkeypatch.setattr(mining_manager_module, "shared_heavy_job_limiter", limiter)
return limiter
@pytest.fixture
def make_manager(
tmp_path: Path,
isolated_limiter: HeavyJobLimiter,
):
managers: list[MiningJobManager] = []
def factory(
runner: Callable[
[dict[str, Any], Callable[[dict[str, Any]], None], threading.Event],
dict[str, Any],
],
) -> MiningJobManager:
manager = MiningJobManager(
tmp_path,
worker_runner=runner,
task_factory=_task_factory,
)
managers.append(manager)
return manager
yield factory
for manager in managers:
manager.shutdown()
assert isolated_limiter.in_use == 0
def test_start_records_states_events_progress_and_worker_payload(
make_manager, tmp_path: Path
) -> None:
progress_recorded = threading.Event()
finish = threading.Event()
tasks: list[dict[str, Any]] = []
progress = {"phase": "screen", "done": 1, "total": 2}
result = {"status": "succeeded", "candidate_count": 3, "elapsed_ms": 12.5}
def runner(task, progress_cb, cancel_event):
tasks.append(task)
progress_cb(progress)
progress_recorded.set()
assert finish.wait(2)
assert not cancel_event.is_set()
return result
manager = make_manager(runner)
request = {"factor_names": ["momentum"], "budget_profile": "balanced"}
created = manager.start(request, {"daily": "v1"}, source="scheduled")
run_id = created["run_id"]
assert created["status"] == "queued"
assert progress_recorded.wait(1)
assert manager.store.read_summary(run_id) == {"progress": progress}
assert [event["type"] for event in manager.store.read_events(run_id)] == [
"queued",
"running",
"progress",
]
assert manager.store.read_events(run_id)[0]["payload"]["source"] == "scheduled"
assert tasks == [
{
"kind": "mining",
"data_dir": str(tmp_path.resolve()),
"payload": {
"run_id": run_id,
"request": request,
"data_fingerprint": {"daily": "v1"},
"source": "scheduled",
},
}
]
finish.set()
terminal = _wait_for_status(manager, run_id, "succeeded")
assert terminal["started_at"] is not None
assert terminal["finished_at"] is not None
assert manager.store.read_summary(run_id) == result
assert [event["type"] for event in manager.store.read_events(run_id)] == [
"queued",
"running",
"progress",
"succeeded",
]
def test_start_accepts_valid_persistent_run_id(make_manager) -> None:
def runner(task, progress_cb, cancel_event):
return {"status": "succeeded"}
manager = make_manager(runner)
created = manager.start(
{"factor_names": ["value"]},
"data-v1",
run_id="weekly_claim_2026_33",
)
assert created["run_id"] == "weekly_claim_2026_33"
terminal = _wait_for_status(manager, created["run_id"], "succeeded")
assert terminal["run_id"] == "weekly_claim_2026_33"
def test_duplicate_persistent_run_id_reuses_existing_without_starting_runner(
make_manager,
) -> None:
runner_called = threading.Event()
def runner(task, progress_cb, cancel_event):
runner_called.set()
return {"status": "succeeded"}
manager = make_manager(runner)
existing = manager.store.create(
{"factor_names": ["value"]},
"data-v1",
run_id="weekly_claim_2026_33",
)
reused = manager.start(
{"factor_names": ["value"]},
"data-v1",
run_id="weekly_claim_2026_33",
)
assert reused == existing
assert not runner_called.wait(0.05)
def test_start_reuses_active_and_success_but_force_creates_new_run(make_manager) -> None:
started = threading.Event()
release = threading.Event()
tasks: list[dict[str, Any]] = []
def runner(task, progress_cb, cancel_event):
tasks.append(task)
started.set()
assert release.wait(2)
return {"status": "succeeded", "candidate_count": 1}
manager = make_manager(runner)
request = {"factor_names": ["value"]}
first = manager.start(request, "data-v1")
assert started.wait(1)
active_reuse = manager.start(request, "data-v1")
assert active_reuse["run_id"] == first["run_id"]
assert len(tasks) == 1
release.set()
_wait_for_status(manager, first["run_id"], "succeeded")
success_reuse = manager.start(request, "data-v1")
assert success_reuse["run_id"] == first["run_id"]
assert len(tasks) == 1
forced = manager.start(request, "data-v1", force=True)
assert forced["run_id"] != first["run_id"]
_wait_for_status(manager, forced["run_id"], "succeeded")
assert len(tasks) == 2
assert len(manager.store.list_runs()) == 2
def test_cancel_while_waiting_for_capacity_never_calls_runner(
make_manager,
isolated_limiter: HeavyJobLimiter,
) -> None:
runner_called = threading.Event()
def runner(task, progress_cb, cancel_event):
runner_called.set()
return {"status": "succeeded"}
assert isolated_limiter.acquire("mining", timeout=0)
try:
manager = make_manager(runner)
created = manager.start({"factor_names": ["quality"]}, "data-v1")
run_id = created["run_id"]
assert manager.store.get(run_id)["status"] == "queued" # type: ignore[index]
cancelling = manager.cancel(run_id)
assert cancelling["status"] == "cancelling"
_wait_for_status(manager, run_id, "cancelled")
assert not runner_called.is_set()
assert [event["type"] for event in manager.store.read_events(run_id)] == [
"queued",
"cancelling",
"cancelled",
]
finally:
isolated_limiter.release("mining")
def test_cancel_running_job_wins_over_worker_success(make_manager) -> None:
runner_started = threading.Event()
def runner(task, progress_cb, cancel_event):
runner_started.set()
assert cancel_event.wait(2)
return {"status": "succeeded", "candidate_count": 9}
manager = make_manager(runner)
created = manager.start({"factor_names": ["growth"]}, "data-v1")
run_id = created["run_id"]
assert runner_started.wait(1)
cancelling = manager.cancel(run_id)
assert cancelling["status"] == "cancelling"
_wait_for_status(manager, run_id, "cancelled")
event_types = [event["type"] for event in manager.store.read_events(run_id)]
assert event_types == ["queued", "running", "cancelling", "cancelled"]
assert manager.store.read_summary(run_id) == {}
def test_runner_exception_marks_failed_and_appends_error_event(make_manager) -> None:
def runner(task, progress_cb, cancel_event):
raise RuntimeError("mining exploded")
manager = make_manager(runner)
created = manager.start({"factor_names": ["size"]}, "data-v1")
run_id = created["run_id"]
failed = _wait_for_status(manager, run_id, "failed")
assert failed["error"] == "mining exploded"
events = manager.store.read_events(run_id)
assert [event["type"] for event in events] == ["queued", "running", "error"]
assert events[-1]["payload"] == {
"status": "failed",
"message": "mining exploded",
}
def test_non_dict_worker_result_is_rejected(make_manager) -> None:
def runner(task, progress_cb, cancel_event):
return ["full", "result"]
manager = make_manager(runner)
created = manager.start({"factor_names": ["liquidity"]}, "data-v1")
failed = _wait_for_status(manager, created["run_id"], "failed")
assert failed["error"] == "mining worker result must be a compact dict"
def test_budget_exhausted_result_uses_distinct_success_status(make_manager) -> None:
result = {
"status": "succeeded_with_budget_exhausted",
"candidate_count": 2,
"budget_exhausted": True,
}
def runner(task, progress_cb, cancel_event):
return result
manager = make_manager(runner)
created = manager.start({"factor_names": ["volatility"]}, "data-v1")
run_id = created["run_id"]
_wait_for_status(manager, run_id, "succeeded_with_budget_exhausted")
assert manager.store.read_summary(run_id) == result
assert manager.store.read_events(run_id)[-1]["type"] == ("succeeded_with_budget_exhausted")
def test_recover_interrupted_delegates_to_store(make_manager) -> None:
def runner(task, progress_cb, cancel_event):
return {"status": "succeeded"}
manager = make_manager(runner)
manager.store.create({}, "v1", run_id="running_before_restart")
manager.store.transition_status("running_before_restart", "running")
manager.store.create({}, "v1", run_id="cancelling_before_restart")
manager.store.transition_status("cancelling_before_restart", "cancelling")
manager.store.create({}, "v1", run_id="queued_before_restart")
assert manager.recover_interrupted() == 3
assert manager.store.get("running_before_restart")["status"] == "interrupted" # type: ignore[index]
assert manager.store.get("cancelling_before_restart")["status"] == "interrupted" # type: ignore[index]
assert manager.store.get("queued_before_restart")["status"] == "interrupted" # type: ignore[index]
def test_shutdown_sets_cancel_uses_bounded_join_and_keeps_history(
make_manager,
monkeypatch: pytest.MonkeyPatch,
) -> None:
runner_started = threading.Event()
release_runner = threading.Event()
def runner(task, progress_cb, cancel_event):
runner_started.set()
assert release_runner.wait(2)
return {"status": "succeeded"}
monkeypatch.setattr(mining_manager_module, "_SHUTDOWN_JOIN_SECONDS", 0.02)
manager = make_manager(runner)
created = manager.start({"factor_names": ["reversal"]}, "data-v1")
run_id = created["run_id"]
assert runner_started.wait(1)
started = time.monotonic()
manager.shutdown()
elapsed = time.monotonic() - started
assert elapsed < 0.2
assert manager.store.get(run_id)["status"] == "cancelling" # type: ignore[index]
release_runner.set()
_wait_for_status(manager, run_id, "cancelled")
assert manager.store.get(run_id) is not None
with pytest.raises(RuntimeError, match="shut down"):
manager.start({"factor_names": ["new"]}, "data-v1")
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
import multiprocessing
import os
from pathlib import Path
from queue import Empty
from app.services.mining_process_lock import (
MiningProcessLock,
MiningProcessLockError,
)
def _acquire_in_spawned_process(data_dir: str, result_queue) -> None:
lock = MiningProcessLock(Path(data_dir))
try:
lock.acquire()
except MiningProcessLockError as exc:
result_queue.put(("blocked", str(exc)))
return
try:
result_queue.put(("acquired", None))
finally:
lock.release()
def _spawn_lock_attempt(data_dir: Path) -> tuple[str, str | None]:
context = multiprocessing.get_context("spawn")
result_queue = context.Queue()
process = context.Process(
target=_acquire_in_spawned_process,
args=(str(data_dir), result_queue),
)
process.start()
process.join(timeout=10)
if process.is_alive():
process.terminate()
process.join(timeout=5)
raise AssertionError("spawned lock process did not exit")
assert process.exitcode == 0
try:
return result_queue.get(timeout=2)
except Empty as exc:
raise AssertionError("spawned lock process returned no result") from exc
finally:
result_queue.close()
result_queue.join_thread()
def test_process_lock_rejects_contention_and_allows_acquire_after_release(
tmp_path,
) -> None:
owner = MiningProcessLock(tmp_path)
owner.acquire()
blocked, message = _spawn_lock_attempt(tmp_path)
assert blocked == "blocked"
assert message is not None and "already owns mining" in message
owner.release()
acquired, message = _spawn_lock_attempt(tmp_path)
assert acquired == "acquired"
assert message is None
def test_process_lock_handle_is_not_inheritable_and_release_is_idempotent(
tmp_path,
) -> None:
lock = MiningProcessLock(tmp_path)
lock.acquire()
stream = vars(lock)["_stream"]
assert stream is not None
assert not os.get_inheritable(stream.fileno())
lock.release()
lock.release()
+427
View File
@@ -0,0 +1,427 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import polars as pl
import pytest
from app.jobs import daily_pipeline
from app.services import mining_schedule, preferences
from app.services.mining_jobs import MiningRunStore
class FakeRepo:
def __init__(self, data_dir: Path, *, latest: date = date(2026, 8, 14)) -> None:
self.store = SimpleNamespace(data_dir=data_dir)
self.latest = latest
self.generation = "generation-1"
def latest_enriched_date(self, asset_type: str = "stock") -> date | None:
assert asset_type == "stock"
return self.latest
def get_matrix_data_generation(self, asset_type: str = "stock") -> str:
assert asset_type == "stock"
return self.generation
def get_instruments_asset(self, asset_type: str = "stock") -> pl.DataFrame:
assert asset_type == "stock"
return pl.DataFrame({
"symbol": ["000001.SZ"],
"name": ["示例"],
"total_shares": [1_000_000.0],
"float_shares": [800_000.0],
})
class FakeManager:
def __init__(self, data_dir: Path) -> None:
self.store = MiningRunStore(data_dir)
self.calls: list[dict] = []
def start(self, request, fingerprint, *, force: bool, source: str, run_id: str):
call = {
"request": request,
"fingerprint": fingerprint,
"force": force,
"source": source,
"run_id": run_id,
}
self.calls.append(call)
manifest = self.store.create(request, fingerprint, run_id=run_id)
return {"run_id": manifest["run_id"]}
@pytest.fixture
def scheduled_state(tmp_path: Path, monkeypatch):
repo = FakeRepo(tmp_path)
manager = FakeManager(tmp_path)
state = SimpleNamespace(repo=repo, mining_manager=manager, strategy_engine=None)
monkeypatch.setattr(
preferences,
"get_mining_schedule",
lambda: {
"mining_schedule_enabled": True,
"mining_schedule_weekday": 4,
"mining_budget_profile": "balanced",
},
)
_write_prerequisites(tmp_path, repo.latest, days=1200)
return state
def _friday(week_offset: int = 0) -> datetime:
return datetime(2026, 8, 14, 16, tzinfo=ZoneInfo("Asia/Shanghai")) + timedelta(
weeks=week_offset
)
def _write_prerequisites(data_dir: Path, latest: date, *, days: int) -> None:
enriched = data_dir / "kline_daily_enriched"
trading_dates: list[date] = []
for offset in range(days):
day = latest - timedelta(days=offset)
if day.weekday() >= 5:
continue
trading_dates.append(day)
partition = enriched / f"date={day.isoformat()}"
partition.mkdir(parents=True, exist_ok=True)
(partition / "part.parquet").write_bytes(b"enriched")
regime = data_dir / "regime_history" / "part.parquet"
regime.parent.mkdir(parents=True, exist_ok=True)
pl.DataFrame({
"date": sorted(trading_dates),
"state": ["range"] * len(trading_dates),
}).write_parquet(regime)
def test_beijing_date_and_iso_week_use_china_timezone():
utc = ZoneInfo("UTC")
instant = datetime(2026, 8, 13, 16, 30, tzinfo=utc)
assert mining_schedule.beijing_date(instant) == date(2026, 8, 14)
assert mining_schedule.iso_week(date(2027, 1, 1)) == (2026, 53)
def test_fingerprint_retries_generation_change_and_returns_stable_token(
tmp_path,
monkeypatch,
) -> None:
repo = FakeRepo(tmp_path)
state = SimpleNamespace(strategy_engine=None)
generations = iter([
"generation-1",
"generation-2",
"generation-2",
"generation-2",
])
monkeypatch.setattr(repo, "get_matrix_data_generation", lambda _asset: next(generations))
fingerprint = mining_schedule.build_data_fingerprint(
repo,
state,
{"asset_type": "stock", "strategy_ids": []},
)
assert fingerprint["generation"] == "generation-2"
def test_implementation_metadata_is_recursive_content_based_and_root_independent(
tmp_path,
) -> None:
roots = [tmp_path / "first" / "app", tmp_path / "second" / "app"]
for root in roots:
nested = root / "backtest"
nested.mkdir(parents=True)
(root / "main.py").write_text("VALUE = 1\n", encoding="utf-8")
(nested / "runtime.py").write_text("RESULT = 1\n", encoding="utf-8")
(nested / "ignored.txt").write_text("ignored\n", encoding="utf-8")
first = mining_schedule._implementation_metadata(roots[0])
second = mining_schedule._implementation_metadata(roots[1])
(roots[1] / "backtest" / "runtime.py").write_text("RESULT = 2\n", encoding="utf-8")
changed = mining_schedule._implementation_metadata(roots[1])
assert first == second
assert first["file_count"] == 2
assert str(tmp_path) not in str(first)
assert first["digest"] != changed["digest"]
def test_fingerprint_covers_result_implementation_digest(
tmp_path,
monkeypatch,
) -> None:
repo = FakeRepo(tmp_path)
state = SimpleNamespace(strategy_engine=None)
first = mining_schedule.build_data_fingerprint(
repo,
state,
{"asset_type": "stock", "strategy_ids": []},
)
monkeypatch.setattr(
mining_schedule,
"_implementation_metadata",
lambda _root: {"file_count": 1, "digest": "changed-runtime"},
)
second = mining_schedule.build_data_fingerprint(
repo,
state,
{"asset_type": "stock", "strategy_ids": []},
)
assert first["implementation"] != second["implementation"]
assert first["digest"] != second["digest"]
def test_selected_strategy_metadata_changes_with_same_size_source_edit(tmp_path) -> None:
source = tmp_path / "strategies" / "custom" / "demo.py"
source.parent.mkdir(parents=True)
source.write_text("VALUE = 1\n", encoding="utf-8")
strategy = SimpleNamespace(execution_backend="matrix_native", file_path=source)
state = SimpleNamespace(
strategy_engine=SimpleNamespace(get=lambda _strategy_id: strategy)
)
first = mining_schedule._selected_strategy_metadata(
state,
["demo"],
tmp_path,
)
source.write_text("VALUE = 2\n", encoding="utf-8")
second = mining_schedule._selected_strategy_metadata(
state,
["demo"],
tmp_path,
)
assert first[0]["source"]["size"] == second[0]["source"]["size"]
assert first[0]["source"]["sha256"] != second[0]["source"]["sha256"]
assert first[0]["source_tree"]["digest"] != second[0]["source_tree"]["digest"]
def test_fingerprint_rejects_continuously_changing_generation(
tmp_path,
monkeypatch,
) -> None:
repo = FakeRepo(tmp_path)
state = SimpleNamespace(strategy_engine=None)
generations = iter(["a", "b", "c", "d"])
monkeypatch.setattr(repo, "get_matrix_data_generation", lambda _asset: next(generations))
with pytest.raises(ValueError, match="changed"):
mining_schedule.build_data_fingerprint(
repo,
state,
{"asset_type": "stock", "strategy_ids": []},
)
def test_disabled_and_before_scheduled_weekday_do_not_enqueue(
scheduled_state,
monkeypatch,
):
state = scheduled_state
monkeypatch.setattr(
preferences,
"get_mining_schedule",
lambda: {
"mining_schedule_enabled": False,
"mining_schedule_weekday": 4,
"mining_budget_profile": "balanced",
},
)
assert mining_schedule.run_weekly_mining(state, now=_friday())["status"] == "disabled"
monkeypatch.setattr(
preferences,
"get_mining_schedule",
lambda: {
"mining_schedule_enabled": True,
"mining_schedule_weekday": 4,
"mining_budget_profile": "balanced",
},
)
thursday = _friday() - timedelta(days=1)
assert mining_schedule.run_weekly_mining(state, now=thursday)["status"] == "weekday_mismatch"
assert state.mining_manager.calls == []
def test_later_workday_catches_up_once_in_same_iso_week(scheduled_state, monkeypatch):
monkeypatch.setattr(
preferences,
"get_mining_schedule",
lambda: {
"mining_schedule_enabled": True,
"mining_schedule_weekday": 3,
"mining_budget_profile": "balanced",
},
)
first = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
second = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
assert first["status"] == "enqueued"
assert second == {"status": "already_claimed", "run_id": first["run_id"]}
assert len(scheduled_state.mining_manager.calls) == 1
def test_same_week_and_fingerprint_enqueue_once(scheduled_state):
first = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
second = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
assert first["status"] == "enqueued"
assert second == {"status": "already_claimed", "run_id": first["run_id"]}
assert len(scheduled_state.mining_manager.calls) == 1
call = scheduled_state.mining_manager.calls[0]
assert call["force"] is False
assert call["source"] == "scheduled"
assert call["run_id"] == first["run_id"]
assert call["run_id"] == call["fingerprint"]["source_claim"]
assert call["request"]["asset_type"] == "stock"
assert call["request"]["symbols"] is None
assert call["request"]["strategy_ids"] == []
assert call["request"]["require_regime"] is True
assert call["request"]["end"] == "2026-08-14"
assert len(call["request"]["factor_names"]) <= 48
def test_profile_change_cannot_bypass_same_week_claim(scheduled_state, monkeypatch):
first = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
scheduled_state.mining_manager.store.transition_status(
first["run_id"], "failed", error="worker failed"
)
monkeypatch.setattr(
preferences,
"get_mining_schedule",
lambda: {
"mining_schedule_enabled": True,
"mining_schedule_weekday": 4,
"mining_budget_profile": "strict",
},
)
second = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
assert second == {"status": "already_claimed", "run_id": first["run_id"]}
assert len(scheduled_state.mining_manager.calls) == 1
def test_new_week_creates_new_claim_but_same_week_metadata_change_does_not(
scheduled_state,
):
manager = scheduled_state.mining_manager
first = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
next_week = mining_schedule.run_weekly_mining(scheduled_state, now=_friday(1))
latest_file = (
scheduled_state.repo.store.data_dir
/ "kline_daily_enriched"
/ "date=2026-08-14"
/ "part.parquet"
)
latest_file.write_bytes(b"changed-enriched-metadata")
changed = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
assert first["run_id"] != next_week["run_id"]
assert changed == {"status": "already_claimed", "run_id": first["run_id"]}
assert len(manager.calls) == 2
def test_missing_regime_records_visible_skipped_prerequisite(scheduled_state):
regime = scheduled_state.repo.store.data_dir / "regime_history" / "part.parquet"
regime.unlink()
result = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
manifest = scheduled_state.mining_manager.store.get(result["run_id"])
assert result["status"] == "skipped_prerequisite"
assert manifest is not None
assert manifest["status"] == "skipped_prerequisite"
assert "regime" in manifest["error"]
assert scheduled_state.mining_manager.calls == []
def test_incomplete_regime_coverage_records_visible_skip(scheduled_state):
regime_path = (
scheduled_state.repo.store.data_dir
/ "regime_history"
/ "part.parquet"
)
history = pl.read_parquet(regime_path).sort("date")
history.filter(pl.col("date") != history["date"][-2]).write_parquet(regime_path)
result = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
manifest = scheduled_state.mining_manager.store.get(result["run_id"])
assert result["status"] == "skipped_prerequisite"
assert manifest is not None
assert "T-1" in manifest["error"]
assert scheduled_state.mining_manager.calls == []
def test_early_regime_gap_records_visible_skipped_prerequisite(scheduled_state):
data_dir = scheduled_state.repo.store.data_dir
regime_path = data_dir / "regime_history" / "part.parquet"
history = pl.read_parquet(regime_path).sort("date")
history.slice(1).write_parquet(regime_path)
result = mining_schedule.run_weekly_mining(scheduled_state, now=_friday())
manifest = scheduled_state.mining_manager.store.get(result["run_id"])
assert result["status"] == "skipped_prerequisite"
assert manifest is not None
assert "T-1" in manifest["error"]
assert scheduled_state.mining_manager.calls == []
def test_insufficient_data_records_visible_skipped_prerequisite(tmp_path, monkeypatch):
repo = FakeRepo(tmp_path)
manager = FakeManager(tmp_path)
state = SimpleNamespace(repo=repo, mining_manager=manager, strategy_engine=None)
monkeypatch.setattr(
preferences,
"get_mining_schedule",
lambda: {
"mining_schedule_enabled": True,
"mining_schedule_weekday": 4,
"mining_budget_profile": "strict",
},
)
_write_prerequisites(tmp_path, repo.latest, days=30)
result = mining_schedule.run_weekly_mining(state, now=_friday())
manifest = manager.store.get(result["run_id"])
assert result["status"] == "skipped_prerequisite"
assert manifest is not None
assert manifest["status"] == "skipped_prerequisite"
assert "insufficient" in manifest["error"]
assert manager.calls == []
def test_pipeline_failure_does_not_trigger_mining(monkeypatch):
mining_calls = []
monkeypatch.setattr(daily_pipeline, "_run_tracked", lambda *_args: False)
monkeypatch.setattr(
"app.services.mining_schedule.run_weekly_mining",
lambda state: mining_calls.append(state),
)
daily_pipeline._scheduled_pipeline_task(lambda: None)
assert mining_calls == []
def test_enqueue_failure_does_not_escape_successful_pipeline(monkeypatch):
monkeypatch.setattr(daily_pipeline, "_run_tracked", lambda *_args: True)
def fail_enqueue(_state):
raise RuntimeError("queue unavailable")
monkeypatch.setattr("app.services.mining_schedule.run_weekly_mining", fail_enqueue)
daily_pipeline._scheduled_pipeline_task(lambda: None)
+7 -1
View File
@@ -26,7 +26,13 @@ def _request(repo=None, capset=None):
def test_minute_range_returns_latest_sessions_with_previous_closes():
repo = MagicMock()
repo.resolve_asset_type.return_value = "stock"
repo.execute_one.return_value = ("浦发银行", 1.0, 1.0)
# _get_stock_info 走 instruments 内存缓存 (不再走 execute_one DuckDB 查询)
repo.get_instruments.return_value = pl.DataFrame({
"symbol": ["600000.SH"],
"name": ["浦发银行"],
"total_shares": [1.0],
"float_shares": [1.0],
})
repo.get_minute_range.return_value = pl.DataFrame({
"symbol": ["600000.SH"] * 3,
"datetime": [
@@ -0,0 +1,97 @@
"""策略 override mtime 签名缓存测试 — 读盘去重, 写入/删除后立即可见, 返回深拷贝。"""
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from app.strategy import config as strat_config
@pytest.fixture(autouse=True)
def _clean_cache():
strat_config._override_cache.clear()
strat_config._override_cache_sig.clear()
yield
strat_config._override_cache.clear()
strat_config._override_cache_sig.clear()
def _patched_loads(monkeypatch, counter: dict):
real_loads = json.loads
def _counting(text):
counter["loads"] += 1
return real_loads(text)
monkeypatch.setattr(strat_config.json, "loads", _counting)
def test_second_load_hits_cache_without_disk_parse(tmp_path, monkeypatch):
strat_config.save_override(tmp_path, "s1", {"params": {"p": 1}})
counter = {"loads": 0}
_patched_loads(monkeypatch, counter)
assert strat_config.load_override(tmp_path, "s1")["params"] == {"p": 1}
assert strat_config.load_override(tmp_path, "s1")["params"] == {"p": 1}
assert counter["loads"] == 0 or counter["loads"] == 1, "save 后首次 load 允许一次 parse"
before = counter["loads"]
strat_config.load_override(tmp_path, "s1")
assert counter["loads"] == before, "签名未变时不得重复读盘+parse"
def test_external_file_change_visible(tmp_path):
strat_config.save_override(tmp_path, "s1", {"params": {"p": 1}})
assert strat_config.load_override(tmp_path, "s1")["params"]["p"] == 1
p: Path = tmp_path / "user_data" / "strategy_overrides" / "s1.json"
p.write_text(json.dumps({"params": {"p": 2}}), encoding="utf-8")
st = p.stat()
os.utime(p, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000))
assert strat_config.load_override(tmp_path, "s1")["params"]["p"] == 2
def test_save_override_invalidates_cache(tmp_path):
strat_config.save_override(tmp_path, "s1", {"params": {"p": 1}})
assert strat_config.load_override(tmp_path, "s1")["params"]["p"] == 1
strat_config.save_override(tmp_path, "s1", {"params": {"p": 9}})
assert strat_config.load_override(tmp_path, "s1")["params"]["p"] == 9
def test_delete_override_invalidates_cache(tmp_path):
strat_config.save_override(tmp_path, "s1", {"params": {"p": 1}})
assert strat_config.load_override(tmp_path, "s1") != {}
strat_config.delete_override(tmp_path, "s1")
assert strat_config.load_override(tmp_path, "s1") == {}
assert strat_config.load_override(tmp_path, "s1") == {}
def test_load_returns_deep_copy_not_cached_object(tmp_path):
strat_config.save_override(tmp_path, "s1", {"params": {"p": 1}, "basic_filter": {"a": 1}})
first = strat_config.load_override(tmp_path, "s1")
first["params"]["p"] = 999
first["extra"] = True
again = strat_config.load_override(tmp_path, "s1")
assert again["params"]["p"] == 1
assert "extra" not in again
def test_basic_filter_cleaning_preserved(tmp_path):
strat_config.save_override(
tmp_path, "s1", {"basic_filter": {"keep": 1, "drop": None}},
)
data = strat_config.load_override(tmp_path, "s1")
assert data["basic_filter"] == {"keep": 1}
strat_config.save_override(tmp_path, "s2", {"basic_filter": {"drop": None}})
assert "basic_filter" not in strat_config.load_override(tmp_path, "s2")
def test_load_missing_override_returns_empty(tmp_path):
assert strat_config.load_override(tmp_path, "never_saved") == {}
+123
View File
@@ -0,0 +1,123 @@
"""preferences mtime 缓存测试 — 读盘去重, 且外部修改/自身写入后立即可见。"""
from __future__ import annotations
import json
import os
import pytest
from app.services import preferences
@pytest.fixture(autouse=True)
def _isolated(tmp_path, monkeypatch):
path = tmp_path / "preferences.json"
monkeypatch.setattr(preferences, "_path", lambda: path)
preferences._invalidate_cache()
yield path
preferences._invalidate_cache()
def _patched_loads(monkeypatch, counter: dict):
real_loads = json.loads
def _counting(text):
counter["loads"] += 1
return real_loads(text)
monkeypatch.setattr(preferences.json, "loads", _counting)
def test_second_load_hits_cache_without_disk_parse(_isolated, monkeypatch):
_isolated.write_text(json.dumps({"realtime_quotes_enabled": True}), encoding="utf-8")
counter = {"loads": 0}
_patched_loads(monkeypatch, counter)
assert preferences.load()["realtime_quotes_enabled"] is True
assert preferences.load()["realtime_quotes_enabled"] is True
assert counter["loads"] == 1, "签名未变时第二次 load 不得重复读盘+parse"
def test_external_file_change_invalidates_cache(_isolated, monkeypatch):
_isolated.write_text(json.dumps({"realtime_quote_interval": 6.0}), encoding="utf-8")
assert preferences.load()["realtime_quote_interval"] == 6.0
_isolated.write_text(json.dumps({"realtime_quote_interval": 3.0}), encoding="utf-8")
# 同尺寸修改且 mtime 粒度可能不变时, 显式推进 mtime 模拟真实场景
st = _isolated.stat()
os.utime(_isolated, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000))
assert preferences.load()["realtime_quote_interval"] == 3.0
def test_save_then_load_sees_merged_values(_isolated):
_isolated.write_text(json.dumps({"a": 1}), encoding="utf-8")
out = preferences.save({"b": 2})
assert out == {"a": 1, "b": 2}
assert preferences.load() == {"a": 1, "b": 2}
def test_interval_setter_invalidates_cache(_isolated):
preferences.set_realtime_quote_interval(2.0)
assert preferences.load()["realtime_quote_interval"] == 2.0
def test_load_returns_copy_not_cached_object(_isolated):
_isolated.write_text(json.dumps({"k": [1, 2]}), encoding="utf-8")
first = preferences.load()
first["k"].append(3)
first["extra"] = True
again = preferences.load()
assert again == {"k": [1, 2]}
def test_mining_schedule_defaults_are_disabled(_isolated):
assert preferences.get_mining_schedule() == {
"mining_schedule_enabled": False,
"mining_schedule_weekday": 4,
"mining_budget_profile": "balanced",
}
def test_mining_schedule_invalid_stored_values_fail_closed(_isolated):
_isolated.write_text(
json.dumps(
{
"mining_schedule_enabled": "false",
"mining_schedule_weekday": True,
"mining_budget_profile": None,
}
),
encoding="utf-8",
)
assert preferences.get_mining_schedule() == {
"mining_schedule_enabled": False,
"mining_schedule_weekday": 4,
"mining_budget_profile": "balanced",
}
def test_mining_schedule_setter_saves_group_once(monkeypatch):
calls = []
monkeypatch.setattr(preferences, "save", lambda updates: calls.append(updates) or updates)
result = preferences.set_mining_schedule(True, 2, "strict")
assert result == {
"mining_schedule_enabled": True,
"mining_schedule_weekday": 2,
"mining_budget_profile": "strict",
}
assert calls == [result]
@pytest.mark.parametrize("weekday", [-1, 5, True])
def test_mining_schedule_setter_rejects_invalid_weekday(weekday):
with pytest.raises(ValueError, match="weekday"):
preferences.set_mining_schedule(True, weekday, "balanced")
def test_mining_schedule_setter_rejects_invalid_profile():
with pytest.raises(ValueError, match="profile"):
preferences.set_mining_schedule(True, 4, "exploratory")
+39
View File
@@ -200,6 +200,7 @@ def test_realtime_limit_prices_ignore_stale_instrument_date():
"close": [9.10],
"raw_close": [9.10],
"raw_high": [9.10],
"raw_low": [9.10],
"_prev_close_raw": [10.0],
"volume": [1000.0],
})
@@ -215,3 +216,41 @@ def test_realtime_limit_prices_ignore_stale_instrument_date():
assert result["signal_limit_down"][0] is False
assert "_instrument_as_of" not in result.columns
def test_limit_down_recovery_uses_raw_low_under_later_ex_div():
"""除权事件之后重算历史时, 跌停翘板"曾触及跌停"必须用原始价 low 判断。
day2 (历史日): 原始 low 9.30 未触及跌停价 9.00, 不应触发翘板;
day3 除权 (ex_factor=2) 使 day2 前复权 low 变为 4.65,
若误用复权 low 对比原始口径跌停价会误报翘板
day3 (除权日, 最新日不复权): 涨跌停基准切换为前复权昨收 4.825 跌停价 4.34,
原始 low 4.34 触及且收阳未封死 真翘板
"""
raw = pl.DataFrame({
"symbol": ["600001.SH"] * 3,
"date": [date(2024, 1, 2), date(2024, 1, 3), date(2024, 1, 4)],
"open": [10.00, 9.60, 4.30],
"high": [10.10, 9.70, 4.45],
"low": [9.90, 9.30, 4.34],
"close": [10.00, 9.65, 4.42],
"volume": [10000.0, 10000.0, 10000.0],
"amount": [1.0e7, 1.0e7, 1.0e7],
})
factors = pl.DataFrame({
"symbol": ["600001.SH"],
"trade_date": [date(2024, 1, 4)],
"ex_factor": [2.0],
})
instruments = pl.DataFrame({
"symbol": ["600001.SH"],
"name": ["普通股"],
"float_shares": [1.0e8],
})
df = pipeline.compute_enriched(raw, factors=factors, instruments=instruments)
day2 = df.filter(pl.col("date") == date(2024, 1, 3))
assert day2["signal_limit_down_recovery"][0] is False
day3 = df.filter(pl.col("date") == date(2024, 1, 4))
assert day3["signal_limit_down_recovery"][0] is True
@@ -0,0 +1,57 @@
"""监控 name_map 测试 — 走 repo.get_name_map() memo, 过滤空名称与旧行为一致。"""
from __future__ import annotations
from types import SimpleNamespace
from app.services.quote_service import _monitor_name_map
class _FakeRepo:
def __init__(self, mapping: dict[str, str]) -> None:
self._mapping = mapping
self.calls = 0
def get_name_map(self) -> dict[str, str]:
self.calls += 1
return dict(self._mapping)
def test_filters_falsy_names_like_legacy_build():
repo = _FakeRepo({
"600000.SH": "浦发银行",
"000001.SZ": "", # 空名称: 旧 iter_rows 构建会跳过
"399001.SZ": None, # None 名称: 同上
"510300.SH": "沪深300ETF",
})
assert _monitor_name_map(repo) == {
"600000.SH": "浦发银行",
"510300.SH": "沪深300ETF",
}
def test_merges_all_asset_types_from_repo_map():
# get_name_map 已合并股票 + ETF + 指数 (股票优先), 监控回填无需再分表构建
repo = _FakeRepo({"600000.SH": "股票", "510300.SH": "ETF", "000001.SH": "指数"})
assert _monitor_name_map(repo) == repo._mapping
def test_delegates_to_repo_each_call():
repo = _FakeRepo({"600000.SH": "浦发银行"})
_monitor_name_map(repo)
_monitor_name_map(repo)
assert repo.calls == 2
def test_empty_repo_map_returns_empty():
assert _monitor_name_map(_FakeRepo({})) == {}
def test_survives_repo_failure():
# 与旧行为一致: name_map 构建失败不影响监控主流程 (调用方捕获)
repo = SimpleNamespace()
try:
_monitor_name_map(repo) # type: ignore[arg-type]
raised = False
except AttributeError:
raised = True
assert raised, "repo 无 get_name_map 时应抛出, 由调用方 try 兜底"
@@ -16,6 +16,7 @@ def _today_rows(turnover_rate: float | None = None) -> pl.DataFrame:
"close": 10.0,
"raw_close": 10.0,
"raw_high": 10.0,
"raw_low": 10.0,
"volume": 8000.0,
}
if turnover_rate is not None:
+21 -8
View File
@@ -341,17 +341,30 @@ def test_build_regime_mask_fails_when_required_t1_date_is_missing(tmp_path):
)
def test_build_regime_mask_first_day_allowed(tmp_path):
"""首日无前一日环境数据 → 默认允许(不阻断)"""
def test_build_regime_mask_first_formal_day_requires_warmup_predecessor(tmp_path):
"""正式首日缺少前一交易标签时必须阻断; warmup 前缀可安全对齐"""
from app.backtest.strategy import StrategyBacktestService
regime_builder.upsert_regime_history(tmp_path, pl.DataFrame({
"date": [date(2026, 1, 1)],
"state": ["weak"], "score": [10],
"date": [date(2026, 1, 1), date(2026, 1, 2)],
"state": ["weak", "strong"],
"score": [10, 85],
}))
labels = ("2026-01-01", "2026-01-02")
with pytest.raises(ValueError, match="正式首日"):
StrategyBacktestService._build_regime_mask(
("2026-01-01", "2026-01-02"),
{"states": ["strong"]},
tmp_path,
required_start=date(2026, 1, 1),
required_end=date(2026, 1, 2),
)
mask = StrategyBacktestService._build_regime_mask(
labels, {"states": ["strong"]}, tmp_path,
("2026-01-01", "2026-01-02", "2026-01-03"),
{"states": ["strong"]},
tmp_path,
required_start=date(2026, 1, 2),
required_end=date(2026, 1, 3),
)
# 1/1 首日 → True; 1/2 由 1/1(weak) → False
assert mask.tolist() == [True, False]
assert mask is not None
assert mask.tolist() == [True, False, True]
+37
View File
@@ -46,6 +46,43 @@ def test_name_map_stock_beats_index(repo):
assert repo.get_name_map(["600000.SH"]).get("600000.SH") == "浦发银行"
def _write_stock_instruments(repo, symbols, names):
pl.DataFrame({
"symbol": symbols, "name": names, "code": [s[:6] for s in symbols],
"exchange": ["SH"] * len(symbols), "region": ["CN"] * len(symbols),
"type": ["stock"] * len(symbols),
"listing_date": [None] * len(symbols), "total_shares": [None] * len(symbols),
"float_shares": [None] * len(symbols), "tick_size": [None] * len(symbols),
"limit_up": [None] * len(symbols), "limit_down": [None] * len(symbols),
"as_of": ["2026-08-14"] * len(symbols),
}).write_parquet(repo.store.data_dir / "instruments" / "instruments.parquet")
repo._refresh_instruments()
def test_name_map_partial_query_does_not_poison_cache(repo):
"""带 symbols 的部分查询不能把残缺映射写入缓存 (自选新加股票无名称的回归).
bug: 首次 get_name_map(["600000.SH"]) 把只含 600000 的映射缓存住,
之后自选加入 000001.SZ 再查名称命中残缺缓存 name=None
"""
_write_stock_instruments(repo, ["600000.SH", "000001.SZ"], ["浦发银行", "平安银行"])
first = repo.get_name_map(["600000.SH"])
assert first == {"600000.SH": "浦发银行"}
# 缓存必须是全量: 后续其他 symbols 查询仍能命中
second = repo.get_name_map(["000001.SZ"])
assert second == {"000001.SZ": "平安银行"}
full = repo.get_name_map()
assert full == {"600000.SH": "浦发银行", "000001.SZ": "平安银行"}
def test_name_map_cache_invalidated_on_instruments_refresh(repo):
"""维表刷新后缓存必须失效: 新收录的股票能立刻查到名称。"""
_write_stock_instruments(repo, ["600000.SH"], ["浦发银行"])
assert repo.get_name_map(["600000.SH"]) == {"600000.SH": "浦发银行"}
_write_stock_instruments(repo, ["600000.SH", "301999.SZ"], ["浦发银行", "新股股份"])
assert repo.get_name_map(["301999.SZ"]) == {"301999.SZ": "新股股份"}
import datetime as _dt
@@ -44,6 +44,11 @@ class _CapturingStrategyEngine:
def has(self, strategy_id):
return strategy_id == "builtin_strategy"
def get(self, strategy_id):
if not self.has(strategy_id):
raise ValueError(f"unknown strategy: {strategy_id}")
return types.SimpleNamespace(meta={"id": strategy_id})
def run(self, strategy_id, context, *, pool=None, params=None, overrides=None):
self.calls.append({
"kind": "run",
@@ -0,0 +1,71 @@
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.api import settings
def test_mining_schedule_model_is_strict_and_forbids_extra_fields():
valid = settings.MiningSchedulePrefs(
mining_schedule_enabled=True,
mining_schedule_weekday=4,
mining_budget_profile="strict",
)
assert valid.model_dump() == {
"mining_schedule_enabled": True,
"mining_schedule_weekday": 4,
"mining_budget_profile": "strict",
}
invalid_payloads = [
{
"mining_schedule_enabled": "true",
"mining_schedule_weekday": 4,
"mining_budget_profile": "balanced",
},
{
"mining_schedule_enabled": True,
"mining_schedule_weekday": 5,
"mining_budget_profile": "balanced",
},
{
"mining_schedule_enabled": True,
"mining_schedule_weekday": 4,
"mining_budget_profile": "exploratory",
},
{
"mining_schedule_enabled": True,
"mining_schedule_weekday": 4,
"mining_budget_profile": "balanced",
"unknown": True,
},
]
for payload in invalid_payloads:
with pytest.raises(ValidationError):
settings.MiningSchedulePrefs.model_validate(payload)
def test_update_mining_schedule_calls_group_setter_once(monkeypatch):
calls = []
monkeypatch.setattr(
"app.services.preferences.set_mining_schedule",
lambda enabled, weekday, profile: (
calls.append((enabled, weekday, profile))
or {
"mining_schedule_enabled": enabled,
"mining_schedule_weekday": weekday,
"mining_budget_profile": profile,
}
),
)
request = settings.MiningSchedulePrefs(
mining_schedule_enabled=True,
mining_schedule_weekday=1,
mining_budget_profile="strict",
)
result = settings.update_mining_schedule(request)
assert calls == [(True, 1, "strict")]
assert result["mining_budget_profile"] == "strict"
@@ -55,6 +55,7 @@ def _two_day(
"raw_close": [prev_close, today_close],
"close": [prev_close, today_close],
"raw_high": [prev_close, today_close],
"raw_low": [prev_close, today_close],
"open": [prev_close, today_close],
"high": [prev_close, today_close],
"low": [prev_close, today_close],
+5 -1
View File
@@ -8,7 +8,7 @@
## 🔍 选股引擎(Screener)
**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`):
**18 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`):
| 类型 | 代表策略 |
| :---------- | :------------------------------------------------------- |
@@ -54,6 +54,10 @@
输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。SSE 流式进度支持切页重连,不会丢失回测任务。
**因子与策略挖掘**:复用已有日频因子和 matrix-native 策略,通过 T-1 市场环境、相关去重和嵌套样本外验证生成研究候选。任务在 spawn worker 中运行,支持持久 run ID、取消、刷新重连和显式发布;自动周度任务默认关闭且永不自动发布。完整口径见 [因子与策略挖掘](./mining.md)。
**市场阶段与主线**:市场环境页在原 5 档状态之外新增情绪周期阶段(冰点/启动/主升/高潮/退潮/修复,由连板梯队的高度、宽度、晋级率、梯队完整度判定,平均段长约 10 天)与主线识别(概念/行业维度的涨停梯队聚合排名,可配置宽基标签过滤)。完整口径见 [市场阶段与主线识别](./market-phase.md)。
**ETF 支持**:三种模式的后端与 API 均支持 `asset_type=etf`,回测面板改从 `kline_etf_enriched` 读取(单次回测为单一资产类型,不混合股票与 ETF)。策略组合与因子回测页均有 `股票 / ETF` 切换,ETF 模式下策略列表与标的搜索跟随资产。需先开启 ETF 拉取并跑盘后管道。
---
+95
View File
@@ -0,0 +1,95 @@
# 市场阶段(情绪周期)与主线识别
本文说明市场环境页的阶段体系与主线识别的口径、阈值来源、持久化设计、已知偏差与运行方式。它与原有的 5 档环境 state 并存,不替代任何既有消费方。
## 功能边界
阶段体系回答"市场处于情绪周期什么位置",主线识别回答"当前/某阶段内什么板块概念在领涨"。两者都只用本地已存储数据(`consecutive_limit_ups``amount`、概念映射快照),不依赖扩展数据源,2020-08 起全历史可回算。
明确不做的:
- 挖掘、回测环境过滤、因子 regime 统计仍用原 5 档 `state`,本期不切换到阶段体系。
- 概念成分是当前快照,不改成 timeseries 积累模式(见「快照回填偏差」)。
- 不生成任何交易信号;阶段与主线仅供研究分析。
## 与 5 档 state 的关系
| | `state`(原有) | `phase`(本期新增) |
|---|---|---|
| 驱动量 | 赚钱/投机/抗跌/趋势 4 维综合分 | 连板梯队:高度、宽度、晋级率、梯队完整度 |
| 档位 | 强势/偏强/震荡/偏弱/弱势(5 档) | 冰点/启动/主升/高潮/退潮/修复(6 阶段) |
| 消费方 | 回测环境过滤、挖掘、策略页 | 市场环境页分析与主线识别 |
| 持续性 | 日频打分,切换频繁(历史 76.6% 天数发生切换,平均段长 1.1-1.5 天) | EMA 平滑 + 2 日确认,平均段长 9.7 天 |
`state`/`score` 列原样保留,新列(`phase``first_board``ge2_count``ge3_count``ge5_count``ladder_completeness``promo_rate``promo_pool`)对既有消费方透明。
## 每日梯队指标
全部由已存储的 `consecutive_limit_ups` 列向量化派生,实现在 `backend/app/services/market_phase.py`
- `first_board`:首板(1 连板)家数。
- `ge2_count` / `ge3_count` / `ge5_count`N 板以上家数(宽度)。
- `promo_rate` 晋级率:昨日连板池今日继续封板的比例。昨日连板数用 `consec.shift(1).over("symbol")` 按个股前一日连接(跨批次边界也正确,`_compute_batch` 在 warmup 截断前计算);池不足 10 家记 `null`(小样本噪声),不填 0。
- `ladder_completeness` 梯队完整度:2..height 档位中非空档位占比;height < 3 时为 `null`
- 高度(`max_consecutive`)与封板率(`seal_rate`)沿用 regime 已有列。
## 阶段规则
词汇与优先级:`climax 高潮 > rally 主升 > ebb 退潮 > ignite 启动 > ice 冰点 > repair 修复(兜底)`。冰点排在退潮前判定——长期死市不应被标成"自高位退潮"。
阈值标定自 2020-08~2026-08 全市场 1454 个交易日的 p10/p60/p90 分位数,全部集中在模块顶部常量,调整只改那里:
| 阶段 | 核心条件(EMA 平滑后) |
|---|---|
| 高潮 climax | ge2 ≥ 50p90 的 2 倍)或首板 ≥ 220(p90 的 2.5 倍),历史占比 <2% |
| 主升 rally | 高度 ≥7 且 ge2 ≥15 且晋级率 ≥0.23(全中位以上);或晋级率 ≥0.30(p85+)配合高度 ≥5、ge2 ≥12 |
| 退潮 ebb | 晋级率 <0.15p20)且宽度自 5 日前高位(ge2>12 或高度>6)回落;或晋级率 <0.13 且封板率 <0.57 双弱 |
| 启动 ignite | 宽度/高度自低位扩张(ge2 较 5 日前 +3 且 ≥8,或高度抬升且 ≥5)且晋级率恢复到 ~0.19-0.20 |
| 冰点 ice | 高度 ≤4、ge2 ≤6、首板 ≤24 同时贴地(均 ~p10) |
| 修复 repair | 兜底 |
**持续性设计**:驱动量先做 EMA 平滑(alpha=1/3,约 5 日),原始标签出来后再做 2 日确认(切换需连续 2 日出现新标签才生效)。
**弱档否决**5 档 `state ∈ {weak, lean_weak}` 时,正向阶段(主升/高潮/启动)一律降为修复。这修复了一类错标:连板梯队强但大盘崩的交易日(如 2024-01 微盘流动性危机)会被梯队指标误判为主升/启动——涨停生态与大盘背离时,以大盘弱势为准。
**回填验收**(1454 天真实数据):平均段长 9.7 天(对比原 5 档的 1.1-1.5 天)。抽查:2024-09-24→10-08 为启动→主升→高潮(九二四行情);2024-01/02 微盘崩为退潮/冰点;高潮 6 年仅 2 段(2024-10-08、2024-10-31 ST 重组潮 17 板),均为真实极端期。
已知特性,如实说明:退潮/冰点天然是短段(平均 2.8/2.5 天)——恐慌释放本身快于趋势形成;修复是占比最高的兜底段(~74% 天数),A股大部分时间没有处于可辨认的周期位置。
## 主线识别
实现在 `backend/app/services/market_mainline.py`。复用 `rps_rotation` 的概念映射加载,窄扫描 `kline_daily_enriched` 的 symbol/date/consecutive_limit_ups/amount 四列,按 (date, 概念/行业) 聚合:
- `limit_up_count` 涨停家数、`ge2_count` 二板以上家数、`max_boards` 最高板、`boards_sum``rungs_filled` 梯队档位数、龙头股(按连板数、成交额排序取第一)。
- 主线分 = 当日截面 rank 归一后 `0.35*涨停数 + 0.25*最高板 + 0.25*梯队档位数 + 0.15*二板宽度`。这是"同板块涨停越多、梯队越完整越是主升主线"判据的直接量化。
- 每概念当日涨停 <3 家不参与排名;每日持久化 top30 到 `data/mainline_history/part.parquet`upsert 按 date+kind 整日替换)。
- 行业维度取前两级(如 `计算机-软件开发`),作为概念维度的交叉验证。
### 宽基/风格标签过滤
融资融券(~7700 家)、沪深股通(~3300 家)这类超大概念会垄断 top1,需要过滤。配置存于用户偏好(`preferences.json`),市场环境页「过滤」面板可改:
- 成员数上限:默认 600,超过视为宽基/风格标签不参与排名(夹取范围 50-5000)。保留华为概念(2006)、人工智能(2166)等真主题。
- 成员数下限:默认 4(夹取 1-200)。
- 名称黑名单:手动屏蔽任意概念,支持逗号/分号/空格分隔。
修改保存后自动触发全量主线重算(`POST /api/regime/mainline/recompute`,秒级)。API 层为 `PUT /api/preferences/mainline-filter`
### 快照回填偏差
概念成分为**当前快照回看历史**(本地自 2026-07 起留存,无历史版本)。新纳入指数或改名的个股会出现在旧时段、成分调整会错归属,因此早年主线存在归属漂移,越近越准。此口径限制以 `membership_note` 字段随 API 返回并在页面展示。若后续把 ext 概念同步改为按月累积快照,主线历史精度会逐月自然提升。
## API 与运行
- `GET /api/regime/phases?start&end`:连续阶段段列表(阶段、区间、天数、高度/宽度/晋级率/封板率均值、段内 top 主线)。
- `GET /api/regime/mainline?start&end&top&kind`:窗口内主线排行(top1 天数、日均分、最高板)与每日明细。
- `POST /api/regime/mainline/recompute`:过滤配置变更后的全量重算(两类维度)。
- `POST /api/regime/recompute`:扩展为重算 regime 后自动重标 phase 并回填主线。
- daily pipeline 在 regime 步骤后追加主线增量(复用 `pipeline_regime_enabled` 开关,软失败不阻塞)。
阶段标签由 `regime_builder.refresh_phase_labels` 在每次 regime upsert 后对全量历史重标(1454 行,开销可忽略)——因为 EMA 与 2 日确认依赖完整序列。
## 测试
- `backend/tests/test_market_phase.py`:梯队聚合、晋级率(池不足记 null)、梯队完整度、阶段序列规则、弱档否决、持续性(噪声下不出现 1 日翻转)、refresh 往返。
- `backend/tests/test_market_mainline.py`:概念/行业聚合、宽基过滤、黑名单、最少涨停家数、同日 upsert 替换、增量补齐、偏好读写与夹取。
+138
View File
@@ -0,0 +1,138 @@
# 因子与策略挖掘
本文说明 V1 因子与策略挖掘的金融口径、执行边界、结果解释和运行要求。
## 功能边界
V1 仅研究仓库已经提供的日频因子和用户明确选择的 matrix-native 日线策略。它完成以下闭环:
1. 在训练区间重新计算因子方向与统计。
2. 按日截面 Rank IC 计算因子相关性并去重。
3. 搜索最多四个因子的受控排名组合;进入 beam 搜索的因子按训练折综合分排序并截断到 `beam_width` 个,保证单因子打分后组合搜索仍有代理预算可用。
4. 用嵌套样本外验证比较新组合;用户选择的已有策略作为对照轨在每个 outer 测试窗独立评估,不参与因子竞争,也不会占用候选名额。
5. 将运行、事件和结果持久化,允许刷新后重连。
6. 将候选保存到研究候选库;只有用户显式确认且通过晋级门槛后才发布独立策略。
V1 不生成任意公式,不接受 AI 自由代码,不使用扩展数据生成新因子,也不使用分钟数据优化入场。分钟级成交和分钟因子需要独立的数据完整性、防未来函数和性能边界,属于后续版本范围。
因子数量以运行时 `FACTOR_COLUMNS` 目录为准。当前目录与后续版本可能不同;单次请求硬上限为 48,不应把历史设计稿中的固定数量当作稳定 API 契约。
当前目录除价量技术类(动量、均线偏离、趋势、波动率、量价、价格位置、超买超卖)外,还包含三个 A 股实证维度:收益形态(`max_ret_20d` 彩票效应、`ret_skew_20d``up_days_20d`)、流动性(`amihud_20d` 非流动性、`turnover_z_60d` 换手异动)、涨停基因(`limit_up_count_20d/60d`,基于存储列 `consecutive_limit_ups` 计数,涨停判定沿用环境信号口径)。这些因子仍只用本地日频存储数据计算,不依赖扩展数据源。
财务因子(`pb_latest``roe_latest``gross_margin_latest``net_margin_latest``revenue_yoy_latest``net_income_yoy_latest``debt_ratio_latest`)来自本地财务快照(数据页同步),采用严格点时口径:**因子值只在晚于公告日的交易日生效**(公告多在盘后, 保守取公告次一交易日起),同一报告期以最新公告为准。无财务数据的标的、公告前的日期一律为空值并从当日截面剔除,绝不填 0;本地完全没有财务数据时因子回测直接返回明确错误而不是全空结果。回测区间早于首次公告时同样报告"无有效数据"。挖掘中财务因子在训练折覆盖不足时综合分为 0 并被自然淘汰,不会阻塞其他因子。财务报表同步按 `(symbol, period_end)` 累积历史(同 shares 表模式),每次同步只拉最新期并为新标的补全量历史;随着季度累积,财务因子的可回测区间会逐步变长。
## 时间与收益口径
### 全局交易日标签
1、3、5 日 forward return 都按全局交易日轴精确连接。停牌或缺行不会把“下一条标的数据”误当成下一交易日。
周频使用每个 ISO 周的第一个实际交易日,月频使用每月第一个实际交易日。节假日不会导致整周漏掉调仓。
### T-1 市场环境
交易日 T 只能使用上一交易日已经得到的环境标签 T-1。**统计与挖掘口径**聚合为三档:
```text
strong = strong + lean_strong
range = range
weak = lean_weak + weak
```
**策略回测的环境过滤**按原始五档匹配:勾选“强势”只允许 T-1 为 strong 的交易日入场,需要三档口径时必须同时勾选“强势+偏强”。挖掘内部的 strong/range/weak 环境拆分仍按上方三档聚合实现(显式枚举原始状态),两者互不影响。
正式区间缺少前驱交易日或前驱环境时会 fail-closed,不会用当日环境、最近自然日或默认环境补齐。
### 成交和成本
候选策略沿用现有回测撮合规则,包括 T+1、佣金、卖侧印花税、滑点以及涨跌停不可成交约束。因子统计中的 long-short spread 仅用于衡量因子区分能力,是理论价差,不表示 A 股可执行卖空。
## 防止未来数据
挖掘使用嵌套 walk-forward,而不是在完整样本上选择后再报告同一段表现:
- 每个 inner 训练折独立学习方向、计算统计、相关去重和组合搜索。
- inner test 只用于选择候选,不参与该折的训练计算。
- 选择完成后在完整 outer train 上重新训练。
- outer test 只进行一次最终样本外评估。
- 训练和测试之间保留 purge 与 embargo;默认 purge 为 30 个交易日。
任何 fold 缺数据、缺 T-1 环境或不能可靠撮合时都记录为 skipped 并给出原因,缺失指标返回 `null`,不会显示为 0。
## 候选证据口径
run 级的“有效折”统计只描述因子赛道:它按每折被选中的候选聚合,不是任何单个候选的样本量。单个候选的逐折证据由三类行组成,`folds.parquet``evaluation_kind` 区分:
- `selected`:该候选在该 outer 折经 inner 验证胜出后的 outer 测试结果。
- `cross`:该候选的定义在其他折胜出后,在本折补评的跨折结果。胜出定义会在所有 outer 折上评估,使单个候选的证据不再依赖“它恰好在哪里获胜”。
- `benchmark`:对照策略在该 outer 测试窗的独立评估,与因子赛道成败无关;即使因子赛道在某折没有任何候选完成内部验证,对照行仍会写入。
对照策略是固定定义,不需要逐折重拟合,因此每个 outer 折只评估一次;预算耗尽时写入带原因的 skipped 行,不会静默缺失。探索档(exploratory)结果固定为低置信度,只能保存为 pending 候选。
## 晋级与发布门槛
保存(promote)始终允许,结果进入研究候选库 pending 状态。发布(publish)在服务端强制校验以下证据门槛,不满足即拒绝并返回原因列表:
- 非探索档置信度(exploratory 运行产生的候选不能发布);
- 至少 2 个有效 outer 折;
- 正收益折比例不低于 2/3
- 样本外 Sharpe 不低于 0.5
- 最大回撤不劣于 -25%
- 样本外交易数不低于 60。
门槛按 artifact 行指标在读取时计算,不改变历史 artifact 的 schema,因此旧运行的候选会得到相同的门槛判断。工作台会在候选上显示“达标/未达标”标记,未达标的候选“显式发布”按钮被禁用并展示原因。
## 置信度
三个预算档使用不同的训练窗口:
| 档位 | 外层训练 | 外层测试 | 步长 | 用途 |
| --- | ---: | ---: | ---: | --- |
| exploratory | 126 | 63 | 63 | 数据较短时探索,只能作为低置信度 pending 候选 |
| balanced | 504 | 126 | 63 | 默认自动研究,至少需要 3 个 outer folds |
| strict | 756 | 126 | 126 | 更长训练窗,至少需要 3 个 outer folds |
探索性结果不是已验证策略。候选晋级仍需同时检查正收益折比例、样本外 Sharpe、最大回撤、交易数和环境样本覆盖;发布动作会按上文“晋级与发布门槛”在服务端强制执行同一组阈值。
## 任务与资源隔离
挖掘通过 `spawn` 子进程执行,不在 FastAPI 请求线程、实时行情回调线程或浏览器连接生命周期内运行。浏览器断开不会取消任务;刷新后可通过持久 run ID 重连。取消请求会进入 `cancelling`,界面应等待后端进入终态。
共享重任务限流容量为 2:普通回测、优化、walk-forward 和矩阵预热占 1,挖掘独占 2。因此一个挖掘任务不会与另一项大矩阵计算并发。
当前 run store 和重任务 limiter 使用进程内锁。生产环境必须只启动一个应用进程负责挖掘;多 worker Uvicorn 部署不能保证跨进程单飞、事件追加和容量限制。要支持多应用进程,必须先增加操作系统级文件锁或外部任务协调器。
每个运行目录包含 manifest、compact summary、最近 256 条事件和四个 Parquet artifact。大面板、完整矩阵对象和逐折宽结果不会通过 worker IPC 返回。
## 自动运行
周度自动挖掘默认关闭,只允许 balanced 或 strict。启用后,它会在北京时间配置工作日及其后的同周工作日、日线 enriched 刷新和环境更新成功后尝试入队;如果配置日的数据流水线失败,后续工作日可以补跑。
同一 ISO 周使用确定性的 claim,只触发一次。已经生成运行记录的失败或 `skipped_prerequisite` 仍会占用该周 claim,不重复消耗资源。数据或 T-1 环境覆盖不足时会生成可见的 `skipped_prerequisite` 运行,不会静默降级到 exploratory。挖掘失败不改变已经成功的日度数据流水线状态。
自动任务只生成 pending 结果,永远不会自动发布策略。
## 保存与发布
“保存候选”只从服务端已注册的 `candidates.parquet` 读取定义,并重新校验 artifact schema、canonical signature、原始请求中的因子/策略范围和当前策略兼容性,再写入研究候选库。客户端只能提交 run ID 与 candidate signature,不能在保存时重交权重、方向、公式或代码。保存按 `(origin_run_id, candidate_signature)` 幂等;如果候选库已写入但 artifact backlink 回写失败,重试只修复 backlink,不会创建重复候选。
“发布”是独立的显式动作:
- 已有策略候选返回原策略 ID,不复制或覆盖源文件。
- 因子组合候选由服务端根据 run ID 与 candidate signature 派生独立策略 ID;客户端不能提交策略 ID、权重、方向、公式或代码。
- 不同 run 即使得到相同组合,也会生成互相独立的策略文件;同一 run 与 signature 重试时只验证已有源码并修复 backlink。
- 发布过程执行 AST/META 校验、create-only 原子写入、引擎 reload、策略缓存失效和监控状态失效。
- reload 或运行时失效失败会回滚首次创建的新文件;backlink 回写失败不会删除已成功加载的策略,重试可修复 backlink。
- 发布不会修改共享 `factor_rank_research` 源码或 override。
发布仅表示把固定候选变成可使用策略,不代表样本外表现会在未来持续。
## 性能口径
相关矩阵按交易日计算 pairwise-finite Spearman,只聚合因子平方级的逐日结果,不物化百万行永久 rank 宽表。缺失集合不同的因子会先取共同 finite 样本再排名;无法估计的 pair 保持空值,不按零处理。
生产 runtime 只生成本次搜索 horizon 的一列 forward label,并在阶段间释放中间列。内存表示优化对照 run `46ed81d537e9404baa06324a3ac3a45f` 在 132.0 万行、243 个交易日、44 个因子上峰值 RSS 为 1.291 GiB,总耗时 25.140 秒;相对 2.395 GiB、37.358 秒的原始对照基线,RSS 和耗时分别下降 46.09% 和 32.70%,四个 Parquet artifact 逐项完全一致。
最终 `mining-v2` 额外修复了缺失掩码下的 pairwise Spearman 口径,因此 correlation artifact 与 v1 不再要求数值相等。最终 run `10574abb5d8e4b32ade7cbdd23975c45` 峰值 RSS 为 1.350 GiB、总耗时 40.699 秒,相对原始对照基线内存下降 43.63%、耗时增加 8.94%;仍满足 1.5 GiB 内存门槛和统计增强耗时不超过 30% 的目标。v2 的 Float32 聚合与 Float64 慢参考具有相同 pair counts、空值位置和 0.75 剪枝判断,相关系数最大绝对误差为 `1.70e-7`
这些结果只证明上述基准工作负载达到目标,不表示所有数据规模和搜索预算都具有固定内存上界。当前验证结果:后端全量测试 `876 passed, 24 warnings`,挖掘相关回归含基准轨、跨折证据与晋级门槛共 145 项通过,前端 TypeScript 检查和生产构建均通过;另以真实数据完成 exploratory 运行核验 `selected`/`cross`/`benchmark` 三类行与发布禁用。
+2
View File
@@ -16,6 +16,8 @@
| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 · 接近涨停 |
| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 · 回踩支撑 · 强势开盘 |
内置目录 `backend/app/strategy/builtin/` 还包含一个仅供挖掘 worker 使用的受控因子排名研究模板。它不出现在普通选股列表,也不能通过普通策略 API 直接运行或保存 override;挖掘结果发布时会生成独立策略。详见 [因子与策略挖掘](./mining.md)。
内置目录 `backend/app/strategy/builtin/` 由项目维护,**AI 生成的策略不会落入此目录**。
---
+23 -6
View File
@@ -25,6 +25,7 @@ import {
Star,
ScanSearch,
History,
Pickaxe,
FileText,
Settings,
Key,
@@ -79,6 +80,7 @@ const nav = [
{ to: '/watchlist', label: '自选', icon: Star },
{ to: '/screener', label: '策略', icon: ScanSearch },
{ to: '/backtest', label: '回测', icon: History },
{ to: '/mining', label: '挖掘', icon: Pickaxe },
{ to: '/stock-analysis', label: '个股分析', icon: TrendingUp },
{ to: '/limit-ladder', label: '连板梯队', icon: Flame },
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
@@ -424,12 +426,11 @@ export function Layout() {
: (dataSources?.custom?.find(s => s.name === activeProvider)?.display_name || activeProvider)
const isCustomActive = activeProvider !== 'tickflow'
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒; 后台标签页由 SSE 事件驱动, 不轮询)
const alertsTotalQuery = useQuery({
queryKey: ['alerts-total'],
queryFn: () => api.alertsList({ days: 7, limit: 1 }),
refetchInterval: 15000,
refetchIntervalInBackground: true,
select: (data) => data.total,
})
// 只在拿到真实总数时同步徽标 (避免 data=undefined 时传 0 重置 lastSeen)
@@ -456,11 +457,27 @@ export function Layout() {
const navItems = savedOrder.length > 0
? (() => {
const byTo = new Map(allNav.map(n => [n.to, n]))
const ordered = savedOrder
const ordered = (savedOrder
.map(id => byTo.get(id) ?? byTo.get(`/analysis/${id}`))
.filter(Boolean)
const seen = new Set(ordered.map(n => n!.to))
return [...ordered as typeof allNav, ...allNav.filter(n => !seen.has(n.to))]
.filter(Boolean)) as typeof allNav
const seen = new Set(ordered.map(n => n.to))
const merged = [...ordered]
for (const item of allNav) {
if (seen.has(item.to)) continue
// 未保存过排序的新条目: 内置页插回默认位置(排在已保存的默认前驱之后),
// 分析/扩展菜单仍追加到末尾
const defaultIndex = nav.findIndex(n => n.to === item.to)
let anchor = -1
if (defaultIndex > 0) {
for (let i = defaultIndex - 1; i >= 0 && anchor < 0; i -= 1) {
anchor = merged.findIndex(n => n.to === nav[i].to)
}
}
if (anchor >= 0) merged.splice(anchor + 1, 0, item)
else if (defaultIndex >= 0) merged.unshift(item)
else merged.push(item)
}
return merged
})()
: allNav
+383 -1
View File
@@ -473,6 +473,15 @@ export interface RegimeRow {
speculation_score?: number
resilience_score?: number
trend_score?: number
// 情绪周期阶段与梯队指标(重算后才有; 旧数据可能缺)
phase?: MarketPhase | null
first_board?: number | null
ge2_count?: number | null
ge3_count?: number | null
ge5_count?: number | null
ladder_completeness?: number | null
promo_rate?: number | null
promo_pool?: number | null
}
export interface RegimeHistory {
@@ -498,6 +507,90 @@ export interface RegimeCoverage {
latest_date: string | null
}
// ── 市场阶段(情绪周期) 与 主线 ──
export type MarketPhase = 'ice' | 'ignite' | 'rally' | 'climax' | 'ebb' | 'repair'
export const MARKET_PHASE_LABELS: Record<MarketPhase, string> = {
ice: '冰点',
ignite: '启动',
rally: '主升',
climax: '高潮',
ebb: '退潮',
repair: '修复',
}
export const MARKET_PHASE_COLORS: Record<MarketPhase, string> = {
ice: '#38bdf8', // 天蓝(冻结)
ignite: '#f59e0b', // 琥珀(升温)
rally: '#ef4444', // 红(主升)
climax: '#d946ef', // 品红(极端)
ebb: '#14b8a6', // 青(退潮)
repair: '#94a3b8', // 灰(修复)
}
export const MARKET_PHASE_ORDER: MarketPhase[] = ['ice', 'ignite', 'rally', 'climax', 'ebb', 'repair']
export interface MainlineMemberStat {
member: string
top5_days: number
score_sum: number
max_boards: number
leader_symbol: string
}
export interface PhaseSegment {
phase: MarketPhase
label: string
start: string
end: string
days: number
avg_height: number
avg_first_board: number
avg_ge2: number
avg_promo: number | null
avg_seal_rate: number
top_mainlines: MainlineMemberStat[]
}
export interface PhaseSegments {
segments: PhaseSegment[]
total: number
}
export interface MainlineRow {
date: string
kind: string
member: string
limit_up_count: number
ge2_count: number
max_boards: number
boards_sum: number
rungs_filled: number
leader_symbol: string
score: number
rank: number
}
export interface MainlineLeader {
member: string
top1_days: number
avg_score: number
max_boards: number
}
export interface MainlineFilter {
min_members: number
max_members: number
blacklist: string[]
}
export interface MainlineResult {
rows: MainlineRow[]
leaders: MainlineLeader[]
membership_note: string
filter: MainlineFilter
}
// ===== 大盘复盘 =====
export interface AiReviewReport {
id: string
@@ -846,6 +939,217 @@ export interface FactorBatchResult {
error: string | null
}
// ===== Factor / strategy mining =====
export type MiningBudgetProfile = 'exploratory' | 'balanced' | 'strict'
export type MiningRunStatus =
| 'queued'
| 'running'
| 'cancelling'
| 'succeeded'
| 'succeeded_with_budget_exhausted'
| 'failed'
| 'cancelled'
| 'interrupted'
| 'skipped_prerequisite'
export interface MiningAvailability {
asset_type: 'stock' | 'etf'
budget_profile: MiningBudgetProfile
trading_bars: number
required_bars: number
outer_folds: number
required_outer_folds: number
eligible: boolean
available_start: string | null
available_end: string | null
effective_start: string | null
effective_end: string | null
suggested_start: string | null
}
export interface MiningRequestV1 {
factor_names: string[]
strategy_ids?: string[]
symbols?: string[] | null
asset_type?: 'stock' | 'etf'
start?: string | null
end?: string | null
budget_profile?: MiningBudgetProfile
commission_pct?: number
stamp_tax_pct?: number
slippage_bps?: number
correlation_threshold?: number
max_combination_factors?: number
beam_width?: number
max_finalists?: number
force?: boolean
}
export interface MiningRunProgress {
phase: string
label?: string
done?: number
total?: number
percent?: number
elapsed_ms?: number
message?: string
}
export interface MiningRun {
run_id: string
signature: string
status: MiningRunStatus
request: MiningRequestV1
source?: 'manual' | 'scheduled'
created_at: string
updated_at: string
started_at?: string | null
finished_at?: string | null
data_as_of?: string | null
progress?: MiningRunProgress | null
error?: string | null
reused?: boolean
summary?: MiningResultSummary | null
}
export interface MiningResultSummary {
factor_count: number
selected_factor_count: number
candidate_count: number
valid_fold_count: number
skipped_fold_count: number
confidence: 'low' | 'standard' | 'high'
budget_exhausted?: boolean
elapsed_ms?: number
peak_rss_bytes?: number
}
export interface MiningFactorRow {
factor_name: string
label?: string
direction: 1 | -1
score: number | null
ic_mean: number | null
ir: number | null
coverage: number | null
turnover: number | null
spread_return?: number | null
spread_sharpe?: number | null
selected: boolean
excluded_reason?: string | null
}
export interface MiningRegimeRow {
state: 'overall' | 'strong' | 'range' | 'weak' | string
label: string
n_dates: number
total_return: number | null
sharpe: number | null
max_drawdown: number | null
}
export interface MiningFoldRow {
fold: number
label?: string
train_start?: string
train_end?: string
test_start?: string
test_end?: string
selected_factors?: string[]
total_return: number | null
sharpe: number | null
max_drawdown?: number | null
n_trades?: number | null
skipped?: boolean
reason?: string | null
evaluation_kind?: 'selected' | 'cross' | 'benchmark' | null
}
export interface MiningCandidateGate {
qualified: boolean
reasons: string[]
}
export interface MiningCandidateRow {
signature: string
name: string
kind: 'factor_combination' | 'existing_strategy'
factor_names?: string[]
strategy_id?: string | null
regime_state?: string | null
score: number | null
oos_return: number | null
oos_sharpe: number | null
oos_max_drawdown: number | null
oos_positive_fold_ratio: number | null
oos_n_trades: number | null
confidence: 'low' | 'standard' | 'high'
valid_folds?: number | null
skipped_folds?: number | null
promoted_candidate_id?: string | null
published_strategy_id?: string | null
gate?: MiningCandidateGate | null
folds?: MiningFoldRow[]
}
export interface MiningTelemetry {
elapsed_ms?: number
peak_rss_bytes?: number
panel_scans?: number
matrix_bytes?: number
cache_hits?: number
fold_reuses?: number
serialized_result_bytes?: number
phase_ms?: Record<string, number>
}
export interface MiningRequestSummary {
asset_type: string
budget_profile: string
start: string | null
end: string | null
factor_count: number
strategy_count: number
commission_pct: number | null
stamp_tax_pct: number | null
slippage_bps: number | null
correlation_threshold: number | null
}
export interface MiningResult {
run_id: string
methodology_version: string
algorithm_version: string
data_as_of: string | null
summary: MiningResultSummary
request_summary?: MiningRequestSummary | null
factors: MiningFactorRow[]
correlation: {
labels: string[]
matrix: (number | null)[][]
pair_counts?: (number | null)[][]
threshold: number
}
regimes: MiningRegimeRow[]
candidates: MiningCandidateRow[]
folds: MiningFoldRow[]
telemetry: MiningTelemetry
}
export interface MiningEvent {
id: number
type: string
timestamp?: string
payload?: Record<string, unknown>
message?: string
}
export interface MiningScheduleConfig {
mining_schedule_enabled: boolean
mining_schedule_weekday: number
mining_budget_profile: Exclude<MiningBudgetProfile, 'exploratory'>
}
export type ResearchCandidateKind = 'factor' | 'strategy'
export type ResearchCandidateStatus = 'pending' | 'validated' | 'rejected'
@@ -1751,8 +2055,28 @@ export const api = {
if (start) params.set('start', start)
if (end) params.set('end', end)
const qs = params.toString()
return request<{ ok: boolean; computed: number }>(`/api/regime/recompute${qs ? `?${qs}` : ''}`, { method: 'POST' })
return request<{ ok: boolean; computed: number; phase_days?: number; mainline_rows?: number }>(`/api/regime/recompute${qs ? `?${qs}` : ''}`, { method: 'POST' })
},
regimePhases: (start?: string, end?: string) => {
const params = new URLSearchParams()
if (start) params.set('start', start)
if (end) params.set('end', end)
const qs = params.toString()
return request<PhaseSegments>(`/api/regime/phases${qs ? `?${qs}` : ''}`)
},
regimeMainline: (start?: string, end?: string, top = 10, kind: 'concept' | 'industry' = 'concept') => {
const params = new URLSearchParams({ top: String(top), kind })
if (start) params.set('start', start)
if (end) params.set('end', end)
return request<MainlineResult>(`/api/regime/mainline?${params.toString()}`)
},
regimeMainlineRecompute: () =>
request<{ ok: boolean; rows: number }>('/api/regime/mainline/recompute', { method: 'POST' }),
mainlineFilterUpdate: (payload: { min_members?: number; max_members?: number; blacklist?: string[] }) =>
request<MainlineFilter>('/api/settings/preferences/mainline-filter', {
method: 'PUT',
body: JSON.stringify(payload),
}),
limitLadder: (asOf?: string, extColumns?: string, direction?: 'up' | 'down') => {
const params = new URLSearchParams()
@@ -1820,6 +2144,64 @@ export const api = {
body: JSON.stringify(payload),
}),
miningRuns: () =>
request<{ items: MiningRun[] }>('/api/backtest/mining/runs'),
miningAvailability: (params: {
assetType: 'stock' | 'etf'
budgetProfile: MiningBudgetProfile
start?: string
end?: string
}) => {
const query = new URLSearchParams({
asset_type: params.assetType,
budget_profile: params.budgetProfile,
})
if (params.start) query.set('start', params.start)
if (params.end) query.set('end', params.end)
return request<MiningAvailability>(`/api/backtest/mining/availability?${query}`, {
quiet: true,
})
},
miningRun: (runId: string) =>
request<MiningRun>(`/api/backtest/mining/runs/${encodeURIComponent(runId)}`),
miningStart: (payload: MiningRequestV1) =>
request<MiningRun>('/api/backtest/mining/runs', {
method: 'POST',
body: JSON.stringify(payload),
}),
miningResult: (runId: string) =>
request<MiningResult>(`/api/backtest/mining/runs/${encodeURIComponent(runId)}/result`),
miningCancel: (runId: string) =>
request<MiningRun>(`/api/backtest/mining/runs/${encodeURIComponent(runId)}/cancel`, {
method: 'POST',
}),
miningPromote: (runId: string, signature: string) =>
request<ResearchCandidate>(
`/api/backtest/mining/runs/${encodeURIComponent(runId)}/candidates/${encodeURIComponent(signature)}/promote`,
{ method: 'POST' },
),
miningPublish: (runId: string, signature: string) =>
request<{ ok: boolean; strategy_id: string }>(
`/api/backtest/mining/runs/${encodeURIComponent(runId)}/candidates/${encodeURIComponent(signature)}/publish`,
{ method: 'POST' },
),
miningConfig: () =>
request<MiningScheduleConfig>('/api/backtest/mining/config'),
updateMiningConfig: (payload: Partial<MiningScheduleConfig>) =>
request<MiningScheduleConfig>('/api/backtest/mining/config', {
method: 'PATCH',
body: JSON.stringify(payload),
}),
researchCandidates: () =>
request<{ items: ResearchCandidate[] }>('/api/backtest/candidates'),
+376
View File
@@ -0,0 +1,376 @@
import { useSyncExternalStore } from 'react'
import { api, type MiningResult, type MiningRun, type MiningRunProgress, type MiningRunStatus } from './api'
export interface MiningTask {
runId: string | null
isPending: boolean
cancelling: boolean
reconnecting: boolean
run: MiningRun | null
progress: MiningRunProgress | null
result: MiningResult | null
previousResult: MiningResult | null
error: string | null
}
const ACTIVE_RUN_KEY = 'mining_active_run_id'
const TERMINAL_STATES = new Set<MiningRunStatus>([
'succeeded',
'succeeded_with_budget_exhausted',
'failed',
'cancelled',
'interrupted',
'skipped_prerequisite',
])
const SUCCESS_STATES = new Set<MiningRunStatus>([
'succeeded',
'succeeded_with_budget_exhausted',
])
const STATUS_POLL_INTERVAL_MS = 2000
let current: MiningTask = {
runId: null,
isPending: false,
cancelling: false,
reconnecting: false,
run: null,
progress: null,
result: null,
previousResult: null,
error: null,
}
let eventSource: EventSource | null = null
let connectionToken = 0
let statusPoll: {
runId: string
token: number
timer: ReturnType<typeof setTimeout> | null
} | null = null
const listeners = new Set<() => void>()
function emit() {
listeners.forEach(listener => listener())
}
function update(patch: Partial<MiningTask>) {
current = { ...current, ...patch }
emit()
}
function subscribe(listener: () => void) {
listeners.add(listener)
return () => listeners.delete(listener)
}
function stopStatusPolling() {
if (statusPoll?.timer) clearTimeout(statusPoll.timer)
statusPoll = null
}
function closeEvents() {
connectionToken += 1
stopStatusPolling()
eventSource?.close()
eventSource = null
}
function eventPayload(event: MessageEvent): Record<string, any> {
try {
const parsed = JSON.parse(event.data)
if (parsed && typeof parsed === 'object') {
return parsed.payload && typeof parsed.payload === 'object'
? { ...parsed, ...parsed.payload }
: parsed
}
} catch { /* ignore malformed progress events */ }
return {}
}
async function refreshTerminalRun(
runId: string,
fallbackStatus?: MiningRunStatus,
knownRun?: MiningRun,
token = connectionToken,
fallbackError?: string,
) {
try {
const run = knownRun ?? await api.miningRun(runId)
let result: MiningResult | null = null
if (SUCCESS_STATES.has(run.status)) {
result = await api.miningResult(runId)
if (result.run_id !== runId) throw new Error('任务结果与运行 ID 不匹配')
}
if (current.runId !== runId || connectionToken !== token) return
localStorage.removeItem(ACTIVE_RUN_KEY)
update({
run,
progress: run.progress ?? current.progress,
result,
isPending: false,
cancelling: false,
reconnecting: false,
error: run.error || fallbackError || null,
})
} catch (error) {
if (current.runId !== runId || connectionToken !== token) return
localStorage.removeItem(ACTIVE_RUN_KEY)
const run = current.run && fallbackStatus
? { ...current.run, status: fallbackStatus, error: fallbackError || current.run.error }
: current.run
update({
run,
result: null,
isPending: false,
cancelling: false,
reconnecting: false,
error: fallbackStatus === 'cancelled'
? '任务已取消'
: fallbackError || String((error as Error).message || error),
})
}
}
function startStatusPolling(runId: string, token: number, restart = false) {
if (restart) stopStatusPolling()
if (statusPoll?.runId === runId && statusPoll.token === token) return
stopStatusPolling()
const poll = { runId, token, timer: null as ReturnType<typeof setTimeout> | null }
statusPoll = poll
const pollStatus = async () => {
if (
statusPoll !== poll
|| current.runId !== runId
|| connectionToken !== token
|| !current.isPending
) return
try {
const run = await api.miningRun(runId)
if (statusPoll !== poll || current.runId !== runId || connectionToken !== token) return
update({
run,
progress: run.progress ?? current.progress,
cancelling: current.cancelling || run.status === 'cancelling',
})
if (TERMINAL_STATES.has(run.status)) {
closeEvents()
const terminalToken = connectionToken
await refreshTerminalRun(runId, run.status, run, terminalToken)
return
}
} catch {
// EventSource keeps reconnecting; polling is only a bounded status fallback.
}
if (
statusPoll !== poll
|| current.runId !== runId
|| connectionToken !== token
|| !current.isPending
) return
poll.timer = setTimeout(() => {
poll.timer = null
void pollStatus()
}, STATUS_POLL_INTERVAL_MS)
}
void pollStatus()
}
function connect(runId: string) {
closeEvents()
const token = connectionToken
const source = new EventSource(`/api/backtest/mining/runs/${encodeURIComponent(runId)}/events`)
eventSource = source
source.onopen = () => {
if (token !== connectionToken) return
update({ reconnecting: false })
if (!current.cancelling) stopStatusPolling()
}
source.addEventListener('progress', event => {
if (token !== connectionToken) return
update({
progress: eventPayload(event as MessageEvent) as unknown as MiningRunProgress,
reconnecting: false,
})
if (!current.cancelling) stopStatusPolling()
})
const onTerminal = (event: Event) => {
if (token !== connectionToken) return
const payload = eventPayload(event as MessageEvent)
const eventType = (event as MessageEvent).type
const status = (payload.status || eventType) as MiningRunStatus
closeEvents()
const terminalToken = connectionToken
void refreshTerminalRun(
runId,
status,
undefined,
terminalToken,
typeof payload.message === 'string' ? payload.message : undefined,
)
}
for (const type of [
'succeeded',
'succeeded_with_budget_exhausted',
'failed',
'cancelled',
'interrupted',
'skipped_prerequisite',
]) {
source.addEventListener(type, onTerminal)
}
source.onerror = () => {
if (token !== connectionToken || !current.isPending) return
update({ reconnecting: true })
startStatusPolling(runId, token)
}
}
export async function startMining(payload: Parameters<typeof api.miningStart>[0]) {
closeEvents()
localStorage.removeItem(ACTIVE_RUN_KEY)
const token = connectionToken
update({
runId: null,
isPending: true,
cancelling: false,
reconnecting: false,
run: null,
progress: { phase: 'queued', label: '创建任务' },
result: null,
previousResult: current.result ?? current.previousResult,
error: null,
})
try {
const run = await api.miningStart(payload)
if (connectionToken !== token || current.runId !== null) return
localStorage.setItem(ACTIVE_RUN_KEY, run.run_id)
update({
runId: run.run_id,
run,
progress: run.progress ?? current.progress,
isPending: !TERMINAL_STATES.has(run.status),
})
if (TERMINAL_STATES.has(run.status)) {
await refreshTerminalRun(run.run_id, run.status, run, token)
} else {
connect(run.run_id)
}
} catch (error) {
if (connectionToken !== token || current.runId !== null) return
update({
isPending: false,
error: String((error as Error).message || error),
})
}
}
export async function cancelMining() {
if (!current.runId || !current.isPending || current.cancelling) return
const runId = current.runId
const token = connectionToken
update({ cancelling: true, error: null })
startStatusPolling(runId, token, true)
try {
const run = await api.miningCancel(runId)
if (current.runId !== runId || connectionToken !== token) return
update({
run,
progress: run.progress ?? current.progress,
cancelling: !TERMINAL_STATES.has(run.status),
})
if (TERMINAL_STATES.has(run.status)) {
closeEvents()
const terminalToken = connectionToken
await refreshTerminalRun(runId, run.status, run, terminalToken)
}
} catch (error) {
if (current.runId !== runId || connectionToken !== token) return
update({
cancelling: true,
reconnecting: true,
error: String((error as Error).message || error),
})
startStatusPolling(runId, token, true)
}
}
export async function attachMiningRun(runId: string): Promise<boolean> {
closeEvents()
const token = connectionToken
const previousResult = current.result ?? current.previousResult
update({
runId,
isPending: true,
cancelling: false,
reconnecting: true,
run: null,
progress: { phase: 'reconnecting', label: '读取任务状态' },
result: null,
previousResult,
error: null,
})
try {
const run = await api.miningRun(runId)
if (current.runId !== runId || connectionToken !== token) return false
update({
run,
progress: run.progress ?? null,
isPending: !TERMINAL_STATES.has(run.status),
cancelling: run.status === 'cancelling',
reconnecting: false,
error: run.error ?? null,
})
if (TERMINAL_STATES.has(run.status)) {
await refreshTerminalRun(runId, run.status, run, token)
} else {
localStorage.setItem(ACTIVE_RUN_KEY, runId)
connect(runId)
}
return true
} catch (error) {
if (current.runId !== runId || connectionToken !== token) return false
localStorage.removeItem(ACTIVE_RUN_KEY)
update({
isPending: false,
reconnecting: false,
error: String((error as Error).message || error),
})
return false
}
}
export function tryReconnectMining(): boolean {
const runId = localStorage.getItem(ACTIVE_RUN_KEY)
if (!runId) return false
void attachMiningRun(runId)
return true
}
export function clearMiningTask() {
closeEvents()
localStorage.removeItem(ACTIVE_RUN_KEY)
current = {
runId: null,
isPending: false,
cancelling: false,
reconnecting: false,
run: null,
progress: null,
result: null,
previousResult: current.result ?? current.previousResult,
error: null,
}
emit()
}
export function useMiningTask(): MiningTask {
return useSyncExternalStore(subscribe, () => current, () => current)
}
+21 -3
View File
@@ -26,7 +26,11 @@ export const QK = {
watchlistGroups: ['watchlist-groups'] as const,
watchlistQuotes: ['watchlist-quotes'] as const,
watchlistEnriched: (ext?: string) => ['watchlist-enriched', ext] as const,
watchlistKlineBatch: (symbols: string) => ['watchlist-kline-batch', symbols] as const,
// 不用 watchlist- 前缀: 日K历史盘中几乎不变, 若被 SSE quotes_updated 高频失效
// (expert 1s) 会导致全自选日K每秒重拉, staleTime 形同虚设。
// 刷新点: staleTime 过期 + Watchlist 增删自选/改蜡烛天数时的手动失效;
// 当日最后一根蜡烛由 Watchlist 用 enriched 实时 OHLC 前端修补 (零额外请求)。
watchlistKlineBatch: (symbols: string) => ['kline-batch', symbols] as const,
// 不用 watchlist- 前缀: 避免被 SSE quotes_updated 高频失效(expert 1s/pro 2s)
// 导致每次都拉 TickFlow 触限流。分时图用固定 refetchInterval 刷新即可。
minuteBatch: (symbols: string) => ['minute-batch', symbols] as const,
@@ -45,8 +49,16 @@ export const QK = {
// Backtest
backtestStatus: ['backtest-status'] as const,
factorColumns: ['backtest-factor-columns'] as const,
miningRuns: ['backtest-mining-runs'] as const,
miningAvailability: (assetType: string, profile: string, start: string, end: string) =>
['backtest-mining-availability', assetType, profile, start, end] as const,
miningRun: (id: string) => ['backtest-mining-run', id] as const,
miningResult: (id: string) => ['backtest-mining-result', id] as const,
miningConfig: ['backtest-mining-config'] as const,
researchCandidates: ['research-candidates'] as const,
strategyLinkOptions: ['strategy-link-options'] as const,
strategyLinkOptions: (assetType?: 'stock' | 'etf') => assetType
? ['strategy-link-options', assetType] as const
: ['strategy-link-options'] as const,
strategyDetail: (id: string) => ['strategy-detail', id] as const,
// Data / Pipeline
@@ -96,6 +108,8 @@ export const QK = {
regimeLatest: ['regime-latest'] as const,
regimeStates: (days: number) => ['regime-states', days] as const,
regimeCoverage: ['regime-coverage'] as const,
regimePhases: (start?: string, end?: string) => ['regime-phases', start ?? '', end ?? ''] as const,
regimeMainline: (kind: string, start?: string, end?: string) => ['regime-mainline', kind, start ?? '', end ?? ''] as const,
} as const
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
@@ -107,7 +121,11 @@ export const QK = {
// 且在 monitor "重算" 窗口内读到空结果, 造成策略列表闪烁 (变 0 → 空失效 → 又出现)。
export const SSE_INVALIDATE_PREFIXES = [
'watchlist',
// 精确前缀: 只命中自选页的实时数据 (quotes/enriched)。不能用宽泛的 'watchlist' ——
// 会误伤 ['watchlist'] (自选列表) 和 ['watchlist-groups'] (分组配置, 只随手动操作变化)。
// 旧设置里的 'watchlist' 单开关由 useQuoteStream 兼容读取。
'watchlist-quotes',
'watchlist-enriched',
'quote-status',
'index-quotes',
'overview-market',

Some files were not shown because too many files have changed in this diff Show More