初始化工程
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""FastAPI 路由汇总。"""
|
||||
@@ -0,0 +1,144 @@
|
||||
"""告警触发记录 API — 查询/清空/生成演示数据 alerts.jsonl。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.services import alert_store
|
||||
|
||||
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
|
||||
|
||||
|
||||
def _data_dir(request: Request) -> Path:
|
||||
return request.app.state.repo.store.data_dir
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_alerts(
|
||||
request: Request,
|
||||
days: int = 7,
|
||||
limit: int = 5000,
|
||||
source: str | None = None,
|
||||
type: str | None = None,
|
||||
):
|
||||
"""查询触发记录 (时间倒序)。"""
|
||||
events = alert_store.list_recent(
|
||||
_data_dir(request), days=days, limit=limit, source=source, type=type,
|
||||
)
|
||||
total = alert_store.count(_data_dir(request))
|
||||
return {"alerts": events, "total": total}
|
||||
|
||||
|
||||
@router.delete("")
|
||||
def clear_alerts(request: Request):
|
||||
"""清空全部触发记录。"""
|
||||
n = alert_store.clear(_data_dir(request))
|
||||
return {"ok": True, "cleared": n}
|
||||
|
||||
|
||||
@router.delete("/{ts}")
|
||||
def delete_alert(ts: int, request: Request):
|
||||
"""删除单条触发记录 (按 ts 毫秒时间戳)。"""
|
||||
deleted = alert_store.delete_one(_data_dir(request), ts)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 演示数据生成 (仅 Dev 页用) ─────────────────────────
|
||||
|
||||
_DEMO_STOCKS = [
|
||||
("600519.SH", "贵州茅台"), ("000001.SZ", "平安银行"), ("300750.SZ", "宁德时代"),
|
||||
("002594.SZ", "比亚迪"), ("000858.SZ", "五粮液"), ("601318.SH", "中国平安"),
|
||||
("002475.SZ", "立讯精密"), ("600036.SH", "招商银行"), ("000725.SZ", "京东方A"),
|
||||
("300059.SZ", "东方财富"),
|
||||
]
|
||||
_DEMO_TEMPLATES = [
|
||||
("signal", "MA金叉触发", ["signal_ma_golden_5_20"], "info"),
|
||||
("signal", "放量突破新高", ["signal_volume_surge", "signal_n_day_high"], "warn"),
|
||||
("signal", "MACD金叉", ["signal_macd_golden"], "info"),
|
||||
("signal", "跌破MA20", ["signal_ma20_breakdown"], "info"),
|
||||
("price", "涨幅超 5%", [], "warn"),
|
||||
("price", "RSI 极度超卖", [], "warn"),
|
||||
("price", "跌幅超 3%", [], "info"),
|
||||
("market", "涨停封板", ["signal_limit_up"], "critical"),
|
||||
("market", "连板异动", ["signal_limit_up"], "warn"),
|
||||
("market", "炸板", ["signal_broken_limit_up"], "warn"),
|
||||
# 新策略变更格式
|
||||
("strategy", "策略「趋势突破」进入 贵州茅台 +2.3%", ["signal_n_day_high", "signal_volume_surge"], "info"),
|
||||
("strategy", "策略「趋势突破」移出 五粮液 -1.5%", ["signal_ma20_breakdown"], "info"),
|
||||
("strategy", "策略「新低反转」进入 平安银行 +1.1%", ["signal_n_day_low"], "warn"),
|
||||
("strategy", "策略「MACD金叉」移出 比亚迪 -0.8%", ["signal_macd_golden"], "info"),
|
||||
# 批量变更
|
||||
("strategy", "策略「趋势突破」进入 6 只:平安银行、宁德时代、比亚迪、东方财富、招商银行、立讯精密", [], "info"),
|
||||
("strategy", "策略「MACD金叉」移出 7 只:京东方A、平安银行、五粮液、立讯精密、招商银行、东方财富、比亚迪", [], "warn"),
|
||||
]
|
||||
|
||||
|
||||
@router.post("/seed")
|
||||
def seed_demo_alerts(request: Request, count: int = 12, recent: bool = True):
|
||||
"""生成演示触发记录 (Dev 页用)。
|
||||
|
||||
Args:
|
||||
count: 生成条数 (1-50)
|
||||
recent: True=时间戳设为"刚刚"(用于测试闪烁效果); False=分散在近3天
|
||||
"""
|
||||
count = max(1, min(50, count))
|
||||
now_ms = int(time.time() * 1000)
|
||||
events = []
|
||||
for i in range(count):
|
||||
source, message, signals, severity = _DEMO_TEMPLATES[i % len(_DEMO_TEMPLATES)]
|
||||
sym, name = _DEMO_STOCKS[i % len(_DEMO_STOCKS)]
|
||||
# 策略类型按消息推导 type: new_entry / dropped, 否则沿用 source
|
||||
if source == "strategy":
|
||||
if "进入" in message:
|
||||
ev_type = "new_entry"
|
||||
elif "移出" in message:
|
||||
ev_type = "dropped"
|
||||
else:
|
||||
ev_type = "strategy"
|
||||
else:
|
||||
ev_type = source
|
||||
# recent 模式: 时间戳从现在往前每条错开 30 秒 (最新在前)
|
||||
ts = now_ms - (i * 30000) if recent else now_ms - random.randint(60, 4320) * 60 * 1000
|
||||
events.append({
|
||||
"ts": ts,
|
||||
"rule_id": f"demo_rule_{i}",
|
||||
"rule_name": message,
|
||||
"source": source,
|
||||
"type": ev_type,
|
||||
"symbol": "" if source == "strategy" and ("只:" in message) else sym,
|
||||
"name": name,
|
||||
"message": message,
|
||||
"price": round(random.uniform(8, 1800), 2) if not (source == "strategy" and "只:" in message) else None,
|
||||
"change_pct": round(random.uniform(-0.06, 0.098), 4) if not (source == "strategy" and "只:" in message) else None,
|
||||
"signals": signals,
|
||||
"severity": severity,
|
||||
})
|
||||
alert_store.append_many(_data_dir(request), events)
|
||||
|
||||
# 同步推入 SSE 队列, 让所有连着 SSE 的客户端实时收到 (不依赖轮询)
|
||||
qs = getattr(request.app.state, "quote_service", None)
|
||||
if qs:
|
||||
# 转成 SSE 推送格式 (和 _evaluate_monitors 一致)
|
||||
sse_alerts = [{
|
||||
"source": ev["source"],
|
||||
"type": ev["type"],
|
||||
"rule_id": ev.get("rule_id"),
|
||||
"symbol": ev["symbol"],
|
||||
"name": ev["name"],
|
||||
"message": ev["message"],
|
||||
"price": ev["price"],
|
||||
"change_pct": ev["change_pct"],
|
||||
"signals": ev["signals"],
|
||||
"severity": ev.get("severity", "info"),
|
||||
} for ev in events]
|
||||
with qs._lock:
|
||||
qs._pending_alerts.extend(sse_alerts)
|
||||
qs._alert_event.set()
|
||||
|
||||
return {"ok": True, "generated": len(events)}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""自定义分析菜单 API。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services.ext_data import ExtConfigStore
|
||||
|
||||
router = APIRouter(prefix="/api/analysis-menus", tags=["analysis-menus"])
|
||||
|
||||
|
||||
class AnalysisColumn(BaseModel):
|
||||
field: str
|
||||
label: str = ""
|
||||
type: Literal["string", "number", "percent", "amount", "date"] = "string"
|
||||
width: int | None = None
|
||||
sortable: bool = False
|
||||
precision: int | None = None
|
||||
format: str | None = None
|
||||
aggregate: Literal["count", "avg", "sum", "min", "max"] | None = None
|
||||
visible: bool = True
|
||||
|
||||
|
||||
class DefaultSort(BaseModel):
|
||||
field: str
|
||||
order: Literal["asc", "desc"] = "desc"
|
||||
|
||||
|
||||
class AnalysisMenu(BaseModel):
|
||||
id: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_]+$")
|
||||
label: str = Field(..., min_length=1, max_length=64)
|
||||
icon: str = "chart"
|
||||
data_source: str = Field(..., min_length=1)
|
||||
template: Literal["dimension_rank", "ranking", "table"] = "dimension_rank"
|
||||
dimension_field: str | None = None
|
||||
rank_field: str | None = None
|
||||
group_columns: list[AnalysisColumn] = Field(default_factory=list)
|
||||
detail_columns: list[AnalysisColumn] = Field(default_factory=list)
|
||||
default_sort: DefaultSort | None = None
|
||||
visible: bool = True
|
||||
order: int = 0
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
builtin: bool = False
|
||||
|
||||
|
||||
class UpsertAnalysisMenu(BaseModel):
|
||||
label: str = Field(..., min_length=1, max_length=64)
|
||||
icon: str = "chart"
|
||||
data_source: str = Field(..., min_length=1)
|
||||
template: Literal["dimension_rank", "ranking", "table"] = "dimension_rank"
|
||||
dimension_field: str | None = None
|
||||
rank_field: str | None = None
|
||||
group_columns: list[AnalysisColumn] = Field(default_factory=list)
|
||||
detail_columns: list[AnalysisColumn] = Field(default_factory=list)
|
||||
default_sort: DefaultSort | None = None
|
||||
visible: bool = True
|
||||
order: int = 0
|
||||
|
||||
|
||||
class ReorderMenusReq(BaseModel):
|
||||
ids: list[str] = Field(..., min_length=1)
|
||||
|
||||
|
||||
def _data_dir(request: Request) -> Path:
|
||||
return request.app.state.repo.store.data_dir
|
||||
|
||||
|
||||
def _base_dir(request: Request) -> Path:
|
||||
return _data_dir(request) / "analysis_menus"
|
||||
|
||||
|
||||
def _path(request: Request, menu_id: str) -> Path:
|
||||
return _base_dir(request) / f"{menu_id}.json"
|
||||
|
||||
|
||||
def _load_saved(request: Request) -> list[AnalysisMenu]:
|
||||
base = _base_dir(request)
|
||||
if not base.exists():
|
||||
return []
|
||||
items: list[AnalysisMenu] = []
|
||||
for p in sorted(base.glob("*.json")):
|
||||
try:
|
||||
raw = json.loads(p.read_text(encoding="utf-8"))
|
||||
items.append(AnalysisMenu(**raw))
|
||||
except Exception:
|
||||
continue
|
||||
return items
|
||||
|
||||
|
||||
def _ordered(items: list[AnalysisMenu]) -> list[AnalysisMenu]:
|
||||
return sorted(items, key=lambda m: (m.order, m.label, m.id))
|
||||
|
||||
|
||||
def _save(request: Request, menu: AnalysisMenu) -> AnalysisMenu:
|
||||
now = datetime.now().isoformat()
|
||||
if not menu.created_at:
|
||||
menu.created_at = now
|
||||
menu.updated_at = now
|
||||
menu.builtin = False
|
||||
base = _base_dir(request)
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
_path(request, menu.id).write_text(
|
||||
json.dumps(menu.model_dump(), ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return menu
|
||||
|
||||
|
||||
def _default_menus(request: Request) -> list[AnalysisMenu]:
|
||||
ext_store = ExtConfigStore(_data_dir(request))
|
||||
menus: list[AnalysisMenu] = []
|
||||
for cfg in ext_store.load_all():
|
||||
fields = cfg.fields
|
||||
concept = next((f for f in fields if "概念" in f.name or "概念" in f.label or "concept" in f.name.lower()), None)
|
||||
if concept:
|
||||
detail_names = ["股票简称", "股票代码", concept.name, "人气排名", "资金流向", "PE", "PB"]
|
||||
detail_columns = []
|
||||
for name in detail_names:
|
||||
f = next((x for x in fields if x.name == name), None)
|
||||
if not f:
|
||||
continue
|
||||
is_num = f.dtype in ("int", "float")
|
||||
detail_columns.append(AnalysisColumn(
|
||||
field=f.name,
|
||||
label=f.label or f.name,
|
||||
type="number" if is_num else "string",
|
||||
sortable=is_num,
|
||||
precision=2 if f.dtype == "float" else None,
|
||||
))
|
||||
menus.append(AnalysisMenu(
|
||||
id="concept_analysis",
|
||||
label="概念分析",
|
||||
icon="tags",
|
||||
data_source=cfg.id,
|
||||
template="dimension_rank",
|
||||
dimension_field=concept.name,
|
||||
group_columns=[
|
||||
AnalysisColumn(field="__dimension", label="概念"),
|
||||
AnalysisColumn(field="__count", label="股票数", type="number", sortable=True),
|
||||
],
|
||||
detail_columns=detail_columns,
|
||||
default_sort=DefaultSort(field="人气排名", order="asc") if any(c.field == "人气排名" for c in detail_columns) else None,
|
||||
order=100,
|
||||
builtin=True,
|
||||
))
|
||||
break
|
||||
return menus
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_menus(request: Request):
|
||||
saved = _load_saved(request)
|
||||
saved_ids = {m.id for m in saved}
|
||||
defaults = [m for m in _default_menus(request) if m.id not in saved_ids]
|
||||
return {"items": _ordered(saved + defaults)}
|
||||
|
||||
|
||||
@router.get("/{menu_id}")
|
||||
def get_menu(request: Request, menu_id: str):
|
||||
for menu in _ordered(_load_saved(request) + _default_menus(request)):
|
||||
if menu.id == menu_id:
|
||||
return menu
|
||||
raise HTTPException(404, f"分析菜单 '{menu_id}' 不存在")
|
||||
|
||||
|
||||
@router.post("/reorder")
|
||||
def reorder_menus(request: Request, body: ReorderMenusReq):
|
||||
saved = {m.id: m for m in _load_saved(request)}
|
||||
defaults = {m.id: m for m in _default_menus(request)}
|
||||
for idx, menu_id in enumerate(body.ids):
|
||||
menu = saved.get(menu_id) or defaults.get(menu_id)
|
||||
if not menu:
|
||||
continue
|
||||
menu.order = idx
|
||||
_save(request, menu)
|
||||
return {"items": _ordered(_load_saved(request))}
|
||||
|
||||
|
||||
@router.post("/{menu_id}")
|
||||
def upsert_menu(request: Request, menu_id: str, body: UpsertAnalysisMenu):
|
||||
if not menu_id.replace("_", "").isalnum():
|
||||
raise HTTPException(400, "菜单标识只能包含字母、数字和下划线")
|
||||
existing = next((m for m in _load_saved(request) if m.id == menu_id), None)
|
||||
menu = AnalysisMenu(
|
||||
id=menu_id,
|
||||
created_at=existing.created_at if existing else None,
|
||||
**body.model_dump(),
|
||||
)
|
||||
return _save(request, menu)
|
||||
|
||||
|
||||
@router.delete("/{menu_id}")
|
||||
def delete_menu(request: Request, menu_id: str):
|
||||
p = _path(request, menu_id)
|
||||
if not p.exists():
|
||||
raise HTTPException(404, f"分析菜单 '{menu_id}' 不存在或为默认菜单")
|
||||
p.unlink()
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""访问门控 API 与管理接口。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from app import auth
|
||||
from app import uuid_store
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
def verify_credential(req: auth.VerifyIn) -> auth.VerifyOut:
|
||||
"""校验管理员令牌或普通 UUID,成功后返回访问令牌及角色。"""
|
||||
role = auth.verify_credential(req.credential)
|
||||
if role:
|
||||
token = auth.create_access_token(role)
|
||||
return auth.VerifyOut(valid=True, role=role.value, token=token)
|
||||
return auth.VerifyOut(valid=False, role=None, token=None)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def auth_status(request: Request) -> auth.AuthStatusOut:
|
||||
"""返回当前门控状态、当前请求是否通过校验及角色。"""
|
||||
enabled = auth.access_control_enabled()
|
||||
token = auth.get_access_token_from_request(request)
|
||||
role = auth.validate_access_token(token)
|
||||
return auth.AuthStatusOut(
|
||||
enabled=enabled,
|
||||
verified=role is not None,
|
||||
role=role.value if role else None,
|
||||
)
|
||||
|
||||
|
||||
# ===== 管理员 UUID 管理 =====
|
||||
|
||||
@admin_router.get("/uuids")
|
||||
def list_uuids(request: Request) -> list[auth.UuidRecordOut]:
|
||||
"""列出所有动态 UUID(仅管理员)。"""
|
||||
auth.require_admin(request)
|
||||
records = uuid_store.list_uuids()
|
||||
return [
|
||||
auth.UuidRecordOut(
|
||||
uuid=r["uuid"],
|
||||
label=r.get("label", ""),
|
||||
enabled=r.get("enabled", True),
|
||||
created_at=r.get("created_at", 0),
|
||||
)
|
||||
for r in records
|
||||
]
|
||||
|
||||
|
||||
@admin_router.post("/uuids")
|
||||
def create_uuid(req: auth.UuidCreateIn, request: Request) -> auth.UuidRecordOut:
|
||||
"""创建新的访问 UUID(仅管理员)。"""
|
||||
auth.require_admin(request)
|
||||
record = uuid_store.create(req.label)
|
||||
return auth.UuidRecordOut(
|
||||
uuid=record["uuid"],
|
||||
label=record["label"],
|
||||
enabled=record["enabled"],
|
||||
created_at=record["created_at"],
|
||||
)
|
||||
|
||||
|
||||
@admin_router.delete("/uuids/{uuid}")
|
||||
def delete_uuid(uuid: str, request: Request) -> dict:
|
||||
"""删除访问 UUID(仅管理员)。"""
|
||||
auth.require_admin(request)
|
||||
ok = uuid_store.delete(uuid)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
@admin_router.put("/uuids/{uuid}/toggle")
|
||||
def toggle_uuid(uuid: str, request: Request, enabled: bool) -> dict:
|
||||
"""启用/禁用访问 UUID(仅管理员)。"""
|
||||
auth.require_admin(request)
|
||||
ok = uuid_store.toggle(uuid, enabled)
|
||||
return {"ok": ok}
|
||||
@@ -0,0 +1,474 @@
|
||||
"""回测 API — 信号回测 + 因子回测 + 策略回测。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from dataclasses import asdict
|
||||
from datetime import date, timedelta
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
from app.services.backtest import (
|
||||
BacktestConfig,
|
||||
BacktestService,
|
||||
VectorbtUnavailable,
|
||||
is_available,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/backtest", tags=["backtest"])
|
||||
|
||||
FACTOR_DEFAULT_DAYS = 180
|
||||
STRATEGY_DEFAULT_DAYS = 365 * 3
|
||||
BACKTEST_MAX_SERVER_DAYS = 186
|
||||
FACTOR_MAX_SYMBOLS = 1000
|
||||
BACKTEST_SERVER_GUARD_MESSAGE = (
|
||||
"当前服务器内存约 1.8GB,回测区间最多支持 6 个月;"
|
||||
"更长周期容易触发 OOM,建议在 8GB 以上内存环境或本机运行。"
|
||||
)
|
||||
|
||||
|
||||
def _get_engine(request: Request):
|
||||
"""获取或创建 BacktestEngine (单例,PanelCache 跨请求生效)。"""
|
||||
from app.backtest.engine import BacktestEngine
|
||||
engine = getattr(request.app.state, "backtest_engine", None)
|
||||
if engine is None:
|
||||
engine = BacktestEngine(request.app.state.repo)
|
||||
request.app.state.backtest_engine = engine
|
||||
return engine
|
||||
|
||||
|
||||
def _resolve_start(req: BaseModel, end: date, default_days: int) -> date:
|
||||
"""未传 start 使用默认区间;显式传 null/空值表示全部历史。"""
|
||||
start = getattr(req, "start")
|
||||
if start is not None:
|
||||
return start
|
||||
if "start" in req.model_fields_set:
|
||||
return date(1900, 1, 1)
|
||||
return end - timedelta(days=default_days)
|
||||
|
||||
|
||||
def _guard_server_backtest_range(start: date, end: date):
|
||||
if not settings.backtest_range_guard:
|
||||
return
|
||||
days = (end - start).days + 1
|
||||
if days > BACKTEST_MAX_SERVER_DAYS:
|
||||
raise HTTPException(status_code=400, detail=BACKTEST_SERVER_GUARD_MESSAGE)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 状态
|
||||
# ================================================================
|
||||
|
||||
@router.get("/status")
|
||||
def status():
|
||||
"""前端可用此接口判断回测页是否要灰显。"""
|
||||
return {"available": True}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 信号回测 (现有接口,保持不变)
|
||||
# ================================================================
|
||||
|
||||
class BacktestRequest(BaseModel):
|
||||
symbols: list[str] = Field(..., min_length=1)
|
||||
start: date | None = None
|
||||
end: date | None = None
|
||||
entries: list[str] = []
|
||||
exits: list[str] = []
|
||||
stop_loss_pct: float | None = None
|
||||
max_hold_days: int | None = None
|
||||
fees_pct: float = 0.0002
|
||||
slippage_bps: float = 5
|
||||
matching: Literal["close_t", "open_t+1"] = "close_t"
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
def run(req: BacktestRequest, request: Request):
|
||||
"""信号回测 — 现有接口,向后兼容。"""
|
||||
repo = request.app.state.repo
|
||||
svc = BacktestService(repo)
|
||||
end = req.end or date.today()
|
||||
start = req.start or (end - timedelta(days=365 * 3))
|
||||
|
||||
cfg = BacktestConfig(
|
||||
symbols=req.symbols,
|
||||
start=start,
|
||||
end=end,
|
||||
entries=req.entries,
|
||||
exits=req.exits,
|
||||
stop_loss_pct=req.stop_loss_pct,
|
||||
max_hold_days=req.max_hold_days,
|
||||
fees_pct=req.fees_pct,
|
||||
slippage_bps=req.slippage_bps,
|
||||
matching=req.matching,
|
||||
)
|
||||
try:
|
||||
result = svc.run(cfg)
|
||||
except VectorbtUnavailable as e:
|
||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||
return asdict(result)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 因子回测
|
||||
# ================================================================
|
||||
|
||||
class FactorColumnsResponse(BaseModel):
|
||||
columns: list[dict]
|
||||
|
||||
|
||||
@router.get("/factor/columns")
|
||||
def factor_columns():
|
||||
"""返回可用的因子列列表。"""
|
||||
from app.backtest.factor import FACTOR_COLUMNS
|
||||
return {"columns": FACTOR_COLUMNS}
|
||||
|
||||
|
||||
class FactorBacktestRequest(BaseModel):
|
||||
factor_name: str
|
||||
symbols: list[str] | None = None
|
||||
start: date | None = None
|
||||
end: date | None = None
|
||||
n_groups: int = 5
|
||||
rebalance: Literal["daily", "weekly", "monthly"] = "monthly"
|
||||
weight: Literal["equal", "factor_weight"] = "equal"
|
||||
fees_pct: float = 0.0002
|
||||
slippage_bps: float = 5.0
|
||||
|
||||
|
||||
@router.post("/factor/run")
|
||||
def factor_run(req: FactorBacktestRequest, request: Request):
|
||||
"""因子回测 — IC/IR 分析 + 分层回测。"""
|
||||
from app.backtest.factor import FactorBacktestService, FactorConfig
|
||||
|
||||
engine = _get_engine(request)
|
||||
svc = FactorBacktestService(engine)
|
||||
|
||||
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} 只,请缩小标的范围。",
|
||||
)
|
||||
|
||||
cfg = FactorConfig(
|
||||
factor_name=req.factor_name,
|
||||
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,
|
||||
)
|
||||
result = svc.run(cfg)
|
||||
return asdict(result)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 策略回测
|
||||
# ================================================================
|
||||
|
||||
class StrategyBacktestRequest(BaseModel):
|
||||
strategy_id: str
|
||||
symbols: list[str] | None = None
|
||||
start: date | None = None
|
||||
end: date | None = None
|
||||
params: dict | None = None
|
||||
overrides: dict | None = None
|
||||
# matching 向后兼容; 显式传 entry_fill/exit_fill 时以二者为准。
|
||||
matching: Literal["close_t", "open_t+1"] = "open_t+1"
|
||||
entry_fill: Literal["close_t", "open_t+1"] | None = None
|
||||
exit_fill: Literal["close_t", "open_t+1"] | None = None
|
||||
fees_pct: float = 0.0002
|
||||
slippage_bps: float = 5.0
|
||||
max_positions: int = 10
|
||||
max_exposure_pct: float = 1.0
|
||||
initial_capital: float = 1_000_000.0
|
||||
position_sizing: Literal["equal", "score_weight"] = "equal"
|
||||
mode: Literal["position", "full"] = "position"
|
||||
holding_days: int = 5
|
||||
|
||||
|
||||
@router.post("/strategy/run")
|
||||
def strategy_run(req: StrategyBacktestRequest, request: Request):
|
||||
"""策略回测 — 复用 StrategyDef 体系做全周期回测。"""
|
||||
from app.backtest.strategy import StrategyBacktestService, StrategyBacktestConfig
|
||||
|
||||
engine = _get_engine(request)
|
||||
strategy_engine = request.app.state.strategy_engine
|
||||
svc = StrategyBacktestService(engine, strategy_engine)
|
||||
|
||||
end = req.end or date.today()
|
||||
start = _resolve_start(req, end, FACTOR_DEFAULT_DAYS)
|
||||
_guard_server_backtest_range(start, end)
|
||||
|
||||
cfg = StrategyBacktestConfig(
|
||||
strategy_id=req.strategy_id,
|
||||
symbols=req.symbols if req.symbols else None,
|
||||
start=start,
|
||||
end=end,
|
||||
params=req.params,
|
||||
overrides=req.overrides,
|
||||
matching=req.matching,
|
||||
entry_fill=req.entry_fill,
|
||||
exit_fill=req.exit_fill,
|
||||
fees_pct=req.fees_pct,
|
||||
slippage_bps=req.slippage_bps,
|
||||
max_positions=req.max_positions,
|
||||
max_exposure_pct=req.max_exposure_pct,
|
||||
initial_capital=req.initial_capital,
|
||||
position_sizing=req.position_sizing,
|
||||
mode=req.mode,
|
||||
holding_days=req.holding_days,
|
||||
)
|
||||
result = svc.run(cfg)
|
||||
return asdict(result)
|
||||
|
||||
|
||||
# ── SSE 流式回测 (实时进度 + 可取消 + 支持重连) ───────────────────
|
||||
|
||||
import time
|
||||
import hashlib
|
||||
|
||||
|
||||
class _BacktestJob:
|
||||
"""单个回测任务的状态, 存模块级供重连使用。"""
|
||||
__slots__ = ("key", "cancel_event", "progress", "result", "error", "done", "finish_ts")
|
||||
|
||||
def __init__(self, key: str):
|
||||
self.key = key
|
||||
self.cancel_event = threading.Event()
|
||||
self.progress: list[dict] = [] # 进度历史 (新连接可回放)
|
||||
self.result = None # 完成后的结果
|
||||
self.error: str | None = None
|
||||
self.done = False
|
||||
self.finish_ts: float = 0.0
|
||||
|
||||
|
||||
# 模块级任务表: key -> _BacktestJob
|
||||
_running_jobs: dict[str, _BacktestJob] = {}
|
||||
_jobs_lock = threading.Lock()
|
||||
_JOB_TTL = 300 # 完成后保留 5 分钟
|
||||
|
||||
|
||||
def _cleanup_stale_jobs():
|
||||
"""清理过期任务 (完成超过 TTL 的)。"""
|
||||
now = time.time()
|
||||
stale = [k for k, j in _running_jobs.items() if j.done and now - j.finish_ts > _JOB_TTL]
|
||||
for k in stale:
|
||||
_running_jobs.pop(k, None)
|
||||
|
||||
|
||||
def _make_job_key(
|
||||
strategy_id: str, symbols: str | None, start: str | None, end: str | None,
|
||||
matching: str, entry_fill: str | None, exit_fill: str | None,
|
||||
fees_pct: float, slippage_bps: float,
|
||||
max_positions: int, max_exposure_pct: float, initial_capital: float, position_sizing: str,
|
||||
params: str | None, overrides: str | None,
|
||||
mode: str = "position", holding_days: int = 5,
|
||||
) -> str:
|
||||
raw = f"{strategy_id}|{symbols}|{start}|{end}|{matching}|{entry_fill}|{exit_fill}|{fees_pct}|{slippage_bps}|{max_positions}|{max_exposure_pct}|{initial_capital}|{position_sizing}|{params}|{overrides}|{mode}|{holding_days}"
|
||||
return hashlib.md5(raw.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
@router.get("/strategy/stream")
|
||||
async def strategy_stream(
|
||||
request: Request,
|
||||
strategy_id: str,
|
||||
symbols: str | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
matching: str = "open_t+1",
|
||||
entry_fill: str | None = None,
|
||||
exit_fill: str | None = None,
|
||||
fees_pct: float = 0.0002,
|
||||
slippage_bps: float = 5.0,
|
||||
max_positions: int = 10,
|
||||
max_exposure_pct: float = 1.0,
|
||||
initial_capital: float = 1_000_000.0,
|
||||
position_sizing: str = "equal",
|
||||
params: str | None = None,
|
||||
overrides: str | None = None,
|
||||
mode: str = "position",
|
||||
holding_days: int = 5,
|
||||
):
|
||||
"""SSE 流式策略回测: 实时推送进度, 完成后推送结果, 支持重连 (刷新/切页后恢复)。
|
||||
|
||||
- 相同参数的任务只启动一次, 多次连接订阅同一个任务
|
||||
- 断开连接不会取消任务 (除非显式调用 cancel)
|
||||
- 结果保留 5 分钟供重连
|
||||
|
||||
事件类型:
|
||||
- progress: {day, total, date, equity}
|
||||
- done: {result} (完整回测结果)
|
||||
- error: {message}
|
||||
"""
|
||||
from app.backtest.strategy import StrategyBacktestService, StrategyBacktestConfig
|
||||
|
||||
engine = _get_engine(request)
|
||||
strategy_engine = request.app.state.strategy_engine
|
||||
svc = StrategyBacktestService(engine, strategy_engine)
|
||||
|
||||
end_date = date.fromisoformat(end) if end else date.today()
|
||||
if start:
|
||||
start_date = date.fromisoformat(start)
|
||||
else:
|
||||
# 空 start = 全部历史: 用本地最早日K日期, 查不到再回退到默认窗口
|
||||
earliest = request.app.state.repo.earliest_daily_date()
|
||||
start_date = earliest or (end_date - timedelta(days=FACTOR_DEFAULT_DAYS))
|
||||
|
||||
# 服务端范围保护
|
||||
guard_violated = False
|
||||
if settings.backtest_range_guard:
|
||||
days = (end_date - start_date).days + 1
|
||||
if days > BACKTEST_MAX_SERVER_DAYS:
|
||||
guard_violated = True
|
||||
|
||||
job_key = _make_job_key(
|
||||
strategy_id, symbols, start, end,
|
||||
matching, entry_fill, exit_fill,
|
||||
fees_pct, slippage_bps, max_positions, max_exposure_pct, initial_capital, position_sizing,
|
||||
params, overrides,
|
||||
mode, holding_days,
|
||||
)
|
||||
|
||||
_cleanup_stale_jobs()
|
||||
|
||||
# 获取或创建任务
|
||||
with _jobs_lock:
|
||||
job = _running_jobs.get(job_key)
|
||||
if job is None:
|
||||
job = _BacktestJob(job_key)
|
||||
_running_jobs[job_key] = job
|
||||
is_new = True
|
||||
else:
|
||||
is_new = False
|
||||
|
||||
async def event_generator():
|
||||
# 范围保护: 直接报错
|
||||
if guard_violated:
|
||||
yield f"event: error\ndata: {json.dumps({'message': BACKTEST_SERVER_GUARD_MESSAGE}, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
# 如果是新任务, 启动回测线程
|
||||
if is_new and not job.done:
|
||||
cfg = StrategyBacktestConfig(
|
||||
strategy_id=strategy_id,
|
||||
symbols=[s.strip() for s in symbols.split(",") if s.strip()] if symbols else None,
|
||||
start=start_date,
|
||||
end=end_date,
|
||||
params=json.loads(params) if params else None,
|
||||
overrides=json.loads(overrides) if overrides else None,
|
||||
matching=matching,
|
||||
entry_fill=entry_fill,
|
||||
exit_fill=exit_fill,
|
||||
fees_pct=fees_pct,
|
||||
slippage_bps=slippage_bps,
|
||||
max_positions=int(max_positions),
|
||||
max_exposure_pct=float(max_exposure_pct),
|
||||
initial_capital=float(initial_capital),
|
||||
position_sizing=position_sizing,
|
||||
mode=mode,
|
||||
holding_days=int(holding_days),
|
||||
)
|
||||
|
||||
def _run_backtest():
|
||||
try:
|
||||
result = svc.run(cfg, lambda d: job.progress.append(d), job.cancel_event)
|
||||
job.result = result
|
||||
job.done = True
|
||||
job.finish_ts = time.time()
|
||||
except Exception as e:
|
||||
job.error = str(e)
|
||||
job.done = True
|
||||
job.finish_ts = time.time()
|
||||
|
||||
# 启动后台线程 (不阻塞事件循环)
|
||||
threading.Thread(target=_run_backtest, daemon=True).start()
|
||||
|
||||
# 订阅进度: 用读指针读 job.progress 列表 (多连接互不干扰)
|
||||
cursor = 0
|
||||
tick = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
# 已完成: 推送最终结果/错误并退出
|
||||
if job.done:
|
||||
if job.error:
|
||||
yield f"event: error\ndata: {json.dumps({'message': job.error}, ensure_ascii=False)}\n\n"
|
||||
elif job.result is not None:
|
||||
r = job.result
|
||||
if hasattr(r, "error") and r.error == "cancelled":
|
||||
yield f"event: error\ndata: {json.dumps({'message': '回测已取消'}, ensure_ascii=False)}\n\n"
|
||||
elif hasattr(r, "error") and r.error:
|
||||
yield f"event: error\ndata: {json.dumps({'message': r.error}, ensure_ascii=False)}\n\n"
|
||||
else:
|
||||
yield f"event: done\ndata: {json.dumps(asdict(r), ensure_ascii=False, default=str)}\n\n"
|
||||
return
|
||||
|
||||
# 断开检测: 每 4 轮检查一次 (降低 GIL 抢占频率)
|
||||
tick += 1
|
||||
if tick % 4 == 0 and await request.is_disconnected():
|
||||
break
|
||||
|
||||
# 推送新进度 (从 cursor 开始读)
|
||||
prog_list = job.progress
|
||||
while cursor < len(prog_list):
|
||||
msg = prog_list[cursor]
|
||||
cursor += 1
|
||||
yield f"event: progress\ndata: {json.dumps(msg, ensure_ascii=False, default=str)}\n\n"
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/strategy/cancel")
|
||||
async def strategy_cancel(request: Request):
|
||||
"""取消正在运行的回测任务 (前端传 query string, 后端算 job_key)。"""
|
||||
body = await request.json()
|
||||
qs = body.get("qs", "")
|
||||
# 解析 qs 得到参数
|
||||
from urllib.parse import parse_qs
|
||||
p = parse_qs(qs)
|
||||
def _get(key: str, default: str = "") -> str:
|
||||
return p.get(key, [default])[0]
|
||||
job_key = _make_job_key(
|
||||
_get("strategy_id"),
|
||||
_get("symbols") or None,
|
||||
_get("start") or None,
|
||||
_get("end") or None,
|
||||
_get("matching", "open_t+1"),
|
||||
_get("entry_fill") or None,
|
||||
_get("exit_fill") or None,
|
||||
float(_get("fees_pct", "0.0002")),
|
||||
float(_get("slippage_bps", "5")),
|
||||
int(_get("max_positions", "10")),
|
||||
float(_get("max_exposure_pct", "1")),
|
||||
float(_get("initial_capital", "1000000")),
|
||||
_get("position_sizing", "equal"),
|
||||
_get("params") or None,
|
||||
_get("overrides") or None,
|
||||
_get("mode", "position"),
|
||||
int(_get("holding_days", "5")),
|
||||
)
|
||||
job = _running_jobs.get(job_key)
|
||||
if job and not job.done:
|
||||
job.cancel_event.set()
|
||||
return {"ok": True}
|
||||
return {"ok": False, "message": "任务不存在或已完成"}
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
"""数据画像 API —— 让前端知道"我们本地有什么数据"。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from app.indicators.pipeline import ENRICHED_COLUMNS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/data", tags=["data"])
|
||||
|
||||
# ===== 缓存:storage(文件扫描) + 每张表 aggregate 各自缓存 =====
|
||||
# 同步期间前端 2s 轮一次 status,每张表 aggregate 全表 count + min/max + distinct
|
||||
# 太重,加 TTL + 事件失效。stage 写完只清对应那张表的缓存。
|
||||
|
||||
_TABLE_TTL = 30.0 # 兜底 TTL,即使没人调 invalidate 也会过期
|
||||
_TABLE_TTL_LARGE = 120.0 # 大表(分钟K等)单独 TTL,避免多分区聚合反复重算
|
||||
_STORAGE_TTL = 60.0 # storage 文件扫描独立 TTL,stage 写完不触发重算
|
||||
|
||||
# 聚合慢的大表(分区数多、行数多),使用更长的 TTL
|
||||
_LARGE_TABLES = {"minute"}
|
||||
|
||||
_storage_cache: dict[str, Any] | None = None
|
||||
_storage_cache_ts: float = 0.0
|
||||
_storage_lock = threading.Lock()
|
||||
|
||||
_table_cache: dict[str, dict | None] = {
|
||||
"daily": None,
|
||||
"enriched": None,
|
||||
"index_daily": None,
|
||||
"index_enriched": None,
|
||||
"index_instruments": None,
|
||||
"minute": None,
|
||||
"adj_factor": None,
|
||||
"instruments": None,
|
||||
"financials": None,
|
||||
}
|
||||
_table_cache_ts: dict[str, float] = {k: 0.0 for k in _table_cache}
|
||||
_table_cache_lock = threading.Lock()
|
||||
|
||||
_last_finished_cache: dict[str, str | None] | None = None
|
||||
_last_finished_lock = threading.Lock()
|
||||
|
||||
|
||||
def invalidate_data_cache(table: str | None = None) -> None:
|
||||
"""数据写入/清除后调用。
|
||||
|
||||
table=None 时清所有表 cache + storage(粗粒度,用于 pipeline 完成/clear);
|
||||
指定 table 时只清那张表,不影响 storage(细粒度,用于单 stage 写完)。
|
||||
"""
|
||||
with _table_cache_lock:
|
||||
if table is None:
|
||||
global _storage_cache, _storage_cache_ts, _last_finished_cache
|
||||
_storage_cache = None
|
||||
_storage_cache_ts = 0.0
|
||||
_last_finished_cache = None
|
||||
for k in _table_cache:
|
||||
_table_cache[k] = None
|
||||
_table_cache_ts[k] = 0.0
|
||||
elif table in _table_cache:
|
||||
_table_cache[table] = None
|
||||
_table_cache_ts[table] = 0.0
|
||||
|
||||
|
||||
def invalidate_storage_cache() -> None:
|
||||
"""向后兼容入口 — 清全部缓存。新代码请用 invalidate_data_cache(table)。"""
|
||||
invalidate_data_cache(None)
|
||||
|
||||
|
||||
def _get_table_stats(name: str, fetch: Callable[[], dict | None]) -> dict | None:
|
||||
"""走 TTL+事件 双重缓存。fetch 在锁外执行避免阻塞别的请求。"""
|
||||
ttl = _TABLE_TTL_LARGE if name in _LARGE_TABLES else _TABLE_TTL
|
||||
now = time.time()
|
||||
with _table_cache_lock:
|
||||
cached = _table_cache.get(name)
|
||||
cached_ts = _table_cache_ts.get(name, 0.0)
|
||||
if cached is not None and (now - cached_ts) < ttl:
|
||||
return cached
|
||||
|
||||
fresh = fetch()
|
||||
|
||||
with _table_cache_lock:
|
||||
_table_cache[name] = fresh
|
||||
_table_cache_ts[name] = now
|
||||
return fresh
|
||||
|
||||
|
||||
def _safe_aggregate(repo, view: str) -> dict | None:
|
||||
"""聚合视图基础统计;视图不存在或为空时返 None。"""
|
||||
try:
|
||||
row = repo.execute_one(
|
||||
f"""SELECT count(*) AS rows,
|
||||
min(date) AS earliest,
|
||||
max(date) AS latest,
|
||||
count(DISTINCT symbol) AS symbols,
|
||||
count(DISTINCT date) AS trading_days
|
||||
FROM {view}"""
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("aggregate %s failed: %s", view, e)
|
||||
return None
|
||||
if not row or not row[0]:
|
||||
return None
|
||||
return {
|
||||
"rows": int(row[0]),
|
||||
"earliest_date": str(row[1]) if row[1] else None,
|
||||
"latest_date": str(row[2]) if row[2] else None,
|
||||
"symbols_covered": int(row[3] or 0),
|
||||
"trading_days": int(row[4] or 0),
|
||||
}
|
||||
|
||||
|
||||
def _safe_aggregate_daily(repo, view: str = "kline_daily") -> dict | None:
|
||||
"""日K轻量统计 — 零数据扫描。
|
||||
|
||||
从分区目录名获取日期范围和交易日数,不读任何 parquet。
|
||||
标的数从 instruments 小表获取(~5000行,毫秒级)。
|
||||
"""
|
||||
daily_dir = repo.store.data_dir / "kline_daily"
|
||||
if not daily_dir.exists():
|
||||
return None
|
||||
dates: list[str] = []
|
||||
for d in daily_dir.iterdir():
|
||||
if d.is_dir() and d.name.startswith("date="):
|
||||
dates.append(d.name[5:])
|
||||
if not dates:
|
||||
return None
|
||||
dates.sort()
|
||||
|
||||
symbols = _count_instruments_symbols(repo)
|
||||
|
||||
return {
|
||||
"rows": 0,
|
||||
"earliest_date": dates[0],
|
||||
"latest_date": dates[-1],
|
||||
"symbols_covered": symbols,
|
||||
"trading_days": len(dates),
|
||||
}
|
||||
|
||||
|
||||
def _safe_aggregate_enriched(repo) -> dict | None:
|
||||
"""Enriched 轻量统计 — 零数据扫描。
|
||||
|
||||
字段数从 DESCRIBE 读 schema(不碰数据),毫秒级。
|
||||
日期范围从分区目录名获取(同 minute 策略),不读任何 parquet。
|
||||
标的数从 instruments 小表取。
|
||||
"""
|
||||
# 字段数:读 schema,不碰数据
|
||||
fields = 0
|
||||
try:
|
||||
cols = repo.execute_all("DESCRIBE kline_enriched")
|
||||
fields = len(cols)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# 日期范围:从分区目录名获取,不扫数据
|
||||
enriched_dir = repo.store.data_dir / "kline_daily_enriched"
|
||||
if not enriched_dir.exists():
|
||||
return None
|
||||
dates: list[str] = []
|
||||
for d in enriched_dir.iterdir():
|
||||
if d.is_dir() and d.name.startswith("date="):
|
||||
dates.append(d.name[5:])
|
||||
if not dates:
|
||||
return None
|
||||
dates.sort()
|
||||
|
||||
symbols = _count_instruments_symbols(repo)
|
||||
|
||||
return {
|
||||
"rows": 0,
|
||||
"fields": fields,
|
||||
"earliest_date": dates[0],
|
||||
"latest_date": dates[-1],
|
||||
"symbols_covered": symbols,
|
||||
"trading_days": len(dates),
|
||||
}
|
||||
|
||||
|
||||
def _count_instruments_symbols(repo) -> int:
|
||||
"""从 instruments 小表取标的数(~5000行,毫秒级)。"""
|
||||
try:
|
||||
sym_row = repo.execute_one(
|
||||
"SELECT count(DISTINCT symbol) FROM instruments"
|
||||
)
|
||||
if sym_row and sym_row[0]:
|
||||
return int(sym_row[0])
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _safe_aggregate_instruments(repo) -> dict | None:
|
||||
"""instruments 视图统计(无 date 列,用 as_of)。"""
|
||||
try:
|
||||
row = repo.execute_one(
|
||||
"""SELECT count(*) AS rows,
|
||||
count(DISTINCT symbol) AS symbols,
|
||||
max(as_of) AS latest_as_of,
|
||||
count_if(name IS NOT NULL AND name != '') AS named
|
||||
FROM instruments"""
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("aggregate instruments failed: %s", e)
|
||||
return None
|
||||
if not row or not row[0]:
|
||||
return None
|
||||
return {
|
||||
"rows": int(row[0]),
|
||||
"symbols_covered": int(row[1] or 0),
|
||||
"latest_as_of": str(row[2]) if row[2] else None,
|
||||
"named": int(row[3] or 0),
|
||||
}
|
||||
|
||||
|
||||
def _safe_aggregate_index_daily(repo) -> dict | None:
|
||||
"""指数日K统计。指数数据量较小,直接读取 parquet 元数据统计真实行数。"""
|
||||
return _safe_aggregate(repo, "kline_index_daily")
|
||||
|
||||
|
||||
def _safe_aggregate_index_enriched(repo) -> dict | None:
|
||||
"""指数 enriched 统计。指数数据量较小,直接读取 parquet 元数据统计真实行数。"""
|
||||
fields = 0
|
||||
try:
|
||||
cols = repo.execute_all("DESCRIBE kline_index_enriched")
|
||||
fields = len(cols)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
stats = _safe_aggregate(repo, "kline_index_enriched")
|
||||
if not stats:
|
||||
return None
|
||||
return {**stats, "fields": fields}
|
||||
|
||||
|
||||
def _safe_aggregate_index_instruments(repo) -> dict | None:
|
||||
"""指数 instruments 视图统计。"""
|
||||
try:
|
||||
row = repo.execute_one(
|
||||
"""SELECT count(*) AS rows,
|
||||
count(DISTINCT symbol) AS symbols,
|
||||
count_if(name IS NOT NULL AND name != '') AS named
|
||||
FROM instruments_index"""
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("aggregate instruments_index failed: %s", e)
|
||||
return None
|
||||
if not row or not row[0]:
|
||||
return None
|
||||
return {
|
||||
"rows": int(row[0]),
|
||||
"symbols_covered": int(row[1] or 0),
|
||||
"latest_as_of": None,
|
||||
"named": int(row[2] or 0),
|
||||
}
|
||||
|
||||
|
||||
def _safe_aggregate_adj_factor(repo) -> dict | None:
|
||||
"""adj_factor 视图统计,日期范围对齐日 K 覆盖区间。"""
|
||||
try:
|
||||
# 取日 K 的日期范围作为过滤条件
|
||||
dr = repo.execute_one(
|
||||
"SELECT min(date), max(date) FROM kline_daily"
|
||||
)
|
||||
if not dr or not dr[0]:
|
||||
return None
|
||||
d_min, d_max = dr[0], dr[1]
|
||||
row = repo.execute_one(
|
||||
"""SELECT count(*) AS rows,
|
||||
count(DISTINCT symbol) AS symbols,
|
||||
count(DISTINCT trade_date) AS trading_days
|
||||
FROM adj_factor
|
||||
WHERE trade_date BETWEEN ? AND ?""",
|
||||
[str(d_min), str(d_max)],
|
||||
)
|
||||
if not row or not row[0]:
|
||||
return None
|
||||
return {
|
||||
"rows": int(row[0]),
|
||||
"symbols_covered": int(row[1]) if isinstance(row[1], (int, float)) else 0,
|
||||
"earliest_date": str(d_min),
|
||||
"latest_date": str(d_max),
|
||||
"trading_days": int(row[2] or 0),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("aggregate adj_factor failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _safe_aggregate_minute(repo) -> dict | None:
|
||||
"""kline_minute 统计 — 从分区目录名获取交易日数,跳过全表扫描。
|
||||
|
||||
分钟 K 按 date=YYYY-MM-DD 分区存储,直接数目录即可,
|
||||
无需 count(*) / count(DISTINCT ...) 等昂贵查询。
|
||||
"""
|
||||
minute_dir = repo.store.data_dir / "kline_minute"
|
||||
if not minute_dir.exists():
|
||||
return None
|
||||
|
||||
# 从 date=YYYY-MM-DD 目录名提取交易日
|
||||
dates: list[str] = []
|
||||
for d in minute_dir.iterdir():
|
||||
if d.is_dir() and d.name.startswith("date="):
|
||||
dates.append(d.name[5:])
|
||||
|
||||
if not dates:
|
||||
return None
|
||||
|
||||
dates.sort()
|
||||
return {
|
||||
"rows": 0, # 不再查询行数
|
||||
"earliest_date": dates[0],
|
||||
"latest_date": dates[-1],
|
||||
"symbols_covered": 0, # 不再查询标的数
|
||||
"trading_days": len(dates),
|
||||
}
|
||||
|
||||
|
||||
def _safe_aggregate_financials(repo) -> dict | None:
|
||||
"""财务数据统计 — 检查各表文件是否存在及行数。"""
|
||||
data_dir = repo.store.data_dir
|
||||
tables_info: dict[str, dict] = {}
|
||||
total_rows = 0
|
||||
|
||||
for table in ("metrics", "income", "balance_sheet", "cash_flow"):
|
||||
path = data_dir / "financials" / table / "part.parquet"
|
||||
if path.exists():
|
||||
try:
|
||||
import polars as pl
|
||||
df = pl.read_parquet(path, columns=["symbol"])
|
||||
rows = len(df)
|
||||
symbols = df["symbol"].n_unique() if not df.is_empty() else 0
|
||||
tables_info[table] = {"rows": rows, "symbols": symbols}
|
||||
total_rows += rows
|
||||
except Exception:
|
||||
tables_info[table] = {"rows": 0, "symbols": 0}
|
||||
else:
|
||||
tables_info[table] = {"rows": 0, "symbols": 0}
|
||||
|
||||
if total_rows == 0:
|
||||
return None
|
||||
|
||||
return {
|
||||
"rows": total_rows,
|
||||
"tables": tables_info,
|
||||
}
|
||||
|
||||
|
||||
def _scan_dir_stats(dirpath: Path) -> tuple[int, float]:
|
||||
"""单次遍历统计目录下文件数和总大小(MB)。比 rglob+stat 快很多。"""
|
||||
if not dirpath.exists():
|
||||
return 0, 0.0
|
||||
count = 0
|
||||
total = 0
|
||||
for entry in os.scandir(dirpath):
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
c, s = _scan_dir_recursive(entry)
|
||||
count += c
|
||||
total += s
|
||||
elif entry.is_file(follow_symlinks=False):
|
||||
try:
|
||||
total += entry.stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
count += 1
|
||||
return count, round(total / 1048576, 2)
|
||||
|
||||
|
||||
def _scan_dir_recursive(entry: os.DirEntry) -> tuple[int, int]:
|
||||
"""递归统计一个 DirEntry 下的文件数和总字节数。"""
|
||||
count = 0
|
||||
total = 0
|
||||
try:
|
||||
for sub in os.scandir(entry.path):
|
||||
if sub.is_dir(follow_symlinks=False):
|
||||
c, s = _scan_dir_recursive(sub)
|
||||
count += c
|
||||
total += s
|
||||
elif sub.is_file(follow_symlinks=False):
|
||||
try:
|
||||
total += sub.stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
count += 1
|
||||
except PermissionError:
|
||||
pass
|
||||
return count, total
|
||||
|
||||
|
||||
def _compute_storage(data_dir: Path) -> dict:
|
||||
"""单次遍历计算 storage 统计,避免多次 rglob。"""
|
||||
import os
|
||||
|
||||
# 只统计关心的子目录
|
||||
subdirs = {
|
||||
"daily": data_dir / "kline_daily",
|
||||
"enriched": data_dir / "kline_daily_enriched",
|
||||
"index_daily": data_dir / "kline_index_daily",
|
||||
"index_enriched": data_dir / "kline_index_enriched",
|
||||
"index_instruments": data_dir / "instruments_index",
|
||||
"minute": data_dir / "kline_minute",
|
||||
"adj_factor": data_dir / "adj_factor",
|
||||
"instruments": data_dir / "instruments",
|
||||
"ext_data": data_dir / "ext_data",
|
||||
}
|
||||
stats = {}
|
||||
total_size = 0
|
||||
for key, d in subdirs.items():
|
||||
fc, sz = _scan_dir_stats(d)
|
||||
total_size += sz
|
||||
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"]
|
||||
for name in other_dirs:
|
||||
d = data_dir / name
|
||||
if d.exists():
|
||||
_, s = _scan_dir_stats(d)
|
||||
total_size += s
|
||||
|
||||
# financials 单独统计
|
||||
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):
|
||||
try:
|
||||
total_size += entry.stat().st_size / 1048576
|
||||
except OSError:
|
||||
pass
|
||||
stats["total_size_mb"] = round(total_size, 2)
|
||||
return stats
|
||||
|
||||
|
||||
def _next_cron_run(scheduler, job_id: str) -> str | None:
|
||||
"""读 APScheduler 下次执行时间。"""
|
||||
if not scheduler:
|
||||
return None
|
||||
try:
|
||||
job = scheduler.get_job(job_id)
|
||||
if job and job.next_run_time:
|
||||
return job.next_run_time.isoformat(timespec="seconds")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_storage(data_dir: Path) -> dict:
|
||||
"""返回缓存的 storage 统计;走独立 TTL,stage 写完不触发重算。"""
|
||||
global _storage_cache, _storage_cache_ts
|
||||
now = time.time()
|
||||
with _storage_lock:
|
||||
if _storage_cache is not None and (now - _storage_cache_ts) < _STORAGE_TTL:
|
||||
return _storage_cache
|
||||
fresh = _compute_storage(data_dir)
|
||||
with _storage_lock:
|
||||
_storage_cache = fresh
|
||||
_storage_cache_ts = now
|
||||
return fresh
|
||||
|
||||
|
||||
def _last_finished(job_label: str) -> str | None:
|
||||
"""从 JobStore 读最近一次该类型任务的完成时间(缓存到 pipeline 终态失效)。"""
|
||||
global _last_finished_cache
|
||||
with _last_finished_lock:
|
||||
if _last_finished_cache is not None:
|
||||
return _last_finished_cache.get(job_label)
|
||||
|
||||
from app.services.pipeline_jobs import job_store
|
||||
jobs = job_store.list_recent(limit=50)
|
||||
cache: dict[str, str | None] = {}
|
||||
for j in jobs:
|
||||
if j["status"] not in ("succeeded", "failed"):
|
||||
continue
|
||||
if "instruments_rows" in (j.get("result") or {}) and "instruments" not in cache:
|
||||
cache["instruments"] = j["finished_at"]
|
||||
if "daily_days" in (j.get("result") or {}) and "pipeline" not in cache:
|
||||
cache["pipeline"] = j["finished_at"]
|
||||
with _last_finished_lock:
|
||||
_last_finished_cache = cache
|
||||
return cache.get(job_label)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def status(request: Request) -> dict:
|
||||
repo = request.app.state.repo
|
||||
scheduler = getattr(request.app.state, "scheduler", None)
|
||||
data_dir = repo.store.data_dir
|
||||
|
||||
return {
|
||||
"daily": _get_table_stats("daily", lambda: _safe_aggregate_daily(repo)),
|
||||
"enriched": _get_table_stats("enriched", lambda: _safe_aggregate_enriched(repo)),
|
||||
"index_daily": _get_table_stats("index_daily", lambda: _safe_aggregate_index_daily(repo)),
|
||||
"index_enriched": _get_table_stats("index_enriched", lambda: _safe_aggregate_index_enriched(repo)),
|
||||
"index_instruments": _get_table_stats("index_instruments", lambda: _safe_aggregate_index_instruments(repo)),
|
||||
"minute": _get_table_stats("minute", lambda: _safe_aggregate_minute(repo)),
|
||||
"adj_factor": _get_table_stats("adj_factor", lambda: _safe_aggregate_adj_factor(repo)),
|
||||
"instruments": _get_table_stats("instruments", lambda: _safe_aggregate_instruments(repo)),
|
||||
"financials": _get_table_stats("financials", lambda: _safe_aggregate_financials(repo)),
|
||||
|
||||
# 文件层面信息(缓存)
|
||||
"storage": _get_storage(data_dir),
|
||||
|
||||
# 调度
|
||||
"next_instruments_run": _next_cron_run(scheduler, "pre_market_instruments"),
|
||||
"next_pipeline_run": _next_cron_run(scheduler, "daily_pipeline"),
|
||||
"last_instruments_run": _last_finished("instruments"),
|
||||
"last_pipeline_run": _last_finished("pipeline"),
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/clear")
|
||||
def clear_data(request: Request):
|
||||
"""清除所有本地 Parquet 数据(保留 capabilities.json 和目录结构)。"""
|
||||
import shutil
|
||||
|
||||
repo = request.app.state.repo
|
||||
data_dir = repo.store.data_dir
|
||||
deleted = 0
|
||||
|
||||
for sub in (
|
||||
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched", "kline_minute",
|
||||
"adj_factor", "instruments", "instruments_index", "pools", "financials",
|
||||
"backtest_results", "screener_results", "ai_cache",
|
||||
):
|
||||
d = data_dir / sub
|
||||
if d.exists():
|
||||
# 先删所有 parquet 文件
|
||||
for f in d.rglob("*.parquet"):
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
# 再删除空的日期分区子目录(date=YYYY-MM-DD 等)
|
||||
for child in list(d.iterdir()):
|
||||
if child.is_dir():
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
|
||||
# 清除同步历史(内存 + 磁盘 job_store/ 文件夹)
|
||||
from app.services.pipeline_jobs import job_store
|
||||
job_store.clear()
|
||||
|
||||
# 清除财务数据
|
||||
fin_dir = data_dir / "financials"
|
||||
for sub in ("metrics", "income", "balance_sheet", "cash_flow"):
|
||||
fp = fin_dir / sub / "part.parquet"
|
||||
if fp.exists():
|
||||
fp.unlink()
|
||||
deleted += 1
|
||||
|
||||
# 清除监控运行数据 (user_data 下仅清运行产物, 不动 monitor_rules/preferences/secrets 等用户配置)
|
||||
# - 触发记录 alerts.jsonl
|
||||
from app.services import alert_store
|
||||
alert_store.clear(data_dir)
|
||||
# - 待推送的实时通知队列 (进程内存)
|
||||
qs = getattr(request.app.state, "quote_service", None)
|
||||
if qs is not None:
|
||||
with qs._lock:
|
||||
qs._pending_alerts.clear()
|
||||
|
||||
# 清除 Polars 缓存
|
||||
# 先 clear_cache 无条件清空内存 (refresh_cache 在磁盘无数据时会提前 return,
|
||||
# 导致 _enriched_cache 等旧数据残留 —— 清数据后看板仍显示旧数据的根因),
|
||||
# 再 refresh_cache 尝试重载 (磁盘有数据则重建缓存)。
|
||||
repo.clear_cache()
|
||||
repo.refresh_cache()
|
||||
|
||||
# 清除 Screener 进程级 _history_cache (TTL 缓存)
|
||||
from app.services.screener import ScreenerService
|
||||
ScreenerService.clear_history_cache()
|
||||
|
||||
# 清除 Overview 总览聚合结果缓存 (5s TTL)
|
||||
from app.api.overview import invalidate_overview_cache
|
||||
invalidate_overview_cache()
|
||||
|
||||
# 刷新 DuckDB 视图(空 parquet 目录也需要重新挂载)
|
||||
d = data_dir.as_posix()
|
||||
for name, path in {
|
||||
"kline_daily": f"{d}/kline_daily/**/*.parquet",
|
||||
"kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet",
|
||||
"kline_index_daily": f"{d}/kline_index_daily/**/*.parquet",
|
||||
"kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet",
|
||||
"kline_minute": f"{d}/kline_minute/**/*.parquet",
|
||||
"adj_factor": f"{d}/adj_factor/**/*.parquet",
|
||||
"instruments": f"{d}/instruments/**/*.parquet",
|
||||
"instruments_index": f"{d}/instruments_index/**/*.parquet",
|
||||
}.items():
|
||||
try:
|
||||
repo.db.execute(
|
||||
f"CREATE OR REPLACE VIEW {name} AS "
|
||||
f"SELECT * FROM read_parquet('{path}', union_by_name=true)"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info("数据已清除: 删除 %d 个 parquet 文件", deleted)
|
||||
invalidate_data_cache(None)
|
||||
return {"deleted_files": deleted}
|
||||
|
||||
|
||||
# 各表字段说明
|
||||
_TABLE_FIELD_DESC: dict[str, dict[str, str]] = {
|
||||
"kline_daily": {
|
||||
"symbol": "股票代码",
|
||||
"date": "交易日期",
|
||||
"open": "开盘价",
|
||||
"high": "最高价",
|
||||
"low": "最低价",
|
||||
"close": "收盘价",
|
||||
"volume": "成交量",
|
||||
"amount": "成交额",
|
||||
},
|
||||
"kline_enriched": ENRICHED_COLUMNS,
|
||||
"kline_index_daily": {
|
||||
"symbol": "指数代码",
|
||||
"date": "交易日期",
|
||||
"open": "开盘点位",
|
||||
"high": "最高点位",
|
||||
"low": "最低点位",
|
||||
"close": "收盘点位",
|
||||
"volume": "成交量",
|
||||
"amount": "成交额",
|
||||
},
|
||||
"kline_index_enriched": ENRICHED_COLUMNS,
|
||||
"kline_minute": {
|
||||
"symbol": "股票代码",
|
||||
"datetime": "分钟时间戳",
|
||||
"open": "开盘价",
|
||||
"high": "最高价",
|
||||
"low": "最低价",
|
||||
"close": "收盘价",
|
||||
"volume": "成交量",
|
||||
"amount": "成交额",
|
||||
},
|
||||
"adj_factor": {
|
||||
"symbol": "股票代码",
|
||||
"timestamp": "除权除息时间戳(ms)",
|
||||
"trade_date": "除权除息日",
|
||||
"ex_factor": "复权因子",
|
||||
},
|
||||
"instruments": {
|
||||
"symbol": "股票代码",
|
||||
"name": "股票名称",
|
||||
"code": "股票编码(纯数字)",
|
||||
"exchange": "交易所(SH/SZ/BJ)",
|
||||
"region": "地区",
|
||||
"type": "证券类型",
|
||||
"listing_date": "上市日期",
|
||||
"total_shares": "总股本",
|
||||
"float_shares": "流通股本",
|
||||
"tick_size": "最小价格变动单位",
|
||||
"limit_up": "涨停限制(%)",
|
||||
"limit_down": "跌停限制(%)",
|
||||
"as_of": "快照日期",
|
||||
},
|
||||
"instruments_index": {
|
||||
"symbol": "指数代码",
|
||||
"name": "指数名称",
|
||||
"code": "指数编码(纯数字)",
|
||||
"asset_type": "资产类型(index)",
|
||||
},
|
||||
}
|
||||
|
||||
# view 名 → DuckDB 视图名
|
||||
_SCHEMA_VIEWS: dict[str, str] = {
|
||||
"daily": "kline_daily",
|
||||
"enriched": "kline_enriched",
|
||||
"index_daily": "kline_index_daily",
|
||||
"index_enriched": "kline_index_enriched",
|
||||
"index_instruments": "instruments_index",
|
||||
"minute": "kline_minute",
|
||||
"adj_factor": "adj_factor",
|
||||
"instruments": "instruments",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/schema/{table}")
|
||||
def table_schema(request: Request, table: str) -> list[dict]:
|
||||
"""返回指定表的字段名、类型和中文说明。
|
||||
|
||||
优先从 DuckDB DESCRIBE 读取(有数据时含精确类型);
|
||||
视图不存在(无数据)时回退到 _TABLE_FIELD_DESC 静态定义。
|
||||
"""
|
||||
view = _SCHEMA_VIEWS.get(table)
|
||||
if not view:
|
||||
return []
|
||||
desc_map = _TABLE_FIELD_DESC.get(view, {})
|
||||
repo = request.app.state.repo
|
||||
fields: list[dict] = []
|
||||
try:
|
||||
cols = repo.execute_all(f"DESCRIBE {view}")
|
||||
for col in cols:
|
||||
name = col[0]
|
||||
dtype = col[1]
|
||||
fields.append({
|
||||
"name": name,
|
||||
"type": dtype,
|
||||
"desc": desc_map.get(name, ""),
|
||||
})
|
||||
except Exception: # noqa: BLE001
|
||||
# 视图不存在(本地无数据),用静态字段定义兜底
|
||||
if desc_map:
|
||||
for name, desc in desc_map.items():
|
||||
fields.append({"name": name, "type": "—", "desc": desc})
|
||||
return fields
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
def get_version(request: Request) -> dict:
|
||||
"""返回当前项目版本号。
|
||||
|
||||
优先读 app.__version__ (与 /health 接口同源, 唯一权威版本),
|
||||
回退到项目根 VERSION 文件, 最后兜底 v0.0.0。
|
||||
"""
|
||||
from app import __version__
|
||||
|
||||
# 1. 优先用 app.__version__ (开发期 bump_version.py 写入)
|
||||
if __version__:
|
||||
v = __version__.strip()
|
||||
return {"version": v if v.startswith("v") else f"v{v}"}
|
||||
|
||||
# 2. 回退到项目根 VERSION 文件
|
||||
from app.config import settings
|
||||
project_root = Path(settings.data_dir).parent
|
||||
version_file = project_root / "VERSION"
|
||||
if version_file.exists():
|
||||
v = version_file.read_text(encoding="utf-8").strip()
|
||||
if v:
|
||||
return {"version": v}
|
||||
|
||||
return {"version": "v0.0.0"}
|
||||
@@ -0,0 +1,826 @@
|
||||
"""扩展数据 API — CRUD + 文件上传 + JSON 写入 + 定时拉取 + schema 发现。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import polars as pl
|
||||
from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services.ext_data import (
|
||||
ExtConfig,
|
||||
ExtConfigStore,
|
||||
ExtField,
|
||||
PullConfig,
|
||||
ensure_utf8_csv,
|
||||
fix_symbol_format,
|
||||
normalize_symbol,
|
||||
parse_upload_file,
|
||||
write_ext_parquet,
|
||||
rows_to_parquet,
|
||||
)
|
||||
from app.services.ext_pull import fetch_and_ingest, pull_scheduler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/ext-data", tags=["ext-data"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic 模型
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FieldDef(BaseModel):
|
||||
name: str
|
||||
dtype: str = "string" # string | int | float | bool
|
||||
label: str = ""
|
||||
|
||||
|
||||
class CreateExtReq(BaseModel):
|
||||
id: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_]+$")
|
||||
label: str = Field(..., min_length=1, max_length=64)
|
||||
mode: Literal["snapshot", "timeseries"]
|
||||
fields: list[FieldDef] = Field(..., min_length=1)
|
||||
description: str = ""
|
||||
symbol_map: dict = {} # {"type": "mapped", "col": "..."} 或 {"type": "computed", "from": "code", "method": "append_exchange"}
|
||||
code_map: dict = {} # {"type": "mapped", "col": "..."} 或 {"type": "computed", "from": "symbol", "method": "strip_exchange"}
|
||||
|
||||
|
||||
class UpdateExtReq(BaseModel):
|
||||
label: str | None = None
|
||||
fields: list[FieldDef] | None = None
|
||||
description: str | None = None
|
||||
symbol_map: dict | None = None
|
||||
code_map: dict | None = None
|
||||
|
||||
|
||||
class IngestReq(BaseModel):
|
||||
"""JSON 批量写入请求。"""
|
||||
date: str | None = None # YYYY-MM-DD,不传默认今天
|
||||
rows: list[dict] = Field(..., min_length=1)
|
||||
|
||||
|
||||
class PullConfigReq(BaseModel):
|
||||
"""定时拉取配置请求。"""
|
||||
url: str = Field(..., min_length=1)
|
||||
method: str = "GET"
|
||||
headers: dict[str, str] | None = None
|
||||
body: str | None = None
|
||||
response_path: str = "" # dot-path to rows array
|
||||
field_map: dict[str, str] | None = None # external → internal field name
|
||||
schedule_minutes: int = Field(1440, ge=1)
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 辅助
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _store(request: Request) -> ExtConfigStore:
|
||||
return ExtConfigStore(request.app.state.repo.store.data_dir)
|
||||
|
||||
|
||||
def _data_dir(request: Request) -> Path:
|
||||
return request.app.state.repo.store.data_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _apply_mapping(df: pl.DataFrame, config: ExtConfig, data_dir: Path) -> pl.DataFrame:
|
||||
"""根据 config 的 symbol_map / code_map 自动生成 symbol 和 code 列。
|
||||
|
||||
执行顺序:先 mapped(从文件列复制),再 computed(从已生成的列计算)。
|
||||
"""
|
||||
sm = config.symbol_map or {}
|
||||
cm = config.code_map or {}
|
||||
|
||||
# --- 第一步:mapped 类型,直接从文件列映射 ---
|
||||
if sm.get("type") == "mapped" and sm["col"] in df.columns:
|
||||
df = df.with_columns(df[sm["col"]].cast(pl.Utf8).alias("symbol"))
|
||||
|
||||
if cm.get("type") == "mapped" and cm["col"] in df.columns:
|
||||
df = df.with_columns(df[cm["col"]].cast(pl.Utf8).alias("code"))
|
||||
|
||||
# --- 第二步:computed 类型,从已生成的列计算 ---
|
||||
if "symbol" not in df.columns and sm.get("type") == "computed":
|
||||
if sm.get("from") == "code" and "code" in df.columns:
|
||||
# code → symbol: 000001 → 000001.SZ
|
||||
from app.services.ext_data import build_code_lookup
|
||||
lookup = build_code_lookup(data_dir)
|
||||
df = df.with_columns(normalize_symbol(df["code"].cast(pl.Utf8), lookup).alias("symbol"))
|
||||
|
||||
if "code" not in df.columns and cm.get("type") == "computed":
|
||||
if cm.get("from") == "symbol" and "symbol" in df.columns:
|
||||
# symbol → code: 000001.SZ → 000001
|
||||
df = df.with_columns(
|
||||
df["symbol"].cast(pl.Utf8).str.split(".").list.first().alias("code")
|
||||
)
|
||||
|
||||
# --- 兜底:如果只有一个,自动生成另一个 ---
|
||||
if "symbol" in df.columns and "code" not in df.columns:
|
||||
df = df.with_columns(
|
||||
df["symbol"].cast(pl.Utf8).str.split(".").list.first().alias("code")
|
||||
)
|
||||
elif "code" in df.columns and "symbol" not in df.columns:
|
||||
from app.services.ext_data import build_code_lookup
|
||||
lookup = build_code_lookup(data_dir)
|
||||
df = df.with_columns(normalize_symbol(df["code"].cast(pl.Utf8), lookup).alias("symbol"))
|
||||
|
||||
# 标准化 symbol 列
|
||||
if "symbol" in df.columns:
|
||||
from app.services.ext_data import build_code_lookup
|
||||
lookup = build_code_lookup(data_dir)
|
||||
df = df.with_columns(normalize_symbol(df["symbol"].cast(pl.Utf8), lookup))
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def _clean_col_names(df: pl.DataFrame) -> pl.DataFrame:
|
||||
"""清洗列名:去掉所有 (...) 及其内容,避免时间戳导致列名不稳定。"""
|
||||
import re
|
||||
renames = {col: re.sub(r"\([^)]*\)", "", col).strip() for col in df.columns}
|
||||
# 去重:如果清洗后重名,加序号后缀
|
||||
seen: dict[str, int] = {}
|
||||
final = {}
|
||||
for old, new in renames.items():
|
||||
if new in seen:
|
||||
seen[new] += 1
|
||||
final[old] = f"{new}_{seen[new]}"
|
||||
else:
|
||||
seen[new] = 0
|
||||
final[old] = new
|
||||
return df.rename(final)
|
||||
|
||||
|
||||
def _ext_data_dir(config: ExtConfig, data_dir: Path) -> Path:
|
||||
"""返回扩展数据的数据目录。
|
||||
|
||||
- snapshot: data/ext_data/{id}/(part.parquet 与 config.json 同级)
|
||||
- timeseries: data/ext_data/{id}/timeseries/
|
||||
"""
|
||||
cfg_dir = data_dir / "ext_data" / config.id
|
||||
if config.mode == "timeseries":
|
||||
return cfg_dir / "timeseries"
|
||||
return cfg_dir
|
||||
|
||||
|
||||
def _parquet_glob(config: ExtConfig, data_dir: Path) -> str:
|
||||
"""返回该扩展配置下所有 parquet 文件的 glob 模式。
|
||||
|
||||
snapshot: 'data/ext_data/{id}/*.parquet'(只有 part.parquet)
|
||||
timeseries: 'data/ext_data/{id}/timeseries/**/*.parquet'
|
||||
"""
|
||||
cfg_dir = data_dir / "ext_data" / config.id
|
||||
if config.mode == "snapshot":
|
||||
return str(cfg_dir / "*.parquet")
|
||||
return str(cfg_dir / "timeseries" / "**" / "*.parquet")
|
||||
|
||||
|
||||
def _safe_json_value(value):
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
return None
|
||||
if isinstance(value, (date, datetime)):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def _read_ext_dataframe(
|
||||
config: ExtConfig,
|
||||
data_dir: Path,
|
||||
snapshot_date: str | None = None,
|
||||
) -> tuple[pl.DataFrame, str | None]:
|
||||
cfg_dir = data_dir / "ext_data" / config.id
|
||||
|
||||
if config.mode == "snapshot":
|
||||
path = cfg_dir / "part.parquet"
|
||||
if not path.exists():
|
||||
return pl.DataFrame(), None
|
||||
return pl.read_parquet(path), _latest_sync_date(config, data_dir)
|
||||
|
||||
base = cfg_dir / "timeseries"
|
||||
if not base.exists():
|
||||
return pl.DataFrame(), None
|
||||
|
||||
if snapshot_date:
|
||||
path = base / f"date={snapshot_date}" / "part.parquet"
|
||||
if not path.exists():
|
||||
return pl.DataFrame(), snapshot_date
|
||||
return pl.read_parquet(path), snapshot_date
|
||||
|
||||
partitions = sorted(
|
||||
d for d in base.iterdir()
|
||||
if d.is_dir() and d.name.startswith("date=") and (d / "part.parquet").exists()
|
||||
)
|
||||
if not partitions:
|
||||
return pl.DataFrame(), None
|
||||
|
||||
latest = partitions[-1]
|
||||
latest_date = latest.name[5:]
|
||||
return pl.read_parquet(latest / "part.parquet"), latest_date
|
||||
|
||||
|
||||
def _with_instrument_name(df: pl.DataFrame, data_dir: Path) -> pl.DataFrame:
|
||||
if df.is_empty() or "symbol" not in df.columns or "name" in df.columns:
|
||||
return df
|
||||
path = data_dir / "instruments" / "instruments.parquet"
|
||||
if not path.exists():
|
||||
return df
|
||||
try:
|
||||
inst = pl.read_parquet(path)
|
||||
if "symbol" in inst.columns and "name" in inst.columns:
|
||||
inst = inst.select(["symbol", "name"]).unique(subset=["symbol"], keep="last")
|
||||
return df.join(inst, on="symbol", how="left")
|
||||
except Exception:
|
||||
return df
|
||||
return df
|
||||
|
||||
|
||||
def _latest_sync_date(config: ExtConfig, data_dir: Path) -> str | None:
|
||||
"""扫描数据文件,返回该扩展配置的最新同步时间(含时分秒)。
|
||||
|
||||
- snapshot: 直接取 ext_data/{id}/part.parquet 的 mtime
|
||||
- timeseries: 扫描 ext_data/{id}/timeseries/date=xxx 分区目录
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
if config.mode == "snapshot":
|
||||
# 快照: part.parquet 与 config.json 同级
|
||||
p = data_dir / "ext_data" / config.id / "part.parquet"
|
||||
if p.exists():
|
||||
ts = datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
return ts
|
||||
# 兼容旧路径
|
||||
old = data_dir / "instruments_ext"
|
||||
if old.exists():
|
||||
return _latest_sync_from_partitions(old)
|
||||
return None
|
||||
|
||||
# 时序: 扫描 timeseries/date=xxx
|
||||
base = _ext_data_dir(config, data_dir)
|
||||
if not base.exists():
|
||||
# 兼容旧路径
|
||||
base = data_dir / "kline_ext"
|
||||
if not base.exists():
|
||||
return None
|
||||
return _latest_sync_from_partitions(base)
|
||||
|
||||
|
||||
def _latest_sync_from_partitions(base: Path) -> str | None:
|
||||
"""从 date=xxx 分区目录中找到最新分区的修改时间。"""
|
||||
from datetime import datetime
|
||||
latest_ts: float = 0
|
||||
latest_date: str | None = None
|
||||
for d in base.iterdir():
|
||||
if d.is_dir() and d.name.startswith("date="):
|
||||
for f in d.glob("*.parquet"):
|
||||
mtime = f.stat().st_mtime
|
||||
if mtime > latest_ts:
|
||||
latest_ts = mtime
|
||||
latest_date = d.name[5:]
|
||||
if latest_date and latest_ts > 0:
|
||||
ts = datetime.fromtimestamp(latest_ts).strftime("%H:%M:%S")
|
||||
return f"{latest_date} {ts}"
|
||||
return latest_date
|
||||
|
||||
|
||||
def _date_range(config: ExtConfig, data_dir: Path) -> list[str] | None:
|
||||
"""返回时序型扩展数据的日期范围 [最早, 最新]。"""
|
||||
if config.mode != "timeseries":
|
||||
return None
|
||||
base = _ext_data_dir(config, data_dir)
|
||||
if not base.exists():
|
||||
# 兼容旧路径
|
||||
base = data_dir / "kline_ext"
|
||||
if not base.exists():
|
||||
return None
|
||||
dates: list[str] = []
|
||||
for d in base.iterdir():
|
||||
if d.is_dir() and d.name.startswith("date="):
|
||||
dates.append(d.name[5:])
|
||||
if len(dates) < 1:
|
||||
return None
|
||||
dates.sort()
|
||||
return [dates[0], dates[-1]]
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_configs(request: Request):
|
||||
"""列出所有扩展数据配置。"""
|
||||
configs = _store(request).load_all()
|
||||
data_dir = _data_dir(request)
|
||||
items = []
|
||||
for c in configs:
|
||||
d = c.to_dict()
|
||||
d["latest_sync_date"] = _latest_sync_date(c, data_dir)
|
||||
d["date_range"] = _date_range(c, data_dir)
|
||||
items.append(d)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_config(request: Request, body: CreateExtReq):
|
||||
"""创建扩展数据配置。"""
|
||||
store = _store(request)
|
||||
if store.get(body.id):
|
||||
raise HTTPException(400, f"配置 '{body.id}' 已存在")
|
||||
config = ExtConfig(
|
||||
id=body.id,
|
||||
label=body.label,
|
||||
mode=body.mode,
|
||||
fields=[ExtField(f.name, f.dtype, f.label) for f in body.fields],
|
||||
description=body.description,
|
||||
symbol_map=body.symbol_map,
|
||||
code_map=body.code_map,
|
||||
)
|
||||
store.upsert(config)
|
||||
return config.to_dict()
|
||||
|
||||
|
||||
@router.put("/{config_id}")
|
||||
def update_config(request: Request, config_id: str, body: UpdateExtReq):
|
||||
"""更新扩展数据配置。"""
|
||||
store = _store(request)
|
||||
config = store.get(config_id)
|
||||
if not config:
|
||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
||||
if body.label is not None:
|
||||
config.label = body.label
|
||||
if body.fields is not None:
|
||||
config.fields = [ExtField(f.name, f.dtype, f.label) for f in body.fields]
|
||||
if body.description is not None:
|
||||
config.description = body.description
|
||||
if body.symbol_map is not None:
|
||||
config.symbol_map = body.symbol_map
|
||||
if body.code_map is not None:
|
||||
config.code_map = body.code_map
|
||||
store.upsert(config)
|
||||
return config.to_dict()
|
||||
|
||||
|
||||
@router.delete("/{config_id}")
|
||||
def delete_config(request: Request, config_id: str):
|
||||
"""删除扩展数据配置。"""
|
||||
store = _store(request)
|
||||
if not store.delete(config_id):
|
||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/{config_id}/rows")
|
||||
def list_rows(
|
||||
request: Request,
|
||||
config_id: str,
|
||||
snapshot_date: str | None = Query(None, alias="date"),
|
||||
columns: str | None = Query(None, description="逗号分隔的字段列表"),
|
||||
limit: int = Query(1000, ge=1, le=20000),
|
||||
):
|
||||
"""读取扩展数据明细。
|
||||
|
||||
- snapshot: 返回当前快照。
|
||||
- timeseries: 默认返回最新日期分区,也可通过 date=YYYY-MM-DD 指定。
|
||||
"""
|
||||
config = _store(request).get(config_id)
|
||||
if not config:
|
||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
||||
|
||||
data_dir = _data_dir(request)
|
||||
df, active_date = _read_ext_dataframe(config, data_dir, snapshot_date)
|
||||
df = _with_instrument_name(df, data_dir)
|
||||
requested = [c.strip() for c in (columns or "").split(",") if c.strip()]
|
||||
if requested:
|
||||
keep = [c for c in ["symbol", "code", "name", *requested] if c in df.columns]
|
||||
if keep:
|
||||
df = df.select(list(dict.fromkeys(keep)))
|
||||
total = len(df)
|
||||
if total > limit:
|
||||
df = df.head(limit)
|
||||
|
||||
rows = []
|
||||
for row in df.to_dicts():
|
||||
rows.append({k: _safe_json_value(v) for k, v in row.items()})
|
||||
|
||||
return {
|
||||
"id": config.id,
|
||||
"label": config.label,
|
||||
"mode": config.mode,
|
||||
"date": active_date,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"fields": [f.to_dict() for f in config.fields],
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 文件上传
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{config_id}/upload")
|
||||
async def upload_data(
|
||||
request: Request,
|
||||
config_id: str,
|
||||
file: UploadFile = File(...),
|
||||
snapshot_date: str | None = None,
|
||||
):
|
||||
"""上传 CSV/Excel 文件写入扩展数据。"""
|
||||
store = _store(request)
|
||||
config = store.get(config_id)
|
||||
if not config:
|
||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
||||
|
||||
# 校验文件后缀
|
||||
suffix = Path(file.filename or "").suffix.lower()
|
||||
if suffix not in (".csv", ".xlsx", ".xls"):
|
||||
raise HTTPException(400, "仅支持 CSV / Excel 文件")
|
||||
|
||||
# 写到临时文件再解析
|
||||
tmp_dir = Path(tempfile.mkdtemp())
|
||||
tmp_path = tmp_dir / f"upload{suffix}"
|
||||
try:
|
||||
with tmp_path.open("wb") as f:
|
||||
content = await file.read()
|
||||
f.write(content)
|
||||
|
||||
# 直接读取文件,不做列重命名
|
||||
if suffix == ".csv":
|
||||
df = pl.read_csv(ensure_utf8_csv(tmp_path), infer_schema_length=10000)
|
||||
elif suffix in (".xlsx", ".xls"):
|
||||
df = pl.read_excel(tmp_path)
|
||||
else:
|
||||
raise HTTPException(400, f"不支持的文件格式: {suffix}")
|
||||
|
||||
# 清洗列名:去掉括号内的时间戳等信息
|
||||
df = _clean_col_names(df)
|
||||
|
||||
# 按映射关系自动生成 symbol 和 code 列
|
||||
df = _apply_mapping(df, config, _data_dir(request))
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
# 确保配置的字段列存在于上传数据中(symbol/code 由映射自动生成,不校验)
|
||||
auto_fields = {"symbol", "code"}
|
||||
config_cols = {f.name for f in config.fields} - auto_fields
|
||||
missing = config_cols - set(df.columns)
|
||||
if missing:
|
||||
raise HTTPException(400, f"上传数据缺少字段: {', '.join(sorted(missing))}")
|
||||
|
||||
# 只保留配置中定义的列(包括自动生成的 symbol/code),忽略文件中多余的字段
|
||||
all_config_cols = {f.name for f in config.fields}
|
||||
keep = [c for c in df.columns if c in all_config_cols]
|
||||
df = df.select(keep)
|
||||
|
||||
# 解析快照日期
|
||||
snap = date.fromisoformat(snapshot_date) if snapshot_date else date.today()
|
||||
|
||||
rows = write_ext_parquet(df, config, _data_dir(request), snapshot_date=snap)
|
||||
|
||||
# 刷新 DuckDB 视图
|
||||
_refresh_views(request)
|
||||
|
||||
return {"status": "ok", "rows": rows, "date": snap.isoformat()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON 接口写入
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{config_id}/ingest")
|
||||
def ingest_data(request: Request, config_id: str, body: IngestReq):
|
||||
"""通过 JSON 接口批量写入扩展数据。"""
|
||||
store = _store(request)
|
||||
config = store.get(config_id)
|
||||
if not config:
|
||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
||||
|
||||
# 校验必填字段
|
||||
configured = {f.name for f in config.fields}
|
||||
required = configured - {"symbol"}
|
||||
for i, row in enumerate(body.rows):
|
||||
if "symbol" not in row:
|
||||
raise HTTPException(400, f"第 {i + 1} 行缺少 symbol 字段")
|
||||
missing = required - set(row.keys())
|
||||
if missing:
|
||||
raise HTTPException(400, f"第 {i + 1} 行缺少字段: {', '.join(sorted(missing))}")
|
||||
|
||||
snap = date.fromisoformat(body.date) if body.date else date.today()
|
||||
|
||||
rows_written = rows_to_parquet(body.rows, config, _data_dir(request), snapshot_date=snap)
|
||||
|
||||
_refresh_views(request)
|
||||
|
||||
return {"status": "ok", "rows": rows_written, "date": snap.isoformat()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 定时拉取
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.put("/{config_id}/pull")
|
||||
def configure_pull(request: Request, config_id: str, body: PullConfigReq):
|
||||
"""配置(或更新)定时拉取。"""
|
||||
store = _store(request)
|
||||
config = store.get(config_id)
|
||||
if not config:
|
||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
||||
|
||||
# 保留历史状态字段
|
||||
old_pull = config.pull
|
||||
config.pull = PullConfig(
|
||||
url=body.url,
|
||||
method=body.method,
|
||||
headers=body.headers,
|
||||
body=body.body,
|
||||
response_path=body.response_path,
|
||||
field_map=body.field_map,
|
||||
schedule_minutes=body.schedule_minutes,
|
||||
enabled=body.enabled,
|
||||
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,
|
||||
last_rows=old_pull.last_rows if old_pull else None,
|
||||
)
|
||||
store.upsert(config)
|
||||
|
||||
# 刷新调度器
|
||||
pull_scheduler.refresh(_data_dir(request))
|
||||
|
||||
return {"status": "ok", "pull": config.pull.to_dict()}
|
||||
|
||||
|
||||
@router.post("/{config_id}/pull/test")
|
||||
async def test_pull(request: Request, config_id: str):
|
||||
"""测试拉取:请求外部 API 并返回预览数据,不写入。"""
|
||||
store = _store(request)
|
||||
config = store.get(config_id)
|
||||
if not config:
|
||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
||||
if not config.pull or not config.pull.url:
|
||||
raise HTTPException(400, "拉取未配置或 URL 为空")
|
||||
|
||||
# 临时构建一个带新配置的 config 用于测试
|
||||
from app.services.ext_pull import _extract_rows, _apply_field_map
|
||||
import httpx
|
||||
|
||||
pull = config.pull
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
headers = pull.headers or {}
|
||||
kwargs: dict = {"headers": headers}
|
||||
if pull.method.upper() == "POST" and pull.body:
|
||||
kwargs["content"] = pull.body
|
||||
if "content-type" not in {k.lower() for k in headers}:
|
||||
kwargs["headers"]["Content-Type"] = "application/json"
|
||||
resp = await client.request(pull.method.upper(), pull.url, **kwargs)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
rows = _extract_rows(data, pull.response_path)
|
||||
preview = _apply_field_map(rows[:5], pull.field_map)
|
||||
return {
|
||||
"status": "ok",
|
||||
"total_rows": len(rows),
|
||||
"preview": preview,
|
||||
"has_symbol": bool(rows and "symbol" in rows[0]),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"测试失败: {e}") from e
|
||||
|
||||
|
||||
@router.post("/{config_id}/pull/run")
|
||||
async def run_pull(request: Request, config_id: str):
|
||||
"""手动触发一次拉取并写入。"""
|
||||
store = _store(request)
|
||||
config = store.get(config_id)
|
||||
if not config:
|
||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
||||
if not config.pull or not config.pull.url:
|
||||
raise HTTPException(400, "拉取未配置或 URL 为空")
|
||||
|
||||
try:
|
||||
n, d = await fetch_and_ingest(config, _data_dir(request))
|
||||
_refresh_views(request)
|
||||
return {"status": "ok", "rows": n, "date": d}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"拉取失败: {e}") from e
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ---------------------------------------------------------------------------
|
||||
# Symbol 格式修复
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{config_id}/fix-symbol")
|
||||
def fix_symbol(request: Request, config_id: str):
|
||||
"""扫描已有 Parquet 数据,将 symbol 列标准化为 代码.交易所 格式。"""
|
||||
store = _store(request)
|
||||
config = store.get(config_id)
|
||||
if not config:
|
||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
||||
|
||||
fixed = fix_symbol_format(config, _data_dir(request))
|
||||
_refresh_views(request)
|
||||
return {"status": "ok", "fixed_files": fixed}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema 发现
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_POLARS_TYPE_MAP = {
|
||||
"Int64": "int", "Int32": "int", "Int16": "int", "Int8": "int",
|
||||
"UInt64": "int", "UInt32": "int", "UInt16": "int", "UInt8": "int",
|
||||
"Float64": "float", "Float32": "float",
|
||||
"Boolean": "bool",
|
||||
"Utf8": "string", "String": "string",
|
||||
"Date": "string", "Datetime": "string", "Duration": "string",
|
||||
"Categorical": "string",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/detect-fields")
|
||||
async def detect_fields(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
"""上传 CSV/Excel 文件,自动检测列名和类型。
|
||||
|
||||
返回 symbol_candidates(数据匹配 000001.SZ 格式的列)和
|
||||
code_candidates(数据匹配 6位纯数字的列)。
|
||||
"""
|
||||
suffix = Path(file.filename or "").suffix.lower()
|
||||
if suffix not in (".csv", ".xlsx", ".xls"):
|
||||
raise HTTPException(400, "仅支持 CSV / Excel 文件")
|
||||
|
||||
tmp_dir = Path(tempfile.mkdtemp())
|
||||
tmp_path = tmp_dir / f"upload{suffix}"
|
||||
try:
|
||||
with tmp_path.open("wb") as f:
|
||||
content = await file.read()
|
||||
f.write(content)
|
||||
|
||||
# 直接读取,不要求 symbol 列
|
||||
if suffix == ".csv":
|
||||
df = pl.read_csv(ensure_utf8_csv(tmp_path), infer_schema_length=10000)
|
||||
elif suffix in (".xlsx", ".xls"):
|
||||
df = pl.read_excel(tmp_path)
|
||||
else:
|
||||
raise HTTPException(400, f"不支持的文件格式: {suffix}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
# 清洗列名:去掉括号内的时间戳等信息
|
||||
df = _clean_col_names(df)
|
||||
|
||||
# 构建字段列表
|
||||
fields = []
|
||||
for col_name in df.columns:
|
||||
pl_type = df[col_name].dtype
|
||||
dtype = _POLARS_TYPE_MAP.get(str(pl_type.base_type()), "string")
|
||||
fields.append({"name": col_name, "dtype": dtype, "label": col_name})
|
||||
|
||||
# --- 分别检测 symbol 候选 (000001.SZ) 和 code 候选 (6位纯数字) ---
|
||||
import re
|
||||
_CODE_PAT = re.compile(r"^\d{6}$")
|
||||
_SYMBOL_PAT = re.compile(r"^\d{6}\.[A-Z]{2}$")
|
||||
|
||||
symbol_candidates: list[str] = [] # 数据匹配 000001.SZ 格式
|
||||
code_candidates: list[str] = [] # 数据匹配 000001 格式
|
||||
|
||||
for col in df.columns:
|
||||
col_data = df[col].cast(pl.Utf8).drop_nulls()
|
||||
if len(col_data) == 0:
|
||||
continue
|
||||
sample = col_data.head(200).to_list()
|
||||
sym_hits = sum(1 for v in sample if _SYMBOL_PAT.match(str(v).strip()))
|
||||
code_hits = sum(1 for v in sample if _CODE_PAT.match(str(v).strip()))
|
||||
total = len(sample)
|
||||
if total > 0:
|
||||
if sym_hits / total > 0.5:
|
||||
symbol_candidates.append(col)
|
||||
elif code_hits / total > 0.5:
|
||||
code_candidates.append(col)
|
||||
|
||||
return {
|
||||
"fields": fields,
|
||||
"rows": len(df),
|
||||
"symbol_candidates": symbol_candidates,
|
||||
"code_candidates": code_candidates,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/schema/{config_id}")
|
||||
def discover_schema(request: Request, config_id: str):
|
||||
"""发现扩展数据的实际 Parquet schema(基于已有数据)。"""
|
||||
config = _store(request).get(config_id)
|
||||
if not config:
|
||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
||||
|
||||
data_dir = _data_dir(request)
|
||||
glob = _parquet_glob(config, data_dir)
|
||||
|
||||
try:
|
||||
import duckdb
|
||||
rows = duckdb.query(
|
||||
f"SELECT column_name, data_type FROM (DESCRIBE SELECT * FROM read_parquet('{glob}', union_by_name=true))"
|
||||
).fetchall()
|
||||
return {"columns": [{"name": r[0], "type": r[1]} for r in rows]}
|
||||
except Exception:
|
||||
# 无数据时返回配置中定义的字段
|
||||
return {"columns": [f.to_dict() for f in config.fields]}
|
||||
|
||||
|
||||
@router.get("/schema-all")
|
||||
def discover_all_schemas(request: Request):
|
||||
"""发现所有扩展表的 schema(用于前端动态列选择)。"""
|
||||
configs = _store(request).load_all()
|
||||
result = []
|
||||
for config in configs:
|
||||
data_dir = _data_dir(request)
|
||||
glob = _parquet_glob(config, data_dir)
|
||||
|
||||
try:
|
||||
import duckdb
|
||||
cols = duckdb.query(
|
||||
f"SELECT column_name, data_type FROM (DESCRIBE SELECT * FROM read_parquet('{glob}', union_by_name=true))"
|
||||
).fetchall()
|
||||
field_labels = {f.name: f.label for f in config.fields}
|
||||
columns = [{"name": r[0], "type": r[1], "label": field_labels.get(r[0], r[0])} for r in cols]
|
||||
except Exception:
|
||||
columns = [f.to_dict() for f in config.fields]
|
||||
|
||||
result.append({
|
||||
"id": config.id,
|
||||
"label": config.label,
|
||||
"mode": config.mode,
|
||||
"columns": columns,
|
||||
})
|
||||
return {"items": result}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 视图刷新
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _refresh_views(request: Request) -> None:
|
||||
"""重新注册 DuckDB 视图以包含新的扩展数据。"""
|
||||
repo = request.app.state.repo
|
||||
db = repo.store.db
|
||||
d = repo.store.data_dir.as_posix()
|
||||
|
||||
# 注册旧路径视图(兼容)
|
||||
for name, subdir in [("instruments_ext", "instruments_ext"), ("kline_ext", "kline_ext")]:
|
||||
old_glob = f"{d}/{subdir}/**/*.parquet"
|
||||
old_dir = Path(d) / subdir
|
||||
if old_dir.exists():
|
||||
sql = (
|
||||
f"CREATE OR REPLACE VIEW {name} AS "
|
||||
f"SELECT * FROM read_parquet('{old_glob}', union_by_name=true)"
|
||||
)
|
||||
try:
|
||||
db.execute(sql)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 注册新路径视图:每个扩展表一个视图 ext_{config_id}
|
||||
ext_base = Path(d) / "ext_data"
|
||||
if ext_base.exists():
|
||||
for cfg_dir in ext_base.iterdir():
|
||||
if not cfg_dir.is_dir():
|
||||
continue
|
||||
cp = cfg_dir / "config.json"
|
||||
if not cp.exists():
|
||||
continue
|
||||
try:
|
||||
raw = json.loads(cp.read_text(encoding="utf-8"))
|
||||
cfg_id = raw["id"]
|
||||
# 检查是否有数据文件(snapshot: part.parquet, timeseries: timeseries/ 目录)
|
||||
has_data = (cfg_dir / "part.parquet").exists() or (cfg_dir / "timeseries").exists()
|
||||
if has_data:
|
||||
view_name = f"ext_{cfg_id}"
|
||||
# snapshot: part.parquet 在 cfg_dir/ 根下; timeseries: 在 timeseries/ 子目录
|
||||
mode = raw.get("mode", "snapshot")
|
||||
if mode == "snapshot":
|
||||
glob_pattern = f"{cfg_dir.as_posix()}/*.parquet"
|
||||
else:
|
||||
glob_pattern = f"{cfg_dir.as_posix()}/timeseries/**/*.parquet"
|
||||
sql = (
|
||||
f"CREATE OR REPLACE VIEW {view_name} AS "
|
||||
f"SELECT * FROM read_parquet('{glob_pattern}', union_by_name=true)"
|
||||
)
|
||||
db.execute(sql)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,127 @@
|
||||
"""财务数据 API — 独立路由, Cap.FINANCIAL 门控。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import polars as pl
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.services.financial_sync import get_financial_df
|
||||
from app.tickflow.capabilities import Cap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/financials", tags=["financials"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def financial_status(request: Request):
|
||||
"""返回各财务表的同步状态。无需 FINANCIAL 权限(前端根据 available 决定是否展示)。"""
|
||||
capset = request.app.state.capabilities
|
||||
if not capset.has(Cap.FINANCIAL):
|
||||
return {"available": False, "tables": {}}
|
||||
|
||||
data_dir = request.app.state.repo.store.data_dir
|
||||
tables = {}
|
||||
|
||||
for table in ("metrics", "income", "balance_sheet", "cash_flow"):
|
||||
path = data_dir / "financials" / table / "part.parquet"
|
||||
if path.exists():
|
||||
try:
|
||||
df = pl.read_parquet(path, columns=["symbol"])
|
||||
tables[table] = {
|
||||
"rows": len(df),
|
||||
"symbols": df["symbol"].n_unique() if not df.is_empty() else 0,
|
||||
}
|
||||
except Exception:
|
||||
tables[table] = {"rows": 0, "symbols": 0}
|
||||
else:
|
||||
tables[table] = {"rows": 0, "symbols": 0}
|
||||
|
||||
fs = getattr(request.app.state, "financial_scheduler", None)
|
||||
last_sync = fs.last_sync if fs else {}
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"tables": tables,
|
||||
"last_sync": last_sync,
|
||||
# 服务端是否正在同步(手动触发)——前端据此显示"同步中"并防重复点击,
|
||||
# 且刷新页面后仍能正确反映服务端状态。
|
||||
"syncing": bool(fs and fs.is_syncing),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
def get_metrics(request: Request, symbol: str | None = None):
|
||||
"""查询核心财务指标。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
|
||||
df = get_financial_df(request.app.state.repo.store.data_dir, "metrics")
|
||||
if df.is_empty():
|
||||
return {"data": []}
|
||||
if symbol:
|
||||
df = df.filter(pl.col("symbol") == symbol)
|
||||
return {"data": df.to_dicts()}
|
||||
|
||||
|
||||
@router.get("/income")
|
||||
def get_income(request: Request, symbol: str | None = None):
|
||||
"""查询利润表。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
|
||||
df = get_financial_df(request.app.state.repo.store.data_dir, "income")
|
||||
if df.is_empty():
|
||||
return {"data": []}
|
||||
if symbol:
|
||||
df = df.filter(pl.col("symbol") == symbol)
|
||||
return {"data": df.to_dicts()}
|
||||
|
||||
|
||||
@router.get("/balance-sheet")
|
||||
def get_balance_sheet(request: Request, symbol: str | None = None):
|
||||
"""查询资产负债表。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
|
||||
df = get_financial_df(request.app.state.repo.store.data_dir, "balance_sheet")
|
||||
if df.is_empty():
|
||||
return {"data": []}
|
||||
if symbol:
|
||||
df = df.filter(pl.col("symbol") == symbol)
|
||||
return {"data": df.to_dicts()}
|
||||
|
||||
|
||||
@router.get("/cash-flow")
|
||||
def get_cash_flow(request: Request, symbol: str | None = None):
|
||||
"""查询现金流量表。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
|
||||
df = get_financial_df(request.app.state.repo.store.data_dir, "cash_flow")
|
||||
if df.is_empty():
|
||||
return {"data": []}
|
||||
if symbol:
|
||||
df = df.filter(pl.col("symbol") == symbol)
|
||||
return {"data": df.to_dicts()}
|
||||
|
||||
|
||||
@router.post("/sync/{table}")
|
||||
def sync_table(request: Request, table: str):
|
||||
"""手动触发同步。table: metrics / income / balance_sheet / cash_flow / all"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
|
||||
valid_tables = {"metrics", "income", "balance_sheet", "cash_flow", "all"}
|
||||
if table not in valid_tables:
|
||||
raise HTTPException(400, f"invalid table: {table}, expected one of {valid_tables}")
|
||||
|
||||
fs = getattr(request.app.state, "financial_scheduler", None)
|
||||
if not fs:
|
||||
return {"status": "error", "message": "FinancialScheduler not available"}
|
||||
|
||||
target = None if table == "all" else table
|
||||
result = fs.run_now(target)
|
||||
|
||||
return {"status": "ok", "synced": result}
|
||||
@@ -0,0 +1,149 @@
|
||||
"""指数 API。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import polars as pl
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
from app.indicators.pipeline import compute_enriched
|
||||
from app.services import index_sync, kline_sync
|
||||
from app.tickflow.capabilities import Cap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/index", tags=["index"])
|
||||
|
||||
|
||||
def _index_info(repo, symbol: str) -> dict:
|
||||
df = repo.get_index_instruments()
|
||||
if df.is_empty() or "symbol" not in df.columns:
|
||||
return {}
|
||||
hit = df.filter(pl.col("symbol") == symbol).head(1)
|
||||
if hit.is_empty():
|
||||
return {}
|
||||
return hit.to_dicts()[0]
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
def list_indices(request: Request):
|
||||
"""返回已缓存的 CN_Index 指数列表。"""
|
||||
repo = request.app.state.repo
|
||||
df = repo.get_index_instruments()
|
||||
if df.is_empty():
|
||||
return {"results": [], "count": 0}
|
||||
cols = [c for c in ["symbol", "name", "code", "asset_type"] if c in df.columns]
|
||||
rows = df.select(cols).sort("symbol").to_dicts()
|
||||
return {"results": rows, "count": len(rows)}
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
def search_indices(
|
||||
request: Request,
|
||||
q: str = Query("", min_length=0, max_length=50, description="搜索关键词"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
"""模糊搜索指数。"""
|
||||
repo = request.app.state.repo
|
||||
df = repo.get_index_instruments()
|
||||
if df.is_empty():
|
||||
return {"results": []}
|
||||
if not q.strip():
|
||||
rows = df.head(limit).to_dicts()
|
||||
return {"results": rows}
|
||||
|
||||
keyword = q.strip().upper()
|
||||
masks = []
|
||||
if "code" in df.columns:
|
||||
masks.append(pl.col("code").cast(pl.Utf8).str.contains(keyword, literal=True))
|
||||
masks.append(pl.col("symbol").cast(pl.Utf8).str.to_uppercase().str.contains(keyword, literal=True))
|
||||
if "name" in df.columns:
|
||||
masks.append(pl.col("name").cast(pl.Utf8).str.contains(q.strip(), literal=True))
|
||||
|
||||
mask = masks[0]
|
||||
for m in masks[1:]:
|
||||
mask = mask | m
|
||||
rows = df.filter(mask).head(limit).to_dicts()
|
||||
return {"results": rows}
|
||||
|
||||
|
||||
@router.get("/daily")
|
||||
def get_index_daily(
|
||||
request: Request,
|
||||
symbol: str = Query(..., description="指数代码, 如 000001.SH"),
|
||||
days: int = Query(120, ge=10, le=2000),
|
||||
start_date: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD, 优先于 days"),
|
||||
end_date: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD, 默认今天"),
|
||||
):
|
||||
"""读取指数日 K。指数数据使用独立 kline_index_* parquet。"""
|
||||
repo = request.app.state.repo
|
||||
end = date.fromisoformat(end_date) if end_date else date.today()
|
||||
start = date.fromisoformat(start_date) if start_date else end - timedelta(days=days)
|
||||
info = _index_info(repo, symbol)
|
||||
|
||||
df = repo.get_index_daily(symbol, start, end)
|
||||
if not df.is_empty():
|
||||
return {"symbol": symbol, "name": info.get("name"), "index_info": info, "rows": df.to_dicts(), "source": "index_enriched"}
|
||||
|
||||
capset = request.app.state.capabilities
|
||||
if not capset.has(Cap.KLINE_DAILY_BATCH):
|
||||
return {"symbol": symbol, "name": info.get("name"), "index_info": info, "rows": [], "source": "none"}
|
||||
|
||||
try:
|
||||
raw = kline_sync.sync_daily_batch([symbol], count=days + 150)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"数据源 fetch failed: {e}") from e
|
||||
if raw.is_empty():
|
||||
return {"symbol": symbol, "name": info.get("name"), "index_info": info, "rows": [], "source": "none"}
|
||||
|
||||
enriched = compute_enriched(raw, factors=None, instruments=None)
|
||||
rows = enriched.filter((pl.col("date") >= start) & (pl.col("date") <= end)).to_dicts()
|
||||
return {"symbol": symbol, "name": info.get("name"), "index_info": info, "rows": rows, "source": "live"}
|
||||
|
||||
|
||||
@router.get("/minute")
|
||||
def get_index_minute(
|
||||
request: Request,
|
||||
symbol: str = Query(..., description="指数代码, 如 000001.SH"),
|
||||
trade_date: date | None = Query(None, alias="date", description="交易日期, 默认今天"),
|
||||
):
|
||||
"""实时读取指数分钟 K。不写入股票分钟 parquet。"""
|
||||
repo = request.app.state.repo
|
||||
info = _index_info(repo, symbol)
|
||||
day = trade_date or date.today()
|
||||
df = kline_sync.fetch_minute_single(symbol, day)
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"name": info.get("name"),
|
||||
"index_info": info,
|
||||
"date": str(day),
|
||||
"rows": df.to_dicts(),
|
||||
"source": "live" if not df.is_empty() else "none",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sync_instruments")
|
||||
def sync_index_instruments(request: Request):
|
||||
"""同步 CN_Index 指数标的列表。"""
|
||||
repo = request.app.state.repo
|
||||
count = index_sync.sync_index_instruments(repo)
|
||||
return {"status": "ok", "count": count}
|
||||
|
||||
|
||||
@router.post("/sync_daily")
|
||||
def sync_index_daily(
|
||||
request: Request,
|
||||
days: int = Query(365, ge=30, le=5000),
|
||||
):
|
||||
"""同步指数日K到独立 parquet。"""
|
||||
repo = request.app.state.repo
|
||||
capset = request.app.state.capabilities
|
||||
if not capset.has(Cap.KLINE_DAILY_BATCH):
|
||||
raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (batch K-line)")
|
||||
end = datetime.now()
|
||||
start = end - timedelta(days=days)
|
||||
count = index_sync.sync_index_instruments(repo)
|
||||
rows = index_sync.sync_and_persist_index_daily(repo, capset, start_date=start, end_date=end)
|
||||
return {"status": "ok", "index_count": count, "rows_written": rows}
|
||||
@@ -0,0 +1,201 @@
|
||||
"""行情状态 / SSE 推送 API。
|
||||
|
||||
盘中选股相关端点已迁移至策略页面,此处仅保留全局行情基础设施。
|
||||
SSE 推送三种事件 (使用标准 SSE event 字段):
|
||||
- quotes_updated: 行情数据刷新,前端 invalidate 对应 query
|
||||
- strategy_alert: 策略监控/告警触发,前端弹通知
|
||||
- depth_updated: 五档盘口修正完成,前端刷新连板梯队/看板封单数据
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
router = APIRouter(prefix="/api/intraday", tags=["quotes"])
|
||||
|
||||
|
||||
def _get_quote_service(request: Request):
|
||||
"""获取全局 QuoteService。"""
|
||||
return getattr(request.app.state, "quote_service", None)
|
||||
|
||||
|
||||
def _fallback_index_quotes_from_daily(request: Request, symbols: list[str] | None = None) -> list[dict]:
|
||||
"""实时指数缓存为空时,从本地指数日 K 取最近收盘价作为兜底。"""
|
||||
repo = getattr(request.app.state, "repo", None)
|
||||
if not repo:
|
||||
return []
|
||||
|
||||
params: list[str] = []
|
||||
symbol_filter = ""
|
||||
if symbols:
|
||||
placeholders = ", ".join("?" for _ in symbols)
|
||||
symbol_filter = f"WHERE symbol IN ({placeholders})"
|
||||
params.extend(symbols)
|
||||
|
||||
try:
|
||||
rows = repo.execute_all(
|
||||
f"""
|
||||
WITH ranked AS (
|
||||
SELECT symbol, date, close,
|
||||
row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
|
||||
FROM kline_index_daily
|
||||
{symbol_filter}
|
||||
), latest AS (
|
||||
SELECT symbol,
|
||||
max(CASE WHEN rn = 1 THEN date END) AS date,
|
||||
max(CASE WHEN rn = 1 THEN close END) AS last_price,
|
||||
max(CASE WHEN rn = 2 THEN close END) AS prev_close
|
||||
FROM ranked
|
||||
WHERE rn <= 2
|
||||
GROUP BY symbol
|
||||
)
|
||||
SELECT latest.symbol, latest.date, latest.last_price, latest.prev_close
|
||||
FROM latest
|
||||
ORDER BY latest.symbol
|
||||
""",
|
||||
params,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
|
||||
out: list[dict] = []
|
||||
for symbol, dt, last_price, prev_close in rows:
|
||||
change_amount = None
|
||||
change_pct = None
|
||||
if last_price is not None and prev_close not in (None, 0):
|
||||
change_amount = float(last_price) - float(prev_close)
|
||||
change_pct = change_amount / float(prev_close) * 100
|
||||
out.append({
|
||||
"symbol": symbol,
|
||||
"name": None,
|
||||
"date": str(dt) if dt else None,
|
||||
"last_price": float(last_price) if last_price is not None else None,
|
||||
"close": float(last_price) if last_price is not None else None,
|
||||
"prev_close": float(prev_close) if prev_close is not None else None,
|
||||
"change_amount": change_amount,
|
||||
"change_pct": change_pct,
|
||||
"source": "index_daily",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def status(request: Request):
|
||||
"""行情状态 (来自全局 QuoteService)。"""
|
||||
qs = _get_quote_service(request)
|
||||
if qs:
|
||||
return qs.status()
|
||||
return {"enabled": False, "running": False, "symbol_count": 0, "index_symbol_count": 0,
|
||||
"quote_age_ms": None, "is_trading_hours": False, "last_fetch_ms": None}
|
||||
|
||||
|
||||
@router.get("/indices")
|
||||
def index_quotes(
|
||||
request: Request,
|
||||
symbols: str | None = Query(None, description="逗号分隔的指数 symbol 列表"),
|
||||
):
|
||||
"""返回实时指数行情缓存,不触发数据源请求。"""
|
||||
symbol_list = [s.strip() for s in symbols.split(",") if s.strip()] if symbols else None
|
||||
qs = _get_quote_service(request)
|
||||
if not qs:
|
||||
rows = _fallback_index_quotes_from_daily(request, symbol_list)
|
||||
return {"rows": rows, "count": len(rows), "source": "index_daily"}
|
||||
df = qs.get_index_quotes(symbol_list)
|
||||
rows = df.to_dicts() if not df.is_empty() else []
|
||||
if not rows:
|
||||
rows = _fallback_index_quotes_from_daily(request, symbol_list)
|
||||
return {"rows": rows, "count": len(rows), "source": "index_daily"}
|
||||
return {"rows": rows, "count": len(rows), "source": "realtime"}
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def quote_stream(request: Request):
|
||||
"""SSE 端点: 行情更新 + 告警推送 + 五档修正。
|
||||
|
||||
使用 sse-starlette EventSourceResponse:
|
||||
- 标准 SSE event 字段,前端按 event name 监听
|
||||
- 内置断线检测,客户端断开立即终止 generator
|
||||
- 内置 ping 心跳,保持连接活跃
|
||||
"""
|
||||
qs = _get_quote_service(request)
|
||||
|
||||
async def event_generator():
|
||||
while True:
|
||||
# 同时等待三类信号: 行情更新 / 告警 / 五档修正
|
||||
tasks: dict[str, asyncio.Future] = {
|
||||
"quote": asyncio.ensure_future(
|
||||
asyncio.to_thread(qs.wait_for_update, timeout=5.0) if qs else asyncio.sleep(5)
|
||||
),
|
||||
"alert": asyncio.ensure_future(
|
||||
asyncio.to_thread(qs.wait_for_alert, timeout=5.0) if qs else asyncio.sleep(5)
|
||||
),
|
||||
"depth": asyncio.ensure_future(
|
||||
asyncio.to_thread(qs.wait_for_depth_update, timeout=5.0) if qs else asyncio.sleep(5)
|
||||
),
|
||||
}
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
list(tasks.values()),
|
||||
timeout=30.0,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
|
||||
# 先推送告警 (如果有)
|
||||
if qs:
|
||||
alerts = qs.pop_alerts()
|
||||
if alerts:
|
||||
for chunk_start in range(0, len(alerts), 20):
|
||||
chunk = alerts[chunk_start:chunk_start + 20]
|
||||
yield {
|
||||
"event": "strategy_alert",
|
||||
"data": json.dumps({
|
||||
"ts": int(time.time() * 1000),
|
||||
"alerts": chunk,
|
||||
}, ensure_ascii=False),
|
||||
}
|
||||
|
||||
# 推送行情更新 (行情信号触发)
|
||||
if tasks["quote"] in done:
|
||||
try:
|
||||
update_result = tasks["quote"].result()
|
||||
except Exception: # noqa: BLE001
|
||||
update_result = False
|
||||
if update_result:
|
||||
yield {
|
||||
"event": "quotes_updated",
|
||||
"data": json.dumps({
|
||||
"ts": int(time.time() * 1000),
|
||||
"symbol_count": qs._symbol_count if qs else 0,
|
||||
}),
|
||||
}
|
||||
|
||||
# 推送五档修正完成 (depth 信号触发) — 前端刷新连板梯队封单数据
|
||||
if tasks["depth"] in done:
|
||||
try:
|
||||
depth_result = tasks["depth"].result()
|
||||
except Exception: # noqa: BLE001
|
||||
depth_result = False
|
||||
if depth_result:
|
||||
yield {
|
||||
"event": "depth_updated",
|
||||
"data": json.dumps({
|
||||
"ts": int(time.time() * 1000),
|
||||
}),
|
||||
}
|
||||
|
||||
return EventSourceResponse(event_generator())
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
def refresh_quotes(request: Request):
|
||||
"""手动刷新一次行情数据。"""
|
||||
qs = _get_quote_service(request)
|
||||
if qs:
|
||||
return qs.refresh()
|
||||
return {"error": "QuoteService not available"}
|
||||
@@ -0,0 +1,813 @@
|
||||
"""K 线 / 同步 API。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
from app.indicators.pipeline import compute_enriched_single
|
||||
from app.services import kline_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/kline", tags=["kline"])
|
||||
|
||||
|
||||
@router.get("/instruments/search")
|
||||
def search_instruments(
|
||||
request: Request,
|
||||
q: str = Query("", min_length=0, max_length=50, description="搜索关键词"),
|
||||
limit: int = Query(20, ge=1, le=50),
|
||||
):
|
||||
"""模糊搜索标的 (代码 / 名称)。从内存 instruments 缓存中查。"""
|
||||
repo = request.app.state.repo
|
||||
df = repo.get_instruments()
|
||||
if df.is_empty() or not q.strip():
|
||||
return {"results": []}
|
||||
|
||||
keyword = q.strip().upper()
|
||||
import polars as pl
|
||||
|
||||
# code/symbol 前缀优先,再 name 包含匹配
|
||||
prefix_mask = (
|
||||
pl.col("code").str.starts_with(keyword)
|
||||
| pl.col("symbol").str.to_uppercase().str.starts_with(keyword)
|
||||
)
|
||||
contains_mask = (
|
||||
pl.col("code").str.contains(keyword, literal=True)
|
||||
| pl.col("symbol").str.to_uppercase().str.contains(keyword, literal=True)
|
||||
| pl.col("name").str.contains(keyword, literal=True)
|
||||
)
|
||||
|
||||
# 前缀匹配优先,剩余名额用包含匹配补充
|
||||
prefix_hits = df.filter(prefix_mask).head(limit)
|
||||
if prefix_hits.height >= limit:
|
||||
matched = prefix_hits
|
||||
else:
|
||||
remaining = limit - prefix_hits.height
|
||||
# 排除已匹配的 symbol
|
||||
prefix_symbols = set(prefix_hits["symbol"].to_list()) if not prefix_hits.is_empty() else set()
|
||||
contain_hits = df.filter(contains_mask & ~pl.col("symbol").is_in(prefix_symbols)).head(remaining)
|
||||
matched = pl.concat([prefix_hits, contain_hits]) if not prefix_hits.is_empty() else contain_hits
|
||||
rows = matched.select(["symbol", "name", "code"]).to_dicts()
|
||||
return {"results": rows}
|
||||
|
||||
|
||||
@router.post("/instruments/names")
|
||||
def instruments_names(request: Request, symbols: list[str]):
|
||||
"""批量查股票名称。传入 symbol 列表, 返回 {symbol: name}。"""
|
||||
if not symbols:
|
||||
return {"names": {}}
|
||||
repo = request.app.state.repo
|
||||
df = repo.get_instruments()
|
||||
if df.is_empty():
|
||||
return {"names": {}}
|
||||
import polars as pl
|
||||
matched = df.filter(pl.col("symbol").is_in(symbols)).select(["symbol", "name"])
|
||||
names = {row["symbol"]: row["name"] for row in matched.iter_rows(named=True)}
|
||||
return {"names": names}
|
||||
|
||||
|
||||
def _get_stock_info(repo, symbol: str) -> dict:
|
||||
"""从 instruments 视图查标的名称 + 股本。"""
|
||||
try:
|
||||
row = repo.execute_one(
|
||||
"SELECT name, total_shares, float_shares FROM instruments WHERE symbol = ? LIMIT 1",
|
||||
[symbol],
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
if not row:
|
||||
return {}
|
||||
return {
|
||||
"name": row[0],
|
||||
"total_shares": row[1],
|
||||
"float_shares": row[2],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/daily")
|
||||
def get_daily(
|
||||
request: Request,
|
||||
symbol: str = Query(..., description="标的代码,如 000001.SZ"),
|
||||
days: int = Query(120, ge=10, le=2000),
|
||||
start_date: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD, 优先于 days"),
|
||||
end_date: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD, 默认今天"),
|
||||
ext_columns: Optional[str] = Query(None, description="逗号分隔的 ext 列: config_id.field_name"),
|
||||
):
|
||||
"""读取本地 enriched 表中某只股票的日 K。
|
||||
|
||||
- 若 QuoteService 有实时行情, 追加/覆盖今日实时蜡烛
|
||||
- Free 用户: 若 enriched 表里没有该股票, 实时拉取 + 本地算 enriched 返回
|
||||
- ext_columns: 可选,动态 LEFT JOIN 扩展数据表,结果平铺到 stock_info.ext 下
|
||||
(key 为 "{config_id}__{field_name}"),供日K信息条等场景展示自定义字段
|
||||
"""
|
||||
import polars as pl
|
||||
|
||||
repo = request.app.state.repo
|
||||
end = date.fromisoformat(end_date) if end_date else date.today()
|
||||
if start_date:
|
||||
start = date.fromisoformat(start_date)
|
||||
else:
|
||||
start = end - timedelta(days=days)
|
||||
|
||||
stock_info = _get_stock_info(repo, symbol)
|
||||
stock_name = stock_info.get("name")
|
||||
|
||||
# 从 enriched 表读取 (已含前复权 OHLCV + 技术指标 + 信号)
|
||||
df = repo.get_daily(symbol, start, end)
|
||||
|
||||
if df.is_empty():
|
||||
try:
|
||||
raw = kline_sync.sync_daily_batch([symbol], count=days + 30)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"数据源 fetch failed: {e}") from e
|
||||
if raw.is_empty():
|
||||
return {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": []}
|
||||
enriched = compute_enriched_single(raw)
|
||||
rows = enriched.tail(days).to_dicts()
|
||||
# 即使 live 模式也尝试追加实时蜡烛
|
||||
rows = _maybe_inject_live_candle(request, symbol, rows)
|
||||
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "live"}
|
||||
return _attach_ext(resp, repo, symbol, ext_columns)
|
||||
|
||||
rows = df.to_dicts()
|
||||
|
||||
# 追加/覆盖今日实时蜡烛
|
||||
rows = _maybe_inject_live_candle(request, symbol, rows)
|
||||
|
||||
resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "enriched"}
|
||||
return _attach_ext(resp, repo, symbol, ext_columns)
|
||||
|
||||
|
||||
def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> dict:
|
||||
"""按 ext_columns 规格为单只股票 LEFT JOIN 扩展数据,平铺到 stock_info['ext']。
|
||||
|
||||
key 形如 "{config_id}__{field_name}",与自选列表 enriched 接口保持一致。
|
||||
JOIN 逻辑参考 watchlist.watchlist_enriched;任何 ext 表/字段缺失都静默跳过。
|
||||
"""
|
||||
if not ext_columns or not ext_columns.strip():
|
||||
return resp
|
||||
|
||||
specs: list[tuple[str, str]] = []
|
||||
for part in ext_columns.split(","):
|
||||
part = part.strip()
|
||||
if "." not in part:
|
||||
continue
|
||||
config_id, field_name = part.split(".", 1)
|
||||
config_id, field_name = config_id.strip(), field_name.strip()
|
||||
if config_id and field_name:
|
||||
specs.append((config_id, field_name))
|
||||
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()}
|
||||
except Exception: # noqa: BLE001
|
||||
configs = {}
|
||||
|
||||
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, "{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
|
||||
|
||||
stock_info = dict(resp.get("stock_info") or {})
|
||||
stock_info["ext"] = ext_values
|
||||
resp["stock_info"] = stock_info
|
||||
return resp
|
||||
|
||||
|
||||
def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict]) -> list[dict]:
|
||||
"""如果 QuoteService 有实时 enriched 数据, 用实时数据生成今日蜡烛并追加/覆盖。"""
|
||||
qs = getattr(request.app.state, "quote_service", None)
|
||||
if not qs:
|
||||
return rows
|
||||
|
||||
df_today, enriched_date = qs.get_enriched_today()
|
||||
if df_today.is_empty():
|
||||
return rows
|
||||
|
||||
# 非交易日(周末/假日)缓存的行情日期 != 今天,跳过注入避免产生重复蜡烛
|
||||
if not enriched_date or enriched_date != date.today():
|
||||
return rows
|
||||
|
||||
# 查找该 symbol 的实时 enriched 行
|
||||
import polars as pl
|
||||
try:
|
||||
q = df_today.filter(pl.col("symbol") == symbol).to_dicts()
|
||||
if not q:
|
||||
return rows
|
||||
q = q[0]
|
||||
except Exception: # noqa: BLE001
|
||||
return rows
|
||||
|
||||
close_price = q.get("close")
|
||||
if not close_price or close_price <= 0:
|
||||
return rows
|
||||
|
||||
today_str = str(enriched_date)
|
||||
|
||||
# enriched 行已包含 OHLCV + 全套指标, 直接用它
|
||||
# 修复: API 在非交易时段可能返回 open/high/low=0, 用 close 填充避免异常蜡烛
|
||||
raw_open = q.get("open")
|
||||
raw_high = q.get("high")
|
||||
raw_low = q.get("low")
|
||||
live_row: dict = {
|
||||
"date": today_str,
|
||||
"symbol": symbol,
|
||||
"open": raw_open if raw_open and raw_open > 0 else close_price,
|
||||
"high": raw_high if raw_high and raw_high > 0 else close_price,
|
||||
"low": raw_low if raw_low and raw_low > 0 else close_price,
|
||||
"close": close_price,
|
||||
"volume": q.get("volume"),
|
||||
"amount": q.get("amount"),
|
||||
"change_pct": q.get("change_pct"),
|
||||
"is_live": True,
|
||||
}
|
||||
# 补上 enriched 的技术指标字段
|
||||
for key in ("ma5", "ma10", "ma20", "ma30", "ma60",
|
||||
"macd_dif", "macd_dea", "macd_hist",
|
||||
"kdj_k", "kdj_d", "kdj_j",
|
||||
"boll_upper", "boll_lower",
|
||||
"rsi_6", "rsi_14", "rsi_24",
|
||||
"atr_14", "vol_ratio_5d"):
|
||||
if key in q and q[key] is not None:
|
||||
live_row[key] = q[key]
|
||||
|
||||
# 如果已有今天的 enriched 行, 覆盖; 否则追加
|
||||
found = False
|
||||
for i, r in enumerate(rows):
|
||||
if str(r.get("date")) == today_str:
|
||||
r.update(live_row)
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
rows.append(live_row)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
class DailyBatchRequest:
|
||||
"""批量日K请求。"""
|
||||
symbols: list[str]
|
||||
days: int = 12
|
||||
|
||||
|
||||
@router.post("/daily-batch")
|
||||
def get_daily_batch(request: Request, body: dict):
|
||||
"""批量获取多只股票最近 N 天日K (OHLCV)。
|
||||
|
||||
用于自选列表迷你蜡烛图等场景,只返回基础列,不返回全部 enriched 指标。
|
||||
"""
|
||||
symbols = body.get("symbols", [])
|
||||
days = body.get("days", 12)
|
||||
if not symbols:
|
||||
return {"data": {}}
|
||||
days = max(5, min(60, days))
|
||||
|
||||
repo = request.app.state.repo
|
||||
import polars as pl
|
||||
from datetime import date, timedelta
|
||||
|
||||
end = date.today()
|
||||
start = end - timedelta(days=days * 2) # 多取一些确保交易日够
|
||||
|
||||
cols = ["symbol", "date", "open", "high", "low", "close", "volume"]
|
||||
df = repo.get_daily_batch(symbols, start, end, columns=cols)
|
||||
|
||||
if df.is_empty():
|
||||
return {"data": {}}
|
||||
|
||||
# 按 symbol 分组, 每只取最近 N 条
|
||||
result: dict[str, list[dict]] = {}
|
||||
for sym in symbols:
|
||||
sub = df.filter(pl.col("symbol") == sym).sort("date").tail(days)
|
||||
if not sub.is_empty():
|
||||
result[sym] = sub.to_dicts()
|
||||
|
||||
return {"data": result}
|
||||
|
||||
|
||||
@router.get("/minute")
|
||||
def get_minute(
|
||||
request: Request,
|
||||
symbol: str = Query(..., description="标的代码"),
|
||||
trade_date: date | None = Query(None, alias="date", description="交易日期, 默认最新"),
|
||||
):
|
||||
"""读取某只股票某天的分钟 K 线。
|
||||
|
||||
- 本地有完整数据(240条) → 直接返回
|
||||
- 本地无数据或不完整 → 从数据源实时拉取返回(不写入)
|
||||
"""
|
||||
repo = request.app.state.repo
|
||||
stock_info = _get_stock_info(repo, symbol)
|
||||
stock_name = stock_info.get("name")
|
||||
|
||||
if trade_date is None:
|
||||
trade_date = repo.latest_minute_date(symbol)
|
||||
if trade_date is None:
|
||||
# 本地无任何分钟K,尝试从数据源拉取当天
|
||||
trade_date = date.today()
|
||||
df = kline_sync.fetch_minute_single(symbol, trade_date)
|
||||
return {
|
||||
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
|
||||
"date": str(trade_date), "rows": df.to_dicts(), "source": "live",
|
||||
}
|
||||
|
||||
df = repo.get_minute(symbol, trade_date)
|
||||
|
||||
# 完整交易日应有 240 条分钟K;如果是今天(盘中),期望条数按已交易分钟估算
|
||||
expected = 240
|
||||
today = date.today()
|
||||
if trade_date == today:
|
||||
from datetime import datetime as _dt
|
||||
now = _dt.now()
|
||||
h, m = now.hour, now.minute
|
||||
if h < 9 or (h == 9 and m < 30):
|
||||
expected = 0 # 还没开盘
|
||||
elif h < 12 or (h == 12 and m == 0):
|
||||
expected = (h - 9) * 60 + m - 30 # 9:30 起
|
||||
elif h < 13:
|
||||
expected = 120 # 午休
|
||||
elif h < 15:
|
||||
expected = 120 + (h - 13) * 60 + m
|
||||
else:
|
||||
expected = 240
|
||||
|
||||
is_complete = not df.is_empty() and len(df) >= expected * 0.9 # 允许 10% 容差
|
||||
|
||||
if is_complete:
|
||||
return {
|
||||
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
|
||||
"date": str(trade_date), "rows": df.to_dicts(), "source": "local",
|
||||
}
|
||||
|
||||
# 本地不完整或无数据 → 从数据源实时拉取
|
||||
live_df = kline_sync.fetch_minute_single(symbol, trade_date)
|
||||
return {
|
||||
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
|
||||
"date": str(trade_date), "rows": live_df.to_dicts(),
|
||||
"source": "live" if not live_df.is_empty() else "none",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
def sync_symbol(
|
||||
request: Request,
|
||||
symbol: str = Query(...),
|
||||
days: int = Query(250, ge=10, le=2000),
|
||||
):
|
||||
"""手动触发单股同步(Free 用户在 K 线页用)。"""
|
||||
repo = request.app.state.repo
|
||||
capset = request.app.state.capabilities
|
||||
n = kline_sync.sync_and_persist_daily_batch([symbol], repo, capset, count=days)
|
||||
return {"symbol": symbol, "rows_written": n}
|
||||
|
||||
|
||||
@router.post("/sync_batch")
|
||||
def sync_batch(
|
||||
request: Request,
|
||||
symbols: list[str],
|
||||
days: int = Query(250, ge=10, le=2000),
|
||||
):
|
||||
repo = request.app.state.repo
|
||||
capset = request.app.state.capabilities
|
||||
n = kline_sync.sync_and_persist_daily_batch(symbols, repo, capset, count=days)
|
||||
return {"symbols": symbols, "rows_written": n}
|
||||
|
||||
|
||||
@router.post("/refresh_views")
|
||||
def refresh_views(request: Request):
|
||||
"""刷新所有 DuckDB 视图(解决视图状态不一致问题)。"""
|
||||
from app.jobs.daily_pipeline import _refresh_views
|
||||
repo = request.app.state.repo
|
||||
_refresh_views(repo)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/sync_minute")
|
||||
async def sync_minute(request: Request):
|
||||
"""手动触发分钟 K 同步(全市场)。返回 pipeline job_id 可轮询进度。"""
|
||||
import asyncio
|
||||
|
||||
from app.services.pipeline_jobs import job_store
|
||||
from app.api.data import invalidate_storage_cache
|
||||
from app.services.preferences import get_minute_sync_days
|
||||
from app.tickflow.capabilities import Cap
|
||||
from app.tickflow.pools import get_pool
|
||||
|
||||
repo = request.app.state.repo
|
||||
capset = request.app.state.capabilities
|
||||
|
||||
if not capset.has(Cap.KLINE_MINUTE_BATCH):
|
||||
raise HTTPException(status_code=403, detail="需要 Pro+ 权限")
|
||||
|
||||
job_id = job_store.create()
|
||||
existing = job_store.get(job_id)
|
||||
if existing and existing["status"] == "running":
|
||||
return {"status": "reused", "job_id": job_id}
|
||||
|
||||
async def task() -> None:
|
||||
job_store.start(job_id)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def progress(stage: str, pct: int, msg: str) -> None:
|
||||
job_store.progress(job_id, stage, pct, msg)
|
||||
|
||||
try:
|
||||
progress("sync_minute", 5, "解析标的池…")
|
||||
universe = sorted(set(get_pool("watchlist")) | set(get_pool("CN_Equity_A")))
|
||||
# 补充 instruments 全量标的,覆盖北交所、新股等
|
||||
inst_path = repo.store.data_dir / "instruments" / "instruments.parquet"
|
||||
if inst_path.exists():
|
||||
try:
|
||||
import polars as pl
|
||||
inst = pl.read_parquet(inst_path, columns=["symbol"])
|
||||
universe = sorted(set(universe) | set(inst["symbol"].to_list()))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
progress("sync_minute", 10, f"标的池 {len(universe)} 只")
|
||||
|
||||
days = get_minute_sync_days()
|
||||
|
||||
def _run():
|
||||
return kline_sync.sync_and_persist_minute(universe, repo, capset, days=days)
|
||||
|
||||
written = await loop.run_in_executor(_long_task_executor, _run)
|
||||
|
||||
# 刷新视图
|
||||
from app.jobs.daily_pipeline import _refresh_single_view
|
||||
_refresh_single_view(repo, "kline_minute")
|
||||
|
||||
progress("done", 100, f"分钟 K 同步完成,{written} 行")
|
||||
job_store.succeed(job_id, {"minute_rows": written, "universe_size": len(universe)})
|
||||
invalidate_storage_cache()
|
||||
except Exception as e: # noqa: BLE001
|
||||
job_store.fail(job_id, str(e))
|
||||
invalidate_storage_cache()
|
||||
|
||||
asyncio.create_task(task())
|
||||
return {"status": "started", "job_id": job_id}
|
||||
|
||||
|
||||
@router.post("/extend_history")
|
||||
async def extend_history(request: Request):
|
||||
"""向前扩展历史日K数据 — 独立于盘后管道。
|
||||
|
||||
body: { "value": int, "unit": "day"|"month"|"year" }
|
||||
返回 job_id,可轮询 /api/pipeline/jobs 查看进度。
|
||||
"""
|
||||
import asyncio
|
||||
import traceback as _tb
|
||||
try:
|
||||
body = await request.json()
|
||||
value = body.get("value")
|
||||
unit = body.get("unit", "month")
|
||||
if not value or value <= 0:
|
||||
raise HTTPException(status_code=400, detail="value 必须为正整数")
|
||||
if unit not in ("day", "month", "year"):
|
||||
raise HTTPException(status_code=400, detail="unit 只支持 day/month/year")
|
||||
|
||||
repo = request.app.state.repo
|
||||
capset = request.app.state.capabilities
|
||||
|
||||
from app.tickflow.capabilities import Cap
|
||||
if not capset.has(Cap.KLINE_DAILY_BATCH):
|
||||
raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (batch K-line)")
|
||||
|
||||
from app.services.extend_history import run_extend_history
|
||||
from app.services.pipeline_jobs import job_store
|
||||
from app.api.data import invalidate_storage_cache
|
||||
|
||||
job_id = job_store.create()
|
||||
existing = job_store.get(job_id)
|
||||
if existing and existing["status"] == "running":
|
||||
return {"status": "reused", "job_id": job_id}
|
||||
|
||||
async def task() -> None:
|
||||
job_store.start(job_id)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
_long_task_executor,
|
||||
lambda: run_extend_history(repo, capset, value, unit, on_progress=progress),
|
||||
)
|
||||
if "error" in result:
|
||||
job_store.fail(job_id, result["error"])
|
||||
else:
|
||||
job_store.succeed(job_id, result)
|
||||
invalidate_storage_cache()
|
||||
except Exception as e:
|
||||
logger.exception("extend_history failed: job_id=%s", job_id)
|
||||
job_store.fail(job_id, str(e))
|
||||
invalidate_storage_cache()
|
||||
|
||||
asyncio.create_task(task())
|
||||
return {"status": "started", "job_id": job_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("extend_history error: %s\n%s", e, _tb.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.post("/rebuild_enriched")
|
||||
async def rebuild_enriched(request: Request):
|
||||
"""全量重算 enriched 表 — 不获取任何数据,仅基于已有 kline_daily + adj_factor 重算复权+指标。
|
||||
|
||||
返回 job_id,可轮询 /api/pipeline/jobs 查看进度。
|
||||
"""
|
||||
import asyncio
|
||||
try:
|
||||
repo = request.app.state.repo
|
||||
|
||||
from app.services.pipeline_jobs import job_store
|
||||
from app.api.data import invalidate_storage_cache
|
||||
|
||||
job_id = job_store.create()
|
||||
existing = job_store.get(job_id)
|
||||
if existing and existing["status"] == "running":
|
||||
return {"status": "reused", "job_id": job_id}
|
||||
|
||||
async def task() -> None:
|
||||
job_store.start(job_id)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
progress("rebuild_enriched", 10, "全量计算 enriched…")
|
||||
from app.indicators.pipeline import run_pipeline
|
||||
|
||||
def _batch_progress(cur: int, tot: int) -> None:
|
||||
pct = 10 + int(85 * cur / tot)
|
||||
progress("rebuild_enriched", pct,
|
||||
f"计算指标 批次 {cur}/{tot}",
|
||||
stage_pct=int(100 * cur / tot), skip_log=True)
|
||||
|
||||
written = await loop.run_in_executor(
|
||||
_long_task_executor,
|
||||
lambda: run_pipeline(on_batch_done=_batch_progress),
|
||||
)
|
||||
|
||||
enriched_dir = repo.store.data_dir / "kline_daily_enriched"
|
||||
enriched_days = len(list(enriched_dir.glob("date=*"))) if enriched_dir.exists() else 0
|
||||
|
||||
# 刷新视图
|
||||
d = repo.store.data_dir.as_posix()
|
||||
for view_name, glob in [
|
||||
("kline_enriched", f"{d}/kline_daily_enriched/**/*.parquet"),
|
||||
]:
|
||||
try:
|
||||
repo.db.execute(
|
||||
f"CREATE OR REPLACE VIEW {view_name} AS "
|
||||
f"SELECT * FROM read_parquet('{glob}', union_by_name=true)"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
progress("rebuild_enriched", 100, f"完成,覆盖 {enriched_days} 天")
|
||||
job_store.succeed(job_id, {
|
||||
"enriched_days": enriched_days,
|
||||
"enriched_rows": written,
|
||||
})
|
||||
invalidate_storage_cache()
|
||||
except Exception as e:
|
||||
logger.exception("rebuild_enriched failed: job_id=%s", job_id)
|
||||
job_store.fail(job_id, str(e))
|
||||
invalidate_storage_cache()
|
||||
|
||||
asyncio.create_task(task())
|
||||
return {"status": "started", "job_id": job_id}
|
||||
except Exception as e:
|
||||
import traceback as _tb
|
||||
logger.error("rebuild_enriched error: %s\n%s", e, _tb.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
# 长时间任务专用线程池(隔离于 FastAPI 默认线程池,防止阻塞请求处理)
|
||||
import concurrent.futures as _cf
|
||||
_long_task_executor = _cf.ThreadPoolExecutor(max_workers=2, thread_name_prefix="long-task")
|
||||
|
||||
|
||||
@router.post("/extend_minute_history")
|
||||
async def extend_minute_history(request: Request):
|
||||
"""向前扩展分钟K历史数据 — 仅拉数据,不做任何后续处理。
|
||||
|
||||
body: { "value": int, "unit": "day"|"month" }
|
||||
- day 单位:1~15 天(所有有分钟K权限的套餐可用)
|
||||
- month 单位:1~6 月(每月按 30 天计,即最多 180 天)—— 仅 Expert+ 可用
|
||||
返回 job_id,可轮询 /api/pipeline/jobs 查看进度。
|
||||
"""
|
||||
import asyncio
|
||||
import traceback as _tb
|
||||
try:
|
||||
body = await request.json()
|
||||
value = body.get("value")
|
||||
unit = body.get("unit", "day")
|
||||
if not value or value <= 0:
|
||||
raise HTTPException(status_code=400, detail="value 必须为正整数")
|
||||
if unit not in ("day", "month"):
|
||||
raise HTTPException(status_code=400, detail="unit 只支持 day/month")
|
||||
|
||||
repo = request.app.state.repo
|
||||
capset = request.app.state.capabilities
|
||||
|
||||
from app.tickflow.capabilities import Cap
|
||||
if not capset.has(Cap.KLINE_MINUTE_BATCH):
|
||||
raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (batch minute K-line)")
|
||||
|
||||
# month 单位(按月扩展更长的分钟K历史)仅 Expert+ 开放;Pro 仅可用 day
|
||||
if unit == "month":
|
||||
from app.tickflow.policy import tier_label
|
||||
base_tier = tier_label().split()[0].split("+")[0].strip().lower()
|
||||
if base_tier != "expert":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="按月扩展分钟K历史需要 Expert 及以上套餐",
|
||||
)
|
||||
|
||||
# 计算天数上限:day 最多 15 天;month 最多 6 月(180 天)
|
||||
from datetime import timedelta
|
||||
if unit == "month":
|
||||
total_days = min(value * 30, 180)
|
||||
else:
|
||||
total_days = min(value, 15)
|
||||
|
||||
if total_days <= 0:
|
||||
raise HTTPException(status_code=400, detail="扩展范围无效")
|
||||
|
||||
from app.services.pipeline_jobs import job_store
|
||||
from app.api.data import invalidate_storage_cache
|
||||
|
||||
job_id = job_store.create()
|
||||
existing = job_store.get(job_id)
|
||||
if existing and existing["status"] == "running":
|
||||
return {"status": "reused", "job_id": job_id}
|
||||
|
||||
async def task() -> None:
|
||||
job_store.start(job_id)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
# 获取当前最早日期
|
||||
earliest = repo.earliest_minute_date()
|
||||
if not earliest:
|
||||
# 本地无分钟K数据 → 以今天为基准往前获取
|
||||
from datetime import date as _date
|
||||
latest = _date.today()
|
||||
else:
|
||||
latest = earliest
|
||||
|
||||
new_start = latest - timedelta(days=total_days)
|
||||
if new_start >= latest:
|
||||
job_store.fail(job_id, "扩展范围无效")
|
||||
invalidate_storage_cache()
|
||||
return
|
||||
|
||||
start_str = new_start.strftime("%Y-%m-%d")
|
||||
end_str = latest.strftime("%Y-%m-%d")
|
||||
|
||||
progress("extend_minute", 5, "解析标的池…")
|
||||
universe = _resolve_minute_universe(capset, repo)
|
||||
progress("extend_minute", 8, f"标的池: {len(universe)} 只")
|
||||
|
||||
from app.tickflow.capabilities import Cap
|
||||
|
||||
lim = capset.limits(Cap.KLINE_MINUTE_BATCH)
|
||||
batch_size = lim.batch if lim and lim.batch else 100
|
||||
rpm = lim.rpm if lim else 30
|
||||
|
||||
def _run():
|
||||
"""全部在 executor 线程里完成,避免阻塞事件循环。"""
|
||||
from app.services.kline_sync import sync_minute_batch
|
||||
from datetime import datetime as _dt
|
||||
|
||||
def _chunk(cur: int, tot: int) -> None:
|
||||
progress("extend_minute", 8 + int(85 * cur / tot),
|
||||
f"分钟K 批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True)
|
||||
|
||||
df = sync_minute_batch(
|
||||
universe,
|
||||
start_time=_dt.combine(new_start, _dt.min.time()),
|
||||
end_time=_dt.combine(latest, _dt.min.time()),
|
||||
batch_size=batch_size, rpm=rpm,
|
||||
on_chunk_done=_chunk,
|
||||
)
|
||||
|
||||
written = 0
|
||||
day_count = 0
|
||||
if not df.is_empty():
|
||||
import polars as pl
|
||||
df = df.with_columns(pl.col("datetime").dt.date().alias("_trade_date"))
|
||||
for day_df in df.partition_by("_trade_date"):
|
||||
trade_date = day_df["_trade_date"][0]
|
||||
out = repo.store.data_dir / "kline_minute" / f"date={trade_date}" / "part.parquet"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
if out.exists():
|
||||
existing_df = pl.read_parquet(out)
|
||||
if "datetime" in existing_df.columns:
|
||||
existing_df = existing_df.filter(pl.col("datetime").is_not_null())
|
||||
day_df = pl.concat([existing_df, day_df.drop("_trade_date")]).unique(
|
||||
subset=["symbol", "datetime"], keep="last",
|
||||
)
|
||||
else:
|
||||
day_df = day_df.drop("_trade_date")
|
||||
day_df = day_df.sort("symbol", "datetime")
|
||||
day_df.write_parquet(out)
|
||||
written += day_df.height
|
||||
day_count += 1
|
||||
|
||||
# 刷新视图
|
||||
d = repo.store.data_dir.as_posix()
|
||||
try:
|
||||
repo.db.execute(
|
||||
f"CREATE OR REPLACE VIEW kline_minute AS "
|
||||
f"SELECT * FROM read_parquet('{d}/kline_minute/**/*.parquet', union_by_name=true)"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return written, day_count
|
||||
|
||||
progress("extend_minute", 10, f"获取分钟K [{start_str} ~ {end_str}]…")
|
||||
written, day_count = await loop.run_in_executor(_long_task_executor, _run)
|
||||
|
||||
progress("extend_minute", 95, f"分钟K 完成,{day_count} 天")
|
||||
job_store.succeed(job_id, {
|
||||
"minute_days": day_count,
|
||||
"universe_size": len(universe),
|
||||
"earliest_before": (earliest or latest).isoformat(),
|
||||
"earliest_after": new_start.isoformat(),
|
||||
})
|
||||
invalidate_storage_cache()
|
||||
except Exception as e:
|
||||
logger.exception("extend_minute_history failed: job_id=%s", job_id)
|
||||
job_store.fail(job_id, str(e))
|
||||
invalidate_storage_cache()
|
||||
|
||||
asyncio.create_task(task())
|
||||
return {"status": "started", "job_id": job_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("extend_minute_history error: %s\n%s", e, _tb.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
def _resolve_minute_universe(capset, repo) -> list[str]:
|
||||
"""分钟K标的池解析。"""
|
||||
from app.tickflow.capabilities import Cap
|
||||
if capset.has(Cap.KLINE_MINUTE_BATCH):
|
||||
try:
|
||||
from app.tickflow.pools import get_pool
|
||||
all_a = get_pool("CN_Equity_A", refresh=True)
|
||||
if all_a:
|
||||
return sorted(all_a)
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
@@ -0,0 +1,235 @@
|
||||
"""监控规则 API 路由 — HTTP 请求 → 调用 monitor_rules 模块 → 同步引擎内存态。
|
||||
|
||||
只做胶水: 校验 → 持久化 → 失效引擎内存态。不含评估逻辑。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.strategy import monitor_rules
|
||||
|
||||
router = APIRouter(prefix="/api/monitor-rules", tags=["monitor-rules"])
|
||||
|
||||
|
||||
def _data_dir(request: Request) -> Path:
|
||||
return request.app.state.repo.store.data_dir
|
||||
|
||||
|
||||
def _sync_engine(request: Request) -> None:
|
||||
"""保存/删除后,把最新规则集 reload 到引擎内存态。"""
|
||||
engine = getattr(request.app.state, "monitor_engine", None)
|
||||
if engine is not None:
|
||||
rules = monitor_rules.load_all(_data_dir(request))
|
||||
engine.set_rules(rules)
|
||||
|
||||
|
||||
# ── Pydantic 模型 ───────────────────────────────────────
|
||||
class ConditionModel(BaseModel):
|
||||
field: str
|
||||
op: str # truth | > >= < <= == !=
|
||||
value: float | None = None # op 非 truth 时必填
|
||||
|
||||
|
||||
class RuleModel(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
enabled: bool = True
|
||||
type: str # strategy | signal | price | market
|
||||
scope: str = "symbols" # symbols | all | sector
|
||||
symbols: list[str] = []
|
||||
sector: str | None = None
|
||||
strategy_id: str | None = None
|
||||
direction: str = "entry" # entry | exit | both
|
||||
conditions: list[ConditionModel] = []
|
||||
logic: str = "and" # and | or
|
||||
cooldown_seconds: int = 3600
|
||||
severity: str = "info" # info | warn | critical
|
||||
webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 开发中)
|
||||
webhook_enabled: bool = False
|
||||
message: str = ""
|
||||
|
||||
|
||||
# ── 字段选项 ─────────────────────────────────────────────
|
||||
@router.get("/options")
|
||||
def get_options(request: Request):
|
||||
"""返回可选字段、信号列、运算符、枚举,供前端表单使用。"""
|
||||
from app.indicators.pipeline import ENRICHED_COLUMNS
|
||||
from app.strategy.custom_signals import ALLOWED_FIELDS, load_all as load_csg
|
||||
|
||||
# 阈值字段 (带中文标签)
|
||||
threshold_fields = [
|
||||
{"key": f, "label": ENRICHED_COLUMNS.get(f, f)}
|
||||
for f in sorted(ALLOWED_FIELDS)
|
||||
]
|
||||
# 内置信号列 (布尔, 用于 op=truth)
|
||||
builtin_signals = [
|
||||
{"key": k, "label": v}
|
||||
for k, v in ENRICHED_COLUMNS.items()
|
||||
if k.startswith("signal_")
|
||||
]
|
||||
# 自定义信号列 (csg_)
|
||||
custom_sigs = []
|
||||
try:
|
||||
for cs in load_csg(_data_dir(request)):
|
||||
if cs.get("enabled") is not False:
|
||||
custom_sigs.append({
|
||||
"key": f"csg_{cs['id']}",
|
||||
"label": cs.get("name", cs["id"]),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"threshold_fields": threshold_fields,
|
||||
"builtin_signals": builtin_signals,
|
||||
"custom_signals": custom_sigs,
|
||||
"operators": [">", ">=", "<", "<=", "==", "!="],
|
||||
"types": [
|
||||
{"key": "signal", "label": "个股信号"},
|
||||
{"key": "price", "label": "价格/涨跌"},
|
||||
{"key": "market", "label": "市场异动"},
|
||||
{"key": "strategy", "label": "策略监控"},
|
||||
],
|
||||
"scopes": [
|
||||
{"key": "symbols", "label": "指定股票"},
|
||||
{"key": "all", "label": "全市场"},
|
||||
{"key": "sector", "label": "板块"},
|
||||
],
|
||||
"logics": [
|
||||
{"key": "and", "label": "全部满足 (AND)"},
|
||||
{"key": "or", "label": "任一满足 (OR)"},
|
||||
],
|
||||
"severities": [
|
||||
{"key": "info", "label": "普通"},
|
||||
{"key": "warn", "label": "警告"},
|
||||
{"key": "critical", "label": "重要"},
|
||||
],
|
||||
"directions": [
|
||||
{"key": "entry", "label": "买入"},
|
||||
{"key": "exit", "label": "卖出"},
|
||||
{"key": "both", "label": "买卖都报"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 列表 ───────────────────────────────────────────────
|
||||
@router.get("")
|
||||
def list_rules(request: Request):
|
||||
rules = monitor_rules.load_all(_data_dir(request))
|
||||
# 按 created_at 倒序
|
||||
rules.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
return {"rules": rules}
|
||||
|
||||
|
||||
# ── 新建 / 更新 ────────────────────────────────────────
|
||||
@router.post("")
|
||||
def save_rule(req: RuleModel, request: Request):
|
||||
rule = monitor_rules.normalize(req.model_dump())
|
||||
# 编辑现有规则时, 保留原 created_at (避免按时间排序时位置跳动)
|
||||
existing = monitor_rules.load_one(_data_dir(request), rule["id"])
|
||||
if existing and existing.get("created_at"):
|
||||
rule["created_at"] = existing["created_at"]
|
||||
try:
|
||||
monitor_rules.validate(rule)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
monitor_rules.save_one(_data_dir(request), rule)
|
||||
_sync_engine(request)
|
||||
return {"ok": True, "rule": rule}
|
||||
|
||||
|
||||
# ── 删除 ───────────────────────────────────────────────
|
||||
@router.delete("/{rule_id}")
|
||||
def delete_rule(rule_id: str, request: Request):
|
||||
if not monitor_rules.ID_RE.match(rule_id):
|
||||
raise HTTPException(status_code=400, detail="规则 id 非法")
|
||||
deleted = monitor_rules.delete_one(_data_dir(request), rule_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="规则不存在")
|
||||
_sync_engine(request)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 演示数据生成 (仅 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],
|
||||
conditions: list[dict], logic: str = "or", cooldown: int = 3600,
|
||||
severity: str = "info", message: str = "",
|
||||
strategy_id: str | None = None, direction: str = "entry") -> dict:
|
||||
rule = monitor_rules.normalize({
|
||||
"id": rule_id,
|
||||
"name": name,
|
||||
"type": rtype,
|
||||
"scope": scope,
|
||||
"symbols": symbols,
|
||||
"conditions": conditions,
|
||||
"logic": logic,
|
||||
"cooldown_seconds": cooldown,
|
||||
"severity": severity,
|
||||
"message": message,
|
||||
"enabled": True,
|
||||
})
|
||||
if rtype == "strategy":
|
||||
rule["strategy_id"] = strategy_id
|
||||
rule["direction"] = direction
|
||||
return rule
|
||||
|
||||
|
||||
_DEMO_RULES_TEMPLATE = [
|
||||
("个股信号 · 茅台放量突破", "signal", "symbols", ["600519.SH"],
|
||||
[{"field": "signal_volume_surge", "op": "truth"},
|
||||
{"field": "signal_n_day_high", "op": "truth"}], "or", "info"),
|
||||
("个股信号 · 宁德金叉", "signal", "symbols", ["300750.SZ"],
|
||||
[{"field": "signal_ma_golden_5_20", "op": "truth"}], "or", "info"),
|
||||
("价格 · 平安跌幅监控", "price", "symbols", ["000001.SZ"],
|
||||
[{"field": "change_pct", "op": "<", "value": -0.03}], "or", "warn", "warn"),
|
||||
("价格 · 比亚迪RSI超卖", "price", "symbols", ["002594.SZ"],
|
||||
[{"field": "rsi_14", "op": "<", "value": 30}], "and", "warn", "warn"),
|
||||
("市场异动 · 全市场涨停", "market", "all", [],
|
||||
[{"field": "signal_limit_up", "op": "truth"}], "or", "critical", "critical"),
|
||||
("市场异动 · 全市场炸板", "market", "all", [],
|
||||
[{"field": "signal_broken_limit_up", "op": "truth"}], "or", "warn", "warn"),
|
||||
("市场异动 · 跌幅超5%", "market", "all", [],
|
||||
[{"field": "change_pct", "op": "<", "value": -0.05}], "or", "warn", "warn"),
|
||||
("个股信号 · 茅台跌破MA20", "signal", "symbols", ["600519.SH"],
|
||||
[{"field": "signal_ma20_breakdown", "op": "truth"}], "or", "info"),
|
||||
]
|
||||
|
||||
# 策略类型单独声明 (格式不同: 含 strategy_id + direction)
|
||||
_DEMO_STRATEGY_RULES: list[dict] = [
|
||||
{"name": "策略监控 · 趋势突破", "strategy_id": "trend_breakout", "direction": "entry"},
|
||||
{"name": "策略监控 · MACD金叉", "strategy_id": "macd_golden", "direction": "both"},
|
||||
]
|
||||
|
||||
|
||||
@router.post("/seed")
|
||||
def seed_demo_rules(request: Request):
|
||||
"""生成演示监控规则 (Dev 页用)。覆盖 signal/price/market/strategy 四类。"""
|
||||
ts = int(_time.time() * 1000)
|
||||
created = []
|
||||
i = 0
|
||||
for (name, rtype, scope, symbols, conditions, logic, severity, sev) in _DEMO_RULES_TEMPLATE:
|
||||
rule_id = f"demo_{ts}_{i}"
|
||||
rule = _demo_rule(rule_id, name, rtype, scope, symbols, conditions, logic, 3600, sev)
|
||||
monitor_rules.save_one(_data_dir(request), rule)
|
||||
created.append(rule_id)
|
||||
i += 1
|
||||
# 策略类型规则
|
||||
for sr in _DEMO_STRATEGY_RULES:
|
||||
rule_id = f"demo_{ts}_{i}"
|
||||
rule = _demo_rule(
|
||||
rule_id, sr["name"], "strategy", "all", [], [], "and", 3600, "info",
|
||||
strategy_id=sr["strategy_id"], direction=sr.get("direction", "entry"),
|
||||
)
|
||||
monitor_rules.save_one(_data_dir(request), rule)
|
||||
created.append(rule_id)
|
||||
i += 1
|
||||
_sync_engine(request)
|
||||
return {"ok": True, "generated": len(created), "ids": created}
|
||||
@@ -0,0 +1,570 @@
|
||||
"""市场总览聚合 API。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
import polars as pl
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from app.services.ext_data import ExtConfig, ExtConfigStore
|
||||
from app.services.screener import ScreenerService
|
||||
|
||||
router = APIRouter(prefix="/api/overview", tags=["overview"])
|
||||
|
||||
_CACHE_TTL = 5.0
|
||||
_cache: dict[str, Any] | None = None
|
||||
_cache_key: str | None = None
|
||||
_cache_ts: float = 0.0
|
||||
|
||||
|
||||
def invalidate_overview_cache() -> None:
|
||||
"""清空总览聚合结果缓存。
|
||||
|
||||
清除数据后调用, 避免看板在 TTL 窗口内继续返回旧的聚合结果。
|
||||
"""
|
||||
global _cache, _cache_key, _cache_ts
|
||||
_cache = None
|
||||
_cache_key = None
|
||||
_cache_ts = 0.0
|
||||
|
||||
|
||||
CORE_INDEX_NAMES = {
|
||||
"000001.SH": "上证指数",
|
||||
"399001.SZ": "深证成指",
|
||||
"399006.SZ": "创业板指",
|
||||
"000680.SH": "科创综指",
|
||||
}
|
||||
CORE_INDEX_SYMBOLS = tuple(CORE_INDEX_NAMES.keys())
|
||||
|
||||
_DIMENSION_SEP = re.compile(r"[、,,;;|/\s]+")
|
||||
|
||||
|
||||
def _dimension_field(config: ExtConfig, kind: str) -> str | None:
|
||||
candidates = ["概念", "concept", "theme"] if kind == "concept" else ["行业", "industry", "sector"]
|
||||
for candidate in candidates:
|
||||
needle = candidate.lower()
|
||||
for field in config.fields:
|
||||
haystack = f"{field.name} {field.label}".lower()
|
||||
if needle in haystack:
|
||||
return field.name
|
||||
return None
|
||||
|
||||
|
||||
def _ext_files(data_dir, config: ExtConfig) -> list[str]:
|
||||
base = data_dir / "ext_data" / config.id
|
||||
if config.mode == "timeseries":
|
||||
root = base / "timeseries"
|
||||
return [str(p) for p in sorted(root.rglob("*.parquet")) if p.is_file()]
|
||||
return [str(p) for p in sorted(base.glob("*.parquet")) if p.is_file()]
|
||||
|
||||
|
||||
def _read_ext_rows(data_dir, config: ExtConfig, dimension_field: str) -> list[dict]:
|
||||
files = _ext_files(data_dir, config)
|
||||
if not files:
|
||||
return []
|
||||
try:
|
||||
df = pl.read_parquet(files, hive_partitioning=True)
|
||||
except TypeError:
|
||||
try:
|
||||
df = pl.read_parquet(files)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
if df.is_empty() or dimension_field not in df.columns:
|
||||
return []
|
||||
|
||||
if config.mode == "timeseries" and "date" in df.columns:
|
||||
latest = df.get_column("date").max()
|
||||
if latest is not None:
|
||||
df = df.filter(pl.col("date") == latest)
|
||||
|
||||
symbol_cols = ["symbol", "code", "股票代码", "代码"]
|
||||
for mapping in (config.symbol_map, config.code_map):
|
||||
if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"):
|
||||
symbol_cols.append(str(mapping["col"]))
|
||||
cols = []
|
||||
for col in [dimension_field, *symbol_cols]:
|
||||
if col in df.columns and col not in cols:
|
||||
cols.append(col)
|
||||
return df.select(cols).to_dicts()
|
||||
|
||||
|
||||
def _dimension_values(raw: Any) -> list[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
values = [v.strip() for v in _DIMENSION_SEP.split(str(raw).strip()) if v.strip()]
|
||||
return values
|
||||
|
||||
|
||||
def _symbol_keys(row: dict, config: ExtConfig) -> list[str]:
|
||||
fields = ["symbol", "code", "股票代码", "代码"]
|
||||
for mapping in (config.symbol_map, config.code_map):
|
||||
if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"):
|
||||
fields.append(str(mapping["col"]))
|
||||
|
||||
keys: list[str] = []
|
||||
for field in fields:
|
||||
raw = row.get(field)
|
||||
if raw is None:
|
||||
continue
|
||||
text = str(raw).strip().upper()
|
||||
if not text:
|
||||
continue
|
||||
keys.append(text)
|
||||
if "." in text:
|
||||
keys.append(text.split(".", 1)[0])
|
||||
return keys
|
||||
|
||||
|
||||
def _dimension_rank(rows: list[dict], request: Request, kind: str, limit: int = 5, level: int | None = None) -> dict:
|
||||
if not rows:
|
||||
return {"leading": [], "lagging": []}
|
||||
|
||||
quote_map: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
symbol = str(row.get("symbol") or "").strip().upper()
|
||||
if not symbol:
|
||||
continue
|
||||
quote_map[symbol] = row
|
||||
quote_map[symbol.split(".", 1)[0]] = row
|
||||
|
||||
store = ExtConfigStore(request.app.state.repo.store.data_dir)
|
||||
groups: dict[str, dict[str, dict]] = {}
|
||||
for config in store.load_all():
|
||||
field = _dimension_field(config, kind)
|
||||
if not field:
|
||||
continue
|
||||
for ext_row in _read_ext_rows(request.app.state.repo.store.data_dir, config, field):
|
||||
quote = None
|
||||
for key in _symbol_keys(ext_row, config):
|
||||
quote = quote_map.get(key)
|
||||
if quote:
|
||||
break
|
||||
if not quote:
|
||||
continue
|
||||
symbol = str(quote.get("symbol") or "")
|
||||
for value in _dimension_values(ext_row.get(field)):
|
||||
# 行业按 "-" 拆分级: "银行-银行-股份制银行" → level=2 取"银行"(二级)
|
||||
if level is not None and "-" in value:
|
||||
parts = value.split("-")
|
||||
value = parts[level - 1] if level <= len(parts) else parts[-1]
|
||||
groups.setdefault(value, {})[symbol] = quote
|
||||
|
||||
items = []
|
||||
for name, by_symbol in groups.items():
|
||||
stocks = list(by_symbol.values())
|
||||
changes = [_finite(s.get("change_pct")) for s in stocks]
|
||||
changes = [v for v in changes if v is not None]
|
||||
if not changes:
|
||||
continue
|
||||
leader = max(stocks, key=lambda s: _finite(s.get("change_pct")) or -999)
|
||||
items.append({
|
||||
"name": name,
|
||||
"count": len(stocks),
|
||||
"avg_pct": sum(changes) / len(changes),
|
||||
"up_count": sum(1 for v in changes if v > 0),
|
||||
"down_count": sum(1 for v in changes if v < 0),
|
||||
"amount": sum(_finite(s.get("amount")) or 0 for s in stocks),
|
||||
"leader": {
|
||||
"symbol": leader.get("symbol"),
|
||||
"name": leader.get("name"),
|
||||
"change_pct": _finite(leader.get("change_pct")),
|
||||
},
|
||||
})
|
||||
|
||||
leading = sorted(items, key=lambda x: x["avg_pct"], reverse=True)[:limit]
|
||||
lagging = sorted(items, key=lambda x: x["avg_pct"])[:limit]
|
||||
return {"leading": leading, "lagging": lagging}
|
||||
|
||||
|
||||
def _finite(v: Any) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
f = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return f if math.isfinite(f) else None
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {k: _json_safe(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_json_safe(v) for v in value]
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _board(symbol: str) -> str:
|
||||
if symbol.endswith(".BJ"):
|
||||
return "北交所"
|
||||
if symbol.startswith(("300", "301")):
|
||||
return "创业板"
|
||||
if symbol.startswith(("688", "689")):
|
||||
return "科创板"
|
||||
if symbol.endswith(".SH"):
|
||||
return "沪主板"
|
||||
if symbol.endswith(".SZ"):
|
||||
return "深主板"
|
||||
return "其他"
|
||||
|
||||
|
||||
def _score(value: float, low: float, high: float) -> int:
|
||||
if high <= low:
|
||||
return 50
|
||||
return max(0, min(100, round((value - low) / (high - low) * 100)))
|
||||
|
||||
|
||||
def _quote_status(request: Request) -> dict:
|
||||
qs = getattr(request.app.state, "quote_service", None)
|
||||
if not qs:
|
||||
return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False}
|
||||
return qs.status()
|
||||
|
||||
|
||||
def _index_quotes(request: Request, as_of: date | None = None) -> list[dict]:
|
||||
qs = getattr(request.app.state, "quote_service", None)
|
||||
rows: list[dict] = []
|
||||
if qs and as_of is None:
|
||||
df = qs.get_index_quotes(list(CORE_INDEX_SYMBOLS))
|
||||
if not df.is_empty():
|
||||
rows = df.to_dicts()
|
||||
|
||||
if not rows:
|
||||
repo = getattr(request.app.state, "repo", None)
|
||||
if repo:
|
||||
placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
|
||||
try:
|
||||
db_rows = repo.execute_all(
|
||||
f"""
|
||||
WITH ranked AS (
|
||||
SELECT symbol, date, close,
|
||||
row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
|
||||
FROM kline_index_daily
|
||||
WHERE symbol IN ({placeholders})
|
||||
AND (? IS NULL OR date <= ?)
|
||||
), latest AS (
|
||||
SELECT symbol,
|
||||
max(CASE WHEN rn = 1 THEN date END) AS date,
|
||||
max(CASE WHEN rn = 1 THEN close END) AS last_price,
|
||||
max(CASE WHEN rn = 2 THEN close END) AS prev_close
|
||||
FROM ranked
|
||||
WHERE rn <= 2
|
||||
GROUP BY symbol
|
||||
)
|
||||
SELECT symbol, date, last_price, prev_close
|
||||
FROM latest
|
||||
""",
|
||||
[*CORE_INDEX_SYMBOLS, as_of, as_of],
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
db_rows = []
|
||||
for symbol, dt, last_price, prev_close in db_rows:
|
||||
change_amount = None
|
||||
change_pct = None
|
||||
lp = _finite(last_price)
|
||||
pc = _finite(prev_close)
|
||||
if lp is not None and pc not in (None, 0):
|
||||
change_amount = lp - pc
|
||||
change_pct = change_amount / pc * 100
|
||||
rows.append({
|
||||
"symbol": symbol,
|
||||
"name": CORE_INDEX_NAMES.get(symbol),
|
||||
"date": str(dt) if dt else None,
|
||||
"last_price": lp,
|
||||
"close": lp,
|
||||
"prev_close": pc,
|
||||
"change_amount": change_amount,
|
||||
"change_pct": change_pct,
|
||||
})
|
||||
|
||||
by_symbol = {r.get("symbol"): r for r in rows}
|
||||
out = []
|
||||
for symbol in CORE_INDEX_SYMBOLS:
|
||||
r = by_symbol.get(symbol, {"symbol": symbol})
|
||||
out.append({
|
||||
"symbol": symbol,
|
||||
"name": r.get("name") or CORE_INDEX_NAMES[symbol],
|
||||
"last_price": _finite(r.get("last_price") if r.get("last_price") is not None else r.get("close")),
|
||||
"change_pct": _finite(r.get("change_pct")),
|
||||
"change_amount": _finite(r.get("change_amount")),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _top_rows(rows: list[dict], key: str, descending: bool, limit: int = 8) -> list[dict]:
|
||||
filtered = [r for r in rows if _finite(r.get(key)) is not None]
|
||||
filtered.sort(key=lambda r: _finite(r.get(key)) or 0, reverse=descending)
|
||||
return [
|
||||
{
|
||||
"symbol": r.get("symbol"),
|
||||
"name": r.get("name"),
|
||||
"close": _finite(r.get("close")),
|
||||
"change_pct": _finite(r.get("change_pct")),
|
||||
"amount": _finite(r.get("amount")),
|
||||
"turnover_rate": _finite(r.get("turnover_rate")),
|
||||
"board": _board(str(r.get("symbol") or "")),
|
||||
}
|
||||
for r in filtered[:limit]
|
||||
]
|
||||
|
||||
|
||||
def _pct_band_rows(values: list[float]) -> list[dict]:
|
||||
bands = [
|
||||
("<-5%", None, -0.05),
|
||||
("-5~-3%", -0.05, -0.03),
|
||||
("-3~-1%", -0.03, -0.01),
|
||||
("-1~0%", -0.01, 0),
|
||||
("0~1%", 0, 0.01),
|
||||
("1~3%", 0.01, 0.03),
|
||||
("3~5%", 0.03, 0.05),
|
||||
(">5%", 0.05, None),
|
||||
]
|
||||
total = len(values) or 1
|
||||
out = []
|
||||
for label, low, high in bands:
|
||||
count = 0
|
||||
for v in values:
|
||||
if low is None and v < high:
|
||||
count += 1
|
||||
elif high is None and v >= low:
|
||||
count += 1
|
||||
elif low is not None and high is not None and low <= v < high:
|
||||
count += 1
|
||||
out.append({"label": label, "count": count, "pct": count / total * 100})
|
||||
return out
|
||||
|
||||
|
||||
def _build_overview(request: Request, as_of: date | None = None) -> dict:
|
||||
repo = request.app.state.repo
|
||||
svc = ScreenerService(repo)
|
||||
as_of = as_of or svc.latest_date()
|
||||
status = _quote_status(request)
|
||||
indices = _index_quotes(request, as_of)
|
||||
|
||||
if not as_of:
|
||||
return {
|
||||
"as_of": None,
|
||||
"quote_status": status,
|
||||
"indices": indices,
|
||||
"breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 0},
|
||||
"amount": {"total": 0, "avg": 0},
|
||||
"boards": [],
|
||||
"limit": {"limit_up": 0, "broken": 0, "failed": 0, "limit_down": 0, "max_boards": 0, "tiers": []},
|
||||
"distribution": [],
|
||||
"trend": {"above_ma5": 0, "above_ma20": 0, "above_ma60": 0, "above_ma5_pct": 0, "above_ma20_pct": 0, "above_ma60_pct": 0, "new_high": 0, "new_low": 0},
|
||||
"activity": {"avg_turnover": 0, "high_turnover": 0, "high_vol_ratio": 0, "vol_ratio": 1},
|
||||
"radar": [],
|
||||
"emotion": {"score": 50, "label": "暂无"},
|
||||
"top_gainers": [],
|
||||
"top_losers": [],
|
||||
"turnover_leaders": [],
|
||||
"active_leaders": [],
|
||||
"concept_rank": {"leading": [], "lagging": []},
|
||||
"industry_rank": {"leading": [], "lagging": []},
|
||||
}
|
||||
|
||||
df = svc._load_enriched_for_date(as_of)
|
||||
if df.is_empty():
|
||||
rows: list[dict] = []
|
||||
else:
|
||||
cols = [
|
||||
"symbol", "name", "close", "change_pct", "amount", "turnover_rate", "volume",
|
||||
"vol_ratio_5d", "consecutive_limit_ups", "signal_limit_up", "signal_broken_limit_up", "signal_limit_down",
|
||||
"ma5", "ma20", "ma60", "high_60d", "low_60d", "signal_n_day_high", "signal_n_day_low",
|
||||
]
|
||||
df = df.select([c for c in cols if c in df.columns])
|
||||
rows = df.to_dicts()
|
||||
|
||||
# 过滤真停牌(volume=0 且 change_pct=0),保留有涨跌幅的浮点误差股以对齐同花顺口径
|
||||
if rows and "volume" in rows[0]:
|
||||
rows = [r for r in rows
|
||||
if (_finite(r.get("volume")) or 0) > 0
|
||||
or (_finite(r.get("change_pct")) or 0) != 0]
|
||||
|
||||
total = len(rows)
|
||||
up = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) > 0)
|
||||
down = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) < 0)
|
||||
flat = max(0, total - up - down)
|
||||
up_pct = up / total * 100 if total else 0
|
||||
down_pct = down / total * 100 if total else 0
|
||||
|
||||
amounts = [_finite(r.get("amount")) or 0 for r in rows]
|
||||
total_amount = sum(amounts)
|
||||
avg_amount = total_amount / total if total else 0
|
||||
|
||||
pct_values = [_finite(r.get("change_pct")) for r in rows]
|
||||
pct_values = [v for v in pct_values if v is not None]
|
||||
avg_pct = sum(pct_values) / len(pct_values) if pct_values else 0
|
||||
median_pct = sorted(pct_values)[len(pct_values) // 2] if pct_values else 0
|
||||
strong_up = sum(1 for v in pct_values if v >= 0.03)
|
||||
strong_down = sum(1 for v in pct_values if v <= -0.03)
|
||||
|
||||
limit_up = sum(1 for r in rows if bool(r.get("signal_limit_up")) or (_finite(r.get("consecutive_limit_ups")) or 0) > 0)
|
||||
broken = sum(1 for r in rows if bool(r.get("signal_broken_limit_up")))
|
||||
limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down")))
|
||||
max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0)
|
||||
|
||||
# 五档 sealed 修正: 假涨停/假跌停不计入(需 Pro+ depth5.batch 能力)
|
||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
||||
sealed_ready = False
|
||||
fake_up = 0
|
||||
fake_down = 0
|
||||
if depth_svc:
|
||||
up_map = depth_svc.get_sealed_map(as_of, is_down=False)
|
||||
down_map = depth_svc.get_sealed_map(as_of, is_down=True)
|
||||
sealed_ready = bool(up_map or down_map) and depth_svc.is_sealed_ready(as_of)
|
||||
if up_map:
|
||||
fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False)
|
||||
if down_map:
|
||||
fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False)
|
||||
if sealed_ready:
|
||||
limit_up = max(0, limit_up - fake_up)
|
||||
limit_down = max(0, limit_down - fake_down)
|
||||
|
||||
seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0
|
||||
|
||||
def above_ma_count(ma_key: str) -> int:
|
||||
return sum(1 for r in rows if (_finite(r.get("close")) is not None and _finite(r.get(ma_key)) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get(ma_key)) or 0)))
|
||||
|
||||
above_ma5 = above_ma_count("ma5")
|
||||
above_ma20 = above_ma_count("ma20")
|
||||
above_ma60 = above_ma_count("ma60")
|
||||
new_high = sum(1 for r in rows if bool(r.get("signal_n_day_high")) or (_finite(r.get("close")) is not None and _finite(r.get("high_60d")) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get("high_60d")) or 0)))
|
||||
new_low = sum(1 for r in rows if bool(r.get("signal_n_day_low")) or (_finite(r.get("close")) is not None and _finite(r.get("low_60d")) is not None and (_finite(r.get("close")) or 0) <= (_finite(r.get("low_60d")) or 0)))
|
||||
|
||||
turnovers = [_finite(r.get("turnover_rate")) for r in rows]
|
||||
turnovers = [v for v in turnovers if v is not None]
|
||||
avg_turnover = sum(turnovers) / len(turnovers) if turnovers else 0
|
||||
high_turnover = sum(1 for v in turnovers if v >= 5)
|
||||
|
||||
boards_map: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
b = _board(str(r.get("symbol") or ""))
|
||||
item = boards_map.setdefault(b, {"board": b, "count": 0, "up": 0, "down": 0, "amount": 0.0})
|
||||
item["count"] += 1
|
||||
change = _finite(r.get("change_pct")) or 0
|
||||
if change > 0:
|
||||
item["up"] += 1
|
||||
elif change < 0:
|
||||
item["down"] += 1
|
||||
item["amount"] += _finite(r.get("amount")) or 0
|
||||
boards = sorted(boards_map.values(), key=lambda x: x["amount"], reverse=True)
|
||||
for b in boards:
|
||||
count = b["count"] or 1
|
||||
b["up_pct"] = b["up"] / count * 100
|
||||
|
||||
tiers_map: dict[int, int] = {}
|
||||
for r in rows:
|
||||
n = int(_finite(r.get("consecutive_limit_ups")) or 0)
|
||||
if n > 0:
|
||||
tiers_map[n] = tiers_map.get(n, 0) + 1
|
||||
tiers = [{"boards": k, "count": v} for k, v in sorted(tiers_map.items(), key=lambda item: -item[0])]
|
||||
|
||||
index_changes = [_finite(r.get("change_pct")) for r in indices]
|
||||
index_changes = [v for v in index_changes if v is not None]
|
||||
avg_index_pct = sum(index_changes) / len(index_changes) if index_changes else 0
|
||||
vol_ratios = [_finite(r.get("vol_ratio_5d")) for r in rows]
|
||||
vol_ratios = [v for v in vol_ratios if v is not None]
|
||||
avg_vol_ratio = sum(vol_ratios) / len(vol_ratios) if vol_ratios else 1
|
||||
high_vol_ratio = sum(1 for v in vol_ratios if v >= 1.5)
|
||||
|
||||
concept_rank = _dimension_rank(rows, request, "concept")
|
||||
industry_rank = _dimension_rank(rows, request, "industry", level=2)
|
||||
|
||||
strong_diff_pct = (strong_up - strong_down) / total * 100 if total else 0
|
||||
high_vol_pct = high_vol_ratio / total * 100 if total else 0
|
||||
strong_down_pct = strong_down / total * 100 if total else 0
|
||||
tier2_count = sum(t["count"] for t in tiers if t["boards"] >= 2)
|
||||
mainline_items = [*concept_rank["leading"][:3], *industry_rank["leading"][:3]]
|
||||
mainline_avg = max([_finite(item.get("avg_pct")) or 0 for item in mainline_items], default=0)
|
||||
mainline_cover_pct = max([(_finite(item.get("count")) or 0) / total * 100 for item in mainline_items], default=0) if total else 0
|
||||
mainline_score = round(_score(mainline_avg, -0.005, 0.03) * 0.65 + _score(mainline_cover_pct, 1, 12) * 0.35) if mainline_items else 50
|
||||
|
||||
radar = [
|
||||
{"key": "index", "label": "指数", "value": _score(avg_index_pct, -2.5, 2.5)},
|
||||
{"key": "profit", "label": "赚钱", "value": round(_score(up_pct, 20, 80) * 0.45 + _score(avg_pct, -0.02, 0.02) * 0.25 + _score(median_pct, -0.02, 0.02) * 0.20 + _score(strong_diff_pct, -8, 8) * 0.10)},
|
||||
{"key": "money", "label": "量能", "value": round(_score(avg_vol_ratio, 0.6, 1.8) * 0.70 + _score(high_vol_pct, 2, 12) * 0.30)},
|
||||
{"key": "speculation", "label": "投机", "value": round(_score(limit_up, 5, 90) * 0.25 + _score(seal_rate, 30, 85) * 0.35 + _score(max_boards, 1, 8) * 0.25 + _score(tier2_count, 0, 30) * 0.15)},
|
||||
{"key": "resilience", "label": "抗跌", "value": 100 - round(_score(down_pct, 20, 80) * 0.55 + _score(strong_down_pct, 1, 12) * 0.45)},
|
||||
{"key": "mainline", "label": "主线", "value": mainline_score},
|
||||
]
|
||||
emotion_score = round(sum(r["value"] for r in radar) / len(radar)) if radar else 50
|
||||
if emotion_score >= 70:
|
||||
emotion_label = "强势"
|
||||
elif emotion_score >= 55:
|
||||
emotion_label = "偏暖"
|
||||
elif emotion_score >= 45:
|
||||
emotion_label = "震荡"
|
||||
elif emotion_score >= 30:
|
||||
emotion_label = "偏冷"
|
||||
else:
|
||||
emotion_label = "冰点"
|
||||
|
||||
return _json_safe({
|
||||
"as_of": str(as_of),
|
||||
"quote_status": status,
|
||||
"indices": indices,
|
||||
"breadth": {
|
||||
"total": total,
|
||||
"up": up,
|
||||
"down": down,
|
||||
"flat": flat,
|
||||
"up_pct": up_pct,
|
||||
"down_pct": down_pct,
|
||||
"avg_pct": avg_pct,
|
||||
"median_pct": median_pct,
|
||||
"strong_up": strong_up,
|
||||
"strong_down": strong_down,
|
||||
},
|
||||
"amount": {"total": total_amount, "avg": avg_amount},
|
||||
"boards": boards,
|
||||
"limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers, "sealed_ready": sealed_ready, "fake_up": fake_up, "fake_down": fake_down},
|
||||
"distribution": _pct_band_rows(pct_values),
|
||||
"trend": {
|
||||
"above_ma5": above_ma5,
|
||||
"above_ma20": above_ma20,
|
||||
"above_ma60": above_ma60,
|
||||
"above_ma5_pct": above_ma5 / total * 100 if total else 0,
|
||||
"above_ma20_pct": above_ma20 / total * 100 if total else 0,
|
||||
"above_ma60_pct": above_ma60 / total * 100 if total else 0,
|
||||
"new_high": new_high,
|
||||
"new_low": new_low,
|
||||
},
|
||||
"activity": {
|
||||
"avg_turnover": avg_turnover,
|
||||
"high_turnover": high_turnover,
|
||||
"high_vol_ratio": high_vol_ratio,
|
||||
"vol_ratio": avg_vol_ratio,
|
||||
},
|
||||
"radar": radar,
|
||||
"emotion": {"score": emotion_score, "label": emotion_label},
|
||||
"top_gainers": _top_rows(rows, "change_pct", True),
|
||||
"top_losers": _top_rows(rows, "change_pct", False),
|
||||
"turnover_leaders": _top_rows(rows, "amount", True),
|
||||
"active_leaders": _top_rows(rows, "turnover_rate", True),
|
||||
"concept_rank": concept_rank,
|
||||
"industry_rank": industry_rank,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/market")
|
||||
def market_overview(request: Request, as_of: date | None = None):
|
||||
"""总览页单次请求聚合数据,避免前端拉全市场明细后再计算。"""
|
||||
global _cache, _cache_key, _cache_ts
|
||||
now = time.time()
|
||||
cache_key = as_of.isoformat() if as_of else "latest"
|
||||
if _cache is not None and _cache_key == cache_key and (now - _cache_ts) < _CACHE_TTL:
|
||||
return _cache
|
||||
data = _build_overview(request, as_of)
|
||||
_cache = data
|
||||
_cache_key = cache_key
|
||||
_cache_ts = now
|
||||
return data
|
||||
@@ -0,0 +1,107 @@
|
||||
"""盘后管道 API — 异步触发 + 进度跟踪。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures as _cf
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.jobs import daily_pipeline
|
||||
from app.services.pipeline_jobs import job_store
|
||||
from app.api.data import invalidate_storage_cache
|
||||
|
||||
# 长时间任务专用线程池(隔离于 FastAPI 默认线程池,防止阻塞请求处理)
|
||||
_long_task_executor = _cf.ThreadPoolExecutor(max_workers=2, thread_name_prefix="long-task")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/pipeline", tags=["pipeline"])
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def run_now(request: Request) -> dict:
|
||||
"""异步触发盘后管道,立即返回 job_id。客户端轮询 /jobs/{id} 拿进度。
|
||||
|
||||
若已有任务在跑,**返回该任务 id 而不是开新任务**(防止并发拉数据撞限流)。
|
||||
但如果该任务已运行超过 10 分钟 (可能因 reload 卡死), 强制标记为失败后重新创建。
|
||||
"""
|
||||
repo = request.app.state.repo
|
||||
capset = request.app.state.capabilities
|
||||
|
||||
# 检测卡死的 running job (如 reload 后孤儿 task)
|
||||
existing_id = job_store.active_id()
|
||||
if existing_id:
|
||||
existing = job_store.get(existing_id)
|
||||
if existing and existing["status"] == "running":
|
||||
from datetime import datetime, timezone
|
||||
started = existing.get("started_at")
|
||||
if started:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(started.replace("Z", "+00:00"))
|
||||
elapsed = (datetime.now(timezone.utc) - start_dt).total_seconds()
|
||||
if elapsed > 600: # 超过 10 分钟视为卡死
|
||||
logger.warning("强制取消卡死 job %s (已运行 %.0fs)", existing_id, elapsed)
|
||||
job_store.fail(existing_id, "超时自动取消 (疑似 reload 后孤儿 task)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
job_id = job_store.create()
|
||||
|
||||
# 如果是复用的 active job,直接返回(不重启)
|
||||
existing = job_store.get(job_id)
|
||||
if existing and existing["status"] == "running":
|
||||
return {"job_id": job_id, "reused": True}
|
||||
|
||||
# 在 executor 里跑同步任务(pipeline 内部都是阻塞 IO + CPU)
|
||||
async def task() -> None:
|
||||
job_store.start(job_id)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
_long_task_executor,
|
||||
lambda: daily_pipeline.run_now(repo, capset, on_progress=progress),
|
||||
)
|
||||
job_store.succeed(job_id, result)
|
||||
invalidate_storage_cache()
|
||||
repo.refresh_cache() # 刷新 Polars 缓存
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("pipeline failed")
|
||||
job_store.fail(job_id, str(e))
|
||||
invalidate_storage_cache()
|
||||
|
||||
asyncio.create_task(task())
|
||||
return {"job_id": job_id, "reused": False}
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}")
|
||||
def get_job(job_id: str) -> dict:
|
||||
j = job_store.get(job_id)
|
||||
if not j:
|
||||
raise HTTPException(status_code=404, detail="job not found")
|
||||
return j
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/cancel")
|
||||
def cancel_job(job_id: str) -> dict:
|
||||
"""手动取消一个 running 的 job。"""
|
||||
j = job_store.get(job_id)
|
||||
if not j:
|
||||
raise HTTPException(status_code=404, detail="job not found")
|
||||
if j["status"] not in ("running", "pending"):
|
||||
raise HTTPException(status_code=400, detail=f"job status is {j['status']}, cannot cancel")
|
||||
job_store.fail(job_id, "用户手动取消")
|
||||
return {"cancelled": job_id}
|
||||
|
||||
|
||||
@router.get("/jobs")
|
||||
def list_jobs(limit: int = 20) -> dict:
|
||||
return {
|
||||
"active_id": job_store.active_id(),
|
||||
"jobs": job_store.list_recent(limit=limit),
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"""API 路由 — Phase 0 仅 /health 与 /api/capabilities。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app import __version__
|
||||
from app.tickflow import client as tf_client
|
||||
from app.tickflow.policy import detect_capabilities, tier_label
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health() -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": __version__,
|
||||
# 三态: none(无key/无效) / free(免费key) / api_key(付费档)
|
||||
"mode": tf_client.current_mode(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/capabilities")
|
||||
def capabilities() -> dict:
|
||||
"""前端用来决定哪些功能可用、哪些灰显。"""
|
||||
capset = detect_capabilities()
|
||||
return {
|
||||
"label": tier_label(),
|
||||
"capabilities": capset.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/capabilities/redetect")
|
||||
def redetect() -> dict:
|
||||
"""用户在设置页"重新检测"按钮。"""
|
||||
capset = detect_capabilities(force=True)
|
||||
return {
|
||||
"label": tier_label(),
|
||||
"capabilities": capset.to_dict(),
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
"""Screener API。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services.screener import PRESET_STRATEGIES, ScreenerService
|
||||
from app.services import strategy_cache
|
||||
from app.strategy import config as strategy_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/screener", tags=["screener"])
|
||||
|
||||
|
||||
class CustomRequest(BaseModel):
|
||||
conditions: list[str]
|
||||
order_by: Optional[str] = None
|
||||
limit: int = 30
|
||||
pool: Optional[list[str]] = None
|
||||
as_of: Optional[date] = None
|
||||
ext_columns: Optional[str] = None
|
||||
|
||||
|
||||
class PresetRequest(BaseModel):
|
||||
strategy_id: str
|
||||
pool: Optional[list[str]] = None
|
||||
as_of: Optional[date] = None
|
||||
ext_columns: Optional[str] = None
|
||||
|
||||
|
||||
def _safe(result_dict: dict) -> dict:
|
||||
"""sanitize for JSON(NaN / Inf → None)."""
|
||||
rows = result_dict.get("rows", [])
|
||||
for r in rows:
|
||||
for k, v in list(r.items()):
|
||||
if isinstance(v, float) and not math.isfinite(v):
|
||||
r[k] = None
|
||||
return result_dict
|
||||
|
||||
|
||||
_EXT_IDENT_RE = re.compile(r"^[A-Za-z0-9_]+$")
|
||||
|
||||
|
||||
def _safe_ext_value(value: Any) -> Any:
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
return None
|
||||
if isinstance(value, (date, datetime)):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def _quote_ident(name: str) -> str:
|
||||
return '"' + name.replace('"', '""') + '"'
|
||||
|
||||
|
||||
def _load_ext_value_maps(repo, ext_columns: Optional[str]) -> dict[str, dict[str, Any]]:
|
||||
"""按请求加载扩展列,返回 {输出列名: {symbol: value}}。
|
||||
|
||||
策略结果缓存是共享文件,不能被不同 ext_columns 组合污染;因此扩展列只在
|
||||
返回前通过该投影映射追加到结果副本中。
|
||||
"""
|
||||
ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
|
||||
if not ext_specs:
|
||||
return {}
|
||||
|
||||
import polars as pl
|
||||
from app.api.ext_data import _read_ext_dataframe
|
||||
from app.services.ext_data import ExtConfigStore
|
||||
|
||||
db = repo.store.db
|
||||
data_dir = repo.store.data_dir
|
||||
ext_store = ExtConfigStore(data_dir)
|
||||
configs = {c.id: c for c in ext_store.load_all()}
|
||||
value_maps: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for config_id, field_name in ext_specs:
|
||||
out_col = f"{config_id}__{field_name}"
|
||||
cfg = configs.get(config_id)
|
||||
try:
|
||||
if cfg:
|
||||
# 时序扩展表只取最新分区,避免历史分区把同一 symbol JOIN 放大。
|
||||
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
|
||||
else:
|
||||
view_name = f"ext_{config_id}"
|
||||
ext_df = pl.from_arrow(db.query(
|
||||
f"SELECT symbol, {_quote_ident(field_name)} FROM {view_name}"
|
||||
).arrow())
|
||||
|
||||
if ext_df.is_empty() or "symbol" not in ext_df.columns or field_name not in ext_df.columns:
|
||||
continue
|
||||
|
||||
ext_df = ext_df.select(["symbol", field_name]).unique(subset=["symbol"], keep="last")
|
||||
value_maps[out_col] = {
|
||||
str(row["symbol"]): _safe_ext_value(row.get(field_name))
|
||||
for row in ext_df.to_dicts()
|
||||
if row.get("symbol")
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("screener ext column join skipped for %s.%s: %s", config_id, field_name, e)
|
||||
|
||||
return value_maps
|
||||
|
||||
|
||||
def _row_with_ext(row: dict, ext_values: dict[str, dict[str, Any]], symbol: Optional[str] = None) -> dict:
|
||||
next_row = dict(row)
|
||||
sym = symbol or next_row.get("symbol")
|
||||
for out_col, value_map in ext_values.items():
|
||||
next_row[out_col] = value_map.get(str(sym)) if sym else None
|
||||
return next_row
|
||||
|
||||
|
||||
def _rows_with_ext(rows: list[dict], ext_values: dict[str, dict[str, Any]]) -> list[dict]:
|
||||
if not ext_values:
|
||||
return rows
|
||||
return [_row_with_ext(r, ext_values) for r in rows]
|
||||
|
||||
|
||||
def _result_with_ext(result_dict: dict, ext_values: dict[str, dict[str, Any]]) -> dict:
|
||||
if not ext_values:
|
||||
return result_dict
|
||||
return {**result_dict, "rows": _rows_with_ext(result_dict.get("rows", []), ext_values)}
|
||||
|
||||
|
||||
def _results_with_ext(results: dict[str, dict], ext_values: dict[str, dict[str, Any]]) -> dict[str, dict]:
|
||||
if not ext_values:
|
||||
return results
|
||||
return {sid: _result_with_ext(r, ext_values) for sid, r in results.items()}
|
||||
|
||||
|
||||
def _cache_payload_with_ext(cached: dict, ext_values: dict[str, dict[str, Any]]) -> dict:
|
||||
if not ext_values:
|
||||
return cached
|
||||
|
||||
payload = dict(cached)
|
||||
payload["results"] = _results_with_ext(cached.get("results", {}), ext_values)
|
||||
|
||||
ever_rows = cached.get("today_ever_rows")
|
||||
if isinstance(ever_rows, dict):
|
||||
enriched_ever: dict[str, dict[str, dict]] = {}
|
||||
for sid, sym_map in ever_rows.items():
|
||||
if not isinstance(sym_map, dict):
|
||||
continue
|
||||
enriched_ever[sid] = {
|
||||
sym: _row_with_ext(row, ext_values, symbol=sym)
|
||||
for sym, row in sym_map.items()
|
||||
if isinstance(row, dict)
|
||||
}
|
||||
payload["today_ever_rows"] = enriched_ever
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _update_cache_strategy(data_dir, as_of: str, strategy_id: str, safe_data: dict) -> None:
|
||||
"""单跑后更新缓存中该策略的结果,保持缓存与最新计算一致。"""
|
||||
from app.services import strategy_cache
|
||||
cached = strategy_cache.read_cache(data_dir)
|
||||
if cached and cached.get("as_of") == as_of:
|
||||
results = cached.get("results", {})
|
||||
results[strategy_id] = {
|
||||
"total": safe_data.get("total", 0),
|
||||
"as_of": as_of,
|
||||
"rows": safe_data.get("rows", []),
|
||||
}
|
||||
strategy_cache.write_cache(data_dir, as_of, results)
|
||||
|
||||
|
||||
@router.get("/strategies")
|
||||
def strategies(request: Request):
|
||||
"""策略清单(内置 + 自定义 + AI)。"""
|
||||
data_dir = request.app.state.repo.store.data_dir
|
||||
presets = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
# 内置策略
|
||||
for k, v in PRESET_STRATEGIES.items():
|
||||
overrides = strategy_config.load_override(data_dir, k)
|
||||
name = (overrides.get("name") or v["name"]) if overrides else v["name"]
|
||||
desc = (overrides.get("description") or v["description"]) if overrides else v["description"]
|
||||
presets.append({"id": k, "name": name, "description": desc, "source": "builtin"})
|
||||
seen_ids.add(k)
|
||||
|
||||
# 自定义/AI 策略(不在 PRESET_STRATEGIES 中的)
|
||||
engine = getattr(request.app.state, "strategy_engine", None)
|
||||
if engine:
|
||||
for meta in engine.list_strategies():
|
||||
sid = meta["id"]
|
||||
if sid not in seen_ids:
|
||||
overrides = strategy_config.load_override(data_dir, sid)
|
||||
name = (overrides.get("name") or meta["name"]) if overrides else meta["name"]
|
||||
desc = (overrides.get("description") or meta.get("description", "")) if overrides else meta.get("description", "")
|
||||
presets.append({"id": sid, "name": name, "description": desc, "source": meta.get("source", "custom")})
|
||||
seen_ids.add(sid)
|
||||
|
||||
return {"presets": presets}
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
def run_custom(req: CustomRequest, request: Request):
|
||||
repo = request.app.state.repo
|
||||
svc = ScreenerService(repo)
|
||||
as_of = req.as_of or svc.latest_date()
|
||||
if not as_of:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="无可用数据日期 — enriched 表为空,请先运行盘后管道")
|
||||
result = svc.run(
|
||||
as_of=as_of,
|
||||
conditions=req.conditions,
|
||||
order_by=req.order_by,
|
||||
limit=req.limit,
|
||||
pool=req.pool,
|
||||
)
|
||||
safe_data = _safe(asdict(result))
|
||||
ext_values = _load_ext_value_maps(repo, req.ext_columns)
|
||||
return _result_with_ext(safe_data, ext_values)
|
||||
|
||||
|
||||
@router.post("/run_preset")
|
||||
def run_preset(req: PresetRequest, request: Request):
|
||||
repo = request.app.state.repo
|
||||
svc = ScreenerService(repo)
|
||||
as_of = req.as_of or svc.latest_date()
|
||||
if not as_of:
|
||||
raise HTTPException(status_code=400, detail="无可用数据日期")
|
||||
|
||||
# 加载用户保存的策略配置
|
||||
data_dir = request.app.state.repo.store.data_dir
|
||||
ext_values = _load_ext_value_maps(repo, req.ext_columns)
|
||||
overrides = strategy_config.load_override(data_dir, req.strategy_id)
|
||||
bf = overrides.get("basic_filter") if overrides else None
|
||||
dl = overrides.get("display_limit") if overrides else None
|
||||
if dl is None and overrides and "display_limit" in overrides:
|
||||
dl = 0
|
||||
|
||||
# 内置策略
|
||||
if req.strategy_id in PRESET_STRATEGIES:
|
||||
try:
|
||||
result = svc.run_preset(req.strategy_id, as_of=as_of, pool=req.pool, basic_filter=bf, display_limit=dl)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
safe_data = _safe(asdict(result))
|
||||
_update_cache_strategy(data_dir, str(as_of), req.strategy_id, safe_data)
|
||||
return _result_with_ext(safe_data, ext_values)
|
||||
|
||||
# 自定义/AI 策略 — 通过 StrategyEngine 执行
|
||||
engine = getattr(request.app.state, "strategy_engine", None)
|
||||
if not engine:
|
||||
raise HTTPException(status_code=404, detail=f"策略引擎未初始化或策略 {req.strategy_id} 不存在")
|
||||
|
||||
try:
|
||||
result = engine.run(req.strategy_id, as_of, pool=req.pool, overrides=overrides or None)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
data = asdict(result)
|
||||
|
||||
if dl is not None and dl > 0:
|
||||
data["rows"] = data["rows"][:dl]
|
||||
data["total"] = min(data["total"], dl)
|
||||
|
||||
# 单跑后更新缓存中该策略的结果(保持缓存最新)
|
||||
safe_data = _safe(data)
|
||||
_update_cache_strategy(data_dir, str(as_of), req.strategy_id, safe_data)
|
||||
|
||||
return _result_with_ext(safe_data, ext_values)
|
||||
|
||||
|
||||
@router.get("/cached")
|
||||
def get_cached(
|
||||
request: Request,
|
||||
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
|
||||
):
|
||||
"""读取策略结果缓存。返回 None 表示无缓存。"""
|
||||
data_dir = request.app.state.repo.store.data_dir
|
||||
cached = strategy_cache.read_cache(data_dir)
|
||||
if cached is None:
|
||||
return {"as_of": None, "results": {}, "updated_at": None}
|
||||
ext_values = _load_ext_value_maps(request.app.state.repo, ext_columns)
|
||||
return _cache_payload_with_ext(cached, ext_values)
|
||||
|
||||
|
||||
@router.get("/market-snapshot")
|
||||
def market_snapshot(request: Request):
|
||||
"""最新全市场轻量行情快照,供板块/概念聚合分析使用。"""
|
||||
import polars as pl
|
||||
|
||||
repo = request.app.state.repo
|
||||
svc = ScreenerService(repo)
|
||||
as_of = svc.latest_date()
|
||||
if not as_of:
|
||||
return {"as_of": None, "rows": []}
|
||||
|
||||
df = svc._load_enriched_for_date(as_of)
|
||||
if df.is_empty():
|
||||
return {"as_of": str(as_of), "rows": []}
|
||||
|
||||
if "close" in df.columns and "total_shares" in df.columns and "market_cap" not in df.columns:
|
||||
df = df.with_columns((pl.col("close") * pl.col("total_shares")).alias("market_cap"))
|
||||
if "close" in df.columns and "float_shares" in df.columns and "float_market_cap" not in df.columns:
|
||||
df = df.with_columns((pl.col("close") * pl.col("float_shares")).alias("float_market_cap"))
|
||||
|
||||
cols = [
|
||||
"symbol", "name", "close", "change_pct", "amount", "volume",
|
||||
"turnover_rate", "vol_ratio_5d", "total_shares", "float_shares",
|
||||
"market_cap", "float_market_cap", "consecutive_limit_ups",
|
||||
]
|
||||
df = df.select([c for c in cols if c in df.columns])
|
||||
rows = df.to_dicts()
|
||||
for r in rows:
|
||||
for k, v in list(r.items()):
|
||||
if isinstance(v, float) and not math.isfinite(v):
|
||||
r[k] = None
|
||||
|
||||
return {"as_of": str(as_of), "rows": rows}
|
||||
|
||||
|
||||
@router.post("/run_all")
|
||||
def run_all(request: Request, body: Optional[dict] = None):
|
||||
"""批量运行指定策略,只返回每个策略的命中数。
|
||||
|
||||
优化: 从 enriched 读取一次目标日期数据, 所有策略共享。
|
||||
body.strategy_ids: 只跑指定的策略 ID 列表, 为空则跑全部。
|
||||
"""
|
||||
from datetime import date as date_type
|
||||
|
||||
t_total = time.perf_counter()
|
||||
|
||||
body = body or {}
|
||||
repo = request.app.state.repo
|
||||
svc = ScreenerService(repo)
|
||||
|
||||
# 解析日期
|
||||
raw_date = body.get("as_of")
|
||||
if raw_date:
|
||||
as_of = date_type.fromisoformat(str(raw_date)) if isinstance(raw_date, str) else raw_date
|
||||
else:
|
||||
as_of = svc.latest_date()
|
||||
if not as_of:
|
||||
return {"as_of": None, "results": {}}
|
||||
|
||||
# 一次读取目标日期的全部数据
|
||||
t0 = time.perf_counter()
|
||||
precomputed = svc._load_enriched_for_date(as_of)
|
||||
logger.info("run_all: _load_enriched_for_date took %.1fms", (time.perf_counter() - t0) * 1000)
|
||||
|
||||
results: dict[str, dict] = {}
|
||||
data_dir = request.app.state.repo.store.data_dir
|
||||
|
||||
# 收集需要运行的策略 ID (如果指定了 strategy_ids 则只跑这些)
|
||||
requested_ids = body.get("strategy_ids")
|
||||
all_ids = list(PRESET_STRATEGIES.keys())
|
||||
engine = getattr(request.app.state, "strategy_engine", None)
|
||||
if engine:
|
||||
for meta in engine.list_strategies():
|
||||
sid = meta["id"]
|
||||
if sid not in PRESET_STRATEGIES:
|
||||
all_ids.append(sid)
|
||||
|
||||
if requested_ids and isinstance(requested_ids, list):
|
||||
id_set = set(requested_ids)
|
||||
all_ids = [sid for sid in all_ids if sid in id_set]
|
||||
|
||||
if not all_ids:
|
||||
return {"as_of": str(as_of), "results": {}}
|
||||
|
||||
# 批量预加载所有 override 配置
|
||||
t0 = time.perf_counter()
|
||||
all_overrides = strategy_config.list_overrides(data_dir)
|
||||
logger.info("run_all: list_overrides took %.1fms (%d overrides)", (time.perf_counter() - t0) * 1000, len(all_overrides))
|
||||
|
||||
# 历史策略: 只在需要时加载 (只加载 all_ids 中包含的 filter_history 策略)
|
||||
t0 = time.perf_counter()
|
||||
shared_history = None
|
||||
id_set = set(all_ids)
|
||||
if engine:
|
||||
history_strats = [
|
||||
(sid, s) for sid, s in engine._strategies.items()
|
||||
if s.filter_history_fn and sid in id_set
|
||||
]
|
||||
if history_strats:
|
||||
max_lb = min(max(s.lookback_days for _, s in history_strats), 30)
|
||||
shared_history = svc._load_enriched_history(as_of, max(1, max_lb))
|
||||
else:
|
||||
history_strats = []
|
||||
logger.info("run_all: _load_enriched_history took %.1fms (history_strats=%d)", (time.perf_counter() - t0) * 1000, len(history_strats))
|
||||
|
||||
for sid in all_ids:
|
||||
try:
|
||||
overrides = all_overrides.get(sid, {})
|
||||
bf = overrides.get("basic_filter") if overrides else None
|
||||
dl = overrides.get("display_limit") if overrides else None
|
||||
if dl is None and overrides and "display_limit" in overrides:
|
||||
dl = 0
|
||||
|
||||
if sid in PRESET_STRATEGIES:
|
||||
r = svc.run_preset(sid, as_of=as_of, precomputed=precomputed, basic_filter=bf, display_limit=dl)
|
||||
else:
|
||||
r = engine.run(
|
||||
sid, as_of, overrides=overrides or None,
|
||||
precomputed=precomputed, precomputed_history=shared_history,
|
||||
)
|
||||
if dl is not None and dl > 0:
|
||||
r.rows = r.rows[:dl]
|
||||
r.total = min(r.total, dl)
|
||||
|
||||
safe_rows = _safe(asdict(r)).get("rows", [])
|
||||
results[sid] = {"total": r.total, "as_of": str(as_of), "rows": safe_rows}
|
||||
except (ValueError, Exception):
|
||||
continue
|
||||
|
||||
elapsed = (time.perf_counter() - t_total) * 1000
|
||||
logger.info("run_all: total took %.1fms (%d strategies)", elapsed, len(all_ids))
|
||||
|
||||
# 写入策略缓存 (供页面秒加载)
|
||||
if results:
|
||||
try:
|
||||
strategy_cache.write_cache(data_dir, str(as_of), results)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
ext_values = _load_ext_value_maps(repo, body.get("ext_columns"))
|
||||
return {"as_of": str(as_of), "results": _results_with_ext(results, ext_values)}
|
||||
|
||||
|
||||
@router.get("/limit-ladder")
|
||||
def limit_ladder(
|
||||
request: Request,
|
||||
as_of: Optional[date] = None,
|
||||
direction: str = Query("up", description="up=涨停梯队 | down=跌停梯队"),
|
||||
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
|
||||
):
|
||||
"""连板/连跌梯队 — 按连板数分组, 含三状态。
|
||||
返回: tiers = [{ boards, count, stocks: [{symbol,name,change_pct,status,...}] }]
|
||||
|
||||
direction=up (默认):
|
||||
status: limit_up=涨停 | broken=炸板(摸板未封) | failed=断板(晋级失败)
|
||||
direction=down:
|
||||
status: limit_down=跌停 | recovery=翘板(跌停后回升,含收阳条件) | failed=止跌(昨日跌停今日未跌停也未翘板)
|
||||
|
||||
ext_columns: 动态 JOIN 扩展数据, 如 "concept.concept,industry.industry"
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
import polars as pl
|
||||
|
||||
is_down = direction == "down"
|
||||
|
||||
# 按 direction 参数化字段映射
|
||||
if is_down:
|
||||
sig_col = "signal_limit_down"
|
||||
consec_col = "consecutive_limit_downs"
|
||||
broken_col = "signal_limit_down_recovery"
|
||||
status_main, status_broken, status_failed = "limit_down", "recovery", "failed"
|
||||
else:
|
||||
sig_col = "signal_limit_up"
|
||||
consec_col = "consecutive_limit_ups"
|
||||
broken_col = "signal_broken_limit_up"
|
||||
status_main, status_broken, status_failed = "limit_up", "broken", "failed"
|
||||
|
||||
repo = request.app.state.repo
|
||||
svc = ScreenerService(repo)
|
||||
as_of = as_of or svc.latest_date()
|
||||
if not as_of:
|
||||
raise HTTPException(status_code=400, detail="无可用数据日期")
|
||||
|
||||
df = svc._load_enriched_for_date(as_of)
|
||||
if df.is_empty():
|
||||
return {"as_of": str(as_of), "tiers": [], "counts": {"up": 0, "down": 0}}
|
||||
|
||||
# 双方向涨跌停计数(不论当前 direction, 前端始终同时显示)
|
||||
count_up_raw = int(df.filter(pl.col("signal_limit_up").fill_null(False)).height) if "signal_limit_up" in df.columns else 0
|
||||
count_down_raw = int(df.filter(pl.col("signal_limit_down").fill_null(False)).height) if "signal_limit_down" in df.columns else 0
|
||||
|
||||
# 双方向 sealed 修正: 减去各自的假涨停(假涨停已归炸板, 不计入涨停数)
|
||||
depth_svc_global = getattr(request.app.state, "depth_service", None)
|
||||
fake_up = 0
|
||||
fake_down = 0
|
||||
sealed_up_ready = False
|
||||
sealed_down_ready = False
|
||||
if depth_svc_global:
|
||||
up_map = depth_svc_global.get_sealed_map(as_of, is_down=False)
|
||||
down_map = depth_svc_global.get_sealed_map(as_of, is_down=True)
|
||||
sealed_up_ready = bool(up_map) and depth_svc_global.is_sealed_ready(as_of)
|
||||
sealed_down_ready = bool(down_map) and depth_svc_global.is_sealed_ready(as_of)
|
||||
if up_map:
|
||||
fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False)
|
||||
if down_map:
|
||||
fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False)
|
||||
count_up = count_up_raw - fake_up if sealed_up_ready else count_up_raw
|
||||
count_down = count_down_raw - fake_down if sealed_down_ready else count_down_raw
|
||||
|
||||
# 双方向 sealed 明细(供前端弹窗同时显示涨跌停)
|
||||
def _count_sealed(m: dict, ready: bool):
|
||||
if not m or not ready:
|
||||
return {"real": 0, "fake": 0, "pending": 0}
|
||||
real = sum(1 for v in m.values() if v.get("sealed") is True)
|
||||
fake = sum(1 for v in m.values() if v.get("sealed") is False)
|
||||
pending = sum(1 for v in m.values() if v.get("sealed") is None)
|
||||
return {"real": real, "fake": fake, "pending": pending}
|
||||
sealed_counts_up = _count_sealed(up_map, sealed_up_ready)
|
||||
sealed_counts_down = _count_sealed(down_map, sealed_down_ready)
|
||||
|
||||
# 加载前一日数据获取 prev consecutive_limit_ups/downs
|
||||
prev_consec: pl.DataFrame = pl.DataFrame()
|
||||
for delta in range(1, 10):
|
||||
candidate = as_of - timedelta(days=delta)
|
||||
df_prev = svc._load_enriched_for_date(candidate)
|
||||
if not df_prev.is_empty() and consec_col in df_prev.columns:
|
||||
prev_consec = df_prev.select(
|
||||
"symbol",
|
||||
pl.col(consec_col).alias("prev_consec"),
|
||||
)
|
||||
break
|
||||
|
||||
if not prev_consec.is_empty():
|
||||
df = df.join(prev_consec, on="symbol", how="left")
|
||||
else:
|
||||
df = df.with_columns(pl.lit(0).cast(pl.UInt32).alias("prev_consec"))
|
||||
|
||||
# 表达式
|
||||
is_limit = pl.col(sig_col).fill_null(False) if sig_col in df.columns else pl.lit(False)
|
||||
is_broken = pl.col(broken_col).fill_null(False) if broken_col in df.columns else pl.lit(False)
|
||||
consec = pl.col(consec_col).fill_null(0) if consec_col in df.columns else pl.lit(0)
|
||||
prev_c = pl.col("prev_consec").fill_null(0)
|
||||
|
||||
# 计算 status + boards (结构涨跌停对称, 仅字段与字面量不同)
|
||||
is_failed = ~is_limit & ~is_broken & (prev_c > 0)
|
||||
df = df.with_columns([
|
||||
pl.when(is_limit).then(pl.lit(status_main))
|
||||
.when(is_broken).then(pl.lit(status_broken))
|
||||
.when(is_failed).then(pl.lit(status_failed))
|
||||
.otherwise(None).alias("status"),
|
||||
pl.when(is_limit).then(consec)
|
||||
.when(is_broken | is_failed).then(prev_c + 1)
|
||||
.otherwise(0).cast(pl.UInt32).alias("boards"),
|
||||
])
|
||||
|
||||
df = df.filter(pl.col("status").is_not_null() & (pl.col("boards") > 0))
|
||||
|
||||
# ── 五档 sealed 叠加(独立旁路, 不改 signal_limit_up) ──
|
||||
# 假涨停(收盘价=涨停价但卖一有量)从 limit 降级为 broken(归炸板视图)
|
||||
# 真涨停保留 + 附封单量; sealed=null(待确认/降级)保持原状
|
||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
||||
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_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
|
||||
|
||||
if sealed_map:
|
||||
# 构建 sealed 列(symbol → sealed bool, vol)
|
||||
sym_sealed = {s: v.get("sealed") for s, v in sealed_map.items()}
|
||||
sym_vol = {s: v.get("vol") for s, v in sealed_map.items()}
|
||||
|
||||
# JOIN sealed: 对每只 status=main 的票, 看 sealed 值
|
||||
sealed_rows = pl.DataFrame({
|
||||
"symbol": list(sym_sealed.keys()),
|
||||
"_sealed": list(sym_sealed.values()),
|
||||
"_sealed_vol": list(sym_vol.values()),
|
||||
}) if sym_sealed else pl.DataFrame()
|
||||
|
||||
if not sealed_rows.is_empty():
|
||||
df = df.join(sealed_rows, on="symbol", how="left")
|
||||
# 假涨停(main 状态但 sealed=False)→ 降级为 broken
|
||||
df = df.with_columns(
|
||||
pl.when(
|
||||
(pl.col("status") == status_main)
|
||||
& pl.col("_sealed").is_not_null()
|
||||
& (pl.col("_sealed") == False) # noqa: E712
|
||||
).then(pl.lit(status_broken))
|
||||
.otherwise(pl.col("status")).alias("status"),
|
||||
# sealed_status: real/fake/pending/null
|
||||
pl.when(
|
||||
(pl.col("status") == status_main)
|
||||
& (pl.col("_sealed") == True) # noqa: E712
|
||||
).then(pl.lit("real"))
|
||||
.when(
|
||||
(pl.col("_sealed") == False) # noqa: E712
|
||||
).then(pl.lit("fake"))
|
||||
.when(
|
||||
(pl.col("status") == status_main)
|
||||
& pl.col("_sealed").is_null()
|
||||
).then(pl.lit("pending"))
|
||||
.otherwise(None).alias("sealed_status"),
|
||||
pl.col("_sealed_vol").alias("sealed_vol"),
|
||||
).drop(["_sealed", "_sealed_vol"])
|
||||
else:
|
||||
df = df.with_columns(
|
||||
pl.lit(None).alias("sealed_status"),
|
||||
pl.lit(None).alias("sealed_vol"),
|
||||
)
|
||||
else:
|
||||
df = df.with_columns(
|
||||
pl.lit(None).alias("sealed_status"),
|
||||
pl.lit(None).alias("sealed_vol"),
|
||||
)
|
||||
else:
|
||||
df = df.with_columns(
|
||||
pl.lit(None).alias("sealed_status"),
|
||||
pl.lit(None).alias("sealed_vol"),
|
||||
)
|
||||
|
||||
# 动态 JOIN 扩展数据
|
||||
ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
|
||||
ext_col_names: list[str] = []
|
||||
if ext_specs:
|
||||
db = repo.store.db
|
||||
data_dir = repo.store.data_dir
|
||||
from app.services.ext_data import ExtConfigStore
|
||||
|
||||
ext_store = ExtConfigStore(data_dir)
|
||||
configs = {c.id: c for c in ext_store.load_all()}
|
||||
|
||||
for config_id, field_name in ext_specs:
|
||||
view_name = f"ext_{config_id}"
|
||||
ext_col_name = f"{config_id}__{field_name}"
|
||||
try:
|
||||
ext_df = pl.from_arrow(db.query(
|
||||
f"SELECT symbol, \"{field_name}\" FROM {view_name}"
|
||||
).arrow())
|
||||
if not ext_df.is_empty() and "symbol" in ext_df.columns:
|
||||
ext_df = ext_df.rename({field_name: ext_col_name})
|
||||
df = df.join(ext_df.select(["symbol", ext_col_name]), on="symbol", how="left")
|
||||
ext_col_names.append(ext_col_name)
|
||||
except Exception:
|
||||
cfg = configs.get(config_id)
|
||||
if cfg:
|
||||
try:
|
||||
from app.api.ext_data import _parquet_glob
|
||||
glob = _parquet_glob(cfg, data_dir)
|
||||
ext_df = pl.read_parquet(glob)
|
||||
if not ext_df.is_empty() and "symbol" in ext_df.columns and field_name in ext_df.columns:
|
||||
ext_df = ext_df.select(["symbol", field_name]).rename({field_name: ext_col_name})
|
||||
df = df.join(ext_df, on="symbol", how="left")
|
||||
ext_col_names.append(ext_col_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 选择输出列
|
||||
cols = ["symbol", "name", "close", "change_pct", "boards", "status", consec_col, "sealed_status", "sealed_vol"] + ext_col_names
|
||||
df = df.select([c for c in cols if c in df.columns])
|
||||
# 排序: boards 降序, status 按主状态→炸/翘→断/止
|
||||
status_order = pl.when(pl.col("status") == status_main).then(0)
|
||||
status_order = status_order.when(pl.col("status") == status_broken).then(1)
|
||||
status_order = status_order.otherwise(2).alias("_status_order")
|
||||
df = df.with_columns(status_order).sort(["boards", "_status_order"], descending=[True, False]).drop("_status_order")
|
||||
|
||||
rows = df.to_dicts()
|
||||
for r in rows:
|
||||
for k, v in list(r.items()):
|
||||
if isinstance(v, float) and not math.isfinite(v):
|
||||
r[k] = None
|
||||
|
||||
# 按 boards 分组
|
||||
tiers: dict[int, list] = {}
|
||||
for r in rows:
|
||||
n = int(r.get("boards") or 0)
|
||||
tiers.setdefault(n, []).append(r)
|
||||
|
||||
tier_list = [
|
||||
{"boards": n, "count": len(stocks), "stocks": stocks}
|
||||
for n, stocks in sorted(tiers.items(), key=lambda x: -x[0])
|
||||
]
|
||||
|
||||
return {
|
||||
"as_of": str(as_of),
|
||||
"tiers": tier_list,
|
||||
"counts": {"up": count_up, "down": count_down},
|
||||
"counts_raw": {"up": count_up_raw, "down": count_down_raw},
|
||||
"sealed_ready": sealed_ready,
|
||||
"sealed_age": round(sealed_age, 0) if sealed_age is not None else None,
|
||||
"sealed_counts": {
|
||||
"real": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "real"),
|
||||
"fake": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "fake"),
|
||||
"pending": sum(1 for t in tier_list for s in t.get("stocks", []) if s.get("sealed_status") == "pending"),
|
||||
},
|
||||
"sealed_counts_up": sealed_counts_up,
|
||||
"sealed_counts_down": sealed_counts_down,
|
||||
}
|
||||
|
||||
|
||||
def _parse_ext_columns(ext_columns: str) -> list[tuple[str, str]]:
|
||||
"""解析 'config_id1.field1,config_id2.field2' 为 [(config_id, field_name), ...]。"""
|
||||
result = []
|
||||
for part in ext_columns.split(","):
|
||||
part = part.strip()
|
||||
if "." not in part:
|
||||
continue
|
||||
config_id, field_name = part.split(".", 1)
|
||||
config_id = config_id.strip()
|
||||
field_name = field_name.strip()
|
||||
if not config_id or not field_name:
|
||||
continue
|
||||
if not _EXT_IDENT_RE.match(config_id) or "\x00" in field_name:
|
||||
continue
|
||||
result.append((config_id, field_name))
|
||||
return result
|
||||
@@ -0,0 +1,854 @@
|
||||
"""设置 API — Key 配置 / 模式切换。
|
||||
|
||||
提供面向非开发者的 UI 配置入口,避免逼用户改 .env。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app import secrets_store
|
||||
from app.services.financial_sync import financial_scheduler
|
||||
from app.tickflow import client as tf_client
|
||||
from app.tickflow.policy import (
|
||||
detect_capabilities,
|
||||
extras_caps,
|
||||
missing_caps,
|
||||
probe_log,
|
||||
tier_label,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
# 默认端点 —— endpoints.json 列表第一项,UI"当前使用"始终对齐此项。
|
||||
# 注意:Free 模式 SDK 实际走 free-api(免费数据通道),但 UI 显示统一用默认节点。
|
||||
DEFAULT_PAID_ENDPOINT = "https://api.tickflow.org"
|
||||
|
||||
|
||||
class TickflowKeyIn(BaseModel):
|
||||
api_key: str
|
||||
|
||||
|
||||
def _sync_financial_scheduler(request: Request, capset) -> None:
|
||||
"""Key 变更后同步财务调度器状态,无需重启服务。"""
|
||||
try:
|
||||
financial_scheduler.update(request.app.state.repo.store.data_dir, capset)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("financial_scheduler update failed: %s", e)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_settings() -> dict:
|
||||
"""返回当前配置概况(Key 脱敏)。"""
|
||||
from app.config import settings
|
||||
from app.services import preferences
|
||||
|
||||
key = secrets_store.get_tickflow_key()
|
||||
return {
|
||||
"mode": tf_client.current_mode(),
|
||||
"tickflow_api_key_masked": secrets_store.mask(key),
|
||||
"has_tickflow_key": bool(key),
|
||||
"tier_label": tier_label(),
|
||||
"current_endpoint": tf_client.current_endpoint(),
|
||||
"probe_log": probe_log(),
|
||||
"missing_caps": missing_caps(),
|
||||
"extras_caps": extras_caps(),
|
||||
# 首次使用引导
|
||||
"onboarding_completed": preferences.get_onboarding_completed(),
|
||||
# AI 配置
|
||||
"ai_provider": secrets_store.get_ai_config("ai_provider", settings.ai_provider),
|
||||
"ai_base_url": secrets_store.get_ai_config("ai_base_url", settings.ai_base_url),
|
||||
"ai_api_key_masked": secrets_store.mask(secrets_store.get_ai_key()),
|
||||
"has_ai_key": bool(secrets_store.get_ai_key()),
|
||||
"ai_model": secrets_store.get_ai_config("ai_model", settings.ai_model),
|
||||
"ai_daily_token_budget": int(secrets_store.get_ai_config("ai_daily_token_budget", str(settings.ai_daily_token_budget)) or settings.ai_daily_token_budget),
|
||||
}
|
||||
|
||||
|
||||
class SwitchEndpointIn(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
@router.post("/switch_endpoint")
|
||||
def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
|
||||
"""切换数据源端点并立即生效。
|
||||
|
||||
端点切换仅对付费档(starter+,走付费 API 节点)有意义;
|
||||
none/free 档运行在 free-api 服务器,无付费端点权限,禁止切换。
|
||||
"""
|
||||
# none/free 档没有付费端点权限,禁止切换
|
||||
if tf_client.current_mode() != "api_key":
|
||||
return {"ok": False, "error": "当前档位无法切换端点,仅付费套餐(Starter+)支持"}
|
||||
|
||||
url = req.url.strip().rstrip("/")
|
||||
if not url.startswith("https://"):
|
||||
return {"ok": False, "error": "仅支持 HTTPS 端点"}
|
||||
|
||||
# 持久化到 secrets.json
|
||||
secrets_store.save({"tickflow_base_url": url})
|
||||
# 重置客户端,下次调用自动用新端点
|
||||
tf_client.reset_clients()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"current_endpoint": tf_client.current_endpoint(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tickflow-key")
|
||||
def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||||
"""保存数据源 API Key 并立即重新探测能力。
|
||||
|
||||
先探后存(关键改动,修复乱填 key 也会被持久化的问题):
|
||||
1. 临时用新 key 探测(付费端点),判定档位
|
||||
2. 判定为 none(连单只日K都拿不到)→ key 无效:不存,清除已存的,
|
||||
返回 {ok: false, reason: "invalid"},前端提示「Key 无效」
|
||||
3. 判定为 free(免费有效 key)→ 存 key,客户端切到 free-api 服务器
|
||||
4. 判定为 starter+ → 存 key,切到付费端点(现有逻辑)
|
||||
|
||||
端点联动:从无 key 升级到付费 key 时,残留的 free-api 端点不可用,
|
||||
故自动切到默认付费端点;free 档则清除自定义端点。
|
||||
"""
|
||||
from app.tickflow.policy import (
|
||||
base_tier_name, is_invalid_key,
|
||||
)
|
||||
|
||||
key = req.api_key.strip()
|
||||
if not key:
|
||||
return {"ok": False, "error": "key empty"}
|
||||
|
||||
# ===== 1) 临时存 key + 重置客户端,让探测走付费端点 =====
|
||||
secrets_store.save({"tickflow_api_key": key})
|
||||
tf_client.reset_clients()
|
||||
|
||||
# 立即重新探测(此时 client 已按档位判定,但首次探测必然走付费端点验证)
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
_sync_financial_scheduler(request, capset)
|
||||
|
||||
# ===== 2) 判定为无效 key(连单只日K都拿不到)→ 不存,清除 =====
|
||||
if is_invalid_key() or base_tier_name() == "none":
|
||||
# 无效 key:清除刚存的,避免乱填被持久化;退回 none 档
|
||||
secrets_store.clear("tickflow_api_key", "tickflow_base_url")
|
||||
tf_client.reset_clients()
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
_sync_financial_scheduler(request, capset)
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": "invalid",
|
||||
"error": "Key 无效或已过期,请检查后重试",
|
||||
"mode": "none",
|
||||
"tier_label": tier_label(),
|
||||
"current_endpoint": tf_client.current_endpoint(),
|
||||
"probe_log": [],
|
||||
"capabilities_count": len(capset.all()),
|
||||
}
|
||||
|
||||
# ===== 3) free 档(免费有效 key)→ 存 key,切到 free-api 服务器 =====
|
||||
if base_tier_name() == "free":
|
||||
# 免费档运行时走 free-api 服务器,清除付费端点的自定义配置
|
||||
secrets_store.clear("tickflow_base_url")
|
||||
tf_client.reset_clients()
|
||||
return {
|
||||
"ok": True,
|
||||
"tickflow_api_key_masked": secrets_store.mask(key),
|
||||
"mode": "free",
|
||||
"tier_label": tier_label(),
|
||||
"current_endpoint": tf_client.current_endpoint(),
|
||||
"probe_log": [],
|
||||
"capabilities_count": len(capset.all()),
|
||||
}
|
||||
|
||||
# ===== 4) starter+ 付费档 → 确保走付费端点(现有逻辑) =====
|
||||
# 若之前是 none/free(无自定义付费端点),切到默认付费端点
|
||||
base = secrets_store.load().get("tickflow_base_url")
|
||||
if not base:
|
||||
secrets_store.save({"tickflow_base_url": DEFAULT_PAID_ENDPOINT})
|
||||
tf_client.reset_clients()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"tickflow_api_key_masked": secrets_store.mask(key),
|
||||
"mode": "api_key",
|
||||
"tier_label": tier_label(),
|
||||
"current_endpoint": tf_client.current_endpoint(),
|
||||
"probe_log": [],
|
||||
"capabilities_count": len(capset.all()),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/tickflow-key")
|
||||
def clear_tickflow_key(request: Request) -> dict:
|
||||
"""清除 Key,退回无档(none)。
|
||||
|
||||
同时清除 tickflow_base_url(测速切换的自定义端点),使客户端走 free-api
|
||||
服务器取历史日K;档位标签为 None(无档)。
|
||||
"""
|
||||
secrets_store.clear("tickflow_api_key", "tickflow_base_url")
|
||||
tf_client.reset_clients()
|
||||
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
_sync_financial_scheduler(request, capset)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": "none",
|
||||
"tier_label": tier_label(),
|
||||
"current_endpoint": tf_client.current_endpoint(),
|
||||
"capabilities_count": len(capset.all()),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/onboarding/complete")
|
||||
def complete_onboarding() -> dict:
|
||||
"""标记首次使用向导完成。
|
||||
|
||||
写入 preferences.json,前端守卫据此判断是否需要再次展示向导。
|
||||
跨设备/清缓存安全 —— 状态落在后端文件,不依赖浏览器本地存储。
|
||||
"""
|
||||
from app.services import preferences
|
||||
done = preferences.set_onboarding_completed(True)
|
||||
return {"ok": True, "onboarding_completed": done}
|
||||
|
||||
|
||||
class AiSettingsIn(BaseModel):
|
||||
provider: str = "openai_compat"
|
||||
base_url: str = ""
|
||||
api_key: str | None = None
|
||||
model: str = ""
|
||||
daily_token_budget: int = 500_000
|
||||
|
||||
|
||||
@router.post("/ai")
|
||||
def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
"""保存 AI 配置(全部持久化到 secrets.json)"""
|
||||
from app.config import settings
|
||||
|
||||
updates: 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.model:
|
||||
updates["ai_model"] = req.model
|
||||
settings.ai_model = req.model
|
||||
updates["ai_daily_token_budget"] = req.daily_token_budget
|
||||
settings.ai_daily_token_budget = req.daily_token_budget
|
||||
|
||||
if updates:
|
||||
secrets_store.save(updates)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ===== 偏好设置 =====
|
||||
|
||||
def _realtime_allowed() -> bool:
|
||||
"""当前档位是否允许实时行情(none/free 不允许)。"""
|
||||
from app.services.quote_service import QuoteService
|
||||
return QuoteService.is_realtime_allowed()
|
||||
|
||||
|
||||
class MinuteSyncPrefs(BaseModel):
|
||||
minute_sync_enabled: bool
|
||||
minute_sync_days: int = 5
|
||||
|
||||
|
||||
@router.get("/preferences")
|
||||
def get_preferences() -> dict:
|
||||
"""返回用户偏好设置。"""
|
||||
from app.services import preferences
|
||||
return {
|
||||
"realtime_quotes_enabled": preferences.get_realtime_quotes_enabled(),
|
||||
"realtime_allowed": _realtime_allowed(),
|
||||
"indices_nav_pinned": preferences.get_indices_nav_pinned(),
|
||||
"minute_sync_enabled": preferences.get_minute_sync_enabled(),
|
||||
"minute_sync_days": preferences.get_minute_sync_days(),
|
||||
"pipeline_schedule": preferences.get_pipeline_schedule(),
|
||||
"instruments_schedule": preferences.get_instruments_schedule(),
|
||||
"enriched_batch_size": preferences.get_enriched_batch_size(),
|
||||
"index_daily_batch_size": preferences.get_index_daily_batch_size(),
|
||||
"watchlist_columns": preferences.get_watchlist_columns(),
|
||||
"screener_result_columns": preferences.get_screener_result_columns(),
|
||||
"sse_refresh_pages": preferences.get_sse_refresh_pages(),
|
||||
"strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(),
|
||||
"strategy_monitor_ids": preferences.get_strategy_monitor_ids(),
|
||||
"system_notify_enabled": preferences.get_system_notify_enabled(),
|
||||
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
|
||||
"nav_order": preferences.get_nav_order(),
|
||||
"nav_hidden": preferences.get_nav_hidden(),
|
||||
"screener_auto_run": preferences.get_screener_auto_run(),
|
||||
"limit_ladder_monitor_enabled": preferences.get_limit_ladder_monitor_enabled(),
|
||||
"depth_polling_interval": preferences.get_depth_polling_interval(),
|
||||
"depth_finalize_time": preferences.get_depth_finalize_time(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/preferences/watchlist-columns")
|
||||
def get_watchlist_columns() -> dict:
|
||||
"""返回自选列表列配置。"""
|
||||
from app.services import preferences
|
||||
cols = preferences.get_watchlist_columns()
|
||||
return {"columns": cols}
|
||||
|
||||
|
||||
class NavOrderIn(BaseModel):
|
||||
nav_order: list[str]
|
||||
|
||||
|
||||
class NavHiddenIn(BaseModel):
|
||||
nav_hidden: list[str]
|
||||
|
||||
|
||||
@router.put("/preferences/nav-order")
|
||||
def update_nav_order(req: NavOrderIn) -> dict:
|
||||
"""保存左侧菜单排序(内置页面 path + 扩展分析菜单 id 的有序列表)。"""
|
||||
from app.services import preferences
|
||||
saved = preferences.set_nav_order(req.nav_order)
|
||||
return {"nav_order": saved}
|
||||
|
||||
|
||||
@router.put("/preferences/nav-hidden")
|
||||
def update_nav_hidden(req: NavHiddenIn) -> dict:
|
||||
"""保存左侧菜单隐藏项。"""
|
||||
from app.services import preferences
|
||||
saved = preferences.set_nav_hidden(req.nav_hidden)
|
||||
return {"nav_hidden": saved}
|
||||
|
||||
|
||||
@router.put("/preferences/watchlist-columns")
|
||||
def update_watchlist_columns(req: dict) -> dict:
|
||||
"""保存自选列表列配置。"""
|
||||
from app.services import preferences
|
||||
columns = req.get("columns", [])
|
||||
saved = preferences.set_watchlist_columns(columns)
|
||||
return {"columns": saved}
|
||||
|
||||
|
||||
@router.get("/preferences/screener-result-columns")
|
||||
def get_screener_result_columns() -> dict:
|
||||
"""返回策略结果列表列配置。"""
|
||||
from app.services import preferences
|
||||
cols = preferences.get_screener_result_columns()
|
||||
return {"columns": cols}
|
||||
|
||||
|
||||
@router.put("/preferences/screener-result-columns")
|
||||
def update_screener_result_columns(req: dict) -> dict:
|
||||
"""保存策略结果列表列配置。"""
|
||||
from app.services import preferences
|
||||
columns = req.get("columns", [])
|
||||
saved = preferences.set_screener_result_columns(columns)
|
||||
return {"columns": saved}
|
||||
|
||||
|
||||
@router.put("/preferences/minute-sync")
|
||||
def update_minute_sync(req: MinuteSyncPrefs) -> dict:
|
||||
"""保存分钟 K 同步偏好。"""
|
||||
from app.services import preferences
|
||||
days = max(1, min(30, req.minute_sync_days))
|
||||
preferences.save({
|
||||
"minute_sync_enabled": req.minute_sync_enabled,
|
||||
"minute_sync_days": days,
|
||||
})
|
||||
return {
|
||||
"minute_sync_enabled": req.minute_sync_enabled,
|
||||
"minute_sync_days": days,
|
||||
}
|
||||
|
||||
|
||||
class RealtimeQuotesPrefs(BaseModel):
|
||||
realtime_quotes_enabled: bool
|
||||
|
||||
|
||||
@router.put("/preferences/realtime-quotes")
|
||||
def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
||||
"""保存全局实时行情开关。
|
||||
|
||||
none/free 档无实时行情权限:拒绝开启,persist 为关闭并返回 allowed=False,
|
||||
前端据此把开关置灰 / 回弹。
|
||||
"""
|
||||
from app.services import preferences
|
||||
qs = getattr(request.app.state, "quote_service", None)
|
||||
|
||||
allowed = qs.is_realtime_allowed() if qs else True
|
||||
if req.realtime_quotes_enabled and not allowed:
|
||||
# 当前档位不允许开启实时行情 — 强制关闭
|
||||
preferences.save({"realtime_quotes_enabled": False})
|
||||
if qs:
|
||||
qs.disable()
|
||||
return {"realtime_quotes_enabled": False, "realtime_allowed": False}
|
||||
|
||||
preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled})
|
||||
if qs:
|
||||
if req.realtime_quotes_enabled:
|
||||
qs.enable()
|
||||
else:
|
||||
qs.disable()
|
||||
|
||||
return {"realtime_quotes_enabled": req.realtime_quotes_enabled, "realtime_allowed": allowed}
|
||||
|
||||
|
||||
class IndicesNavPinnedPrefs(BaseModel):
|
||||
indices_nav_pinned: bool
|
||||
|
||||
|
||||
@router.put("/preferences/indices-nav-pinned")
|
||||
def update_indices_nav_pinned(req: IndicesNavPinnedPrefs) -> dict:
|
||||
"""保存侧栏指数报价卡片固定显示开关。
|
||||
ON=常驻显示;OFF=跟随实时行情开关(仅实时开时显示)。"""
|
||||
from app.services import preferences
|
||||
preferences.save({"indices_nav_pinned": req.indices_nav_pinned})
|
||||
return {"indices_nav_pinned": req.indices_nav_pinned}
|
||||
|
||||
|
||||
class RealtimeMonitorConfigIn(BaseModel):
|
||||
sse_refresh_pages: dict[str, bool] | None = None
|
||||
strategy_monitor_enabled: bool | None = None
|
||||
strategy_monitor_ids: list[str] | None = None
|
||||
sidebar_index_symbols: list[str] | None = None
|
||||
screener_auto_run: bool | None = None
|
||||
|
||||
|
||||
@router.put("/preferences/realtime-monitor")
|
||||
def update_realtime_monitor_config(req: RealtimeMonitorConfigIn, request: Request) -> dict:
|
||||
"""更新实时监控配置。策略监控统一迁移为 MonitorRule,由监控引擎评估。"""
|
||||
from app.services import preferences
|
||||
|
||||
cfg = req.model_dump(exclude_none=True)
|
||||
result = preferences.set_realtime_monitor_config(cfg)
|
||||
|
||||
# 策略监控开关/池变化 → 同步迁移为 type=strategy 规则 + reload 引擎
|
||||
if req.strategy_monitor_ids is not None or req.strategy_monitor_enabled is not None:
|
||||
monitor_engine = getattr(request.app.state, "monitor_engine", None)
|
||||
strategy_engine = getattr(request.app.state, "strategy_engine", None)
|
||||
data_dir = request.app.state.repo.store.data_dir
|
||||
if monitor_engine is not None and strategy_engine is not None:
|
||||
from app.strategy import monitor_rules as mr_store
|
||||
try:
|
||||
if preferences.get_strategy_monitor_enabled():
|
||||
ids = preferences.get_strategy_monitor_ids()
|
||||
names = {s.id: s.name for s in strategy_engine.list_strategies()}
|
||||
mr_store.migrate_strategy_monitors(data_dir, ids, names)
|
||||
else:
|
||||
# 关闭策略监控: 停用所有策略规则
|
||||
mr_store.migrate_strategy_monitors(data_dir, [], {})
|
||||
# reload 规则到引擎
|
||||
monitor_engine.set_rules(mr_store.load_all(data_dir))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class QuoteIntervalIn(BaseModel):
|
||||
interval: float
|
||||
|
||||
|
||||
class SystemNotifyPrefsIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@router.put("/preferences/system-notify")
|
||||
def update_system_notify(req: SystemNotifyPrefsIn) -> dict:
|
||||
"""系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。
|
||||
|
||||
纯偏好, 无副作用 (不像策略监控要迁移规则), 直接落盘即可。
|
||||
quote_service 在每轮告警评估时读此开关决定是否发系统通知。
|
||||
"""
|
||||
from app.services import preferences
|
||||
saved = preferences.set_system_notify_enabled(req.enabled)
|
||||
return {"system_notify_enabled": saved}
|
||||
|
||||
|
||||
@router.put("/preferences/quote-interval")
|
||||
def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict:
|
||||
"""更新行情轮询间隔。按档位自动 clamp。"""
|
||||
qs = getattr(request.app.state, "quote_service", None)
|
||||
if not qs:
|
||||
return {"interval": req.interval, "min_interval": qs.get_min_interval(), "max_interval": 60.0}
|
||||
clamped = qs.set_interval(req.interval)
|
||||
return {
|
||||
"interval": clamped,
|
||||
"min_interval": qs.get_min_interval(),
|
||||
"max_interval": qs.MAX_INTERVAL,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/preferences/quote-interval")
|
||||
def get_quote_interval(request: Request) -> dict:
|
||||
"""获取当前行情轮询间隔和档位限制。"""
|
||||
qs = getattr(request.app.state, "quote_service", None)
|
||||
if not qs:
|
||||
return {"interval": 10.0, "min_interval": 5.0, "max_interval": 60.0}
|
||||
return {
|
||||
"interval": qs._interval,
|
||||
"min_interval": qs.get_min_interval(),
|
||||
"max_interval": qs.MAX_INTERVAL,
|
||||
}
|
||||
|
||||
|
||||
class TestEndpointIn(BaseModel):
|
||||
url: str
|
||||
# 测试轮数;不传时取 endpoints.json 的 testRounds(默认 5)
|
||||
rounds: int | None = None
|
||||
|
||||
|
||||
# 官方端点发现清单 —— 前端浏览器无法直接跨域拉取数据源官网 /endpoints.json
|
||||
# (无 CORS 头),因此由后端代理。缓存 5 分钟,失败时回退到内置列表。
|
||||
ENDPOINTS_URL = "https://tickflow.org/endpoints.json"
|
||||
ENDPOINTS_TTL = 300.0 # 秒
|
||||
|
||||
# 回退列表 —— 与官方 endpoints.json 的 endpoints[] 字段对齐。
|
||||
# 当远程拉取失败时使用,保证 UI 永远有内容可显示。
|
||||
_FALLBACK_ENDPOINTS: list[dict] = [
|
||||
{
|
||||
"id": "default",
|
||||
"url": "https://api.tickflow.org",
|
||||
"label": "默认端点",
|
||||
"region": "auto",
|
||||
"description": "默认端点",
|
||||
"premium": False,
|
||||
},
|
||||
{
|
||||
"id": "hk",
|
||||
"url": "https://hk-api.tickflow.org",
|
||||
"label": "香港端点",
|
||||
"region": "ap-east-1",
|
||||
"description": "备用端点,部分地区访问更稳定",
|
||||
"premium": False,
|
||||
},
|
||||
{
|
||||
"id": "sg",
|
||||
"url": "https://sg-api.tickflow.org",
|
||||
"label": "新加坡端点",
|
||||
"region": "ap-southeast-1",
|
||||
"description": "备用端点,亚太地区访问更稳定",
|
||||
"premium": False,
|
||||
},
|
||||
{
|
||||
"id": "us",
|
||||
"url": "https://us-api.tickflow.org",
|
||||
"label": "美国端点",
|
||||
"region": "us-east-1",
|
||||
"description": "备用端点,欧美地区访问更稳定",
|
||||
"premium": False,
|
||||
},
|
||||
{
|
||||
"id": "cn",
|
||||
"url": "https://139.196.55.234:50443",
|
||||
"label": "中国大陆端点(Beta)",
|
||||
"region": "cn-east-1",
|
||||
"description": "备用端点,中国大陆地区访问更稳定,目前处于测试阶段,谨慎使用",
|
||||
"premium": False,
|
||||
},
|
||||
{
|
||||
"id": "cn-premium",
|
||||
"url": "https://106.15.238.72:50443",
|
||||
"label": "中国大陆专线端点",
|
||||
"region": "cn-east-1",
|
||||
"description": "专线加速端点,需要专线加速权限(该权限包含在 Expert 及以上套餐中,也可通过自定义组合单独开通)",
|
||||
"premium": True,
|
||||
},
|
||||
]
|
||||
|
||||
# 进程内缓存:{ "ts": float, "data": dict }
|
||||
_endpoints_cache: dict = {"ts": 0.0, "data": None}
|
||||
|
||||
|
||||
@router.get("/endpoints")
|
||||
def list_endpoints() -> dict:
|
||||
"""代理拉取数据源官网 /endpoints.json 并返回规范化端点列表。
|
||||
|
||||
前端无法跨域直连该 URL(无 CORS 头),故由本接口代理。带 8s 超时、
|
||||
5 分钟内存缓存,远程失败时回退到内置列表,保证 UI 始终有内容。
|
||||
返回结构与原始 endpoints.json 一致(透传 schema/version 等元信息)。
|
||||
"""
|
||||
import httpx
|
||||
|
||||
now = time.monotonic()
|
||||
cached = _endpoints_cache.get("data")
|
||||
if cached is not None and (now - _endpoints_cache["ts"]) < ENDPOINTS_TTL:
|
||||
return cached
|
||||
|
||||
source = "remote"
|
||||
data: dict | None = None
|
||||
try:
|
||||
resp = httpx.get(ENDPOINTS_URL, timeout=8.0, follow_redirects=True)
|
||||
if resp.status_code == 200:
|
||||
parsed = resp.json()
|
||||
eps = parsed.get("endpoints")
|
||||
# 校验:必须是列表且每项含必要字段,否则视为无效
|
||||
if isinstance(eps, list) and all(
|
||||
isinstance(e, dict) and "url" in e for e in eps
|
||||
):
|
||||
data = {
|
||||
"version": parsed.get("version", 1),
|
||||
"description": parsed.get(
|
||||
"description", "API 端点配置"
|
||||
),
|
||||
"healthPath": parsed.get("healthPath", "/health"),
|
||||
"testRounds": parsed.get("testRounds", 5),
|
||||
"endpoints": eps,
|
||||
}
|
||||
except (httpx.HTTPError, ValueError):
|
||||
logger.warning("拉取 endpoints.json 失败,使用内置回退列表", exc_info=True)
|
||||
|
||||
if data is None:
|
||||
source = "fallback"
|
||||
data = {
|
||||
"version": 1,
|
||||
"description": "API 端点配置",
|
||||
"healthPath": "/health",
|
||||
"testRounds": 5,
|
||||
"endpoints": _FALLBACK_ENDPOINTS,
|
||||
}
|
||||
|
||||
# 标记数据来源,便于前端提示(回退时显示"内置列表")。
|
||||
data["source"] = source
|
||||
_endpoints_cache["ts"] = now
|
||||
_endpoints_cache["data"] = data
|
||||
return data
|
||||
|
||||
|
||||
async def _http_ping(url: str, timeout: float = 10.0) -> float | None:
|
||||
"""单次异步 GET 请求并返回延迟(ms),失败返回 None。
|
||||
|
||||
对齐官方 latency_test.py:用 /health 轻量端点测真实网络延迟,
|
||||
不携带 API Key(/health 公开)。异步实现,保证多端点并行测速不阻塞。
|
||||
"""
|
||||
import httpx
|
||||
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
resp = await client.get(url)
|
||||
dt = (time.perf_counter() - t0) * 1000
|
||||
# 只把 <400 视为成功;4xx/5xx 也算"不可达"
|
||||
if resp.status_code < 400:
|
||||
return round(dt, 2)
|
||||
return None
|
||||
except (httpx.TimeoutException, httpx.ConnectError, httpx.HTTPError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/test_endpoint")
|
||||
async def test_endpoint(req: TestEndpointIn) -> dict:
|
||||
"""测试端点网络延迟:对 /health 多轮探测取中位数。
|
||||
|
||||
参考官方 latency_test.py:
|
||||
- 路径用 /health(公开、轻量),反映真实网络延迟而非业务接口耗时
|
||||
- 多轮探测(默认 5 轮,取自 endpoints.json 的 testRounds),间隔 0.3s
|
||||
- 返回 median/min/max/success,前端显示中位数
|
||||
- 异步实现,保证"全部测速"时多端点真正并行
|
||||
"""
|
||||
import asyncio
|
||||
import statistics
|
||||
|
||||
base = req.url.rstrip("/")
|
||||
rounds = max(1, min(10, req.rounds or _endpoints_cache.get("data", {}).get("testRounds", 5)))
|
||||
health_url = base + "/health"
|
||||
|
||||
latencies: list[float] = []
|
||||
for _ in range(rounds):
|
||||
ms = await _http_ping(health_url)
|
||||
if ms is not None:
|
||||
latencies.append(ms)
|
||||
# 官方脚本间隔 0.3s;末轮无需等待
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
success = len(latencies)
|
||||
if success == 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "不可达",
|
||||
"url": req.url,
|
||||
"rounds": rounds,
|
||||
"success": 0,
|
||||
"median_ms": None,
|
||||
"min_ms": None,
|
||||
"max_ms": None,
|
||||
}
|
||||
|
||||
median = round(statistics.median(latencies), 2)
|
||||
return {
|
||||
"ok": True,
|
||||
"url": req.url,
|
||||
"rounds": rounds,
|
||||
"success": success,
|
||||
"median_ms": median,
|
||||
"min_ms": round(min(latencies), 2),
|
||||
"max_ms": round(max(latencies), 2),
|
||||
# 兼容旧字段:取中位数作为代表延迟
|
||||
"latency_ms": median,
|
||||
}
|
||||
|
||||
|
||||
class PipelineScheduleIn(BaseModel):
|
||||
hour: int
|
||||
minute: int
|
||||
|
||||
|
||||
@router.put("/preferences/pipeline-schedule")
|
||||
def update_pipeline_schedule(req: PipelineScheduleIn, request: Request) -> dict:
|
||||
"""保存盘后管道调度时间并立即 reschedule。"""
|
||||
from app.services import preferences
|
||||
sched = preferences.set_pipeline_schedule(req.hour, req.minute)
|
||||
|
||||
# 动态 reschedule
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
scheduler = getattr(request.app.state, "scheduler", None)
|
||||
if scheduler:
|
||||
scheduler.reschedule_job(
|
||||
"daily_pipeline",
|
||||
trigger=CronTrigger(
|
||||
day_of_week="mon-fri",
|
||||
hour=sched["hour"],
|
||||
minute=sched["minute"],
|
||||
timezone="Asia/Shanghai",
|
||||
),
|
||||
)
|
||||
logger.info("pipeline rescheduled to %02d:%02d mon-fri", sched["hour"], sched["minute"])
|
||||
|
||||
return sched
|
||||
|
||||
|
||||
@router.put("/preferences/instruments-schedule")
|
||||
def update_instruments_schedule(req: PipelineScheduleIn, request: Request) -> dict:
|
||||
"""保存盘前标的维表调度时间并立即 reschedule。"""
|
||||
from app.services import preferences
|
||||
sched = preferences.set_instruments_schedule(req.hour, req.minute)
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
scheduler = getattr(request.app.state, "scheduler", None)
|
||||
if scheduler:
|
||||
scheduler.reschedule_job(
|
||||
"pre_market_instruments",
|
||||
trigger=CronTrigger(
|
||||
day_of_week="mon-fri",
|
||||
hour=sched["hour"],
|
||||
minute=sched["minute"],
|
||||
timezone="Asia/Shanghai",
|
||||
),
|
||||
)
|
||||
return sched
|
||||
|
||||
|
||||
class EnrichedBatchSizeIn(BaseModel):
|
||||
size: int
|
||||
|
||||
|
||||
@router.put("/preferences/enriched-batch-size")
|
||||
def update_enriched_batch_size(req: EnrichedBatchSizeIn) -> dict:
|
||||
"""保存 enriched 全量计算批次大小。"""
|
||||
from app.services import preferences
|
||||
size = preferences.set_enriched_batch_size(req.size)
|
||||
return {"enriched_batch_size": size}
|
||||
|
||||
|
||||
class IndexDailyBatchSizeIn(BaseModel):
|
||||
size: int
|
||||
|
||||
|
||||
@router.put("/preferences/index-daily-batch-size")
|
||||
def update_index_daily_batch_size(req: IndexDailyBatchSizeIn) -> dict:
|
||||
"""保存指数日 K 同步批次大小。"""
|
||||
from app.services import preferences
|
||||
size = preferences.set_index_daily_batch_size(req.size)
|
||||
return {"index_daily_batch_size": size}
|
||||
|
||||
|
||||
# ── 五档盘口 sealed 配置 ──────────────────────────────
|
||||
|
||||
class LimitLadderMonitorIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@router.put("/preferences/limit-ladder-monitor")
|
||||
def update_limit_ladder_monitor(req: LimitLadderMonitorIn, request: Request) -> dict:
|
||||
"""连板梯队 5 档监控开关。开启→启动 depth 轮询, 关闭→停止。"""
|
||||
from app.services import preferences
|
||||
preferences.save({"limit_ladder_monitor_enabled": req.enabled})
|
||||
|
||||
# 立即应用: 启停 depth 轮询线程
|
||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
||||
if depth_svc:
|
||||
depth_svc.apply_monitor_toggle(req.enabled)
|
||||
|
||||
return {"limit_ladder_monitor_enabled": req.enabled}
|
||||
|
||||
|
||||
@router.post("/preferences/limit-ladder-monitor/run")
|
||||
def run_limit_ladder_fix(request: Request) -> dict:
|
||||
"""立即手动修正一次真假板(拉取五档盘口 + 更新缓存)。需 Pro+。"""
|
||||
from app.tickflow.capabilities import Cap
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.DEPTH5_BATCH) # 无能力抛 CapabilityDenied(403)
|
||||
|
||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
||||
if not depth_svc:
|
||||
raise HTTPException(status_code=503, detail="depth 服务未初始化")
|
||||
return depth_svc.run_once()
|
||||
|
||||
|
||||
class DepthPollingIntervalIn(BaseModel):
|
||||
interval: float
|
||||
|
||||
|
||||
@router.put("/preferences/depth-polling-interval")
|
||||
def update_depth_polling_interval(req: DepthPollingIntervalIn, request: Request) -> dict:
|
||||
"""保存五档盘口盘中轮询间隔(秒)。需 Pro+。"""
|
||||
from app.tickflow.capabilities import Cap
|
||||
request.app.state.capabilities.require(Cap.DEPTH5_BATCH)
|
||||
|
||||
from app.services import preferences
|
||||
interval = preferences.set_depth_polling_interval(req.interval)
|
||||
return {"depth_polling_interval": interval}
|
||||
|
||||
|
||||
class DepthFinalizeTimeIn(BaseModel):
|
||||
hour: int
|
||||
minute: int
|
||||
|
||||
|
||||
@router.put("/preferences/depth-finalize-time")
|
||||
def update_depth_finalize_time(req: DepthFinalizeTimeIn, request: Request) -> dict:
|
||||
"""保存盘后 sealed 定版时间(范围15:01~18:00)并立即 reschedule。需 Pro+。"""
|
||||
from app.tickflow.capabilities import Cap
|
||||
request.app.state.capabilities.require(Cap.DEPTH5_BATCH)
|
||||
|
||||
from app.services import preferences
|
||||
sched = preferences.set_depth_finalize_time(req.hour, req.minute)
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
scheduler = getattr(request.app.state, "scheduler", None)
|
||||
if scheduler:
|
||||
scheduler.reschedule_job(
|
||||
"depth_finalize",
|
||||
trigger=CronTrigger(
|
||||
day_of_week="mon-fri",
|
||||
hour=sched["hour"],
|
||||
minute=sched["minute"],
|
||||
timezone="Asia/Shanghai",
|
||||
),
|
||||
)
|
||||
logger.info("depth_finalize rescheduled to %02d:%02d mon-fri", sched["hour"], sched["minute"])
|
||||
|
||||
return sched
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""自定义信号 API 路由 — HTTP 请求 → 调用 custom_signals 模块 → 返回响应。
|
||||
|
||||
只做胶水:校验 → 持久化 → 失效缓存。不含表达式编译逻辑。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.strategy import custom_signals
|
||||
|
||||
router = APIRouter(prefix="/api/custom-signals", tags=["custom-signals"])
|
||||
|
||||
|
||||
def _data_dir(request: Request) -> Path:
|
||||
return request.app.state.repo.store.data_dir
|
||||
|
||||
|
||||
def _invalidate() -> None:
|
||||
"""失效 pipeline 的自定义信号缓存,下次计算重新加载。"""
|
||||
from app.indicators.pipeline import invalidate_custom_signals
|
||||
invalidate_custom_signals()
|
||||
|
||||
|
||||
class ConditionModel(BaseModel):
|
||||
left: str # 字段名(须在白名单)
|
||||
op: str # > >= < <= == !=
|
||||
right: str # "field:xxx" 或数字字符串
|
||||
|
||||
|
||||
class SignalModel(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
kind: str # entry | exit | both
|
||||
conditions: list[ConditionModel]
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
# ── 字段选项 / 运算符 ───────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/options")
|
||||
def get_options():
|
||||
"""返回可选字段与运算符,供前端下拉框使用。"""
|
||||
# 字段带中文标签(取自 ENRICHED_COLUMNS,回退为字段名本身)
|
||||
from app.indicators.pipeline import ENRICHED_COLUMNS
|
||||
|
||||
fields = [
|
||||
{"key": f, "label": ENRICHED_COLUMNS.get(f, f)}
|
||||
for f in sorted(custom_signals.ALLOWED_FIELDS)
|
||||
]
|
||||
return {
|
||||
"fields": fields,
|
||||
"operators": [">", ">=", "<", "<=", "==", "!="],
|
||||
"kinds": [
|
||||
{"key": "entry", "label": "买入"},
|
||||
{"key": "exit", "label": "卖出"},
|
||||
{"key": "both", "label": "买卖通用"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 列表 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_signals(request: Request):
|
||||
sigs = custom_signals.load_all(_data_dir(request))
|
||||
return {"signals": sigs}
|
||||
|
||||
|
||||
# ── 新建 / 更新 ────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("")
|
||||
def save_signal(req: SignalModel, request: Request):
|
||||
sig = req.model_dump()
|
||||
try:
|
||||
custom_signals.validate(sig)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
custom_signals.save_one(_data_dir(request), sig)
|
||||
_invalidate()
|
||||
return {"ok": True, "signal": sig}
|
||||
|
||||
|
||||
# ── 删除 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.delete("/{signal_id}")
|
||||
def delete_signal(signal_id: str, request: Request):
|
||||
if not custom_signals.ID_RE.match(signal_id):
|
||||
raise HTTPException(status_code=400, detail="信号 id 非法")
|
||||
deleted = custom_signals.delete_one(_data_dir(request), signal_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="信号不存在")
|
||||
_invalidate()
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,440 @@
|
||||
"""策略 API 路由 — HTTP 请求 → 调用策略模块 → 返回响应。
|
||||
|
||||
只做胶水,不含业务逻辑。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import asdict
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.strategy import config as strategy_config
|
||||
from app.strategy.engine import StrategyEngine, StrategyDef
|
||||
from app.strategy.ai_generator import AIStrategyGenerator
|
||||
from app.strategy.prompt_builder import build_step1, build_step2
|
||||
from app.strategy.monitor import StrategyMonitorService, StrategyAlert
|
||||
|
||||
router = APIRouter(prefix="/api/strategies", tags=["strategies"])
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_engine(request: Request) -> StrategyEngine:
|
||||
engine = getattr(request.app.state, "strategy_engine", None)
|
||||
if not engine:
|
||||
raise HTTPException(status_code=503, detail="策略引擎未初始化")
|
||||
return engine
|
||||
|
||||
|
||||
def _get_monitor(request: Request) -> StrategyMonitorService:
|
||||
mon = getattr(request.app.state, "strategy_monitor", None)
|
||||
if not mon:
|
||||
raise HTTPException(status_code=503, detail="策略监控未初始化")
|
||||
return mon
|
||||
|
||||
|
||||
def _data_dir(request: Request) -> Path:
|
||||
return request.app.state.repo.store.data_dir
|
||||
|
||||
|
||||
def _safe(result_dict: dict) -> dict:
|
||||
rows = result_dict.get("rows", [])
|
||||
for r in rows:
|
||||
for k, v in list(r.items()):
|
||||
if isinstance(v, float) and not math.isfinite(v):
|
||||
r[k] = None
|
||||
return result_dict
|
||||
|
||||
|
||||
def _strategy_detail(s: StrategyDef, overrides: dict | None = None) -> dict:
|
||||
"""策略详情(含用户覆盖)"""
|
||||
bf = {**s.basic_filter}
|
||||
scoring = dict(s.meta.get("scoring", {}))
|
||||
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"])
|
||||
|
||||
# 名称/描述可被用户覆盖
|
||||
name = overrides.get("name", s.meta.get("name", "")) if overrides else s.meta.get("name", "")
|
||||
description = overrides.get("description", s.meta.get("description", "")) if overrides else s.meta.get("description", "")
|
||||
|
||||
return {
|
||||
"id": s.meta["id"],
|
||||
"name": name or s.meta.get("name", ""),
|
||||
"description": description or s.meta.get("description", ""),
|
||||
"tags": s.meta.get("tags", []),
|
||||
"source": s.source,
|
||||
"version": s.meta.get("version", "1.0.0"),
|
||||
"basic_filter": bf,
|
||||
"params": s.meta.get("params", []),
|
||||
"params_defaults": params_defaults,
|
||||
"scoring": scoring,
|
||||
"entry_signals": s.entry_signals,
|
||||
"exit_signals": s.exit_signals,
|
||||
"stop_loss": overrides.get("stop_loss", s.stop_loss) if overrides else s.stop_loss,
|
||||
"trailing_stop": getattr(s, "trailing_stop", None),
|
||||
"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),
|
||||
"display_limit": overrides.get("display_limit") if overrides and "display_limit" in overrides else None,
|
||||
}
|
||||
|
||||
|
||||
# ── Request Models ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RunRequest(BaseModel):
|
||||
strategy_id: str
|
||||
as_of: date | None = None
|
||||
pool: list[str] | None = None
|
||||
params: dict | None = None
|
||||
|
||||
|
||||
class RunAllRequest(BaseModel):
|
||||
as_of: date | None = None
|
||||
|
||||
|
||||
class SaveConfigRequest(BaseModel):
|
||||
strategy_id: str
|
||||
overrides: dict
|
||||
|
||||
|
||||
class AIGenerateRequest(BaseModel):
|
||||
prompt: str
|
||||
|
||||
|
||||
class AISaveRequest(BaseModel):
|
||||
code: str
|
||||
strategy_id: str
|
||||
|
||||
|
||||
class MonitorStartRequest(BaseModel):
|
||||
strategy_id: str
|
||||
|
||||
|
||||
# ── 列表 / 详情 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_strategies(request: Request):
|
||||
engine = _get_engine(request)
|
||||
data_dir = _data_dir(request)
|
||||
all_overrides = strategy_config.list_overrides(data_dir)
|
||||
|
||||
result = []
|
||||
for meta in engine.list_strategies():
|
||||
sid = meta["id"]
|
||||
s = engine.get(sid)
|
||||
overrides = all_overrides.get(sid)
|
||||
result.append(_strategy_detail(s, overrides))
|
||||
return {"strategies": result}
|
||||
|
||||
|
||||
@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
|
||||
overrides = strategy_config.load_override(_data_dir(request), strategy_id)
|
||||
return _strategy_detail(s, overrides or None)
|
||||
|
||||
|
||||
# ── 执行选股 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
def run_strategy(req: RunRequest, request: Request):
|
||||
engine = _get_engine(request)
|
||||
data_dir = _data_dir(request)
|
||||
|
||||
# 读取用户覆盖配置
|
||||
overrides = strategy_config.load_override(data_dir, req.strategy_id)
|
||||
params = req.params or {}
|
||||
# 合并用户保存的策略参数
|
||||
if overrides.get("params"):
|
||||
merged = dict(overrides["params"])
|
||||
merged.update(params) # 请求里的优先
|
||||
params = merged
|
||||
|
||||
# 确定日期
|
||||
as_of = req.as_of
|
||||
if not as_of:
|
||||
from app.services.screener import ScreenerService
|
||||
svc = ScreenerService(request.app.state.repo)
|
||||
as_of = svc.latest_date()
|
||||
if not as_of:
|
||||
raise HTTPException(status_code=400, detail="无可用数据日期")
|
||||
|
||||
try:
|
||||
result = engine.run(
|
||||
req.strategy_id, as_of,
|
||||
pool=req.pool,
|
||||
params=params,
|
||||
overrides=overrides or None,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
return _safe(asdict(result))
|
||||
|
||||
|
||||
@router.post("/run-all")
|
||||
def run_all(req: RunAllRequest, request: Request):
|
||||
engine = _get_engine(request)
|
||||
data_dir = _data_dir(request)
|
||||
|
||||
as_of = req.as_of
|
||||
if not as_of:
|
||||
from app.services.screener import ScreenerService
|
||||
svc = ScreenerService(request.app.state.repo)
|
||||
as_of = svc.latest_date()
|
||||
if not as_of:
|
||||
return {"as_of": None, "results": {}}
|
||||
|
||||
all_overrides = strategy_config.list_overrides(data_dir)
|
||||
results: dict[str, dict] = {}
|
||||
for sid, result in engine.run_all(as_of, overrides_map=all_overrides).items():
|
||||
results[sid] = {"total": result.total, "as_of": str(as_of)}
|
||||
|
||||
return {"as_of": str(as_of), "results": results}
|
||||
|
||||
|
||||
# ── 配置持久化 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@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} 不存在")
|
||||
|
||||
# 剥离与策略默认值相同的字段,只保存用户真正修改过的值
|
||||
overrides = _strip_defaults(req.strategy_id, req.overrides, engine)
|
||||
|
||||
strategy_config.save_override(_data_dir(request), req.strategy_id, overrides)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _strip_defaults(strategy_id: str, overrides: dict, engine) -> dict:
|
||||
"""剥离与策略默认值相同的字段,避免默认值被固化到 override 中。
|
||||
|
||||
核心问题: 前端把策略的默认 basic_filter 全量发回后端保存,
|
||||
导致隐含的默认过滤条件 (如 market_cap_min, amount_min) 被写入 override 文件。
|
||||
即使前端 UI 不展示这些字段,它们仍会在策略运行时生效。
|
||||
"""
|
||||
s = engine.get(strategy_id)
|
||||
result = dict(overrides)
|
||||
|
||||
# 处理 basic_filter: 只保留与策略默认值不同的键
|
||||
bf = result.get("basic_filter")
|
||||
if bf and isinstance(bf, dict):
|
||||
default_bf = s.basic_filter if s else {}
|
||||
stripped_bf = {}
|
||||
for k, v in bf.items():
|
||||
default_val = default_bf.get(k)
|
||||
# 保留与默认值不同的键,以及没有默认值的键
|
||||
if k not in default_bf or v != default_val:
|
||||
stripped_bf[k] = v
|
||||
if stripped_bf:
|
||||
result["basic_filter"] = stripped_bf
|
||||
else:
|
||||
del result["basic_filter"]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/config/{strategy_id}")
|
||||
def reset_config(strategy_id: str, request: Request):
|
||||
strategy_config.delete_override(_data_dir(request), strategy_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── AI 生成 ───────────────────────────────────────────────────────────
|
||||
|
||||
class BuildRequest(BaseModel):
|
||||
"""两步策略构建请求"""
|
||||
step: int # 1 / 2
|
||||
# step1 字段
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
direction: str = "long"
|
||||
rules: str = ""
|
||||
strategy_id: str = ""
|
||||
# step2 字段
|
||||
current_code: str = ""
|
||||
instruction: str = ""
|
||||
|
||||
|
||||
@router.get("/ai/status")
|
||||
def ai_status(request: Request):
|
||||
"""检查 AI 配置状态"""
|
||||
from app.config import settings
|
||||
from app import secrets_store
|
||||
has_key = bool(secrets_store.get_ai_key())
|
||||
has_model = bool(settings.ai_model)
|
||||
return {"configured": has_key and has_model, "has_key": has_key, "has_model": has_model}
|
||||
|
||||
|
||||
@router.get("/{strategy_id}/source")
|
||||
def get_strategy_source(strategy_id: str, request: Request):
|
||||
"""获取策略源文件内容(用于 AI 修改)"""
|
||||
from pathlib import Path
|
||||
|
||||
# 先查 StrategyEngine 获取文件路径
|
||||
engine = _get_engine(request)
|
||||
try:
|
||||
s = engine.get(strategy_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
|
||||
|
||||
path = s.file_path
|
||||
if not path or not path.exists():
|
||||
raise HTTPException(status_code=404, detail="策略源文件不存在")
|
||||
|
||||
return {"code": path.read_text(encoding="utf-8"), "source": s.source}
|
||||
|
||||
|
||||
@router.post("/ai/test")
|
||||
async def ai_test(request: Request):
|
||||
"""测试 AI 连通性 — 发送简单请求验证 Key 和模型"""
|
||||
from app.config import settings
|
||||
from app import secrets_store
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
ai_key = secrets_store.get_ai_key()
|
||||
if not ai_key:
|
||||
return {"ok": False, "error": "未配置 API Key"}
|
||||
|
||||
try:
|
||||
client = AsyncOpenAI(api_key=ai_key, base_url=settings.ai_base_url)
|
||||
resp = await client.chat.completions.create(
|
||||
model=settings.ai_model,
|
||||
messages=[{"role": "user", "content": "回复 OK"}],
|
||||
max_tokens=5,
|
||||
timeout=15,
|
||||
)
|
||||
return {"ok": True, "model": resp.model, "usage": {"prompt": resp.usage.prompt_tokens, "completion": resp.usage.completion_tokens} if resp.usage else None}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/build")
|
||||
async def build_strategy(req: BuildRequest, request: Request):
|
||||
"""两步策略构建。
|
||||
step1: name + description + direction + rules → 完整策略
|
||||
step2: current_code + instruction → 修改任意部分
|
||||
"""
|
||||
gen = AIStrategyGenerator()
|
||||
|
||||
if req.step == 1:
|
||||
prompt = build_step1(req.name, req.description, req.direction, req.rules, req.strategy_id)
|
||||
elif req.step == 2:
|
||||
prompt = build_step2(req.current_code, req.instruction)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"无效步骤: {req.step}")
|
||||
|
||||
try:
|
||||
result = await gen.generate(prompt)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@router.post("/ai/generate")
|
||||
async def ai_generate(req: AIGenerateRequest, request: Request):
|
||||
try:
|
||||
gen = AIStrategyGenerator()
|
||||
result = await gen.generate(req.prompt)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"AI生成失败: {e}") from e
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/ai/save")
|
||||
async def ai_save(req: AISaveRequest, request: Request):
|
||||
data_dir = _data_dir(request)
|
||||
out_dir = data_dir / "strategies" / "ai"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / f"{req.strategy_id}.py"
|
||||
previous_code = path.read_text(encoding="utf-8") if path.exists() else None
|
||||
path.write_text(req.code, encoding="utf-8")
|
||||
|
||||
# 热重载,并确认保存的策略真的被引擎加载。
|
||||
engine = _get_engine(request)
|
||||
engine.reload()
|
||||
if not engine.has(req.strategy_id):
|
||||
if previous_code is None:
|
||||
path.unlink(missing_ok=True)
|
||||
else:
|
||||
path.write_text(previous_code, encoding="utf-8")
|
||||
engine.reload()
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"策略保存成功但加载失败: {req.strategy_id},请检查代码语法和 META.id 是否一致",
|
||||
)
|
||||
return {"ok": True, "path": str(path)}
|
||||
|
||||
|
||||
@router.delete("/{strategy_id}")
|
||||
def delete_strategy(strategy_id: str, request: Request):
|
||||
"""删除自定义策略 — 清除 .py 文件 + overrides + 热重载。内置策略不可删除。"""
|
||||
from pathlib import Path
|
||||
|
||||
engine = _get_engine(request)
|
||||
try:
|
||||
s = engine.get(strategy_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
|
||||
|
||||
if s.source == "builtin":
|
||||
raise HTTPException(status_code=403, detail="内置策略不可删除")
|
||||
|
||||
# 删除策略文件
|
||||
if s.file_path and s.file_path.exists():
|
||||
s.file_path.unlink()
|
||||
|
||||
# 删除 overrides
|
||||
data_dir = _data_dir(request)
|
||||
override_path = data_dir / "user_data" / "strategy_overrides" / f"{strategy_id}.json"
|
||||
if override_path.exists():
|
||||
override_path.unlink()
|
||||
|
||||
# 热重载
|
||||
engine.reload()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── 监控 ─────────────────────────────────────────────────────────────
|
||||
# 注: 策略监控已统一迁移到 MonitorRuleEngine (监控通知页), 旧的 start/stop/status
|
||||
# 路由已移除。StrategyMonitorService 类保留 (其 _check_signals 被 MonitorRuleEngine 复用)。
|
||||
|
||||
|
||||
# ── 热重载 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/reload")
|
||||
def reload_strategies(request: Request):
|
||||
engine = _get_engine(request)
|
||||
engine.reload()
|
||||
return {"ok": True, "count": len(engine.list_strategies())}
|
||||
@@ -0,0 +1,202 @@
|
||||
"""自选股 API。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from datetime import date
|
||||
|
||||
import polars as pl
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services import watchlist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
|
||||
|
||||
|
||||
class AddRequest(BaseModel):
|
||||
symbol: str
|
||||
note: str = ""
|
||||
|
||||
|
||||
class BatchAddRequest(BaseModel):
|
||||
symbols: list[str]
|
||||
note: str = ""
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_all():
|
||||
return {"symbols": watchlist.list_symbols()}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def add_one(req: AddRequest):
|
||||
rows = watchlist.add(req.symbol, req.note)
|
||||
return {"symbols": rows}
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
def add_batch(req: BatchAddRequest):
|
||||
for sym in req.symbols:
|
||||
watchlist.add(sym, req.note)
|
||||
return {"symbols": watchlist.list_symbols(), "added": len(req.symbols)}
|
||||
|
||||
|
||||
@router.delete("/{symbol}")
|
||||
def remove_one(symbol: str):
|
||||
rows = watchlist.remove(symbol)
|
||||
return {"symbols": rows}
|
||||
|
||||
|
||||
@router.delete("")
|
||||
def clear_all():
|
||||
"""清空自选列表。"""
|
||||
count = watchlist.clear()
|
||||
return {"removed": count}
|
||||
|
||||
|
||||
# 自选页需要的列
|
||||
_WATCHLIST_COLS = [
|
||||
"symbol", "close", "change_pct", "change_amount", "amount",
|
||||
"turnover_rate",
|
||||
"amplitude", "annual_vol_20d",
|
||||
"vol_ratio_5d",
|
||||
"ma5", "ma10", "ma20", "ma60",
|
||||
"vol_ma5", "vol_ma10",
|
||||
"high_60d", "low_60d",
|
||||
"rsi_6", "rsi_14", "rsi_24",
|
||||
"macd_dif", "macd_dea", "macd_hist",
|
||||
"kdj_k", "kdj_d", "kdj_j",
|
||||
"boll_upper", "boll_lower",
|
||||
"atr_14",
|
||||
"momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d",
|
||||
"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",
|
||||
"signal_boll_breakout_upper", "signal_ma20_breakout",
|
||||
"signal_ma_dead_5_20", "signal_macd_dead", "signal_n_day_low",
|
||||
"signal_boll_breakdown_lower", "signal_ma20_breakdown",
|
||||
]
|
||||
|
||||
|
||||
@router.get("/enriched")
|
||||
def watchlist_enriched(
|
||||
request: Request,
|
||||
ext_columns: str | None = Query(None, description="逗号分隔的 ext 列: config_id.field_name"),
|
||||
):
|
||||
"""自选股 enriched 数据 — 直接从 enriched 最新日读取, 无即时计算。
|
||||
|
||||
ext_columns 参数示例: "industry_rating.score,fund_flow.net_inflow"
|
||||
会动态 LEFT JOIN 对应的 ext_{config_id} DuckDB view。
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
repo = request.app.state.repo
|
||||
symbols = [r["symbol"] for r in watchlist.list_symbols()]
|
||||
if not symbols:
|
||||
return {"rows": [], "as_of": None, "elapsed_ms": 0}
|
||||
|
||||
df_e, cache_date = repo.get_enriched_latest()
|
||||
if df_e.is_empty():
|
||||
return {"rows": [], "as_of": None, "elapsed_ms": 0}
|
||||
|
||||
# 按 symbol 过滤
|
||||
df = df_e.filter(pl.col("symbol").is_in(symbols))
|
||||
if df.is_empty():
|
||||
return {"rows": [], "as_of": str(cache_date) if cache_date else None, "elapsed_ms": 0}
|
||||
|
||||
# JOIN instruments 取 name + float_shares
|
||||
df_i = repo.get_instruments()
|
||||
if not df_i.is_empty() and "name" in df_i.columns:
|
||||
inst_cols = [c for c in ["symbol", "name", "float_shares"] if c in df_i.columns]
|
||||
df = df.join(df_i.select(inst_cols), on="symbol", how="left")
|
||||
|
||||
# 选择内置需要的列
|
||||
keep = [c for c in _WATCHLIST_COLS + ["name", "float_shares"] if c in df.columns]
|
||||
df = df.select(keep)
|
||||
|
||||
# 动态 JOIN 扩展数据表
|
||||
ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
|
||||
if ext_specs:
|
||||
db = repo.store.db
|
||||
data_dir = repo.store.data_dir
|
||||
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()}
|
||||
|
||||
for config_id, field_name in ext_specs:
|
||||
view_name = f"ext_{config_id}"
|
||||
ext_col_name = f"{config_id}__{field_name}"
|
||||
try:
|
||||
# 扩展时序数据必须只取最新分区;否则一个 symbol 会按历史分区数被 JOIN 放大。
|
||||
cfg = configs.get(config_id)
|
||||
if cfg:
|
||||
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
|
||||
else:
|
||||
ext_df = pl.from_arrow(db.query(
|
||||
f"SELECT symbol, \"{field_name}\" FROM {view_name}"
|
||||
).arrow())
|
||||
if not ext_df.is_empty() and "symbol" in ext_df.columns:
|
||||
ext_df = (
|
||||
ext_df
|
||||
.select(["symbol", field_name])
|
||||
.unique(subset=["symbol"], keep="last")
|
||||
.rename({field_name: ext_col_name})
|
||||
)
|
||||
df = df.join(ext_df.select(["symbol", ext_col_name]), on="symbol", how="left")
|
||||
except Exception:
|
||||
# view 不存在或字段不存在,尝试直接读 parquet
|
||||
cfg = configs.get(config_id)
|
||||
if cfg:
|
||||
try:
|
||||
ext_df, _ = _read_ext_dataframe(cfg, data_dir)
|
||||
if not ext_df.is_empty() and "symbol" in ext_df.columns and field_name in ext_df.columns:
|
||||
ext_df = (
|
||||
ext_df
|
||||
.select(["symbol", field_name])
|
||||
.unique(subset=["symbol"], keep="last")
|
||||
.rename({field_name: ext_col_name})
|
||||
)
|
||||
df = df.join(ext_df, on="symbol", how="left")
|
||||
except Exception as e2:
|
||||
logger.debug("ext join fallback failed for %s.%s: %s", config_id, field_name, e2)
|
||||
|
||||
# sanitize NaN / Inf
|
||||
float_cols = [c for c in df.columns if df[c].dtype.is_float()]
|
||||
if float_cols:
|
||||
df = df.with_columns([
|
||||
pl.when(pl.col(c).is_nan() | pl.col(c).is_infinite())
|
||||
.then(None)
|
||||
.otherwise(pl.col(c))
|
||||
.alias(c)
|
||||
for c in float_cols
|
||||
])
|
||||
|
||||
# 按自选添加顺序(新加的在前)重排行
|
||||
order_map = {s: i for i, s in enumerate(symbols)}
|
||||
df = df.with_columns(pl.col("symbol").map_elements(lambda s: order_map.get(s, len(symbols)), return_dtype=pl.Int32).alias("_sort_order"))
|
||||
df = df.sort("_sort_order").drop("_sort_order")
|
||||
|
||||
rows = df.to_dicts()
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
return {"rows": rows, "as_of": str(cache_date) if cache_date else None, "elapsed_ms": elapsed}
|
||||
|
||||
|
||||
def _parse_ext_columns(ext_columns: str) -> list[tuple[str, str]]:
|
||||
"""解析 'config_id1.field1,config_id2.field2' 为 [(config_id, field_name), ...]"""
|
||||
result = []
|
||||
for part in ext_columns.split(","):
|
||||
part = part.strip()
|
||||
if "." not in part:
|
||||
continue
|
||||
config_id, field_name = part.split(".", 1)
|
||||
config_id = config_id.strip()
|
||||
field_name = field_name.strip()
|
||||
if config_id and field_name:
|
||||
result.append((config_id, field_name))
|
||||
return result
|
||||
Reference in New Issue
Block a user