mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
Merge branch 'v0.2'
# Conflicts: # dev.sh
This commit is contained in:
+1
-1
@@ -18,7 +18,7 @@ LOG_LEVEL=INFO
|
||||
# 首次启动时预置访问密码(可选)。公网服务器部署时填入,免去 SSH 端口转发设密码。
|
||||
# 仅在尚未设置密码时生效(一次性初始化);设过后改密码请用页面 UI, 此处不再读取。
|
||||
# 建议至少 6 位。.env 文件权限保持 600 且不要提交到 Git。
|
||||
AUTH_PASSWORD=
|
||||
AUTH_PASSWORD=''
|
||||
|
||||
# Optional backend dependency extras for Docker and ./dev.sh / .\dev.ps1.
|
||||
# Set to legacy-cpu on older CPUs without AVX2/FMA support.
|
||||
|
||||
@@ -179,7 +179,7 @@ jobs:
|
||||
run: |
|
||||
brew install create-dmg
|
||||
create-dmg \
|
||||
--volname "TickFlow Stock Panel" \
|
||||
--volname "Tick Stock Panel" \
|
||||
--window-pos 200 120 \
|
||||
--window-size 600 400 \
|
||||
--icon-size 100 \
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
修改、调试或审查本仓库前,必须完整阅读并遵循根目录的 [`CONTRIBUTING.md`](CONTRIBUTING.md)。其中定义了项目架构、数据契约、数据源插件化、缓存与性能要求、测试矩阵以及 PR 复审和合并标准。
|
||||
|
||||
涉及代码二次开发、前端插槽、后端可替换策略、扩展注册或上游升级兼容时,还必须阅读 [`docs/secondary-development.md`](docs/secondary-development.md)。该文档区分当前已实现能力与目标扩展契约;不得根据设计示例虚构尚不存在的 API。
|
||||
|
||||
同时遵守以下规则:
|
||||
|
||||
- 先理解调用链和现有测试,再进行修改。
|
||||
|
||||
+3
-1
@@ -1,9 +1,11 @@
|
||||
# TickFlow Stock Panel 贡献、AI 开发与复审指南
|
||||
# Tick Stock Panel 贡献、AI 开发与复审指南
|
||||
|
||||
本文档适用于整个仓库,供贡献者、AI 编码代理和 PR 审查者共同使用。目标是让改动落在正确的模块,保持数据口径、插件化和兼容性一致,并通过可复现的验证减少返工。
|
||||
|
||||
`CONTRIBUTING.md` 是项目贡献与审查规范,不替代 `README.md`、`docs/` 中的用户文档和领域文档。所有贡献者和 AI 编码代理在修改代码、提交或审查 PR 前都应阅读本文档。
|
||||
|
||||
涉及代码二次开发时,同时遵循 [`docs/secondary-development.md`](docs/secondary-development.md)。前端优先使用真实存在的受控插槽或注册入口,后端优先使用小粒度策略接口和依赖注入;现有扩展点无法表达核心行为变化时允许直接修改源码,但必须保持改动聚焦并补足兼容性验证。
|
||||
|
||||
## 1. 基本原则
|
||||
|
||||
### 1.1 修改前先理解
|
||||
|
||||
+2
-1
@@ -125,7 +125,8 @@ COPY --from=stocksdk-builder /build/node_modules ./app/plugins/stocksdk/node_mod
|
||||
COPY tiers.yaml /app/tiers.yaml
|
||||
ENV STATIC_DIR=/app/static \
|
||||
TIERS_YAML=/app/tiers.yaml \
|
||||
DATA_DIR=/app/data
|
||||
DATA_DIR=/app/data \
|
||||
TICKFLOW_ENV_FILE=/app/.env
|
||||
|
||||
# Frontend 静态产物
|
||||
COPY --from=frontend-builder /build/dist ./static
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
# 📈 A股智能量化工作台
|
||||
|
||||
[](https://github.com/shy3130/tickflow-stock-panel)
|
||||
[](https://github.com/shy3130/tick-stock-panel)
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
[](https://react.dev/)
|
||||
[](https://tickflow.org/auth/register?ref=V3KDKGXPEA)
|
||||
[](./Dockerfile)
|
||||
[](https://github.com/shy3130/tickflow-stock-panel/stargazers)
|
||||
[](https://github.com/shy3130/tick-stock-panel/stargazers)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -54,11 +54,14 @@
|
||||
| :--------------- | :--------------------------------------------------------------------- | :-------------------------------- |
|
||||
| 🔍 **选股引擎** | 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 持久任务 | [features.md](./docs/features.md) |
|
||||
| ⛏️ **因子挖掘** | 嵌套样本外搜索多因子排名组合,与自有策略对照,候选库显式发布、永不自动上线 | [mining.md](./docs/mining.md) |
|
||||
| 🌡️ **市场环境** | 情绪周期 6 阶段(连板梯队驱动)+ 概念/行业主线排名,与 5 档环境分并存 | [market-phase.md](./docs/market-phase.md) |
|
||||
| 🚨 **异动监控** | 交易所异动规则口径(3/10/30 日偏离值),盘中实时接近度,系统告警与推送接入 | — |
|
||||
| 📡 **监控中心** | 四类监控(策略/个股信号/价格/异动),多条件 AND/OR + 语音播报 + 飞书推送 | [features.md](./docs/features.md) |
|
||||
| 📈 **个股分析** | 9 类关键价位 + AI 四维分析(技术/基本面/财务/消息面) | [features.md](./docs/features.md) |
|
||||
| 🏆 **连板梯队** | 连板层级统计 + 概念涨幅轮动 + 盘后 AI 复盘 + 炸板/翘板预警 | [features.md](./docs/features.md) |
|
||||
| 🧰 **数据扩展** | TickFlow 多源 + 第三方接入(接口/推送/CSV/JSON)同台分析 | [features.md](./docs/features.md) |
|
||||
| 🧰 **数据扩展** | 数据源插件化(stock-sdk 示例 + YAML 自定义源),扩展字段配成一级页面同台分析 | [custom-data-source.md](./docs/custom-data-source.md) |
|
||||
|
||||
|
||||
|
||||
@@ -69,7 +72,7 @@
|
||||
|
||||
**📊 行情总览**
|
||||
- **看板** Dashboard — 市场情绪评分 + 涨跌/成交额榜单 + 概念领涨领跌 + 大盘异动事件流,一日全貌
|
||||
- **自选** Watchlist — 自选股池,表格/卡片双视图,换手/量比/RSI 等实时指标
|
||||
- **自选** Watchlist — 自选股池,多分组管理(M:N),表格/卡片双视图,换手/量比/RSI 等实时指标
|
||||
- **指数** Indices — 沪深指数浏览与同步
|
||||
|
||||
**🔍 选股与回测**
|
||||
@@ -77,22 +80,25 @@
|
||||
- **回测** Backtest — 两种模式:
|
||||
- **因子回测** — IC/IR、分层收益、多空组合,先筛掉无效指标
|
||||
- **策略回测** — 净值曲线、回撤、夏普、胜率,支持 T+1/手续费/滑点/止损,SSE 流式进度
|
||||
- **挖掘** Mining — 嵌套样本外因子与策略挖掘:训练区间因子方向重估 + 相关性去重 + 多因子排名组合搜索,自有策略作对照轨;候选入库,显式确认后才发布,永不自动上线
|
||||
|
||||
**📈 个股与板块分析**
|
||||
- **个股分析** Stock Analysis (Beta) — 日K + 9 类关键价位 + AI 四维分析(技术/基本面/财务/消息面)
|
||||
- **财务分析** Financials — 利润表/资负表/现金流/关键指标 + AI 解读
|
||||
- **概念分析** Concept Analysis — ths 概念涨幅轮动矩阵 + 领涨/领跌主线 + 个股穿透
|
||||
- **行业分析** Industry Analysis — 行业分层涨幅轮动 + 领涨/领跌主线 + 成分股
|
||||
- **市场环境** Regime — 情绪周期 6 阶段(冰点/启动/主升/高潮/退潮/修复,连板梯队驱动,EMA 平滑 + 2 日确认)+ 概念/行业主线排名,与 5 档环境分并存
|
||||
- **连板梯队** Limit Up Ladder — 连板层级统计 + 概念/行业分布 + 封单监控(可切换连跌梯队)
|
||||
|
||||
**🔔 监控与复盘**
|
||||
- **监控中心** Monitor — 策略/个股信号/价格/异动四类规则,盘中实时弹窗 + 语音播报(播报个股名称与信号) + 触发记录持久化
|
||||
- **监控中心** Monitor — 策略/个股信号/价格/异动四类规则,支持自选分组作用域,盘中实时弹窗 + 语音播报(播报个股名称与信号) + 触发记录持久化
|
||||
- **异动监控** Abnormal Moves — 按交易所异动规则口径(3日 ±20%/±30%/±40% · 10日 +100% · 30日 +200%)实时计算个股偏离值接近度,盯住异动边缘名单;触发记录 + 站内通知/飞书·企微推送
|
||||
- **复盘** Review (Beta) — 盘后 AI 自动生成市场复盘,可定时执行、推送飞书、下载 Markdown
|
||||
|
||||
**🗄️ 数据与扩展**
|
||||
- **数据** Data — 本地数据画像与同步状态(维表/日K/除权/Enriched/指数/ETF/分钟K/财务),盘后管道与历史扩展
|
||||
- **扩展分析** (动态菜单) — 把任意第三方/扩展数据字段配成一级菜单,与内置数据同台分析
|
||||
- **设置** Settings — TickFlow Key 与订阅档位、AI 接口、实时监控、扩展页面、信号库、菜单与系统设置
|
||||
- **设置** Settings — 数据源与能力检测、AI 接口、实时监控、扩展页面、信号库、菜单与系统设置
|
||||
|
||||
</details>
|
||||
|
||||
@@ -210,7 +216,7 @@ PORT=3018 # 服务端口
|
||||
| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
|
||||
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) |
|
||||
| **回测** | vectorbt(全项目唯一 pandas 边界) |
|
||||
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK · 其他数据源后续迭代实装 |
|
||||
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK · 插件化扩展(stock-sdk 示例插件 · YAML 自定义源) |
|
||||
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
|
||||
| **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
|
||||
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
|
||||
@@ -225,6 +231,7 @@ PORT=3018 # 服务端口
|
||||
| 2-3 | Polars enriched 流水线 · Screener · vectorbt 回测(T+1/手续费/止损) | ✅ |
|
||||
| 4-5 | 监控引擎 · 四类监控规则 · 实时 SSE 推送 · 持久化记录 | ✅ |
|
||||
| 6 | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析) | ✅ |
|
||||
| **v0.2** | 因子挖掘全链路 · 市场阶段与主线识别 · 异动监控 · 数据源插件化 | ✅ |
|
||||
| **v2** | Webhook 推送· 板块异动 · 早晚报 · 更多扩展 | 🚧 |
|
||||
|
||||
---
|
||||
@@ -238,6 +245,10 @@ 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/market-phase.md](./docs/market-phase.md) | 市场情绪周期 6 阶段与概念/行业主线识别的口径与设计 |
|
||||
| [docs/plugin-development.md](./docs/plugin-development.md) | 数据源插件开发规范(以 stock-sdk 为参考实现) |
|
||||
| [docs/secondary-development.md](./docs/secondary-development.md) | 代码二次开发、前端插槽、后端策略接口与 AI 开发模板 |
|
||||
| [backend/app/strategy/prompts/strategy-guide.md](./backend/app/strategy/prompts/strategy-guide.md) | 策略开发完整规范(AI 生成与手写) |
|
||||
|
||||
fork同时请点个star哦,欢迎 Issue 和 PR。
|
||||
@@ -258,7 +269,7 @@ fork同时请点个star哦,欢迎 Issue 和 PR。
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT](./LICENSE) © tickflow-stock-panel contributors
|
||||
[MIT](./LICENSE) © tick-stock-panel contributors
|
||||
|
||||
本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""TickFlow Stock Panel backend."""
|
||||
"""Tick Stock Panel backend."""
|
||||
|
||||
import sys
|
||||
|
||||
__version__ = "0.1.88"
|
||||
__version__ = "0.2.1"
|
||||
|
||||
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
|
||||
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""异动边缘监控 API — 按交易所异动规则口径统计接近触发的个股。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from app.services.abnormal_moves import build_overview
|
||||
|
||||
router = APIRouter(prefix="/api/abnormal", tags=["abnormal"])
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
def abnormal_overview(
|
||||
request: Request,
|
||||
min_closeness: float = Query(0.5, ge=0.0, le=1.0),
|
||||
limit: int = Query(200, ge=1, le=1000),
|
||||
):
|
||||
"""异动边缘总览: 规则表 + 各窗口实时偏离 + 接近度排序。
|
||||
|
||||
min_closeness: 0.5=观察 / 0.7=边缘 / 1.0=已触发。
|
||||
"""
|
||||
repo = request.app.state.repo
|
||||
quote_service = getattr(request.app.state, "quote_service", None)
|
||||
return build_overview(repo, quote_service, min_closeness=min_closeness, limit=limit)
|
||||
+194
-31
@@ -134,11 +134,11 @@ def factor_columns():
|
||||
|
||||
|
||||
class FactorBacktestRequest(BaseModel):
|
||||
factor_name: str
|
||||
factor_name: str = Field(..., min_length=1, max_length=64)
|
||||
symbols: list[str] | None = None
|
||||
start: date | None = None
|
||||
end: date | None = None
|
||||
n_groups: int = 5
|
||||
n_groups: int = Field(5, ge=2, le=10)
|
||||
rebalance: Literal["daily", "weekly", "monthly"] = "monthly"
|
||||
weight: Literal["equal", "factor_weight"] = "equal"
|
||||
fees_pct: float = 0.0002
|
||||
@@ -149,7 +149,10 @@ class FactorBacktestRequest(BaseModel):
|
||||
@router.post("/factor/run")
|
||||
def factor_run(req: FactorBacktestRequest, request: Request):
|
||||
"""因子回测 — IC/IR 分析 + 分层回测。"""
|
||||
from app.backtest.factor import FactorBacktestService, FactorConfig
|
||||
from app.backtest.factor import FACTOR_COLUMNS, FactorBacktestService, FactorConfig
|
||||
|
||||
if req.factor_name not in {item["id"] for item in FACTOR_COLUMNS}:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的因子: {req.factor_name}")
|
||||
|
||||
engine = _get_engine(request)
|
||||
svc = FactorBacktestService(engine)
|
||||
@@ -180,6 +183,139 @@ def factor_run(req: FactorBacktestRequest, request: Request):
|
||||
return asdict(result)
|
||||
|
||||
|
||||
class FactorBatchRequest(BaseModel):
|
||||
factor_names: list[str] = Field(..., min_length=1, max_length=64)
|
||||
symbols: list[str] | None = None
|
||||
start: date | None = None
|
||||
end: date | None = None
|
||||
n_groups: int = Field(5, ge=2, le=10)
|
||||
rebalance: Literal["daily", "weekly", "monthly"] = "monthly"
|
||||
weight: Literal["equal", "factor_weight"] = "equal"
|
||||
fees_pct: float = 0.0002
|
||||
slippage_bps: float = 5.0
|
||||
asset_type: str = "stock"
|
||||
|
||||
|
||||
@router.post("/factor/batch")
|
||||
def factor_batch(req: FactorBatchRequest, request: Request):
|
||||
"""批量筛选因子, 同一批次只加载并计算一次数据面板。"""
|
||||
from app.backtest.factor import (
|
||||
FACTOR_COLUMNS,
|
||||
FactorBacktestService,
|
||||
FactorBatchConfig,
|
||||
)
|
||||
|
||||
factor_names = list(dict.fromkeys(req.factor_names))
|
||||
allowed = {item["id"] for item in FACTOR_COLUMNS}
|
||||
invalid = [name for name in factor_names if name not in allowed]
|
||||
if invalid:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的因子: {', '.join(invalid)}")
|
||||
|
||||
end = req.end or date.today()
|
||||
start = _resolve_start(req, end, STRATEGY_DEFAULT_DAYS)
|
||||
_guard_server_backtest_range(start, end)
|
||||
symbols = req.symbols if req.symbols else None
|
||||
if symbols is not None and len(symbols) > FACTOR_MAX_SYMBOLS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"指定标的最多支持 {FACTOR_MAX_SYMBOLS} 只, 请缩小标的范围。",
|
||||
)
|
||||
|
||||
svc = FactorBacktestService(_get_engine(request))
|
||||
result = svc.run_batch(FactorBatchConfig(
|
||||
factor_names=factor_names,
|
||||
symbols=symbols,
|
||||
start=start,
|
||||
end=end,
|
||||
n_groups=req.n_groups,
|
||||
rebalance=req.rebalance,
|
||||
weight=req.weight,
|
||||
fees_pct=req.fees_pct,
|
||||
slippage_bps=req.slippage_bps,
|
||||
asset_type=req.asset_type,
|
||||
))
|
||||
return asdict(result)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 研究候选方案
|
||||
# ================================================================
|
||||
|
||||
class CandidateCreateRequest(BaseModel):
|
||||
kind: Literal["factor", "strategy"]
|
||||
name: str = Field(..., min_length=1, max_length=80)
|
||||
source_id: str = Field(..., min_length=1, max_length=120)
|
||||
config: dict = Field(default_factory=dict)
|
||||
metrics: dict = Field(default_factory=dict)
|
||||
data_as_of: date | None = None
|
||||
status: Literal["pending", "validated", "rejected"] = "pending"
|
||||
|
||||
|
||||
class CandidateUpdateRequest(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=80)
|
||||
status: Literal["pending", "validated", "rejected"] | None = None
|
||||
|
||||
|
||||
def _candidate_store():
|
||||
from app.backtest.candidates import CandidateStore
|
||||
|
||||
return CandidateStore(settings.data_dir)
|
||||
|
||||
|
||||
def _raise_candidate_error(exc: Exception) -> None:
|
||||
from app.backtest.candidates import CandidateValidationError
|
||||
|
||||
status_code = 400 if isinstance(exc, CandidateValidationError) else 500
|
||||
raise HTTPException(status_code=status_code, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/candidates")
|
||||
def candidates_list():
|
||||
try:
|
||||
return {"items": _candidate_store().list()}
|
||||
except Exception as exc:
|
||||
_raise_candidate_error(exc)
|
||||
|
||||
|
||||
@router.post("/candidates")
|
||||
def candidate_create(req: CandidateCreateRequest):
|
||||
try:
|
||||
return _candidate_store().create(
|
||||
kind=req.kind,
|
||||
name=req.name,
|
||||
source_id=req.source_id,
|
||||
config=req.config,
|
||||
metrics=req.metrics,
|
||||
data_as_of=req.data_as_of.isoformat() if req.data_as_of else None,
|
||||
status=req.status,
|
||||
)
|
||||
except Exception as exc:
|
||||
_raise_candidate_error(exc)
|
||||
|
||||
|
||||
@router.patch("/candidates/{candidate_id}")
|
||||
def candidate_update(candidate_id: str, req: CandidateUpdateRequest):
|
||||
if req.name is None and req.status is None:
|
||||
raise HTTPException(status_code=400, detail="至少提供一个需要更新的字段")
|
||||
try:
|
||||
return _candidate_store().update(candidate_id, name=req.name, status=req.status)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail="候选方案不存在") from exc
|
||||
except Exception as exc:
|
||||
_raise_candidate_error(exc)
|
||||
|
||||
|
||||
@router.delete("/candidates/{candidate_id}")
|
||||
def candidate_delete(candidate_id: str):
|
||||
try:
|
||||
_candidate_store().delete(candidate_id)
|
||||
return {"ok": True}
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail="候选方案不存在") from exc
|
||||
except Exception as exc:
|
||||
_raise_candidate_error(exc)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 策略回测
|
||||
# ================================================================
|
||||
@@ -245,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 流式回测 (实时进度 + 可取消 + 支持重连) ───────────────────
|
||||
@@ -273,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 与其他访问互斥。"""
|
||||
@@ -452,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()
|
||||
@@ -750,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))
|
||||
|
||||
@@ -965,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
@@ -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
|
||||
|
||||
@@ -79,6 +79,8 @@ class PullConfigReq(BaseModel):
|
||||
field_map: dict[str, str] | None = None # external → internal field name
|
||||
schedule_minutes: int = Field(1440, ge=1)
|
||||
enabled: bool = False
|
||||
time_window_start: str | None = None # "HH:MM", None=不限
|
||||
time_window_end: str | None = None # "HH:MM", None=不限
|
||||
|
||||
|
||||
class DetectUrlReq(BaseModel):
|
||||
@@ -608,6 +610,8 @@ def configure_pull(request: Request, config_id: str, body: PullConfigReq):
|
||||
field_map=body.field_map,
|
||||
schedule_minutes=body.schedule_minutes,
|
||||
enabled=body.enabled,
|
||||
time_window_start=body.time_window_start,
|
||||
time_window_end=body.time_window_end,
|
||||
last_run=old_pull.last_run if old_pull else None,
|
||||
last_status=old_pull.last_status if old_pull else None,
|
||||
last_message=old_pull.last_message if old_pull else None,
|
||||
|
||||
+174
-61
@@ -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:
|
||||
@@ -268,6 +275,47 @@ def _get_price_limit_info(
|
||||
return info
|
||||
|
||||
|
||||
def _get_previous_closes(
|
||||
repo,
|
||||
symbol: str,
|
||||
trade_dates: list[date],
|
||||
asset_type: str,
|
||||
) -> dict[date, float | None]:
|
||||
"""Return the previous trading day's adjusted close for each session."""
|
||||
if not trade_dates:
|
||||
return {}
|
||||
start = min(trade_dates) - timedelta(days=45)
|
||||
end = max(trade_dates)
|
||||
try:
|
||||
daily = repo.get_daily_asset(
|
||||
asset_type,
|
||||
symbol,
|
||||
start,
|
||||
end,
|
||||
columns=["date", "close"],
|
||||
).sort("date")
|
||||
except Exception:
|
||||
daily = None
|
||||
if daily is None or daily.is_empty():
|
||||
return {trade_date: None for trade_date in trade_dates}
|
||||
|
||||
closes: list[tuple[date, float]] = []
|
||||
for daily_date, close in daily.select(["date", "close"]).iter_rows():
|
||||
if close is None:
|
||||
continue
|
||||
numeric = float(close)
|
||||
if math.isfinite(numeric) and numeric > 0:
|
||||
closes.append((daily_date, numeric))
|
||||
|
||||
result: dict[date, float | None] = {}
|
||||
for trade_date in trade_dates:
|
||||
result[trade_date] = next(
|
||||
(close for daily_date, close in reversed(closes) if daily_date < trade_date),
|
||||
None,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/daily")
|
||||
def get_daily(
|
||||
request: Request,
|
||||
@@ -336,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
|
||||
@@ -353,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
|
||||
@@ -534,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}
|
||||
|
||||
@@ -621,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():
|
||||
@@ -671,14 +696,85 @@ 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}
|
||||
|
||||
|
||||
@router.get("/minute-range")
|
||||
def get_minute_range(
|
||||
request: Request,
|
||||
symbol: str = Query(..., description="标的代码"),
|
||||
days: int = Query(10, ge=1, le=20, description="最近交易日数量"),
|
||||
):
|
||||
"""读取单只标的最近 N 个已落库交易日的分钟 K。"""
|
||||
import polars as pl
|
||||
|
||||
repo = request.app.state.repo
|
||||
asset_type = repo.resolve_asset_type(symbol)
|
||||
stock_info = (
|
||||
_get_stock_info(repo, symbol)
|
||||
if asset_type == "stock"
|
||||
else _get_asset_info(repo, symbol, asset_type)
|
||||
)
|
||||
base_response = {
|
||||
"symbol": symbol,
|
||||
"name": stock_info.get("name"),
|
||||
"asset_type": asset_type,
|
||||
"requested_days": days,
|
||||
}
|
||||
|
||||
# 指数分钟 K 不落本地仓库, 最新分时仍由 /api/index/minute 实时读取。
|
||||
if asset_type == "index":
|
||||
return {**base_response, "sessions": [], "source": "none"}
|
||||
|
||||
end = cn_today()
|
||||
start = end - timedelta(days=days * 3 + 20)
|
||||
minute = repo.get_minute_range([symbol], start, end, asset_type=asset_type)
|
||||
if minute.is_empty() or "datetime" not in minute.columns:
|
||||
return {**base_response, "sessions": [], "source": "none"}
|
||||
|
||||
minute = minute.with_columns(
|
||||
pl.col("datetime").dt.date().alias("_trade_date"),
|
||||
)
|
||||
trade_dates = sorted(minute["_trade_date"].unique().to_list())[-days:]
|
||||
previous_closes = _get_previous_closes(repo, symbol, trade_dates, asset_type)
|
||||
row_columns = [
|
||||
column
|
||||
for column in (
|
||||
"datetime", "open", "high", "low", "close", "volume", "amount"
|
||||
)
|
||||
if column in minute.columns
|
||||
]
|
||||
sessions = []
|
||||
for trade_date in trade_dates:
|
||||
rows = (
|
||||
minute.filter(pl.col("_trade_date") == trade_date)
|
||||
.sort("datetime")
|
||||
.select(row_columns)
|
||||
.to_dicts()
|
||||
)
|
||||
if rows:
|
||||
sessions.append({
|
||||
"date": trade_date.isoformat(),
|
||||
"prev_close": previous_closes.get(trade_date),
|
||||
"rows": rows,
|
||||
})
|
||||
|
||||
return {
|
||||
**base_response,
|
||||
"sessions": sessions,
|
||||
"source": "local" if sessions else "none",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/minute")
|
||||
def get_minute(
|
||||
request: Request,
|
||||
@@ -721,13 +817,20 @@ def get_minute(
|
||||
price_limit = _get_price_limit_info(
|
||||
repo, symbol, trade_date, asset_type, stock_name,
|
||||
)
|
||||
prev_close = _get_previous_closes(
|
||||
repo, symbol, [trade_date], asset_type,
|
||||
).get(trade_date)
|
||||
return {
|
||||
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
|
||||
"date": str(trade_date), "rows": df.to_dicts(), "source": "live",
|
||||
"asset_type": asset_type,
|
||||
"price_limit": price_limit,
|
||||
"prev_close": prev_close,
|
||||
}
|
||||
|
||||
prev_close = _get_previous_closes(
|
||||
repo, symbol, [trade_date], asset_type,
|
||||
).get(trade_date)
|
||||
price_limit = _get_price_limit_info(
|
||||
repo, symbol, trade_date, asset_type, stock_name,
|
||||
)
|
||||
@@ -758,6 +861,7 @@ def get_minute(
|
||||
"date": str(trade_date), "rows": df.to_dicts(), "source": "local",
|
||||
"asset_type": asset_type,
|
||||
"price_limit": price_limit,
|
||||
"prev_close": prev_close,
|
||||
}
|
||||
|
||||
# 本地不完整或无数据 → 从 TickFlow 实时拉取
|
||||
@@ -768,6 +872,7 @@ def get_minute(
|
||||
"source": "live" if not live_df.is_empty() else "none",
|
||||
"asset_type": asset_type,
|
||||
"price_limit": price_limit,
|
||||
"prev_close": prev_close,
|
||||
}
|
||||
|
||||
|
||||
@@ -813,7 +918,7 @@ async def sync_minute(request: Request):
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot, LONG_JOB_TIMEOUT_S
|
||||
from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot
|
||||
from app.api.data import invalidate_storage_cache
|
||||
from app.services.preferences import get_minute_sync_days
|
||||
from app.tickflow.capabilities import Cap
|
||||
@@ -836,7 +941,7 @@ async def sync_minute(request: Request):
|
||||
extend_flag = body.get("extend")
|
||||
|
||||
# 分钟K全市场同步是长任务(数据量是日K的 ~240 倍),用更宽松的卡死阈值
|
||||
job_id, is_new = job_store.create(timeout_s=LONG_JOB_TIMEOUT_S)
|
||||
job_id, is_new = job_store.create(long_running=True)
|
||||
if not is_new:
|
||||
return {"status": "reused", "job_id": job_id}
|
||||
|
||||
@@ -909,13 +1014,21 @@ async def sync_minute_single(request: Request, body: dict):
|
||||
body: { "symbol": "000001.SZ" }
|
||||
用于个股分时图"获取数据"按钮: 本地无数据时单独拉取并持久化。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from app.services.preferences import get_minute_sync_days
|
||||
from app.tickflow.capabilities import Cap
|
||||
|
||||
symbol = body.get("symbol", "").strip()
|
||||
if not symbol:
|
||||
raise HTTPException(status_code=400, detail="symbol 不能为空")
|
||||
|
||||
requested_days = body.get("days")
|
||||
if requested_days is not None:
|
||||
if isinstance(requested_days, bool) or not isinstance(requested_days, int):
|
||||
raise HTTPException(status_code=400, detail="days 必须是整数")
|
||||
if requested_days < 1 or requested_days > 30:
|
||||
raise HTTPException(status_code=400, detail="days 必须在 1 到 30 之间")
|
||||
|
||||
repo = request.app.state.repo
|
||||
capset = request.app.state.capabilities
|
||||
|
||||
@@ -927,11 +1040,11 @@ async def sync_minute_single(request: Request, body: dict):
|
||||
if not _minute_allowed(capset):
|
||||
raise HTTPException(status_code=403, detail="需要 Pro+ 权限")
|
||||
|
||||
days = get_minute_sync_days()
|
||||
days = requested_days if requested_days is not None else get_minute_sync_days()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _run():
|
||||
return kline_sync.sync_and_persist_minute([symbol], repo, capset, days=days)
|
||||
return kline_sync.sync_and_persist_minute([symbol], repo, capset, days=days, force_full_days=True)
|
||||
|
||||
written = await loop.run_in_executor(_long_task_executor, _run)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -77,10 +77,12 @@ class RuleModel(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
enabled: bool = True
|
||||
type: str # strategy | signal | price | market | sector
|
||||
type: str # strategy | signal | price | market | sector | abnormal
|
||||
asset_type: str = "stock" # stock | etf (etf: strategy 型走 ETF 历史加载器)
|
||||
scope: str = "symbols" # symbols | all | sector
|
||||
scope: str = "symbols" # symbols | all | sector | watchlist_group
|
||||
symbols: list[str] = []
|
||||
# watchlist_group 作用域: 绑定的自选分组 id (成员动态解析, 增删自选自动生效)
|
||||
group_id: str | None = None
|
||||
sector: str | None = None
|
||||
sector_kind: str | None = None # index | concept | industry
|
||||
sector_targets: list[SectorTargetModel] = []
|
||||
@@ -88,8 +90,10 @@ class RuleModel(BaseModel):
|
||||
threshold_pct: float = 1.0
|
||||
window_minutes: int = 5
|
||||
strategy_id: str | None = None
|
||||
direction: str = "entry" # entry | exit | both
|
||||
direction: str = "entry" # entry | exit | both | (sector/ladder/abnormal: up|down|both)
|
||||
notify_events: list[str] | None = None
|
||||
score_min: float | None = None
|
||||
score_max: float | None = None
|
||||
conditions: list[ConditionModel] = []
|
||||
logic: str = "and" # and | or
|
||||
cooldown_seconds: int = 3600
|
||||
@@ -98,6 +102,8 @@ class RuleModel(BaseModel):
|
||||
webhook_enabled: bool = False # 兼容老规则 (已由 webhook_channels 取代, 仅做向后兼容读)
|
||||
webhook_channels: list[str] = [] # 命中时推送的外部渠道 (合法值 'feishu' | 'wecom')
|
||||
message: str = ""
|
||||
# abnormal 专属 (异动边缘监控): any | 3d | 10d | 30d
|
||||
abnormal_window: str = "any"
|
||||
# ladder 专属 (连板梯队封单监控)
|
||||
metric: str = "sealed_vol" # sealed_vol=封单量(手) | sealed_amount=封单额(元)
|
||||
threshold: float = 0 # 封单 <= 此值时报警 (原始单位: 量=手, 额=元)
|
||||
@@ -152,10 +158,12 @@ def get_options(request: Request):
|
||||
{"key": "price", "label": "价格/涨跌"},
|
||||
{"key": "market", "label": "市场异动"},
|
||||
{"key": "strategy", "label": "策略监控"},
|
||||
{"key": "abnormal", "label": "异动监控"},
|
||||
{"key": "sector", "label": "板块监控"},
|
||||
],
|
||||
"scopes": [
|
||||
{"key": "symbols", "label": "指定标的"},
|
||||
{"key": "watchlist_group", "label": "自选分组"},
|
||||
{"key": "all", "label": "全市场"},
|
||||
{"key": "sector", "label": "板块"},
|
||||
],
|
||||
@@ -222,6 +230,18 @@ def list_rules(request: Request):
|
||||
rule["runtime_warning"] = "部分板块数据已不存在, 请重新选择监控对象"
|
||||
elif unavailable:
|
||||
rule["runtime_warning"] = "所选指数未加入实时指数池, 请先在实时监控设置中启用"
|
||||
# 分组作用域规则: 绑定的分组被删除 → 标注运行时警告 (引擎侧已 fail-closed 跳过)
|
||||
group_rules = [rule for rule in rules if rule.get("scope") == "watchlist_group"]
|
||||
if group_rules:
|
||||
from app.services import watchlist as watchlist_service
|
||||
|
||||
try:
|
||||
existing_ids = {g["id"] for g in watchlist_service.list_groups()}
|
||||
for rule in group_rules:
|
||||
if rule.get("group_id") not in existing_ids:
|
||||
rule["runtime_warning"] = "绑定的自选分组已删除, 规则已暂停监控, 编辑可重新选择"
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
# 按 created_at 倒序
|
||||
rules.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
return {"rules": rules}
|
||||
@@ -268,6 +288,17 @@ def save_rule(req: RuleModel, request: Request):
|
||||
monitor_rules.validate(rule)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
if rule.get("scope") == "watchlist_group":
|
||||
# 绑定的分组必须存在 (strategy 层校验形状, 存在性在本层校验)
|
||||
from app.services import watchlist as watchlist_service
|
||||
|
||||
group_id = str(rule.get("group_id") or "")
|
||||
try:
|
||||
group_ids = {g["id"] for g in watchlist_service.list_groups()}
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(status_code=503, detail=f"自选分组读取失败: {e}") from e
|
||||
if group_id not in group_ids:
|
||||
raise HTTPException(status_code=400, detail="自选分组不存在或已被删除, 请重新选择")
|
||||
if rule.get("type") == "sector":
|
||||
sector_service = getattr(request.app.state, "sector_monitor_service", None)
|
||||
if sector_service is None:
|
||||
@@ -317,7 +348,6 @@ def delete_rule(rule_id: str, request: Request):
|
||||
# ── 演示数据生成 (仅 Dev 页用) ─────────────────────────
|
||||
|
||||
import time as _time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def _demo_rule(rule_id: str, name: str, rtype: str, scope: str, symbols: list[str],
|
||||
@@ -614,7 +644,7 @@ def trigger_ladder(request: Request):
|
||||
# 1. 落盘到 alerts.jsonl
|
||||
try:
|
||||
alert_store.append_many(repo.store.data_dir, rule_events)
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception: # noqa: BLE001
|
||||
pass # 落盘失败不阻断推送
|
||||
|
||||
# 2. SSE 推送 (入 pending_alerts 队列)
|
||||
|
||||
+201
-2
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+136
-20
@@ -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
|
||||
@@ -58,7 +59,10 @@ def get_settings() -> dict:
|
||||
ai_configured,
|
||||
current_ai_model,
|
||||
current_codex_command,
|
||||
current_codex_model,
|
||||
current_codex_reasoning_effort,
|
||||
current_openai_model,
|
||||
current_openai_reasoning_effort,
|
||||
)
|
||||
|
||||
key = secrets_store.get_tickflow_key()
|
||||
@@ -81,6 +85,9 @@ def get_settings() -> dict:
|
||||
"has_ai_key": bool(secrets_store.get_ai_key()),
|
||||
"ai_configured": ai_configured(ai_provider),
|
||||
"ai_model": current_ai_model(),
|
||||
"ai_openai_model": current_openai_model(),
|
||||
"ai_reasoning_effort": current_openai_reasoning_effort(),
|
||||
"ai_codex_model": current_codex_model(),
|
||||
"ai_codex_command": current_codex_command(),
|
||||
"ai_codex_reasoning_effort": current_codex_reasoning_effort(),
|
||||
"ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent),
|
||||
@@ -240,6 +247,7 @@ class AiSettingsIn(BaseModel):
|
||||
base_url: str = ""
|
||||
api_key: str | None = None
|
||||
model: str = ""
|
||||
reasoning_effort: str = Field(default="high", max_length=64)
|
||||
codex_command: str = ""
|
||||
codex_reasoning_effort: str = ""
|
||||
user_agent: str = ""
|
||||
@@ -250,12 +258,17 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
"""保存 AI 配置(全部持久化到 secrets.json)"""
|
||||
from app.config import settings
|
||||
from app.services.ai_provider import (
|
||||
OPENAI_PROVIDER,
|
||||
ai_configured,
|
||||
current_ai_model,
|
||||
current_ai_provider,
|
||||
current_codex_command,
|
||||
current_codex_model,
|
||||
current_codex_reasoning_effort,
|
||||
current_openai_model,
|
||||
current_openai_reasoning_effort,
|
||||
normalize_codex_command,
|
||||
normalize_codex_model,
|
||||
normalize_codex_reasoning_effort,
|
||||
)
|
||||
|
||||
@@ -263,23 +276,8 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
if req.provider:
|
||||
updates["ai_provider"] = req.provider
|
||||
settings.ai_provider = req.provider
|
||||
if req.base_url:
|
||||
updates["ai_base_url"] = req.base_url
|
||||
settings.ai_base_url = req.base_url
|
||||
if req.api_key is not None:
|
||||
if req.api_key:
|
||||
updates["ai_api_key"] = req.api_key
|
||||
settings.ai_api_key = req.api_key
|
||||
else:
|
||||
secrets_store.clear("ai_api_key")
|
||||
settings.ai_api_key = ""
|
||||
if req.provider == "codex_cli" and not req.model:
|
||||
secrets_store.clear("ai_model")
|
||||
settings.ai_model = ""
|
||||
elif req.model:
|
||||
updates["ai_model"] = req.model
|
||||
settings.ai_model = req.model
|
||||
if req.provider == "codex_cli":
|
||||
updates["ai_codex_model"] = normalize_codex_model(req.model)
|
||||
try:
|
||||
codex_command = normalize_codex_command(req.codex_command)
|
||||
except ValueError as exc:
|
||||
@@ -289,6 +287,22 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
updates["ai_codex_reasoning_effort"] = codex_reasoning_effort
|
||||
settings.ai_codex_command = codex_command
|
||||
settings.ai_codex_reasoning_effort = codex_reasoning_effort
|
||||
else:
|
||||
if req.base_url:
|
||||
updates["ai_base_url"] = req.base_url
|
||||
settings.ai_base_url = req.base_url
|
||||
if req.api_key is not None:
|
||||
if req.api_key:
|
||||
updates["ai_api_key"] = req.api_key
|
||||
settings.ai_api_key = req.api_key
|
||||
else:
|
||||
secrets_store.clear("ai_api_key")
|
||||
settings.ai_api_key = ""
|
||||
if req.model:
|
||||
updates["ai_model"] = req.model
|
||||
settings.ai_model = req.model
|
||||
if req.provider == OPENAI_PROVIDER:
|
||||
updates["ai_reasoning_effort"] = req.reasoning_effort.strip()
|
||||
# user_agent 允许清空(回到默认浏览器 UA),故无条件持久化
|
||||
updates["ai_user_agent"] = req.user_agent
|
||||
settings.ai_user_agent = req.user_agent
|
||||
@@ -301,6 +315,9 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
"ok": True,
|
||||
"ai_provider": provider,
|
||||
"ai_model": current_ai_model(),
|
||||
"ai_openai_model": current_openai_model(),
|
||||
"ai_reasoning_effort": current_openai_reasoning_effort(),
|
||||
"ai_codex_model": current_codex_model(),
|
||||
"ai_codex_command": current_codex_command(),
|
||||
"ai_codex_reasoning_effort": current_codex_reasoning_effort(),
|
||||
"ai_configured": ai_configured(provider),
|
||||
@@ -315,7 +332,16 @@ def clear_ai_settings() -> dict:
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command", "ai_codex_reasoning_effort")
|
||||
secrets_store.clear(
|
||||
"ai_provider",
|
||||
"ai_base_url",
|
||||
"ai_api_key",
|
||||
"ai_model",
|
||||
"ai_reasoning_effort",
|
||||
"ai_codex_model",
|
||||
"ai_codex_command",
|
||||
"ai_codex_reasoning_effort",
|
||||
)
|
||||
# 同步重置运行时内存(provider 回默认值,其余置空)
|
||||
settings.ai_provider = "openai_compat"
|
||||
settings.ai_base_url = ""
|
||||
@@ -350,6 +376,11 @@ class DataProvidersIn(BaseModel):
|
||||
financial_data_provider: str | None = None
|
||||
|
||||
|
||||
class DataSourceJobTimeoutPrefs(BaseModel):
|
||||
data_source_job_timeout_s: int = Field(ge=60)
|
||||
data_source_long_job_timeout_s: int = Field(ge=60)
|
||||
|
||||
|
||||
class DatasetFieldMapItem(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
@@ -397,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:
|
||||
"""返回用户偏好设置。"""
|
||||
@@ -405,6 +444,7 @@ def get_preferences() -> dict:
|
||||
"realtime_quotes_enabled": preferences.get_realtime_quotes_enabled(),
|
||||
"realtime_allowed": _realtime_allowed(),
|
||||
"indices_nav_pinned": preferences.get_indices_nav_pinned(),
|
||||
"watchlist_groups_in_nav": preferences.get_watchlist_groups_in_nav(),
|
||||
"minute_sync_enabled": preferences.get_minute_sync_enabled(),
|
||||
"minute_sync_days": preferences.get_minute_sync_days(),
|
||||
"minute_sync_segment_days": preferences.get_minute_sync_segment_days(),
|
||||
@@ -413,6 +453,8 @@ def get_preferences() -> dict:
|
||||
"minute_data_provider": preferences.get_minute_data_provider(),
|
||||
"realtime_data_provider": preferences.get_realtime_data_provider(),
|
||||
"financial_data_provider": preferences.get_financial_provider(),
|
||||
"data_source_job_timeout_s": preferences.get_data_source_job_timeout_s(),
|
||||
"data_source_long_job_timeout_s": preferences.get_data_source_long_job_timeout_s(),
|
||||
"realtime_watchlist_symbols": preferences.get_realtime_watchlist_symbols(),
|
||||
**preferences.get_realtime_quote_scope(),
|
||||
"pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(),
|
||||
@@ -452,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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -547,7 +590,7 @@ def save_data_source(req: CustomSourceIn) -> dict:
|
||||
|
||||
|
||||
@router.delete("/data-sources/{name}")
|
||||
def delete_data_source(name: str) -> dict:
|
||||
def delete_data_source(name: str, request: Request) -> dict:
|
||||
"""删除一个自定义数据源 yaml, 保存后自动 reload。
|
||||
|
||||
若当前总开关选中的就是被删的源, 回退到 tickflow。
|
||||
@@ -572,6 +615,8 @@ def delete_data_source(name: str) -> dict:
|
||||
updates["adj_factor_provider"] = "same_as_daily"
|
||||
if updates:
|
||||
preferences.save(updates)
|
||||
# 删除源可能触发偏好回退 tickflow, 同步刷新能力快照
|
||||
request.app.state.capabilities = detect_capabilities()
|
||||
return list_data_sources()
|
||||
|
||||
|
||||
@@ -601,12 +646,14 @@ def test_data_source(req: CustomSourceTestIn) -> dict:
|
||||
|
||||
|
||||
@router.put("/preferences/data-providers")
|
||||
def update_data_providers(req: DataProvidersIn) -> dict:
|
||||
def update_data_providers(req: DataProvidersIn, request: Request) -> dict:
|
||||
"""保存数据源选择。"""
|
||||
from app.services import preferences
|
||||
updates = req.model_dump(exclude_none=True)
|
||||
if updates:
|
||||
preferences.save(updates)
|
||||
# 刷新能力快照: 当前 provider 变化会改变自定义源能力增广结果 (读缓存, 无网络请求)
|
||||
request.app.state.capabilities = detect_capabilities()
|
||||
return {
|
||||
"daily_data_provider": preferences.get_daily_data_provider(),
|
||||
"adj_factor_provider": preferences.get_adj_factor_provider(),
|
||||
@@ -616,6 +663,26 @@ def update_data_providers(req: DataProvidersIn) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.put("/preferences/data-source-job-timeouts")
|
||||
def update_data_source_job_timeouts(req: DataSourceJobTimeoutPrefs) -> dict:
|
||||
"""保存普通与长数据后台任务的卡死判定时间。"""
|
||||
from app.services import preferences
|
||||
preferences.save(req.model_dump())
|
||||
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:
|
||||
"""返回自选列表列配置。"""
|
||||
@@ -718,6 +785,19 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
||||
"""
|
||||
from app.services import preferences
|
||||
qs = getattr(request.app.state, "quote_service", None)
|
||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
||||
|
||||
def _sync_depth_polling(realtime_on: bool) -> None:
|
||||
"""实时行情开关联动 depth 盘中轮询: 开→恢复(仍受监控开关/能力门控), 关→立即停。
|
||||
|
||||
实时行情关闭时 enriched 停留在上一交易日, depth 轮询只会反复拉陈旧名单。
|
||||
"""
|
||||
if not depth_svc:
|
||||
return
|
||||
if realtime_on:
|
||||
depth_svc.start_polling()
|
||||
else:
|
||||
depth_svc.stop_polling()
|
||||
|
||||
allowed = qs.is_realtime_allowed() if qs else True
|
||||
if req.realtime_quotes_enabled and not allowed:
|
||||
@@ -725,12 +805,14 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
||||
preferences.save({"realtime_quotes_enabled": False})
|
||||
if qs:
|
||||
qs.disable()
|
||||
_sync_depth_polling(False)
|
||||
return {"realtime_quotes_enabled": False, "realtime_allowed": False}
|
||||
if req.realtime_quotes_enabled and qs and qs.is_paused():
|
||||
# 管道/数据修正运行期间禁止开启实时行情 — 防止写盘竞态
|
||||
raise HTTPException(status_code=409, detail="数据同步运行中,实时行情已临时暂停,请稍后再开启")
|
||||
if req.realtime_quotes_enabled and qs and qs.realtime_mode() == "watchlist" and not preferences.get_realtime_watchlist_symbols():
|
||||
preferences.save({"realtime_quotes_enabled": False})
|
||||
_sync_depth_polling(False)
|
||||
return {"realtime_quotes_enabled": False, "realtime_allowed": True, "mode": "watchlist", "error": "watchlist_empty"}
|
||||
|
||||
preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled})
|
||||
@@ -739,6 +821,7 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
||||
qs.enable()
|
||||
else:
|
||||
qs.disable()
|
||||
_sync_depth_polling(req.realtime_quotes_enabled)
|
||||
|
||||
return {"realtime_quotes_enabled": req.realtime_quotes_enabled, "realtime_allowed": allowed}
|
||||
|
||||
@@ -776,6 +859,18 @@ def update_indices_nav_pinned(req: IndicesNavPinnedPrefs) -> dict:
|
||||
return {"indices_nav_pinned": req.indices_nav_pinned}
|
||||
|
||||
|
||||
class WatchlistGroupsInNavPrefs(BaseModel):
|
||||
watchlist_groups_in_nav: bool
|
||||
|
||||
|
||||
@router.put("/preferences/watchlist-groups-in-nav")
|
||||
def update_watchlist_groups_in_nav(req: WatchlistGroupsInNavPrefs) -> dict:
|
||||
"""保存自选分组是否显示在侧边栏开关。"""
|
||||
from app.services import preferences
|
||||
preferences.save({"watchlist_groups_in_nav": req.watchlist_groups_in_nav})
|
||||
return {"watchlist_groups_in_nav": req.watchlist_groups_in_nav}
|
||||
|
||||
|
||||
class RealtimeMonitorConfigIn(BaseModel):
|
||||
sse_refresh_pages: dict[str, bool] | None = None
|
||||
strategy_monitor_enabled: bool | None = None
|
||||
@@ -874,6 +969,27 @@ class PipelineIndexSymbolsIn(BaseModel):
|
||||
symbols: str = ""
|
||||
|
||||
|
||||
class MainlineFilterIn(BaseModel):
|
||||
"""市场主线过滤配置(宽基/风格标签按成员数过滤 + 名称黑名单 + ST 剔除开关)。"""
|
||||
|
||||
min_members: int | None = None
|
||||
max_members: int | None = None
|
||||
blacklist: list[str] | str | None = None
|
||||
exclude_st: bool | None = None
|
||||
|
||||
|
||||
@router.put("/preferences/mainline-filter")
|
||||
def update_mainline_filter(req: MainlineFilterIn) -> dict:
|
||||
"""更新市场主线过滤配置。部分更新; 修改后需重算主线(POST /api/regime/mainline/recompute)生效。
|
||||
|
||||
exclude_st 同步控制市场环境(regime)统计口径 — 切换后需全量重算 regime。
|
||||
"""
|
||||
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:
|
||||
"""保存指数自定义拉取代码。"""
|
||||
|
||||
+74
-22
@@ -24,6 +24,11 @@ from app.strategy.ai_generator import AIStrategyGenerator, find_meta_assignment
|
||||
from app.strategy.engine import StrategyDef, StrategyEngine
|
||||
from app.strategy.monitor import StrategyMonitorService
|
||||
from app.strategy.prompt_builder import build_step1, build_step2
|
||||
from app.strategy.scoring import (
|
||||
SCORING_DIRECTIONS,
|
||||
effective_scoring,
|
||||
effective_scoring_directions,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/strategies", tags=["strategies"])
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,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:
|
||||
@@ -136,14 +151,13 @@ def _strategy_detail(
|
||||
) -> dict:
|
||||
"""策略详情(含用户覆盖)"""
|
||||
bf = {**s.basic_filter}
|
||||
scoring = dict(s.meta.get("scoring", {}))
|
||||
scoring = effective_scoring(s.meta.get("scoring"), overrides)
|
||||
scoring_directions = effective_scoring_directions(overrides)
|
||||
params_defaults = {p["id"]: p["default"] for p in s.meta.get("params", [])}
|
||||
|
||||
if overrides:
|
||||
if overrides.get("basic_filter"):
|
||||
bf.update(overrides["basic_filter"])
|
||||
if overrides.get("scoring"):
|
||||
scoring.update(overrides["scoring"])
|
||||
# 用户保存的参数覆盖默认值: 合并进 params_defaults, 前端据此回显
|
||||
if overrides.get("params"):
|
||||
params_defaults.update(overrides["params"])
|
||||
@@ -166,6 +180,11 @@ def _strategy_detail(
|
||||
"params": s.meta.get("params", []),
|
||||
"params_defaults": params_defaults,
|
||||
"scoring": scoring,
|
||||
"scoring_directions": {
|
||||
name: direction
|
||||
for name, direction in scoring_directions.items()
|
||||
if name in scoring
|
||||
},
|
||||
"entry_signals": overrides.get("entry_signals", s.entry_signals) if overrides else s.entry_signals,
|
||||
"exit_signals": overrides.get("exit_signals", s.exit_signals) if overrides else s.exit_signals,
|
||||
"minute_exit_trigger_supported_signals": sorted(MINUTE_EXIT_TRIGGER_SIGNALS),
|
||||
@@ -175,7 +194,6 @@ def _strategy_detail(
|
||||
"trailing_take_profit_activate": getattr(s, "trailing_take_profit_activate", None),
|
||||
"trailing_take_profit_drawdown": getattr(s, "trailing_take_profit_drawdown", None),
|
||||
"max_hold_days": overrides.get("max_hold_days", s.max_hold_days) if overrides else s.max_hold_days,
|
||||
"alerts": s.alerts,
|
||||
"order_by": s.meta.get("order_by", "score"),
|
||||
"descending": s.meta.get("descending", True),
|
||||
"limit": s.meta.get("limit", 30),
|
||||
@@ -280,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"]):
|
||||
@@ -294,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)
|
||||
|
||||
@@ -308,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)
|
||||
|
||||
# 读取用户覆盖配置
|
||||
@@ -369,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
|
||||
@@ -404,9 +423,9 @@ 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)
|
||||
# 剥离与策略默认值相同的字段,只保存用户真正修改过的值
|
||||
overrides = _strip_defaults(req.strategy_id, req.overrides, engine)
|
||||
|
||||
@@ -414,6 +433,43 @@ def save_config(req: SaveConfigRequest, request: Request):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.patch("/config")
|
||||
def patch_config(req: SaveConfigRequest, request: Request):
|
||||
engine = _get_engine(request)
|
||||
_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)
|
||||
_validate_scoring_config(overrides)
|
||||
strategy_config.save_override(
|
||||
data_dir,
|
||||
req.strategy_id,
|
||||
_strip_defaults(req.strategy_id, overrides, engine),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _validate_scoring_config(overrides: dict) -> None:
|
||||
scoring = overrides.get("scoring")
|
||||
if scoring is not None:
|
||||
if not isinstance(scoring, dict):
|
||||
raise HTTPException(status_code=400, detail="评分权重必须是对象")
|
||||
for name, weight in scoring.items():
|
||||
if not isinstance(name, str) or not name:
|
||||
raise HTTPException(status_code=400, detail="评分因子名称无效")
|
||||
if isinstance(weight, bool) or not isinstance(weight, (int, float)) or not math.isfinite(weight) or weight < 0:
|
||||
raise HTTPException(status_code=400, detail=f"评分因子 {name} 的权重必须是非负数")
|
||||
directions = overrides.get("scoring_directions")
|
||||
if directions is not None:
|
||||
if not isinstance(directions, dict):
|
||||
raise HTTPException(status_code=400, detail="评分方向必须是对象")
|
||||
invalid = [name for name, direction in directions.items() if direction not in SCORING_DIRECTIONS]
|
||||
if invalid:
|
||||
raise HTTPException(status_code=400, detail=f"评分因子 {invalid[0]} 的方向无效")
|
||||
if "scoring_replace" in overrides and not isinstance(overrides["scoring_replace"], bool):
|
||||
raise HTTPException(status_code=400, detail="scoring_replace 必须是布尔值")
|
||||
|
||||
|
||||
def _strip_defaults(strategy_id: str, overrides: dict, engine) -> dict:
|
||||
"""剥离与策略默认值相同的字段,避免默认值被固化到 override 中。
|
||||
|
||||
@@ -444,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}
|
||||
|
||||
@@ -708,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():
|
||||
@@ -917,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
|
||||
|
||||
+116
-10
@@ -36,11 +36,26 @@ _OCR_LIMITER = anyio.CapacityLimiter(2)
|
||||
class AddRequest(BaseModel):
|
||||
symbol: str
|
||||
note: str = ""
|
||||
group_id: str | None = None
|
||||
|
||||
|
||||
class BatchAddRequest(BaseModel):
|
||||
symbols: list[str]
|
||||
note: str = ""
|
||||
group_id: str | None = None
|
||||
|
||||
|
||||
class GroupNameRequest(BaseModel):
|
||||
name: str
|
||||
color: str | None = None
|
||||
|
||||
|
||||
class GroupReorderRequest(BaseModel):
|
||||
ordered_ids: list[str]
|
||||
|
||||
|
||||
class GroupAssignRequest(BaseModel):
|
||||
group_id: str | None = None
|
||||
|
||||
|
||||
def _with_names(rows: list[dict], request: Request) -> list[dict]:
|
||||
@@ -64,20 +79,74 @@ def list_all(request: Request):
|
||||
|
||||
@router.post("")
|
||||
def add_one(req: AddRequest, request: Request):
|
||||
rows = watchlist.add(req.symbol, req.note)
|
||||
try:
|
||||
rows = watchlist.add(req.symbol, req.note, req.group_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
def add_batch(req: BatchAddRequest, request: Request):
|
||||
existing = {r["symbol"] for r in watchlist.list_symbols()}
|
||||
added = 0
|
||||
for sym in req.symbols:
|
||||
if sym not in existing:
|
||||
added += 1
|
||||
existing.add(sym)
|
||||
watchlist.add(sym, req.note)
|
||||
return {"symbols": _with_names(watchlist.list_symbols(), request), "added": added}
|
||||
try:
|
||||
rows, added = watchlist.add_batch(req.symbols, req.note, req.group_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"symbols": _with_names(rows, request), "added": added}
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
def list_groups():
|
||||
return {"groups": watchlist.list_groups()}
|
||||
|
||||
|
||||
@router.post("/groups")
|
||||
def create_group(req: GroupNameRequest):
|
||||
try:
|
||||
groups, group = watchlist.create_group(req.name, req.color)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"groups": groups, "group": group}
|
||||
|
||||
|
||||
@router.put("/groups/reorder")
|
||||
def reorder_groups(req: GroupReorderRequest):
|
||||
"""重排分组前后顺序 (json 数组顺序即定义顺序, 侧边栏/标签栏/分组视图共用)。"""
|
||||
try:
|
||||
groups = watchlist.reorder_groups(req.ordered_ids)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"groups": groups}
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}")
|
||||
def rename_group(group_id: str, req: GroupNameRequest):
|
||||
try:
|
||||
groups = watchlist.rename_group(group_id, req.name, req.color)
|
||||
except KeyError as e:
|
||||
raise HTTPException(404, "自选分组不存在") from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"groups": groups}
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}")
|
||||
def delete_group(group_id: str, request: Request):
|
||||
try:
|
||||
groups, rows = watchlist.delete_group(group_id)
|
||||
except KeyError as e:
|
||||
raise HTTPException(404, "自选分组不存在") from e
|
||||
return {"groups": groups, "symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/clear")
|
||||
def clear_group(group_id: str, request: Request):
|
||||
"""清空分组成员:把该分组内所有股票转为未分组,保留分组定义。"""
|
||||
try:
|
||||
rows = watchlist.clear_group(group_id)
|
||||
except KeyError as e:
|
||||
raise HTTPException(404, "自选分组不存在") from e
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.get("/ocr-status")
|
||||
@@ -131,6 +200,42 @@ def move_one_to_top(symbol: str, request: Request):
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.put("/{symbol}/group")
|
||||
def assign_group(symbol: str, req: GroupAssignRequest, request: Request):
|
||||
"""互斥设定分组(仅保留此组; None=移出全部分组)。多组操作用 members 端点。"""
|
||||
try:
|
||||
rows = watchlist.set_group(symbol, req.group_id)
|
||||
except KeyError as e:
|
||||
raise HTTPException(404, "自选标的不存在") from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/members/{symbol}")
|
||||
def add_member(group_id: str, symbol: str, request: Request):
|
||||
"""把标的加入分组(多组成员关系: 不影响其他分组)。"""
|
||||
try:
|
||||
rows = watchlist.add_to_group(symbol, group_id)
|
||||
except KeyError as e:
|
||||
raise HTTPException(404, "自选标的不存在") from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}/members/{symbol}")
|
||||
def remove_member(group_id: str, symbol: str, request: Request):
|
||||
"""把标的移出分组(仅摘本组标签; 标的仍在自选, 可能落入未分组)。"""
|
||||
try:
|
||||
rows = watchlist.remove_from_group(symbol, group_id)
|
||||
except KeyError as e:
|
||||
raise HTTPException(404, "自选标的不存在") from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.delete("/{symbol}")
|
||||
def remove_one(symbol: str, request: Request):
|
||||
rows = watchlist.remove(symbol)
|
||||
@@ -146,7 +251,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",
|
||||
@@ -159,6 +264,7 @@ _WATCHLIST_COLS = [
|
||||
"boll_upper", "boll_lower",
|
||||
"atr_14",
|
||||
"momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d",
|
||||
"deviate_3d", "deviate_10d", "deviate_30d",
|
||||
"consecutive_limit_ups", "consecutive_limit_downs",
|
||||
"signal_limit_up", "signal_limit_down", "signal_volume_surge",
|
||||
"signal_ma_golden_5_20", "signal_macd_golden", "signal_n_day_high",
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"""量化研究候选方案的轻量本地存储。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
CandidateKind = Literal["factor", "strategy"]
|
||||
CandidateStatus = Literal["pending", "validated", "rejected"]
|
||||
|
||||
MAX_CANDIDATES = 200
|
||||
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",
|
||||
}
|
||||
)
|
||||
| _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",
|
||||
}
|
||||
)
|
||||
| _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()
|
||||
|
||||
|
||||
class CandidateStoreError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class CandidateValidationError(CandidateStoreError):
|
||||
pass
|
||||
|
||||
|
||||
class CandidateStore:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.path = Path(data_dir) / "user_data" / "research_candidates.json"
|
||||
|
||||
def list(self) -> list[dict[str, Any]]:
|
||||
with _lock:
|
||||
return self._load()
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
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]:
|
||||
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)
|
||||
|
||||
with _lock:
|
||||
items = self._load()
|
||||
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 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,
|
||||
*,
|
||||
name: str | None = None,
|
||||
status: CandidateStatus | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with _lock:
|
||||
items = self._load()
|
||||
for item in items:
|
||||
if item["id"] != candidate_id:
|
||||
continue
|
||||
if name is not None:
|
||||
item["name"] = self._validate_name(name)
|
||||
if status is not None:
|
||||
item["status"] = status
|
||||
item["updated_at"] = datetime.now(UTC).isoformat()
|
||||
self._write(items)
|
||||
return item
|
||||
raise KeyError(candidate_id)
|
||||
|
||||
def delete(self, candidate_id: str) -> None:
|
||||
with _lock:
|
||||
items = self._load()
|
||||
remaining = [item for item in items if item["id"] != candidate_id]
|
||||
if len(remaining) == len(items):
|
||||
raise KeyError(candidate_id)
|
||||
self._write(remaining)
|
||||
|
||||
def _load(self) -> list[dict[str, Any]]:
|
||||
if not self.path.exists():
|
||||
return []
|
||||
try:
|
||||
if self.path.stat().st_size > MAX_FILE_BYTES:
|
||||
raise CandidateStoreError("候选方案文件过大, 已停止读取")
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except CandidateStoreError:
|
||||
raise
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise CandidateStoreError("候选方案文件损坏或无法读取, 未执行覆盖写入") from exc
|
||||
if not isinstance(raw, list):
|
||||
raise CandidateStoreError("候选方案文件格式无效, 未执行覆盖写入")
|
||||
return [item for value in raw if (item := self._normalize(value)) is not None]
|
||||
|
||||
def _write(self, items: list[dict[str, Any]]) -> None:
|
||||
payload = json.dumps(items, ensure_ascii=False, indent=2, allow_nan=False)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self.path.with_suffix(".json.tmp")
|
||||
try:
|
||||
with temporary.open("w", encoding="utf-8") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, self.path)
|
||||
except OSError as exc:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise CandidateStoreError("候选方案保存失败") from exc
|
||||
|
||||
@staticmethod
|
||||
def _normalize(value: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
kind = value.get("kind")
|
||||
if kind not in _CONFIG_FIELDS or not isinstance(value.get("id"), str):
|
||||
return None
|
||||
raw_config = value.get("config") if isinstance(value.get("config"), dict) else {}
|
||||
config = {key: item for key, item in raw_config.items() if key in _CONFIG_FIELDS[kind]}
|
||||
raw_metrics = value.get("metrics") if isinstance(value.get("metrics"), dict) else {}
|
||||
metrics = {
|
||||
key: item
|
||||
for key, item in raw_metrics.items()
|
||||
if key in _METRIC_FIELDS[kind] and not isinstance(item, (dict, list))
|
||||
}
|
||||
source_id = value.get("source_id") or config.get(f"{kind}_name") or config.get(f"{kind}_id")
|
||||
if not isinstance(source_id, str) or not source_id:
|
||||
return None
|
||||
status = value.get("status")
|
||||
if status not in {"pending", "validated", "rejected"}:
|
||||
status = "pending"
|
||||
return {
|
||||
"id": value["id"],
|
||||
"kind": kind,
|
||||
"name": str(value.get("name") or source_id)[:MAX_NAME_LENGTH],
|
||||
"source_id": source_id,
|
||||
"config": config,
|
||||
"metrics": metrics,
|
||||
"data_as_of": value.get("data_as_of"),
|
||||
"status": status,
|
||||
"created_at": str(value.get("created_at") or ""),
|
||||
"updated_at": str(value.get("updated_at") or value.get("created_at") or ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _validate_name(name: str) -> str:
|
||||
clean = name.strip()
|
||||
if not clean:
|
||||
raise CandidateValidationError("候选名称不能为空")
|
||||
if len(clean) > MAX_NAME_LENGTH:
|
||||
raise CandidateValidationError(f"候选名称不能超过 {MAX_NAME_LENGTH} 个字符")
|
||||
return clean
|
||||
|
||||
@staticmethod
|
||||
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))}"
|
||||
)
|
||||
CandidateStore._check_json_size(config)
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
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))}"
|
||||
)
|
||||
if any(isinstance(value, (dict, list)) for value in metrics.values()):
|
||||
raise CandidateValidationError("候选指标只允许保存标量摘要")
|
||||
CandidateStore._check_json_size(metrics)
|
||||
return metrics
|
||||
|
||||
@staticmethod
|
||||
def _check_json_size(value: dict[str, Any]) -> None:
|
||||
try:
|
||||
payload = json.dumps(value, ensure_ascii=False, allow_nan=False)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CandidateValidationError("候选内容无法序列化") from exc
|
||||
if len(payload.encode("utf-8")) > MAX_PAYLOAD_BYTES:
|
||||
raise CandidateValidationError("候选内容超过 32KB 限制")
|
||||
+135
-27
@@ -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,
|
||||
|
||||
+961
-159
File diff suppressed because it is too large
Load Diff
@@ -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 同一口径: 公告日次一交易日起前向填充,
|
||||
无数据为 NaN。pb 类因子在矩阵侧用 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))
|
||||
+316
-50
@@ -34,6 +34,7 @@ from app.price_limits import (
|
||||
numpy_limit_price,
|
||||
write_numpy_price_limit_matrix,
|
||||
)
|
||||
from app.strategy.scoring import SCORING_DIRECTION_LOW
|
||||
|
||||
try:
|
||||
from numba import njit, prange
|
||||
@@ -58,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()
|
||||
@@ -678,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)
|
||||
@@ -728,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)
|
||||
@@ -806,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,
|
||||
@@ -915,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")
|
||||
@@ -952,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,
|
||||
@@ -1036,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)
|
||||
@@ -1056,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)
|
||||
@@ -1100,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)
|
||||
@@ -1117,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"],
|
||||
@@ -1146,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()
|
||||
@@ -1180,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:
|
||||
@@ -1312,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)}
|
||||
@@ -1342,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
|
||||
@@ -1897,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():
|
||||
@@ -1930,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}
|
||||
@@ -1955,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()
|
||||
@@ -1970,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),
|
||||
@@ -1992,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()
|
||||
@@ -2002,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)
|
||||
@@ -2997,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)
|
||||
@@ -3048,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):
|
||||
@@ -3186,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)
|
||||
@@ -3397,6 +3465,7 @@ class MatrixPipelineConfig:
|
||||
scoring: dict[str, float]
|
||||
order_by: str | None
|
||||
descending: bool
|
||||
scoring_directions: dict[str, str] = field(default_factory=dict)
|
||||
asset_mask: np.ndarray | None = None
|
||||
protect_strategy_cache: bool = False
|
||||
|
||||
@@ -3462,6 +3531,7 @@ class MatrixStrategyPipeline:
|
||||
config.order_by,
|
||||
config.descending,
|
||||
fallback=signals.score,
|
||||
directions=config.scoring_directions,
|
||||
)
|
||||
entry_codes = np.where(entry != 0, signals.entry_signal_code, -1).astype(np.int16)
|
||||
exit_codes = np.where(signals.exit != 0, signals.exit_signal_code, -1).astype(np.int16)
|
||||
@@ -3502,14 +3572,7 @@ def _estimate_pipeline_cache_bytes(
|
||||
for name in feature_names:
|
||||
if name in {"open", "high", "low", "close", "volume"} or name in market.fields:
|
||||
continue
|
||||
if name == "vol_ratio_5d":
|
||||
estimated += 2 * float_bytes
|
||||
elif name == "ma20_bias":
|
||||
estimated += 2 * float_bytes
|
||||
elif name == "change_pct" or (
|
||||
name.startswith("momentum_") and name.endswith("d")
|
||||
):
|
||||
estimated += float_bytes
|
||||
estimated += 5 * float_bytes
|
||||
return estimated
|
||||
|
||||
|
||||
@@ -3588,6 +3651,7 @@ def build_matrix_score(
|
||||
descending: bool,
|
||||
*,
|
||||
fallback: np.ndarray,
|
||||
directions: Mapping[str, str] | None = None,
|
||||
) -> np.ndarray:
|
||||
weights = {name: float(weight) for name, weight in scoring.items() if float(weight) != 0.0}
|
||||
total_weight = sum(weights.values())
|
||||
@@ -3639,6 +3703,8 @@ def build_matrix_score(
|
||||
np.divide(scratch, row_range[:, None], out=scratch, where=mask)
|
||||
np.logical_and(finite, ~varying_rows[:, None], out=mask)
|
||||
scratch[mask] = np.float32(0.5)
|
||||
if (directions or {}).get(name) == SCORING_DIRECTION_LOW:
|
||||
scratch[finite] = np.float32(1.0) - scratch[finite]
|
||||
scratch *= normalized_weight
|
||||
score[:, start:stop] += scratch
|
||||
score *= np.float32(100.0)
|
||||
@@ -3665,38 +3731,42 @@ def build_matrix_score(
|
||||
return result
|
||||
|
||||
|
||||
_MATRIX_COMPUTED_FEATURES = frozenset({
|
||||
"prev_close", "change_pct", "change_amount", "amplitude",
|
||||
"boll_upper", "boll_lower", "boll_position", "boll_width",
|
||||
"high_60d", "low_60d", "annual_vol_20d",
|
||||
"macd_dif", "macd_dea", "macd_hist",
|
||||
"macd_dif_pct", "macd_dea_pct", "macd_hist_pct",
|
||||
"kdj_k", "kdj_d", "kdj_j", "atr_14", "atr_pct",
|
||||
"vol_ma5", "vol_ma10", "vol_ratio_5d", "vol_ratio_10d", "vol_trend_5_10",
|
||||
"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",
|
||||
})
|
||||
|
||||
|
||||
def matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray:
|
||||
if name in {"open", "high", "low", "close", "volume"} or name in market.fields:
|
||||
return market.field(name)
|
||||
close_feature = (
|
||||
name in {
|
||||
"prev_close",
|
||||
"change_pct",
|
||||
"change_amount",
|
||||
"amplitude",
|
||||
"boll_upper",
|
||||
"boll_lower",
|
||||
"high_60d",
|
||||
"low_60d",
|
||||
"annual_vol_20d",
|
||||
"ma20_bias",
|
||||
}
|
||||
supported = (
|
||||
name in _MATRIX_COMPUTED_FEATURES
|
||||
or (name.startswith("ma") and name.endswith("_bias") and name[2:-5].isdigit())
|
||||
or (name.startswith("ema") and name.endswith("_bias") and name[3:-5].isdigit())
|
||||
or (name.startswith("ma") and name[2:].isdigit())
|
||||
or (name.startswith("ema") and name[3:].isdigit())
|
||||
or (name.startswith("rsi_") and name[4:].isdigit())
|
||||
or (
|
||||
name.startswith("momentum_") and name.endswith("d")
|
||||
)
|
||||
or (name.startswith("momentum_") and name.endswith("d"))
|
||||
)
|
||||
if close_feature:
|
||||
source = market.close
|
||||
elif name == "vol_ratio_5d":
|
||||
source = market.volume
|
||||
else:
|
||||
if not supported:
|
||||
raise ValueError(f"unsupported matrix feature: {name}")
|
||||
with _activate_valid_bar_index(market.valid_bars):
|
||||
return _cached_matrix_operation(
|
||||
"matrix_feature",
|
||||
(source,),
|
||||
(market.close,),
|
||||
{"name": name},
|
||||
lambda: _compute_matrix_feature(market, name),
|
||||
)
|
||||
@@ -3729,40 +3799,82 @@ def _compute_matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray:
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"unsupported matrix feature: {name}") from exc
|
||||
return _valid_return_over_bars(market.close, close_valid, bars)
|
||||
if name == "vol_ratio_5d":
|
||||
if name.startswith("ma") and name.endswith("_bias"):
|
||||
period = int(name.removeprefix("ma").removesuffix("_bias"))
|
||||
return _matrix_relative(market.close, valid_rolling_mean(market.close, close_valid, period))
|
||||
if name.startswith("ema") and name.endswith("_bias"):
|
||||
period = int(name.removeprefix("ema").removesuffix("_bias"))
|
||||
return _matrix_relative(market.close, _matrix_ema(market.close, close_valid, period))
|
||||
if name.startswith("ma") and name[2:].isdigit():
|
||||
return valid_rolling_mean(market.close, close_valid, int(name[2:]))
|
||||
if name.startswith("ema") and name[3:].isdigit():
|
||||
return _matrix_ema(market.close, close_valid, int(name[3:]))
|
||||
if name in {"macd_dif", "macd_dea", "macd_hist"}:
|
||||
dif, dea = _matrix_macd(market.close, close_valid)
|
||||
if name == "macd_dif":
|
||||
return dif
|
||||
if name == "macd_dea":
|
||||
return dea
|
||||
return ((dif - dea) * np.float32(2.0)).astype(np.float32, copy=False)
|
||||
if name in {"macd_dif_pct", "macd_dea_pct", "macd_hist_pct"}:
|
||||
source = matrix_feature(market, name.removesuffix("_pct"))
|
||||
return _matrix_ratio(source, market.close)
|
||||
if name in {"vol_ratio_5d", "vol_ratio_10d"}:
|
||||
window = 5 if name == "vol_ratio_5d" else 10
|
||||
volume_valid = close_valid & np.isfinite(market.volume)
|
||||
previous_volume = valid_shift(market.volume, 1, volume_valid)
|
||||
previous_mean = valid_rolling_mean(
|
||||
previous_volume,
|
||||
np.isfinite(previous_volume),
|
||||
5,
|
||||
window,
|
||||
)
|
||||
return _matrix_ratio(market.volume, previous_mean)
|
||||
if name in {"vol_ma5", "vol_ma10"}:
|
||||
window = 5 if name == "vol_ma5" else 10
|
||||
volume_valid = close_valid & np.isfinite(market.volume)
|
||||
return valid_rolling_mean(market.volume, volume_valid, window)
|
||||
if name == "vol_trend_5_10":
|
||||
return _matrix_relative(
|
||||
matrix_feature(market, "vol_ma5"),
|
||||
matrix_feature(market, "vol_ma10"),
|
||||
)
|
||||
if name == "turnover_ratio_5d":
|
||||
turnover = market.field("turnover_rate")
|
||||
valid = close_valid & np.isfinite(turnover)
|
||||
previous = valid_shift(turnover, 1, valid)
|
||||
return _matrix_relative(
|
||||
turnover,
|
||||
valid_rolling_mean(previous, np.isfinite(previous), 5),
|
||||
)
|
||||
if name == "log_amount":
|
||||
amount = market.field("amount")
|
||||
out = np.full(market.shape, np.nan, dtype=np.float32)
|
||||
np.divide(
|
||||
market.volume,
|
||||
previous_mean,
|
||||
out=out,
|
||||
where=volume_valid & np.isfinite(previous_mean) & (previous_mean != 0),
|
||||
)
|
||||
valid = close_valid & np.isfinite(amount) & (amount >= 0)
|
||||
np.log(amount + np.float32(1.0), out=out, where=valid)
|
||||
return out
|
||||
if name == "ma20_bias":
|
||||
ma20 = valid_rolling_mean(market.close, close_valid, 20)
|
||||
out = np.full(market.shape, np.nan, dtype=np.float32)
|
||||
np.divide(
|
||||
market.close,
|
||||
ma20,
|
||||
out=out,
|
||||
where=close_valid & np.isfinite(ma20) & (ma20 != 0),
|
||||
if name == "amount_ratio_5d":
|
||||
amount = market.field("amount")
|
||||
valid = close_valid & np.isfinite(amount)
|
||||
previous = valid_shift(amount, 1, valid)
|
||||
return _matrix_relative(
|
||||
amount,
|
||||
valid_rolling_mean(previous, np.isfinite(previous), 5),
|
||||
)
|
||||
out -= np.float32(1.0)
|
||||
return out
|
||||
if name.startswith("ma") and name[2:].isdigit():
|
||||
return valid_rolling_mean(market.close, close_valid, int(name[2:]))
|
||||
if name == "boll_upper" or name == "boll_lower":
|
||||
middle = valid_rolling_mean(market.close, close_valid, 20)
|
||||
deviation = valid_rolling_std(market.close, close_valid, 20, ddof=1)
|
||||
offset = np.float32(2.0) * deviation
|
||||
return middle + offset if name == "boll_upper" else middle - offset
|
||||
if name == "boll_position":
|
||||
return _matrix_ratio(
|
||||
market.close - matrix_feature(market, "boll_lower"),
|
||||
matrix_feature(market, "boll_upper") - matrix_feature(market, "boll_lower"),
|
||||
)
|
||||
if name == "boll_width":
|
||||
return _matrix_ratio(
|
||||
matrix_feature(market, "boll_upper") - matrix_feature(market, "boll_lower"),
|
||||
matrix_feature(market, "ma20"),
|
||||
)
|
||||
if name == "high_60d":
|
||||
return valid_rolling_max(market.close, close_valid, 60)
|
||||
if name == "low_60d":
|
||||
@@ -3775,6 +3887,29 @@ def _compute_matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray:
|
||||
20,
|
||||
ddof=1,
|
||||
) * np.float32(252 ** 0.5)
|
||||
if name in {"kdj_k", "kdj_d", "kdj_j"}:
|
||||
low_valid = close_valid & np.isfinite(market.low)
|
||||
high_valid = close_valid & np.isfinite(market.high)
|
||||
low_9 = valid_rolling_min(market.low, low_valid, 9)
|
||||
high_9 = valid_rolling_max(market.high, high_valid, 9)
|
||||
rsv = _matrix_ratio(market.close - low_9, high_9 - low_9) * np.float32(100.0)
|
||||
k = valid_ewm_adjust_false(rsv, np.isfinite(rsv), alpha=1.0 / 3.0)
|
||||
if name == "kdj_k":
|
||||
return k
|
||||
d = valid_ewm_adjust_false(k, np.isfinite(k), alpha=1.0 / 3.0)
|
||||
if name == "kdj_d":
|
||||
return d
|
||||
return (np.float32(3.0) * k - np.float32(2.0) * d).astype(np.float32, copy=False)
|
||||
if name in {"atr_14", "atr_pct"}:
|
||||
previous = valid_shift(market.close, 1, close_valid)
|
||||
true_range = np.fmax.reduce([
|
||||
market.high - market.low,
|
||||
np.abs(market.high - previous),
|
||||
np.abs(market.low - previous),
|
||||
]).astype(np.float32, copy=False)
|
||||
true_range[~close_valid] = np.nan
|
||||
atr = valid_ewm_adjust_false(true_range, np.isfinite(true_range), alpha=1.0 / 14.0)
|
||||
return atr if name == "atr_14" else _matrix_ratio(atr, market.close)
|
||||
if name.startswith("rsi_") and name[4:].isdigit():
|
||||
window = int(name[4:])
|
||||
delta = market.close - valid_shift(market.close, 1, close_valid)
|
||||
@@ -3798,9 +3933,140 @@ def _compute_matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray:
|
||||
np.divide(average_gain, denominator, out=out, where=np.isfinite(denominator))
|
||||
out = np.float32(100.0) - np.float32(100.0) / (np.float32(1.0) + out)
|
||||
return out
|
||||
if name == "gap_return":
|
||||
return _matrix_relative(market.open, valid_shift(market.close, 1, close_valid))
|
||||
if name == "intraday_return":
|
||||
return _matrix_relative(market.close, market.open)
|
||||
if name == "close_position":
|
||||
return _matrix_ratio(market.close - market.low, market.high - market.low)
|
||||
if name == "distance_to_high_60d":
|
||||
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))
|
||||
|
||||
|
||||
def _matrix_macd(values: np.ndarray, valid: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
dif = _matrix_ema(values, valid, 12) - _matrix_ema(values, valid, 26)
|
||||
dif = dif.astype(np.float32, copy=False)
|
||||
dea = valid_ewm_adjust_false(dif, np.isfinite(dif), alpha=2.0 / 10.0)
|
||||
return dif, dea
|
||||
|
||||
|
||||
def _matrix_ratio(numerator: np.ndarray, denominator: np.ndarray) -> np.ndarray:
|
||||
out = np.full(numerator.shape, np.nan, dtype=np.float32)
|
||||
valid = np.isfinite(numerator) & np.isfinite(denominator) & (denominator != 0)
|
||||
np.divide(numerator, denominator, out=out, where=valid)
|
||||
return out
|
||||
|
||||
|
||||
def _matrix_relative(numerator: np.ndarray, denominator: np.ndarray) -> np.ndarray:
|
||||
out = _matrix_ratio(numerator, denominator)
|
||||
out[np.isfinite(out)] -= np.float32(1.0)
|
||||
return out
|
||||
|
||||
|
||||
def apply_time_masks(
|
||||
signals: SignalMatrix,
|
||||
entry_time_mask: np.ndarray,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
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 clamp_formal_start_for_regime(
|
||||
labels: Sequence[str],
|
||||
required_start: date | None,
|
||||
regime_filter: Mapping[str, Any] | None,
|
||||
) -> date | None:
|
||||
"""正式首日无前驱交易日时, 把首日让渡为预热, 返回顺延后的正式起点。
|
||||
|
||||
触发条件: 环境过滤实际启用, 且面板首日 >= 正式起点 (数据边界即正式起点,
|
||||
典型如「全部」范围从本地数据第一天开始)。此时首日的 T-1 环境所需的
|
||||
上一交易日不在面板内, fail-closed 校验会直接拒绝; 把首日降级为预热、
|
||||
正式起点顺延到第二个交易日, 首日环境即成为次日的 T-1。
|
||||
其余情况 (有预热日 / 过滤未启用 / 标签不足两天) 原样返回 required_start,
|
||||
无法顺延时由后续校验给出明确报错。
|
||||
"""
|
||||
if not regime_filter or required_start is None:
|
||||
return required_start
|
||||
has_states = bool(regime_filter.get("states") or [])
|
||||
has_score = regime_filter.get("min_score") is not None
|
||||
if not (has_states or has_score):
|
||||
return required_start
|
||||
if len(labels) < 2:
|
||||
return required_start
|
||||
if _date_text(labels[0]) < str(required_start):
|
||||
return required_start
|
||||
try:
|
||||
return date.fromisoformat(_date_text(labels[1]))
|
||||
except ValueError:
|
||||
return required_start
|
||||
|
||||
|
||||
def align_regime_t_minus_one(
|
||||
labels: Sequence[str],
|
||||
regime_by_date: Mapping[object, Any],
|
||||
required_start: date | None,
|
||||
required_end: date | None,
|
||||
*,
|
||||
first_day_boundary_ok: bool = False,
|
||||
) -> list[RegimePoint | None]:
|
||||
"""Align each label with the preceding label's regime without any I/O.
|
||||
|
||||
first_day_boundary_ok: 统计类调用方 (因子环境分组) 允许首日无前驱环境 ——
|
||||
数据边界即正式首日 (本地数据从正式首日开始) 时首日没有 T-1 环境属正常,
|
||||
跳过首日不参与分组即可, 不应阻断整个回测。内部缺口仍 fail-closed。
|
||||
"""
|
||||
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 and not first_day_boundary_ok:
|
||||
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
|
||||
@@ -11,7 +11,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
@@ -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,
|
||||
@@ -42,7 +44,15 @@ from app.indicators.pipeline import (
|
||||
get_signal_dependencies,
|
||||
)
|
||||
from app.strategy.engine import StrategyDataContext, StrategyDef, StrategyEngine
|
||||
from app.strategy.scoring import scoring_dependencies, scoring_value_expr
|
||||
from app.strategy.scoring import (
|
||||
SCORING_DIRECTION_LOW,
|
||||
effective_scoring,
|
||||
effective_scoring_directions,
|
||||
materialize_scoring_columns,
|
||||
scoring_dependencies,
|
||||
scoring_value_expr,
|
||||
scoring_warmup_bars,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,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"})
|
||||
|
||||
|
||||
@@ -73,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(
|
||||
@@ -100,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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -134,8 +147,7 @@ class StrategyDependencyResolver:
|
||||
}
|
||||
required_signals.update({"signal_limit_up", "signal_limit_down"})
|
||||
|
||||
scoring = dict(strategy.meta.get("scoring", {}) or {})
|
||||
scoring.update(overrides.get("scoring") or {})
|
||||
scoring = effective_scoring(strategy.meta.get("scoring"), overrides)
|
||||
required_features.update(scoring_dependencies(scoring))
|
||||
order_by = strategy.meta.get("order_by")
|
||||
if order_by and order_by != "score":
|
||||
@@ -187,7 +199,7 @@ class StrategyDependencyResolver:
|
||||
plan = FeaturePlan(
|
||||
required_features=frozenset(required_features),
|
||||
required_signals=frozenset(required_signals),
|
||||
warmup_bars=max(60, int(strategy.lookback_days or 1)),
|
||||
warmup_bars=max(60, int(strategy.lookback_days or 1), scoring_warmup_bars(scoring)),
|
||||
)
|
||||
return ResolvedFeaturePlan(
|
||||
base_columns=base_columns,
|
||||
@@ -199,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
|
||||
@@ -217,9 +232,20 @@ 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 = dict(strategy.meta.get("scoring", {}) or {})
|
||||
scoring.update(overrides.get("scoring") or {})
|
||||
scoring = effective_scoring(strategy.meta.get("scoring"), overrides)
|
||||
required_features.update(scoring_dependencies(scoring))
|
||||
order_by = strategy.meta.get("order_by")
|
||||
if order_by and order_by != "score":
|
||||
@@ -229,7 +255,12 @@ class StrategyDependencyResolver:
|
||||
base_columns = frozenset(set(base_columns) | set(_LIMIT_BASE_COLUMNS))
|
||||
instrument_columns = frozenset(required_features & set(_INSTRUMENT_COLUMNS))
|
||||
instrument_columns = frozenset(set(instrument_columns) | {"name"})
|
||||
warmup_bars = max(60, int(strategy.matrix_strategy.required_warmup_bars(params)))
|
||||
warmup_bars = max(
|
||||
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",
|
||||
"signal_limit_down",
|
||||
@@ -244,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
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -336,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,
|
||||
@@ -380,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,
|
||||
@@ -600,6 +638,7 @@ class StrategyBacktestService:
|
||||
config.holding_days,
|
||||
config.minute_fill,
|
||||
json.dumps(config.overrides or {}, sort_keys=True, ensure_ascii=False, default=str),
|
||||
json.dumps(config.regime_filter or {}, sort_keys=True, ensure_ascii=False, default=str),
|
||||
)
|
||||
|
||||
def _resolve_composite_feature_plan(
|
||||
@@ -661,12 +700,11 @@ class StrategyBacktestService:
|
||||
plans.append(child_plan)
|
||||
# pipeline 用 composite 统一的 basic_filter; scoring 用子策略自己的
|
||||
# (默认 + 用户 override), 因为子策略内部排序影响合并器的排名融合。
|
||||
child_scoring = dict(child_def.meta.get("scoring", {}) or {})
|
||||
if isinstance(child_override.get("scoring"), dict):
|
||||
child_scoring.update(child_override["scoring"])
|
||||
child_scoring = effective_scoring(child_def.meta.get("scoring"), child_override)
|
||||
child_pipeline_cfg = MatrixPipelineConfig(
|
||||
basic_filter=basic_filter,
|
||||
scoring=child_scoring,
|
||||
scoring_directions=effective_scoring_directions(child_override),
|
||||
order_by=child_def.meta.get("order_by"),
|
||||
descending=bool(child_def.meta.get("descending", True)),
|
||||
protect_strategy_cache=False,
|
||||
@@ -835,6 +873,8 @@ class StrategyBacktestService:
|
||||
timing_ms["load_panel"] = direct_load_ms
|
||||
timing_ms["market_data_matrix_build"] = 0.0
|
||||
timing_ms["market_data_direct_load"] = direct_load_ms
|
||||
# 环境过滤下正式起点=矩阵首日时顺延 (首日让渡为预热), 见 _clamp_regime_formal_start
|
||||
first = self._clamp_regime_formal_start(first, market_data.timestamp_labels)
|
||||
formal_range = self._matrix_date_range_mask(
|
||||
market_data.timestamp_labels,
|
||||
first.start,
|
||||
@@ -854,6 +894,8 @@ class StrategyBacktestService:
|
||||
_rm = self._build_regime_mask(
|
||||
market_data.timestamp_labels, first.regime_filter,
|
||||
getattr(getattr(self.engine.repo, "store", None), "data_dir", None),
|
||||
required_start=first.start,
|
||||
required_end=first.end,
|
||||
)
|
||||
if _rm is not None:
|
||||
entry_time_mask = entry_time_mask & _rm
|
||||
@@ -1028,6 +1070,11 @@ class StrategyBacktestService:
|
||||
matrix_data_cache_status = prepared.market_data.cache_status
|
||||
matrix_data_cache_hit = matrix_data_cache_status in {"exact", "covering"}
|
||||
matrix_data_cache_timing_ms = prepared.market_data.cache_timing_ms
|
||||
# 环境过滤下正式起点=矩阵首日时顺延 (首日让渡为预热)
|
||||
if config.regime_filter:
|
||||
config = self._clamp_regime_formal_start(
|
||||
config, prepared.market_data.timestamp_labels
|
||||
)
|
||||
elif s.execution_backend in ("matrix_native", "composite"):
|
||||
t_load = time.perf_counter()
|
||||
max_hold_for_profile = self._override_value(
|
||||
@@ -1073,6 +1120,11 @@ class StrategyBacktestService:
|
||||
matrix_data_cache_status = market_data.cache_status
|
||||
matrix_data_cache_hit = matrix_data_cache_status in {"exact", "covering"}
|
||||
matrix_data_cache_timing_ms = market_data.cache_timing_ms
|
||||
# 环境过滤下正式起点=矩阵首日时顺延 (首日让渡为预热)
|
||||
if config.regime_filter:
|
||||
config = self._clamp_regime_formal_start(
|
||||
config, market_data.timestamp_labels
|
||||
)
|
||||
formal_time_mask = self._matrix_date_range_mask(
|
||||
market_data.timestamp_labels,
|
||||
config.start,
|
||||
@@ -1096,6 +1148,13 @@ class StrategyBacktestService:
|
||||
timing_ms["load_panel"] = round((time.perf_counter() - t_load) * 1000, 1)
|
||||
if panel.is_empty():
|
||||
return _err("无数据,请检查日期范围或先运行盘后管道")
|
||||
# 环境过滤下正式起点=面板首日时顺延 (首日让渡为预热)
|
||||
if config.regime_filter:
|
||||
date_labels = tuple(
|
||||
str(value)[:10]
|
||||
for value in panel.get_column("date").unique().sort().to_list()
|
||||
)
|
||||
config = self._clamp_regime_formal_start(config, date_labels)
|
||||
formal_range = self._date_range_mask(panel, config.start, config.end)
|
||||
if not formal_range.any():
|
||||
return _err("正式回测区间内无数据")
|
||||
@@ -1139,10 +1198,15 @@ class StrategyBacktestService:
|
||||
config.end,
|
||||
)
|
||||
# 市场环境过滤(强制 T-1): 只叠加 entry, 不影响 exit
|
||||
_rm = self._build_regime_mask(
|
||||
market_data.timestamp_labels, config.regime_filter,
|
||||
getattr(getattr(self.engine.repo, "store", None), "data_dir", None),
|
||||
)
|
||||
try:
|
||||
_rm = self._build_regime_mask(
|
||||
market_data.timestamp_labels, config.regime_filter,
|
||||
getattr(getattr(self.engine.repo, "store", None), "data_dir", None),
|
||||
required_start=config.start,
|
||||
required_end=config.end,
|
||||
)
|
||||
except ValueError as e:
|
||||
return _err(str(e))
|
||||
if _rm is not None:
|
||||
entry_time_mask = entry_time_mask & _rm
|
||||
exit_time_mask = self._matrix_date_range_mask(
|
||||
@@ -1238,10 +1302,15 @@ class StrategyBacktestService:
|
||||
config.start,
|
||||
config.end,
|
||||
)
|
||||
_rm = self._build_regime_mask(
|
||||
market_data.timestamp_labels, config.regime_filter,
|
||||
getattr(getattr(self.engine.repo, "store", None), "data_dir", None),
|
||||
)
|
||||
try:
|
||||
_rm = self._build_regime_mask(
|
||||
market_data.timestamp_labels, config.regime_filter,
|
||||
getattr(getattr(self.engine.repo, "store", None), "data_dir", None),
|
||||
required_start=config.start,
|
||||
required_end=config.end,
|
||||
)
|
||||
except ValueError as e:
|
||||
return _err(str(e))
|
||||
if _rm is not None:
|
||||
entry_time_mask = entry_time_mask & _rm
|
||||
exit_time_mask = self._matrix_date_range_mask(
|
||||
@@ -1267,12 +1336,12 @@ class StrategyBacktestService:
|
||||
else None
|
||||
)
|
||||
|
||||
scoring = dict(s.meta.get("scoring", {}) or {})
|
||||
scoring.update(overrides.get("scoring") or {})
|
||||
scoring = effective_scoring(s.meta.get("scoring"), overrides)
|
||||
try:
|
||||
pipeline_config = MatrixPipelineConfig(
|
||||
basic_filter=basic_filter,
|
||||
scoring=scoring,
|
||||
scoring_directions=effective_scoring_directions(overrides),
|
||||
order_by=s.meta.get("order_by"),
|
||||
descending=bool(s.meta.get("descending", True)),
|
||||
protect_strategy_cache=prepared is not None,
|
||||
@@ -1348,6 +1417,27 @@ class StrategyBacktestService:
|
||||
formal_candidate_mask = candidate_mask & formal_range
|
||||
entry_mask = self._build_entry_mask_from_candidate(panel, candidate_mask, s, entry_signals)
|
||||
entry_mask = entry_mask & formal_range
|
||||
if config.regime_filter:
|
||||
date_values = panel.get_column("date").unique().sort().to_list()
|
||||
date_labels = tuple(str(value)[:10] for value in date_values)
|
||||
try:
|
||||
regime_time_mask = self._build_regime_mask(
|
||||
date_labels,
|
||||
config.regime_filter,
|
||||
getattr(getattr(self.engine.repo, "store", None), "data_dir", None),
|
||||
required_start=config.start,
|
||||
required_end=config.end,
|
||||
)
|
||||
except ValueError as e:
|
||||
return _err(str(e))
|
||||
if regime_time_mask is not None:
|
||||
allowed_dates = [
|
||||
value for value, allowed in zip(date_values, regime_time_mask, strict=True)
|
||||
if allowed
|
||||
]
|
||||
regime_row_mask = panel.get_column("date").is_in(allowed_dates).fill_null(False)
|
||||
formal_candidate_mask = formal_candidate_mask & regime_row_mask
|
||||
entry_mask = entry_mask & regime_row_mask
|
||||
raw_exit_mask = self._build_signal_mask(panel, exit_signals, "_exit")
|
||||
exit_range = self._date_range_mask(panel, config.start, load_end) if config.mode == "full" else formal_range
|
||||
exit_mask = raw_exit_mask & exit_range
|
||||
@@ -1644,55 +1734,60 @@ class StrategyBacktestService:
|
||||
count=len(timestamp_labels),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clamp_regime_formal_start(
|
||||
config: StrategyBacktestConfig, labels: tuple[str, ...] | list[str]
|
||||
) -> StrategyBacktestConfig:
|
||||
"""环境过滤下正式起点=面板首日 (无前驱交易日) 时, 顺延到第二个交易日。
|
||||
|
||||
数据边界即正式起点 (如「全部」范围) 时, T-1 环境校验会 fail-closed 拒绝;
|
||||
首日降级为预热后, 其环境即成为次日的 T-1, 仅损失 1 个正式交易日。
|
||||
"""
|
||||
from app.backtest.regime_alignment import clamp_formal_start_for_regime
|
||||
|
||||
shifted = clamp_formal_start_for_regime(labels, config.start, config.regime_filter)
|
||||
if shifted is not None and shifted != config.start:
|
||||
return replace(config, start=shifted)
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def _build_regime_mask(
|
||||
timestamp_labels: tuple[str, ...],
|
||||
regime_filter: dict | None,
|
||||
data_dir: Path | None,
|
||||
*,
|
||||
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 或无 regime 数据时返回 None(不过滤)。
|
||||
"""
|
||||
if not regime_filter or data_dir is None:
|
||||
"""构造逐日 T-1 regime mask, 保留历史静态入口兼容调用方。"""
|
||||
if not regime_filter:
|
||||
return None
|
||||
allowed_states = set(regime_filter.get("states") or [])
|
||||
min_score = regime_filter.get("min_score")
|
||||
if not allowed_states and min_score is None:
|
||||
return None
|
||||
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():
|
||||
return None
|
||||
|
||||
# 构建 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) # 默认允许
|
||||
for i in range(1, n):
|
||||
prev_label = timestamp_labels[i - 1][:10]
|
||||
entry = regime_map.get(prev_label)
|
||||
if entry is None:
|
||||
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
|
||||
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,
|
||||
@@ -1975,6 +2070,7 @@ class StrategyBacktestService:
|
||||
"mode": c.mode,
|
||||
"holding_days": c.holding_days,
|
||||
"minute_fill": c.minute_fill,
|
||||
"regime_filter": c.regime_filter,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -1984,12 +2080,11 @@ class StrategyBacktestService:
|
||||
overrides: dict | None,
|
||||
universe_mask: pl.Series | None = None,
|
||||
) -> pl.DataFrame:
|
||||
scoring = s.meta.get("scoring", {})
|
||||
scoring_overrides = (overrides or {}).get("scoring")
|
||||
if scoring_overrides:
|
||||
scoring = {**scoring, **scoring_overrides}
|
||||
scoring = effective_scoring(s.meta.get("scoring"), overrides)
|
||||
directions = effective_scoring_directions(overrides)
|
||||
|
||||
work = panel
|
||||
work = materialize_scoring_columns(panel, scoring.keys())
|
||||
temporary_scoring_columns = [name for name in scoring if name not in panel.columns and name in work.columns]
|
||||
has_universe = universe_mask is not None and len(universe_mask) == len(panel)
|
||||
if has_universe:
|
||||
work = work.with_columns(universe_mask.rename("_score_universe"))
|
||||
@@ -2000,18 +2095,23 @@ class StrategyBacktestService:
|
||||
return value
|
||||
|
||||
def _finish(df: pl.DataFrame) -> pl.DataFrame:
|
||||
return df.drop("_score_universe") if "_score_universe" in df.columns else df
|
||||
temporary = [
|
||||
name
|
||||
for name in ["_score_universe", *temporary_scoring_columns]
|
||||
if name in df.columns
|
||||
]
|
||||
return df.drop(temporary) if temporary else df
|
||||
|
||||
if scoring:
|
||||
executable = [
|
||||
(value, weight)
|
||||
(str(col), value, weight)
|
||||
for col, weight in scoring.items()
|
||||
if weight and (value := scoring_value_expr(work.columns, str(col))) is not None
|
||||
]
|
||||
total_weight = sum(weight for _, weight in executable)
|
||||
total_weight = sum(weight for _, _, weight in executable)
|
||||
if total_weight > 0:
|
||||
score_parts: list[pl.Expr] = []
|
||||
for score_value, weight in executable:
|
||||
for name, score_value, weight in executable:
|
||||
w = weight / total_weight
|
||||
value = _value_in_universe(score_value)
|
||||
col_min = value.min().over("date")
|
||||
@@ -2020,6 +2120,8 @@ class StrategyBacktestService:
|
||||
normalized = pl.when(col_range > 0).then(
|
||||
(score_value - col_min) / col_range
|
||||
).otherwise(pl.lit(0.5))
|
||||
if directions.get(name) == SCORING_DIRECTION_LOW:
|
||||
normalized = 1.0 - normalized
|
||||
if has_universe:
|
||||
normalized = pl.when(pl.col("_score_universe")).then(normalized).otherwise(0.0)
|
||||
score_parts.append(normalized * w)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""全局配置 — 从环境变量 / .env 读取。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -63,11 +64,17 @@ def _project_root() -> Path:
|
||||
|
||||
_PROJECT_ROOT = _project_root()
|
||||
_RESOURCE_ROOT = _resource_root()
|
||||
_ENV_FILE = Path(
|
||||
os.environ.get(
|
||||
"TICKFLOW_ENV_FILE",
|
||||
str(_RESOURCE_ROOT / ".env") if not _IS_FROZEN else ".env",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=str(_RESOURCE_ROOT / ".env") if not _IS_FROZEN else ".env",
|
||||
env_file=str(_ENV_FILE),
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# 后端二次开发目录
|
||||
|
||||
在本目录新增普通 `.py` 模块即可由应用自动发现,无需修改 `app/main.py`。以下划线开头的模块不会加载。
|
||||
|
||||
以 [`_template.py.example`](_template.py.example) 为起点,并遵循 [`docs/secondary-development.md`](../../../docs/secondary-development.md)。模板文件不会参与运行。
|
||||
@@ -0,0 +1,4 @@
|
||||
"""In-repository backend secondary-development modules.
|
||||
|
||||
Copy ``_template.py.example`` to a non-underscore ``.py`` module to enable it.
|
||||
"""
|
||||
@@ -0,0 +1,38 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.extensions import (
|
||||
BACKEND_EXTENSION_API_VERSION,
|
||||
BackendExtensionRegistrar,
|
||||
ExtensionContext,
|
||||
NotificationFormatContext,
|
||||
NotificationFormatter,
|
||||
)
|
||||
|
||||
|
||||
class CompanyNotificationFormatter(NotificationFormatter):
|
||||
def format_message(self, event: dict, context: NotificationFormatContext) -> str:
|
||||
return f"[公司规则] {event.get('message', '')}".strip()
|
||||
|
||||
|
||||
EXTENSION_ID = "company.example"
|
||||
EXTENSION_API_VERSION = BACKEND_EXTENSION_API_VERSION
|
||||
|
||||
|
||||
def setup(registrar: BackendExtensionRegistrar) -> None:
|
||||
registrar.register_notification_formatter(
|
||||
"company.notification",
|
||||
CompanyNotificationFormatter(),
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/custom/example", tags=["custom-example"])
|
||||
|
||||
@router.get("/status")
|
||||
def status() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
registrar.include_router(router)
|
||||
|
||||
|
||||
def startup(context: ExtensionContext) -> None:
|
||||
# Core repository and data directory are available here. Keep this hook fast.
|
||||
_ = context
|
||||
@@ -0,0 +1,323 @@
|
||||
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) as exc:
|
||||
# Windows 对不存在的 pid 返回 WinError 87 (ERROR_INVALID_PARAMETER),
|
||||
# 不会映射为 ProcessLookupError; 按存活处理会让孤儿发布锁永远无法恢复。
|
||||
if getattr(exc, "winerror", None) == 87:
|
||||
return False
|
||||
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
|
||||
@@ -0,0 +1,16 @@
|
||||
from app.extensions.contracts import (
|
||||
BACKEND_EXTENSION_API_VERSION,
|
||||
ExtensionContext,
|
||||
NotificationFormatContext,
|
||||
NotificationFormatter,
|
||||
)
|
||||
from app.extensions.registry import BackendExtensionRegistrar, BackendExtensionRegistry
|
||||
|
||||
__all__ = [
|
||||
"BACKEND_EXTENSION_API_VERSION",
|
||||
"BackendExtensionRegistrar",
|
||||
"BackendExtensionRegistry",
|
||||
"ExtensionContext",
|
||||
"NotificationFormatContext",
|
||||
"NotificationFormatter",
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Stable, small-grained contracts for in-repository secondary development."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
BACKEND_EXTENSION_API_VERSION = 1
|
||||
|
||||
|
||||
class RepositoryAccess(Protocol):
|
||||
"""Read-oriented repository surface exposed to backend extensions."""
|
||||
|
||||
def get_name_map(self, symbols: list[str] | None = None) -> dict[str, str]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtensionContext:
|
||||
api_version: int
|
||||
data_dir: Path
|
||||
repository: RepositoryAccess
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NotificationFormatContext:
|
||||
api_version: int
|
||||
|
||||
|
||||
class NotificationFormatter(ABC):
|
||||
"""Customize notification copy without changing the event schema or semantics."""
|
||||
|
||||
api_version = BACKEND_EXTENSION_API_VERSION
|
||||
|
||||
@abstractmethod
|
||||
def format_message(
|
||||
self,
|
||||
event: dict[str, Any],
|
||||
context: NotificationFormatContext,
|
||||
) -> str:
|
||||
"""Return notification copy. The input event must not be mutated."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DefaultNotificationFormatter(NotificationFormatter):
|
||||
def format_message(
|
||||
self,
|
||||
event: dict[str, Any],
|
||||
context: NotificationFormatContext,
|
||||
) -> str:
|
||||
del context
|
||||
return str(event.get("message") or "")
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Discover in-repository backend customizations without touching user data."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import pkgutil
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from app.extensions.contracts import BACKEND_EXTENSION_API_VERSION, ExtensionContext
|
||||
from app.extensions.registry import BackendExtensionRegistrar, BackendExtensionRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackendExtensionLoadError:
|
||||
module: str
|
||||
error: str
|
||||
|
||||
|
||||
def _custom_module_names() -> list[str]:
|
||||
try:
|
||||
package = importlib.import_module("app.custom")
|
||||
except ModuleNotFoundError:
|
||||
return []
|
||||
return sorted(
|
||||
item.name
|
||||
for item in pkgutil.iter_modules(package.__path__, f"{package.__name__}.")
|
||||
if not item.name.rsplit(".", 1)[-1].startswith("_")
|
||||
)
|
||||
|
||||
|
||||
def configure_backend_extensions(
|
||||
app: FastAPI,
|
||||
) -> tuple[BackendExtensionRegistry, tuple[BackendExtensionLoadError, ...]]:
|
||||
"""Import custom modules and register validated routes and policies."""
|
||||
registry = BackendExtensionRegistry()
|
||||
errors: list[BackendExtensionLoadError] = []
|
||||
|
||||
for module_name in _custom_module_names():
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
extension_id = getattr(module, "EXTENSION_ID", None)
|
||||
api_version = getattr(module, "EXTENSION_API_VERSION", None)
|
||||
if not isinstance(extension_id, str):
|
||||
raise ValueError("backend extension module must define EXTENSION_ID")
|
||||
registrar = BackendExtensionRegistrar(extension_id, api_version=api_version)
|
||||
setup = getattr(module, "setup", None)
|
||||
if not callable(setup):
|
||||
raise ValueError("backend extension module must define setup(registrar)")
|
||||
setup(registrar)
|
||||
_validate_router_conflicts(app, registrar)
|
||||
registry.register(registrar)
|
||||
for router in registrar.routers:
|
||||
app.include_router(router)
|
||||
except Exception as exc:
|
||||
logger.warning("backend extension load failed %s: %s", module_name, exc)
|
||||
errors.append(BackendExtensionLoadError(module_name, str(exc)))
|
||||
|
||||
registry.freeze()
|
||||
return registry, tuple(errors)
|
||||
|
||||
|
||||
def _validate_router_conflicts(app: FastAPI, registrar: BackendExtensionRegistrar) -> None:
|
||||
existing = {
|
||||
(route.path, method)
|
||||
for route in app.routes
|
||||
if isinstance(route, APIRoute)
|
||||
for method in route.methods
|
||||
}
|
||||
staged: set[tuple[str, str]] = set()
|
||||
for router in registrar.routers:
|
||||
for route in router.routes:
|
||||
if not isinstance(route, APIRoute):
|
||||
continue
|
||||
for method in route.methods:
|
||||
key = (route.path, method)
|
||||
if key in existing or key in staged:
|
||||
raise ValueError(
|
||||
f"extension {registrar.extension_id!r} route conflicts: "
|
||||
f"{method} {route.path}"
|
||||
)
|
||||
staged.add(key)
|
||||
|
||||
|
||||
def start_backend_extensions(
|
||||
context: ExtensionContext,
|
||||
registry: BackendExtensionRegistry,
|
||||
) -> None:
|
||||
"""Run optional post-core startup hooks after the stable context is available."""
|
||||
for module_name in _custom_module_names():
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
if getattr(module, "EXTENSION_ID", None) not in registry.extension_ids():
|
||||
continue
|
||||
startup = getattr(module, "startup", None)
|
||||
if callable(startup):
|
||||
startup(context)
|
||||
except Exception as exc:
|
||||
logger.warning("backend extension startup failed %s: %s", module_name, exc)
|
||||
|
||||
|
||||
def current_extension_context(*, data_dir, repository) -> ExtensionContext:
|
||||
return ExtensionContext(
|
||||
api_version=BACKEND_EXTENSION_API_VERSION,
|
||||
data_dir=data_dir,
|
||||
repository=repository,
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Backend extension registry with version checks and deterministic freezing."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.extensions.contracts import (
|
||||
BACKEND_EXTENSION_API_VERSION,
|
||||
DefaultNotificationFormatter,
|
||||
NotificationFormatter,
|
||||
)
|
||||
|
||||
_ID_RE = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$")
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegisteredImplementation(Generic[T]):
|
||||
extension_id: str
|
||||
implementation_id: str
|
||||
implementation: T
|
||||
order: int
|
||||
|
||||
|
||||
class BackendExtensionRegistrar:
|
||||
"""Staging area: a failed setup is discarded without partial registration."""
|
||||
|
||||
def __init__(self, extension_id: str, *, api_version: int) -> None:
|
||||
self.extension_id = extension_id
|
||||
self.api_version = api_version
|
||||
self.routers: list[APIRouter] = []
|
||||
self.notification_formatters: list[tuple[str, NotificationFormatter, int]] = []
|
||||
|
||||
def include_router(self, router: APIRouter) -> None:
|
||||
if not isinstance(router, APIRouter):
|
||||
raise TypeError("router must be fastapi.APIRouter")
|
||||
self.routers.append(router)
|
||||
|
||||
def register_notification_formatter(
|
||||
self,
|
||||
implementation_id: str,
|
||||
formatter: NotificationFormatter,
|
||||
*,
|
||||
order: int = 100,
|
||||
) -> None:
|
||||
self.notification_formatters.append((implementation_id, formatter, order))
|
||||
|
||||
|
||||
class BackendExtensionRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._extension_ids: set[str] = set()
|
||||
self._notification_formatters: list[RegisteredImplementation[NotificationFormatter]] = []
|
||||
self._frozen = False
|
||||
|
||||
@property
|
||||
def frozen(self) -> bool:
|
||||
return self._frozen
|
||||
|
||||
@property
|
||||
def has_customizations(self) -> bool:
|
||||
return bool(self._extension_ids)
|
||||
|
||||
@property
|
||||
def has_notification_formatters(self) -> bool:
|
||||
return bool(self._notification_formatters)
|
||||
|
||||
def extension_ids(self) -> frozenset[str]:
|
||||
return frozenset(self._extension_ids)
|
||||
|
||||
def register(self, registrar: BackendExtensionRegistrar) -> None:
|
||||
"""Validate a staged extension fully before mutating the registry."""
|
||||
self._ensure_mutable()
|
||||
extension_id = registrar.extension_id
|
||||
self._validate_id(extension_id, "extension_id")
|
||||
if registrar.api_version != BACKEND_EXTENSION_API_VERSION:
|
||||
raise ValueError(
|
||||
f"extension {extension_id!r} requires backend API v{registrar.api_version}; "
|
||||
f"current is v{BACKEND_EXTENSION_API_VERSION}"
|
||||
)
|
||||
if extension_id in self._extension_ids:
|
||||
raise ValueError(f"duplicate extension id: {extension_id}")
|
||||
|
||||
known_ids = {item.implementation_id for item in self._notification_formatters}
|
||||
staged_ids: set[str] = set()
|
||||
staged: list[RegisteredImplementation[NotificationFormatter]] = []
|
||||
for implementation_id, formatter, order in registrar.notification_formatters:
|
||||
self._validate_id(implementation_id, "implementation_id")
|
||||
if not isinstance(formatter, NotificationFormatter):
|
||||
raise TypeError("formatter must inherit NotificationFormatter")
|
||||
if formatter.api_version != BACKEND_EXTENSION_API_VERSION:
|
||||
raise ValueError(
|
||||
f"formatter {implementation_id!r} requires API v{formatter.api_version}; "
|
||||
f"current is v{BACKEND_EXTENSION_API_VERSION}"
|
||||
)
|
||||
if implementation_id in known_ids or implementation_id in staged_ids:
|
||||
raise ValueError(f"duplicate notification formatter id: {implementation_id}")
|
||||
staged_ids.add(implementation_id)
|
||||
staged.append(
|
||||
RegisteredImplementation(extension_id, implementation_id, formatter, order)
|
||||
)
|
||||
|
||||
self._extension_ids.add(extension_id)
|
||||
self._notification_formatters.extend(staged)
|
||||
|
||||
def freeze(self) -> None:
|
||||
self._notification_formatters.sort(
|
||||
key=lambda item: (item.order, item.implementation_id)
|
||||
)
|
||||
self._frozen = True
|
||||
|
||||
def notification_formatters(
|
||||
self,
|
||||
) -> tuple[RegisteredImplementation[NotificationFormatter], ...]:
|
||||
if not self._frozen:
|
||||
raise RuntimeError("backend extension registry must be frozen before use")
|
||||
if not self._notification_formatters:
|
||||
return (
|
||||
RegisteredImplementation(
|
||||
"core", "core.notification", DefaultNotificationFormatter(), 0,
|
||||
),
|
||||
)
|
||||
return tuple(self._notification_formatters)
|
||||
|
||||
def _ensure_mutable(self) -> None:
|
||||
if self._frozen:
|
||||
raise RuntimeError("backend extension registry is frozen")
|
||||
|
||||
@staticmethod
|
||||
def _validate_id(value: str, label: str) -> None:
|
||||
if not isinstance(value, str) or not _ID_RE.fullmatch(value):
|
||||
raise ValueError(f"invalid {label}: {value!r}")
|
||||
@@ -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 (
|
||||
@@ -160,6 +164,10 @@ ENRICHED_COLUMNS: dict[str, dict[str, str]] = {
|
||||
"momentum_20d": "20日动量",
|
||||
"momentum_30d": "30日动量",
|
||||
"momentum_60d": "60日动量",
|
||||
# ── 异动偏离 (运行时由 repository 附着, 不落盘) ────────
|
||||
"deviate_3d": "3日涨跌幅偏离值(vs对应指数, 小数)",
|
||||
"deviate_10d": "10日涨跌幅偏离值",
|
||||
"deviate_30d": "30日涨跌幅偏离值",
|
||||
# ── 波动率 ───────────────────────────────────────────
|
||||
"annual_vol_20d": "20日年化波动率",
|
||||
# ── RSI ──────────────────────────────────────────────
|
||||
@@ -206,6 +214,7 @@ ENRICHED_COLUMNS_BY_CATEGORY: dict[str, list[str]] = {
|
||||
"volume": ["vol_ma5", "vol_ma10", "vol_ratio_5d"],
|
||||
"extremes": ["high_60d", "low_60d"],
|
||||
"momentum": ["momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d"],
|
||||
"deviation": ["deviate_3d", "deviate_10d", "deviate_30d"],
|
||||
"volatility": ["annual_vol_20d"],
|
||||
"rsi": ["rsi_6", "rsi_14", "rsi_24"],
|
||||
"signals": [k for k in ENRICHED_COLUMNS if k.startswith("signal_")],
|
||||
@@ -350,7 +359,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 +384,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 +684,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 +893,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")
|
||||
@@ -1019,6 +1034,249 @@ def _select_storage_cols(df: pl.DataFrame) -> pl.DataFrame:
|
||||
return df.select(cols)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 异动偏离列 (deviate_3d/10d/30d)
|
||||
#
|
||||
# N 日涨跌幅偏离值 = 个股 N 日累计涨跌幅 - 对应指数同期涨跌幅,
|
||||
# 是交易所「异常波动 / 严重异常波动」规则的量化口径 (如主板 3日±20%,
|
||||
# 10日+100%, 30日+200%)。不属于 compute_indicators 的纯函数范围
|
||||
# (需要指数数据), 因此在 repository 读取路径上附着, 不随 parquet 落盘。
|
||||
# ================================================================
|
||||
|
||||
DEVIATION_WINDOWS: tuple[int, ...] = (3, 10, 30)
|
||||
|
||||
# 各交易所基准指数 (偏离值规则的「对应指数」近似): 优先分类指数, 缺失时回退
|
||||
_BENCHMARK_PREFERENCE: dict[str, list[str]] = {
|
||||
"SH": ["000002.SH", "000001.SH"], # 上证A指 → 上证指数
|
||||
"SZ": ["399107.SZ", "399001.SZ"], # 深证A指 → 深证成指
|
||||
"BJ": ["899050.BJ", "000001.SH"], # 北证50 → 上证指数
|
||||
}
|
||||
|
||||
_benchmark_cache: dict[str, tuple[float, pl.DataFrame | None]] = {}
|
||||
_BENCHMARK_CACHE_TTL = 600.0
|
||||
|
||||
|
||||
def load_benchmark_momentum(data_dir: Path) -> pl.DataFrame | None:
|
||||
"""读取指数日K, 计算各基准指数的滚动 N 日涨跌幅。
|
||||
|
||||
返回长表: date, bench_exchange, bench_close, bench_mom3d, bench_mom10d, bench_mom30d。
|
||||
bench_close 供盘中路径外推今日基准动量 (benchmark_momentum_today)。
|
||||
无可用指数数据时返回 None (偏离列置 null, 不阻塞主流程)。
|
||||
进程内按 data_dir 缓存 (TTL 10 分钟)。
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
now = _time.monotonic()
|
||||
key = str(Path(data_dir).resolve())
|
||||
cached = _benchmark_cache.get(key)
|
||||
if cached is not None and now - cached[0] < _BENCHMARK_CACHE_TTL:
|
||||
return cached[1]
|
||||
|
||||
frame: pl.DataFrame | None = None
|
||||
try:
|
||||
index_glob = str(Path(data_dir) / "kline_index_daily" / "**" / "*.parquet")
|
||||
wanted: list[str] = []
|
||||
bench_of: dict[str, str] = {}
|
||||
for exchange, candidates in _BENCHMARK_PREFERENCE.items():
|
||||
for sym in candidates:
|
||||
if sym not in bench_of:
|
||||
wanted.append(sym)
|
||||
bench_of[sym] = exchange
|
||||
lf = scan_daily_parquet(
|
||||
index_glob, cast_options=pl.ScanCastOptions(integer_cast="allow-float")
|
||||
)
|
||||
df_idx = (
|
||||
lf.filter(pl.col("symbol").is_in(wanted))
|
||||
.select(["symbol", "date", "close"])
|
||||
.sort(["symbol", "date"])
|
||||
.collect()
|
||||
)
|
||||
if not df_idx.is_empty():
|
||||
available = set(df_idx["symbol"].to_list())
|
||||
picked = [s for s in wanted if s in available]
|
||||
# 每个交易所取优先级最高的可用基准; 全缺时回退到任一可用基准。
|
||||
# 同一基准可服务多个交易所 (如北证50 缺失时北交所回退上证指数)。
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for exchange, candidates in _BENCHMARK_PREFERENCE.items():
|
||||
hit = next((s for s in candidates if s in available), None)
|
||||
if hit is None and picked:
|
||||
hit = picked[0]
|
||||
if hit is not None:
|
||||
pairs.append((hit, exchange))
|
||||
df_bench = df_idx.filter(pl.col("symbol").is_in([p[0] for p in pairs]))
|
||||
if not df_bench.is_empty():
|
||||
df_bench = df_bench.with_columns(
|
||||
pl.col("close").cast(pl.Float64, strict=False)
|
||||
).with_columns([
|
||||
(pl.col("close") / pl.col("close").shift(n).over("symbol") - 1).alias(f"_bm{n}")
|
||||
for n in DEVIATION_WINDOWS
|
||||
]).rename({f"_bm{n}": f"bench_mom{n}d" for n in DEVIATION_WINDOWS})
|
||||
exchange_map = pl.DataFrame({
|
||||
"symbol": [p[0] for p in pairs],
|
||||
"bench_exchange": [p[1] for p in pairs],
|
||||
})
|
||||
frame = (
|
||||
df_bench.join(exchange_map, on="symbol", how="inner")
|
||||
.select(["date", "bench_exchange", "close",
|
||||
*[f"bench_mom{n}d" for n in DEVIATION_WINDOWS]])
|
||||
.rename({"close": "bench_close"})
|
||||
.unique(subset=["date", "bench_exchange"])
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("基准指数偏离数据加载失败: %s", exc)
|
||||
frame = None
|
||||
|
||||
_benchmark_cache[key] = (now, frame)
|
||||
return frame
|
||||
|
||||
|
||||
def _bench_exchange_expr() -> pl.Expr:
|
||||
"""symbol 后缀 → 交易所 (SH/SZ/BJ), 无法识别时 null。"""
|
||||
return (
|
||||
pl.col("symbol").str.slice(-2).str.to_uppercase().replace(
|
||||
{ex: ex for ex in _BENCHMARK_PREFERENCE},
|
||||
default=None,
|
||||
return_dtype=pl.Utf8,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def attach_deviation_columns(df: pl.DataFrame, data_dir: Path) -> pl.DataFrame:
|
||||
"""为已含 momentum_Nd 的 enriched 帧附着 deviate_Nd 偏离列 (全量/冷路径)。
|
||||
|
||||
缺失的动量列 (如 momentum_3d 不在指标全集里) 就地按 close 补算,
|
||||
与 compute_indicators 在同一帧上的 shift 语义一致。
|
||||
基准按 symbol 后缀分交易所匹配, join 不上的行 (新上市/基准缺失) 置 null。
|
||||
"""
|
||||
if df.is_empty():
|
||||
return df
|
||||
bench = load_benchmark_momentum(data_dir)
|
||||
dev_cols = [f"deviate_{n}d" for n in DEVIATION_WINDOWS]
|
||||
if bench is None or bench.is_empty():
|
||||
return df.with_columns([pl.lit(None, dtype=pl.Float64).alias(c) for c in dev_cols])
|
||||
if "close" not in df.columns:
|
||||
logger.warning("偏离列附着跳过: 缺少 close 列")
|
||||
return df.with_columns([pl.lit(None, dtype=pl.Float64).alias(c) for c in dev_cols])
|
||||
missing = [n for n in DEVIATION_WINDOWS if f"momentum_{n}d" not in df.columns]
|
||||
if missing:
|
||||
df = df.sort(["symbol", "date"]).with_columns([
|
||||
(pl.col("close") / pl.col("close").shift(n).over("symbol") - 1).alias(f"momentum_{n}d")
|
||||
for n in missing
|
||||
])
|
||||
out = (
|
||||
df.with_columns(_bench_exchange_expr().alias("_bench_ex"))
|
||||
.join(bench, left_on=["_bench_ex", "date"], right_on=["bench_exchange", "date"], how="left")
|
||||
.with_columns([
|
||||
(pl.col(f"momentum_{n}d") - pl.col(f"bench_mom{n}d")).alias(f"deviate_{n}d")
|
||||
for n in DEVIATION_WINDOWS
|
||||
])
|
||||
.drop(["_bench_ex", "bench_close", *[f"bench_mom{n}d" for n in DEVIATION_WINDOWS]])
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _bench_rt_pct_of(index_quotes: pl.DataFrame | None, candidates: list[str]) -> float:
|
||||
"""从实时指数行情取某交易所首选基准的今日涨跌, 缺数据时 0。"""
|
||||
if index_quotes is None or index_quotes.is_empty():
|
||||
return 0.0
|
||||
df = index_quotes.filter(pl.col("symbol").is_in(candidates))
|
||||
if df.is_empty():
|
||||
return 0.0
|
||||
# 候选按优先级排序, 取第一个有有效涨跌的
|
||||
by_sym = {r["symbol"]: r for r in df.iter_rows(named=True)}
|
||||
for sym in candidates:
|
||||
row = by_sym.get(sym)
|
||||
if row is None:
|
||||
continue
|
||||
for col in ("change_pct", "pct", "pct_change"):
|
||||
v = row.get(col)
|
||||
if v is not None:
|
||||
return float(v)
|
||||
if row.get("close") is not None and row.get("prev_close") is not None and row["prev_close"]:
|
||||
return float(row["close"] / row["prev_close"] - 1)
|
||||
return 0.0
|
||||
|
||||
|
||||
def benchmark_momentum_today(
|
||||
data_dir: Path,
|
||||
index_quotes: pl.DataFrame | None = None,
|
||||
) -> pl.DataFrame | None:
|
||||
"""各交易所基准指数的「今日」N 日动量 (盘中实时外推)。
|
||||
|
||||
基准日K parquet 盘中不含今日, 今日基准收盘 = 昨收 × (1 + 实时涨跌)。
|
||||
N 日动量 = 今日基准收盘 / N 个交易日前的收盘 - 1; 交易所与
|
||||
load_benchmark_momentum 的选基逻辑一致 (同一 TTL 缓存帧)。
|
||||
返回小表: bench_exchange, bench_mom3d, bench_mom10d, bench_mom30d。
|
||||
无基准数据时 None。
|
||||
"""
|
||||
bench = load_benchmark_momentum(data_dir)
|
||||
if bench is None or bench.is_empty():
|
||||
return None
|
||||
# 指数监控 (mode=all) 盘中会向 kline_index_daily 写入今日行;
|
||||
# 「昨收」必须排除今日, 否则实时涨跌被重复叠加
|
||||
today = cn_today()
|
||||
bench = bench.filter(pl.col("date") < today)
|
||||
if bench.is_empty():
|
||||
return None
|
||||
rows: list[dict[str, float | str]] = []
|
||||
for ex in sorted(bench["bench_exchange"].unique().to_list()):
|
||||
sub = bench.filter(pl.col("bench_exchange") == ex).sort("date")
|
||||
closes = sub["bench_close"]
|
||||
if closes.len() == 0:
|
||||
continue
|
||||
yesterday_close = closes[-1]
|
||||
rt = _bench_rt_pct_of(index_quotes, _BENCHMARK_PREFERENCE.get(ex, []))
|
||||
row: dict[str, float | str] = {
|
||||
"bench_exchange": ex,
|
||||
}
|
||||
for n in DEVIATION_WINDOWS:
|
||||
base = closes[-n] if closes.len() >= n else None # N 个交易日前 (不含今日)
|
||||
row[f"bench_mom{n}d"] = (
|
||||
(yesterday_close * (1.0 + rt)) / base - 1.0
|
||||
if base is not None and yesterday_close is not None and base > 0
|
||||
else None
|
||||
)
|
||||
rows.append(row)
|
||||
if not rows:
|
||||
return None
|
||||
schema = {"bench_exchange": pl.Utf8, **{f"bench_mom{n}d": pl.Float64 for n in DEVIATION_WINDOWS}}
|
||||
return pl.DataFrame(rows, schema=schema)
|
||||
|
||||
|
||||
def attach_deviation_columns_today(
|
||||
df: pl.DataFrame,
|
||||
data_dir: Path,
|
||||
index_quotes: pl.DataFrame | None = None,
|
||||
) -> pl.DataFrame:
|
||||
"""为盘中单日 enriched 帧附着 deviate_Nd 偏离列 (增量热路径)。
|
||||
|
||||
与 attach_deviation_columns 的区别: 入参是「仅今日」的单日帧, 无法用
|
||||
shift 补算动量, 直接使用帧上已有的 momentum_Nd (compute_enriched_today
|
||||
产出); 基准动量用 benchmark_momentum_today 的实时外推值。
|
||||
缺失动量的窗口 (如全量回退路径无 momentum_3d) 置 null, 不阻塞主流程。
|
||||
"""
|
||||
dev_cols = [f"deviate_{n}d" for n in DEVIATION_WINDOWS]
|
||||
if df.is_empty():
|
||||
return df
|
||||
bench = benchmark_momentum_today(data_dir, index_quotes)
|
||||
if bench is None or bench.is_empty():
|
||||
return df.with_columns([
|
||||
pl.lit(None, dtype=pl.Float64).alias(c) for c in dev_cols if c not in df.columns
|
||||
])
|
||||
exprs = [
|
||||
(pl.col(f"momentum_{n}d") - pl.col(f"bench_mom{n}d")).alias(f"deviate_{n}d")
|
||||
if f"momentum_{n}d" in df.columns
|
||||
else pl.lit(None, dtype=pl.Float64).alias(f"deviate_{n}d")
|
||||
for n in DEVIATION_WINDOWS
|
||||
]
|
||||
return (
|
||||
df.with_columns(_bench_exchange_expr().alias("_bench_ex"))
|
||||
.join(bench, left_on="_bench_ex", right_on="bench_exchange", how="left")
|
||||
.with_columns(exprs)
|
||||
.drop(["_bench_ex", *[f"bench_mom{n}d" for n in DEVIATION_WINDOWS]])
|
||||
)
|
||||
|
||||
|
||||
def run_pipeline(data_dir: Path | None = None,
|
||||
symbols: list[str] | None = None,
|
||||
new_dates_only: bool = False,
|
||||
@@ -1045,6 +1303,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 +1397,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 +1429,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 +1531,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 +1572,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 +1604,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)
|
||||
@@ -1572,6 +1841,12 @@ def compute_enriched_today(
|
||||
(pl.col("close") / pl.col("_close_60d_ago") - 1).alias("momentum_60d"),
|
||||
])
|
||||
|
||||
# ---- 动量 3d (异动偏离 deviate_3d 用; 旧 live_agg 未带该状态时跳过, 偏离列自然置 null) ----
|
||||
if "_close_3d_ago" in df.columns:
|
||||
df = df.with_columns(
|
||||
(pl.col("close") / pl.col("_close_3d_ago") - 1).alias("momentum_3d")
|
||||
)
|
||||
|
||||
# ---- 年化波动率 20d (递推) ----
|
||||
# 用 Welford 简化: sum + sum_sq of 19 historical returns + today's return
|
||||
today_ret = pl.col("close") / pl.col("prev_close") - 1
|
||||
@@ -1814,10 +2089,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"),
|
||||
|
||||
@@ -537,11 +537,40 @@ def run_now(
|
||||
invalidate_regime_cache()
|
||||
logger.info("compute_regime: %d days", regime_days)
|
||||
emit("compute_regime", 92, f"市场环境 {regime_days} 天")
|
||||
# 阶段切换推送监控通知 (软失败, 不影响管道): 末两日阶段不同 = 今日发生切换。
|
||||
# 切入退潮/冰点为风险信号, 用 warn 级别; 其余 info。
|
||||
if regime_days:
|
||||
try:
|
||||
_push_phase_change_alert(repo.store.data_dir)
|
||||
except Exception as e:
|
||||
logger.warning("phase change alert failed (soft): %s", e)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("compute_regime failed (soft): %s", e)
|
||||
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 +590,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 +656,81 @@ def _refresh_instruments_view(repo: KlineRepository) -> None:
|
||||
logger.warning("refresh instruments view failed: %s", e)
|
||||
|
||||
|
||||
def _run_tracked(fn, job_label: str) -> None:
|
||||
def _push_phase_change_alert(data_dir) -> None:
|
||||
"""情绪周期阶段切换 → 推送监控通知(SSE toast + 监控中心)。
|
||||
|
||||
阶段切换(如 退潮→冰点)是重要的市场信号, 原先只有打开市场环境页才能看到。
|
||||
复用 quote_service.push_alerts 广播通道; 未发生切换静默返回。
|
||||
"""
|
||||
from app.services.market_phase import PHASE_LABELS
|
||||
from app.services.regime_builder import latest_phase_transition
|
||||
|
||||
tr = latest_phase_transition(data_dir)
|
||||
if not tr:
|
||||
return
|
||||
prev, cur, d = tr
|
||||
msg = f"情绪周期阶段切换: {PHASE_LABELS.get(prev, prev)} → {PHASE_LABELS.get(cur, cur)} ({d})"
|
||||
severity = "warn" if cur in ("ebb", "ice") else "info"
|
||||
app_state = _get_app_state()
|
||||
qs = getattr(app_state, "quote_service", None) if app_state else None
|
||||
if qs:
|
||||
qs.push_alerts([{
|
||||
"source": "market",
|
||||
"type": "phase_change",
|
||||
"message": msg,
|
||||
"severity": severity,
|
||||
}])
|
||||
logger.info("phase change alert: %s (severity=%s)", msg, severity)
|
||||
|
||||
|
||||
def _run_tracked(fn, job_label: str) -> bool:
|
||||
"""调度触发时包装 JobStore 跟踪,确保同步历史有记录。
|
||||
|
||||
单飞: 若已有活跃(pending∨running)任务(手动同步中), 本次调度直接跳过, 不并发。
|
||||
重任务执行槽: 再挡一层僵尸并发(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 +990,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"),
|
||||
|
||||
+147
-51
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,10 +12,43 @@ 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 (
|
||||
abnormal,
|
||||
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
|
||||
@@ -27,11 +60,33 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 追加文件日志: uvicorn (含 --reload 开发模式) 默认只有 StreamHandler, 同步/管道等
|
||||
# 运行时日志仅出现在 dev 终端, 关掉或滚屏后即丢失, 排查「同步后日志没落」时无处可查。
|
||||
# 落盘到 data/backend.log 与桌面版 (desktop.py:_setup_logging → desktop.log) 行为对齐,
|
||||
# 事后可查。桌面版 (frozen) 已由 desktop.py 写 desktop.log, 此处跳过避免重复落盘。
|
||||
# RotatingFileHandler 防止长期运行/频繁 reload 导致文件无限增长。
|
||||
if not getattr(sys, "frozen", False):
|
||||
try:
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
_log_path = settings.data_dir / "backend.log"
|
||||
_log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_file_handler = RotatingFileHandler(
|
||||
_log_path, maxBytes=10 * 1024 * 1024, backupCount=3,
|
||||
mode="a", encoding="utf-8", errors="replace",
|
||||
)
|
||||
_file_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
)
|
||||
logging.getLogger().addHandler(_file_handler)
|
||||
except Exception as _e: # noqa: BLE001
|
||||
logger.warning("文件日志初始化失败, 仅输出到终端: %s", _e)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async def _application_lifespan(app: FastAPI):
|
||||
logger.info(
|
||||
"TickFlow Stock Panel v%s starting (mode=%s)",
|
||||
"Tick Stock Panel v%s starting (mode=%s)",
|
||||
__version__, tf_client.current_mode(),
|
||||
)
|
||||
|
||||
@@ -48,9 +103,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
|
||||
@@ -160,51 +225,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:
|
||||
@@ -246,30 +310,55 @@ async def lifespan(app: FastAPI):
|
||||
app.state.monitor_engine = monitor_engine
|
||||
app.state.sector_monitor_service = sector_monitor_service
|
||||
|
||||
yield
|
||||
# 源码内二次开发启动钩子: 仅暴露稳定只读上下文, 单个扩展失败不影响核心启动。
|
||||
extension_registry = app.state.extension_registry
|
||||
start_backend_extensions(
|
||||
current_extension_context(data_dir=store.data_dir, repository=repo),
|
||||
extension_registry,
|
||||
)
|
||||
|
||||
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")
|
||||
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")
|
||||
|
||||
|
||||
@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(
|
||||
title="TickFlow Stock Panel",
|
||||
title="Tick Stock Panel",
|
||||
version=__version__,
|
||||
description="A 股选股 + 回测面板 — TickFlow 适配",
|
||||
lifespan=lifespan,
|
||||
@@ -339,9 +428,11 @@ 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)
|
||||
app.include_router(abnormal.router)
|
||||
app.include_router(regime.router)
|
||||
app.include_router(analysis.router)
|
||||
app.include_router(pipeline.router)
|
||||
@@ -357,6 +448,11 @@ app.include_router(monitor_rules.router)
|
||||
app.include_router(alerts.router)
|
||||
app.include_router(rps.router)
|
||||
|
||||
# 二次开发路由与小粒度策略在所有核心路由后注册, 禁止覆盖核心路径。
|
||||
extension_registry, extension_load_errors = configure_backend_extensions(app)
|
||||
app.state.extension_registry = extension_registry
|
||||
app.state.extension_load_errors = extension_load_errors
|
||||
|
||||
|
||||
# 能力门控异常 → 403(而非默认 500)
|
||||
# 业务代码用 capset.require(Cap.X) 断言能力,缺失时抛 CapabilityDenied;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# 开发模式下需手动安装依赖: cd backend/app/plugins/stocksdk && npm install
|
||||
|
||||
name: stocksdk
|
||||
display_name: "stock-sdk(第三方行情·合规风险自负)"
|
||||
display_name: "stock-sdk"
|
||||
runtime: node
|
||||
entry: app.plugins.stocksdk.provider:StockSDKProvider
|
||||
check: app.plugins.stocksdk.bridge:availability
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""异动边缘统计 — 按交易所异动规则口径实时计算个股接近度。
|
||||
|
||||
规则 (近似口径, 与交易所《交易规则》的异常波动/严重异常波动披露阈值对齐;
|
||||
主板/科创板条款号指上交所《交易规则(2026年修订)》, 2026-07-06 施行):
|
||||
- 主板: 连续3日收盘价涨跌幅偏离值累计 ±20% (5.4.2)
|
||||
- 创业板/科创板: 3日 ±30% (科创板 6.10)
|
||||
- 北交所: 3日 ±40%
|
||||
- 严重异常波动 (5.4.3/6.11): 10日累计偏离 +100%(-50%), 30日 +200%(-70%) —
|
||||
负向阈值显著严于正向 (跌方向更早触发), 各板块相同。
|
||||
「10日内4次同向异常波动」情形 (科创板3次) 需事件计数, 暂未实现。
|
||||
- 风险警示 (ST/*ST): 2026-07-06 起主板风险警示股票涨跌幅限制调整为 10%,
|
||||
异常波动特别规定 (原 3日±15% / 10日+50% / 30日+100%) 同步废止,
|
||||
与主板普通股票适用同一套标准 (见 price_limits.MAIN_BOARD_ST_LIMIT_CHANGE_DATE)。
|
||||
|
||||
偏离值 = 个股 N 日累计涨跌幅 - 对应指数同期涨跌幅 (enriched 运行时列 deviate_Nd)。
|
||||
「接近度」= |实时偏离| / 该方向阈值: ≥1 已触发, ≥0.7 边缘, ≥0.5 观察。
|
||||
盘中实时叠加: 历史偏离 (已完成交易日) + 今日实时涨跌 - 基准指数今日涨跌。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
import polars as pl
|
||||
|
||||
from app.indicators.pipeline import DEVIATION_WINDOWS
|
||||
|
||||
# ── 规则表 ────────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AbnormalRule:
|
||||
board: str
|
||||
st: bool
|
||||
# 各窗口阈值 (小数): {窗口: (正向, 负向)} — 严重异动负向阈值更严 (见模块 docstring)
|
||||
thresholds: dict[int, tuple[float, float]]
|
||||
|
||||
|
||||
# 3日异常波动阈值各板块对称; 10/30日严重异动各板块一致且不对称 (+100%/-50%, +200%/-70%)
|
||||
_MAIN = {3: (0.20, 0.20), 10: (1.00, 0.50), 30: (2.00, 0.70)}
|
||||
_GEM_STAR = {3: (0.30, 0.30), 10: (1.00, 0.50), 30: (2.00, 0.70)}
|
||||
_BSE = {3: (0.40, 0.40), 10: (1.00, 0.50), 30: (2.00, 0.70)}
|
||||
|
||||
RULES_META: list[dict[str, Any]] = [
|
||||
{"board": "主板", "st": False, "thresholds": {f"{k}d": {"up": u, "down": d} for k, (u, d) in _MAIN.items()},
|
||||
"note": "3日±20% 异常波动; 严重异常波动 10日+100%(-50%) / 30日+200%(-70%), "
|
||||
"负向更严; 2026-07-06 起风险警示(ST)股票同口径 (原±15%特别规定已废止)"},
|
||||
{"board": "创业板/科创板", "st": False, "thresholds": {f"{k}d": {"up": u, "down": d} for k, (u, d) in _GEM_STAR.items()},
|
||||
"note": "20%涨跌幅板块, 3日±30%"},
|
||||
{"board": "北交所", "st": False, "thresholds": {f"{k}d": {"up": u, "down": d} for k, (u, d) in _BSE.items()},
|
||||
"note": "30%涨跌幅板块, 3日±40%"},
|
||||
]
|
||||
|
||||
_BENCH_RT_CANDIDATES = ["000002.SH", "000001.SH", "399107.SZ", "399001.SZ", "899050.BJ"]
|
||||
|
||||
|
||||
def board_of(symbol: str) -> str:
|
||||
"""按代码前缀判定板块。"""
|
||||
code = symbol.split(".")[0]
|
||||
if symbol.endswith(".BJ") or code[:2] in {"43", "83", "87", "92"}:
|
||||
return "北交所"
|
||||
if code.startswith("68"):
|
||||
return "科创板"
|
||||
if code.startswith(("30", "301")):
|
||||
return "创业板"
|
||||
return "主板"
|
||||
|
||||
|
||||
def is_st_name(name: str | None) -> bool:
|
||||
return bool(name) and "ST" in str(name).upper()
|
||||
|
||||
|
||||
def rule_for(symbol: str, name: str | None) -> AbnormalRule:
|
||||
board = board_of(symbol)
|
||||
st = is_st_name(name)
|
||||
# 主板风险警示股票 2026-07-06 起与普通股票同标准 (涨跌幅 10%,
|
||||
# 异常波动特别规定废止); st 仅为展示标记。创业板/科创板/北交所本就不区分。
|
||||
if board == "北交所":
|
||||
return AbnormalRule(board, st, _BSE)
|
||||
if board in ("创业板", "科创板"):
|
||||
return AbnormalRule(board, st, _GEM_STAR)
|
||||
return AbnormalRule(board, st, _MAIN)
|
||||
|
||||
|
||||
# ── 快照计算 ──────────────────────────────────────────────
|
||||
|
||||
_hist_cache_lock = threading.Lock()
|
||||
_hist_cache: dict[str, Any] = {}
|
||||
_HIST_CACHE_TTL = 60.0
|
||||
|
||||
_STATUS_TRIGGERED = "triggered"
|
||||
_STATUS_EDGE = "edge"
|
||||
_STATUS_WATCH = "watch"
|
||||
|
||||
|
||||
def _status_of(closeness: float) -> str:
|
||||
if closeness >= 1.0:
|
||||
return _STATUS_TRIGGERED
|
||||
if closeness >= 0.7:
|
||||
return _STATUS_EDGE
|
||||
return _STATUS_WATCH
|
||||
|
||||
|
||||
def _hist_snapshot(repo: Any) -> dict[str, Any]:
|
||||
"""enriched 最新日的偏离列快照 (60s 进程内缓存)。"""
|
||||
now = time.monotonic()
|
||||
with _hist_cache_lock:
|
||||
cached = _hist_cache.get("data")
|
||||
if cached is not None and now - cached["_ts"] < _HIST_CACHE_TTL:
|
||||
return cached
|
||||
|
||||
df, cache_date = repo.get_enriched_latest()
|
||||
rows: dict[str, dict[str, Any]] = {}
|
||||
if not df.is_empty() and "symbol" in df.columns:
|
||||
cols = ["symbol", *[c for c in ("name", "close", "change_pct",
|
||||
"deviate_3d", "deviate_10d", "deviate_30d") if c in df.columns]]
|
||||
df = df.select(cols)
|
||||
for r in df.iter_rows(named=True):
|
||||
rows[str(r["symbol"])] = {
|
||||
"name": r.get("name"),
|
||||
"close": r.get("close"),
|
||||
"rt_pct": r.get("change_pct"),
|
||||
"deviate_3d": r.get("deviate_3d"),
|
||||
"deviate_10d": r.get("deviate_10d"),
|
||||
"deviate_30d": r.get("deviate_30d"),
|
||||
}
|
||||
payload = {"_ts": now, "rows": rows, "cache_date": cache_date.isoformat() if cache_date else None}
|
||||
with _hist_cache_lock:
|
||||
_hist_cache["data"] = payload
|
||||
return payload
|
||||
|
||||
|
||||
def _bench_rt_pct(quote_service: Any) -> float:
|
||||
"""基准指数今日实时涨跌 (各候选均值, 缺数据时 0)。"""
|
||||
try:
|
||||
df = quote_service.get_index_quotes()
|
||||
except Exception:
|
||||
return 0.0
|
||||
if df is None or df.is_empty():
|
||||
return 0.0
|
||||
df = df.filter(pl.col("symbol").is_in(_BENCH_RT_CANDIDATES))
|
||||
if df.is_empty():
|
||||
return 0.0
|
||||
for col in ("change_pct", "pct", "pct_change"):
|
||||
if col in df.columns:
|
||||
vals = df[col].drop_nulls()
|
||||
if vals.len() > 0:
|
||||
return float(vals.mean())
|
||||
if {"close", "prev_close"} <= set(df.columns):
|
||||
sub = df.select(["close", "prev_close"]).drop_nulls()
|
||||
if sub.height > 0:
|
||||
return float((sub["close"] / sub["prev_close"] - 1).mean())
|
||||
return 0.0
|
||||
|
||||
|
||||
def build_overview(
|
||||
repo: Any,
|
||||
quote_service: Any = None,
|
||||
*,
|
||||
min_closeness: float = 0.5,
|
||||
limit: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
"""返回异动边缘总览: 规则表 + 按接近度排序的个股列表。"""
|
||||
hist = _hist_snapshot(repo)
|
||||
cache_date = hist.get("cache_date")
|
||||
hist_rows: dict[str, dict[str, Any]] = hist["rows"]
|
||||
|
||||
bench_rt = _bench_rt_pct(quote_service) if quote_service is not None else 0.0
|
||||
# enriched 已含今日收盘 (盘后已同步) 时, 今日涨跌已计入历史偏离, 不再叠加
|
||||
includes_today = cache_date is not None and cache_date >= date.today().isoformat()
|
||||
|
||||
out_rows: list[dict[str, Any]] = []
|
||||
for symbol, base in hist_rows.items():
|
||||
rule = rule_for(symbol, base.get("name"))
|
||||
rt_pct = base.get("rt_pct")
|
||||
rt_delta = 0.0 if includes_today else ((rt_pct or 0.0) - bench_rt)
|
||||
|
||||
windows: dict[str, dict[str, Any]] = {}
|
||||
max_closeness = 0.0
|
||||
for n in DEVIATION_WINDOWS:
|
||||
hist_dev = base.get(f"deviate_{n}d")
|
||||
if hist_dev is None:
|
||||
continue
|
||||
live = hist_dev + rt_delta
|
||||
up_t, down_t = rule.thresholds[n]
|
||||
threshold = up_t if live >= 0 else down_t
|
||||
closeness = abs(live) / threshold if threshold > 0 else 0.0
|
||||
windows[f"{n}d"] = {
|
||||
"value": round(live, 4),
|
||||
"threshold": threshold,
|
||||
"closeness": round(closeness, 4),
|
||||
}
|
||||
max_closeness = max(max_closeness, closeness)
|
||||
if not windows or max_closeness < min_closeness:
|
||||
continue
|
||||
out_rows.append({
|
||||
"symbol": symbol,
|
||||
"name": base.get("name"),
|
||||
"board": rule.board,
|
||||
"st": rule.st,
|
||||
"close": base.get("close"),
|
||||
"rt_pct": rt_pct,
|
||||
"windows": windows,
|
||||
"max_closeness": round(max_closeness, 4),
|
||||
"status": _status_of(max_closeness),
|
||||
})
|
||||
|
||||
out_rows.sort(key=lambda r: r["max_closeness"], reverse=True)
|
||||
counts = {
|
||||
_STATUS_TRIGGERED: sum(1 for r in out_rows if r["status"] == _STATUS_TRIGGERED),
|
||||
_STATUS_EDGE: sum(1 for r in out_rows if r["status"] == _STATUS_EDGE),
|
||||
_STATUS_WATCH: sum(1 for r in out_rows if r["status"] == _STATUS_WATCH),
|
||||
}
|
||||
return {
|
||||
"asof": time.time(),
|
||||
"cache_date": cache_date,
|
||||
"bench_rt_pct": round(bench_rt, 4),
|
||||
"includes_today": includes_today,
|
||||
"rules": RULES_META,
|
||||
"counts": counts,
|
||||
"rows": out_rows[:limit],
|
||||
}
|
||||
@@ -20,9 +20,11 @@ from app import secrets_store
|
||||
from app.config import settings
|
||||
|
||||
OPENAI_COMPAT_PROVIDER = "openai_compat"
|
||||
OPENAI_PROVIDER = "openai"
|
||||
CODEX_CLI_PROVIDER = "codex_cli"
|
||||
CODEX_DEFAULT_COMMAND = "codex"
|
||||
CODEX_SUPPORTED_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"}
|
||||
OPENAI_DEFAULT_REASONING_EFFORT = "high"
|
||||
|
||||
_CODEX_ENV_ALLOWLIST = (
|
||||
"PATH",
|
||||
@@ -104,10 +106,31 @@ def current_ai_provider() -> str:
|
||||
return secrets_store.get_ai_config("ai_provider", settings.ai_provider) or OPENAI_COMPAT_PROVIDER
|
||||
|
||||
|
||||
def current_openai_model() -> str:
|
||||
return secrets_store.get_ai_config("ai_model", settings.ai_model)
|
||||
|
||||
|
||||
def current_codex_model() -> str:
|
||||
stored = secrets_store.load()
|
||||
model = stored.get("ai_codex_model")
|
||||
# 旧版本的两种 provider 共用 ai_model。仅在旧配置仍启用 Codex 时回退读取,
|
||||
# 避免把正常的 OpenAI-compatible 模型误当作 Codex 模型。
|
||||
if model is None and current_ai_provider() == CODEX_CLI_PROVIDER:
|
||||
model = stored.get("ai_model")
|
||||
return normalize_codex_model(str(model or ""))
|
||||
|
||||
|
||||
def current_ai_model() -> str:
|
||||
if current_ai_provider() == CODEX_CLI_PROVIDER:
|
||||
return normalize_codex_model(str(secrets_store.load().get("ai_model") or ""))
|
||||
return secrets_store.get_ai_config("ai_model", settings.ai_model)
|
||||
return current_codex_model()
|
||||
return current_openai_model()
|
||||
|
||||
|
||||
def current_openai_reasoning_effort() -> str:
|
||||
stored = secrets_store.load()
|
||||
if "ai_reasoning_effort" not in stored:
|
||||
return OPENAI_DEFAULT_REASONING_EFFORT
|
||||
return str(stored.get("ai_reasoning_effort") or "").strip()
|
||||
|
||||
|
||||
def current_codex_command() -> str:
|
||||
@@ -193,10 +216,15 @@ async def generate_ai_text(
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
temperature: float | None = 0.3,
|
||||
max_tokens: int = 3000,
|
||||
max_tokens: int | None = 3000,
|
||||
timeout: float = 180.0,
|
||||
) -> str:
|
||||
"""Return a complete AI response from the currently configured provider."""
|
||||
"""Return a complete AI response from the currently configured provider.
|
||||
|
||||
max_tokens=None 表示不传该参数(输出上限交给服务端默认) — 推理型模型
|
||||
(如 deepseek reasoner 系)的思考 token 计入 max_tokens 预算, 显式限制
|
||||
会挤占正文甚至全部吃光(正文 0 字 + finish=length), 长分析类调用应放开。
|
||||
"""
|
||||
if is_codex_cli_provider():
|
||||
return await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
|
||||
return await _run_openai_once(
|
||||
@@ -211,13 +239,15 @@ async def stream_ai_text(
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
temperature: float | None = 0.5,
|
||||
max_tokens: int = 4000,
|
||||
max_tokens: int | None = 4000,
|
||||
timeout: float = 180.0,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Yield text deltas from the configured provider.
|
||||
|
||||
Codex CLI only exposes the final assistant message for this use case, so it
|
||||
yields one complete chunk after the command exits.
|
||||
|
||||
max_tokens=None 表示不限制输出(同 generate_ai_text 的说明)。
|
||||
"""
|
||||
if is_codex_cli_provider():
|
||||
yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
|
||||
@@ -236,7 +266,7 @@ async def _run_openai_once(
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
temperature: float | None,
|
||||
max_tokens: int,
|
||||
max_tokens: int | None,
|
||||
timeout: float,
|
||||
) -> str:
|
||||
ai_key = secrets_store.get_ai_key()
|
||||
@@ -246,23 +276,20 @@ async def _run_openai_once(
|
||||
client = _openai_client(ai_key, timeout)
|
||||
model = current_ai_model()
|
||||
req_messages = list(messages)
|
||||
try:
|
||||
resp = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=req_messages,
|
||||
**_openai_kwargs(temperature=temperature, max_tokens=max_tokens),
|
||||
)
|
||||
except Exception as exc:
|
||||
# Reasoning 类模型 (如 kimi-k2.7-code, deepseek-r1, o 系列) 拒绝非约定
|
||||
# temperature (Moonshot 报 "only 1 is allowed for this model")。不再靠
|
||||
# 模型名猜测, 而是捕获该错误后去掉 temperature 重试一次 —— 对所有此类模型都稳。
|
||||
if temperature is not None and _is_temperature_rejected(exc):
|
||||
kwargs = _openai_kwargs(temperature=temperature, max_tokens=max_tokens)
|
||||
while True:
|
||||
try:
|
||||
resp = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=req_messages,
|
||||
**_openai_kwargs(temperature=None, max_tokens=max_tokens),
|
||||
**kwargs,
|
||||
)
|
||||
else:
|
||||
break
|
||||
except Exception as exc:
|
||||
retry_kwargs = _openai_retry_kwargs(exc, kwargs)
|
||||
if retry_kwargs is not None:
|
||||
kwargs = retry_kwargs
|
||||
continue
|
||||
if _is_openai_transport_error(exc):
|
||||
raise RuntimeError(_format_openai_error(exc)) from exc
|
||||
raise
|
||||
@@ -275,7 +302,7 @@ async def _stream_openai(
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
temperature: float | None,
|
||||
max_tokens: int,
|
||||
max_tokens: int | None,
|
||||
timeout: float,
|
||||
) -> AsyncIterator[str]:
|
||||
ai_key = secrets_store.get_ai_key()
|
||||
@@ -292,23 +319,22 @@ async def _stream_openai(
|
||||
if delta and delta.content:
|
||||
yield delta.content
|
||||
|
||||
try:
|
||||
stream = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=req_messages,
|
||||
**_openai_kwargs(temperature=temperature, max_tokens=max_tokens),
|
||||
stream=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
# 流尚未开始 yield, 可安全重建: 去掉 temperature 后重开 stream。
|
||||
if temperature is not None and _is_temperature_rejected(exc):
|
||||
kwargs = _openai_kwargs(temperature=temperature, max_tokens=max_tokens)
|
||||
while True:
|
||||
try:
|
||||
stream = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=req_messages,
|
||||
**_openai_kwargs(temperature=None, max_tokens=max_tokens),
|
||||
**kwargs,
|
||||
stream=True,
|
||||
)
|
||||
else:
|
||||
break
|
||||
except Exception as exc:
|
||||
# 流尚未开始 yield, 可安全移除被拒绝的可选参数后重建。
|
||||
retry_kwargs = _openai_retry_kwargs(exc, kwargs)
|
||||
if retry_kwargs is not None:
|
||||
kwargs = retry_kwargs
|
||||
continue
|
||||
if _is_openai_transport_error(exc):
|
||||
raise RuntimeError(_format_openai_error(exc)) from exc
|
||||
raise
|
||||
@@ -335,11 +361,10 @@ def _openai_client(api_key: str, timeout: float):
|
||||
)
|
||||
|
||||
|
||||
# Reasoning / thinking 类模型 (kimi-k2.7-code, deepseek-r1, OpenAI o 系列等) 不接受
|
||||
# 任意 temperature, 上游会以 400 拒绝 (如 Moonshot: "only 1 is allowed for this model")。
|
||||
# 这里不靠模型名猜测, 而是在真正命中该错误后自动去掉 temperature 重试 (见
|
||||
# _run_openai_once / _stream_openai), 对任意 reasoning 模型都稳健。
|
||||
_TEMP_REJECT_HINTS = ("temperature", "only 1 is allowed", "unsupported parameter")
|
||||
# 不同模型可能拒绝 temperature 或 reasoning_effort。这里不靠模型名猜测,
|
||||
# 只在 400 明确指出对应参数时移除该参数并重试; 每个参数最多移除一次。
|
||||
_TEMP_REJECT_HINTS = ("temperature", "only 1 is allowed")
|
||||
_REASONING_EFFORT_REJECT_HINTS = ("reasoning_effort", "reasoning effort")
|
||||
|
||||
|
||||
def _is_temperature_rejected(exc: Exception) -> bool:
|
||||
@@ -347,14 +372,58 @@ def _is_temperature_rejected(exc: Exception) -> bool:
|
||||
if getattr(exc, "status_code", None) != 400:
|
||||
return False
|
||||
text = _openai_error_detail(exc) or str(exc)
|
||||
return any(h in text.lower() for h in _TEMP_REJECT_HINTS)
|
||||
return _openai_error_param(exc) == "temperature" or any(
|
||||
h in text.lower() for h in _TEMP_REJECT_HINTS
|
||||
)
|
||||
|
||||
|
||||
def _openai_kwargs(*, temperature: float | None, max_tokens: int) -> dict:
|
||||
"""Build OpenAI create() kwargs; temperature omitted when None."""
|
||||
kwargs: dict = {"max_tokens": max_tokens}
|
||||
def _is_reasoning_effort_rejected(exc: Exception) -> bool:
|
||||
"""True if the upstream 400 specifically rejects reasoning_effort."""
|
||||
if getattr(exc, "status_code", None) != 400:
|
||||
return False
|
||||
text = _openai_error_detail(exc) or str(exc)
|
||||
return _openai_error_param(exc) == "reasoning_effort" or any(
|
||||
h in text.lower() for h in _REASONING_EFFORT_REJECT_HINTS
|
||||
)
|
||||
|
||||
|
||||
def _openai_error_param(exc: Exception) -> str:
|
||||
body = getattr(exc, "body", None)
|
||||
if not isinstance(body, dict):
|
||||
return ""
|
||||
error = body.get("error")
|
||||
if isinstance(error, dict):
|
||||
body = error
|
||||
return str(body.get("param") or "").strip().lower()
|
||||
|
||||
|
||||
def _openai_retry_kwargs(exc: Exception, kwargs: dict) -> dict | None:
|
||||
"""Remove one explicitly rejected optional argument for a bounded retry."""
|
||||
retry_kwargs = dict(kwargs)
|
||||
if "temperature" in retry_kwargs and _is_temperature_rejected(exc):
|
||||
retry_kwargs.pop("temperature")
|
||||
return retry_kwargs
|
||||
if "reasoning_effort" in retry_kwargs and _is_reasoning_effort_rejected(exc):
|
||||
retry_kwargs.pop("reasoning_effort")
|
||||
return retry_kwargs
|
||||
return None
|
||||
|
||||
|
||||
def _openai_kwargs(*, temperature: float | None, max_tokens: int | None) -> dict:
|
||||
"""Build OpenAI create() kwargs; optional parameters are omitted when empty.
|
||||
|
||||
max_tokens=None 时不传 — 由服务端默认上限管理(推理模型的思考 token 也
|
||||
计入该参数预算, 限制会挤占正文, 见 stream_ai_text 文档)。
|
||||
"""
|
||||
kwargs: dict = {}
|
||||
if max_tokens is not None:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
if temperature is not None:
|
||||
kwargs["temperature"] = temperature
|
||||
if current_ai_provider() == OPENAI_PROVIDER:
|
||||
reasoning_effort = current_openai_reasoning_effort()
|
||||
if reasoning_effort:
|
||||
kwargs["reasoning_effort"] = reasoning_effort
|
||||
return kwargs
|
||||
|
||||
|
||||
@@ -451,7 +520,7 @@ def _compact_error_text(text: str) -> str:
|
||||
async def _run_codex_cli(
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
max_tokens: int,
|
||||
max_tokens: int | None,
|
||||
timeout: float,
|
||||
) -> str:
|
||||
prompt = _codex_prompt(messages, max_tokens=max_tokens)
|
||||
@@ -584,14 +653,14 @@ def _make_writable_and_retry(
|
||||
raise exc_info[1] from None
|
||||
|
||||
|
||||
def _codex_prompt(messages: Sequence[Message], *, max_tokens: int) -> str:
|
||||
def _codex_prompt(messages: Sequence[Message], *, max_tokens: int | None) -> str:
|
||||
parts = [
|
||||
"You are TickFlow Stock Panel's local AI provider.",
|
||||
"You are Tick Stock Panel's local AI provider.",
|
||||
"This is a text-generation task. The working directory is intentionally empty.",
|
||||
"Use only the user-provided prompt content below; do not inspect or modify local files.",
|
||||
"Return only the final requested content; do not include execution logs.",
|
||||
]
|
||||
if max_tokens > 0:
|
||||
if max_tokens:
|
||||
parts.append(f"Keep the final answer within about {max_tokens} output tokens.")
|
||||
for message in messages:
|
||||
role = message.get("role", "user")
|
||||
@@ -700,10 +769,14 @@ def _codex_home() -> Path:
|
||||
def _write_compatible_codex_config(path: Path) -> None:
|
||||
config = _read_codex_config()
|
||||
lines: list[str] = []
|
||||
local_provider = _docker_codex_local_provider(config)
|
||||
active_provider = _active_codex_provider(config)
|
||||
|
||||
if local_provider:
|
||||
lines.append(_toml_string("model_provider", "codex_local_access"))
|
||||
if active_provider:
|
||||
lines.append(_toml_string("model_provider", active_provider[0]))
|
||||
|
||||
openai_base_url = config.get("openai_base_url")
|
||||
if isinstance(openai_base_url, str) and openai_base_url:
|
||||
lines.append(_toml_string("openai_base_url", openai_base_url))
|
||||
|
||||
model = current_ai_model() or normalize_codex_model(str(config.get("model") or ""))
|
||||
if model:
|
||||
@@ -718,41 +791,43 @@ def _write_compatible_codex_config(path: Path) -> None:
|
||||
lines.append(_toml_string("approval_policy", "never"))
|
||||
lines.append(_toml_string("sandbox_mode", "read-only"))
|
||||
|
||||
if local_provider:
|
||||
if active_provider:
|
||||
provider_name, provider = active_provider
|
||||
lines.append("")
|
||||
lines.append("[model_providers.codex_local_access]")
|
||||
lines.append(f"[model_providers.{_toml_key(provider_name)}]")
|
||||
for key in ("name", "base_url", "wire_api", "experimental_bearer_token"):
|
||||
value = local_provider.get(key)
|
||||
value = provider.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
lines.append(_toml_string(key, value))
|
||||
for key in ("requires_openai_auth", "supports_websockets"):
|
||||
value = local_provider.get(key)
|
||||
value = provider.get(key)
|
||||
if isinstance(value, bool):
|
||||
lines.append(f"{key} = {'true' if value else 'false'}")
|
||||
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _docker_codex_local_provider(config: dict) -> dict | None:
|
||||
"""Return the local-access provider adapted to Docker's host gateway."""
|
||||
docker_host = os.environ.get("CODEX_DOCKER_HOST", "").strip()
|
||||
if not docker_host or config.get("model_provider") != "codex_local_access":
|
||||
def _active_codex_provider(config: dict) -> tuple[str, dict] | None:
|
||||
"""Return the active custom provider, adapting loopback URLs for Docker."""
|
||||
provider_name = config.get("model_provider")
|
||||
if not isinstance(provider_name, str) or not provider_name:
|
||||
return None
|
||||
|
||||
providers = config.get("model_providers")
|
||||
if not isinstance(providers, dict):
|
||||
return None
|
||||
source = providers.get("codex_local_access")
|
||||
source = providers.get(provider_name)
|
||||
if not isinstance(source, dict):
|
||||
return None
|
||||
|
||||
provider = dict(source)
|
||||
base_url = str(provider.get("base_url") or "").strip()
|
||||
parsed = urlsplit(base_url)
|
||||
if parsed.hostname in {"localhost", "127.0.0.1", "::1"}:
|
||||
docker_host = os.environ.get("CODEX_DOCKER_HOST", "").strip()
|
||||
if docker_host and parsed.hostname in {"localhost", "127.0.0.1", "::1"}:
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
provider["base_url"] = urlunsplit(parsed._replace(netloc=f"{docker_host}{port}"))
|
||||
return provider
|
||||
return provider_name, provider
|
||||
|
||||
|
||||
def _read_codex_config() -> dict:
|
||||
@@ -786,6 +861,13 @@ def _toml_string(key: str, value: str) -> str:
|
||||
return f'{key} = "{escaped}"'
|
||||
|
||||
|
||||
def _toml_key(value: str) -> str:
|
||||
if re.fullmatch(r"[A-Za-z0-9_-]+", value):
|
||||
return value
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def _clean_process_text(raw: bytes) -> str:
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
return _ANSI_RE.sub("", text).strip()
|
||||
|
||||
@@ -131,9 +131,17 @@ def bootstrap_from_env() -> bool:
|
||||
Returns:
|
||||
True 表示本次用环境变量初始化了密码; False 表示无需初始化。
|
||||
"""
|
||||
from app.config import settings
|
||||
from app.config import _ENV_FILE, settings
|
||||
|
||||
pwd = (settings.auth_password or "").strip()
|
||||
# Compose 会对 env_file 中未加单引号的 $VAR 做插值。Docker 部署时同时
|
||||
# 只读挂载原始 .env,首次初始化密码直接按 dotenv 语义读取,避免特殊字符被截断。
|
||||
if _ENV_FILE.is_file():
|
||||
from dotenv import dotenv_values
|
||||
|
||||
raw_pwd = dotenv_values(_ENV_FILE, encoding="utf-8", interpolate=False).get("AUTH_PASSWORD")
|
||||
if isinstance(raw_pwd, str) and raw_pwd.strip():
|
||||
pwd = raw_pwd.strip()
|
||||
if not pwd:
|
||||
return False
|
||||
if is_configured():
|
||||
|
||||
@@ -365,18 +365,26 @@ async def analyze_rotation_stream(
|
||||
return
|
||||
|
||||
user_prompt = _build_user_prompt(signals, overview, days, dates, focus, kind)
|
||||
got_content = False
|
||||
async for delta in stream_ai_text(
|
||||
[
|
||||
{"role": "system", "content": _build_system_prompt(kind)},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.5,
|
||||
max_tokens=4000,
|
||||
# 不限制输出(推理模型思考 token 计入预算, 见 ai_provider.stream_ai_text)
|
||||
max_tokens=None,
|
||||
):
|
||||
got_content = True
|
||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("AI %s rotation analyze failed: %s", kind, e)
|
||||
yield json.dumps({"type": "error", "message": f"AI 轮动分析失败: {e}"}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
if not got_content:
|
||||
logger.warning("AI %s rotation analyze ended with empty content", kind)
|
||||
yield json.dumps({"type": "error", "message": "AI 未返回正文(输出被截断), 请重试"}, ensure_ascii=False)
|
||||
return
|
||||
yield json.dumps({"type": "done"}, ensure_ascii=False)
|
||||
|
||||
@@ -146,12 +146,19 @@ class DepthService:
|
||||
logger.warning("depth sealed 从 parquet 恢复失败: %s", e)
|
||||
|
||||
def start_polling(self) -> None:
|
||||
"""启动盘中轮询线程(连板梯队监控开启 + 有能力 + 交易时段)。"""
|
||||
"""启动盘中轮询线程(连板梯队监控开启 + 实时行情开启 + 有能力)。
|
||||
|
||||
依赖实时行情开关: 实时行情关闭时 enriched 内存缓存停留在上一交易日,
|
||||
轮询会反复拉取陈旧的涨跌停名单(浪费 API 额度且数据无意义)。
|
||||
实时行情开关切换时由 settings API 调 stop_polling/start_polling 同步启停。
|
||||
"""
|
||||
if not self._has_capability():
|
||||
return
|
||||
from app.services import preferences
|
||||
if not preferences.get_limit_ladder_monitor_enabled():
|
||||
return
|
||||
if not preferences.get_realtime_quotes_enabled():
|
||||
return
|
||||
# check-then-act 加锁: 两个线程同时 start_polling 不会各起一个轮询线程
|
||||
with self._lock:
|
||||
if self._running:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""扩展数据服务 — 配置管理 + 文件解析 + Parquet 存储。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -39,7 +40,7 @@ class PullConfig:
|
||||
"url", "method", "headers", "body", "response_path",
|
||||
"field_map", "schedule_minutes", "enabled",
|
||||
"last_run", "last_status", "last_message", "last_rows",
|
||||
"next_run",
|
||||
"next_run", "time_window_start", "time_window_end",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -57,6 +58,8 @@ class PullConfig:
|
||||
last_message: str | None = None,
|
||||
last_rows: int | None = None,
|
||||
next_run: str | None = None,
|
||||
time_window_start: str | None = None,
|
||||
time_window_end: str | None = None,
|
||||
) -> None:
|
||||
self.url = url
|
||||
self.method = method # GET | POST
|
||||
@@ -71,6 +74,8 @@ class PullConfig:
|
||||
self.last_message = last_message
|
||||
self.last_rows = last_rows
|
||||
self.next_run = next_run # 下次预计运行 (ISO, 调度器写入)
|
||||
self.time_window_start = time_window_start # 每日拉取窗口起始 "HH:MM", None=不限
|
||||
self.time_window_end = time_window_end # 每日拉取窗口结束 "HH:MM", None=不限
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -87,6 +92,8 @@ class PullConfig:
|
||||
"last_message": self.last_message,
|
||||
"last_rows": self.last_rows,
|
||||
"next_run": self.next_run,
|
||||
"time_window_start": self.time_window_start,
|
||||
"time_window_end": self.time_window_end,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -107,6 +114,8 @@ class PullConfig:
|
||||
last_message=d.get("last_message"),
|
||||
last_rows=d.get("last_rows"),
|
||||
next_run=d.get("next_run"),
|
||||
time_window_start=d.get("time_window_start"),
|
||||
time_window_end=d.get("time_window_end"),
|
||||
)
|
||||
|
||||
|
||||
@@ -179,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():
|
||||
@@ -207,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:
|
||||
@@ -230,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)
|
||||
|
||||
@@ -20,6 +20,21 @@ from app.services.ext_data import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _in_time_window(start: str | None, end: str | None) -> bool:
|
||||
"""检查当前本地时间是否在每日时间窗口内。
|
||||
|
||||
start/end 为 "HH:MM" 格式。两者都为 None 时不限制(返回 True)。
|
||||
支持跨午夜窗口(如 22:00-02:00)。
|
||||
"""
|
||||
if not start or not end:
|
||||
return True
|
||||
now = datetime.now().strftime("%H:%M")
|
||||
if start <= end:
|
||||
return start <= now < end
|
||||
# 跨午夜: 如 22:00-02:00
|
||||
return now >= start or now < end
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 响应解析
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -253,6 +268,17 @@ class PullScheduler:
|
||||
break
|
||||
pull = fresh.pull
|
||||
|
||||
# 时间窗口检查: 不在窗口内则跳过本次拉取
|
||||
if not _in_time_window(pull.time_window_start, pull.time_window_end):
|
||||
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
|
||||
fresh.pull.last_status = "skipped"
|
||||
fresh.pull.last_message = "不在拉取时间窗口内"
|
||||
store.upsert(fresh)
|
||||
logger.info("PullScheduler: %s skipped (outside time window)", config.id)
|
||||
interval = max(pull.schedule_minutes * 60, 60)
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
|
||||
# 先执行一次 (启用即拉取, 让用户立刻看到生效)
|
||||
try:
|
||||
n, d = await fetch_and_ingest(fresh, self._data_dir)
|
||||
|
||||
@@ -178,14 +178,17 @@ async def analyze_financials_stream(
|
||||
from app.services.ai_provider import stream_ai_text
|
||||
|
||||
user_prompt = _build_user_prompt(fins, symbol, focus)
|
||||
got_content = False
|
||||
async for delta in stream_ai_text(
|
||||
[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.4,
|
||||
max_tokens=4000,
|
||||
# 不限制输出(推理模型思考 token 计入预算, 见 ai_provider.stream_ai_text)
|
||||
max_tokens=None,
|
||||
):
|
||||
got_content = True
|
||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
@@ -193,4 +196,8 @@ async def analyze_financials_stream(
|
||||
yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
if not got_content:
|
||||
logger.warning("AI financial analysis ended with empty content for %s", symbol)
|
||||
yield json.dumps({"type": "error", "message": "AI 未返回正文(输出被截断), 请重试"}, ensure_ascii=False)
|
||||
return
|
||||
yield json.dumps({"type": "done"}, ensure_ascii=False)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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+ 权限的用户分时图首次打开(本地无数据)时补拉失败返回空。
|
||||
@@ -998,11 +1002,13 @@ def sync_and_persist_minute(
|
||||
days: int = 5,
|
||||
on_chunk_done: Callable[[int, int, str], None] | None = None,
|
||||
extend_backward: bool = False,
|
||||
force_full_days: bool = False,
|
||||
) -> int:
|
||||
"""同步分钟 K 并存到 Parquet(前复权价格, SDK 端 adjust=qfq)。返回写入行数。
|
||||
|
||||
使用 start_time / end_time 区间拉取, 确保所有标的覆盖同一时间段。
|
||||
on_chunk_done(current, total) 每个 chunk 完成后回调。
|
||||
force_full_days=True 时强制回溯 days 自然日 (不增量补, 用于个股补齐历史)。
|
||||
"""
|
||||
minute_provider = preferences.get_minute_data_provider()
|
||||
# resolver 调用统一走 _resolve_minute_provider, 与 _try_custom_minute 共用异常边界。
|
||||
@@ -1042,8 +1048,13 @@ def sync_and_persist_minute(
|
||||
end_time = now
|
||||
else:
|
||||
# 默认增量模式: 首次拉取回溯 N 天, 已有数据则从最新时间增量补到今天
|
||||
# force_full_days=True: 强制回溯 days 自然日 (个股补齐历史, 不增量)
|
||||
last_dt = _latest_minute_datetime(repo)
|
||||
if last_dt:
|
||||
if force_full_days:
|
||||
# 按交易日换算自然日 (7/5 系数), 确保覆盖足够交易日
|
||||
calendar_days = int(days * 7 / 5) + 5
|
||||
start_time = now - timedelta(days=calendar_days)
|
||||
elif last_dt:
|
||||
start_time = last_dt
|
||||
else:
|
||||
start_time = now - timedelta(days=days)
|
||||
@@ -1063,7 +1074,10 @@ def sync_and_persist_minute(
|
||||
written_box = [0] # list 闭包, 绕过 Python 闭包外层赋值
|
||||
|
||||
def _persist(seg_df: pl.DataFrame) -> None:
|
||||
written_box[0] += _write_minute_partition(seg_df, minute_dir)
|
||||
# 单股自动补齐可能与另一个补齐请求同时写同一日期分区。Windows 不允许
|
||||
# 替换仍被另一写入占用的临时文件,因此读-改-写必须复用仓库写锁。
|
||||
with repo._write_lock:
|
||||
written_box[0] += _write_minute_partition(seg_df, minute_dir)
|
||||
|
||||
segment_days = preferences.get_minute_sync_segment_days()
|
||||
sync_minute_batch(
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""市场主线(板块/概念)识别 — 基于涨停梯队的历史聚合。
|
||||
|
||||
用户判据的量化: 主升阶段的主线 = 同一概念内涨停家数多、最高连板高、
|
||||
梯队档位填得满(2 板到最高板之间不断层)。对每个交易日按概念聚合涨停梯队,
|
||||
截面 rank 归一后加权成主线分, 持久化为日频时序, 供市场环境页展示
|
||||
"什么阶段走什么主升"。
|
||||
|
||||
口径限制(重要): 概念成分来自 ext_gn_ths 快照(本地自 2026-07 起留存, 无历史
|
||||
版本)。历史主线是把"今天的成分"回看历史 — 早年存在归属漂移(新概念不会
|
||||
出现在旧时段、成分调整会错归属)。MEMBERSHIP_NOTE 随 API 返回给前端展示。
|
||||
|
||||
性能: 全量回填只窄扫 enriched 的 4 列并先过滤连板 >=1(全历史 ~10 万行),
|
||||
join 概念映射后 group_by, 峰值内存 <100MB。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
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"
|
||||
|
||||
|
||||
_ST_SYMBOLS_CACHE: tuple[float, frozenset[str]] | None = None
|
||||
|
||||
|
||||
def load_risk_warning_symbols(data_dir: Path) -> frozenset[str]:
|
||||
"""当前维表快照中名称含 ST 标记的 symbol 集合(大写), 供主线/情绪统计剔除。
|
||||
|
||||
判定与 indicators 涨跌停口径共用同一权威实现(price_limits.polars_is_risk_warning_name,
|
||||
即名称含 "ST", 覆盖 ST/*ST/S*ST)。维表是快照无历史版本, 与概念成分同样的
|
||||
回看限制。600s 进程内缓存(维表 snapshot 进程内不变)。
|
||||
"""
|
||||
global _ST_SYMBOLS_CACHE
|
||||
now = time.time()
|
||||
if _ST_SYMBOLS_CACHE is not None and now - _ST_SYMBOLS_CACHE[0] < 600:
|
||||
return _ST_SYMBOLS_CACHE[1]
|
||||
from app.price_limits import polars_is_risk_warning_name
|
||||
|
||||
syms: frozenset[str] = frozenset()
|
||||
inst_dir = data_dir / "instruments"
|
||||
if inst_dir.exists():
|
||||
try:
|
||||
df = pl.read_parquet(inst_dir / "**" / "*.parquet").select(["symbol", "name"])
|
||||
st = df.filter(polars_is_risk_warning_name(pl.col("name")))
|
||||
syms = frozenset(s.upper() for s in st["symbol"].to_list())
|
||||
except Exception as e:
|
||||
logger.warning("load risk-warning symbols failed: %s", e)
|
||||
_ST_SYMBOLS_CACHE = (now, syms)
|
||||
return syms
|
||||
|
||||
|
||||
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,
|
||||
exclude_st: bool | None = None) -> pl.DataFrame:
|
||||
"""计算 [start, end] 每日主线排行(按 _SCORE_WEIGHTS 加权截面分)。
|
||||
|
||||
filter_cfg: {"min_members", "max_members", "blacklist"}; None 时读用户偏好。
|
||||
宽基/风格标签(融资融券/沪深股通等数千成分)按成员数上限过滤,
|
||||
用户黑名单按名称过滤(不论大小)。修改配置后重算主线生效。
|
||||
exclude_st: 是否剔除风险警示(ST)股(按当前维表名称); None 时读用户偏好
|
||||
(默认剔除 — ST 是状态桶非题材, 主板 5% 便宜板时代曾系统性霸榜)。
|
||||
|
||||
返回列: 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"))
|
||||
|
||||
# 剔除风险警示股: ST 板块的涨停生态(主板曾 5% 便宜板)不代表题材主线。
|
||||
if exclude_st is None:
|
||||
try:
|
||||
from app.services import preferences
|
||||
exclude_st = preferences.get_sentiment_exclude_st()
|
||||
except Exception:
|
||||
exclude_st = True
|
||||
if exclude_st:
|
||||
st_syms = load_risk_warning_symbols(repo.store.data_dir)
|
||||
if st_syms:
|
||||
limit_rows = limit_rows.filter(~pl.col("_sym_up").is_in(sorted(st_syms)))
|
||||
|
||||
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
|
||||
@@ -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 rally→climax→ebb,
|
||||
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")
|
||||
@@ -301,14 +301,17 @@ async def recap_market_stream(
|
||||
from app.services.ai_provider import stream_ai_text
|
||||
|
||||
user_prompt = _build_user_prompt(overview, news or [], focus)
|
||||
got_content = False
|
||||
async for delta in stream_ai_text(
|
||||
[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.5,
|
||||
max_tokens=4500,
|
||||
# 不限制输出(推理模型思考 token 计入预算, 见 ai_provider.stream_ai_text)
|
||||
max_tokens=None,
|
||||
):
|
||||
got_content = True
|
||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
@@ -316,6 +319,10 @@ async def recap_market_stream(
|
||||
yield json.dumps({"type": "error", "message": f"AI 复盘失败: {e}"}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
if not got_content:
|
||||
logger.warning("AI market recap ended with empty content for %s", as_of_str)
|
||||
yield json.dumps({"type": "error", "message": "AI 未返回正文(输出被截断), 请重试"}, ensure_ascii=False)
|
||||
return
|
||||
yield json.dumps({"type": "done"}, ensure_ascii=False)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ""),
|
||||
)
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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=(",", ":"),
|
||||
)
|
||||
@@ -27,7 +27,7 @@ JobStatus = Literal["pending", "running", "succeeded", "failed"]
|
||||
# 由 reap_stale() 在 /run 和 /jobs/{id} 轮询端点检查 — 保证卡死后能自愈,
|
||||
# 无需用户再次点击「同步」。
|
||||
#
|
||||
# 超时阈值按任务类型区分:
|
||||
# 默认超时阈值按任务类型区分,可在 Web 数据源设置中调整:
|
||||
# - 普通任务(日K管道/扩展/修正/重算): 1200s (20 分钟)
|
||||
# - 长任务(分钟K全市场同步,数据量是日K的 ~240 倍): 1800s (30 分钟)
|
||||
# 分钟K即使流式落盘后仍可能跑十几到数十分钟(限速 sleep 是主因),
|
||||
@@ -105,7 +105,12 @@ class JobStore:
|
||||
|
||||
# ===== lifecycle =====
|
||||
|
||||
def create(self, timeout_s: int = DEFAULT_JOB_TIMEOUT_S) -> tuple[str, bool]:
|
||||
def create(
|
||||
self,
|
||||
timeout_s: int | None = None,
|
||||
*,
|
||||
long_running: bool = False,
|
||||
) -> tuple[str, bool]:
|
||||
"""单飞创建任务。返回 (job_id, is_new)。
|
||||
|
||||
去重条件为 **pending ∨ running**(而非仅 running):`/run` 先 create() 再在
|
||||
@@ -115,9 +120,17 @@ class JobStore:
|
||||
|
||||
is_new=False 表示复用了已有活跃任务,调用方**不得**再调度新的后台任务。
|
||||
|
||||
timeout_s: reap_stale 判定卡死的阈值。普通任务默认 1200s;
|
||||
分钟K全市场同步等长任务传 LONG_JOB_TIMEOUT_S (1800s)。
|
||||
timeout_s: reap_stale 判定卡死的阈值。None 时读取用户配置。
|
||||
long_running: timeout_s 为 None 时,是否读取长任务配置;普通任务默认
|
||||
1200s,分钟K全市场同步等长任务默认 1800s。
|
||||
"""
|
||||
if timeout_s is None:
|
||||
from app.services import preferences
|
||||
if long_running:
|
||||
timeout_s = preferences.get_data_source_long_job_timeout_s()
|
||||
else:
|
||||
timeout_s = preferences.get_data_source_job_timeout_s()
|
||||
|
||||
with self._lock:
|
||||
if self._active_id:
|
||||
active = self._active_jobs.get(self._active_id)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -49,6 +75,11 @@ def get_indices_nav_pinned() -> bool:
|
||||
return load().get("indices_nav_pinned", True)
|
||||
|
||||
|
||||
def get_watchlist_groups_in_nav() -> bool:
|
||||
"""自选分组是否显示在侧边栏(可展开二级子菜单)。默认 False。"""
|
||||
return load().get("watchlist_groups_in_nav", False)
|
||||
|
||||
|
||||
def get_realtime_quote_interval() -> float:
|
||||
return load().get("realtime_quote_interval", 6.0)
|
||||
|
||||
@@ -83,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
|
||||
|
||||
|
||||
@@ -186,6 +218,32 @@ def get_minute_sync_segment_days() -> int:
|
||||
# ===== 数据源选择 (默认 TickFlow;第一阶段仅日K切换入口) =====
|
||||
|
||||
_ALLOWED_DATA_PROVIDERS = {"tickflow"}
|
||||
DATA_SOURCE_JOB_TIMEOUT_MIN_S = 60
|
||||
|
||||
|
||||
def get_data_source_job_timeout_s() -> int:
|
||||
"""返回普通数据后台任务的卡死判定时间(秒)。"""
|
||||
from app.services.pipeline_jobs import DEFAULT_JOB_TIMEOUT_S
|
||||
raw = load().get("data_source_job_timeout_s", DEFAULT_JOB_TIMEOUT_S)
|
||||
try:
|
||||
timeout_s = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
timeout_s = DEFAULT_JOB_TIMEOUT_S
|
||||
return max(DATA_SOURCE_JOB_TIMEOUT_MIN_S, timeout_s)
|
||||
|
||||
|
||||
def get_data_source_long_job_timeout_s() -> int:
|
||||
"""返回分钟 K 全市场等长任务的卡死判定时间(秒)。"""
|
||||
from app.services.pipeline_jobs import LONG_JOB_TIMEOUT_S
|
||||
raw = load().get(
|
||||
"data_source_long_job_timeout_s",
|
||||
LONG_JOB_TIMEOUT_S,
|
||||
)
|
||||
try:
|
||||
timeout_s = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
timeout_s = LONG_JOB_TIMEOUT_S
|
||||
return max(DATA_SOURCE_JOB_TIMEOUT_MIN_S, timeout_s)
|
||||
|
||||
|
||||
def _allowed_data_providers() -> set[str]:
|
||||
@@ -285,6 +343,92 @@ 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_sentiment_exclude_st() -> bool:
|
||||
"""市场环境/主线统计是否剔除风险警示(ST)股。默认 True。
|
||||
|
||||
口径: 主板 ST 在 2026-07 前享 5% 涨跌幅(封板成本减半), 且 ST 是跨行业的
|
||||
状态桶而非投资题材, 混入会系统性抬高涨停宽度/高度(弱市尤甚)。剔除后
|
||||
涨跌家数等宽度占比几乎不受影响。修改后需重算 regime 与主线生效。
|
||||
"""
|
||||
return bool(load().get("sentiment_exclude_st", True))
|
||||
|
||||
|
||||
def set_sentiment_exclude_st(v: bool) -> bool:
|
||||
save({"sentiment_exclude_st": bool(v)})
|
||||
return get_sentiment_exclude_st()
|
||||
|
||||
|
||||
def get_mainline_filter_config() -> dict:
|
||||
"""主线过滤配置汇总(供 API 返回与计算读取)。"""
|
||||
return {
|
||||
"min_members": get_mainline_min_members(),
|
||||
"max_members": get_mainline_max_members(),
|
||||
"blacklist": get_mainline_blacklist(),
|
||||
"exclude_st": get_sentiment_exclude_st(),
|
||||
}
|
||||
|
||||
|
||||
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 "exclude_st" in cfg and cfg["exclude_st"] is not None:
|
||||
updates["sentiment_exclude_st"] = bool(cfg["exclude_st"])
|
||||
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")
|
||||
|
||||
|
||||
@@ -444,6 +588,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]:
|
||||
"""复盘推送渠道(多选) — 选定的外部工具列表, 复盘归档后逐个推送。
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""全局实时行情服务 — 单例。"""
|
||||
|
||||
@@ -175,6 +195,9 @@ class QuoteService:
|
||||
self._subscribers: set[QuoteSubscriber] = set()
|
||||
self._strategy_monitor = None # 延迟注入
|
||||
self._app_state = None # 延迟注入 (FastAPI app.state)
|
||||
# 异动边缘规则上次评估时间戳 (秒)。异动快照历史部分有 60s 缓存,
|
||||
# 但每次构建仍有全市场循环, 轮询线程里限频到 30s 一次。
|
||||
self._abnormal_last_eval = 0.0
|
||||
|
||||
# 拉取元信息 (给 SSE / status 用)
|
||||
self._fetch_time: float = 0.0 # perf_counter (用于计算 quote_age_ms)
|
||||
@@ -1081,28 +1104,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
|
||||
@@ -1121,6 +1126,23 @@ class QuoteService:
|
||||
enriched_today if stock_ready else pl.DataFrame(),
|
||||
self.get_index_quotes(),
|
||||
)
|
||||
# 异动边缘规则轮: 快照 (enriched 偏离列 + 实时叠加) 由
|
||||
# abnormal_moves.build_overview 统一构建, 引擎只做边缘触发判定。
|
||||
# 30s 限频 —— 快照历史部分 60s 缓存, 无需跟行情轮询同频重算。
|
||||
if engine.has_rule_type("abnormal") and self._repo is not None:
|
||||
_now_ts = time.time()
|
||||
if _now_ts - self._abnormal_last_eval >= 30.0:
|
||||
self._abnormal_last_eval = _now_ts
|
||||
try:
|
||||
from app.services import abnormal_moves
|
||||
_overview = abnormal_moves.build_overview(
|
||||
self._repo, self,
|
||||
min_closeness=engine.min_abnormal_closeness(),
|
||||
limit=1000,
|
||||
)
|
||||
rule_events += engine.evaluate_abnormal(_overview.get("rows") or [])
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("异动监控规则评估失败 (不影响其他告警): %s", e)
|
||||
# ETF 规则轮: 股票快照不含 ETF, 用 ETF enriched 快照单独评估。
|
||||
# 独立 try —— ETF 轮任何异常都不得丢弃本轮已算出的股票告警。
|
||||
# refresh=False —— 不在轮询线程上触发 ETF 冷缓存的同步重算 (缓存由 ETF 实时
|
||||
@@ -1149,6 +1171,7 @@ class QuoteService:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("指数监控评估失败 (不影响股票/ETF 告警): %s", e)
|
||||
if rule_events:
|
||||
rule_events = self._format_extension_notifications(rule_events)
|
||||
# 落盘到 alerts.jsonl
|
||||
try:
|
||||
from app.services import alert_store
|
||||
@@ -1179,6 +1202,8 @@ class QuoteService:
|
||||
"sector_source_field", "sector_value", "sector_level",
|
||||
"window_change_pct", "coverage_ratio", "valid_count",
|
||||
"total_count", "up_count", "down_count", "leader",
|
||||
"abnormal_window", "abnormal_value", "abnormal_threshold",
|
||||
"abnormal_closeness",
|
||||
):
|
||||
if key in ev:
|
||||
alert[key] = ev[key]
|
||||
@@ -1207,6 +1232,42 @@ class QuoteService:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("监控评估失败: %s", e)
|
||||
|
||||
def _format_extension_notifications(self, events: list[dict]) -> list[dict]:
|
||||
"""Apply optional copy formatters after evaluation and before every output channel."""
|
||||
registry = (
|
||||
getattr(self._app_state, "extension_registry", None)
|
||||
if self._app_state is not None
|
||||
else None
|
||||
)
|
||||
if registry is None or not registry.has_notification_formatters:
|
||||
return events
|
||||
|
||||
from app.extensions.contracts import (
|
||||
BACKEND_EXTENSION_API_VERSION,
|
||||
NotificationFormatContext,
|
||||
)
|
||||
|
||||
formatted_events: list[dict] = []
|
||||
for event in events:
|
||||
formatted = dict(event)
|
||||
context = NotificationFormatContext(
|
||||
api_version=BACKEND_EXTENSION_API_VERSION,
|
||||
)
|
||||
for registered in registry.notification_formatters():
|
||||
try:
|
||||
message = registered.implementation.format_message(dict(formatted), context)
|
||||
if not isinstance(message, str):
|
||||
raise TypeError("notification formatter must return str")
|
||||
formatted["message"] = message
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"notification formatter failed %s: %s",
|
||||
registered.implementation_id,
|
||||
exc,
|
||||
)
|
||||
formatted_events.append(formatted)
|
||||
return formatted_events
|
||||
|
||||
def _enrich_alerts_ext(self, alerts: list[dict]) -> None:
|
||||
"""就地给告警事件按 symbol 追加行业/概念 ext 字段。
|
||||
|
||||
@@ -1532,11 +1593,27 @@ class QuoteService:
|
||||
else None
|
||||
),
|
||||
)
|
||||
# momentum_3d 不在指标全集里, 但 deviate_3d 需要; 多日帧上 shift 补算
|
||||
enriched_full = enriched_full.sort(["symbol", "date"]).with_columns(
|
||||
(pl.col("close") / pl.col("close").shift(3).over("symbol") - 1).alias("momentum_3d")
|
||||
)
|
||||
enriched_today = enriched_full.filter(pl.col("date") == today)
|
||||
|
||||
if enriched_today.is_empty():
|
||||
return
|
||||
|
||||
# 异动偏离列: 盘中路径不经过 _refresh_enriched 冷刷新,
|
||||
# 需在此附着 (基准 = 历史帧 + 指数实时外推), 否则盘中异动列表为空
|
||||
if asset_type == "stock":
|
||||
from app.indicators.pipeline import attach_deviation_columns_today
|
||||
try:
|
||||
index_quotes = self.get_index_quotes()
|
||||
except Exception:
|
||||
index_quotes = None
|
||||
enriched_today = attach_deviation_columns_today(
|
||||
enriched_today, self._repo.store.data_dir, index_quotes
|
||||
)
|
||||
|
||||
# ---- 写盘 + 更新缓存 ----
|
||||
if merge:
|
||||
self._repo.merge_live_enriched_asset(asset_type, enriched_today)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -422,6 +449,23 @@ def run_regime_batch(repo, start: date, end: date) -> pl.DataFrame:
|
||||
logger.info("regime batch: no enriched data for [%s~%s]", start, end)
|
||||
return pl.DataFrame()
|
||||
|
||||
# 口径: 默认剔除风险警示(ST)股(与主线统计同一开关) — 主板 ST 在 2026-07 前
|
||||
# 享 5% 涨跌幅且是跨行业状态桶, 混入会系统性抬高涨停宽度/高度(弱市炒 ST 尤甚)。
|
||||
# 涨跌家数/MA20 占比等宽度指标几乎不受影响。切换口径需全量重算 regime。
|
||||
try:
|
||||
from app.services import preferences as _prefs_st
|
||||
exclude_st = _prefs_st.get_sentiment_exclude_st()
|
||||
except Exception:
|
||||
exclude_st = True
|
||||
if exclude_st:
|
||||
from app.services.market_mainline import load_risk_warning_symbols
|
||||
|
||||
st_syms = load_risk_warning_symbols(repo.store.data_dir)
|
||||
if st_syms and "symbol" in df.columns:
|
||||
df = df.filter(
|
||||
~pl.col("symbol").str.to_uppercase().is_in(sorted(st_syms))
|
||||
)
|
||||
|
||||
return _aggregate_daily(df, index_pct_map)
|
||||
|
||||
|
||||
@@ -446,6 +490,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): 重算的天覆盖旧行, 新天追加。
|
||||
|
||||
@@ -526,6 +592,23 @@ def detect_stale_dates(data_dir: Path, repo) -> list[date]:
|
||||
return sorted(stale)
|
||||
|
||||
|
||||
def latest_phase_transition(data_dir: Path) -> tuple[str, str, str] | None:
|
||||
"""读取 regime 时序末两日, 返回最近一次阶段切换 (prev, new, 日期str)。
|
||||
|
||||
末两日阶段相同(或数据不足/无阶段列)返回 None。供盘后管道推送阶段切换通知。
|
||||
"""
|
||||
hist = load_regime_history(data_dir)
|
||||
if hist.is_empty() or "phase" not in hist.columns:
|
||||
return None
|
||||
tail = hist.select(["date", "phase"]).sort("date").tail(2)
|
||||
if tail.height < 2:
|
||||
return None
|
||||
prev_phase, cur_phase = tail["phase"].to_list()
|
||||
if not prev_phase or not cur_phase or prev_phase == cur_phase:
|
||||
return None
|
||||
return prev_phase, cur_phase, str(tail["date"][-1])
|
||||
|
||||
|
||||
def compute_regime_incremental(repo, data_dir: Path, *, today: date | None = None) -> pl.DataFrame:
|
||||
"""增量计算 regime(供 daily_pipeline / 启动补算调用)。
|
||||
|
||||
@@ -553,6 +636,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
|
||||
|
||||
|
||||
|
||||
@@ -315,14 +315,18 @@ async def analyze_stock_stream(
|
||||
kline_tail = _clean_rows(df, _KLINE_KEEP_COLS)
|
||||
user_prompt = _build_user_prompt(kline_tail, fins, levels, close, symbol, focus,
|
||||
asset_type=repo.resolve_asset_type(symbol))
|
||||
got_content = False
|
||||
async for delta in stream_ai_text(
|
||||
[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.5,
|
||||
max_tokens=4500,
|
||||
# 不限制输出: 推理模型(deepseek reasoner 系)思考 token 计入 max_tokens
|
||||
# 预算, 固定上限会把正文挤光(实测 4500 全被推理吃掉 → 正文 0 字)。
|
||||
max_tokens=None,
|
||||
):
|
||||
got_content = True
|
||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
@@ -330,4 +334,9 @@ async def analyze_stock_stream(
|
||||
yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
if not got_content:
|
||||
# 流正常结束但一个正文块都没有(典型: 输出上限被思考吃光后静默截断)
|
||||
logger.warning("AI stock analysis ended with empty content for %s", symbol)
|
||||
yield json.dumps({"type": "error", "message": "AI 未返回正文(输出被截断), 请重试"}, ensure_ascii=False)
|
||||
return
|
||||
yield json.dumps({"type": "done"}, ensure_ascii=False)
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
"""自选股服务(§6.1)。
|
||||
"""自选股与分组服务。
|
||||
|
||||
存储:`data/user_data/watchlist.parquet`,字段 symbol + added_at + note。
|
||||
自选存储于 ``data/user_data/watchlist.parquet``,分组定义存储于同目录的
|
||||
``watchlist_groups.json``。
|
||||
|
||||
成员关系为多值 (M:N): 每条自选带 ``group_ids: list[str]``, 同一标的可同时
|
||||
属于多个分组; 移出分组只摘标签(标的仍在自选), 移出自选才删除实体。
|
||||
旧 schema (单值 ``group_id`` 列) 读取时自动迁移为 ``[group_id]``, 首次写回
|
||||
新 schema 前留一份 ``watchlist.parquet.bak`` 备份。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import TimeoutError as FuturesTimeout
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,6 +30,38 @@ from app.tickflow.rate_limits import chunked, resolve_limit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LOCK = threading.RLock()
|
||||
# 数据版本号: 每次写盘 +1 (在 _LOCK 内递增, 读取免锁)。供监控引擎等进程内
|
||||
# 消费方做缓存失效判断 —— 版本没变就不必重读文件, 版本一变立即拿到新成员。
|
||||
_REVISION = 0
|
||||
|
||||
|
||||
def revision() -> int:
|
||||
"""自选/分组数据版本号, 每次写操作递增。"""
|
||||
return _REVISION
|
||||
_MAX_GROUP_NAME_LENGTH = 24
|
||||
DEFAULT_GROUP_COLOR = "sky"
|
||||
GROUP_COLORS = frozenset({
|
||||
"sky",
|
||||
"blue",
|
||||
"indigo",
|
||||
"violet",
|
||||
"fuchsia",
|
||||
"rose",
|
||||
"orange",
|
||||
"amber",
|
||||
"lime",
|
||||
"emerald",
|
||||
"teal",
|
||||
"cyan",
|
||||
})
|
||||
_ENTRY_SCHEMA = {
|
||||
"symbol": pl.Utf8,
|
||||
"added_at": pl.Utf8,
|
||||
"note": pl.Utf8,
|
||||
"group_ids": pl.List(pl.Utf8),
|
||||
}
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
p = settings.data_dir / "user_data" / "watchlist.parquet"
|
||||
@@ -24,70 +69,319 @@ def _path() -> Path:
|
||||
return p
|
||||
|
||||
|
||||
def list_symbols() -> list[dict]:
|
||||
def _groups_path() -> Path:
|
||||
p = settings.data_dir / "user_data" / "watchlist_groups.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def _empty_entries() -> pl.DataFrame:
|
||||
return pl.DataFrame(schema=_ENTRY_SCHEMA)
|
||||
|
||||
|
||||
def _read_entries() -> pl.DataFrame:
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return []
|
||||
return _empty_entries()
|
||||
df = pl.read_parquet(p)
|
||||
if df.is_empty():
|
||||
return []
|
||||
return df.to_dicts()
|
||||
# 旧 schema 兼容: 单值 group_id → group_ids=[gid]; 两列都缺 → 空列表
|
||||
if "group_ids" not in df.columns:
|
||||
old = df["group_id"].to_list() if "group_id" in df.columns else [None] * df.height
|
||||
df = df.with_columns(
|
||||
pl.Series("group_ids", [[g] if g else [] for g in old], dtype=pl.List(pl.Utf8))
|
||||
).drop("group_id", strict=False)
|
||||
if "symbol" not in df.columns:
|
||||
df = df.with_columns(pl.lit("", dtype=pl.Utf8).alias("symbol"))
|
||||
if "added_at" not in df.columns:
|
||||
df = df.with_columns(pl.lit("", dtype=pl.Utf8).alias("added_at"))
|
||||
if "note" not in df.columns:
|
||||
df = df.with_columns(pl.lit(None, dtype=pl.Utf8).alias("note"))
|
||||
return df.select(list(_ENTRY_SCHEMA))
|
||||
|
||||
|
||||
def add(symbol: str, note: str = "") -> list[dict]:
|
||||
def _write_entries(df: pl.DataFrame) -> None:
|
||||
global _REVISION
|
||||
p = _path()
|
||||
# 首次从旧 schema 迁移到 group_ids 前, 备份原文件(一次性)
|
||||
if p.exists():
|
||||
df = pl.read_parquet(p)
|
||||
# 已存在则先移除,后面重新插入到最前面
|
||||
if symbol in df["symbol"].to_list():
|
||||
df = df.filter(pl.col("symbol") != symbol)
|
||||
else:
|
||||
df = pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8})
|
||||
try:
|
||||
if "group_ids" not in pl.read_parquet_schema(p).names():
|
||||
shutil.copy(p, p.with_suffix(p.suffix + ".bak"))
|
||||
except OSError as e:
|
||||
logger.warning("watchlist backup before migration failed: %s", e)
|
||||
tmp = p.with_suffix(p.suffix + ".tmp")
|
||||
df.select(list(_ENTRY_SCHEMA)).write_parquet(tmp)
|
||||
os.replace(tmp, p)
|
||||
_REVISION += 1
|
||||
|
||||
new_row = pl.DataFrame({
|
||||
"symbol": [symbol],
|
||||
"added_at": [datetime.utcnow().isoformat(timespec="seconds")],
|
||||
"note": [note],
|
||||
})
|
||||
out = pl.concat([new_row, df], how="diagonal_relaxed")
|
||||
out.write_parquet(p)
|
||||
return out.to_dicts()
|
||||
|
||||
def _read_groups() -> list[dict]:
|
||||
p = _groups_path()
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("自选分组配置损坏,请检查 watchlist_groups.json") from exc
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError("自选分组配置格式不正确")
|
||||
groups = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict) or not item.get("id") or not item.get("name"):
|
||||
continue
|
||||
color = str(item.get("color", DEFAULT_GROUP_COLOR))
|
||||
groups.append({
|
||||
"id": str(item["id"]),
|
||||
"name": str(item["name"]),
|
||||
"color": color if color in GROUP_COLORS else DEFAULT_GROUP_COLOR,
|
||||
})
|
||||
return groups
|
||||
|
||||
|
||||
def _write_groups(groups: list[dict]) -> None:
|
||||
global _REVISION
|
||||
p = _groups_path()
|
||||
tmp = p.with_suffix(p.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(groups, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, p)
|
||||
_REVISION += 1
|
||||
|
||||
|
||||
def _normalize_group_name(name: str) -> str:
|
||||
normalized = name.strip()
|
||||
if not normalized:
|
||||
raise ValueError("分组名称不能为空")
|
||||
if len(normalized) > _MAX_GROUP_NAME_LENGTH:
|
||||
raise ValueError(f"分组名称不能超过 {_MAX_GROUP_NAME_LENGTH} 个字符")
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_group_color(color: str | None) -> str:
|
||||
normalized = (color or DEFAULT_GROUP_COLOR).strip().lower()
|
||||
if normalized not in GROUP_COLORS:
|
||||
raise ValueError("不支持的分组颜色")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_group_id(group_id: str | None, groups: list[dict]) -> None:
|
||||
if group_id is not None and not any(group["id"] == group_id for group in groups):
|
||||
raise ValueError("自选分组不存在")
|
||||
|
||||
|
||||
def list_symbols() -> list[dict]:
|
||||
with _LOCK:
|
||||
df = _read_entries()
|
||||
return [] if df.is_empty() else df.to_dicts()
|
||||
|
||||
|
||||
def add(symbol: str, note: str = "", group_id: str | None = None) -> list[dict]:
|
||||
rows, _ = add_batch([symbol], note=note, group_id=group_id)
|
||||
return rows
|
||||
|
||||
|
||||
def add_batch(
|
||||
symbols: list[str],
|
||||
note: str = "",
|
||||
group_id: str | None = None,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""批量添加并保持既有语义:每个新处理的标的移动到列表最前面。
|
||||
|
||||
group_id 为可选的初始分组(如从某分组页添加时); 重复添加的标的保留
|
||||
既有全部分组, 仅在显式传入 group_id 且尚未属于该组时并入。
|
||||
"""
|
||||
with _LOCK:
|
||||
groups = _read_groups()
|
||||
_validate_group_id(group_id, groups)
|
||||
rows = _read_entries().to_dicts()
|
||||
added = 0
|
||||
for symbol in symbols:
|
||||
existing = next((row for row in rows if row["symbol"] == symbol), None)
|
||||
if existing is None:
|
||||
added += 1
|
||||
rows = [row for row in rows if row["symbol"] != symbol]
|
||||
gids = list((existing or {}).get("group_ids") or [])
|
||||
if group_id is not None and group_id not in gids:
|
||||
gids.append(group_id)
|
||||
rows.insert(0, {
|
||||
"symbol": symbol,
|
||||
"added_at": datetime.utcnow().isoformat(timespec="seconds"),
|
||||
"note": note,
|
||||
"group_ids": gids,
|
||||
})
|
||||
out = pl.DataFrame(rows, schema=_ENTRY_SCHEMA) if rows else _empty_entries()
|
||||
_write_entries(out)
|
||||
return out.to_dicts(), added
|
||||
|
||||
|
||||
def remove(symbol: str) -> list[dict]:
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return []
|
||||
df = pl.read_parquet(p)
|
||||
df = df.filter(pl.col("symbol") != symbol)
|
||||
df.write_parquet(p)
|
||||
return df.to_dicts()
|
||||
with _LOCK:
|
||||
df = _read_entries().filter(pl.col("symbol") != symbol)
|
||||
_write_entries(df)
|
||||
return df.to_dicts()
|
||||
|
||||
|
||||
def move_to_top(symbol: str) -> list[dict]:
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return []
|
||||
df = pl.read_parquet(p)
|
||||
if df.is_empty() or symbol not in df["symbol"].to_list():
|
||||
return df.to_dicts()
|
||||
target = df.filter(pl.col("symbol") == symbol)
|
||||
rest = df.filter(pl.col("symbol") != symbol)
|
||||
out = pl.concat([target, rest], how="diagonal_relaxed")
|
||||
out.write_parquet(p)
|
||||
return out.to_dicts()
|
||||
with _LOCK:
|
||||
df = _read_entries()
|
||||
if df.is_empty() or symbol not in df["symbol"].to_list():
|
||||
return df.to_dicts()
|
||||
target = df.filter(pl.col("symbol") == symbol)
|
||||
rest = df.filter(pl.col("symbol") != symbol)
|
||||
out = pl.concat([target, rest], how="diagonal_relaxed")
|
||||
_write_entries(out)
|
||||
return out.to_dicts()
|
||||
|
||||
|
||||
def clear() -> int:
|
||||
"""清空自选列表。返回移除的数量。"""
|
||||
p = _path()
|
||||
if not p.exists():
|
||||
return 0
|
||||
df = pl.read_parquet(p)
|
||||
count = df.height
|
||||
if count > 0:
|
||||
pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8}).write_parquet(p)
|
||||
return count
|
||||
with _LOCK:
|
||||
df = _read_entries()
|
||||
count = df.height
|
||||
if count > 0:
|
||||
_write_entries(_empty_entries())
|
||||
return count
|
||||
|
||||
|
||||
def list_groups() -> list[dict]:
|
||||
with _LOCK:
|
||||
return _read_groups()
|
||||
|
||||
|
||||
def create_group(name: str, color: str | None = None) -> tuple[list[dict], dict]:
|
||||
with _LOCK:
|
||||
normalized = _normalize_group_name(name)
|
||||
normalized_color = _normalize_group_color(color)
|
||||
groups = _read_groups()
|
||||
if any(group["name"].casefold() == normalized.casefold() for group in groups):
|
||||
raise ValueError("分组名称已存在")
|
||||
group = {
|
||||
"id": uuid.uuid4().hex,
|
||||
"name": normalized,
|
||||
"color": normalized_color,
|
||||
}
|
||||
groups.append(group)
|
||||
_write_groups(groups)
|
||||
return groups, group
|
||||
|
||||
|
||||
def rename_group(group_id: str, name: str, color: str | None = None) -> list[dict]:
|
||||
with _LOCK:
|
||||
normalized = _normalize_group_name(name)
|
||||
groups = _read_groups()
|
||||
target = next((group for group in groups if group["id"] == group_id), None)
|
||||
if target is None:
|
||||
raise KeyError(group_id)
|
||||
if any(
|
||||
group["id"] != group_id and group["name"].casefold() == normalized.casefold()
|
||||
for group in groups
|
||||
):
|
||||
raise ValueError("分组名称已存在")
|
||||
target["name"] = normalized
|
||||
if color is not None:
|
||||
target["color"] = _normalize_group_color(color)
|
||||
_write_groups(groups)
|
||||
return groups
|
||||
|
||||
|
||||
def reorder_groups(ordered_ids: list[str]) -> list[dict]:
|
||||
"""按给定 id 顺序重排分组 (json 数组顺序即定义顺序)。"""
|
||||
with _LOCK:
|
||||
groups = _read_groups()
|
||||
by_id = {group["id"]: group for group in groups}
|
||||
if len(ordered_ids) != len(groups) or set(ordered_ids) != set(by_id):
|
||||
raise ValueError("分组顺序与现有分组不一致")
|
||||
reordered = [by_id[group_id] for group_id in ordered_ids]
|
||||
_write_groups(reordered)
|
||||
return reordered
|
||||
|
||||
|
||||
def delete_group(group_id: str) -> tuple[list[dict], list[dict]]:
|
||||
"""删除分组定义,原分组内的自选保留并转为未分组(仅摘掉该组标签)。"""
|
||||
with _LOCK:
|
||||
groups = _read_groups()
|
||||
if not any(group["id"] == group_id for group in groups):
|
||||
raise KeyError(group_id)
|
||||
df = _strip_group(_read_entries(), group_id)
|
||||
remaining = [group for group in groups if group["id"] != group_id]
|
||||
_write_entries(df)
|
||||
_write_groups(remaining)
|
||||
return remaining, df.to_dicts()
|
||||
|
||||
|
||||
def set_group(symbol: str, group_id: str | None) -> list[dict]:
|
||||
"""互斥设定: 该标的只保留这一个分组(group_id=None 即全部移出, 变未分组)。
|
||||
|
||||
多组模型的日常操作走 add_to_group / remove_from_group; 本函数服务于
|
||||
「仅保留此组」的显式场景。
|
||||
"""
|
||||
with _LOCK:
|
||||
groups = _read_groups()
|
||||
_validate_group_id(group_id, groups)
|
||||
rows = _read_entries().to_dicts()
|
||||
if not any(row["symbol"] == symbol for row in rows):
|
||||
raise KeyError(symbol)
|
||||
for row in rows:
|
||||
if row["symbol"] == symbol:
|
||||
row["group_ids"] = [group_id] if group_id is not None else []
|
||||
out = pl.DataFrame(rows, schema=_ENTRY_SCHEMA)
|
||||
_write_entries(out)
|
||||
return out.to_dicts()
|
||||
|
||||
|
||||
def add_to_group(symbol: str, group_id: str) -> list[dict]:
|
||||
"""把标的加入一个分组(多组成员关系: 不影响已属于的其他分组)。"""
|
||||
with _LOCK:
|
||||
groups = _read_groups()
|
||||
_validate_group_id(group_id, groups)
|
||||
rows = _read_entries().to_dicts()
|
||||
if not any(row["symbol"] == symbol for row in rows):
|
||||
raise KeyError(symbol)
|
||||
for row in rows:
|
||||
if row["symbol"] == symbol:
|
||||
gids = row["group_ids"] or []
|
||||
if group_id not in gids:
|
||||
gids.append(group_id)
|
||||
row["group_ids"] = gids
|
||||
out = pl.DataFrame(rows, schema=_ENTRY_SCHEMA)
|
||||
_write_entries(out)
|
||||
return out.to_dicts()
|
||||
|
||||
|
||||
def remove_from_group(symbol: str, group_id: str) -> list[dict]:
|
||||
"""把标的移出一个分组(仅摘本组标签; 标的仍在自选, 可能落入未分组)。"""
|
||||
with _LOCK:
|
||||
groups = _read_groups()
|
||||
_validate_group_id(group_id, groups)
|
||||
rows = _read_entries().to_dicts()
|
||||
if not any(row["symbol"] == symbol for row in rows):
|
||||
raise KeyError(symbol)
|
||||
for row in rows:
|
||||
if row["symbol"] == symbol:
|
||||
row["group_ids"] = [g for g in (row["group_ids"] or []) if g != group_id]
|
||||
out = pl.DataFrame(rows, schema=_ENTRY_SCHEMA)
|
||||
_write_entries(out)
|
||||
return out.to_dicts()
|
||||
|
||||
|
||||
def _strip_group(df: pl.DataFrame, group_id: str) -> pl.DataFrame:
|
||||
"""从所有条目的 group_ids 中摘掉指定分组(删除分组/清空分组共用)。"""
|
||||
rows = df.to_dicts()
|
||||
for row in rows:
|
||||
gids = row.get("group_ids") or []
|
||||
if group_id in gids:
|
||||
row["group_ids"] = [g for g in gids if g != group_id]
|
||||
return pl.DataFrame(rows, schema=_ENTRY_SCHEMA) if rows else _empty_entries()
|
||||
|
||||
|
||||
def clear_group(group_id: str) -> list[dict]:
|
||||
"""清空分组成员:把该分组标签从所有条目摘掉(变未分组),保留分组定义。"""
|
||||
with _LOCK:
|
||||
groups = _read_groups()
|
||||
if not any(group["id"] == group_id for group in groups):
|
||||
raise KeyError(group_id)
|
||||
df = _strip_group(_read_entries(), group_id)
|
||||
_write_entries(df)
|
||||
return df.to_dicts()
|
||||
|
||||
|
||||
def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8.0) -> list[dict]:
|
||||
@@ -96,8 +390,6 @@ def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8
|
||||
优先用 quote.batch;否则降级为 quote.by_symbol 单股请求。
|
||||
timeout_s: 单批次请求超时(秒),防止 API 卡死阻塞整个请求。
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
|
||||
if not symbols:
|
||||
return []
|
||||
|
||||
|
||||
@@ -343,6 +343,7 @@ META = {{...}},{entrypoint_requirement}。只输出完整 Python 代码。
|
||||
"polars",
|
||||
"numpy",
|
||||
"app.backtest.matrix",
|
||||
"app.strategy.builtin.factor_rank_research",
|
||||
"datetime",
|
||||
"__future__",
|
||||
})
|
||||
|
||||
@@ -40,7 +40,6 @@ ENTRY_SIGNALS = ["signal_boll_breakout_upper"]
|
||||
EXIT_SIGNALS = ["signal_boll_breakdown_lower"]
|
||||
STOP_LOSS = -0.06
|
||||
MAX_HOLD_DAYS = 15
|
||||
ALERTS = []
|
||||
|
||||
|
||||
class BollBreakoutMatrixStrategy:
|
||||
|
||||
@@ -53,7 +53,6 @@ ENTRY_SIGNALS = ["signal_limit_up"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.06
|
||||
MAX_HOLD_DAYS = 10
|
||||
ALERTS = []
|
||||
|
||||
|
||||
class BrokenBoardRecoveryMatrixStrategy:
|
||||
|
||||
@@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_ma_golden_5_20", "signal_ma_golden_20_60"]
|
||||
EXIT_SIGNALS = ["signal_ma_dead_5_20", "signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.06
|
||||
MAX_HOLD_DAYS = 20
|
||||
ALERTS = []
|
||||
|
||||
|
||||
class BullishAlignmentMatrixStrategy:
|
||||
|
||||
@@ -35,7 +35,6 @@ ENTRY_SIGNALS = ["signal_limit_up"]
|
||||
EXIT_SIGNALS = []
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 5
|
||||
ALERTS = []
|
||||
|
||||
|
||||
class ConsecutiveLimitUpsMatrixStrategy:
|
||||
|
||||
@@ -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()
|
||||
@@ -52,7 +52,6 @@ ENTRY_SIGNALS = ["signal_volume_surge"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 10
|
||||
ALERTS = []
|
||||
|
||||
|
||||
class HighTurnoverSurgeMatrixStrategy:
|
||||
|
||||
@@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_limit_up"]
|
||||
EXIT_SIGNALS = []
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 5
|
||||
ALERTS = []
|
||||
|
||||
|
||||
class LimitUpMomentumMatrixStrategy:
|
||||
|
||||
@@ -54,7 +54,6 @@ ENTRY_SIGNALS = ["signal_ma20_breakout"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 30
|
||||
ALERTS = []
|
||||
|
||||
|
||||
class LowVolatilityLeaderMatrixStrategy:
|
||||
|
||||
@@ -49,7 +49,6 @@ ENTRY_SIGNALS = ["signal_ma_golden_5_20"]
|
||||
EXIT_SIGNALS = ["signal_ma_dead_5_20"]
|
||||
STOP_LOSS = -0.06
|
||||
MAX_HOLD_DAYS = 15
|
||||
ALERTS = []
|
||||
|
||||
|
||||
class MAGoldenCrossMatrixStrategy:
|
||||
|
||||
@@ -46,7 +46,6 @@ EXIT_SIGNALS = ["signal_macd_dead"]
|
||||
EXECUTION_BACKEND = "matrix_native"
|
||||
STOP_LOSS = -0.07
|
||||
MAX_HOLD_DAYS = 20
|
||||
ALERTS = []
|
||||
|
||||
|
||||
class MACDGoldenMatrixStrategy:
|
||||
|
||||
@@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_n_day_low"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.06
|
||||
MAX_HOLD_DAYS = 15
|
||||
ALERTS = []
|
||||
|
||||
|
||||
class NDayLowReversalMatrixStrategy:
|
||||
|
||||
@@ -57,7 +57,6 @@ ENTRY_SIGNALS = []
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 5
|
||||
ALERTS = []
|
||||
|
||||
|
||||
class NearLimitUpMatrixStrategy:
|
||||
|
||||
@@ -53,9 +53,6 @@ ENTRY_SIGNALS = []
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 15
|
||||
ALERTS = [
|
||||
{"field": "rsi_14", "op": "<", "value": 25, "message": "RSI极度超卖"},
|
||||
]
|
||||
|
||||
|
||||
class OversoldBounceMatrixStrategy:
|
||||
|
||||
@@ -58,9 +58,6 @@ ENTRY_SIGNALS = []
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 15
|
||||
ALERTS = [
|
||||
{"field": "rsi_14", "op": "<", "value": 25, "message": "RSI极度超卖"},
|
||||
]
|
||||
|
||||
|
||||
class OversoldReversalMatrixStrategy:
|
||||
|
||||
@@ -49,7 +49,6 @@ ENTRY_SIGNALS = ["signal_ma_golden_5_20"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown", "signal_ma_dead_5_20"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 15
|
||||
ALERTS = []
|
||||
|
||||
|
||||
class PullbackMA20BounceMatrixStrategy:
|
||||
|
||||
@@ -64,7 +64,6 @@ ENTRY_SIGNALS = ["signal_ma_golden_5_20"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 20
|
||||
ALERTS = []
|
||||
|
||||
|
||||
class PullbackToSupportMatrixStrategy:
|
||||
|
||||
@@ -58,7 +58,6 @@ ENTRY_SIGNALS = []
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 10
|
||||
ALERTS = []
|
||||
|
||||
|
||||
class StrongOpenMatrixStrategy:
|
||||
|
||||
@@ -57,9 +57,6 @@ ENTRY_SIGNALS = ["signal_n_day_high"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.08
|
||||
MAX_HOLD_DAYS = 20
|
||||
ALERTS = [
|
||||
{"field": "signal_volume_surge", "message": "放量异动"},
|
||||
]
|
||||
|
||||
|
||||
class TrendBreakoutMatrixStrategy:
|
||||
|
||||
@@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_ma20_breakout"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.06
|
||||
MAX_HOLD_DAYS = 15
|
||||
ALERTS = []
|
||||
|
||||
|
||||
class VolumePriceSurgeMatrixStrategy:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ ALLOWED_FIELDS: frozenset[str] = frozenset({
|
||||
"momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d",
|
||||
"annual_vol_20d",
|
||||
"rsi_6", "rsi_14", "rsi_24",
|
||||
# 异动偏离 (交易所异动规则口径, 运行时列)
|
||||
"deviate_3d", "deviate_10d", "deviate_30d",
|
||||
})
|
||||
|
||||
# 运算符 → Polars 表达式构造器(输入 col_expr, value)
|
||||
|
||||
+107
-29
@@ -11,15 +11,24 @@ import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
|
||||
from app.strategy.scoring import scoring_dependencies, scoring_value_expr
|
||||
from app.strategy.scoring import (
|
||||
SCORING_DIRECTION_LOW,
|
||||
effective_scoring,
|
||||
effective_scoring_directions,
|
||||
materialize_scoring_columns,
|
||||
scoring_dependencies,
|
||||
scoring_value_expr,
|
||||
scoring_warmup_bars,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -181,7 +190,6 @@ class StrategyDef:
|
||||
trailing_take_profit_activate: float | None
|
||||
trailing_take_profit_drawdown: float | None
|
||||
max_hold_days: int | None
|
||||
alerts: list[dict]
|
||||
filter_fn: Callable[[pl.DataFrame, dict], pl.Expr] | None
|
||||
filter_history_fn: Callable[[pl.DataFrame, dict], pl.DataFrame] | None
|
||||
lookback_days: int
|
||||
@@ -520,7 +528,6 @@ class StrategyEngine:
|
||||
trailing_take_profit_activate=getattr(mod, "TRAILING_TAKE_PROFIT_ACTIVATE", None),
|
||||
trailing_take_profit_drawdown=getattr(mod, "TRAILING_TAKE_PROFIT_DRAWDOWN", None),
|
||||
max_hold_days=getattr(mod, "MAX_HOLD_DAYS", None),
|
||||
alerts=getattr(mod, "ALERTS", []),
|
||||
filter_fn=filter_fn,
|
||||
filter_history_fn=filter_history_fn,
|
||||
required_features=frozenset(meta.get("required_features", []) or [])
|
||||
@@ -547,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,
|
||||
@@ -655,11 +664,14 @@ class StrategyEngine:
|
||||
required = 1
|
||||
for strategy_id in strategy_ids:
|
||||
strategy = self.get(strategy_id)
|
||||
overrides = overrides_map.get(strategy_id) or {}
|
||||
scoring = effective_scoring(strategy.meta.get("scoring"), overrides)
|
||||
required = max(required, scoring_warmup_bars(scoring))
|
||||
if strategy.execution_backend == "matrix_native":
|
||||
params = self.resolve_params(
|
||||
strategy,
|
||||
params_map.get(strategy_id),
|
||||
overrides_map.get(strategy_id),
|
||||
overrides,
|
||||
)
|
||||
required = max(
|
||||
required,
|
||||
@@ -714,9 +726,19 @@ class StrategyEngine:
|
||||
max_warmup = max(
|
||||
max_warmup,
|
||||
int(strategy.matrix_strategy.required_warmup_bars(params)) + 1,
|
||||
scoring_warmup_bars(
|
||||
effective_scoring(
|
||||
strategy.meta.get("scoring"),
|
||||
overrides_map.get(strategy_id),
|
||||
)
|
||||
),
|
||||
)
|
||||
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
|
||||
@@ -832,7 +854,15 @@ class StrategyEngine:
|
||||
started_at=t0,
|
||||
)
|
||||
|
||||
signal_df = context.current if context.current is not None else context.history
|
||||
scoring = effective_scoring(s.meta.get("scoring"), overrides)
|
||||
scoring_directions = effective_scoring_directions(overrides)
|
||||
current, history = self._materialize_scoring_frames(
|
||||
context.current,
|
||||
context.history,
|
||||
scoring,
|
||||
)
|
||||
|
||||
signal_df = current if current is not None else history
|
||||
if signal_df is None:
|
||||
signal_df = pl.DataFrame()
|
||||
if not signal_df.is_empty() and "date" in signal_df.columns:
|
||||
@@ -843,9 +873,9 @@ class StrategyEngine:
|
||||
|
||||
# 普通策略只读目标日期;历史策略读取调用方注入的历史窗口。
|
||||
if s.filter_history_fn:
|
||||
if context.history is None:
|
||||
if history is None:
|
||||
raise ValueError(f"strategy {strategy_id} requires history data")
|
||||
df = context.history
|
||||
df = history
|
||||
if df.is_empty():
|
||||
return StrategyResult(
|
||||
as_of=as_of,
|
||||
@@ -856,9 +886,9 @@ class StrategyEngine:
|
||||
if "date" in df.columns:
|
||||
df = df.filter(pl.col("date") == as_of)
|
||||
else:
|
||||
if context.current is None:
|
||||
if current is None:
|
||||
raise ValueError(f"strategy {strategy_id} requires current data")
|
||||
df = context.current
|
||||
df = current
|
||||
|
||||
if df.is_empty():
|
||||
return StrategyResult(
|
||||
@@ -887,11 +917,7 @@ class StrategyEngine:
|
||||
df = df.filter(expr)
|
||||
|
||||
# Stage 3: 评分
|
||||
scoring = s.meta.get("scoring", {})
|
||||
scoring_overrides = overrides.get("scoring")
|
||||
if scoring_overrides:
|
||||
scoring = {**scoring, **scoring_overrides}
|
||||
df = self._apply_scoring(df, scoring)
|
||||
df = self._apply_scoring(df, scoring, scoring_directions)
|
||||
entry_signal_hits = self._collect_signal_hits(df, entry_signals)
|
||||
if not entry_signals and (s.filter_history_fn or s.filter_fn):
|
||||
entry_signal_hits = [
|
||||
@@ -1007,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,
|
||||
@@ -1032,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"])
|
||||
@@ -1048,8 +1096,7 @@ class StrategyEngine:
|
||||
or basic_filter.get(f"{prefix}_max") is not None
|
||||
):
|
||||
fields.add(field_name)
|
||||
scoring = dict(strategy.meta.get("scoring", {}) or {})
|
||||
scoring.update((overrides or {}).get("scoring") or {})
|
||||
scoring = effective_scoring(strategy.meta.get("scoring"), overrides)
|
||||
fields.update(scoring_dependencies(scoring))
|
||||
order_by = strategy.meta.get("order_by")
|
||||
if order_by and order_by != "score":
|
||||
@@ -1083,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():
|
||||
@@ -1094,8 +1141,7 @@ class StrategyEngine:
|
||||
basic_filter = dict(strategy.basic_filter or {})
|
||||
if overrides.get("basic_filter"):
|
||||
basic_filter.update(overrides["basic_filter"])
|
||||
scoring = dict(strategy.meta.get("scoring", {}) or {})
|
||||
scoring.update(overrides.get("scoring") or {})
|
||||
scoring = effective_scoring(strategy.meta.get("scoring"), overrides)
|
||||
asset_mask = None
|
||||
if pool:
|
||||
pool_set = set(pool)
|
||||
@@ -1112,6 +1158,7 @@ class StrategyEngine:
|
||||
MatrixPipelineConfig(
|
||||
basic_filter=basic_filter,
|
||||
scoring=scoring,
|
||||
scoring_directions=effective_scoring_directions(overrides),
|
||||
order_by=strategy.meta.get("order_by"),
|
||||
descending=bool(strategy.meta.get("descending", True)),
|
||||
asset_mask=asset_mask,
|
||||
@@ -1398,28 +1445,34 @@ class StrategyEngine:
|
||||
# ================================================================
|
||||
|
||||
@staticmethod
|
||||
def _apply_scoring(df: pl.DataFrame, weights: dict) -> pl.DataFrame:
|
||||
def _apply_scoring(
|
||||
df: pl.DataFrame,
|
||||
weights: dict,
|
||||
directions: Mapping[str, str] | None = None,
|
||||
) -> pl.DataFrame:
|
||||
"""通用评分: min-max 归一化 → 加权求和 → 0~100 分"""
|
||||
if not weights:
|
||||
return df
|
||||
|
||||
executable = [
|
||||
(value, weight)
|
||||
(str(col), value, weight)
|
||||
for col, weight in weights.items()
|
||||
if weight and (value := scoring_value_expr(df.columns, str(col))) is not None
|
||||
]
|
||||
total_weight = sum(weight for _, weight in executable)
|
||||
total_weight = sum(weight for _, _, weight in executable)
|
||||
if total_weight <= 0:
|
||||
return df
|
||||
|
||||
score_parts: list[pl.Expr] = []
|
||||
for value, weight in executable:
|
||||
for name, value, weight in executable:
|
||||
w = weight / total_weight
|
||||
col_min = value.min()
|
||||
col_range = value.max() - col_min
|
||||
normalized = pl.when(col_range > 0).then(
|
||||
(value - col_min) / col_range
|
||||
).otherwise(pl.lit(0.5))
|
||||
if (directions or {}).get(name) == SCORING_DIRECTION_LOW:
|
||||
normalized = 1.0 - normalized
|
||||
score_parts.append(normalized * w)
|
||||
|
||||
if not score_parts:
|
||||
@@ -1430,6 +1483,31 @@ class StrategyEngine:
|
||||
score_expr = score_expr + part
|
||||
return df.with_columns((score_expr * 100).alias("score"))
|
||||
|
||||
@staticmethod
|
||||
def _materialize_scoring_frames(
|
||||
current: pl.DataFrame | None,
|
||||
history: pl.DataFrame | None,
|
||||
scoring: Mapping[str, Any],
|
||||
) -> tuple[pl.DataFrame | None, pl.DataFrame | None]:
|
||||
names = [str(name) for name, weight in scoring.items() if weight]
|
||||
if not names:
|
||||
return current, history
|
||||
if history is None or history.is_empty():
|
||||
return (
|
||||
materialize_scoring_columns(current, names) if current is not None else None,
|
||||
history,
|
||||
)
|
||||
|
||||
scored_history = materialize_scoring_columns(history, names)
|
||||
if current is None or current.is_empty():
|
||||
return current, scored_history
|
||||
join_keys = [key for key in ("symbol", "date", "datetime") if key in current.columns and key in scored_history.columns]
|
||||
added = [name for name in names if name not in current.columns and name in scored_history.columns]
|
||||
if not join_keys or not added:
|
||||
return materialize_scoring_columns(current, names), scored_history
|
||||
values = scored_history.select([*join_keys, *added]).unique(subset=join_keys, keep="last")
|
||||
return current.join(values, on=join_keys, how="left"), scored_history
|
||||
|
||||
|
||||
def _sanitize(rows: list[dict]) -> list[dict]:
|
||||
for r in rows:
|
||||
|
||||
+250
-68
@@ -1,6 +1,6 @@
|
||||
"""策略实时监控 — 订阅行情更新,检查策略买卖信号和提醒条件。
|
||||
"""策略实时监控 — 订阅行情更新,检查策略买卖信号。
|
||||
|
||||
职责: 接收实时行情 DataFrame → 检查监控中策略的信号/提醒 → 推送告警。
|
||||
职责: 接收实时行情 DataFrame → 检查监控中策略的信号 → 推送告警。
|
||||
不知道: 策略加载逻辑、AI、API、配置持久化、回测。
|
||||
依赖: 外部调用 on_quote_update() 传入实时数据。
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import logging
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
@@ -72,7 +73,7 @@ def _signal_cn_name(name: str) -> str:
|
||||
@dataclass
|
||||
class StrategyAlert:
|
||||
"""策略告警"""
|
||||
type: str # "entry" | "exit" | "alert"
|
||||
type: str # "entry" | "exit"
|
||||
strategy_id: str
|
||||
symbol: str
|
||||
name: str | None
|
||||
@@ -104,7 +105,6 @@ class StrategyMonitorService:
|
||||
config: {
|
||||
"entry_signals": ["signal_n_day_high", ...],
|
||||
"exit_signals": ["signal_ma20_breakdown", ...],
|
||||
"alerts": [{"field": "rsi_14", "op": ">", "value": 80, "message": "..."}],
|
||||
}
|
||||
"""
|
||||
with self._watching_lock:
|
||||
@@ -152,7 +152,7 @@ class StrategyMonitorService:
|
||||
strategy_id=strategy_id,
|
||||
symbol=sym,
|
||||
name=name,
|
||||
message=f"入场信号触发",
|
||||
message="入场信号触发",
|
||||
price=price,
|
||||
change_pct=pct,
|
||||
signals=hit_sigs,
|
||||
@@ -169,7 +169,7 @@ class StrategyMonitorService:
|
||||
strategy_id=strategy_id,
|
||||
symbol=sym,
|
||||
name=name,
|
||||
message=f"出场信号触发",
|
||||
message="出场信号触发",
|
||||
price=price,
|
||||
change_pct=pct,
|
||||
signals=hit_sigs,
|
||||
@@ -177,21 +177,6 @@ class StrategyMonitorService:
|
||||
all_alerts.append(alert)
|
||||
self._emit(alert)
|
||||
|
||||
# 提醒条件
|
||||
for alert_cfg in cfg.get("alerts", []):
|
||||
for sym, name, price, pct in self._check_alert(df, alert_cfg):
|
||||
alert = StrategyAlert(
|
||||
type="alert",
|
||||
strategy_id=strategy_id,
|
||||
symbol=sym,
|
||||
name=name,
|
||||
message=alert_cfg.get("message", "提醒"),
|
||||
price=price,
|
||||
change_pct=pct,
|
||||
)
|
||||
all_alerts.append(alert)
|
||||
self._emit(alert)
|
||||
|
||||
return all_alerts
|
||||
|
||||
def _emit(self, alert: StrategyAlert) -> None:
|
||||
@@ -230,46 +215,6 @@ class StrategyMonitorService:
|
||||
results.append((sym, name, price, pct, hit_sigs))
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _check_alert(
|
||||
df: pl.DataFrame,
|
||||
alert: dict,
|
||||
) -> list[tuple[str, str | None, float | None, float | None]]:
|
||||
"""检查阈值型提醒条件"""
|
||||
field = alert.get("field", "")
|
||||
if field not in df.columns:
|
||||
return []
|
||||
|
||||
if "op" in alert:
|
||||
# 阈值比较
|
||||
op = alert["op"]
|
||||
value = alert["value"]
|
||||
col = pl.col(field)
|
||||
ops = {
|
||||
">": col > value,
|
||||
">=": col >= value,
|
||||
"<": col < value,
|
||||
"<=": col <= value,
|
||||
}
|
||||
expr = ops.get(op)
|
||||
if expr is None:
|
||||
return []
|
||||
else:
|
||||
# 信号列 (布尔)
|
||||
expr = pl.col(field).fill_null(False)
|
||||
|
||||
hit_df = df.filter(expr)
|
||||
results = []
|
||||
for row in hit_df.iter_rows(named=True):
|
||||
results.append((
|
||||
row.get("symbol", ""),
|
||||
row.get("name"),
|
||||
row.get("close"),
|
||||
row.get("change_pct"),
|
||||
))
|
||||
return results
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 通用监控规则引擎 MonitorRuleEngine
|
||||
# ================================================================
|
||||
@@ -277,6 +222,58 @@ class StrategyMonitorService:
|
||||
_SIGNAL_PREFIXES = ("signal_", "csg_")
|
||||
|
||||
|
||||
# ── 自选分组作用域: group_id → 成员集合解析 (进程内缓存) ────
|
||||
# 缓存按 watchlist 数据版本号失效: 版本不变时零磁盘 IO; 自选页任何增删
|
||||
# 分组/成员的操作都会 bump 版本号, 下一轮评估立即拿到新成员 (无需等 TTL)。
|
||||
_group_cache_lock = threading.Lock()
|
||||
_group_cache: dict[str, Any] = {}
|
||||
# 已告警过的「分组已删除」(rule_id, group_id), 防止每轮评估刷日志
|
||||
_warned_missing_groups: set[tuple[str, str]] = set()
|
||||
|
||||
|
||||
def _watchlist_groups_snapshot() -> dict[str, frozenset[str]]:
|
||||
"""返回 {group_id: 成员symbol集}。读前后版本一致才写缓存, 避免缓存住写竞态下的旧数据。"""
|
||||
from app.services import watchlist
|
||||
|
||||
rev_before = watchlist.revision()
|
||||
with _group_cache_lock:
|
||||
cached = _group_cache.get("groups")
|
||||
if cached is not None and _group_cache.get("_rev") == rev_before:
|
||||
return cached
|
||||
groups: dict[str, set[str]] = {g["id"]: set() for g in watchlist.list_groups()}
|
||||
for row in watchlist.list_symbols():
|
||||
for gid in row.get("group_ids") or []:
|
||||
members = groups.get(gid)
|
||||
if members is not None:
|
||||
members.add(str(row["symbol"]))
|
||||
frozen = {gid: frozenset(syms) for gid, syms in groups.items()}
|
||||
if watchlist.revision() == rev_before:
|
||||
with _group_cache_lock:
|
||||
_group_cache["_rev"] = rev_before
|
||||
_group_cache["groups"] = frozen
|
||||
return frozen
|
||||
|
||||
|
||||
def _group_members_or_none(rule: dict) -> frozenset[str] | None:
|
||||
"""解析规则绑定的分组成员; 分组已删除返回 None, 解析异常返回 None 并记日志。"""
|
||||
group_id = str(rule.get("group_id") or "")
|
||||
try:
|
||||
groups = _watchlist_groups_snapshot()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("自选分组数据读取失败, 规则 %s 本轮跳过: %s", rule.get("id"), exc)
|
||||
return None
|
||||
members = groups.get(group_id)
|
||||
if members is None:
|
||||
key = (str(rule.get("id") or ""), group_id)
|
||||
if key not in _warned_missing_groups:
|
||||
_warned_missing_groups.add(key)
|
||||
logger.warning(
|
||||
"监控规则 %s 绑定的自选分组 %s 已删除, 本轮跳过 (fail-closed, 恢复分组后自动生效)",
|
||||
rule.get("id"), group_id,
|
||||
)
|
||||
return members
|
||||
|
||||
|
||||
def _is_signal_field(field: str) -> bool:
|
||||
return any(field.startswith(p) for p in _SIGNAL_PREFIXES)
|
||||
|
||||
@@ -355,6 +352,9 @@ class MonitorRuleEngine:
|
||||
self._latest_strategy_result_ids: set[str] = set()
|
||||
self._sector_monitor_service = None
|
||||
self._sector_condition_state: dict[tuple[str, str], bool] = {}
|
||||
# abnormal 规则边缘触发状态: (rule_id, symbol) → 上一轮是否已达阈值。
|
||||
# 只在 False → True 跳变时告警 (首轮观测不触发, 防止新建规则瞬间刷屏)。
|
||||
self._abnormal_condition_state: dict[tuple[str, str], bool] = {}
|
||||
|
||||
def set_strategy_engine(self, engine) -> None:
|
||||
"""注入 StrategyEngine, type=strategy 规则据此跑选股。"""
|
||||
@@ -414,6 +414,8 @@ class MonitorRuleEngine:
|
||||
return (
|
||||
rule.get("type"),
|
||||
rule.get("strategy_id"),
|
||||
rule.get("score_min"),
|
||||
rule.get("score_max"),
|
||||
rule.get("asset_type", "stock"),
|
||||
rule.get("scope", "symbols"),
|
||||
tuple(sorted(str(symbol) for symbol in rule.get("symbols", []))),
|
||||
@@ -424,6 +426,7 @@ class MonitorRuleEngine:
|
||||
rule.get("direction"),
|
||||
rule.get("threshold_pct"),
|
||||
rule.get("window_minutes"),
|
||||
rule.get("abnormal_window"),
|
||||
)
|
||||
|
||||
def set_rules(self, rules: list[dict]) -> None:
|
||||
@@ -466,6 +469,11 @@ class MonitorRuleEngine:
|
||||
for key, value in list(self._sector_condition_state.items())
|
||||
if key[0] in active_ids
|
||||
}
|
||||
self._abnormal_condition_state = {
|
||||
key: value
|
||||
for key, value in list(self._abnormal_condition_state.items())
|
||||
if key[0] in active_ids
|
||||
}
|
||||
logger.info("MonitorRuleEngine: 装载 %d 条规则", len(self._rules))
|
||||
|
||||
def add_rule(self, rule: dict) -> None:
|
||||
@@ -660,7 +668,7 @@ class MonitorRuleEngine:
|
||||
for rule_id, rule in list(self._rules.items()):
|
||||
if rule.get("asset_type", "stock") != asset_type:
|
||||
continue
|
||||
if rule.get("type") == "sector":
|
||||
if rule.get("type") in ("sector", "abnormal"):
|
||||
continue
|
||||
try:
|
||||
events.extend(self._evaluate_rule(df, rule, now))
|
||||
@@ -824,6 +832,139 @@ class MonitorRuleEngine:
|
||||
)
|
||||
return "|".join(parts)
|
||||
|
||||
def min_abnormal_closeness(self) -> float:
|
||||
"""启用的 abnormal 规则中最小的接近度阈值 (小数)。
|
||||
|
||||
供调用方 (quote_service) 构建异动快照时预过滤, 不必按最高阈值拉全量。
|
||||
"""
|
||||
thresholds = [
|
||||
float(r.get("threshold_pct", 70)) / 100
|
||||
for r in list(self._rules.values())
|
||||
if r.get("enabled", True) and r.get("type") == "abnormal"
|
||||
]
|
||||
return min(thresholds) if thresholds else 1.0
|
||||
|
||||
def evaluate_abnormal(self, rows: list[dict], *, now: float | None = None) -> list[dict]:
|
||||
"""按异动边缘快照评估 type=abnormal 规则。
|
||||
|
||||
rows 为 abnormal_moves.build_overview 的 rows (调用方已按
|
||||
min_abnormal_closeness 预过滤)。rows 为空也照常评估 —— 用于把
|
||||
已消失标的的边缘状态清理回 False。
|
||||
"""
|
||||
rules = [
|
||||
rule for rule in list(self._rules.values())
|
||||
if rule.get("enabled", True) and rule.get("type") == "abnormal"
|
||||
]
|
||||
if not rules:
|
||||
return []
|
||||
timestamp = time.time() if now is None else now
|
||||
events: list[dict] = []
|
||||
for rule in rules:
|
||||
try:
|
||||
events.extend(self._evaluate_abnormal_rule(rule, rows, timestamp))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("异动规则评估失败 %s: %s", rule.get("id"), exc)
|
||||
return events
|
||||
|
||||
def _evaluate_abnormal_rule(self, rule: dict, rows: list[dict], now: float) -> list[dict]:
|
||||
events: list[dict] = []
|
||||
threshold = float(rule.get("threshold_pct", 70)) / 100
|
||||
if not 0 < threshold <= 1.5:
|
||||
threshold = 0.7
|
||||
direction = rule.get("direction", "both")
|
||||
window_filter = str(rule.get("abnormal_window", "any"))
|
||||
if rule.get("scope") == "symbols":
|
||||
scope_symbols = {str(s) for s in rule.get("symbols", []) if s}
|
||||
elif rule.get("scope") == "watchlist_group":
|
||||
# 异动规则同样支持动态分组; 分组已删除返回 None → 本轮整体跳过
|
||||
members = _group_members_or_none(rule)
|
||||
if members is None:
|
||||
return events
|
||||
scope_symbols = set(members)
|
||||
else:
|
||||
scope_symbols = None
|
||||
|
||||
seen: set[str] = set()
|
||||
for row in rows:
|
||||
symbol = str(row.get("symbol") or "")
|
||||
if not symbol or (scope_symbols is not None and symbol not in scope_symbols):
|
||||
continue
|
||||
seen.add(symbol)
|
||||
# 方向/窗口过滤后取接近度最高的窗口作为代表
|
||||
best: tuple[str, float, float, float] | None = None # (窗口, 接近度, 偏离值, 阈值)
|
||||
for key, win in (row.get("windows") or {}).items():
|
||||
if window_filter != "any" and key != window_filter:
|
||||
continue
|
||||
value = win.get("value")
|
||||
if value is None:
|
||||
continue
|
||||
if direction == "up" and value <= 0:
|
||||
continue
|
||||
if direction == "down" and value >= 0:
|
||||
continue
|
||||
closeness = float(win.get("closeness") or 0)
|
||||
if best is None or closeness > best[1]:
|
||||
best = (key, closeness, float(value), float(win.get("threshold") or 0))
|
||||
condition = best is not None and best[1] >= threshold
|
||||
state_key = (rule["id"], symbol)
|
||||
previous = self._abnormal_condition_state.get(state_key)
|
||||
self._abnormal_condition_state[state_key] = condition
|
||||
if previous is None or previous or not condition:
|
||||
continue
|
||||
|
||||
event_type = f"abnormal_{'up' if best[2] > 0 else 'down'}"
|
||||
cooldown_key = (rule["id"], symbol, event_type)
|
||||
last = self._last_fire.get(cooldown_key)
|
||||
cooldown = int(rule.get("cooldown_seconds", 3600))
|
||||
if last is not None and now - last < cooldown:
|
||||
continue
|
||||
self._last_fire[cooldown_key] = now
|
||||
event = {
|
||||
"ts": int(now * 1000),
|
||||
"rule_id": rule["id"],
|
||||
"rule_name": rule.get("name", ""),
|
||||
"strategy_id": None,
|
||||
"source": "abnormal",
|
||||
"type": event_type,
|
||||
"symbol": symbol,
|
||||
"name": row.get("name"),
|
||||
"message": rule.get("message", "") or self._abnormal_message(row, best),
|
||||
"price": row.get("close"),
|
||||
"change_pct": row.get("rt_pct"),
|
||||
"signals": [],
|
||||
"severity": rule.get("severity", "info"),
|
||||
"conditions": [],
|
||||
"logic": "and",
|
||||
"abnormal_window": best[0],
|
||||
"abnormal_value": round(best[2], 4),
|
||||
"abnormal_threshold": best[3],
|
||||
"abnormal_closeness": round(best[1], 4),
|
||||
}
|
||||
events.append(event)
|
||||
if self._alert_handler:
|
||||
try:
|
||||
self._alert_handler(event)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("alert handler failed: %s", exc)
|
||||
# 本轮未出现的标的 (跌出预过滤区间) 状态置 False 而非删除:
|
||||
# 删除会被当成「首轮观测」而不触发, 置 False 才能在回升穿过阈值时再次告警。
|
||||
for key, value in list(self._abnormal_condition_state.items()):
|
||||
if key[0] == rule["id"] and key[1] not in seen and value:
|
||||
self._abnormal_condition_state[key] = False
|
||||
return events
|
||||
|
||||
@staticmethod
|
||||
def _abnormal_message(row: dict, best: tuple[str, float, float, float]) -> str:
|
||||
window, closeness, value, threshold = best
|
||||
board = row.get("board") or ""
|
||||
tag = f"{board}{'·ST' if row.get('st') else ''}"
|
||||
state = "已达异常波动阈值" if closeness >= 1 else "接近异常波动阈值"
|
||||
return (
|
||||
f"{row.get('name') or row.get('symbol')} {window}偏离值 "
|
||||
f"{value * 100:+.2f}%/阈值{threshold * 100:.0f}% ({tag}) "
|
||||
f"接近度{closeness * 100:.0f}%, {state}"
|
||||
)
|
||||
|
||||
def _evaluate_rule(self, df: pl.DataFrame, rule: dict, now: float) -> list[dict]:
|
||||
"""评估单条规则,返回触发的 events。"""
|
||||
# 1. 按 scope 过滤作用域
|
||||
@@ -917,6 +1058,13 @@ class MonitorRuleEngine:
|
||||
if not syms:
|
||||
return df.head(0)
|
||||
return df.filter(pl.col("symbol").is_in(syms))
|
||||
if scope == "watchlist_group":
|
||||
# 动态绑定自选分组: 每轮评估按分组当前成员过滤 (带版本号缓存)。
|
||||
# 分组已删除/暂时为空 → fail-closed 返回空, 绝不退化为全市场。
|
||||
members = _group_members_or_none(rule)
|
||||
if not members:
|
||||
return df.head(0)
|
||||
return df.filter(pl.col("symbol").is_in(list(members)))
|
||||
if scope == "sector":
|
||||
# sector 过滤需 df 含板块列 (后续接入 ext_data JOIN)。在 JOIN 落地前
|
||||
# fail-closed 返回空 —— 绝不退化为「全市场」误触发 (旧行为 return df 会让
|
||||
@@ -987,7 +1135,16 @@ class MonitorRuleEngine:
|
||||
current=df,
|
||||
market=matrix,
|
||||
)
|
||||
elif s.filter_history_fn:
|
||||
required_history_bars = 1
|
||||
history_resolver = getattr(self._strategy_engine, "required_history_bars", None)
|
||||
if callable(history_resolver):
|
||||
required_history_bars = history_resolver(
|
||||
[sid],
|
||||
overrides_map={sid: overrides},
|
||||
)
|
||||
if getattr(s, "execution_backend", "polars_expr") not in {"composite", "matrix_native"} and (
|
||||
s.filter_history_fn or required_history_bars > 1
|
||||
):
|
||||
history_loader = self._history_loader_for(rule)
|
||||
if history_loader is None:
|
||||
logger.debug("策略 %s 需要历史数据但未注入 history_loader (asset_type=%s), 跳过实时监控",
|
||||
@@ -995,7 +1152,7 @@ class MonitorRuleEngine:
|
||||
return []
|
||||
try:
|
||||
today = cn_today()
|
||||
lookback = max(1, getattr(s, "lookback_days", 30))
|
||||
lookback = max(1, getattr(s, "lookback_days", 1), required_history_bars)
|
||||
hist_df = history_loader(today, lookback)
|
||||
if hist_df is None or hist_df.is_empty():
|
||||
logger.debug("策略 %s 历史数据为空, 跳过本轮实时监控", sid)
|
||||
@@ -1039,7 +1196,6 @@ class MonitorRuleEngine:
|
||||
# 避免并发读到半填充状态。
|
||||
if at == "stock":
|
||||
try:
|
||||
import math
|
||||
self._building_strategy_results[sid] = {
|
||||
"total": result.total,
|
||||
"as_of": str(cn_today()),
|
||||
@@ -1053,7 +1209,27 @@ class MonitorRuleEngine:
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
current_pool: set[str] = {r["symbol"] for r in result.rows}
|
||||
score_min = rule.get("score_min")
|
||||
score_max = rule.get("score_max")
|
||||
score_filter_enabled = score_min is not None or score_max is not None
|
||||
eligible_symbols: set[str] = set()
|
||||
if score_filter_enabled:
|
||||
for row in result.rows:
|
||||
symbol = str(row.get("symbol", ""))
|
||||
score = row.get("score", result.scores.get(symbol))
|
||||
if isinstance(score, bool) or not isinstance(score, (int, float)):
|
||||
continue
|
||||
if not math.isfinite(score):
|
||||
continue
|
||||
if score_min is not None and score < score_min:
|
||||
continue
|
||||
if score_max is not None and score > score_max:
|
||||
continue
|
||||
eligible_symbols.add(symbol)
|
||||
else:
|
||||
eligible_symbols = {str(row["symbol"]) for row in result.rows}
|
||||
|
||||
current_pool = eligible_symbols
|
||||
prev_pool = self._strategy_pools.get(pool_key)
|
||||
self._strategy_pools[pool_key] = current_pool
|
||||
|
||||
@@ -1066,9 +1242,15 @@ class MonitorRuleEngine:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
entry_signal_hits = result.entry_signal_hits
|
||||
if score_filter_enabled:
|
||||
entry_signal_hits = [
|
||||
hit for hit in entry_signal_hits
|
||||
if str(hit.get("symbol", "")) in eligible_symbols
|
||||
]
|
||||
changes: dict[str, set[str]] = {
|
||||
"buy_signal": self._new_strategy_signals(
|
||||
pool_key, "buy_signal", result.as_of, result.entry_signal_hits,
|
||||
pool_key, "buy_signal", result.as_of, entry_signal_hits,
|
||||
),
|
||||
"sell_signal": self._new_strategy_signals(
|
||||
pool_key, "sell_signal", result.as_of, result.exit_signal_hits,
|
||||
@@ -1081,7 +1263,7 @@ class MonitorRuleEngine:
|
||||
signal_map = {
|
||||
"buy_signal": {
|
||||
str(hit["symbol"]): list(hit.get("signals") or [])
|
||||
for hit in result.entry_signal_hits
|
||||
for hit in entry_signal_hits
|
||||
},
|
||||
"sell_signal": {
|
||||
str(hit["symbol"]): list(hit.get("signals") or [])
|
||||
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -27,8 +28,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 常量 ────────────────────────────────────────────────
|
||||
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
||||
RULE_TYPES = {"strategy", "signal", "price", "market", "ladder", "sector"}
|
||||
SCOPES = {"symbols", "all", "sector"}
|
||||
RULE_TYPES = {"strategy", "signal", "price", "market", "ladder", "sector", "abnormal"}
|
||||
SCOPES = {"symbols", "all", "sector", "watchlist_group"}
|
||||
LOGICS = {"and", "or"}
|
||||
DIRECTIONS = {"entry", "exit", "both"}
|
||||
STRATEGY_NOTIFY_EVENTS = {"buy_signal", "sell_signal", "pool_entry", "pool_exit"}
|
||||
@@ -41,6 +42,9 @@ LADDER_DIRECTIONS = {"up", "down"}
|
||||
SECTOR_KINDS = {"index", "concept", "industry"}
|
||||
SECTOR_TRIGGERS = {"change_pct", "momentum"}
|
||||
SECTOR_WINDOWS = {1, 3, 5, 10, 15}
|
||||
# abnormal 规则 (异动边缘): 接近度方向 / 关注窗口
|
||||
ABNORMAL_DIRECTIONS = {"up", "down", "both"}
|
||||
ABNORMAL_WINDOWS = {"any", "3d", "10d", "30d"}
|
||||
|
||||
# 布尔信号列前缀 (op=truth 时 field 取这些)
|
||||
_SIGNAL_PREFIXES = ("signal_", "csg_")
|
||||
@@ -132,6 +136,17 @@ def validate(rule: dict) -> None:
|
||||
invalid_events = set(notify_events) - STRATEGY_NOTIFY_EVENTS
|
||||
if invalid_events:
|
||||
raise ValueError(f"notify_events 包含非法事件: {sorted(invalid_events)}")
|
||||
score_min = rule.get("score_min")
|
||||
score_max = rule.get("score_max")
|
||||
for label, value in (("评分下限", score_min), ("评分上限", score_max)):
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
||||
raise ValueError(f"{label}必须是 0 到 100 之间的数字")
|
||||
if value < 0 or value > 100:
|
||||
raise ValueError(f"{label}必须是 0 到 100 之间的数字")
|
||||
if score_min is not None and score_max is not None and score_min > score_max:
|
||||
raise ValueError("评分下限不能大于评分上限")
|
||||
elif rule.get("type") == "ladder":
|
||||
# 连板梯队封单监控: 需 metric + threshold + direction(up/down), 不用 conditions
|
||||
if rule.get("metric", "sealed_vol") not in LADDER_METRICS:
|
||||
@@ -164,6 +179,17 @@ def validate(rule: dict) -> None:
|
||||
raise ValueError("板块监控阈值必须大于 0 且不超过 20%")
|
||||
if rule.get("sector_trigger") == "momentum" and rule.get("window_minutes") not in SECTOR_WINDOWS:
|
||||
raise ValueError(f"板块异动窗口必须是 {sorted(SECTOR_WINDOWS)} 分钟之一")
|
||||
elif rule.get("type") == "abnormal":
|
||||
# 异动边缘监控: threshold_pct = 接近度阈值% (|偏离值|/规则阈值), 不用 conditions
|
||||
if rule.get("asset_type", "stock") != "stock":
|
||||
raise ValueError("异动监控仅支持个股 (偏离值仅对个股计算)")
|
||||
if rule.get("direction", "both") not in ABNORMAL_DIRECTIONS:
|
||||
raise ValueError(f"异动监控 direction 必须是 {ABNORMAL_DIRECTIONS} 之一")
|
||||
if rule.get("abnormal_window", "any") not in ABNORMAL_WINDOWS:
|
||||
raise ValueError(f"异动监控窗口必须是 {sorted(ABNORMAL_WINDOWS)} 之一")
|
||||
threshold_pct = rule.get("threshold_pct")
|
||||
if not isinstance(threshold_pct, (int, float)) or not 1 <= threshold_pct <= 150:
|
||||
raise ValueError("异动接近度阈值必须是 1 到 150 之间的百分比数字")
|
||||
else:
|
||||
# 信号/价格/市场类型: 需要 conditions
|
||||
conds = rule.get("conditions")
|
||||
@@ -198,6 +224,14 @@ def validate(rule: dict) -> None:
|
||||
syms = rule.get("symbols")
|
||||
if not isinstance(syms, list) or len(syms) == 0:
|
||||
raise ValueError("scope=symbols 时 symbols 不能为空")
|
||||
if rule.get("scope") == "watchlist_group":
|
||||
# 动态绑定自选分组: 评估时实时解析成员 (分组后续增删自动生效)。
|
||||
# 分组存在性由 API 层在保存时校验 (strategy 层不依赖 services)。
|
||||
gid = rule.get("group_id")
|
||||
if not isinstance(gid, str) or not gid.strip():
|
||||
raise ValueError("scope=watchlist_group 时必须选择自选分组")
|
||||
if rule.get("asset_type", "stock") != "stock":
|
||||
raise ValueError("自选分组作用域仅支持个股")
|
||||
if uses_intraday_signals(rule) and rule.get("scope") != "symbols":
|
||||
raise ValueError("分时穿越信号仅支持指定标的")
|
||||
# sector 作用域的板块 JOIN 尚未实现: _apply_scope 目前会退化为「全市场」,
|
||||
@@ -219,18 +253,30 @@ def normalize(rule: dict) -> dict:
|
||||
r = dict(rule)
|
||||
r.setdefault("enabled", True)
|
||||
r.setdefault("asset_type", "stock")
|
||||
r.setdefault("scope", "symbols")
|
||||
# sector/abnormal 默认全市场 (sector 随后强制 all; abnormal 支持指定标的)
|
||||
r.setdefault("scope", "all" if r.get("type") in {"sector", "abnormal"} else "symbols")
|
||||
r.setdefault("symbols", [])
|
||||
r.setdefault("group_id", None)
|
||||
# watchlist_group 作用域: 成员动态来自分组, symbols 不参与; 其他作用域清掉残留 group_id
|
||||
if r.get("scope") == "watchlist_group":
|
||||
r["symbols"] = []
|
||||
else:
|
||||
r["group_id"] = None
|
||||
r.setdefault("sector", None)
|
||||
r.setdefault("sector_kind", None)
|
||||
r.setdefault("sector_targets", [])
|
||||
r.setdefault("sector_trigger", "change_pct")
|
||||
r.setdefault("threshold_pct", 1.0)
|
||||
r.setdefault("threshold_pct", 70.0 if r.get("type") == "abnormal" else 1.0)
|
||||
r.setdefault("window_minutes", 5)
|
||||
r.setdefault("strategy_id", None)
|
||||
# direction 默认值: ladder/sector 用 "up", 其余用 "entry"
|
||||
r.setdefault("direction", "up" if r.get("type") in {"ladder", "sector"} else "entry")
|
||||
# direction 默认值: ladder/sector 用 "up", abnormal 用 "both", 其余用 "entry"
|
||||
r.setdefault(
|
||||
"direction",
|
||||
"up" if r.get("type") in {"ladder", "sector"} else "both" if r.get("type") == "abnormal" else "entry",
|
||||
)
|
||||
if r.get("type") == "strategy":
|
||||
r.setdefault("score_min", None)
|
||||
r.setdefault("score_max", None)
|
||||
if r.get("notify_events") is None:
|
||||
# 兼容统一监控上线后的旧规则: 当时实际行为是同时通知进入和移出。
|
||||
r["notify_events"] = ["pool_entry", "pool_exit"]
|
||||
@@ -238,6 +284,8 @@ def normalize(rule: dict) -> dict:
|
||||
r["notify_events"] = list(dict.fromkeys(r["notify_events"]))
|
||||
else:
|
||||
r.pop("notify_events", None)
|
||||
r.pop("score_min", None)
|
||||
r.pop("score_max", None)
|
||||
r.setdefault("conditions", [])
|
||||
# ladder 专属默认字段
|
||||
r.setdefault("metric", "sealed_vol")
|
||||
@@ -245,6 +293,9 @@ def normalize(rule: dict) -> dict:
|
||||
if r.get("type") == "sector":
|
||||
r["scope"] = "all"
|
||||
r["symbols"] = []
|
||||
r["group_id"] = None
|
||||
# abnormal 专属默认字段 (异动边缘监控)
|
||||
r.setdefault("abnormal_window", "any")
|
||||
r.setdefault("logic", "and")
|
||||
r.setdefault("cooldown_seconds", 3600)
|
||||
r.setdefault("severity", "info")
|
||||
|
||||
@@ -30,7 +30,7 @@ def build_step1(
|
||||
strategy_id: str = "",
|
||||
execution_backend: str = "polars_expr",
|
||||
) -> str:
|
||||
"""步骤1:规则 → 完整策略代码(参数 + 信号 + 评分 + 告警)
|
||||
"""步骤1:规则 → 完整策略代码(参数 + 信号 + 评分)
|
||||
|
||||
注意: 生成规范已在 ai_generator.py 的 system prompt 中加载,
|
||||
此处只拼用户输入以降低网关超时概率。
|
||||
|
||||
@@ -35,9 +35,8 @@
|
||||
1. **META**:id(name, description, tags, params, scoring, basic_filter, limit 等)
|
||||
2. **ENTRY_SIGNALS / EXIT_SIGNALS**:根据策略逻辑自行选择合适的信号列(参考下方可用信号表),不要照抄示例
|
||||
3. **STOP_LOSS / MAX_HOLD_DAYS**:根据策略类型合理设定,做多止损一般为 -5%~-8%,短线持有 5~20 天
|
||||
4. **ALERTS**:列出需要监控提醒的条件
|
||||
5. **RULES**:中文逐条列出核心筛选逻辑(至少 3 条),准确完整
|
||||
6. **EXECUTION_BACKEND + filter() 或 filter_history()**:只选择一个后端和一份核心筛选逻辑
|
||||
4. **RULES**:中文逐条列出核心筛选逻辑(至少 3 条),准确完整
|
||||
5. **EXECUTION_BACKEND + filter() 或 filter_history()**:只选择一个后端和一份核心筛选逻辑
|
||||
|
||||
## 性能原则
|
||||
|
||||
@@ -84,8 +83,6 @@ EXIT_SIGNALS = []
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 20
|
||||
|
||||
ALERTS = []
|
||||
|
||||
RULES = """
|
||||
1. 规则一
|
||||
2. 规则二
|
||||
@@ -136,8 +133,6 @@ EXIT_SIGNALS = []
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 20
|
||||
|
||||
ALERTS = []
|
||||
|
||||
RULES = """
|
||||
1. 规则一(包含时序逻辑)
|
||||
2. 规则二
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
- 增/删/改参数 → 更新 META["params"],同步修改当前执行后端对应的 `filter()`、`filter_history()` 或 `MATRIX_STRATEGY`
|
||||
- 调整信号 → 更新 ENTRY_SIGNALS / EXIT_SIGNALS
|
||||
- 修改止损/持有 → 更新 STOP_LOSS / MAX_HOLD_DAYS
|
||||
- 增减告警 → 更新 ALERTS
|
||||
- 调整评分 → 更新 META["scoring"];只使用真实数值字段或受控虚拟字段 `ma20_bias`,权重总和保持 1.0
|
||||
- 修改筛选逻辑 → 更新唯一公式;新增历史回溯时切换为 `python_history_legacy` + `filter_history()`,移除回溯时切回 `polars_expr` + `filter()`,不得同时保留两套公式
|
||||
|
||||
@@ -38,3 +37,4 @@
|
||||
7. 优先使用 Polars 表达式、窗口函数、聚合和 join,不要默认改成逐行/逐股 Python 循环
|
||||
8. **输出前自我检查**:完整通读修改后的代码,确认 Python 语法正确、括号匹配、引号闭合、缩进一致。有错误直接修正再输出。
|
||||
9. 直接输出完整 Python 代码
|
||||
10. 历史代码中的 `ALERTS` 已废弃,输出时删除;实时提醒由监控中心统一管理
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| 方向 | 做多 |
|
||||
| 规则 | 前一交易日为明显阴线且跌幅不低于2%,今日阳线收盘反包前一日实体,收盘价接近或高于前一日高点,成交量较前一日放大1.2倍以上,当前 close > ma5 或 close > ma10;使用 filter_history,并优先用 Polars shift/with_columns/filter 实现。 |
|
||||
|
||||
点击「AI 生成」,AI 返回完整策略代码(含参数、信号、评分、告警):
|
||||
点击「AI 生成」,AI 返回完整策略代码(含参数、信号、评分):
|
||||
|
||||
```python
|
||||
"""强势反包 — 前日阴线下跌 + 今日放量阳线反包"""
|
||||
@@ -82,9 +82,6 @@ ENTRY_SIGNALS = ["signal_broken_board_recovery"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 10
|
||||
ALERTS = [
|
||||
{"field": "signal_broken_board_recovery", "message": "反包信号"},
|
||||
]
|
||||
|
||||
RULES = """
|
||||
1. 前一交易日为阴线,且跌幅不小于设定阈值
|
||||
|
||||
@@ -47,7 +47,6 @@ ENTRY_SIGNALS = []
|
||||
EXIT_SIGNALS = []
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 20
|
||||
ALERTS = []
|
||||
|
||||
RULES = """
|
||||
1. 规则一
|
||||
|
||||
@@ -61,10 +61,6 @@ STOP_LOSS = -0.05
|
||||
# 最长持有天数 (短线 5~20, 中线 20~60)
|
||||
MAX_HOLD_DAYS = 20
|
||||
|
||||
# 提醒条件 (监控用)
|
||||
ALERTS = []
|
||||
|
||||
|
||||
# 策略规则(人类可读,逐条编号,至少 3 条)
|
||||
RULES = """
|
||||
1. 规则描述一
|
||||
@@ -330,7 +326,6 @@ ENTRY_SIGNALS = ["signal_broken_board_recovery"]
|
||||
EXIT_SIGNALS = ["signal_ma20_breakdown"]
|
||||
STOP_LOSS = -0.05
|
||||
MAX_HOLD_DAYS = 10
|
||||
ALERTS = [{"field": "signal_broken_board_recovery", "message": "反包信号"}]
|
||||
|
||||
RULES = """
|
||||
1. 前一交易日为阴线,且跌幅不小于设定阈值
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user