复制 local 代码到 serve

This commit is contained in:
2026-07-04 16:59:25 +08:00
parent cee04c1b46
commit 0474e5fb46
302 changed files with 78290 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""FastAPI 路由汇总。"""
+144
View File
@@ -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)}
+171
View File
@@ -0,0 +1,171 @@
"""自定义分析菜单 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
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]:
"""自动生成的默认分析菜单。
历史上会扫描扩展数据配置,对含「概念」字段的表自动生成一个「概念分析」菜单。
现已关闭自动生成 —— 内置的概念分析页(/concept-analysis)已覆盖该场景,
自动菜单会造成导航重复。需要时用户可在「设置 → 扩展页面」手动创建。
"""
return []
@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"}
+213
View File
@@ -0,0 +1,213 @@
"""访问认证 API。
端点:
GET /api/auth/status — 是否已设密码、当前会话是否有效
POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占)
POST /api/auth/login — 登录(密码 → 会话 token, 含限流)
POST /api/auth/logout — 注销当前会话
POST /api/auth/change-password — 改密码(需已登录)
安全:
- setup 端点只接受本机/内网请求(request.client.host), 公网请求 403。
否则黑客可比用户更早扫到域名, 抢先设密码, 反客为主。
- login 限流: 同一来源 IP 连续失败 5 次, 锁 5 分钟(内存计数)。
- 会话 token 通过 HttpOnly cookie 下发, 前端无需手动管理。
"""
from __future__ import annotations
import logging
import time
from collections import defaultdict
from threading import Lock
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel, Field
from app.services import auth
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
COOKIE_NAME = "tf_session"
_COOKIE_MAX_AGE = 30 * 24 * 3600 # 与 SESSION_TTL 一致
# 限流: { ip: (fail_count, lock_until_ts) }
_fail_counter: dict[str, tuple[int, float]] = defaultdict(lambda: (0, 0.0))
_fail_lock = Lock()
_MAX_FAILS = 5
_LOCK_SECONDS = 300
def _is_local_network(host: str | None) -> bool:
"""是否本机或内网请求。
反向代理(Nginx)场景下 request.client.host 是代理本身(127.0.0.1),
需信任 X-Forwarded-For 的最左(原始客户端)。本项目部署若经反代,
请在反代配置正确的 X-Forwarded-For(标准做法)。
"""
if not host:
return False
if host in ("127.0.0.1", "::1", "localhost"):
return True
# 内网网段: 10.x / 172.16-31.x / 192.168.x
if host.startswith("10.") or host.startswith("192.168."):
return True
if host.startswith("172."):
try:
second = int(host.split(".")[1])
if 16 <= second <= 31:
return True
except (IndexError, ValueError):
pass
return False
def _client_ip(request: Request) -> str:
"""取真实客户端 IP(信任反代 X-Forwarded-For)。"""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _check_login_rate_limit(ip: str) -> None:
"""登录失败限流检查, 触发则抛 429。"""
with _fail_lock:
count, until = _fail_counter.get(ip, (0, 0.0))
now = time.time()
if until > now:
wait = int(until - now)
raise HTTPException(
status_code=429,
detail=f"登录失败次数过多, 请 {wait} 秒后重试",
)
def _record_login_fail(ip: str) -> None:
"""记录一次登录失败, 达阈值则锁定。"""
with _fail_lock:
count, until = _fail_counter.get(ip, (0, 0.0))
count += 1
if count >= _MAX_FAILS:
until = time.time() + _LOCK_SECONDS
logger.warning("auth login locked for %s after %d fails", ip, count)
_fail_counter[ip] = (count, until)
def _clear_login_fails(ip: str) -> None:
"""登录成功后清除该 IP 的失败计数。"""
with _fail_lock:
_fail_counter.pop(ip, None)
# ================================================================
# 端点
# ================================================================
class PasswordIn(BaseModel):
password: str = Field(min_length=6, max_length=128)
class LoginIn(BaseModel):
password: str = Field(min_length=1, max_length=128)
class ChangePasswordIn(BaseModel):
old_password: str = Field(min_length=1, max_length=128)
new_password: str = Field(min_length=6, max_length=128)
@router.get("/status")
def auth_status(request: Request) -> dict:
"""认证状态: 是否已设密码 + 当前请求是否已登录。"""
token = request.cookies.get(COOKIE_NAME)
return {
"configured": auth.is_configured(),
"authenticated": bool(token and auth.is_valid_session(token)),
}
@router.post("/setup")
def setup_password(req: PasswordIn, request: Request) -> dict:
"""首次设置访问密码。仅限本机/内网请求(防公网抢占)。
若已设置过密码, 返回 409(改密码走 /change-password)。
"""
# 关键: 限制只有服务器主人(本机/内网)能设密码
client_ip = _client_ip(request)
if not _is_local_network(client_ip):
logger.warning("setup rejected from non-local ip: %s", client_ip)
raise HTTPException(
status_code=403,
detail="首次设置密码仅允许本机或内网访问,请通过 SSH/本地浏览器操作",
)
if auth.is_configured():
raise HTTPException(status_code=409, detail="密码已设置,如需修改请登录后使用改密码功能")
auth.set_password(req.password)
logger.info("access password set up from %s", client_ip)
return {"ok": True, "configured": True}
@router.post("/login")
def login(req: LoginIn, request: Request, response: Response) -> dict:
"""登录: 密码 → 会话 token(写 HttpOnly cookie)。含失败限流。"""
ip = _client_ip(request)
_check_login_rate_limit(ip)
if not auth.is_configured():
raise HTTPException(status_code=409, detail="尚未设置访问密码")
token = auth.verify_and_create_session(req.password)
if not token:
_record_login_fail(ip)
raise HTTPException(status_code=401, detail="密码错误")
_clear_login_fails(ip)
# HttpOnly: 防 XSS 窃取; SameSite=Lax: 防 CSRF; Path=/: 全站生效
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=_COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
path="/",
secure=False, # 自托管可能无 HTTPS, 不强制 secure(建议反代加 HTTPS)
)
return {"ok": True, "authenticated": True}
@router.post("/logout")
def logout(request: Request, response: Response) -> dict:
"""注销当前会话。"""
token = request.cookies.get(COOKIE_NAME)
if token:
auth.revoke_session(token)
response.delete_cookie(key=COOKIE_NAME, path="/")
return {"ok": True}
@router.post("/change-password")
def change_password(req: ChangePasswordIn, request: Request) -> dict:
"""修改密码: 需验证旧密码, 成功后所有会话失效(含当前, 需重新登录)。"""
token = request.cookies.get(COOKIE_NAME)
if not (token and auth.is_valid_session(token)):
raise HTTPException(status_code=401, detail="请先登录")
if not auth.is_configured():
raise HTTPException(status_code=409, detail="尚未设置访问密码")
# 验证旧密码
new_token = auth.verify_and_create_session(req.old_password)
if not new_token:
ip = _client_ip(request)
_record_login_fail(ip)
raise HTTPException(status_code=401, detail="旧密码错误")
# 临时 token 用完即弃
auth.revoke_session(new_token)
# 改密码(set_password 会清空所有会话)
auth.set_password(req.new_password)
return {"ok": True, "message": "密码已修改, 请重新登录"}
+474
View File
@@ -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": "任务不存在或已完成"}
+863
View File
@@ -0,0 +1,863 @@
"""数据画像 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,
"etf_daily": None,
"etf_enriched": None,
"etf_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_etf_instruments(repo) -> dict | None:
"""ETF instruments 统计 — 优先独立 instruments_etf,兼容旧 instruments_index。"""
queries = [
"""SELECT count(*) AS rows,
count(DISTINCT symbol) AS symbols,
count_if(name IS NOT NULL AND name != '') AS named
FROM instruments_etf""",
"""SELECT count(*) AS rows,
count(DISTINCT symbol) AS symbols,
count_if(name IS NOT NULL AND name != '') AS named
FROM instruments_index
WHERE asset_type = 'etf'""",
]
for sql in queries:
try:
row = repo.execute_one(sql)
except Exception as e: # noqa: BLE001
logger.debug("aggregate etf instruments fallback failed: %s", e)
continue
if row and row[0]:
return {
"rows": int(row[0]),
"symbols_covered": int(row[1] or 0),
"latest_as_of": None,
"named": int(row[2] or 0),
}
return None
def _safe_aggregate_etf_enriched(repo) -> dict | None:
"""ETF enriched 统计 — 独立 kline_etf_enriched。"""
fields = 0
try:
cols = repo.execute_all("DESCRIBE kline_etf_enriched")
fields = len(cols)
except Exception: # noqa: BLE001
pass
stats = _safe_aggregate(repo, "kline_etf_enriched")
if not stats:
return None
return {**stats, "fields": fields}
def _safe_aggregate_etf_daily(repo) -> dict | None:
"""ETF 日K统计 — 优先独立 kline_etf_daily,兼容旧 index 存储。"""
queries = [
"""SELECT count(*) AS rows,
min(date) AS earliest,
max(date) AS latest,
count(DISTINCT symbol) AS symbols,
count(DISTINCT date) AS trading_days
FROM kline_etf_daily""",
"""SELECT count(*) AS rows,
min(date) AS earliest,
max(date) AS latest,
count(DISTINCT symbol) AS symbols,
count(DISTINCT date) AS trading_days
FROM kline_index_daily
WHERE symbol IN (
SELECT DISTINCT symbol FROM instruments_index WHERE asset_type = 'etf'
)""",
]
for sql in queries:
try:
row = repo.execute_one(sql)
except Exception as e: # noqa: BLE001
logger.debug("aggregate etf daily fallback failed: %s", e)
continue
if row and row[0]:
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),
}
return None
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",
"etf_daily": data_dir / "kline_etf_daily",
"etf_enriched": data_dir / "kline_etf_enriched",
"etf_instruments": data_dir / "instruments_etf",
"etf_adj_factor": data_dir / "adj_factor_etf",
"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)),
"etf_daily": _get_table_stats("etf_daily", lambda: _safe_aggregate_etf_daily(repo)),
"etf_enriched": _get_table_stats("etf_enriched", lambda: _safe_aggregate_etf_enriched(repo)),
"etf_instruments": _get_table_stats("etf_instruments", lambda: _safe_aggregate_etf_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_etf_daily", "kline_etf_enriched", "kline_etf_minute", "kline_minute",
"adj_factor", "adj_factor_etf", "instruments", "instruments_index", "instruments_etf", "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_etf_daily": f"{d}/kline_etf_daily/**/*.parquet",
"kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet",
"kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet",
"kline_minute": f"{d}/kline_minute/**/*.parquet",
"adj_factor": f"{d}/adj_factor/**/*.parquet",
"adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet",
"instruments": f"{d}/instruments/**/*.parquet",
"instruments_index": f"{d}/instruments_index/**/*.parquet",
"instruments_etf": f"{d}/instruments_etf/**/*.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_etf_daily": {
"symbol": "ETF代码",
"date": "交易日期",
"open": "开盘价",
"high": "最高价",
"low": "最低价",
"close": "收盘价",
"volume": "成交量",
"amount": "成交额",
},
"kline_etf_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)",
},
"instruments_etf": {
"symbol": "ETF代码",
"name": "ETF名称",
"code": "ETF编码(纯数字)",
"asset_type": "资产类型(etf)",
"source": "数据源",
},
}
# 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",
"etf_daily": "kline_etf_daily",
"etf_enriched": "kline_etf_enriched",
"etf_instruments": "instruments_etf",
"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__ (唯一权威版本, 打包期由 PyInstaller 注入)
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"}
+871
View File
@@ -0,0 +1,871 @@
"""扩展数据 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("/presets/{config_id}/fetch")
async def fetch_preset_data(request: Request, config_id: str):
"""手动触发内置预设 (概念/行业) 的数据拉取。
注意: 必须在 /{config_id}/... 动态路由之前声明, 否则 'presets' 会被当成 config_id。
与通用 pull/run 不同: 走 ext_presets 的结构转换 (接口的 concepts/industries
数组 → 拼接成字符串), 保证 schema 与现有数据一致。
"""
from app.services.ext_presets import fetch_preset
try:
n = await fetch_preset(config_id, _data_dir(request))
except ValueError as e:
raise HTTPException(404, str(e)) from e
except Exception as e:
raise HTTPException(400, f"拉取失败: {e}") from e
_refresh_views(request)
return {"status": "ok", "rows": n}
@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))
# 关闭定时拉取时清理残留的 next_run, 避免前端展示一个永不执行的"下次"
if not config.pull.enabled:
cleared = store.get(config_id)
if cleared and cleared.pull and cleared.pull.next_run:
cleared.pull.next_run = None
store.upsert(cleared)
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)
# 写回执行状态, 让前端"上次执行"面板立即反映
updated = store.get(config_id)
if updated and updated.pull:
from datetime import datetime, timezone
updated.pull.last_run = datetime.now(timezone.utc).isoformat()
updated.pull.last_status = "success"
updated.pull.last_message = f"{n} rows @ {d}"
updated.pull.last_rows = n
store.upsert(updated)
return {"status": "ok", "rows": n, "date": d}
except Exception as e:
# 失败也写回状态, 记录错误信息
failed = store.get(config_id)
if failed and failed.pull:
from datetime import datetime, timezone
failed.pull.last_run = datetime.now(timezone.utc).isoformat()
failed.pull.last_status = "error"
failed.pull.last_message = str(e)[:200]
store.upsert(failed)
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
+217
View File
@@ -0,0 +1,217 @@
"""财务数据 API — 独立路由, Cap.FINANCIAL 门控。"""
from __future__ import annotations
import logging
import polars as pl
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.services.financial_sync import get_financial_df
from app.services.financial_analyzer import analyze_financials_stream
from app.services import ai_reports
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
同步在后台线程执行,全量同步需数分钟。本接口立即返回 started 状态,
前端通过轮询 GET /status 的 syncing 字段观察进度。
"""
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.trigger(target)
return {"status": "ok", "synced": result}
class AnalyzeRequest(BaseModel):
"""AI 财务分析请求。"""
symbol: str
focus: str = "" # 可选:用户追加的分析关注点
@router.post("/analyze")
async def analyze_financials(request: Request, req: AnalyzeRequest):
"""AI 财务分析 — SSE 流式返回。
后端读取该标的 4 张财务表 → 注入 CFA 分析师级提示词 → 流式调用 LLM →
逐 chunk 以 SSE 形式推给前端(JSON per line, 非 text/event-stream,
以便前端用 ReadableStream 逐行解析,更简单可靠)。
"""
capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL)
if not req.symbol:
raise HTTPException(400, "symbol 不能为空")
data_dir = request.app.state.repo.store.data_dir
async def stream_gen():
async for chunk in analyze_financials_stream(data_dir, req.symbol, req.focus):
yield chunk + "\n"
return StreamingResponse(
stream_gen(),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ================================================================
# AI 报告 CRUD(历史报告持久化)
# ================================================================
class SaveReportRequest(BaseModel):
"""保存一条 AI 财务分析报告。"""
symbol: str
name: str = ""
focus: str = ""
content: str
periods: int | None = None
summary: str = ""
@router.get("/reports")
def list_reports(request: Request):
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。无需 FINANCIAL 能力读取列表元信息。"""
capset = request.app.state.capabilities
if not capset.has(Cap.FINANCIAL):
return {"reports": []}
return {"reports": ai_reports.list_reports()}
@router.post("/reports")
def save_report(request: Request, req: SaveReportRequest):
"""保存一条报告。"""
capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL)
report = ai_reports.save_report({
"symbol": req.symbol,
"name": req.name,
"focus": req.focus,
"content": req.content,
"periods": req.periods,
"summary": req.summary,
})
return {"ok": True, "report": report}
@router.delete("/reports/{report_id}")
def delete_report(request: Request, report_id: str):
"""删除一条报告。"""
capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL)
ok = ai_reports.delete_report(report_id)
return {"ok": ok}
+149
View File
@@ -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"TickFlow 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}
+212
View File
@@ -0,0 +1,212 @@
"""行情状态 / 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 列表"),
):
"""返回实时指数行情缓存,不触发 TickFlow 请求。"""
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)
),
"review": asyncio.ensure_future(
asyncio.to_thread(qs.wait_for_review, 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),
}
# 推送复盘进度 (定时复盘流式生成时) — 前端 reviewStore 直接消费
# 事件已是 recap_market_stream 产出的 JSON 字符串, 逐条转发
for evt_json in qs.pop_review_events():
yield {
"event": "review_progress",
"data": evt_json,
}
# 推送行情更新 (行情信号触发)
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"}
+822
View File
@@ -0,0 +1,822 @@
"""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, 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"TickFlow fetch failed: {e}") from e
if raw.is_empty():
return {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": []}
# 拉除权因子做前复权 (Starter+ 有权限), 否则空 df → compute_enriched 退回未复权
factors = pl.DataFrame()
capset = getattr(request.app.state, "capabilities", None)
try:
from app.tickflow.capabilities import Cap
if capset and capset.has(Cap.ADJ_FACTOR):
factors = kline_sync.fetch_adj_factor_single(symbol)
except Exception as e: # noqa: BLE001
logger.debug("单股除权因子拉取失败 %s: %s", symbol, e)
enriched = compute_enriched(raw, factors=factors)
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条) → 直接返回
- 本地无数据或不完整 → 从 TickFlow 实时拉取返回(不写入)
"""
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,尝试从 TickFlow 拉取当天
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",
}
# 本地不完整或无数据 → 从 TickFlow 实时拉取
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 []
+115
View File
@@ -0,0 +1,115 @@
"""AI 大盘复盘 API — 流式复盘 + 报告持久化。
路由前缀: /api/market-recap
端点:
POST /analyze AI 流式大盘复盘(NDJSON)
GET /reports 历史复盘列表
POST /reports 保存一条复盘报告
DELETE /reports/{report_id} 删除一条复盘报告
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.services import market_recap_reports
from app.services.market_recap import recap_market_stream
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/market-recap", tags=["market-recap"])
class AnalyzeRequest(BaseModel):
"""AI 大盘复盘请求。"""
as_of: str | None = None # 可选:复盘日期(YYYY-MM-DD),缺省取最新有数据日
focus: str = "" # 可选:用户追加的复盘关注点
@router.post("/analyze")
async def analyze_market(request: Request, req: AnalyzeRequest):
"""AI 大盘复盘 — NDJSON 流式返回。
装配市场总览(指数/涨跌/连板/封板/板块/情绪雷达)→ 复盘提示词 →
流式调用 LLM → 逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。
协议:
{"type":"meta","as_of","emotion_score","emotion_label","summary"}
{"type":"delta","content":"..."}
{"type":"error","message":"..."}
{"type":"done"}
"""
from datetime import date as date_cls
repo = request.app.state.repo
quote_service = getattr(request.app.state, "quote_service", None)
depth_service = getattr(request.app.state, "depth_service", None)
as_of = None
if req.as_of:
try:
as_of = date_cls.fromisoformat(req.as_of)
except ValueError:
raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}")
async def stream_gen():
async for chunk in recap_market_stream(repo, quote_service, depth_service, as_of, req.focus):
yield chunk + "\n"
return StreamingResponse(
stream_gen(),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ================================================================
# 报告 CRUD(历史复盘持久化)
# ================================================================
class SaveReportRequest(BaseModel):
"""保存一条 AI 大盘复盘报告。"""
as_of: str
focus: str = ""
content: str
summary: str = ""
emotion_score: int | None = None
emotion_label: str = ""
@router.get("/reports")
def list_reports(request: Request):
"""获取全部历史复盘(按时间降序,后端已裁剪到上限)。"""
return {"reports": market_recap_reports.list_reports()}
@router.post("/reports")
def save_report(request: Request, req: SaveReportRequest):
"""保存一条复盘报告。"""
report = market_recap_reports.save_report({
"as_of": req.as_of,
"focus": req.focus,
"content": req.content,
"summary": req.summary,
"emotion_score": req.emotion_score,
"emotion_label": req.emotion_label,
})
# 推送到飞书(可选): 与定时复盘共用同一开关 review_push_enabled 与 _maybe_push_review。
# 内部 try/except 静默降级, 不影响归档返回值。
from app.jobs.daily_pipeline import _maybe_push_review
_maybe_push_review(req.content, {
"as_of": req.as_of,
"emotion_label": req.emotion_label,
})
return {"ok": True, "report": report}
@router.delete("/reports/{report_id}")
def delete_report(request: Request, report_id: str):
"""删除一条复盘报告。"""
ok = market_recap_reports.delete_report(report_id)
return {"ok": ok}
+499
View File
@@ -0,0 +1,499 @@
"""监控规则 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 = ""
# ladder 专属 (连板梯队封单监控)
metric: str = "sealed_vol" # sealed_vol=封单量(手) | sealed_amount=封单额(元)
threshold: float = 0 # 封单 <= 此值时报警 (原始单位: 量=手, 额=元)
# ── 字段选项 ─────────────────────────────────────────────
@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())
# 连板梯队封单监控 (type=ladder) 依赖五档盘口数据, 需 Pro+ (DEPTH5_BATCH 能力)。
# 无能力时拒绝创建, 避免规则存了却永远无法触发。
if rule.get("type") == "ladder":
from app.tickflow.capabilities import Cap
capset = getattr(request.app.state, "capabilities", None)
if capset is None or not capset.has(Cap.DEPTH5_BATCH):
raise HTTPException(
status_code=403,
detail="封单监控需要 Pro+ 套餐 (批量五档能力),请升级后在「设置」页配置",
)
# 编辑现有规则时, 保留原 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}
# ── 封单监控模拟触发 (Dev 调试用) ─────────────────────
@router.post("/test-ladder")
def test_ladder(request: Request):
"""模拟触发所有 ladder 规则, 返回命中结果 (不落盘、不推送飞书)。
用当前 depth_service 的封单数据 + enriched 最新日 close 构造 mock DataFrame,
跑 _evaluate_ladder 判断哪些规则会触发。供 Dev 页面调试验证。
"""
import polars as pl
repo = request.app.state.repo
depth_svc = getattr(request.app.state, "depth_service", None)
engine = getattr(request.app.state, "monitor_engine", None)
if not depth_svc:
raise HTTPException(status_code=503, detail="depth 服务未初始化")
if not engine or not engine.has_rule_type("ladder"):
raise HTTPException(status_code=400, detail="无 ladder 类型监控规则")
# 最新交易日
latest = repo.enriched_latest_date()
if not latest:
raise HTTPException(status_code=400, detail="无 enriched 数据")
# 取涨停+跌停封单 {symbol: vol}
sealed: dict[str, int] = {}
for is_down in (False, True):
m = depth_svc.get_sealed_map(latest, is_down=is_down)
for sym, info in m.items():
vol = (info or {}).get("vol")
if vol and vol > 0:
sealed[sym] = vol
if not sealed:
raise HTTPException(status_code=400, detail="无封单数据 (depth 未拉取或无涨停/跌停股)")
# 取这些 symbol 的 close (算封单额用)
enriched_today, _ = repo.get_enriched_latest()
cols = ["symbol", "close", "change_pct"]
avail = [c for c in cols if c in enriched_today.columns]
mock = enriched_today.select(avail).filter(pl.col("symbol").is_in(list(sealed.keys())))
# 注入 _sealed_vol
sealed_df = pl.DataFrame({
"symbol": list(sealed.keys()),
"_sealed_vol": list(sealed.values()),
})
mock = mock.join(sealed_df, on="symbol", how="inner")
# 取所有 ladder 规则, 逐条纯条件判断 (绕过引擎 cooldown, 不污染 _last_fire)
ladder_rules = [r for r in engine.rules.values() if r.get("type") == "ladder" and r.get("enabled", True)]
all_events = []
not_triggered = []
for rule in ladder_rules:
syms = rule.get("symbols", [])
sym = syms[0] if syms else None
metric = rule.get("metric", "sealed_vol")
thr = rule.get("threshold", 0)
direction = rule.get("direction", "up")
warn_label = "炸板预警" if direction == "up" else "翘板预警"
# 取该 symbol 的封单数据
cur_vol = sealed.get(sym) if sym else None
row = mock.filter(pl.col("symbol") == sym) if sym else mock.clear()
cur_close = row["close"][0] if len(row) and "close" in row.columns else None
cur_amt = (cur_vol * 100 * cur_close) if (cur_vol and cur_close) else None
cur_val = cur_amt if metric == "sealed_amount" else cur_vol
# 条件判断: 封单 > 0 且 比较值 <= 阈值
if cur_val is not None and cur_val > 0 and cur_val <= thr:
if metric == "sealed_amount":
sv_text = f"{cur_val / 1e4:.0f}万元"
th_text = f"{thr / 1e4:.0f}万元"
else:
sv_text = f"{cur_val:,.0f}"
th_text = f"{thr:,.0f}"
all_events.append({
"rule_id": rule["id"],
"rule_name": rule.get("name", ""),
"symbol": sym,
"name": sym,
"type": warn_label,
"message": f"{warn_label} · 封单 {sv_text}{th_text}",
"severity": rule.get("severity", "warn"),
"sealed_value": cur_val,
"sealed_metric": metric,
"current_sealed_vol": cur_vol,
"current_sealed_amount": cur_amt,
})
else:
reason = "封单数据缺失" if cur_val is None else (
f"封单 {cur_val:,.0f} > 阈值 {thr:,.0f}" if cur_val > thr else "封单为 0"
)
not_triggered.append({
"rule_id": rule["id"],
"rule_name": rule.get("name", ""),
"symbol": sym,
"metric": metric,
"threshold": thr,
"current_value": cur_val,
"current_sealed_vol": cur_vol,
"current_sealed_amount": cur_amt,
"reason": reason,
})
return {
"ok": True,
"as_of": str(latest),
"sealed_count": len(sealed),
"triggered": all_events,
"not_triggered": not_triggered,
}
@router.post("/trigger-ladder")
def trigger_ladder(request: Request):
"""真实触发一次 ladder 预警 (落盘 + 飞书推送 + SSE), 供 Dev 调试验证完整效果。
与 test-ladder 区别: 本端点会真的把预警写入 alerts.jsonl、推送飞书、触发 SSE,
让用户看到真实的预警通知。绕过 cooldown 强制触发。
"""
import time
from app.services import alert_store
repo = request.app.state.repo
depth_svc = getattr(request.app.state, "depth_service", None)
engine = getattr(request.app.state, "monitor_engine", None)
quote_svc = getattr(request.app.state, "quote_service", None)
if not depth_svc:
raise HTTPException(status_code=503, detail="depth 服务未初始化")
if not engine or not engine.has_rule_type("ladder"):
raise HTTPException(status_code=400, detail="无 ladder 类型监控规则")
latest = repo.enriched_latest_date()
if not latest:
raise HTTPException(status_code=400, detail="无 enriched 数据")
# 取封单
sealed: dict[str, int] = {}
for is_down in (False, True):
m = depth_svc.get_sealed_map(latest, is_down=is_down)
for sym, info in m.items():
vol = (info or {}).get("vol")
if vol and vol > 0:
sealed[sym] = vol
if not sealed:
raise HTTPException(status_code=400, detail="无封单数据")
# 构造真实 rule_events (与 _evaluate_ladder 产出格式一致)
import polars as pl
enriched_today, _ = repo.get_enriched_latest()
cols = [c for c in ["symbol", "close", "change_pct"] if c in enriched_today.columns]
mock = enriched_today.select(cols).filter(pl.col("symbol").is_in(list(sealed.keys())))
sealed_df = pl.DataFrame({"symbol": list(sealed.keys()), "_sealed_vol": list(sealed.values())})
mock = mock.join(sealed_df, on="symbol", how="inner")
now = time.time()
rule_events: list[dict] = []
name_map = {}
try:
inst = repo.get_instruments()
if not inst.is_empty() and "name" in inst.columns:
name_map = {r["symbol"]: r["name"] for r in inst.select(["symbol", "name"]).iter_rows(named=True) if r.get("name")}
except Exception: # noqa: BLE001
pass
for rule in engine.rules.values():
if rule.get("type") != "ladder" or not rule.get("enabled", True):
continue
sym = rule.get("symbols", [""])[0] if rule.get("symbols") else ""
metric = rule.get("metric", "sealed_vol")
thr = rule.get("threshold", 0)
direction = rule.get("direction", "up")
warn_label = "炸板预警" if direction == "up" else "翘板预警"
row = mock.filter(pl.col("symbol") == sym)
if row.is_empty():
continue
cur_vol = row["_sealed_vol"][0]
close_v = row["close"][0] if "close" in row.columns else None
cur_val = cur_vol * 100 * close_v if metric == "sealed_amount" else cur_vol
if not cur_val or cur_val <= 0 or cur_val > thr:
continue # 不满足条件, 跳过
if metric == "sealed_amount":
sv_text = f"{cur_val / 1e4:.0f}万元"
th_text = f"{thr / 1e4:.0f}万元"
else:
sv_text = f"{cur_val:,.0f}"
th_text = f"{thr:,.0f}"
rule_events.append({
"ts": int(now * 1000),
"rule_id": rule["id"],
"rule_name": rule.get("name", ""),
"source": "ladder",
"type": warn_label,
"symbol": sym,
"name": name_map.get(sym, sym),
"message": f"{warn_label} · 封单 {sv_text}{th_text}",
"price": close_v,
"change_pct": row["change_pct"][0] if "change_pct" in row.columns else None,
"signals": [],
"severity": rule.get("severity", "warn"),
"conditions": [],
"logic": "and",
"sealed_value": cur_val,
"sealed_metric": metric,
})
if not rule_events:
raise HTTPException(status_code=400, detail="当前无 ladder 规则满足触发条件 (封单均 > 阈值)")
# 1. 落盘到 alerts.jsonl
try:
alert_store.append_many(repo.store.data_dir, rule_events)
except Exception as e: # noqa: BLE001
pass # 落盘失败不阻断推送
# 2. SSE 推送 (入 pending_alerts 队列)
if quote_svc:
sse_alerts = [{
"source": ev["source"], "type": ev["type"], "rule_id": ev["rule_id"],
"strategy_id": None, "symbol": ev["symbol"], "name": ev["name"],
"message": ev["message"], "price": ev["price"], "change_pct": ev["change_pct"],
"signals": ev["signals"], "severity": ev["severity"],
"conditions": ev["conditions"], "logic": ev["logic"],
} for ev in rule_events]
try:
with quote_svc._lock:
quote_svc._pending_alerts.extend(sse_alerts)
quote_svc._alert_event.set()
except Exception: # noqa: BLE001
pass
# 3. 飞书推送
if quote_svc:
try:
quote_svc._maybe_send_webhook(rule_events, engine)
except Exception: # noqa: BLE001
pass
return {
"ok": True,
"triggered": len(rule_events),
"events": [{"symbol": ev["symbol"], "name": ev["name"], "message": ev["message"]} for ev in rule_events],
}
+372
View File
@@ -0,0 +1,372 @@
"""市场总览聚合 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:
"""装配市场总览(委托给 services.market_overview_builder,保持行为一致)。
逻辑已抽离至 build_market_overview,以解耦对 Request 的依赖,
使大盘复盘等无 Request 的调用方可复用同一装配逻辑。
"""
from app.services.market_overview_builder import build_market_overview
return build_market_overview(
repo=request.app.state.repo,
quote_service=getattr(request.app.state, "quote_service", None),
depth_service=getattr(request.app.state, "depth_service", None),
as_of=as_of,
)
@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
+107
View File
@@ -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),
}
+40
View File
@@ -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(),
}
+67
View File
@@ -0,0 +1,67 @@
"""涨幅轮动矩阵 API。
供「概念分析 → 涨幅RPS轮动」对话框调用。返回最近 N 个交易日的概念涨幅
排名矩阵:每列(日期)各自把所有概念按当天涨幅从高到低排序。
"""
from __future__ import annotations
from fastapi import APIRouter, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.services import rps_rotation
from app.services.concept_rotation_analyzer import analyze_rotation_stream
router = APIRouter(prefix="/api/rps", tags=["rps"])
@router.get("/rotation")
def get_rotation(
request: Request,
days: int = Query(12, ge=7, le=30, description="最近 N 个交易日(7-30)"),
) -> dict:
"""概念涨幅轮动矩阵。
Returns:
dates: 日期字符串列表(最新在最前)
columns: {日期: [[概念名, 涨幅小数], ...]} 每列各自降序
concept_count: 去重概念总数
"""
return rps_rotation.build_rps_rotation(request.app.state.repo, days)
class AnalyzeRequest(BaseModel):
"""AI 概念轮动分析请求。"""
days: int = 12 # 分析最近 N 个交易日
focus: str = "" # 用户追加的关注点
@router.post("/rotation-analyze")
async def analyze_rotation(request: Request, req: AnalyzeRequest):
"""AI 概念轮动分析 — NDJSON 流式返回。
装配轮动矩阵信号 + 大盘背景 → 分析提示词 → 流式调用 LLM →
逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。
协议:
{"type":"meta","days","summary"}
{"type":"delta","content":"..."}
{"type":"error","message":"..."}
{"type":"done"}
"""
repo = request.app.state.repo
quote_service = getattr(request.app.state, "quote_service", None)
depth_service = getattr(request.app.state, "depth_service", None)
days = max(7, min(30, req.days))
async def stream_gen():
async for chunk in analyze_rotation_stream(
repo, days, req.focus, quote_service, depth_service,
):
yield chunk + "\n"
return StreamingResponse(
stream_gen(),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
+730
View File
@@ -0,0 +1,730 @@
"""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"),
):
"""读取策略结果缓存, 并叠加监控引擎本轮实时算出的结果。
- 盘后缓存 (strategy_cache.json): 非监控策略 / 页面秒加载用, run_all 写入。
- 监控引擎内存结果 (latest_strategy_results): 实时行情每轮对「加入监控的策略」算出,
不落盘 (避免与 read_cache 的 mtime 校验冲突), 在此直接叠加覆盖盘后结果。
被监控的策略拿到新鲜数据, 非监控策略仍用盘后缓存。
"""
data_dir = request.app.state.repo.store.data_dir
cached = strategy_cache.read_cache(data_dir)
if cached is None:
cached = {"as_of": None, "results": {}, "updated_at": None}
# 叠加监控引擎内存里的实时结果 (若有), 用新鲜数据覆盖同策略的盘后结果
monitor_engine = getattr(request.app.state, "monitor_engine", None)
if monitor_engine is not None:
realtime_results = monitor_engine.latest_strategy_results()
if realtime_results:
results = dict(cached.get("results") or {})
results.update(realtime_results)
cached = dict(cached)
cached["results"] = results
# 有实时数据时, 以最新时间戳为准
import time as _time
cached["updated_at"] = int(_time.time() * 1000)
# 无任何数据 (盘后缓存空 + 无实时结果) → 返回空标记, 前端据此提示
if not cached.get("results") and cached.get("as_of") 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
File diff suppressed because it is too large Load Diff
+100
View File
@@ -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}
+217
View File
@@ -0,0 +1,217 @@
"""个股分析 API — 关键价位 + AI 四维分析 + 报告持久化。
路由前缀: /api/stock-analysis
端点:
GET /levels?symbol= 11 类关键价位(图表 markLine 数据源)
POST /analyze AI 流式四维分析(NDJSON)
GET /reports 历史报告列表
POST /reports 保存一条报告
DELETE /reports/{report_id} 删除一条报告
"""
from __future__ import annotations
import logging
import math
from datetime import date, timedelta
import polars as pl
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.indicators.levels import compute_levels, summarize_levels
from app.services import stock_reports
from app.services.stock_analyzer import analyze_stock_stream
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/stock-analysis", tags=["stock-analysis"])
def _to_float_list(series: pl.Series) -> list:
"""polars Series → JSON 安全的 float 列表(null/NaN → None)。"""
out: list = []
for v in series.to_list():
if v is None:
out.append(None)
continue
try:
f = float(v)
out.append(round(f, 2) if math.isfinite(f) else None)
except (TypeError, ValueError):
out.append(None)
return out
def _build_series(df: pl.DataFrame) -> dict:
"""提取带状指标(布林带 / Keltner通道 / ATR止损)的每日时间序列。
这些指标的本质是"每日一条线",随 MA/ATR/σ 漂移,画成曲线才能体现通道形态。
其余固定价位(枢轴/前高前低等)不在此,仍用水平 markLine。
返回结构(每个 value 都是按日期对齐的数组):
{
"boll": {"upper": [...], "lower": [...]},
"keltner_s": {"upper": [...], "lower": [...]}, # 短期 MA20±2ATR
"keltner_m": {"upper": [...], "lower": [...]}, # 中期 MA60±2.5ATR
"keltner_l": {"upper": [...], "lower": [...]}, # 长期 MA120±3ATR
"atr": {"stop_loss": [...], "take_profit": [...]}, # close∓2ATR
}
"""
if df.is_empty() or "close" not in df.columns:
return {}
out: dict[str, dict] = {}
close = df["close"]
has_atr = "atr_14" in df.columns
# 布林带(上/下/中轨;中轨 = MA20,数据层已预计算)
if "boll_upper" in df.columns and "boll_lower" in df.columns:
out["boll"] = {
"upper": _to_float_list(df["boll_upper"]),
"lower": _to_float_list(df["boll_lower"]),
"mid": _to_float_list(df["ma20"]) if "ma20" in df.columns else None,
}
# Keltner 通道三档(需要 ATR)
if has_atr:
atr = df["atr_14"]
# MA120 现场算(不在预计算列中)
ma120 = df.select(pl.col("close").rolling_mean(120))["close"] if df.height >= 120 else None
def _channel(ma: pl.Series, n: float) -> dict:
return {
"upper": _to_float_list(ma + n * atr),
"lower": _to_float_list(ma - n * atr),
}
if "ma20" in df.columns:
out["keltner_s"] = _channel(df["ma20"], 2.0)
if "ma60" in df.columns:
out["keltner_m"] = _channel(df["ma60"], 2.5)
if ma120 is not None:
out["keltner_l"] = _channel(ma120, 3.0)
# ATR 止损/止盈: close ± 2×ATR(跟随行情漂移的动态止损线)
out["atr"] = {
"stop_loss": _to_float_list(close - 2 * atr),
"take_profit": _to_float_list(close + 2 * atr),
}
return out
@router.get("/levels")
def get_levels(
request: Request,
symbol: str = Query(..., description="标的代码,如 000001.SZ"),
days: int = Query(120, ge=30, le=500, description="计算样本天数"),
):
"""计算 11 类关键价位(成交密集区压力支撑 / 枢轴点 / 前高前低 /
布林带 / Keltner短中长 / ATR止损 / 缺口 / 斐波那契 / 整数关口)。
返回 {levels: {sr, pivot, extreme, boll, keltner_s, keltner_m, keltner_l,
atr_stop, gap, fib, round}, close, summary, dates, series}。
前端按 levels 的 key 渲染开关按钮,逐组显隐 markLine / 曲线。
"""
if not symbol:
raise HTTPException(400, "symbol 不能为空")
repo = request.app.state.repo
end = date.today()
start = end - timedelta(days=days * 2)
df = repo.get_daily(symbol, start, end)
if df.is_empty():
return {"levels": {"sr": [], "pivot": [], "extreme": [],
"boll": [], "keltner_s": [], "keltner_m": [], "keltner_l": [],
"atr_stop": [], "gap": [], "fib": [], "round": []},
"close": None, "summary": "无数据", "symbol": symbol,
"dates": [], "series": {}}
levels = compute_levels(df)
close = float(df.tail(1)["close"][0]) if "close" in df.columns else None
# 日期 + 带状曲线序列(供前端画 Keltner/ATR/布林带曲线)
dates = df["date"].to_list()
series = _build_series(df)
return {
"levels": levels,
"close": close,
"summary": summarize_levels(levels, close),
"symbol": symbol,
"dates": [str(d) for d in dates],
"series": series,
}
class AnalyzeRequest(BaseModel):
"""AI 个股分析请求。"""
symbol: str
focus: str = "" # 可选:用户追加的分析关注点
@router.post("/analyze")
async def analyze_stock(request: Request, req: AnalyzeRequest):
"""AI 个股四维分析 — NDJSON 流式返回。
组合 K 线(技术指标)+ 财务表 + 关键价位 → 实战派提示词 →
流式调用 LLM → 逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。
"""
if not req.symbol:
raise HTTPException(400, "symbol 不能为空")
repo = request.app.state.repo
data_dir = repo.store.data_dir
async def stream_gen():
async for chunk in analyze_stock_stream(repo, data_dir, req.symbol, req.focus):
yield chunk + "\n"
return StreamingResponse(
stream_gen(),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ================================================================
# 报告 CRUD(历史报告持久化)
# ================================================================
class SaveReportRequest(BaseModel):
"""保存一条 AI 个股分析报告。"""
symbol: str
name: str = ""
focus: str = ""
content: str
summary: str = ""
close: float | None = None
levels: dict | None = None
@router.get("/reports")
def list_reports(request: Request):
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。"""
return {"reports": stock_reports.list_reports()}
@router.post("/reports")
def save_report(request: Request, req: SaveReportRequest):
"""保存一条报告。"""
report = stock_reports.save_report({
"symbol": req.symbol,
"name": req.name,
"focus": req.focus,
"content": req.content,
"summary": req.summary,
"close": req.close,
"levels": req.levels,
})
return {"ok": True, "report": report}
@router.delete("/reports/{report_id}")
def delete_report(request: Request, report_id: str):
"""删除一条报告。"""
ok = stock_reports.delete_report(report_id)
return {"ok": ok}
+441
View File
@@ -0,0 +1,441 @@
"""策略 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,
"take_profit": getattr(s, "take_profit", None),
"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):
"""Check whether the selected AI provider is configured."""
from app import secrets_store
from app.services.ai_provider import ai_configured, current_ai_model, current_ai_provider
has_key = bool(secrets_store.get_ai_key())
model = current_ai_model()
provider = current_ai_provider()
return {
"configured": ai_configured(provider) and bool(model or provider == "codex_cli"),
"has_key": has_key,
"has_model": bool(model),
"provider": provider,
}
@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):
"""Send a small prompt through the selected AI provider."""
from app.services.ai_provider import current_ai_model, current_ai_provider, generate_ai_text
try:
text = await generate_ai_text(
[{"role": "user", "content": "Reply exactly: OK"}],
temperature=0,
max_tokens=8,
timeout=15,
)
return {"ok": True, "model": current_ai_model() or current_ai_provider(), "response": text[:80]}
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())}
+222
View File
@@ -0,0 +1,222 @@
"""自选股 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 = ""
def _with_names(rows: list[dict], request: Request) -> list[dict]:
if not rows:
return rows
try:
df_i = request.app.state.repo.get_instruments()
if df_i.is_empty() or "symbol" not in df_i.columns or "name" not in df_i.columns:
return rows
name_by_symbol = dict(df_i.select(["symbol", "name"]).iter_rows())
return [{**row, "name": name_by_symbol.get(row.get("symbol"))} for row in rows]
except Exception as e: # noqa: BLE001
logger.debug("attach watchlist names failed: %s", e)
return rows
@router.get("")
def list_all(request: Request):
return {"symbols": _with_names(watchlist.list_symbols(), request)}
@router.post("")
def add_one(req: AddRequest, request: Request):
rows = watchlist.add(req.symbol, req.note)
return {"symbols": _with_names(rows, request)}
@router.post("/batch")
def add_batch(req: BatchAddRequest, request: Request):
for sym in req.symbols:
watchlist.add(sym, req.note)
return {"symbols": _with_names(watchlist.list_symbols(), request), "added": len(req.symbols)}
@router.post("/{symbol}/top")
def move_one_to_top(symbol: str, request: Request):
rows = watchlist.move_to_top(symbol)
return {"symbols": _with_names(rows, request)}
@router.delete("/{symbol}")
def remove_one(symbol: str, request: Request):
rows = watchlist.remove(symbol)
return {"symbols": _with_names(rows, request)}
@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