Merge branch 'v0.2'

# Conflicts:
#	dev.sh
This commit is contained in:
shy3130
2026-08-23 12:37:20 +08:00
312 changed files with 37964 additions and 4498 deletions
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -2,6 +2,8 @@
修改、调试或审查本仓库前,必须完整阅读并遵循根目录的 [`CONTRIBUTING.md`](CONTRIBUTING.md)。其中定义了项目架构、数据契约、数据源插件化、缓存与性能要求、测试矩阵以及 PR 复审和合并标准。
涉及代码二次开发、前端插槽、后端可替换策略、扩展注册或上游升级兼容时,还必须阅读 [`docs/secondary-development.md`](docs/secondary-development.md)。该文档区分当前已实现能力与目标扩展契约;不得根据设计示例虚构尚不存在的 API。
同时遵守以下规则:
- 先理解调用链和现有测试,再进行修改。
+3 -1
View File
@@ -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
View File
@@ -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
+20 -9
View File
@@ -3,7 +3,7 @@
# 📈 A股智能量化工作台
[![声明:个人开源](https://img.shields.io/badge/⚠️_声明-个人开源_非_TickFlow_官方项目-green?style=for-the-badge&labelColor=red)](https://github.com/shy3130/tickflow-stock-panel)
[![声明:个人开源](https://img.shields.io/badge/⚠️_声明-个人开源_非_TickFlow_官方项目-green?style=for-the-badge&labelColor=red)](https://github.com/shy3130/tick-stock-panel)
@@ -16,7 +16,7 @@
[![React](https://img.shields.io/badge/React-18-61dafb.svg)](https://react.dev/)
[![Data: TickFlow](https://img.shields.io/badge/Data-TickFlow-00b386.svg)](https://tickflow.org/auth/register?ref=V3KDKGXPEA)
[![Deploy: Docker](https://img.shields.io/badge/Deploy-Docker-2496ed.svg)](./Dockerfile)
[![GitHub stars](https://img.shields.io/github/stars/shy3130/tickflow-stock-panel?style=social)](https://github.com/shy3130/tickflow-stock-panel/stargazers)
[![GitHub stars](https://img.shields.io/github/stars/shy3130/tick-stock-panel?style=social)](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) 提供数据服务,使用前请遵守其服务条款
+2 -2
View File
@@ -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,导致请求失败。
+23
View File
@@ -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
View File
@@ -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
View File
@@ -11,6 +11,7 @@ from typing import Any, Callable
from fastapi import APIRouter, Request
from app.enriched_generation import EnrichedPublication
from app.indicators.pipeline import ENRICHED_COLUMNS
logger = logging.getLogger(__name__)
@@ -504,26 +505,21 @@ def _compute_storage(data_dir: Path) -> dict:
stats[f"{key}_files"] = fc
stats[f"{key}_size_mb"] = sz
# total: 再加上其他零散文件(pools, financials, capabilities.json 等)
other_dirs = ["pools", "financials", "backtest_results", "screener_results", "ai_cache"]
# total: 再加上其他零散目录 (financials 有下方专属明细统计, 不在此列)
other_dirs = ["pools", "backtest_results", "screener_results", "ai_cache"]
for name in other_dirs:
d = data_dir / name
if d.exists():
_, s = _scan_dir_stats(d)
total_size += s
# financials 单独统计
# financials 单独统计 (明细与 total 各计入一次, 不得与其他目录循环重复累加)
fin_dir = data_dir / "financials"
if fin_dir.exists():
fc, sz = _scan_dir_stats(fin_dir)
stats["financials_files"] = fc
stats["financials_size_mb"] = sz
total_size += sz
for name in other_dirs:
d = data_dir / name
if d.exists():
_, s = _scan_dir_stats(d)
total_size += s
# 根目录散文件
for entry in os.scandir(data_dir):
if entry.is_file(follow_symlinks=False):
@@ -626,6 +622,10 @@ def clear_data(request: Request):
repo = request.app.state.repo
data_dir = repo.store.data_dir
deleted = 0
publications = {
"kline_daily_enriched": EnrichedPublication(data_dir, "stock"),
"kline_etf_enriched": EnrichedPublication(data_dir, "etf"),
}
for sub in (
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched",
@@ -634,15 +634,27 @@ def clear_data(request: Request):
"backtest_results", "screener_results", "ai_cache",
):
d = data_dir / sub
if d.exists():
# 先删所有 parquet 文件
for f in d.rglob("*.parquet"):
if not d.exists():
continue
publication = publications.get(sub)
parquet_files = list(d.rglob("*.parquet"))
if publication is not None and parquet_files:
publication.begin()
try:
for f in parquet_files:
f.unlink()
deleted += 1
# 再删除空的日期分区子目录(date=YYYY-MM-DD 等)
if publication is not None:
publication.mark_changed()
for child in list(d.iterdir()):
if child.is_dir():
shutil.rmtree(child, ignore_errors=True)
if publication is not None:
publication.commit()
except BaseException:
if publication is not None:
publication.abandon()
raise
# 清除同步历史(内存 + 磁盘 job_store/ 文件夹)
from app.services.pipeline_jobs import job_store
+4
View File
@@ -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
View File
@@ -12,7 +12,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
from app.indicators.pipeline import compute_enriched, compute_enriched_single
from app.market_time import cn_now, cn_today
from app.price_limits import is_risk_warning_name, price_limit_pct
from app.db_safe import is_valid_ext_ident, quote_ident
from app.db_safe import is_valid_ext_ident
from app.services import kline_sync
logger = logging.getLogger(__name__)
@@ -175,21 +175,28 @@ def instruments_names(request: Request, symbols: list[str]):
def _get_stock_info(repo, symbol: str) -> dict:
"""从 instruments 视图查标的名称 + 股本。"""
"""从 instruments 内存缓存查标的名称 + 股本。
该接口在个股弹窗打开时每秒被调用 (SSE invalidate 触发重拉), 走
repo.get_instruments() 的 Polars 内存缓存按 symbol 过滤, 不再每请求
DuckDB 扫 instruments parquet。列缺失时返回空 dict, 与旧 SQL 报错路径一致。
"""
import polars as pl
try:
row = repo.execute_one(
"SELECT name, total_shares, float_shares FROM instruments WHERE symbol = ? LIMIT 1",
[symbol],
)
df = repo.get_instruments()
needed = ("symbol", "name", "total_shares", "float_shares")
if df.is_empty() or not all(c in df.columns for c in needed):
return {}
hit = df.filter(pl.col("symbol") == symbol).head(1)
if hit.is_empty():
return {}
return {
"name": hit["name"][0],
"total_shares": hit["total_shares"][0],
"float_shares": hit["float_shares"][0],
}
except Exception: # noqa: BLE001
return {}
if not row:
return {}
return {
"name": row[0],
"total_shares": row[1],
"float_shares": row[2],
}
def _get_asset_info(repo, symbol: str, asset_type: str) -> dict:
@@ -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)
+736
View File
@@ -0,0 +1,736 @@
"""Persistent factor and strategy mining HTTP API."""
from __future__ import annotations
import asyncio
import json
import math
from collections.abc import AsyncIterator, Mapping, Sequence
from datetime import date
from typing import Annotated, Any, Literal
import polars as pl
from fastapi import APIRouter, Header, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from sse_starlette.sse import EventSourceResponse
from app.backtest.factor import FACTOR_COLUMNS
from app.backtest.mining import (
MAX_BEAM_WIDTH,
MAX_COMBINATION_SIZE,
MAX_FINALISTS,
evaluate_candidate_gate,
)
from app.services import preferences
from app.services.mining_jobs import (
RUN_STATUSES,
SUCCESS_RUN_STATUSES,
TERMINAL_RUN_STATUSES,
MiningRunStore,
MiningRunStoreError,
MiningRunValidationError,
)
from app.services.mining_preflight import (
mining_availability,
require_mining_availability,
)
from app.services.mining_schedule import (
MINING_ALGORITHM_VERSION,
build_data_fingerprint,
)
router = APIRouter(prefix="/api/backtest/mining", tags=["backtest"])
_FACTOR_IDS = frozenset(str(item["id"]) for item in FACTOR_COLUMNS)
_MAX_ARTIFACT_BYTES = 64 * 1024 * 1024
_SSE_POLL_SECONDS = 0.5
_SSE_HEARTBEAT_SECONDS = 15.0
class MiningStartRequest(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
factor_names: list[str] = Field(min_length=1, max_length=48)
strategy_ids: list[str] = Field(default_factory=list, max_length=8)
symbols: list[str] | None = None
asset_type: Literal["stock", "etf"] = "stock"
start: date | None = None
end: date | None = None
budget_profile: Literal["exploratory", "balanced", "strict"] = "balanced"
commission_pct: float = Field(0.0002, ge=0.0, le=0.05, allow_inf_nan=False)
stamp_tax_pct: float = Field(0.0005, ge=0.0, le=0.05, allow_inf_nan=False)
slippage_bps: float = Field(5.0, ge=0.0, le=1000.0, allow_inf_nan=False)
correlation_threshold: float = Field(0.75, gt=0.0, le=1.0, allow_inf_nan=False)
max_combination_factors: int = Field(4, ge=1, le=MAX_COMBINATION_SIZE)
beam_width: int = Field(12, ge=1, le=MAX_BEAM_WIDTH)
max_finalists: int = Field(MAX_FINALISTS, ge=1, le=MAX_FINALISTS)
force: bool = False
@field_validator("start", "end", mode="before")
@classmethod
def _iso_dates(cls, value: Any) -> Any:
if isinstance(value, str):
try:
return date.fromisoformat(value)
except ValueError as exc:
raise ValueError("dates must use ISO YYYY-MM-DD format") from exc
return value
@field_validator("factor_names", "strategy_ids")
@classmethod
def _unique_ids(cls, values: list[str]) -> list[str]:
if any(not value or len(value) > 120 for value in values):
raise ValueError("IDs must contain 1 to 120 characters")
if len(set(values)) != len(values):
raise ValueError("IDs must be unique")
return values
@field_validator("factor_names")
@classmethod
def _known_factors(cls, values: list[str]) -> list[str]:
unknown = sorted(set(values) - _FACTOR_IDS)
if unknown:
raise ValueError(f"unknown mining factors: {unknown}")
return values
@field_validator("symbols")
@classmethod
def _symbols(cls, values: list[str] | None) -> list[str] | None:
if values is None:
return None
cleaned = [value for value in values if value]
if len(cleaned) > 10_000:
raise ValueError("symbols contains more than 10000 entries")
if len(set(cleaned)) != len(cleaned):
raise ValueError("symbols must be unique")
return cleaned or None
@model_validator(mode="after")
def _date_range(self) -> MiningStartRequest:
if self.start is not None and self.end is not None and self.start > self.end:
raise ValueError("start must not be after end")
return self
class MiningSchedulePatch(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
mining_schedule_enabled: bool | None = None
mining_schedule_weekday: int | None = Field(None, ge=0, le=4)
mining_budget_profile: Literal["balanced", "strict"] | None = None
@router.get("/availability")
def get_availability(
request: Request,
asset_type: Annotated[Literal["stock", "etf"], Query()] = "stock",
budget_profile: Annotated[
Literal["exploratory", "balanced", "strict"], Query()
] = "balanced",
start: Annotated[date | None, Query()] = None,
end: Annotated[date | None, Query()] = None,
) -> dict[str, Any]:
try:
return mining_availability(
request.app.state.repo.store.data_dir,
asset_type=asset_type,
budget_profile=budget_profile,
start=start,
end=end,
).to_dict()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/runs")
def list_runs(
request: Request,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
status: Annotated[list[str] | None, Query()] = None,
) -> dict[str, Any]:
manager = _manager(request)
statuses = None
if status:
unknown = sorted(set(status) - RUN_STATUSES)
if unknown:
raise HTTPException(status_code=400, detail=f"unsupported mining statuses: {unknown}")
statuses = status
try:
manifests = manager.store.list_runs(limit=limit, statuses=statuses)
return {"items": [_project_run(manager.store, item) for item in manifests]}
except MiningRunValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except MiningRunStoreError as exc:
raise HTTPException(status_code=500, detail="failed to read mining runs") from exc
@router.post("/runs")
def start_run(payload: MiningStartRequest, request: Request) -> dict[str, Any]:
manager = _manager(request)
worker_request = payload.model_dump(mode="json", exclude={"force"})
try:
_validate_selected_strategies(
request.app.state.strategy_engine,
payload.strategy_ids,
payload.asset_type,
)
require_mining_availability(
request.app.state.repo.store.data_dir,
asset_type=payload.asset_type,
budget_profile=payload.budget_profile,
start=payload.start,
end=payload.end,
)
fingerprint = build_data_fingerprint(
request.app.state.repo,
request.app.state,
worker_request,
)
existing = None
if not payload.force:
from app.services.mining_jobs import (
ACTIVE_RUN_STATUSES,
SUCCESS_RUN_STATUSES,
compute_run_signature,
)
signature = compute_run_signature(worker_request, fingerprint)
existing = manager.store.find_by_signature(
signature,
statuses=ACTIVE_RUN_STATUSES | SUCCESS_RUN_STATUSES,
)
manifest = manager.start(
worker_request,
fingerprint,
force=payload.force,
source="manual",
)
projected = _project_run(manager.store, manifest)
projected["reused"] = existing is not None
return projected
except (MiningRunValidationError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except MiningRunStoreError as exc:
raise HTTPException(status_code=500, detail="failed to persist mining run") from exc
@router.get("/runs/{run_id}")
def get_run(run_id: str, request: Request) -> dict[str, Any]:
store = _manager(request).store
return _project_run(store, _required_manifest(store, run_id))
@router.post("/runs/{run_id}/cancel")
def cancel_run(run_id: str, request: Request) -> dict[str, Any]:
manager = _manager(request)
try:
return _project_run(manager.store, manager.cancel(run_id))
except KeyError as exc:
raise HTTPException(status_code=404, detail="mining run not found") from exc
except MiningRunValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/runs/{run_id}/result")
def get_result(run_id: str, request: Request) -> dict[str, Any]:
store = _manager(request).store
manifest = _required_manifest(store, run_id)
status = str(manifest["status"])
if status not in SUCCESS_RUN_STATUSES:
status_code = 409 if status not in TERMINAL_RUN_STATUSES else 422
raise HTTPException(
status_code=status_code,
detail=f"mining result is unavailable for status {status}",
)
try:
summary = store.read_summary(run_id)
frames = {
name: _read_registered_artifact(store, manifest, name)
for name in ("factors", "correlation", "candidates", "folds")
}
return _project_result(manifest, summary, frames)
except (
MiningRunStoreError,
OSError,
pl.exceptions.PolarsError,
ValueError,
) as exc:
raise HTTPException(
status_code=500,
detail="mining result artifacts are unavailable",
) from exc
@router.get("/runs/{run_id}/events")
def stream_events(
run_id: str,
request: Request,
last_event_id: str | None = Header(None, alias="Last-Event-ID"),
) -> EventSourceResponse:
store = _manager(request).store
_required_manifest(store, run_id)
cursor = _event_cursor(last_event_id)
async def generate() -> AsyncIterator[dict[str, str]]:
nonlocal cursor
last_emit = asyncio.get_running_loop().time()
terminal_sent = False
first_batch = True
while not await request.is_disconnected():
events = await asyncio.to_thread(store.read_events, run_id, after_id=cursor)
if first_batch and events and int(events[0]["id"]) > cursor + 1:
summary = await asyncio.to_thread(store.read_summary, run_id)
progress = summary.get("progress")
if isinstance(progress, Mapping):
yield {
"id": str(cursor),
"event": "progress",
"data": json.dumps(progress, ensure_ascii=False, allow_nan=False),
}
last_emit = asyncio.get_running_loop().time()
first_batch = False
for event in events:
cursor = int(event["id"])
event_type = "failed" if event.get("type") == "error" else str(event["type"])
payload = dict(event.get("payload") or {})
if event_type in TERMINAL_RUN_STATUSES:
payload.setdefault("status", event_type)
terminal_sent = True
yield {
"id": str(cursor),
"event": event_type,
"data": json.dumps(payload, ensure_ascii=False, allow_nan=False),
}
last_emit = asyncio.get_running_loop().time()
manifest = await asyncio.to_thread(store.get, run_id)
if manifest is None:
return
status = str(manifest["status"])
if status in TERMINAL_RUN_STATUSES:
if not terminal_sent:
event_type = "failed" if status == "failed" else status
yield {
"id": str(cursor),
"event": event_type,
"data": json.dumps(
{"status": status, "message": manifest.get("error")},
ensure_ascii=False,
),
}
return
now = asyncio.get_running_loop().time()
if now - last_emit >= _SSE_HEARTBEAT_SECONDS:
yield {"event": "heartbeat", "data": "{}"}
last_emit = now
await asyncio.sleep(_SSE_POLL_SECONDS)
return EventSourceResponse(generate(), ping=_SSE_HEARTBEAT_SECONDS)
@router.post("/runs/{run_id}/candidates/{signature}/promote")
def promote_candidate(run_id: str, signature: str, request: Request) -> dict[str, Any]:
service = _candidate_service(request)
try:
return service.promote(run_id, signature)
except KeyError as exc:
raise HTTPException(status_code=404, detail="mining run or candidate not found") from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("/runs/{run_id}/candidates/{signature}/publish")
def publish_candidate(run_id: str, signature: str, request: Request) -> dict[str, Any]:
service = _candidate_service(request)
try:
return service.publish(run_id, signature)
except KeyError as exc:
raise HTTPException(status_code=404, detail="mining run or candidate not found") from exc
except FileExistsError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.get("/config")
def get_config() -> dict[str, Any]:
return preferences.get_mining_schedule()
@router.patch("/config")
def update_config(payload: MiningSchedulePatch) -> dict[str, Any]:
current = preferences.get_mining_schedule()
updates = payload.model_dump(exclude_none=True)
if not updates:
raise HTTPException(status_code=400, detail="at least one mining config field is required")
merged = {**current, **updates}
try:
return preferences.set_mining_schedule(
merged["mining_schedule_enabled"],
merged["mining_schedule_weekday"],
merged["mining_budget_profile"],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _manager(request: Request):
manager = getattr(request.app.state, "mining_manager", None)
if manager is None:
raise HTTPException(status_code=503, detail="mining manager is unavailable")
return manager
def _candidate_service(request: Request):
service = getattr(request.app.state, "mining_candidate_service", None)
if service is not None:
return service
from app.backtest.candidates import CandidateStore
from app.services.mining_candidates import MiningCandidateService
manager = _manager(request)
data_dir = request.app.state.repo.store.data_dir
monitor_engine = getattr(request.app.state, "monitor_engine", None)
service = MiningCandidateService(
data_dir,
manager.store,
CandidateStore(data_dir),
request.app.state.strategy_engine,
monitor_state_invalidator=(
monitor_engine.invalidate_strategy_state
if monitor_engine is not None
else None
),
)
request.app.state.mining_candidate_service = service
return service
def _required_manifest(store: MiningRunStore, run_id: str) -> dict[str, Any]:
try:
manifest = store.get(run_id)
except MiningRunValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except MiningRunStoreError as exc:
raise HTTPException(status_code=500, detail="failed to read mining run") from exc
if manifest is None:
raise HTTPException(status_code=404, detail="mining run not found")
return manifest
def _project_run(store: MiningRunStore, manifest: Mapping[str, Any]) -> dict[str, Any]:
run_id = str(manifest["run_id"])
summary = store.read_summary(run_id)
events = store.read_events(run_id)
source = next(
(
event.get("payload", {}).get("source")
for event in events
if event.get("type") == "queued" and event.get("payload", {}).get("source")
),
None,
)
if source is None and isinstance(manifest.get("data_fingerprint"), Mapping):
source = manifest["data_fingerprint"].get("source")
compact = _summary_projection(summary) if manifest["status"] in SUCCESS_RUN_STATUSES else None
return {
"run_id": run_id,
"signature": manifest["run_signature"],
"status": manifest["status"],
"request": manifest.get("request") or {},
"source": source or "manual",
"created_at": manifest.get("created_at"),
"updated_at": manifest.get("updated_at"),
"started_at": manifest.get("started_at"),
"finished_at": manifest.get("finished_at"),
"data_as_of": summary.get("data_as_of"),
"progress": (
summary.get("progress")
if isinstance(summary.get("progress"), Mapping)
else None
),
"error": manifest.get("error"),
"summary": compact,
}
def _request_summary(manifest: Mapping[str, Any]) -> dict[str, Any]:
request = manifest.get("request") or {}
factor_names = request.get("factor_names")
strategy_ids = request.get("strategy_ids")
return {
"asset_type": request.get("asset_type") or "stock",
"budget_profile": request.get("budget_profile") or "balanced",
"start": request.get("start"),
"end": request.get("end"),
"factor_count": len(factor_names) if isinstance(factor_names, list) else 0,
"strategy_count": len(strategy_ids) if isinstance(strategy_ids, list) else 0,
"commission_pct": _finite(request.get("commission_pct")),
"stamp_tax_pct": _finite(request.get("stamp_tax_pct")),
"slippage_bps": _finite(request.get("slippage_bps")),
"correlation_threshold": _finite(request.get("correlation_threshold")),
}
def _summary_projection(summary: Mapping[str, Any]) -> dict[str, Any]:
worker = summary.get("worker") if isinstance(summary.get("worker"), Mapping) else {}
return {
"factor_count": int(summary.get("factor_count") or 0),
"selected_factor_count": int(summary.get("selected_factor_count") or 0),
"candidate_count": int(summary.get("candidate_count") or 0),
"valid_fold_count": int(summary.get("valid_fold_count") or 0),
"skipped_fold_count": int(summary.get("skipped_fold_count") or 0),
"confidence": summary.get("confidence") or "low",
"budget_exhausted": bool(summary.get("budget_exhausted", False)),
"elapsed_ms": _finite(summary.get("elapsed_ms")),
"peak_rss_bytes": _optional_int(worker.get("peak_rss_bytes")),
}
def _read_registered_artifact(
store: MiningRunStore,
manifest: Mapping[str, Any],
name: str,
) -> pl.DataFrame:
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, Mapping) or name not in artifacts:
raise ValueError(f"mining artifact is not registered: {name}")
raw_path = artifacts[name]
if not isinstance(raw_path, str):
raise ValueError(f"mining artifact registration is invalid: {name}")
run_dir = store.artifact_path(str(manifest["run_id"]), name).parent # type: ignore[arg-type]
registered = (run_dir / raw_path).resolve()
if not registered.is_relative_to(run_dir.resolve()):
raise ValueError(f"mining artifact escapes its run directory: {name}")
if registered.suffix.lower() != ".parquet" or not registered.is_file():
raise ValueError(f"mining artifact is unavailable: {name}")
if registered.stat().st_size > _MAX_ARTIFACT_BYTES:
raise ValueError(f"mining artifact exceeds size limit: {name}")
return pl.read_parquet(registered)
def _project_result(
manifest: Mapping[str, Any],
summary: Mapping[str, Any],
frames: Mapping[str, pl.DataFrame],
) -> dict[str, Any]:
factors = [_clean_record(row) for row in frames["factors"].to_dicts()]
correlation = _project_correlation(frames["correlation"])
fold_records = [_project_fold(row) for row in frames["folds"].to_dicts()]
candidates = _project_candidates(frames["candidates"], fold_records)
selected_signature = candidates[0]["signature"] if candidates else None
folds = [
_public_fold(row)
for row in fold_records
if row["regime_state"] == "overall"
and (selected_signature is None or row["candidate_signature"] == selected_signature)
]
regimes = _project_regimes(fold_records, selected_signature)
worker = summary.get("worker") if isinstance(summary.get("worker"), Mapping) else {}
threshold = _finite((manifest.get("request") or {}).get("correlation_threshold"))
correlation["threshold"] = threshold if threshold is not None else 0.75
return {
"run_id": manifest["run_id"],
"methodology_version": summary.get("methodology_version") or "factor_v2",
"algorithm_version": summary.get("algorithm_version") or MINING_ALGORITHM_VERSION,
"data_as_of": summary.get("data_as_of"),
"request_summary": _request_summary(manifest),
"summary": _summary_projection(summary),
"factors": factors,
"correlation": correlation,
"regimes": regimes,
"candidates": candidates,
"folds": folds,
"telemetry": {
"elapsed_ms": _finite(summary.get("elapsed_ms")),
"peak_rss_bytes": _optional_int(worker.get("peak_rss_bytes")),
"panel_scans": _optional_int(summary.get("panel_scans")),
"matrix_bytes": _optional_int(summary.get("matrix_bytes")),
"serialized_result_bytes": _optional_int(worker.get("serialized_result_bytes")),
"phase_ms": _finite_mapping(summary.get("phase_ms")),
},
}
def _project_correlation(frame: pl.DataFrame) -> dict[str, Any]:
required = {"factor_x", "factor_y", "rho", "pair_count"}
if not required.issubset(frame.columns):
raise ValueError("correlation artifact schema is invalid")
labels = sorted(set(frame["factor_x"].to_list()) | set(frame["factor_y"].to_list()))
positions = {str(label): index for index, label in enumerate(labels)}
matrix: list[list[float | None]] = [[None for _ in labels] for _ in labels]
counts: list[list[int | None]] = [[None for _ in labels] for _ in labels]
for row in frame.iter_rows(named=True):
left = positions[str(row["factor_x"])]
right = positions[str(row["factor_y"])]
matrix[left][right] = _finite(row["rho"])
counts[left][right] = _optional_int(row["pair_count"])
return {"labels": labels, "matrix": matrix, "pair_counts": counts}
def _project_fold(row: Mapping[str, Any]) -> dict[str, Any]:
projected = _clean_record(row)
projected["selected_factors"] = _json_string_list(row.get("selected_factors_json"))
projected["candidate_signature"] = row.get("candidate_signature")
projected["regime_state"] = str(row.get("regime_state") or "overall")
projected["n_dates"] = int(row.get("n_dates") or 0)
return projected
def _public_fold(row: Mapping[str, Any]) -> dict[str, Any]:
return {
key: row.get(key)
for key in (
"fold", "label", "train_start", "train_end", "test_start", "test_end",
"selected_factors", "total_return", "sharpe", "max_drawdown", "n_trades",
"skipped", "reason", "evaluation_kind",
)
}
def _project_candidates(
frame: pl.DataFrame,
folds: Sequence[Mapping[str, Any]],
) -> list[dict[str, Any]]:
required = {"signature", "name", "kind", "factor_names_json", "confidence"}
if not required.issubset(frame.columns):
raise ValueError("candidates artifact schema is invalid")
candidates = []
for row in frame.to_dicts():
candidate = _clean_record(row)
candidate.pop("definition_json", None)
candidate.pop("factor_names_json", None)
candidate["factor_names"] = _json_string_list(row.get("factor_names_json"))
signature = str(row["signature"])
candidate["folds"] = [
_public_fold(fold)
for fold in folds
if fold["regime_state"] == "overall"
and fold["candidate_signature"] == signature
]
gate = evaluate_candidate_gate(
confidence=row.get("confidence"),
valid_folds=row.get("valid_folds"),
positive_fold_ratio=row.get("oos_positive_fold_ratio"),
sharpe=row.get("oos_sharpe"),
max_drawdown=row.get("oos_max_drawdown"),
n_trades=row.get("oos_n_trades"),
)
candidate["gate"] = {
"qualified": gate.qualified,
"reasons": list(gate.reasons),
}
candidates.append(candidate)
candidates.sort(
key=lambda item: (
-(item.get("oos_sharpe") if item.get("oos_sharpe") is not None else -math.inf),
str(item["signature"]),
)
)
return candidates
def _project_regimes(
folds: Sequence[Mapping[str, Any]],
signature: str | None,
) -> list[dict[str, Any]]:
labels = {"overall": "整体", "strong": "强势", "range": "震荡", "weak": "弱势"}
result = []
for state in ("overall", "strong", "range", "weak"):
rows = [
row
for row in folds
if row["regime_state"] == state
and (signature is None or row["candidate_signature"] == signature)
and not row.get("skipped")
]
result.append({
"state": state,
"label": labels[state],
"n_dates": sum(int(row.get("n_dates") or 0) for row in rows),
"total_return": _mean(row.get("total_return") for row in rows),
"sharpe": _mean(row.get("sharpe") for row in rows),
"max_drawdown": _minimum(row.get("max_drawdown") for row in rows),
})
return result
def _validate_selected_strategies(
strategy_engine: Any,
strategy_ids: Sequence[str],
asset_type: str,
) -> None:
for strategy_id in strategy_ids:
strategy = strategy_engine.get(strategy_id)
if strategy.meta.get("research_only"):
raise ValueError(f"research template cannot be mined as existing: {strategy_id}")
if strategy.execution_backend != "matrix_native":
raise ValueError(f"mining strategy is not matrix-native: {strategy_id}")
if "1d" not in strategy.meta.get("timeframes", ["1d"]):
raise ValueError(f"mining strategy is not daily-compatible: {strategy_id}")
if asset_type not in strategy.meta.get("asset_types", ["stock"]):
raise ValueError(f"mining strategy does not support {asset_type}: {strategy_id}")
def _event_cursor(value: str | None) -> int:
if value in (None, ""):
return 0
try:
cursor = int(value)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Last-Event-ID must be an integer") from exc
if cursor < 0:
raise HTTPException(status_code=400, detail="Last-Event-ID must be non-negative")
return cursor
def _json_string_list(value: Any) -> list[str]:
if not isinstance(value, str):
return []
parsed = json.loads(value)
if not isinstance(parsed, list) or any(not isinstance(item, str) for item in parsed):
raise ValueError("artifact JSON list is invalid")
return parsed
def _clean_record(row: Mapping[str, Any]) -> dict[str, Any]:
return {
str(key): (_finite(value) if isinstance(value, float) else value)
for key, value in row.items()
}
def _finite(value: Any) -> float | None:
if value is None:
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) else None
def _optional_int(value: Any) -> int | None:
number = _finite(value)
return int(number) if number is not None else None
def _finite_mapping(value: Any) -> dict[str, float] | None:
if not isinstance(value, Mapping):
return None
return {
str(key): number
for key, item in value.items()
if (number := _finite(item)) is not None
}
def _mean(values: Sequence[Any] | Any) -> float | None:
finite = [number for value in values if (number := _finite(value)) is not None]
return sum(finite) / len(finite) if finite else None
def _minimum(values: Sequence[Any] | Any) -> float | None:
finite = [number for value in values if (number := _finite(value)) is not None]
return min(finite) if finite else None
+35 -5
View File
@@ -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
View File
@@ -7,8 +7,9 @@ from __future__ import annotations
import threading
import time
from datetime import date
from typing import Any
from typing import Annotated, Any
import polars as pl
from fastapi import APIRouter, Query, Request
from app.services import regime_builder
@@ -142,6 +143,8 @@ def regime_recompute(request: Request, start: date | None = None, end: date | No
与 daily_pipeline 的增量补差(compute_regime_incremental)不同 —— 此接口面向
人工「我要重新算一遍」的预期, 必须真正重算而非增量补缺口。
- 传 start: 仅重算 [start, end] 区间。
- 重算后统一重标情绪周期阶段(refresh_phase_labels)并回填主线
(概念+行业, 概念成分为当前快照回看历史, 早年有归属漂移)。
"""
repo = request.app.state.repo
data_dir = _data_dir(request)
@@ -156,5 +159,201 @@ def regime_recompute(request: Request, start: date | None = None, end: date | No
new_rows = regime_builder.run_regime_batch(repo, start=start, end=end)
if not new_rows.is_empty():
regime_builder.upsert_regime_history(data_dir, new_rows)
phase_days = regime_builder.refresh_phase_labels(data_dir)
from app.services import market_mainline
mainline_rows = 0
for kind in ("concept", "industry"):
rows = market_mainline.compute_mainline_range(repo, data_dir, start, end, kind=kind)
if not rows.is_empty():
market_mainline.upsert_mainline_history(data_dir, rows)
mainline_rows += rows.height
invalidate_regime_cache()
return {"ok": True, "computed": new_rows.height if not new_rows.is_empty() else 0}
return {
"ok": True,
"computed": new_rows.height if not new_rows.is_empty() else 0,
"phase_days": phase_days,
"mainline_rows": mainline_rows,
}
@router.get("/phases")
def regime_phases(
request: Request,
start: date | None = None,
end: date | None = None,
):
"""情绪周期阶段段列表: 连续同阶段合段, 附段内均值指标与主导主线。
直接回答「什么阶段走什么主升」: 主升/高潮段的主导主线即该段行情主线。
主线按段内进入当日 top5 的天数与累计分排序, 取前 3。
"""
from app.services.market_mainline import load_mainline_history
from app.services.market_phase import PHASE_LABELS
data_dir = _data_dir(request)
df = regime_builder.load_regime_history(data_dir)
if df.is_empty() or "phase" not in df.columns:
return {"segments": [], "total": 0}
if start:
df = df.filter(pl_col_date(df, ">=", start))
if end:
df = df.filter(pl_col_date(df, "<=", end))
df = df.sort("date")
if df.is_empty():
return {"segments": [], "total": 0}
mainline = load_mainline_history(data_dir, "concept")
segments: list[dict] = []
cur: dict | None = None
for r in df.iter_rows(named=True):
phase = r.get("phase")
if cur is None or cur["phase"] != phase:
cur = {
"phase": phase,
"label": PHASE_LABELS.get(phase, phase),
"start": str(r["date"]),
"end": str(r["date"]),
"days": 0,
"_height": 0.0,
"_first_board": 0.0,
"_ge2": 0.0,
"_promo_sum": 0.0,
"_promo_n": 0,
"_seal": 0.0,
}
segments.append(cur)
cur["end"] = str(r["date"])
cur["days"] += 1
cur["_height"] += float(r.get("max_consecutive") or 0)
cur["_first_board"] += float(r.get("first_board") or 0)
cur["_ge2"] += float(r.get("ge2_count") or 0)
promo = r.get("promo_rate")
if promo is not None:
cur["_promo_sum"] += float(promo)
cur["_promo_n"] += 1
cur["_seal"] += float(r.get("seal_rate") or 0)
for seg in segments:
n = seg["days"]
seg["avg_height"] = round(seg.pop("_height") / n, 1)
seg["avg_first_board"] = round(seg.pop("_first_board") / n, 1)
seg["avg_ge2"] = round(seg.pop("_ge2") / n, 1)
seg["avg_promo"] = (
round(seg.pop("_promo_sum") / seg["_promo_n"], 3) if seg["_promo_n"] else None
)
seg.pop("_promo_n")
seg["avg_seal_rate"] = round(seg.pop("_seal") / n, 3)
seg["top_mainlines"] = _segment_mainlines(
mainline, date.fromisoformat(seg["start"]), date.fromisoformat(seg["end"])
)
return {"segments": segments, "total": len(segments)}
def _segment_mainlines(mainline: pl.DataFrame, start: date, end: date, top: int = 3) -> list[dict]:
"""段内主导主线: 按进入当日 top5 的天数与累计分排序。"""
if mainline.is_empty():
return []
seg = mainline.filter(
(pl.col("date") >= start) & (pl.col("date") <= end) & (pl.col("rank") <= 5)
)
if seg.is_empty():
return []
ranked = (
seg.group_by("member")
.agg(
pl.col("date").n_unique().alias("top5_days"),
pl.col("score").sum().alias("score_sum"),
pl.col("max_boards").max().alias("max_boards"),
pl.col("leader_symbol").first().alias("leader_symbol"),
)
.sort(["top5_days", "score_sum"], descending=[True, True])
.head(top)
)
return [
{
"member": r["member"],
"top5_days": r["top5_days"],
"score_sum": round(r["score_sum"], 1),
"max_boards": r["max_boards"],
"leader_symbol": r["leader_symbol"],
}
for r in ranked.to_dicts()
]
@router.post("/mainline/recompute")
def mainline_recompute(request: Request):
"""全量重算主线(概念+行业), 应用当前过滤配置。窄扫描, 秒级。
修改过滤配置(preferences mainline-filter)后调用本接口生效,
无需触发较重的 regime 全量重算。
"""
from app.services import market_mainline
repo = request.app.state.repo
data_dir = _data_dir(request)
earliest = regime_builder.earliest_enriched_date(repo)
if earliest is None:
return {"ok": True, "rows": 0}
rows = 0
for kind in ("concept", "industry"):
computed = market_mainline.compute_mainline_range(
repo, data_dir, earliest, date.today(), kind=kind
)
if not computed.is_empty():
market_mainline.upsert_mainline_history(data_dir, computed)
rows += computed.height
return {"ok": True, "rows": rows}
@router.get("/mainline")
def regime_mainline(
request: Request,
start: date | None = None,
end: date | None = None,
top: Annotated[int, Query(ge=1, le=30)] = 10,
kind: Annotated[str, Query(pattern="^(concept|industry)$")] = "concept",
):
"""每日主线排行(截 rank<=top) + 窗口内持续性汇总。
membership_note 说明概念成分口径(当前快照回看历史)。
"""
from app.services.market_mainline import MEMBERSHIP_NOTE, load_mainline_history
try:
from app.services import preferences
filter_cfg = preferences.get_mainline_filter_config()
except Exception:
filter_cfg = {"min_members": 4, "max_members": 600, "blacklist": []}
df = load_mainline_history(_data_dir(request), kind)
if df.is_empty():
return {"rows": [], "leaders": [], "membership_note": MEMBERSHIP_NOTE, "filter": filter_cfg}
if start:
df = df.filter(pl_col_date(df, ">=", start))
if end:
df = df.filter(pl_col_date(df, "<=", end))
df = df.sort(["date", "rank"])
rows_df = df.filter(pl.col("rank") <= top)
leaders = (
df.filter(pl.col("rank") == 1)
.group_by("member")
.agg(
pl.col("date").n_unique().alias("top1_days"),
pl.col("score").mean().round(1).alias("avg_score"),
pl.col("max_boards").max().alias("max_boards"),
)
.sort(["top1_days", "avg_score"], descending=[True, True])
.head(10)
)
return {
"rows": _df_to_records(rows_df),
"leaders": leaders.to_dicts(),
"membership_note": MEMBERSHIP_NOTE,
"filter": filter_cfg,
}
+14 -3
View File
@@ -238,6 +238,8 @@ def strategies(
raise HTTPException(status_code=503, detail="策略引擎未初始化")
presets = []
for meta in engine.list_strategies():
if meta.get("research_only"):
continue
if asset_type not in meta.get("asset_types", ["stock"]):
continue
if timeframe not in meta.get("timeframes", ["1d"]):
@@ -292,6 +294,8 @@ def run_preset(req: PresetRequest, request: Request):
try:
if not engine.has(req.strategy_id):
raise ValueError(f"unknown strategy: {req.strategy_id}")
if engine.get(req.strategy_id).meta.get("research_only"):
raise ValueError(f"unknown strategy: {req.strategy_id}")
params = dict(overrides.get("params") or {})
context = svc.build_strategy_context(
engine,
@@ -520,14 +524,19 @@ def run_all(request: Request, body: Optional[dict] = None):
requested_ids = body.get("strategy_ids")
if requested_ids and isinstance(requested_ids, list):
all_ids = [str(sid) for sid in requested_ids]
unknown = [sid for sid in all_ids if not engine.has(sid)]
unknown = [
sid
for sid in all_ids
if not engine.has(sid) or engine.get(sid).meta.get("research_only")
]
if unknown:
raise HTTPException(status_code=404, detail=f"unknown strategies: {unknown}")
else:
all_ids = [
meta["id"]
for meta in engine.list_strategies()
if asset_type in meta.get("asset_types", ["stock"])
if not meta.get("research_only")
and asset_type in meta.get("asset_types", ["stock"])
and timeframe in meta.get("timeframes", ["1d"])
]
@@ -707,7 +716,9 @@ def limit_ladder(
sealed_ready = False
sealed_age: float | None = None
if depth_svc:
sealed_map = depth_svc.get_sealed_map(as_of, is_down=is_down)
# 复用上方双方向计数已读取的 sealed map: 同一请求、同一 as_of、同一对象,
# 不再第三次读取 (内存路径含全量浅拷贝, parquet 路径含整文件读)。
sealed_map = down_map if is_down else up_map
sealed_ready = bool(sealed_map) and depth_svc.is_sealed_ready(as_of)
sealed_age = depth_svc.get_sealed_age(as_of) if sealed_ready else None
+136 -20
View File
@@ -6,9 +6,10 @@ from __future__ import annotations
import logging
import time
from typing import Literal
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from app import secrets_store
from app.data_providers.custom.config import MAX_TIMEOUT
@@ -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
View File
@@ -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
View File
@@ -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",
+376
View File
@@ -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
View File
@@ -26,6 +26,7 @@ from app.backtest.matrix import (
load_market_data_matrix_from_parquet,
)
from app.config import settings
from app.enriched_generation import EnrichedGenerationUnavailableError
from app.parquet import scan_enriched_parquet
from app.tickflow.repository import KlineRepository
@@ -213,8 +214,11 @@ class PanelCache:
columns: list[str] | None,
compute_fn,
asset_type: str = "stock",
generation: str | None = None,
) -> pl.DataFrame:
key = self._make_key(symbols, start, end, columns, asset_type)
key = self._make_key(
symbols, start, end, columns, asset_type, generation
)
now = time.monotonic()
with self._lock:
@@ -280,13 +284,20 @@ class PanelCache:
self._cache.clear()
@staticmethod
def _make_key(symbols: list[str] | None, start: date, end: date, columns: list[str] | None, asset_type: str = "stock") -> str:
def _make_key(
symbols: list[str] | None,
start: date,
end: date,
columns: list[str] | None,
asset_type: str = "stock",
generation: str | None = None,
) -> str:
if symbols is None:
h = "all"
else:
h = hashlib.md5(",".join(sorted(symbols)).encode()).hexdigest()[:12]
cols = "all" if columns is None else hashlib.md5(",".join(sorted(columns)).encode()).hexdigest()[:8]
return f"{asset_type}:{h}:{start}:{end}:{cols}"
return f"{asset_type}:{generation or 'unmanaged'}:{h}:{start}:{end}:{cols}"
# ================================================================
@@ -302,6 +313,23 @@ class BacktestEngine:
# ── 数据加载 ──────────────────────────────────────
def data_generation(self, asset_type: str = "stock") -> str | None:
loader = getattr(self.repo, "get_matrix_data_generation", None)
return loader(asset_type) if callable(loader) else None
def assert_data_generation(
self,
asset_type: str,
expected: str | None,
) -> None:
if expected is None:
return
current = self.data_generation(asset_type)
if current != expected:
raise EnrichedGenerationUnavailableError(
"enriched data changed while the snapshot was being read"
)
def load_panel(
self,
symbols: list[str] | None,
@@ -309,9 +337,36 @@ class BacktestEngine:
end: date,
columns: list[str] | None = None,
asset_type: str = "stock",
*,
expected_generation: str | None = None,
) -> pl.DataFrame:
"""加载 enriched 数据面板,带缓存。asset_type='etf' 时读 ETF enriched。"""
return self._cache.get_or_compute(symbols, start, end, columns, self._load_panel_inner, asset_type=asset_type)
attempts = 1 if expected_generation is not None else 2
for attempt in range(attempts):
generation = (
expected_generation
if expected_generation is not None
else self.data_generation(asset_type)
)
panel = self._cache.get_or_compute(
symbols,
start,
end,
columns,
self._load_panel_inner,
asset_type=asset_type,
generation=generation,
)
try:
self.assert_data_generation(asset_type, generation)
except EnrichedGenerationUnavailableError:
if attempt + 1 >= attempts:
raise
continue
return panel
raise EnrichedGenerationUnavailableError(
"unable to read a stable enriched data snapshot"
)
def load_panel_for_backtest(
self,
@@ -338,6 +393,25 @@ class BacktestEngine:
if df.is_empty():
return df
from app.backtest.fundamentals import (
attach_fundamental_factors,
load_fundamental_snapshot,
)
fundamental_names = sorted(
getattr(feature_plan, "fundamental_columns", frozenset())
or frozenset()
)
if fundamental_names:
# 财务因子列不落 enriched 存储, 在加载口按公告日门控并入。
df = attach_fundamental_factors(
df,
load_fundamental_snapshot(
self.repo.store.data_dir if self.repo is not None else None
),
fundamental_names,
)
instruments = (
self.repo.get_instruments_asset(asset_type)
if self.repo is not None
@@ -402,6 +476,8 @@ class BacktestEngine:
cache_profile: MatrixCacheProfile | None = None,
coverage_start: date | None = None,
coverage_end: date | None = None,
expected_generation: str | None = None,
cancel_event: threading.Event | None = None,
) -> MarketDataMatrix:
"""Load a matrix-native backtest directly from projected parquet batches."""
if feature_plan.execution_backend != "matrix_native":
@@ -432,35 +508,67 @@ class BacktestEngine:
)
generation_loader = getattr(self.repo, "get_matrix_data_generation", None)
source_generation = (
generation_loader(asset_type)
if cache_root is not None and callable(generation_loader)
else None
)
try:
return load_market_data_matrix_from_parquet(
parquet_root,
start,
end,
field_columns=field_columns,
symbols=symbols,
instruments=instruments,
cache_root=cache_root,
coverage_start=coverage_start,
coverage_end=coverage_end,
cache_field_columns=cache_fields,
cache_max_bytes=cache_max_bytes,
profile_generation=(
cache_profile.generation if cache_profile is not None else "request"
),
source_generation=source_generation,
expected_generation
if expected_generation is not None
else (
generation_loader(asset_type)
if callable(generation_loader)
else None
)
except pa.ArrowException as exc:
raise ValueError(f"direct market matrix parquet scan failed: {exc}") from exc
)
attempts = 1 if expected_generation is not None else 2
for attempt in range(attempts):
try:
market = load_market_data_matrix_from_parquet(
parquet_root,
start,
end,
field_columns=field_columns,
symbols=symbols,
instruments=instruments,
cache_root=cache_root,
coverage_start=coverage_start,
coverage_end=coverage_end,
cache_field_columns=cache_fields,
cache_max_bytes=cache_max_bytes,
profile_generation=(
cache_profile.generation if cache_profile is not None else "request"
),
source_generation=source_generation,
cancel_event=cancel_event,
)
self.assert_data_generation(asset_type, source_generation)
from app.backtest.fundamentals import attach_matrix_fundamental_fields
fundamental_names = sorted(
getattr(feature_plan, "fundamental_columns", frozenset())
or frozenset()
)
if fundamental_names:
# 财务因子不落 enriched 存储: 矩阵加载后按公告日门控附加字段。
market = attach_matrix_fundamental_fields(
market,
self.repo.store.data_dir if self.repo is not None else None,
fundamental_names,
)
return market
except EnrichedGenerationUnavailableError:
if attempt + 1 >= attempts:
raise
source_generation = self.data_generation(asset_type)
except pa.ArrowException as exc:
raise ValueError(f"direct market matrix parquet scan failed: {exc}") from exc
raise EnrichedGenerationUnavailableError(
"unable to read a stable enriched matrix snapshot"
)
def cache_stats(self) -> dict:
"""暴露 PanelCache 遥测快照 (扫盘耗时/次数/命中/复用), 供上层量化 IO 占比。"""
return self._cache.stats()
def clear_panel_cache(self) -> None:
self._cache.invalidate()
def _load_panel_inner(
self,
symbols: list[str] | None,
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
"""财务因子: 基于本地财务快照的点时 (point-in-time) 无未来函数接入。
数据契约:
- 输入为 data/financials/metrics/part.parquet, 每行一份报告期指标;
- ``announce_date`` 是公告日因子只在 **严格晚于公告日的交易日** 才有值
(公告多在盘后发布, 保守取 T+1 生效), 此前保持 null;
- 财报历史按 (symbol, period_end) 累积 ( services/financial_sync.py),
同一期以最新公告为准;
- 无财务数据的标的/日期一律为 null, 绝不填 0 ( 0 会污染截面排名,
例如资产负债率 0 会被当成最优杠杆)下游 IC/分层/评分对 null 自动剔除
性能:
- 财务表约数千行, join_asof symbol 分组回填, 对百万行面板的代价是
毫秒级; 矩阵路径每个因子只物化一张 float32 TxN 矩阵 (T~900, N~5500
20MB), 且仅在策略/挖掘请求该因子时才构建
"""
from __future__ import annotations
import logging
from pathlib import Path
from types import MappingProxyType
from typing import Any
import numpy as np
import polars as pl
logger = logging.getLogger(__name__)
# 财务因子名 -> (metrics 表列名, 是否需要除以收盘价)
# pb_latest 单列声明为 bps 倒数口径: 因子值 = close / bps。
FUNDAMENTAL_FACTORS: dict[str, dict[str, Any]] = {
"pb_latest": {"column": "bps", "price_ratio": True},
"roe_latest": {"column": "roe", "price_ratio": False},
"gross_margin_latest": {"column": "gross_margin", "price_ratio": False},
"net_margin_latest": {"column": "net_margin", "price_ratio": False},
"revenue_yoy_latest": {"column": "revenue_yoy", "price_ratio": False},
"net_income_yoy_latest": {"column": "net_income_yoy", "price_ratio": False},
"debt_ratio_latest": {"column": "debt_to_asset_ratio", "price_ratio": False},
}
FUNDAMENTAL_FACTOR_NAMES = frozenset(FUNDAMENTAL_FACTORS)
def load_fundamental_snapshot(data_dir: Path | None) -> pl.DataFrame | None:
"""读取财务指标快照; 文件缺失或无有效行时返回 None。
返回列: symbol, _announce (Date), 以及各因子对应的 metrics
"""
if data_dir is None:
return None
path = data_dir / "financials" / "metrics" / "part.parquet"
if not path.exists():
return None
try:
frame = pl.read_parquet(path)
except Exception as exc:
logger.warning("读取财务指标快照失败: %s", exc)
return None
needed = {"symbol", "announce_date"} | {
spec["column"] for spec in FUNDAMENTAL_FACTORS.values()
}
if not needed.issubset(frame.columns):
logger.warning("财务指标快照缺少列: %s", sorted(needed - set(frame.columns)))
return None
snapshot = (
frame.select(sorted(needed))
.filter(
pl.col("symbol").is_not_null()
& pl.col("announce_date").is_not_null()
)
.with_columns(
pl.col("announce_date").cast(pl.Utf8).str.slice(0, 10).str.to_date().alias("_announce")
)
.sort(["symbol", "_announce"])
)
if snapshot.is_empty():
return None
return snapshot
def attach_fundamental_factors(
panel: pl.DataFrame,
snapshot: pl.DataFrame | None,
names: Any,
) -> pl.DataFrame:
"""把财务因子列按公告日门控地并入日频面板。
- snapshot None (本地无财务数据): 产出全 null , 保持面板形状,
由上层决定是否报"无财务数据"错误;
- 面板必须已按 (symbol, date) 排序 (存储与挖掘路径均满足)
"""
requested = [str(name) for name in names if str(name) in FUNDAMENTAL_FACTOR_NAMES]
missing_columns = [name for name in requested if name not in panel.columns]
if not missing_columns:
return panel
if snapshot is None:
return panel.with_columns([
pl.lit(None, dtype=pl.Float64).alias(name)
for name in missing_columns
])
columns = sorted(
{FUNDAMENTAL_FACTORS[name]["column"] for name in missing_columns}
)
right = snapshot.select(["symbol", "_announce", *columns]).sort(["symbol", "_announce"])
joined = panel.join_asof(
right,
left_on="date",
right_on="_announce",
by="symbol",
strategy="backward",
check_sortedness=False, # 双侧均已按 (symbol, key) 排序, 免除逐组检查开销
)
announced = pl.col("_announce").is_not_null() & (pl.col("date") > pl.col("_announce"))
expressions = []
for name in missing_columns:
spec = FUNDAMENTAL_FACTORS[name]
source = pl.col(spec["column"])
if spec["price_ratio"]:
value = (
pl.when(source > 0)
.then(pl.col("close") / source)
.otherwise(None)
)
else:
value = source
expressions.append(
pl.when(announced).then(value).otherwise(None).alias(name)
)
return joined.with_columns(expressions)
def build_fundamental_matrices(
market: Any,
snapshot: pl.DataFrame | None,
names: Any,
) -> dict[str, np.ndarray]:
"""为 MarketDataMatrix 构建财务因子 TxN float32 字段。
attach_fundamental_factors 同一口径: 公告日次一交易日起前向填充,
无数据为 NaNpb 类因子在矩阵侧用 close / bps 现算
"""
requested = [str(name) for name in names if str(name) in FUNDAMENTAL_FACTOR_NAMES]
if not requested:
return {}
shape = market.shape
result: dict[str, np.ndarray] = {}
if snapshot is None:
for name in requested:
result[name] = np.full(shape, np.nan, dtype=np.float32)
return result
asset_index = {symbol: index for index, symbol in enumerate(market.symbols)}
labels = market.timestamp_labels
label_dates = np.array([label[:10] for label in labels], dtype="datetime64[D]")
raw_columns = {
FUNDAMENTAL_FACTORS[name]["column"]: np.full(shape, np.nan, dtype=np.float32)
for name in requested
}
announce_text = snapshot["announce_date"].str.slice(0, 10)
for row_index, symbol in enumerate(snapshot["symbol"].to_list()):
column_index = asset_index.get(symbol)
if column_index is None:
continue
announce = announce_text[row_index]
if announce is None:
continue
# 公告日之后 (严格大于) 的首个时间行索引
start = int(np.searchsorted(label_dates, np.datetime64(announce, "D"), side="right"))
if start >= shape[0]:
continue
for column, target in raw_columns.items():
value = snapshot[column][row_index]
if value is None or not np.isfinite(float(value)):
continue
target[start:, column_index] = float(value)
for name in requested:
spec = FUNDAMENTAL_FACTORS[name]
source = raw_columns[spec["column"]]
if spec["price_ratio"]:
with np.errstate(divide="ignore", invalid="ignore"):
matrix = (market.close / source).astype(np.float32)
matrix[~(source > 0)] = np.nan
matrix[np.isinf(matrix)] = np.nan
else:
matrix = source
result[name] = matrix
return result
def attach_matrix_fundamental_fields(market: Any, data_dir: Path | None, names: Any) -> Any:
"""把财务因子作为 matrix fields 附加到 (frozen) MarketDataMatrix 副本。"""
import dataclasses
requested = [str(name) for name in names if str(name) in FUNDAMENTAL_FACTOR_NAMES]
if not requested:
return market
snapshot = load_fundamental_snapshot(data_dir)
extra = build_fundamental_matrices(market, snapshot, requested)
if not extra:
return market
merged = {**dict(market.fields), **extra}
for array in extra.values():
array.flags.writeable = False
return dataclasses.replace(market, fields=MappingProxyType(merged))
+316 -50
View File
@@ -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
+166
View File
@@ -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
+168 -66
View File
@@ -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)
+29 -1
View File
@@ -22,6 +22,9 @@ class BacktestWorkerError(RuntimeError):
"""Raised when a spawned worker fails before returning a task result."""
_CANCEL_GRACE_SECONDS = 5.0
class _PeakRssSampler:
"""Track whole-task and resettable phase RSS peaks with one sampling thread."""
@@ -136,6 +139,10 @@ def make_worker_task(kind: str, data_dir: Path, config) -> dict[str, Any]:
encoded = asdict(config)
encoded["start"] = config.start.isoformat()
encoded["end"] = config.end.isoformat()
elif kind == "mining":
if not isinstance(config, dict):
raise TypeError("mining worker config must be a dict")
encoded = dict(config)
else:
raise ValueError(f"unsupported worker task kind: {kind}")
return {
@@ -165,8 +172,8 @@ def _worker_entry(task: dict[str, Any], event_queue, cancel_event) -> None:
from app.backtest.engine import BacktestEngine
from app.backtest.optimizer import StrategyOptimizer
from app.backtest.strategy import StrategyBacktestService
from app.strategy.engine import StrategyEngine
from app.strategy import config as strategy_config
from app.strategy.engine import StrategyEngine
from app.tickflow.repository import DataStore, KlineRepository
data_dir = Path(task["data_dir"])
@@ -201,6 +208,18 @@ def _worker_entry(task: dict[str, Any], event_queue, cancel_event) -> None:
optimizer = StrategyOptimizer(service, strategy_engine)
walkforward = WalkForwardService(optimizer, service, strategy_engine)
result = walkforward.run(config, _progress, cancel_event)
elif kind == "mining":
from app.backtest.mining_runtime import run_mining_runtime
result = run_mining_runtime(
task["config"],
data_dir=data_dir,
service=service,
strategy_engine=strategy_engine,
progress_cb=_progress,
cancel_check=cancel_event,
rss_sampler=sampler,
)
else:
raise ValueError(f"unsupported worker task kind: {kind}")
@@ -261,11 +280,20 @@ def run_worker_task(
result: dict[str, Any] | None = None
failure: dict[str, Any] | None = None
ipc_started = time.perf_counter()
cancel_started: float | None = None
try:
while result is None and failure is None:
if cancel_event is not None and cancel_event.is_set():
process_cancel.set()
if cancel_started is None:
cancel_started = time.monotonic()
elif time.monotonic() - cancel_started >= _CANCEL_GRACE_SECONDS:
process.terminate()
process.join(timeout=5.0)
raise BacktestWorkerError(
"backtest worker did not stop within 5 seconds after cancellation"
)
try:
message = events.get(timeout=0.1)
except queue.Empty:
+8 -1
View File
@@ -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",
)
+5
View File
@@ -0,0 +1,5 @@
# 后端二次开发目录
在本目录新增普通 `.py` 模块即可由应用自动发现,无需修改 `app/main.py`。以下划线开头的模块不会加载。
以 [`_template.py.example`](_template.py.example) 为起点,并遵循 [`docs/secondary-development.md`](../../../docs/secondary-development.md)。模板文件不会参与运行。
+4
View File
@@ -0,0 +1,4 @@
"""In-repository backend secondary-development modules.
Copy ``_template.py.example`` to a non-underscore ``.py`` module to enable it.
"""
+38
View File
@@ -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
+323
View File
@@ -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
+16
View File
@@ -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",
]
+52
View File
@@ -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 "")
+111
View File
@@ -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,
)
+134
View File
@@ -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}")
+286 -11
View File
@@ -22,6 +22,10 @@ from pathlib import Path
import polars as pl
from app.config import settings
from app.enriched_generation import (
EnrichedPublication,
enriched_publication_incomplete,
)
from app.market_time import cn_today
from app.parquet import scan_daily_parquet, scan_enriched_parquet, scan_parquet_compat
from app.price_limits import (
@@ -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"),
+78 -4
View File
@@ -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 跟踪,确保同步历史有记录。
单飞: 若已有活跃(pendingrunning)任务(手动同步中), 本次调度直接跳过, 不并发
重任务执行槽: 再挡一层僵尸并发(reap 后线程仍活时不得并行写 parquet)
返回 True 仅表示任务已成功并且执行槽已释放
"""
from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot
job_id, is_new = job_store.create()
if not is_new:
logger.info("scheduled %s 跳过: 已有活跃任务在运行 (job_id=%s)", job_label, job_id)
return
return False
if not try_acquire_run_slot():
logger.warning("scheduled %s 跳过: 重任务执行槽被占用(疑似上次任务卡死)", job_label)
job_store.fail(job_id, f"scheduled {job_label} skipped: 已有数据任务在运行")
return
return False
def progress(stage: str, pct: int, msg: str, stage_pct: int | None = None,
skip_log: bool = False) -> None:
job_store.progress(job_id, stage, pct, msg, stage_pct=stage_pct, skip_log=skip_log)
succeeded = False
try:
job_store.start(job_id)
result = fn(on_progress=progress)
job_store.succeed(job_id, result)
succeeded = True
logger.info("scheduled %s completed: job_id=%s", job_label, job_id)
except Exception:
logger.exception("scheduled %s failed: job_id=%s", job_label, job_id)
job_store.fail(job_id, f"scheduled {job_label} failed")
finally:
release_run_slot()
return succeeded
def _scheduled_pipeline_task(pipeline_fn) -> None:
"""Run weekly mining only after the tracked daily pipeline has fully succeeded."""
if not _run_tracked(pipeline_fn, "daily_pipeline"):
return
try:
from app.services.mining_schedule import run_weekly_mining
result = run_weekly_mining(_get_app_state())
logger.info("scheduled mining result: %s", result)
except Exception:
logger.exception("scheduled mining enqueue failed; daily pipeline remains succeeded")
# ================================================================
@@ -916,7 +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
View File
@@ -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;
+1 -1
View File
@@ -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
+225
View File
@@ -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],
}
+141 -59
View File
@@ -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()
+9 -1
View File
@@ -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)
+8 -1
View File
@@ -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:
+52 -3
View File
@@ -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)
+26
View File
@@ -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)
+8 -1
View File
@@ -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)
+34 -29
View File
@@ -154,7 +154,7 @@ def _sync_table(
)
def _merge_share_history(*frames: pl.DataFrame) -> pl.DataFrame:
def _merge_report_history(*frames: pl.DataFrame) -> pl.DataFrame:
valid = [
frame
for frame in frames
@@ -162,65 +162,74 @@ def _merge_share_history(*frames: pl.DataFrame) -> pl.DataFrame:
]
if not valid:
return pl.DataFrame()
return (
merged = (
pl.concat(valid, how="diagonal_relaxed")
.filter(pl.col("symbol").is_not_null() & pl.col("period_end").is_not_null())
.unique(subset=["symbol", "period_end"], keep="last")
.sort(["symbol", "period_end"])
)
# 同一 (symbol, period_end) 多条时保留 announce_date 最新一条 (业绩修正以最新公告为准)。
if "announce_date" in merged.columns:
merged = merged.sort(["symbol", "period_end", "announce_date"], nulls_last=True)
return merged.unique(subset=["symbol", "period_end"], keep="last").sort(
["symbol", "period_end"]
)
def _sync_shares_for_symbols(
def _sync_history_table_for_symbols(
table: str,
symbols: list[str],
data_dir: Path,
capset: CapabilitySet,
) -> int:
"""首次拉全量股本历史,后续更新最新记录并补齐新增标的历史。"""
existing = get_financial_df(data_dir, "shares")
"""历史累积同步: 保留已有各期记录, 仅拉最新期 + 为新标的补全量历史。
shares 同一模式若改为 latest_only 全量覆盖, 历史各期会在每次同步时
被冲掉, 财务因子将永远只有单期快照, 任何回测都是未来函数
"""
existing = get_financial_df(data_dir, table)
if existing.is_empty() or not {"symbol", "period_end"} <= set(existing.columns):
return _sync_table("shares", symbols, data_dir, capset, latest_only=False)
return _sync_table(table, symbols, data_dir, capset, latest_only=False)
existing_symbols = set(existing["symbol"].drop_nulls().to_list())
missing_symbols = [symbol for symbol in symbols if symbol not in existing_symbols]
missing_history = (
_fetch_table("shares", missing_symbols, capset, latest_only=False)
_fetch_table(table, missing_symbols, capset, latest_only=False)
if missing_symbols
else pl.DataFrame()
)
current_symbols = [symbol for symbol in symbols if symbol in existing_symbols]
latest = _fetch_table("shares", current_symbols, capset, latest_only=True)
merged = _merge_share_history(existing, missing_history, latest)
return _write_table("shares", merged, data_dir)
latest = _fetch_table(table, current_symbols, capset, latest_only=True)
merged = _merge_report_history(existing, missing_history, latest)
return _write_table(table, merged, data_dir)
def sync_metrics(data_dir: Path, capset: CapabilitySet) -> int:
"""同步核心财务指标 (metrics)。"""
"""同步核心财务指标 (metrics), 历史各期累积保留"""
symbols = _get_symbols(data_dir)
return _sync_table("metrics", symbols, data_dir, capset, latest_only=True)
return _sync_history_table_for_symbols("metrics", symbols, data_dir, capset)
def sync_income(data_dir: Path, capset: CapabilitySet) -> int:
"""同步利润表。"""
"""同步利润表, 历史各期累积保留"""
symbols = _get_symbols(data_dir)
return _sync_table("income", symbols, data_dir, capset, latest_only=True)
return _sync_history_table_for_symbols("income", symbols, data_dir, capset)
def sync_balance_sheet(data_dir: Path, capset: CapabilitySet) -> int:
"""同步资产负债表。"""
"""同步资产负债表, 历史各期累积保留"""
symbols = _get_symbols(data_dir)
return _sync_table("balance_sheet", symbols, data_dir, capset, latest_only=True)
return _sync_history_table_for_symbols("balance_sheet", symbols, data_dir, capset)
def sync_cash_flow(data_dir: Path, capset: CapabilitySet) -> int:
"""同步现金流量表。"""
"""同步现金流量表, 历史各期累积保留"""
symbols = _get_symbols(data_dir)
return _sync_table("cash_flow", symbols, data_dir, capset, latest_only=True)
return _sync_history_table_for_symbols("cash_flow", symbols, data_dir, capset)
def sync_shares(data_dir: Path, capset: CapabilitySet) -> int:
"""同步历史股本表。"""
symbols = _get_symbols(data_dir)
return _sync_shares_for_symbols(symbols, data_dir, capset)
return _sync_history_table_for_symbols("shares", symbols, data_dir, capset)
def sync_all(data_dir: Path, capset: CapabilitySet) -> dict[str, int]:
@@ -232,10 +241,8 @@ def sync_all(data_dir: Path, capset: CapabilitySet) -> dict[str, int]:
symbols = _get_symbols(data_dir)
results: dict[str, int] = {}
for table in FINANCIAL_TABLES:
results[table] = (
_sync_shares_for_symbols(symbols, data_dir, capset)
if table == "shares"
else _sync_table(table, symbols, data_dir, capset, latest_only=True)
results[table] = _sync_history_table_for_symbols(
table, symbols, data_dir, capset
)
# 同步完成后注册 DuckDB 视图
@@ -428,10 +435,8 @@ class FinancialScheduler:
symbols = _get_symbols(self._data_dir)
result: dict[str, int] = {}
for t in FINANCIAL_TABLES:
result[t] = (
_sync_shares_for_symbols(symbols, self._data_dir, self._capset)
if t == "shares"
else _sync_table(t, symbols, self._data_dir, self._capset, latest_only=True)
result[t] = _sync_history_table_for_symbols(
t, symbols, self._data_dir, self._capset
)
self._record_sync(t)
_refresh_financials_views(self._data_dir)
+121
View File
@@ -0,0 +1,121 @@
"""Weighted process-local limiter for memory-heavy jobs."""
from __future__ import annotations
import threading
import time
from collections.abc import Iterator
from contextlib import contextmanager
from typing import ClassVar, Literal
HeavyJobKind = Literal["normal", "mining"]
class HeavyJobLimitTimeoutError(TimeoutError):
"""Raised when a heavy-job slot cannot be acquired before its deadline."""
class HeavyJobCancelledError(RuntimeError):
"""Raised when slot acquisition is cancelled while waiting."""
class HeavyJobLimiter:
"""A weighted limiter where normal jobs cost one slot and mining costs two."""
_WEIGHTS: ClassVar[dict[HeavyJobKind, int]] = {"normal": 1, "mining": 2}
def __init__(self, capacity: int = 2, *, cancel_poll_interval: float = 0.05) -> None:
if capacity <= 0:
raise ValueError("capacity must be positive")
if cancel_poll_interval <= 0:
raise ValueError("cancel_poll_interval must be positive")
self.capacity = capacity
self._cancel_poll_interval = cancel_poll_interval
self._used = 0
self._acquired = {"normal": 0, "mining": 0}
self._condition = threading.Condition()
@property
def in_use(self) -> int:
with self._condition:
return self._used
@property
def available(self) -> int:
with self._condition:
return self.capacity - self._used
def acquire(
self,
kind: HeavyJobKind = "normal",
*,
timeout: float | None = None,
cancel_event: threading.Event | None = None,
) -> bool:
"""Wait for capacity and return ``False`` on cancellation or timeout."""
weight = self._weight(kind)
if weight > self.capacity:
raise ValueError(f"{kind} requires {weight} slots, capacity is {self.capacity}")
if timeout is not None and timeout < 0:
raise ValueError("timeout must be non-negative")
deadline = None if timeout is None else time.monotonic() + timeout
with self._condition:
while True:
if cancel_event is not None and cancel_event.is_set():
return False
if self._used + weight <= self.capacity:
self._used += weight
self._acquired[kind] += 1
return True
remaining = None if deadline is None else deadline - time.monotonic()
if remaining is not None and remaining <= 0:
return False
wait_for = remaining
if cancel_event is not None:
wait_for = self._cancel_poll_interval
if remaining is not None:
wait_for = min(wait_for, remaining)
self._condition.wait(wait_for)
def release(self, kind: HeavyJobKind = "normal") -> None:
"""Return capacity previously acquired for ``kind``."""
weight = self._weight(kind)
with self._condition:
if self._acquired[kind] == 0:
raise RuntimeError(f"cannot release unacquired {kind} capacity")
self._acquired[kind] -= 1
self._used -= weight
self._condition.notify_all()
@contextmanager
def slot(
self,
kind: HeavyJobKind = "normal",
*,
timeout: float | None = None,
cancel_event: threading.Event | None = None,
) -> Iterator[HeavyJobLimiter]:
"""Acquire weighted capacity for the duration of a ``with`` block."""
acquired = self.acquire(kind, timeout=timeout, cancel_event=cancel_event)
if not acquired:
if cancel_event is not None and cancel_event.is_set():
raise HeavyJobCancelledError(f"{kind} job was cancelled while waiting")
raise HeavyJobLimitTimeoutError(f"timed out waiting for {kind} job capacity")
try:
yield self
finally:
self.release(kind)
@classmethod
def _weight(cls, kind: HeavyJobKind) -> int:
try:
return cls._WEIGHTS[kind]
except KeyError as exc:
raise ValueError(f"unsupported heavy job kind: {kind!r}") from exc
shared_heavy_job_limiter = HeavyJobLimiter(capacity=2)
# Short alias for entry points that prefer the existing module-singleton naming style.
heavy_job_limiter = shared_heavy_job_limiter
+20 -6
View File
@@ -15,7 +15,7 @@ import polars as pl
from app.data_providers.base import AssetType
from app.indicators.pipeline import filter_halt_days
from app.market_time import cn_now
from app.market_time import CN_TZ, cn_now, cn_today
from app.services import preferences
from app.tickflow.capabilities import Cap, CapabilitySet
from app.tickflow.client import get_client
@@ -266,7 +266,9 @@ def sync_daily_by_quotes(repo: KlineRepository) -> int:
if df.is_empty():
return 0
today = _date.today()
# 分区日期用北京交易日 (与 quote_service._build_daily 的 cn_today 一致),
# 避免 UTC 服务器在盘中把日分区写成服务器本地日期。
today = cn_today()
daily_df = df.with_columns(pl.lit(today).cast(pl.Date).alias("date"))
# 过滤停牌 (open/high 为 0; close 可能被填充为前收盘价, 不能用全零判断)
@@ -833,8 +835,10 @@ def fetch_minute_single(
) -> pl.DataFrame:
"""实时拉取单股单日分钟 K(不写入本地)。优先自定义分钟源, 回退 TickFlow。"""
from datetime import datetime
start_time = datetime(trade_date.year, trade_date.month, trade_date.day, 9, 25, 0)
end_time = datetime(trade_date.year, trade_date.month, trade_date.day, 15, 5, 0)
# 北京时间窗口必须带时区: naive datetime 会被 .timestamp() 按服务器本地时区解释,
# UTC 容器上窗口整体偏移 8 小时, 分时补拉必然为空。
start_time = datetime(trade_date.year, trade_date.month, trade_date.day, 9, 25, 0, tzinfo=CN_TZ)
end_time = datetime(trade_date.year, trade_date.month, trade_date.day, 15, 5, 0, tzinfo=CN_TZ)
# 自定义数据源分流: 与 sync_minute_batch 一致, 配了自定义分钟源时走 custom provider,
# 避免无 TickFlow Pro+ 权限的用户分时图首次打开(本地无数据)时补拉失败返回空。
@@ -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(
+294
View File
@@ -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
+255
View File
@@ -0,0 +1,255 @@
"""市场情绪周期阶段(冰点/启动/主升/高潮/退潮/修复) — 纯函数模块。
regime_builder 5 state(强势/偏强/震荡/偏弱/弱势)并存:
- state: 综合情绪分(赚钱/投机/抗跌/趋势 4 维加权), 回测环境过滤与挖掘在用, 不动
- phase: 基于"连板梯队"的阶段(用户判据: 高度宽度晋级率梯队完整度),
刻画情绪周期位置(冰点启动主升高潮退潮), 供市场环境页分析与主线识别
驱动量(全部可从已存储的 consecutive_limit_ups 派生, 2020-08 起全历史可回算):
- height 高度: 当日最高连板数
- first_board 首板宽度: 首板(1 连板)家数
- ge2/ge3/ge5 宽度: N 板以上家数
- promo 晋级率: 昨日连板池今日继续封板的比例 ( <10 家记 null)
- seal_rate 封板率: regime 已有列
- ladder_completeness 梯队完整度: 2..height 档位中非空占比
阈值标定: 2020-08~2026-08 全市场 1454 个交易日的 p10/p60/p90 分位数
(标定脚本一次性运行, 不提交); 关键异常段抽查(2024-09/10 rallyclimaxebb,
2024-01/02 微盘退潮)人工核过归属阈值集中在下方, 调整只需改这里
"""
from __future__ import annotations
import logging
import polars as pl
logger = logging.getLogger(__name__)
# ───────────────────────── 阶段词汇 ─────────────────────────
PHASE_ICE = "ice"
PHASE_IGNITE = "ignite"
PHASE_RALLY = "rally"
PHASE_CLIMAX = "climax"
PHASE_EBB = "ebb"
PHASE_REPAIR = "repair"
PHASE_LABELS = {
PHASE_ICE: "冰点",
PHASE_IGNITE: "启动",
PHASE_RALLY: "主升",
PHASE_CLIMAX: "高潮",
PHASE_EBB: "退潮",
PHASE_REPAIR: "修复",
}
# 规则判定优先级: 高潮 > 主升 > 退潮 > 启动 > 冰点 > 修复(兜底)
_PHASE_PRIORITY = (PHASE_CLIMAX, PHASE_RALLY, PHASE_EBB, PHASE_IGNITE, PHASE_ICE)
# ───────────────────────── 阈值(标定自 2020-08~2026-08 分位数) ─────────────────────────
# 高潮: 情绪极端宣泄 — 二板以上宽度或首板数达到 p90 的 ~2 倍以上(历史 <2% 天数)
CLIMAX_GE2 = 50 # p90(25) 的 2 倍
CLIMAX_FIRST_BOARD = 220 # p90(88) 的 2.5 倍
# 主升: 高度/宽度/晋级率同时高于中位 (p60), 或晋级率极强 (p85+)
RALLY_HEIGHT = 7 # p60
RALLY_GE2 = 15 # p60
RALLY_PROMO = 0.23 # p60
RALLY_PROMO_ALT = 0.30 # p85+
RALLY_GE2_ALT = 12
RALLY_HEIGHT_ALT = 5
# 退潮: 晋级率崩至 p20 以下且宽度自近期高位回落; 或晋级率/封板率双弱
EBB_PROMO = 0.15 # p20
EBB_PROMO_STRICT = 0.13
EBB_SEAL = 0.57 # ~p10-p15
EBB_RECENT_GE2 = 12 # 5 日前 ge2 高于此才认定"自高位退潮"
EBB_RECENT_HEIGHT = 6
# 启动: 宽度/高度自低位扩张且晋级率恢复
IGNITE_GE2_DELTA = 3 # ge2 较 5 日前增加量
IGNITE_GE2 = 8 # p20-p40
IGNITE_PROMO = 0.20 # ~p55
IGNITE_HEIGHT_DELTA = 1 # height 较 5 日前抬升
IGNITE_HEIGHT = 5 # p40
IGNITE_PROMO_SOFT = 0.19
# 冰点: 高度/宽度/首板同时贴地 (p10)
ICE_HEIGHT = 4 # p10-p20
ICE_GE2 = 6 # p10
ICE_FIRST_BOARD = 24 # p10
# 晋级率最小池(家数), 低于此记 null(小样本噪声)
PROMO_MIN_POOL = 10
# 平滑与持续性: EMA alpha≈1/3 (约 5 日), 阶段切换需连续 CONFIRM_DAYS 日同标签
_EMA_ALPHA = 1.0 / 3.0
_CONFIRM_DAYS = 2
# 大盘弱档否决: 正面阶段(主升/高潮/启动)不允许出现在 5 档 state 为弱势/偏弱的日子。
# 涨停梯队可能与大盘背离(如 2024-01 微盘崩期间中字头涨停生态走强), 该否决
# 保证"主升"标签在大盘层面也成立; state 列缺失时(单元测试)不启用否决。
_POSITIVE_PHASES = frozenset({PHASE_CLIMAX, PHASE_RALLY, PHASE_IGNITE})
_VETO_STATES = frozenset({"weak", "lean_weak"})
def with_prev_consecutive(df: pl.DataFrame) -> pl.DataFrame:
"""按 symbol 追加昨日连板数列 _prev_consec (供晋级率)。
df 需含 symbol/date/consecutive_limit_ups; 输入应覆盖前一交易日
(调用方保证 warmup 或直接传全量), 每个符号首行 _prev_consec null
"""
if "_prev_consec" in df.columns:
return df
return (
df.sort(["symbol", "date"])
.with_columns(
pl.col("consecutive_limit_ups").shift(1).over("symbol").alias("_prev_consec")
)
)
def ladder_daily_aggs() -> list[pl.Expr]:
"""group_by("date").agg(...) 可直接拼接的梯队聚合表达式。
要求 df consecutive_limit_ups; _prev_consec 时附带晋级率分子/分母
"""
consec = pl.col("consecutive_limit_ups")
exprs = [
consec.eq(1).sum().alias("first_board"),
consec.ge(2).sum().alias("ge2_count"),
consec.ge(3).sum().alias("ge3_count"),
consec.ge(5).sum().alias("ge5_count"),
consec.filter(consec.ge(2)).n_unique().alias("rungs_filled"),
]
return exprs
def ladder_promo_aggs() -> list[pl.Expr]:
"""晋级率聚合(分子/分母); 要求 df 已含 _prev_consec 列。"""
prev = pl.col("_prev_consec")
consec = pl.col("consecutive_limit_ups")
return [
prev.ge(1).sum().alias("promo_pool"),
(prev.ge(1) & consec.eq(prev + 1)).sum().alias("promo_ok"),
]
def finalize_ladder_row(r: dict) -> dict:
"""把聚合行的梯队原始值整理为持久化字段(晋级率/ladder_completeness)。"""
height = int(r.get("max_consecutive") or 0)
rungs = int(r.get("rungs_filled") or 0)
completeness = (rungs / (height - 1)) if height >= 3 else 0.0
pool = int(r.get("promo_pool") or 0)
ok = int(r.get("promo_ok") or 0)
promo = (ok / pool) if pool >= PROMO_MIN_POOL else None
return {
"first_board": int(r.get("first_board") or 0),
"ge2_count": int(r.get("ge2_count") or 0),
"ge3_count": int(r.get("ge3_count") or 0),
"ge5_count": int(r.get("ge5_count") or 0),
"ladder_completeness": round(completeness, 4),
"promo_pool": pool,
"promo_rate": round(promo, 4) if promo is not None else None,
}
def _ema(values: list[float], alpha: float = _EMA_ALPHA) -> list[float]:
out: list[float] = []
cur = None
for v in values:
if v is None or v != v: # None 或 NaN
if cur is None:
out.append(None)
continue
out.append(cur) # ffill: 缺失沿用上一平滑值
continue
cur = v if cur is None else cur + alpha * (v - cur)
out.append(cur)
# 前向回填: 序列开头缺失用首个有效值
first_valid = next((i for i, x in enumerate(out) if x is not None), None)
if first_valid is not None:
for i in range(first_valid):
out[i] = out[first_valid]
else:
out = [0.0] * len(values)
return out
def classify_phase_series(daily: pl.DataFrame) -> pl.DataFrame:
"""对完整日序打阶段标签, 追加 phase 列。
输入列: date, max_consecutive, first_board, ge2_count, promo_rate, seal_rate
(promo_rate 允许 null)处理: promo 前向填充 各驱动 EMA 平滑
逐日规则判定(按优先级) 连续 _CONFIRM_DAYS 日同标签才切换(持续性)
"""
required = {"date", "max_consecutive", "first_board", "ge2_count", "promo_rate", "seal_rate"}
missing = required - set(daily.columns)
if missing:
raise ValueError(f"classify_phase_series 缺少列: {sorted(missing)}")
rows = daily.sort("date")
n = rows.height
states = rows["state"].to_list() if "state" in rows.columns else None
height_s = _ema([float(v) if v is not None else None for v in rows["max_consecutive"].to_list()])
first_s = _ema([float(v) if v is not None else None for v in rows["first_board"].to_list()])
ge2_s = _ema([float(v) if v is not None else None for v in rows["ge2_count"].to_list()])
promo_s = _ema([float(v) if v is not None else None for v in rows["promo_rate"].to_list()])
seal_s = _ema([float(v) if v is not None else None for v in rows["seal_rate"].to_list()])
def raw_label(i: int) -> str:
h, fb, g2, pr, sr = height_s[i], first_s[i], ge2_s[i], promo_s[i], seal_s[i]
g2_prev = ge2_s[max(0, i - 5)]
h_prev = height_s[max(0, i - 5)]
# 高潮
if g2 >= CLIMAX_GE2 or fb >= CLIMAX_FIRST_BOARD:
return PHASE_CLIMAX
# 主升
if h >= RALLY_HEIGHT and g2 >= RALLY_GE2 and pr >= RALLY_PROMO:
return PHASE_RALLY
if pr >= RALLY_PROMO_ALT and g2 >= RALLY_GE2_ALT and h >= RALLY_HEIGHT_ALT:
return PHASE_RALLY
# 冰点: 高度/宽度/首板同时贴地 — 优先于退潮(持续死寂的市场是"冰点"
# 而非"自高位退潮"; 退潮的规则 B 不带 from_high 条件, 顺序反了会把
# 长期冰点误标成退潮)
if h <= ICE_HEIGHT and g2 <= ICE_GE2 and fb <= ICE_FIRST_BOARD:
return PHASE_ICE
# 退潮: 自高位回落 + 晋级率坍塌, 或晋级/封板双弱
from_high = g2_prev >= EBB_RECENT_GE2 or h_prev >= EBB_RECENT_HEIGHT
if from_high and (pr <= EBB_PROMO and g2 < g2_prev):
return PHASE_EBB
if pr <= EBB_PROMO_STRICT and sr <= EBB_SEAL:
return PHASE_EBB
# 启动: 自低位扩张
if g2 - g2_prev >= IGNITE_GE2_DELTA and g2 >= IGNITE_GE2 and pr >= IGNITE_PROMO:
return PHASE_IGNITE
if h - h_prev >= IGNITE_HEIGHT_DELTA and h >= IGNITE_HEIGHT and pr >= IGNITE_PROMO_SOFT:
return PHASE_IGNITE
return PHASE_REPAIR
labels: list[str] = []
current = None
pending: str | None = None
pending_run = 0
for i in range(n):
raw = raw_label(i)
if (
states is not None
and raw in _POSITIVE_PHASES
and states[i] in _VETO_STATES
):
raw = PHASE_REPAIR
if current is None:
current = raw
labels.append(raw)
continue
if raw == current:
labels.append(current)
pending, pending_run = None, 0
continue
if raw == pending:
pending_run += 1
else:
pending, pending_run = raw, 1
if pending_run >= _CONFIRM_DAYS:
current = raw
labels.append(current)
pending, pending_run = None, 0
else:
labels.append(current)
return daily.with_columns(
pl.Series("phase", labels, dtype=pl.Utf8).alias("phase")
).sort("date")
+8 -1
View File
@@ -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
+793
View File
@@ -0,0 +1,793 @@
"""Trusted promotion and explicit publication for persisted mining candidates.
Concurrency protection is process-local; V1 remains a single-process service.
"""
from __future__ import annotations
import hashlib
import json
import math
import os
import re
import threading
import uuid
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any
import polars as pl
import pyarrow.parquet as pq
from app.backtest.candidates import CandidateStore
from app.backtest.factor import FACTOR_COLUMNS
from app.backtest.mining import compute_candidate_signature, evaluate_candidate_gate
from app.services.mining_jobs import SUCCESS_RUN_STATUSES, MiningRunStore
from app.strategy.ai_generator import AIStrategyGenerator
from app.strategy.engine import StrategyEngine
_MAX_ARTIFACT_BYTES = 8 * 1024 * 1024
_MAX_ARTIFACT_ROWS = 32
_MAX_UNCOMPRESSED_BYTES = 32 * 1024 * 1024
_MAX_DEFINITION_BYTES = 16 * 1024
_FACTOR_IDS = frozenset(str(item["id"]) for item in FACTOR_COLUMNS)
_FACTOR_DEFINITION_FIELDS = frozenset({"kind", "factor_names", "scoring", "directions"})
_EXISTING_DEFINITION_FIELDS = frozenset({"kind", "strategy_id"})
_BACKLINK_FIELDS = frozenset({"promoted_candidate_id", "published_strategy_id"})
_PUBLISHED_PREFIX = "mined_factor_"
_STRATEGY_ID_PATTERN = re.compile(r"^mined_factor_[A-Za-z0-9_-]{1,49}$")
_ARTIFACT_SCHEMA = {
"signature": pl.String,
"name": pl.String,
"kind": pl.String,
"factor_names_json": pl.String,
"strategy_id": pl.String,
"definition_json": pl.String,
"regime_state": pl.String,
"score": pl.Float64,
"oos_return": pl.Float64,
"oos_sharpe": pl.Float64,
"oos_max_drawdown": pl.Float64,
"oos_positive_fold_ratio": pl.Float64,
"oos_n_trades": pl.Int64,
"confidence": pl.String,
"valid_folds": pl.Int64,
"skipped_folds": pl.Int64,
"promoted_candidate_id": pl.String,
"published_strategy_id": pl.String,
}
_LOCK = threading.RLock()
class MiningCandidateService:
def __init__(
self,
data_dir: Path | str,
run_store: MiningRunStore,
candidate_store: CandidateStore,
strategy_engine: StrategyEngine,
*,
strategy_cache_invalidator: Callable[[Path], None] | None = None,
monitor_state_invalidator: Callable[[], None] | None = None,
) -> None:
self.data_dir = Path(data_dir).resolve()
self.run_store = run_store
self.candidate_store = candidate_store
self.strategy_engine = strategy_engine
if strategy_cache_invalidator is None:
from app.services.strategy_cache import clear_cache
strategy_cache_invalidator = clear_cache
self._strategy_cache_invalidator = strategy_cache_invalidator
self._monitor_state_invalidator = monitor_state_invalidator
def promote(self, run_id: str, signature: str) -> dict[str, Any]:
with _LOCK:
manifest, summary, path, frame, row, definition = self._load_candidate(
run_id, signature
)
kind, name, source_id, config = self._promotion_config(
manifest, summary, row, definition
)
item = self.candidate_store.create_or_get_by_provenance(
origin_run_id=run_id,
candidate_signature=signature,
kind=kind,
name=name,
source_id=source_id,
config=config,
metrics=self._candidate_metrics(row),
data_as_of=_optional_string(summary.get("data_as_of")),
status="pending",
)
if row.get("promoted_candidate_id") != item["id"]:
self._write_backlink(
path,
frame,
signature,
"promoted_candidate_id",
item["id"],
)
return item
def publish(self, run_id: str, signature: str) -> dict[str, Any]:
with _LOCK:
manifest, summary, path, frame, row, definition = self._load_candidate(
run_id, signature
)
gate = evaluate_candidate_gate(
confidence=row.get("confidence"),
valid_folds=row.get("valid_folds"),
positive_fold_ratio=row.get("oos_positive_fold_ratio"),
sharpe=row.get("oos_sharpe"),
max_drawdown=row.get("oos_max_drawdown"),
n_trades=row.get("oos_n_trades"),
)
if not gate.qualified:
raise ValueError(
"candidate does not meet the promotion gate: "
+ "; ".join(gate.reasons)
)
asset_type = self._asset_type(manifest)
if definition["kind"] == "existing_strategy":
published_id = str(definition["strategy_id"])
self._validate_publication_backlink(row, published_id)
self._verify_public_strategy(published_id, asset_type)
if row.get("published_strategy_id") != published_id:
self._write_backlink(
path,
frame,
signature,
"published_strategy_id",
published_id,
)
return {"ok": True, "strategy_id": published_id}
published_id = self._validate_published_id(
_published_strategy_id(run_id, signature)
)
self._validate_publication_backlink(row, published_id)
source = self._render_factor_strategy(
manifest, summary, row, definition, published_id
)
target = self._custom_strategy_path(published_id)
created = self._publish_or_verify_source(
target,
source,
published_id,
run_id,
signature,
asset_type,
)
try:
self._strategy_cache_invalidator(self.data_dir)
if self._monitor_state_invalidator is not None:
self._monitor_state_invalidator()
except Exception as exc:
rollback_error = (
self._rollback_created_source(target, source) if created else None
)
message = f"strategy runtime invalidation failed: {exc}"
if rollback_error is not None:
message += f"; strategy rollback failed: {rollback_error}"
raise RuntimeError(message) from exc
if row.get("published_strategy_id") != published_id:
self._write_backlink(
path,
frame,
signature,
"published_strategy_id",
published_id,
)
return {"ok": True, "strategy_id": published_id}
def _load_candidate(
self,
run_id: str,
signature: str,
) -> tuple[
dict[str, Any],
dict[str, Any],
Path,
pl.DataFrame,
dict[str, Any],
dict[str, Any],
]:
if not isinstance(signature, str) or not signature:
raise ValueError("candidate signature must not be empty")
manifest = self.run_store.get(run_id)
if manifest is None:
raise KeyError(run_id)
if manifest.get("status") not in SUCCESS_RUN_STATUSES:
raise ValueError("mining candidates require a successful run")
path = self._registered_candidates_path(manifest)
frame = self._read_artifact(path)
matches = frame.filter(pl.col("signature") == signature)
if matches.height == 0:
raise KeyError(signature)
if matches.height != 1:
raise ValueError("mining candidates artifact contains a duplicate signature")
row = matches.row(0, named=True)
self._asset_type(manifest)
definition = self._validate_definition(manifest, row, signature)
summary = self._validated_summary(self.run_store.read_summary(run_id))
return manifest, summary, path, frame, row, definition
def _registered_candidates_path(self, manifest: Mapping[str, Any]) -> Path:
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, Mapping):
raise ValueError("mining candidates artifact is not registered")
raw_path = artifacts.get("candidates")
if raw_path != "candidates.parquet":
raise ValueError(
"only the registered candidates.parquet artifact can be used"
)
path = self.run_store.artifact_path(
str(manifest["run_id"]), "candidates"
)
if path.is_symlink() or not path.is_file():
raise ValueError("mining candidates artifact is unavailable")
return path
@staticmethod
def _read_artifact(path: Path) -> pl.DataFrame:
try:
size = path.stat().st_size
except OSError as exc:
raise RuntimeError("failed to read mining candidates artifact") from exc
if size <= 0 or size > _MAX_ARTIFACT_BYTES:
raise ValueError("mining candidates artifact exceeds its size limit")
try:
parquet = pq.ParquetFile(path)
metadata = parquet.metadata
schema = pl.read_parquet_schema(path)
except Exception as exc:
raise RuntimeError("failed to read mining candidates artifact") from exc
if metadata is None or metadata.num_rows > _MAX_ARTIFACT_ROWS:
raise ValueError("mining candidates artifact exceeds its row limit")
uncompressed = sum(
metadata.row_group(index).total_byte_size
for index in range(metadata.num_row_groups)
)
if uncompressed > _MAX_UNCOMPRESSED_BYTES:
raise ValueError(
"mining candidates artifact exceeds its uncompressed size limit"
)
missing = sorted(set(_ARTIFACT_SCHEMA) - set(schema))
if missing:
raise ValueError(f"mining candidates artifact schema is invalid: {missing}")
invalid_types = [
name for name, dtype in _ARTIFACT_SCHEMA.items()
if schema[name] != dtype
]
if invalid_types:
raise ValueError(
"mining candidates artifact column types are invalid: "
f"{invalid_types}"
)
try:
return pl.read_parquet(path, columns=list(_ARTIFACT_SCHEMA))
except Exception as exc:
raise RuntimeError("failed to read mining candidates artifact") from exc
def _validate_definition(
self,
manifest: Mapping[str, Any],
row: Mapping[str, Any],
signature: str,
) -> dict[str, Any]:
raw_definition = row.get("definition_json")
if (
not isinstance(raw_definition, str)
or len(raw_definition.encode("utf-8")) > _MAX_DEFINITION_BYTES
):
raise ValueError("mining candidate definition is unavailable")
try:
definition = json.loads(raw_definition)
except json.JSONDecodeError as exc:
raise ValueError("mining candidate definition is invalid") from exc
if not isinstance(definition, dict):
raise ValueError("mining candidate definition must be an object")
kind = definition.get("kind")
if kind == "factor_rank":
if set(definition) != _FACTOR_DEFINITION_FIELDS:
raise ValueError("factor candidate definition contains unsupported fields")
expected_kind = "factor_combination"
elif kind == "existing_strategy":
if set(definition) != _EXISTING_DEFINITION_FIELDS:
raise ValueError("existing candidate definition contains unsupported fields")
expected_kind = "existing_strategy"
else:
raise ValueError(f"unsupported mining candidate kind: {kind!r}")
if row.get("kind") != expected_kind:
raise ValueError("mining candidate kind does not match its definition")
if (
not isinstance(row.get("name"), str)
or not row["name"]
or len(row["name"]) > 80
or row.get("regime_state") != "overall"
):
raise ValueError("mining candidate row metadata is invalid")
if kind == "factor_rank":
self._validate_factor_definition(manifest, row, definition)
else:
self._validate_existing_definition(manifest, row, definition)
computed = compute_candidate_signature(definition)
if row.get("signature") != signature or computed != signature:
raise ValueError("mining candidate signature does not match its definition")
score = row.get("score")
if (
score is not None
and (
isinstance(score, bool)
or not isinstance(score, (int, float))
or not math.isfinite(float(score))
)
):
raise ValueError("candidate score must be finite")
return definition
@staticmethod
def _request(manifest: Mapping[str, Any]) -> Mapping[str, Any]:
request = manifest.get("request")
if not isinstance(request, Mapping):
raise ValueError("mining origin request is unavailable")
return request
def _validate_factor_definition(
self,
manifest: Mapping[str, Any],
row: Mapping[str, Any],
definition: Mapping[str, Any],
) -> None:
factor_names = definition.get("factor_names")
if (
not isinstance(factor_names, list)
or not 1 <= len(factor_names) <= 4
or len(set(factor_names)) != len(factor_names)
or any(not isinstance(name, str) or not name for name in factor_names)
):
raise ValueError("factor candidate must contain 1 to 4 unique factors")
scoring = definition.get("scoring")
directions = definition.get("directions")
if not isinstance(scoring, Mapping) or set(scoring) != set(factor_names):
raise ValueError("factor scoring keys must exactly match factor names")
if not isinstance(directions, Mapping) or set(directions) != set(factor_names):
raise ValueError("factor direction keys must exactly match factor names")
for factor_name in factor_names:
weight = scoring[factor_name]
if (
isinstance(weight, bool)
or not isinstance(weight, (int, float))
or not math.isfinite(float(weight))
or float(weight) <= 0.0
):
raise ValueError("factor weights must be finite and positive")
if directions[factor_name] not in {"high", "low"}:
raise ValueError("factor directions must be high or low")
unknown = sorted(set(factor_names) - _FACTOR_IDS)
if unknown:
raise ValueError(f"factor candidate contains unknown factors: {unknown}")
selected = self._request(manifest).get("factor_names")
if not isinstance(selected, list) or not set(factor_names) <= set(selected):
raise ValueError("factor candidate contains factors absent from its origin request")
try:
persisted_names = json.loads(str(row.get("factor_names_json")))
except json.JSONDecodeError as exc:
raise ValueError("factor candidate factor list is invalid") from exc
if persisted_names != factor_names or row.get("strategy_id") is not None:
raise ValueError("factor candidate columns do not match its definition")
def _validate_existing_definition(
self,
manifest: Mapping[str, Any],
row: Mapping[str, Any],
definition: Mapping[str, Any],
) -> None:
strategy_id = definition.get("strategy_id")
if not isinstance(strategy_id, str) or not strategy_id:
raise ValueError("existing candidate strategy ID is invalid")
selected = self._request(manifest).get("strategy_ids")
if not isinstance(selected, list) or strategy_id not in selected:
raise ValueError("existing candidate was absent from its origin request")
if row.get("strategy_id") != strategy_id:
raise ValueError("existing candidate strategy ID does not match its definition")
asset_type = str(self._request(manifest).get("asset_type") or "stock")
self._verify_public_strategy(strategy_id, asset_type)
def _promotion_config(
self,
manifest: Mapping[str, Any],
summary: Mapping[str, Any],
row: Mapping[str, Any],
definition: Mapping[str, Any],
) -> tuple[str, str, str, dict[str, Any]]:
request = self._request(manifest)
signature = str(row["signature"])
provenance = {
"origin_run_id": str(manifest["run_id"]),
"candidate_signature": signature,
"regime_state": str(row.get("regime_state") or "overall"),
"algorithm_version": str(summary.get("algorithm_version") or "mining-v1"),
"methodology_version": str(summary.get("methodology_version") or "factor_v2"),
}
common = {
"start": request.get("start"),
"end": request.get("end"),
"asset_type": request.get("asset_type") or "stock",
"matching": "open_t+1",
"entry_fill": "open_t+1",
"exit_fill": "open_t+1",
"commission_pct": request.get("commission_pct", 0.0002),
"stamp_tax_pct": request.get("stamp_tax_pct", 0.0005),
"slippage_bps": request.get("slippage_bps", 5.0),
"mode": "position",
"minute_fill": False,
**provenance,
}
name = str(row.get("name") or signature)[:80]
if definition["kind"] == "existing_strategy":
strategy_id = str(definition["strategy_id"])
return "strategy", name, strategy_id, {
**common,
"strategy_id": strategy_id,
}
factor_names = list(definition["factor_names"])
source_id = _published_strategy_id(str(manifest["run_id"]), signature)
return "strategy", name, source_id, {
**common,
"strategy_id": source_id,
"factor_names": factor_names,
"directions": [definition["directions"][name] for name in factor_names],
"weights": [float(definition["scoring"][name]) for name in factor_names],
}
@staticmethod
def _candidate_metrics(row: Mapping[str, Any]) -> dict[str, Any]:
fields = (
"oos_sharpe",
"oos_return",
"oos_max_drawdown",
"oos_positive_fold_ratio",
"oos_n_trades",
"valid_folds",
"skipped_folds",
"confidence",
)
result: dict[str, Any] = {}
for field in fields:
value = row.get(field)
if value is None:
continue
if field == "confidence":
if not isinstance(value, str) or not value or len(value) > 32:
raise ValueError("candidate confidence is invalid")
elif (
isinstance(value, bool)
or not isinstance(value, (int, float))
or not math.isfinite(float(value))
):
raise ValueError(f"candidate metric {field} must be finite")
result[field] = value
return result
@staticmethod
def _validated_summary(summary: Mapping[str, Any]) -> dict[str, str]:
result: dict[str, str] = {}
for field in ("data_as_of", "algorithm_version", "methodology_version"):
value = summary.get(field)
if not isinstance(value, str) or not value or len(value) > 120:
raise ValueError(f"mining summary {field} is invalid")
result[field] = value
return result
@staticmethod
def _asset_type(manifest: Mapping[str, Any]) -> str:
asset_type = MiningCandidateService._request(manifest).get("asset_type")
if asset_type not in {"stock", "etf"}:
raise ValueError("mining origin asset_type is invalid")
return str(asset_type)
def _verify_public_strategy(
self,
strategy_id: str,
asset_type: str,
*,
path: Path | None = None,
run_id: str | None = None,
signature: str | None = None,
) -> None:
strategy = self.strategy_engine.get(strategy_id)
if strategy.meta.get("research_only"):
raise ValueError("research-only strategy cannot be published")
if strategy.execution_backend != "matrix_native":
raise ValueError("published strategy must be matrix-native")
if "1d" not in strategy.meta.get("timeframes", []):
raise ValueError("published strategy must support 1d")
if asset_type not in strategy.meta.get("asset_types", []):
raise ValueError("published strategy does not support the run asset type")
public_ids = {
str(meta.get("id")) for meta in self.strategy_engine.list_strategies()
}
if strategy_id not in public_ids:
raise ValueError("published strategy is not publicly discoverable")
if path is not None and (
strategy.source != "custom"
or strategy.file_path is None
or strategy.file_path.resolve() != path.resolve()
or strategy.meta.get("origin_run_id") != run_id
or strategy.meta.get("candidate_signature") != signature
):
raise ValueError("published strategy provenance is invalid")
@staticmethod
def _validate_published_id(strategy_id: str) -> str:
if not _STRATEGY_ID_PATTERN.fullmatch(strategy_id):
raise ValueError(
f"strategy_id must use {_PUBLISHED_PREFIX!r} and safe characters"
)
return strategy_id
@staticmethod
def _validate_publication_backlink(
row: Mapping[str, Any],
strategy_id: str,
) -> None:
existing = _optional_string(row.get("published_strategy_id"))
if existing is not None and existing != strategy_id:
raise ValueError("candidate publication backlink is inconsistent")
def _custom_strategy_path(self, strategy_id: str) -> Path:
unresolved_root = self.data_dir / "strategies" / "custom"
unresolved_root.mkdir(parents=True, exist_ok=True)
if unresolved_root.is_symlink():
raise ValueError("custom strategy directory must not be a symlink")
root = unresolved_root.resolve()
if not root.is_relative_to(self.data_dir):
raise ValueError("custom strategy directory escapes data_dir")
path = (root / f"{strategy_id}.py").resolve(strict=False)
if path.parent != root:
raise ValueError("strategy publication path escapes custom directory")
return path
def _render_factor_strategy(
self,
manifest: Mapping[str, Any],
summary: Mapping[str, Any],
row: Mapping[str, Any],
definition: Mapping[str, Any],
strategy_id: str,
) -> str:
asset_type = self._asset_type(manifest)
factor_names = list(definition["factor_names"])
scoring = {
name: float(definition["scoring"][name]) for name in factor_names
}
directions = {name: definition["directions"][name] for name in factor_names}
meta = {
"id": strategy_id,
"name": row["name"],
"description": "Published mining factor-rank candidate",
"tags": ["mining", "factor-rank"],
"asset_types": [asset_type],
"timeframes": ["1d"],
"research_only": False,
"origin_run_id": manifest["run_id"],
"candidate_signature": row["signature"],
"mining_algorithm_version": summary["algorithm_version"],
"factor_methodology_version": summary["methodology_version"],
"params": [
{
"id": "entry_score",
"label": "Entry minimum score",
"type": "float",
"default": 70.0,
"min": 0.0,
"max": 100.0,
"step": 5.0,
},
{
"id": "exit_score",
"label": "Exit maximum score",
"type": "float",
"default": 40.0,
"min": 0.0,
"max": 100.0,
"step": 5.0,
},
{
"id": "top_rank",
"label": "Daily selection limit",
"type": "int",
"default": 20,
"min": 1,
"max": 100,
"step": 1,
},
],
"scoring": {},
"order_by": "score",
"descending": True,
"limit": 100,
}
return (
'"""Trusted factor-rank strategy published from a mining run."""\n'
"from app.strategy.builtin.factor_rank_research import "
"FactorRankResearchMatrixStrategy\n\n"
f"META = {meta!r}\n\n"
'EXECUTION_BACKEND = "matrix_native"\n'
'ENTRY_SIGNALS = ["signal_factor_rank_entry"]\n'
'EXIT_SIGNALS = ["signal_factor_rank_exit"]\n'
"STOP_LOSS = -0.08\n"
"MAX_HOLD_DAYS = 30\n\n"
f"SCORING = {scoring!r}\n"
f"DIRECTIONS = {directions!r}\n"
"MATRIX_STRATEGY = FactorRankResearchMatrixStrategy(SCORING, DIRECTIONS)\n"
)
def _publish_or_verify_source(
self,
path: Path,
source: str,
strategy_id: str,
run_id: str,
signature: str,
asset_type: str,
) -> bool:
validation = AIStrategyGenerator().validate_code(source)
if not validation.get("valid"):
raise ValueError(
f"rendered strategy failed validation: {validation.get('error')}"
)
if validation.get("meta", {}).get("id") != strategy_id:
raise ValueError("rendered strategy META id is invalid")
if path.exists() or path.is_symlink():
self._verify_existing_source(
path, source, strategy_id, run_id, signature, asset_type
)
return False
if self.strategy_engine.has(strategy_id):
raise ValueError(f"strategy ID already exists: {strategy_id}")
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
linked = False
try:
with temporary.open("x", encoding="utf-8", newline="\n") as stream:
stream.write(source)
stream.flush()
os.fsync(stream.fileno())
try:
os.link(temporary, path)
except FileExistsError as exc:
raise ValueError(f"strategy path already exists: {strategy_id}") from exc
except OSError as exc:
raise RuntimeError("failed to create strategy source") from exc
linked = True
self._fsync_directory(path.parent)
try:
self.strategy_engine.reload()
self._verify_public_strategy(
strategy_id,
asset_type,
path=path,
run_id=run_id,
signature=signature,
)
except Exception as exc:
rollback_error = self._rollback_publication(path, temporary)
message = f"strategy publication failed: {exc}"
if rollback_error is not None:
message += f"; registry rollback failed: {rollback_error}"
raise RuntimeError(message) from exc
finally:
temporary.unlink(missing_ok=True)
if linked:
self._fsync_directory(path.parent)
return True
def _verify_existing_source(
self,
path: Path,
source: str,
strategy_id: str,
run_id: str,
signature: str,
asset_type: str,
) -> None:
if path.is_symlink() or not path.is_file():
raise ValueError("strategy publication target is not a regular file")
try:
existing_source = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise ValueError("existing strategy source is unreadable") from exc
if existing_source != source:
raise ValueError(f"strategy ID collision: {strategy_id}")
try:
self._verify_public_strategy(
strategy_id,
asset_type,
path=path,
run_id=run_id,
signature=signature,
)
except ValueError:
self.strategy_engine.reload()
self._verify_public_strategy(
strategy_id,
asset_type,
path=path,
run_id=run_id,
signature=signature,
)
def _rollback_publication(self, path: Path, temporary: Path) -> Exception | None:
try:
if path.exists() and temporary.exists() and os.path.samefile(path, temporary):
path.unlink()
self._fsync_directory(path.parent)
self.strategy_engine.reload()
except Exception as exc:
return exc
return None
def _rollback_created_source(self, path: Path, source: str) -> Exception | None:
try:
if path.is_file() and not path.is_symlink():
if path.read_text(encoding="utf-8") != source:
return RuntimeError("published strategy source changed before rollback")
path.unlink()
self._fsync_directory(path.parent)
self.strategy_engine.reload()
except Exception as exc:
return exc
return None
@staticmethod
def _fsync_directory(path: Path) -> None:
if os.name == "nt":
return
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
@staticmethod
def _write_backlink(
path: Path,
frame: pl.DataFrame,
signature: str,
field: str,
value: str,
) -> None:
if field not in _BACKLINK_FIELDS:
raise ValueError("unsupported mining candidate backlink")
matches = frame.filter(pl.col("signature") == signature)
if matches.height != 1:
raise ValueError("mining candidate backlink target is no longer unique")
updated = frame.with_columns(
pl.when(pl.col("signature") == signature)
.then(pl.lit(value))
.otherwise(pl.col(field))
.alias(field)
)
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
try:
updated.write_parquet(temporary)
with temporary.open("r+b") as stream:
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
except Exception as exc:
temporary.unlink(missing_ok=True)
raise RuntimeError("failed to update mining candidate artifact") from exc
def _published_strategy_id(run_id: str, signature: str) -> str:
payload = f"{run_id}\0{signature}".encode()
digest = hashlib.blake2b(payload, digest_size=10).hexdigest()
return f"{_PUBLISHED_PREFIX}{digest}"
def _optional_string(value: Any) -> str | None:
return value if isinstance(value, str) and value else None
+620
View File
@@ -0,0 +1,620 @@
"""Persistent metadata and bounded event storage for mining runs."""
from __future__ import annotations
import json
import math
import os
import re
import threading
import uuid
from collections.abc import Collection, Mapping
from datetime import UTC, date, datetime
from enum import Enum
from pathlib import Path
from typing import Any, Literal, cast
MiningRunStatus = Literal[
"queued",
"running",
"cancelling",
"succeeded",
"succeeded_with_budget_exhausted",
"failed",
"cancelled",
"interrupted",
"skipped_prerequisite",
]
ArtifactName = Literal["factors", "correlation", "candidates", "folds"]
RUN_STATUSES: frozenset[str] = frozenset(
{
"queued",
"running",
"cancelling",
"succeeded",
"succeeded_with_budget_exhausted",
"failed",
"cancelled",
"interrupted",
"skipped_prerequisite",
}
)
ACTIVE_RUN_STATUSES: frozenset[str] = frozenset({"queued", "running", "cancelling"})
SUCCESS_RUN_STATUSES: frozenset[str] = frozenset({"succeeded", "succeeded_with_budget_exhausted"})
TERMINAL_RUN_STATUSES: frozenset[str] = RUN_STATUSES - ACTIVE_RUN_STATUSES
ARTIFACT_NAMES: frozenset[str] = frozenset({"factors", "correlation", "candidates", "folds"})
MAX_EVENTS = 256
MAX_EVENT_PAYLOAD_BYTES = 16 * 1024
_SCHEMA_VERSION = 1
_RUN_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
_STORE_LOCK = threading.RLock()
_ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = {
"queued": frozenset(
{"running", "cancelling", "cancelled", "failed", "interrupted", "skipped_prerequisite"}
),
"running": frozenset(
{
"cancelling",
"succeeded",
"succeeded_with_budget_exhausted",
"failed",
"cancelled",
"interrupted",
"skipped_prerequisite",
}
),
"cancelling": frozenset(
{
"succeeded",
"succeeded_with_budget_exhausted",
"failed",
"cancelled",
"interrupted",
}
),
"succeeded": frozenset(),
"succeeded_with_budget_exhausted": frozenset(),
"failed": frozenset(),
"cancelled": frozenset(),
"interrupted": frozenset(),
"skipped_prerequisite": frozenset(),
}
class MiningRunStoreError(RuntimeError):
pass
class MiningRunValidationError(MiningRunStoreError, ValueError):
pass
class InvalidMiningStatusTransitionError(MiningRunStoreError):
pass
def canonicalize_request(request: Mapping[str, Any]) -> dict[str, Any]:
"""Return a JSON-safe request whose mapping order cannot affect its signature."""
if not isinstance(request, Mapping):
raise MiningRunValidationError("request must be a mapping")
return cast(dict[str, Any], _canonicalize_json_value(request))
def compute_run_signature(request: Mapping[str, Any], data_fingerprint: Any) -> str:
"""Hash every request dimension and the data fingerprint using BLAKE2b."""
import hashlib
signature_input = {
"request": canonicalize_request(request),
"data_fingerprint": _canonicalize_json_value(data_fingerprint),
}
payload = json.dumps(
signature_input,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.blake2b(payload, digest_size=32).hexdigest()
class MiningRunStore:
"""Store one manifest, summary, artifact registry, and bounded event log per run."""
def __init__(self, data_dir: Path | str | None = None) -> None:
if data_dir is None:
from app.config import settings
data_dir = settings.data_dir
self.runs_root = (Path(data_dir).resolve() / "research" / "mining" / "runs").resolve()
self.runs_root.mkdir(parents=True, exist_ok=True)
def create(
self,
request: Mapping[str, Any],
data_fingerprint: Any,
*,
run_id: str | None = None,
) -> dict[str, Any]:
"""Create a queued run and its initial on-disk files."""
safe_run_id = self._validate_run_id(uuid.uuid4().hex if run_id is None else run_id)
canonical_request = canonicalize_request(request)
canonical_fingerprint = _canonicalize_json_value(data_fingerprint)
now = _now_iso()
manifest = {
"schema_version": _SCHEMA_VERSION,
"run_id": safe_run_id,
"status": "queued",
"request": canonical_request,
"data_fingerprint": canonical_fingerprint,
"run_signature": compute_run_signature(canonical_request, canonical_fingerprint),
"artifacts": {},
"created_at": now,
"updated_at": now,
"started_at": None,
"finished_at": None,
"cancellation_requested_at": None,
"error": None,
}
run_dir = self._run_dir(safe_run_id)
with _STORE_LOCK:
if run_dir.exists():
raise MiningRunValidationError(f"run already exists: {safe_run_id}")
run_dir.mkdir(parents=False)
_atomic_write_json(run_dir / "summary.json", {})
_atomic_write_text(run_dir / "events.jsonl", "")
_atomic_write_json(run_dir / "manifest.json", manifest)
return manifest
def get(self, run_id: str) -> dict[str, Any] | None:
"""Read a manifest, filling defaults for manifests written by older versions."""
safe_run_id = self._validate_run_id(run_id)
with _STORE_LOCK:
return self._read_manifest_path(
self._run_dir(safe_run_id) / "manifest.json", safe_run_id
)
def transition_status(
self,
run_id: str,
status: MiningRunStatus,
*,
error: str | None = None,
) -> dict[str, Any]:
"""Apply a validated state transition and atomically replace the manifest."""
if status not in RUN_STATUSES:
raise MiningRunValidationError(f"unsupported mining run status: {status!r}")
safe_run_id = self._validate_run_id(run_id)
with _STORE_LOCK:
manifest = self._required_manifest(safe_run_id)
return self._transition_locked(manifest, status, error=error)
def write_summary(self, run_id: str, summary: Mapping[str, Any]) -> dict[str, Any]:
"""Atomically replace a run's scalar or compact aggregate summary."""
if not isinstance(summary, Mapping):
raise MiningRunValidationError("summary must be a mapping")
safe_run_id = self._validate_run_id(run_id)
clean_summary = cast(dict[str, Any], _canonicalize_json_value(summary))
with _STORE_LOCK:
self._required_manifest(safe_run_id)
_atomic_write_json(self._run_dir(safe_run_id) / "summary.json", clean_summary)
return clean_summary
def read_summary(self, run_id: str) -> dict[str, Any]:
safe_run_id = self._validate_run_id(run_id)
with _STORE_LOCK:
self._required_manifest(safe_run_id)
path = self._run_dir(safe_run_id) / "summary.json"
if not path.exists():
return {}
value = _read_json(path)
if not isinstance(value, dict):
raise MiningRunStoreError(f"invalid summary for run {safe_run_id}")
return value
def artifact_path(self, run_id: str, name: ArtifactName) -> Path:
"""Return the safe default Parquet path for an artifact."""
safe_run_id = self._validate_run_id(run_id)
self._validate_artifact_name(name)
return self._safe_artifact_path(safe_run_id, Path(f"{name}.parquet"))
def register_artifact(
self,
run_id: str,
name: ArtifactName,
path: Path | str | None = None,
) -> dict[str, Any]:
"""Record a Parquet artifact path relative to its owning run directory."""
safe_run_id = self._validate_run_id(run_id)
self._validate_artifact_name(name)
artifact_path = self._safe_artifact_path(
safe_run_id,
Path(path) if path is not None else Path(f"{name}.parquet"),
)
if artifact_path.suffix.lower() != ".parquet":
raise MiningRunValidationError("mining artifacts must use the .parquet suffix")
run_dir = self._run_dir(safe_run_id)
relative_path = artifact_path.relative_to(run_dir).as_posix()
with _STORE_LOCK:
manifest = self._required_manifest(safe_run_id)
artifacts = dict(manifest.get("artifacts") or {})
artifacts[name] = relative_path
manifest["artifacts"] = artifacts
manifest["updated_at"] = _now_iso()
_atomic_write_json(run_dir / "manifest.json", manifest)
return manifest
def append_event(
self,
run_id: str,
event_type: str,
payload: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Append a compact event, retaining only the most recent ``MAX_EVENTS`` entries."""
safe_run_id = self._validate_run_id(run_id)
if not isinstance(event_type, str):
raise MiningRunValidationError("event_type must be a string")
clean_event_type = event_type.strip()
if not clean_event_type or len(clean_event_type) > 64:
raise MiningRunValidationError("event_type must contain 1 to 64 characters")
raw_payload: Mapping[str, Any] | Any = {} if payload is None else payload
if not isinstance(raw_payload, Mapping):
raise MiningRunValidationError("event payload must be a mapping")
clean_payload = cast(dict[str, Any], _canonicalize_json_value(raw_payload))
encoded_payload = json.dumps(
clean_payload,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
if len(encoded_payload) > MAX_EVENT_PAYLOAD_BYTES:
raise MiningRunValidationError(
f"event payload exceeds {MAX_EVENT_PAYLOAD_BYTES} byte limit"
)
with _STORE_LOCK:
self._required_manifest(safe_run_id)
path = self._run_dir(safe_run_id) / "events.jsonl"
events = self._read_events_path(path)
next_id = max((event["id"] for event in events), default=0) + 1
event = {
"id": next_id,
"timestamp": _now_iso(),
"type": clean_event_type,
"payload": clean_payload,
}
events.append(event)
events = events[-MAX_EVENTS:]
text = "".join(
json.dumps(item, ensure_ascii=False, allow_nan=False, separators=(",", ":")) + "\n"
for item in events
)
_atomic_write_text(path, text)
return event
def read_events(self, run_id: str, *, after_id: int = 0) -> list[dict[str, Any]]:
"""Read retained events whose monotonically increasing ID is greater than ``after_id``."""
if isinstance(after_id, bool) or not isinstance(after_id, int) or after_id < 0:
raise MiningRunValidationError("after_id must be a non-negative integer")
safe_run_id = self._validate_run_id(run_id)
with _STORE_LOCK:
self._required_manifest(safe_run_id)
events = self._read_events_path(self._run_dir(safe_run_id) / "events.jsonl")
return [event for event in events if event["id"] > after_id]
def list_runs(
self,
*,
limit: int = 50,
statuses: Collection[MiningRunStatus] | None = None,
) -> list[dict[str, Any]]:
"""Return recent valid manifests without exposing store paths to API callers."""
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 200:
raise MiningRunValidationError("limit must be between 1 and 200")
allowed_statuses = None if statuses is None else set(statuses)
if allowed_statuses is not None and not allowed_statuses <= RUN_STATUSES:
raise MiningRunValidationError("statuses contains an unsupported mining run status")
try:
paths = list(self.runs_root.glob("*/manifest.json"))
except OSError as exc:
raise MiningRunStoreError("failed to scan mining run manifests") from exc
manifests: list[dict[str, Any]] = []
for path in paths:
run_id = path.parent.name
if not _RUN_ID_PATTERN.fullmatch(run_id):
continue
try:
manifest = self._read_manifest_path(
self._run_dir(run_id) / "manifest.json",
run_id,
)
except MiningRunStoreError:
continue
if manifest is None:
continue
if allowed_statuses is not None and manifest.get("status") not in allowed_statuses:
continue
manifests.append(manifest)
manifests.sort(key=_manifest_sort_key, reverse=True)
return manifests[:limit]
def find_by_signature(
self,
run_signature: str,
*,
statuses: Collection[MiningRunStatus] | None = None,
) -> dict[str, Any] | None:
"""Find the newest run with a signature, optionally restricted to selected statuses."""
if not isinstance(run_signature, str) or not run_signature:
raise MiningRunValidationError("run_signature must not be empty")
allowed_statuses = None if statuses is None else set(statuses)
if allowed_statuses is not None and not allowed_statuses <= RUN_STATUSES:
raise MiningRunValidationError("statuses contains an unsupported mining run status")
# Directory enumeration and manifest reads stay outside the write lock. Atomic replacements
# make each individual read coherent while avoiding a lock around a potentially slow scan.
try:
paths = list(self.runs_root.glob("*/manifest.json"))
except OSError as exc:
raise MiningRunStoreError("failed to scan mining run manifests") from exc
matches: list[dict[str, Any]] = []
for path in paths:
run_id = path.parent.name
if not _RUN_ID_PATTERN.fullmatch(run_id):
continue
try:
manifest_path = self._run_dir(run_id) / "manifest.json"
manifest = self._read_manifest_path(manifest_path, run_id)
except MiningRunStoreError:
continue
if manifest is None or manifest.get("run_signature") != run_signature:
continue
if allowed_statuses is not None and manifest.get("status") not in allowed_statuses:
continue
matches.append(manifest)
return max(matches, key=_manifest_sort_key, default=None)
def recover_interrupted(self) -> int:
"""Mark runs without a live in-process worker as interrupted at startup."""
try:
paths = list(self.runs_root.glob("*/manifest.json"))
except OSError as exc:
raise MiningRunStoreError("failed to scan mining run manifests") from exc
candidates: list[str] = []
for path in paths:
run_id = path.parent.name
if not _RUN_ID_PATTERN.fullmatch(run_id):
continue
try:
manifest_path = self._run_dir(run_id) / "manifest.json"
manifest = self._read_manifest_path(manifest_path, run_id)
except MiningRunStoreError:
continue
if manifest is not None and manifest.get("status") in ACTIVE_RUN_STATUSES:
candidates.append(run_id)
recovered = 0
for run_id in candidates:
with _STORE_LOCK:
manifest = self._required_manifest(run_id)
if manifest["status"] not in ACTIVE_RUN_STATUSES:
continue
self._transition_locked(manifest, "interrupted", error=None)
recovered += 1
return recovered
def _transition_locked(
self,
manifest: dict[str, Any],
status: MiningRunStatus,
*,
error: str | None,
) -> dict[str, Any]:
previous = manifest["status"]
if previous == status:
return manifest
if status not in _ALLOWED_TRANSITIONS[previous]:
raise InvalidMiningStatusTransitionError(
f"cannot transition from {previous} to {status}"
)
now = _now_iso()
manifest["status"] = status
manifest["updated_at"] = now
if status == "running" and not manifest.get("started_at"):
manifest["started_at"] = now
if status == "cancelling":
manifest["cancellation_requested_at"] = now
if status in TERMINAL_RUN_STATUSES:
manifest["finished_at"] = now
if error is not None:
manifest["error"] = str(error)
_atomic_write_json(self._run_dir(manifest["run_id"]) / "manifest.json", manifest)
return manifest
def _required_manifest(self, run_id: str) -> dict[str, Any]:
manifest = self._read_manifest_path(self._run_dir(run_id) / "manifest.json", run_id)
if manifest is None:
raise KeyError(run_id)
return manifest
def _read_manifest_path(self, path: Path, run_id: str) -> dict[str, Any] | None:
if not path.exists():
return None
value = _read_json(path)
if not isinstance(value, dict):
raise MiningRunStoreError(f"invalid manifest for run {run_id}")
return self._normalize_manifest(value, run_id)
def _normalize_manifest(self, value: dict[str, Any], run_id: str) -> dict[str, Any]:
status = value.get("status", "queued")
if status not in RUN_STATUSES:
raise MiningRunStoreError(f"invalid status in manifest for run {run_id}")
raw_request = value.get("request") if isinstance(value.get("request"), dict) else {}
data_fingerprint = value.get("data_fingerprint")
signature = value.get("run_signature")
if not isinstance(signature, str) or not signature:
signature = compute_run_signature(raw_request, data_fingerprint)
raw_artifacts = value.get("artifacts") if isinstance(value.get("artifacts"), dict) else {}
artifacts: dict[str, str] = {}
run_dir = self._run_dir(run_id)
for name, raw_path in raw_artifacts.items():
if name not in ARTIFACT_NAMES or not isinstance(raw_path, str):
continue
try:
safe_path = self._safe_artifact_path(run_id, Path(raw_path))
except MiningRunValidationError:
continue
if safe_path.suffix.lower() == ".parquet":
artifacts[name] = safe_path.relative_to(run_dir).as_posix()
normalized = dict(value)
normalized.update(
{
"schema_version": value.get("schema_version", 0),
"run_id": run_id,
"status": status,
"request": raw_request,
"data_fingerprint": data_fingerprint,
"run_signature": signature,
"artifacts": artifacts,
"created_at": value.get("created_at"),
"updated_at": value.get("updated_at") or value.get("created_at"),
"started_at": value.get("started_at"),
"finished_at": value.get("finished_at"),
"cancellation_requested_at": value.get("cancellation_requested_at"),
"error": value.get("error"),
}
)
return normalized
@staticmethod
def _read_events_path(path: Path) -> list[dict[str, Any]]:
if not path.exists():
return []
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as exc:
raise MiningRunStoreError(f"failed to read events file: {path}") from exc
events: list[dict[str, Any]] = []
for line in lines:
if not line.strip():
continue
try:
event = json.loads(line)
except json.JSONDecodeError as exc:
raise MiningRunStoreError(f"invalid events file: {path}") from exc
if (
not isinstance(event, dict)
or isinstance(event.get("id"), bool)
or not isinstance(event.get("id"), int)
or event["id"] <= 0
):
raise MiningRunStoreError(f"invalid event record: {path}")
if events and event["id"] <= events[-1]["id"]:
raise MiningRunStoreError(f"non-monotonic event IDs: {path}")
events.append(event)
return events[-MAX_EVENTS:]
def _run_dir(self, run_id: str) -> Path:
safe_run_id = self._validate_run_id(run_id)
candidate = (self.runs_root / safe_run_id).resolve()
if not candidate.is_relative_to(self.runs_root):
raise MiningRunValidationError("run path escapes mining runs root")
return candidate
def _safe_artifact_path(self, run_id: str, path: Path) -> Path:
run_dir = self._run_dir(run_id)
candidate = path if path.is_absolute() else run_dir / path
resolved = candidate.resolve()
if not resolved.is_relative_to(run_dir):
raise MiningRunValidationError("artifact path escapes its mining run directory")
return resolved
@staticmethod
def _validate_run_id(run_id: str) -> str:
if not isinstance(run_id, str) or not _RUN_ID_PATTERN.fullmatch(run_id):
raise MiningRunValidationError("run_id contains unsafe characters")
return run_id
@staticmethod
def _validate_artifact_name(name: str) -> None:
if name not in ARTIFACT_NAMES:
raise MiningRunValidationError(f"unsupported artifact name: {name!r}")
def _canonicalize_json_value(value: Any) -> Any:
if value is None or isinstance(value, (str, bool, int)):
return value
if isinstance(value, float):
if not math.isfinite(value):
raise MiningRunValidationError("non-finite numbers are not supported")
return value
if isinstance(value, Enum):
return _canonicalize_json_value(value.value)
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Path):
return str(value)
if isinstance(value, Mapping):
result: dict[str, Any] = {}
for key, item in value.items():
if not isinstance(key, str):
raise MiningRunValidationError("JSON mapping keys must be strings")
result[key] = _canonicalize_json_value(item)
return {key: result[key] for key in sorted(result)}
if isinstance(value, (list, tuple)):
return [_canonicalize_json_value(item) for item in value]
if isinstance(value, (set, frozenset)):
items = [_canonicalize_json_value(item) for item in value]
return sorted(
items,
key=lambda item: json.dumps(
item, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":")
),
)
raise MiningRunValidationError(f"value is not JSON serializable: {type(value).__name__}")
def _read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise MiningRunStoreError(f"failed to read JSON file: {path}") from exc
def _atomic_write_json(path: Path, value: Any) -> None:
try:
text = json.dumps(value, ensure_ascii=False, allow_nan=False, indent=2) + "\n"
except (TypeError, ValueError) as exc:
raise MiningRunValidationError("value is not JSON serializable") from exc
_atomic_write_text(path, text)
def _atomic_write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
try:
with temporary.open("w", encoding="utf-8", newline="\n") as stream:
stream.write(text)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
except OSError as exc:
temporary.unlink(missing_ok=True)
raise MiningRunStoreError(f"failed to write file: {path}") from exc
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _manifest_sort_key(manifest: dict[str, Any]) -> tuple[str, str]:
return (
str(manifest.get("updated_at") or manifest.get("created_at") or ""),
str(manifest.get("run_id") or ""),
)
+263
View File
@@ -0,0 +1,263 @@
"""Threaded orchestration for persistent mining jobs."""
from __future__ import annotations
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
from app.backtest.worker import make_worker_task, run_worker_task
from app.services.heavy_job_limiter import (
HeavyJobCancelledError,
shared_heavy_job_limiter,
)
from app.services.mining_jobs import (
ACTIVE_RUN_STATUSES,
SUCCESS_RUN_STATUSES,
TERMINAL_RUN_STATUSES,
MiningRunStore,
MiningRunValidationError,
compute_run_signature,
)
WorkerRunner = Callable[
[dict[str, Any], Callable[[dict[str, Any]], None], threading.Event],
dict[str, Any],
]
TaskFactory = Callable[[str, Path, dict[str, Any]], dict[str, Any]]
_SUCCESS_STATUSES = {"succeeded", "succeeded_with_budget_exhausted"}
_SHUTDOWN_JOIN_SECONDS = 1.0
class MiningJobManager:
"""Coordinate mining persistence, capacity, cancellation, and worker threads."""
def __init__(
self,
data_dir: Path | str,
worker_runner: WorkerRunner = run_worker_task,
task_factory: TaskFactory = make_worker_task,
) -> None:
self._data_dir = Path(data_dir).resolve()
self._store = MiningRunStore(self._data_dir)
self._worker_runner = worker_runner
self._task_factory = task_factory
self._lock = threading.RLock()
self._threads: dict[str, threading.Thread] = {}
self._cancel_events: dict[str, threading.Event] = {}
self._shutdown = False
@property
def store(self) -> MiningRunStore:
return self._store
def start(
self,
request: dict[str, Any],
data_fingerprint: Any,
force: bool = False,
source: str = "manual",
run_id: str | None = None,
) -> dict[str, Any]:
signature = compute_run_signature(request, data_fingerprint)
with self._lock:
if self._shutdown:
raise RuntimeError("mining job manager is shut down")
if not force:
active = self._store.find_by_signature(
signature,
statuses=ACTIVE_RUN_STATUSES,
)
if active is not None:
return active
succeeded = self._store.find_by_signature(
signature,
statuses=SUCCESS_RUN_STATUSES,
)
if succeeded is not None:
return succeeded
try:
manifest = self._store.create(
request,
data_fingerprint,
run_id=run_id,
)
except MiningRunValidationError:
if run_id is None:
raise
existing = self._store.get(run_id)
if existing is None:
raise
return existing
run_id = manifest["run_id"]
self._store.append_event(
run_id,
"queued",
{"status": "queued", "source": source},
)
self._start_thread_locked(run_id, source)
return manifest
def cancel(self, run_id: str) -> dict[str, Any]:
with self._lock:
manifest = self._store.get(run_id)
if manifest is None:
raise KeyError(run_id)
if manifest["status"] in TERMINAL_RUN_STATUSES:
return manifest
cancel_event = self._cancel_events.get(run_id)
if cancel_event is None:
cancelled = self._store.transition_status(run_id, "cancelled")
self._store.append_event(run_id, "cancelled", {"status": "cancelled"})
return cancelled
cancel_event.set()
if manifest["status"] != "cancelling":
manifest = self._store.transition_status(run_id, "cancelling")
self._store.append_event(run_id, "cancelling", {"status": "cancelling"})
return manifest
def shutdown(self) -> None:
with self._lock:
self._shutdown = True
run_ids = list(self._threads)
for run_id in run_ids:
self.cancel(run_id)
deadline = time.monotonic() + _SHUTDOWN_JOIN_SECONDS
current = threading.current_thread()
for run_id in run_ids:
with self._lock:
thread = self._threads.get(run_id)
if thread is None or thread is current:
continue
remaining = deadline - time.monotonic()
if remaining <= 0:
break
thread.join(timeout=remaining)
def recover_interrupted(self) -> int:
return self._store.recover_interrupted()
def _start_thread_locked(self, run_id: str, source: str) -> None:
if run_id in self._threads:
return
cancel_event = threading.Event()
thread = threading.Thread(
target=self._run_job,
args=(run_id, source, cancel_event),
name=f"mining-{run_id}",
daemon=True,
)
self._cancel_events[run_id] = cancel_event
self._threads[run_id] = thread
thread.start()
def _run_job(
self,
run_id: str,
source: str,
cancel_event: threading.Event,
) -> None:
try:
with shared_heavy_job_limiter.slot("mining", cancel_event=cancel_event):
if not self._mark_running(run_id, cancel_event):
return
manifest = self._store.get(run_id)
if manifest is None:
raise KeyError(run_id)
payload = {
"run_id": run_id,
"request": manifest["request"],
"data_fingerprint": manifest["data_fingerprint"],
"source": source,
}
task = self._task_factory("mining", self._data_dir, payload)
result = self._worker_runner(
task,
lambda progress: self._record_progress(run_id, progress, cancel_event),
cancel_event,
)
if not isinstance(result, dict):
raise TypeError("mining worker result must be a compact dict")
self._finish_success(run_id, result, cancel_event)
except HeavyJobCancelledError:
self._finish_cancelled(run_id)
except Exception as exc:
if cancel_event.is_set():
self._finish_cancelled(run_id)
else:
self._finish_failed(run_id, exc)
finally:
with self._lock:
self._threads.pop(run_id, None)
self._cancel_events.pop(run_id, None)
def _mark_running(self, run_id: str, cancel_event: threading.Event) -> bool:
with self._lock:
if cancel_event.is_set():
self._finish_cancelled_locked(run_id)
return False
self._store.transition_status(run_id, "running")
self._store.append_event(run_id, "running", {"status": "running"})
return True
def _record_progress(
self,
run_id: str,
progress: dict[str, Any],
cancel_event: threading.Event,
) -> None:
if not isinstance(progress, dict):
raise TypeError("mining progress must be a compact dict")
with self._lock:
if cancel_event.is_set():
return
self._store.append_event(run_id, "progress", progress)
self._store.write_summary(run_id, {"progress": progress})
def _finish_success(
self,
run_id: str,
result: dict[str, Any],
cancel_event: threading.Event,
) -> None:
status = result.get("status", "succeeded")
if status not in _SUCCESS_STATUSES:
raise ValueError(f"unsupported mining worker status: {status!r}")
with self._lock:
if cancel_event.is_set():
self._finish_cancelled_locked(run_id)
return
self._store.write_summary(run_id, result)
self._store.transition_status(run_id, status)
self._store.append_event(run_id, status, {"status": status})
def _finish_cancelled(self, run_id: str) -> None:
with self._lock:
self._finish_cancelled_locked(run_id)
def _finish_cancelled_locked(self, run_id: str) -> None:
manifest = self._store.get(run_id)
if manifest is None or manifest["status"] in TERMINAL_RUN_STATUSES:
return
self._store.transition_status(run_id, "cancelled")
self._store.append_event(run_id, "cancelled", {"status": "cancelled"})
def _finish_failed(self, run_id: str, exc: Exception) -> None:
message = str(exc)[:2000]
with self._lock:
manifest = self._store.get(run_id)
if manifest is None or manifest["status"] in TERMINAL_RUN_STATUSES:
return
self._store.transition_status(run_id, "failed", error=message)
self._store.append_event(
run_id,
"error",
{"status": "failed", "message": message},
)
+139
View File
@@ -0,0 +1,139 @@
"""Lightweight mining date availability checks shared by API and workers."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import date
from pathlib import Path
from typing import Any
from app.backtest.mining import (
nested_fold_count,
required_outer_folds,
required_trading_bars,
validation_config_for_profile,
)
from app.tickflow.repository import enriched_dirname
@dataclass(frozen=True)
class MiningAvailability:
asset_type: str
budget_profile: str
trading_bars: int
required_bars: int
outer_folds: int
required_outer_folds: int
eligible: bool
available_start: date | None
available_end: date | None
effective_start: date | None
effective_end: date | None
suggested_start: date | None
def to_dict(self) -> dict[str, Any]:
return {
key: value.isoformat() if isinstance(value, date) else value
for key, value in asdict(self).items()
}
def enriched_partition_dates(
data_dir: Path,
asset_type: str,
start: date | None = None,
end: date | None = None,
) -> list[date]:
root = data_dir / enriched_dirname(asset_type)
values: set[date] = set()
for partition in root.glob("date=*"):
try:
value = date.fromisoformat(partition.name.removeprefix("date="))
except ValueError:
continue
if start is not None and value < start:
continue
if end is not None and value > end:
continue
if (partition / "part.parquet").is_file():
values.add(value)
return sorted(values)
def mining_availability(
data_dir: Path,
*,
asset_type: str,
budget_profile: str,
start: date | None = None,
end: date | None = None,
) -> MiningAvailability:
if asset_type not in {"stock", "etf"}:
raise ValueError(f"unsupported mining asset type: {asset_type}")
if start is not None and end is not None and start > end:
raise ValueError("mining start must not be after end")
config = validation_config_for_profile(budget_profile)
required_folds = required_outer_folds(budget_profile)
required_bars = required_trading_bars(config, required_folds)
all_dates = enriched_partition_dates(data_dir, asset_type)
scoped = [
value
for value in all_dates
if (start is None or value >= start) and (end is None or value <= end)
]
dates_through_end = [
value for value in all_dates if end is None or value <= end
]
suggested_start = (
dates_through_end[-required_bars]
if len(dates_through_end) >= required_bars
else None
)
trading_bars = len(scoped)
return MiningAvailability(
asset_type=asset_type,
budget_profile=budget_profile,
trading_bars=trading_bars,
required_bars=required_bars,
outer_folds=nested_fold_count(trading_bars, config),
required_outer_folds=required_folds,
eligible=trading_bars >= required_bars,
available_start=all_dates[0] if all_dates else None,
available_end=all_dates[-1] if all_dates else None,
effective_start=scoped[0] if scoped else None,
effective_end=scoped[-1] if scoped else None,
suggested_start=suggested_start,
)
def require_mining_availability(
data_dir: Path,
*,
asset_type: str,
budget_profile: str,
start: date | None = None,
end: date | None = None,
) -> MiningAvailability:
availability = mining_availability(
data_dir,
asset_type=asset_type,
budget_profile=budget_profile,
start=start,
end=end,
)
if availability.eligible:
return availability
if availability.effective_start is None:
effective_range = "contains no enriched data"
else:
effective_range = (
f"{availability.effective_start.isoformat()} to "
f"{availability.effective_end.isoformat()}"
)
fold_label = "outer fold" if availability.required_outer_folds == 1 else "outer folds"
raise ValueError(
f"{budget_profile} mining requires at least {availability.required_bars} "
f"enriched trading bars for {availability.required_outer_folds} {fold_label}; "
f"effective range {effective_range} has {availability.trading_bars}"
)
@@ -0,0 +1,78 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import BinaryIO
class MiningProcessLockError(RuntimeError):
"""Another application process owns mining for this data directory."""
class MiningProcessLock:
def __init__(self, data_dir: Path) -> None:
self._path = Path(data_dir) / ".mining_process.lock"
self._stream: BinaryIO | None = None
def acquire(self) -> None:
if self._stream is not None:
return
self._path.parent.mkdir(parents=True, exist_ok=True)
stream = self._path.open("a+b")
try:
stream.seek(0, os.SEEK_END)
if stream.tell() == 0:
stream.write(b"0")
stream.flush()
os.set_inheritable(stream.fileno(), False)
_try_lock_file(stream)
except BaseException:
stream.close()
raise
self._stream = stream
def release(self) -> None:
stream = self._stream
if stream is None:
return
self._stream = None
try:
_unlock_file(stream)
finally:
stream.close()
def _try_lock_file(stream: BinaryIO) -> None:
if os.name == "nt":
import msvcrt
stream.seek(0)
try:
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
except OSError as exc:
raise MiningProcessLockError(
"another application process already owns mining for this data directory"
) from exc
return
import fcntl
try:
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
raise MiningProcessLockError(
"another application process already owns mining for this data directory"
) from exc
def _unlock_file(stream: BinaryIO) -> None:
if os.name == "nt":
import msvcrt
stream.seek(0)
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
return
import fcntl
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
+391
View File
@@ -0,0 +1,391 @@
"""Weekly scheduled mining orchestration and deterministic data claims."""
from __future__ import annotations
import hashlib
import json
import logging
import threading
from datetime import date, datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import polars as pl
from app.backtest.factor import FACTOR_COLUMNS, FACTOR_METHODOLOGY_VERSION
from app.backtest.mining import (
required_outer_folds,
required_trading_bars,
validation_config_for_profile,
)
from app.services import preferences
from app.services.mining_preflight import enriched_partition_dates
from app.services.regime_builder import load_regime_history, regime_path
logger = logging.getLogger(__name__)
BEIJING_TZ = ZoneInfo("Asia/Shanghai")
MINING_ALGORITHM_VERSION = "mining-v2"
FINGERPRINT_VERSION = "weekly-mining-data-v2"
_PROFILES = frozenset({"balanced", "strict"})
_CLAIM_LOCK = threading.Lock()
def beijing_now(now: datetime | None = None) -> datetime:
"""Return an aware Beijing datetime without depending on the server timezone."""
if now is None:
return datetime.now(BEIJING_TZ)
if now.tzinfo is None:
return now.replace(tzinfo=BEIJING_TZ)
return now.astimezone(BEIJING_TZ)
def beijing_date(now: datetime | None = None) -> date:
return beijing_now(now).date()
def iso_week(value: date) -> tuple[int, int]:
iso_year, week, _ = value.isocalendar()
return iso_year, week
def build_default_request(repo: Any, profile: str) -> dict[str, Any]:
"""Build the bounded V1 stock/full-market request used by the scheduler."""
if profile not in _PROFILES:
raise ValueError(f"unsupported mining profile: {profile}")
latest = repo.latest_enriched_date("stock")
end = latest.isoformat() if latest is not None else None
return {
"factor_names": [item["id"] for item in FACTOR_COLUMNS[:48]],
"strategy_ids": [],
"symbols": None,
"asset_type": "stock",
"start": None,
"end": end,
"budget_profile": profile,
"require_regime": True,
}
def build_data_fingerprint(
repo: Any,
app_state: Any,
request: dict[str, Any],
) -> dict[str, Any]:
"""Hash one stable managed generation plus source metadata."""
for _attempt in range(2):
fingerprint = _build_data_fingerprint_once(repo, app_state, request)
if repo.get_matrix_data_generation(fingerprint["asset_type"]) == fingerprint["generation"]:
return fingerprint
raise ValueError("enriched data changed while building the mining fingerprint")
def _build_data_fingerprint_once(
repo: Any,
app_state: Any,
request: dict[str, Any],
) -> dict[str, Any]:
data_dir = Path(repo.store.data_dir)
asset_type = str(request.get("asset_type") or "stock")
enriched_root = (
data_dir / "kline_daily_enriched"
if asset_type == "stock"
else data_dir / f"kline_{asset_type}_enriched"
)
module_root = Path(__file__).resolve().parents[1]
components = {
"version": FINGERPRINT_VERSION,
"asset_type": asset_type,
"generation": repo.get_matrix_data_generation(asset_type),
"latest_enriched_date": _iso_or_none(repo.latest_enriched_date(asset_type)),
"enriched": _enriched_metadata(enriched_root),
"instruments": _instrument_metadata(repo, asset_type),
"regime": _path_metadata(regime_path(data_dir), root=data_dir),
"algorithm_version": MINING_ALGORITHM_VERSION,
"methodology_version": FACTOR_METHODOLOGY_VERSION,
"implementation": _implementation_metadata(module_root),
"strategies": _selected_strategy_metadata(
app_state,
request.get("strategy_ids") or [],
data_dir,
),
}
payload = _canonical_json(components)
return {
**components,
"digest": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
}
def schedule_claim(day: date) -> str:
iso_year, week = iso_week(day)
return f"weekly-{iso_year}-W{week:02d}"
def run_weekly_mining(app_state: Any, *, now: datetime | None = None) -> dict[str, Any]:
"""Check the weekly gate and enqueue mining; never perform mining synchronously."""
config = preferences.get_mining_schedule()
day = beijing_date(now)
if not config["mining_schedule_enabled"]:
return {"status": "disabled"}
weekday = day.weekday()
if weekday > 4 or weekday < config["mining_schedule_weekday"]:
return {"status": "weekday_mismatch"}
manager = getattr(app_state, "mining_manager", None)
repo = getattr(app_state, "repo", None)
if manager is None or repo is None:
raise RuntimeError("scheduled mining dependencies are not initialized")
store = getattr(manager, "store", None)
if store is None:
raise RuntimeError("scheduled mining manager has no run store")
request = build_default_request(repo, config["mining_budget_profile"])
fingerprint = build_data_fingerprint(repo, app_state, request)
claim = schedule_claim(day)
fingerprint = {**fingerprint, "source": "scheduled", "source_claim": claim}
with _CLAIM_LOCK:
existing = store.get(claim)
if existing is not None:
return {"status": "already_claimed", "run_id": claim}
prerequisite_error = _prerequisite_error(repo, request)
if prerequisite_error is not None:
_record_skipped_prerequisite(store, claim, request, fingerprint, prerequisite_error)
return {
"status": "skipped_prerequisite",
"run_id": claim,
"error": prerequisite_error,
}
run = manager.start(
request,
fingerprint,
force=False,
source="scheduled",
run_id=claim,
)
run_id = run.get("run_id") if isinstance(run, dict) else getattr(run, "run_id", None)
return {"status": "enqueued", "run_id": run_id or claim}
def _prerequisite_error(repo: Any, request: dict[str, Any]) -> str | None:
data_dir = Path(repo.store.data_dir)
end = request.get("end")
if end is None:
return "stock enriched data is unavailable"
regime = regime_path(data_dir)
try:
if not regime.is_file() or regime.stat().st_size <= 0:
return "regime data is unavailable"
except OSError:
return "regime data is unavailable"
start = request.get("start")
start_date = date.fromisoformat(start) if start is not None else None
end_date = date.fromisoformat(end)
partitions = enriched_partition_dates(
data_dir,
"stock",
start_date,
end_date,
)
covered = [value.isoformat() for value in partitions]
profile = request["budget_profile"]
validation = validation_config_for_profile(profile)
required = required_trading_bars(
validation,
required_outer_folds(profile),
)
if len(covered) < required:
return (
"insufficient enriched trading dates: "
f"need {required}, got {len(covered)}"
)
regime_history = load_regime_history(data_dir)
if regime_history.is_empty() or "date" not in regime_history.columns:
return "regime data is unavailable"
regime_dates = set(
regime_history.select(
pl.col("date").cast(pl.Utf8).str.slice(0, 10)
).to_series().to_list()
)
required_predecessors = set(covered[:-1])
missing_regime = required_predecessors - regime_dates
if missing_regime:
return (
"regime coverage is incomplete for T-1 alignment: "
f"missing {len(missing_regime)} trading dates"
)
return None
def _record_skipped_prerequisite(
store: Any,
claim: str,
request: dict[str, Any],
fingerprint: dict[str, Any],
error: str,
) -> None:
try:
store.create(request, fingerprint, run_id=claim)
except Exception:
if store.get(claim) is not None:
return
raise
store.append_event(
claim,
"skipped_prerequisite",
{"source": "scheduled", "reason": error},
)
store.transition_status(claim, "skipped_prerequisite", error=error)
def _instrument_metadata(repo: Any, asset_type: str) -> dict[str, Any]:
instruments = repo.get_instruments_asset(asset_type)
if instruments is None or instruments.is_empty() or "symbol" not in instruments.columns:
return {"rows": 0, "digest": "no-instruments"}
columns = [
name
for name in (
"symbol",
"name",
"total_shares",
"float_shares",
"limit_up",
"limit_down",
)
if name in instruments.columns
]
payload = instruments.select(columns).sort("symbol").to_dicts()
digest = hashlib.blake2b(
json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8"),
digest_size=20,
).hexdigest()
return {"rows": instruments.height, "columns": columns, "digest": digest}
def _enriched_metadata(root: Path) -> dict[str, Any]:
records: list[dict[str, Any]] = []
for partition in sorted(root.glob("date=*"), key=lambda item: item.name):
try:
date.fromisoformat(partition.name.removeprefix("date="))
except ValueError:
continue
records.append(
{
"partition": partition.name,
"file": _path_metadata(partition / "part.parquet", root=root),
}
)
return {
"partition_count": len(records),
"first_partition": records[0]["partition"] if records else None,
"last_partition": records[-1]["partition"] if records else None,
"metadata_digest": hashlib.sha256(_canonical_json(records).encode("utf-8")).hexdigest(),
}
def _selected_strategy_metadata(
app_state: Any,
strategy_ids: list[str],
data_dir: Path,
) -> list[dict[str, Any]]:
if not strategy_ids:
return []
engine = getattr(app_state, "strategy_engine", None)
if engine is None:
raise RuntimeError("strategy engine is unavailable for scheduled mining fingerprint")
metadata: list[dict[str, Any]] = []
for strategy_id in sorted(strategy_ids):
strategy = engine.get(strategy_id)
if strategy.execution_backend != "matrix_native":
raise ValueError(f"scheduled mining strategy is not matrix-native: {strategy_id}")
source_path = Path(strategy.file_path) if strategy.file_path is not None else None
override_path = data_dir / "user_data" / "strategy_overrides" / f"{strategy_id}.json"
metadata.append(
{
"strategy_id": strategy_id,
"source": _content_metadata(
source_path, root=source_path.parent if source_path else data_dir
),
"source_tree": (
_implementation_metadata(source_path.parent)
if source_path is not None
else None
),
"override": _content_metadata(override_path, root=data_dir),
}
)
return metadata
def _path_metadata(path: Path | None, *, root: Path) -> dict[str, Any] | None:
if path is None:
return None
try:
stat = path.stat()
except OSError:
return {"path": _relative_path(path, root), "exists": False}
return {
"path": _relative_path(path, root),
"exists": True,
"size": stat.st_size,
"mtime_ns": stat.st_mtime_ns,
}
def _content_metadata(path: Path | None, *, root: Path) -> dict[str, Any] | None:
if path is None:
return None
try:
content = path.read_bytes()
except OSError:
return {"path": _relative_path(path, root), "exists": False}
return {
"path": _relative_path(path, root),
"exists": True,
"size": len(content),
"sha256": hashlib.sha256(content).hexdigest(),
}
def _implementation_metadata(module_root: Path) -> dict[str, Any]:
records = []
for path in sorted(module_root.rglob("*.py"), key=lambda item: item.as_posix()):
try:
content = path.read_bytes()
except OSError:
continue
records.append({
"path": path.relative_to(module_root).as_posix(),
"sha256": hashlib.sha256(content).hexdigest(),
})
return {
"file_count": len(records),
"digest": hashlib.sha256(_canonical_json(records).encode("utf-8")).hexdigest(),
}
def _relative_path(path: Path, root: Path) -> str:
try:
return path.resolve().relative_to(root.resolve()).as_posix()
except ValueError:
return path.name
def _iso_or_none(value: date | None) -> str | None:
return value.isoformat() if value is not None else None
def _canonical_json(value: Any) -> str:
return json.dumps(
value,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
)
+17 -4
View File
@@ -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)
+187 -6
View File
@@ -5,12 +5,19 @@
"""
from __future__ import annotations
import copy
import json
import logging
import re
from pathlib import Path
logger = logging.getLogger(__name__)
# 进程内缓存: 行情轮询线程一轮会调用 8~12 次 getter, 每次读盘+parse 是纯重复;
# 文件仅在用户改设置时变化, 以 (mtime_ns, size) 签名判断是否重读。
_cache: dict | None = None
_cache_sig: tuple[int, int] | None = None
def _path() -> Path:
from app.config import settings
@@ -19,14 +26,32 @@ def _path() -> Path:
return p
def _invalidate_cache() -> None:
global _cache, _cache_sig
_cache = None
_cache_sig = None
def load() -> dict:
"""读取 preferences.json (带 mtime 签名缓存)。返回深拷贝, 调用方可自由修改。"""
global _cache, _cache_sig
p = _path()
if p.exists():
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001
logger.warning("preferences.json malformed: %s", e)
return {}
try:
sig = (p.stat().st_mtime_ns, p.stat().st_size)
except OSError:
return {}
if _cache is not None and sig == _cache_sig:
return copy.deepcopy(_cache)
try:
data = json.loads(p.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
except Exception as e:
logger.warning("preferences.json malformed: %s", e)
return {}
_cache = data
_cache_sig = sig
return copy.deepcopy(_cache)
def save(updates: dict) -> dict:
@@ -36,6 +61,7 @@ def save(updates: dict) -> dict:
_path().write_text(
json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8",
)
_invalidate_cache()
return current
@@ -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]:
"""复盘推送渠道(多选) — 选定的外部工具列表, 复盘归档后逐个推送。
+98 -21
View File
@@ -130,18 +130,38 @@ class QuoteSubscriber:
self._event.set()
# 落盘节流间隔: last_fetch_ms 仅在进程重启后用于显示"最后获取时间"(运行中读内存值),
# 每 30s 持久化一次足够, 避免 expert 档每秒一轮的全量 preferences 重写磁盘。
_LAST_FETCH_WRITE_INTERVAL_MS = 30_000.0
_last_fetch_written_at_ms: float = 0.0
def _persist_last_fetch(fetched_at_ms: float) -> None:
""""最后获取"时间戳持久化到 preferences, 使进程重启后仍可显示。
放在锁外调用 (IO); 失败不影响主流程 (内存值已更新, 下次 fetch 再写)
距上次成功落盘不足 30s 时跳过 (节流只影响落盘频率, 内存值不受影响)
"""
global _last_fetch_written_at_ms
if (fetched_at_ms - _last_fetch_written_at_ms) < _LAST_FETCH_WRITE_INTERVAL_MS:
return
try:
from app.services import preferences
preferences.save({"last_fetch_ms": round(fetched_at_ms, 0)})
_last_fetch_written_at_ms = fetched_at_ms
except Exception as e: # noqa: BLE001
logger.debug("last_fetch_ms 持久化失败 (不影响行情): %s", e)
def _monitor_name_map(repo) -> dict[str, str]:
"""监控回填用的 symbol → name 映射 (股票 + ETF + 指数, 股票优先)。
repo.get_name_map() 的进程内 memo (三份 instruments 维表刷新时失效),
避免每轮监控对 ~7000 行维表 iter_rows 重建过滤空名称与旧行为一致
"""
return {s: n for s, n in repo.get_name_map().items() if n}
class QuoteService:
"""全局实时行情服务 — 单例。"""
@@ -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)
+84
View File
@@ -150,7 +150,16 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl.
polars 聚合, 不重算指标(假设 df 已含 signal_*/change_pct/ma20 等列)
index_pct_map: {date: 指数涨幅} 可选, 由调用方从指数数据预先算好
梯队指标(首板/N板宽度/晋级率) market_phase 提供; phase 列不在此算
(需要完整日序做平滑), refresh_phase_labels upsert 后统一重标
"""
from app.services.market_phase import (
finalize_ladder_row,
ladder_daily_aggs,
ladder_promo_aggs,
with_prev_consecutive,
)
needed = ["date", "change_pct", "amount", "signal_limit_up",
"signal_limit_down", "signal_broken_limit_up",
"consecutive_limit_ups", "close", "ma20"]
@@ -158,6 +167,9 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl.
if "date" not in avail or "change_pct" not in avail:
return pl.DataFrame()
if "consecutive_limit_ups" in avail and "symbol" in df.columns:
df = with_prev_consecutive(df)
# 基础聚合 — 全部用 group_by 一次性向量化算出, 避免逐日 filter 扫全表(OOM/超时元凶)。
has_ma20 = "close" in avail and "ma20" in avail
grouped = df.group_by("date").agg(
@@ -216,6 +228,15 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl.
]
if has_ma20 else []
),
# 梯队指标(阶段判定所需): 首板/N板宽度/非空档位数; 晋级率需 _prev_consec
*(
ladder_daily_aggs()
if "consecutive_limit_ups" in avail else []
),
*(
ladder_promo_aggs()
if "consecutive_limit_ups" in avail and "_prev_consec" in df.columns else []
),
).sort("date")
# 转成 dict 列表做分类(规则引擎需逐日算, 但只扫 grouped 行数=天数, 不再回扫全表)
@@ -289,6 +310,8 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl.
"speculation_score": round(sub["speculation"]),
"resilience_score": round(sub["resilience"]),
"trend_score": round(sub["trend"]),
# 梯队指标(阶段判定所需); phase 由 refresh_phase_labels 统一重标
**finalize_ladder_row(r),
})
return pl.DataFrame(rows) if rows else pl.DataFrame()
@@ -325,6 +348,10 @@ def _compute_batch(repo, enriched_dir, instruments, historical_shares,
needed={"signal_limit_up", "signal_limit_down", "signal_broken_limit_up"},
historical_shares=historical_shares,
)
# 晋级率需要昨日连板数: 在裁掉 warmup 之前先按 symbol 平移,
# 保证每批首日的 _prev_consec 来自 warmup 的最后一个交易日而非 null。
from app.services.market_phase import with_prev_consecutive
df = with_prev_consecutive(df)
# 丢弃 warmup 行, 只留目标区间
return df.filter((pl.col("date") >= batch_start) & (pl.col("date") <= batch_end))
@@ -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
+10 -1
View File
@@ -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)
+342 -50
View File
@@ -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 []
+1
View File
@@ -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:
+37 -6
View File
@@ -6,12 +6,25 @@
"""
from __future__ import annotations
import copy
import json
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
# 进程内缓存: 监控引擎每轮对每条策略规则调用 load_override, 每次读盘+parse 纯重复;
# override 仅在用户编辑时变化, 以 (mtime_ns, size) 签名判断是否重读。
# 键为 override 文件路径 (进程内可能有多个 data_dir, 如测试)。
_override_cache: dict[str, dict] = {}
_override_cache_sig: dict[str, tuple[int, int]] = {}
def _invalidate_override_cache(path: Path) -> None:
key = str(path)
_override_cache.pop(key, None)
_override_cache_sig.pop(key, None)
def _overrides_dir(data_dir: Path) -> Path:
d = data_dir / "user_data" / "strategy_overrides"
@@ -19,15 +32,29 @@ def _overrides_dir(data_dir: Path) -> Path:
return d
def _path(data_dir: Path, strategy_id: str) -> Path:
return _overrides_dir(data_dir) / f"{strategy_id}.json"
def _path(data_dir: Path, strategy_id: str, *, ensure_dir: bool = True) -> Path:
# ensure_dir=False 供热路径读取: mkdir 系统调用在 Windows 上 ~0.07ms,
# 读缓存命中时跳过它 (目录由写路径保证存在)。
if ensure_dir:
d = _overrides_dir(data_dir)
else:
d = data_dir / "user_data" / "strategy_overrides"
return d / f"{strategy_id}.json"
def load_override(data_dir: Path, strategy_id: str) -> dict:
"""读取策略的用户覆盖配置,不存在返回空 dict"""
p = _path(data_dir, strategy_id)
if not p.exists():
"""读取策略的用户覆盖配置,不存在返回空 dict (带 mtime 签名缓存, 返回深拷贝)"""
p = _path(data_dir, strategy_id, ensure_dir=False)
key = str(p)
try:
st = p.stat()
sig = (st.st_mtime_ns, st.st_size)
except OSError:
_invalidate_override_cache(p)
return {}
cached = _override_cache.get(key)
if cached is not None and sig == _override_cache_sig.get(key):
return copy.deepcopy(cached)
try:
data = json.loads(p.read_text(encoding="utf-8"))
# 清理 basic_filter 中值为 None/空的键(避免固化无意义的空值)
@@ -38,7 +65,9 @@ def load_override(data_dir: Path, strategy_id: str) -> dict:
data["basic_filter"] = cleaned
else:
del data["basic_filter"]
return data
_override_cache[key] = data
_override_cache_sig[key] = sig
return copy.deepcopy(data)
except Exception as e:
logger.warning("load override %s failed: %s", strategy_id, e)
return {}
@@ -49,11 +78,13 @@ def save_override(data_dir: Path, strategy_id: str, overrides: dict) -> None:
p = _path(data_dir, strategy_id)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(overrides, ensure_ascii=False, indent=2), encoding="utf-8")
_invalidate_override_cache(p)
def delete_override(data_dir: Path, strategy_id: str) -> None:
"""删除策略的用户覆盖配置(重置为默认值)"""
p = _path(data_dir, strategy_id)
_invalidate_override_cache(p)
if p.exists():
p.unlink()
+2
View File
@@ -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
View File
@@ -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
View File
@@ -1,6 +1,6 @@
"""策略实时监控 — 订阅行情更新,检查策略买卖信号和提醒条件
"""策略实时监控 — 订阅行情更新,检查策略买卖信号。
职责: 接收实时行情 DataFrame 检查监控中策略的信号/提醒 推送告警
职责: 接收实时行情 DataFrame 检查监控中策略的信号 推送告警
不知道: 策略加载逻辑AIAPI配置持久化回测
依赖: 外部调用 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 [])
+57 -6
View File
@@ -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")
+1 -1
View File
@@ -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