From d89b9068a7cb15701d17b49b9b63d41c1993b2a9 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Fri, 19 Jun 2026 20:07:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=94=E6=A1=A3=E7=9B=98=E5=8F=A3?= =?UTF-8?q?=E7=9C=9F=E5=81=87=E6=B6=A8=E5=81=9C=E4=BF=AE=E6=AD=A3=20+=20?= =?UTF-8?q?=E8=BF=9E=E6=9D=BF=E6=A2=AF=E9=98=9F=E6=B6=A8=E8=B7=8C=E5=81=9C?= =?UTF-8?q?=E5=88=87=E6=8D=A2=20+=20=E8=87=AA=E5=8A=A8=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 连板梯队: - 新增涨停/跌停方向切换(胶囊式, 涨停红/跌停绿) - 跌停侧三状态: 跌停/翘板/止跌(对称涨停侧) - 涨跌停双计数 + 真假板修正/降级标识(问号弹窗) 五档盘口 sealed(真假涨停): - depth_service 独立旁路线(只读enriched, 不动计算逻辑) - 涨跌停一体(一次depth.batch双向覆盖) - 假涨停归炸板/假跌停归翘板, 真封板显示封单量 - 三层防护节流(套餐clamp+限速clamp+接管通知) - Pro/Expert盘中轮询 + 盘后定版job + 启动补跑 - 收盘job可配置(15:01~18:00, 默认15:02) - 设置页'连板梯队降级修正'卡片(开关+配置+立即修正) - 看板页同步应用sealed修正+降级标识 配置/SDK: - tiers.yaml拆分depth5/depth5.batch, 补全quote.batch - tickflow SDK升级0.1.21→0.1.23(depth.batch可用) - 实时行情轮询间隔下限调整(pro 2s/expert 1s) - capabilities缓存schema版本化(v3) 其他: - 档位配色抽共享组件TierTag/tierStyle - Keys页档位问号弹窗(4档位tag+检测说明) - git hooks自动版本号+0.0.1+打tag --- backend/app/api/overview.py | 20 +- backend/app/api/screener.py | 167 +++++- backend/app/api/settings.py | 98 +++ backend/app/jobs/daily_pipeline.py | 37 +- backend/app/main.py | 18 + backend/app/services/depth_service.py | 560 ++++++++++++++++++ backend/app/services/preferences.py | 52 ++ backend/app/services/quote_service.py | 4 +- backend/app/tickflow/capabilities.py | 1 + backend/app/tickflow/policy.py | 28 +- backend/app/tickflow/repository.py | 4 + backend/pyproject.toml | 4 +- frontend/package.json | 2 +- frontend/src/components/PageHeader.tsx | 7 +- frontend/src/components/SealedBadge.tsx | 143 +++++ .../src/components/data/DepthConfigCard.tsx | 143 +++++ frontend/src/lib/api.ts | 61 +- frontend/src/lib/capability-labels.ts | 30 - frontend/src/lib/capability-labels.tsx | 97 +++ frontend/src/lib/storage.ts | 6 + frontend/src/lib/useQuoteStream.ts | 21 +- frontend/src/pages/Dashboard.tsx | 17 +- frontend/src/pages/LimitUpLadder.tsx | 310 ++++++++-- frontend/src/pages/Settings.tsx | 5 +- frontend/src/pages/settings/Keys.tsx | 74 ++- frontend/src/pages/settings/Monitoring.tsx | 89 ++- scripts/bump_version.py | 79 +++ tiers.yaml | 17 +- 28 files changed, 1949 insertions(+), 145 deletions(-) create mode 100644 backend/app/services/depth_service.py create mode 100644 frontend/src/components/SealedBadge.tsx create mode 100644 frontend/src/components/data/DepthConfigCard.tsx delete mode 100644 frontend/src/lib/capability-labels.ts create mode 100644 frontend/src/lib/capability-labels.tsx create mode 100644 scripts/bump_version.py diff --git a/backend/app/api/overview.py b/backend/app/api/overview.py index abb47b4..fa1eebb 100644 --- a/backend/app/api/overview.py +++ b/backend/app/api/overview.py @@ -399,6 +399,24 @@ def _build_overview(request: Request, as_of: date | None = None) -> dict: broken = sum(1 for r in rows if bool(r.get("signal_broken_limit_up"))) limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down"))) max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0) + + # 五档 sealed 修正: 假涨停/假跌停不计入(需 Pro+ depth5.batch 能力) + depth_svc = getattr(request.app.state, "depth_service", None) + sealed_ready = False + fake_up = 0 + fake_down = 0 + if depth_svc: + up_map = depth_svc.get_sealed_map(as_of, is_down=False) + down_map = depth_svc.get_sealed_map(as_of, is_down=True) + sealed_ready = bool(up_map or down_map) and depth_svc.is_sealed_ready(as_of) + if up_map: + fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False) + if down_map: + fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False) + if sealed_ready: + limit_up = max(0, limit_up - fake_up) + limit_down = max(0, limit_down - fake_down) + seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0 def above_ma_count(ma_key: str) -> int: @@ -496,7 +514,7 @@ def _build_overview(request: Request, as_of: date | None = None) -> dict: }, "amount": {"total": total_amount, "avg": avg_amount}, "boards": boards, - "limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers}, + "limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers, "sealed_ready": sealed_ready, "fake_up": fake_up, "fake_down": fake_down}, "distribution": _pct_band_rows(pct_values), "trend": { "above_ma5": above_ma5, diff --git a/backend/app/api/screener.py b/backend/app/api/screener.py index 9383f5a..a64a837 100644 --- a/backend/app/api/screener.py +++ b/backend/app/api/screener.py @@ -434,17 +434,37 @@ def run_all(request: Request, body: Optional[dict] = None): 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,...}] }] - status: limit_up=涨停 | broken=炸板(摸板未封) | failed=断板(晋级失败) + + 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() @@ -453,17 +473,50 @@ def limit_ladder( df = svc._load_enriched_for_date(as_of) if df.is_empty(): - return {"as_of": str(as_of), "tiers": []} + return {"as_of": str(as_of), "tiers": [], "counts": {"up": 0, "down": 0}} - # 加载前一日数据获取 prev consecutive_limit_ups + # 双方向涨跌停计数(不论当前 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 "consecutive_limit_ups" in df_prev.columns: + if not df_prev.is_empty() and consec_col in df_prev.columns: prev_consec = df_prev.select( "symbol", - pl.col("consecutive_limit_ups").alias("prev_consec"), + pl.col(consec_col).alias("prev_consec"), ) break @@ -473,17 +526,17 @@ def limit_ladder( df = df.with_columns(pl.lit(0).cast(pl.UInt32).alias("prev_consec")) # 表达式 - is_limit = pl.col("signal_limit_up").fill_null(False) if "signal_limit_up" in df.columns else pl.lit(False) - is_broken = pl.col("signal_broken_limit_up").fill_null(False) if "signal_broken_limit_up" in df.columns else pl.lit(False) - consec = pl.col("consecutive_limit_ups").fill_null(0) if "consecutive_limit_ups" in df.columns else pl.lit(0) + 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 + # 计算 status + boards (结构涨跌停对称, 仅字段与字面量不同) is_failed = ~is_limit & ~is_broken & (prev_c > 0) df = df.with_columns([ - pl.when(is_limit).then(pl.lit("limit_up")) - .when(is_broken).then(pl.lit("broken")) - .when(is_failed).then(pl.lit("failed")) + 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) @@ -492,6 +545,70 @@ def limit_ladder( 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] = [] @@ -529,11 +646,11 @@ def limit_ladder( pass # 选择输出列 - cols = ["symbol", "name", "change_pct", "boards", "status"] + ext_col_names + 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") == "limit_up").then(0) - status_order = status_order.when(pl.col("status") == "broken").then(1) + # 排序: 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") @@ -554,7 +671,21 @@ def limit_ladder( for n, stocks in sorted(tiers.items(), key=lambda x: -x[0]) ] - return {"as_of": str(as_of), "tiers": tier_list} + 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]]: diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index d3a3386..aa475ee 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -37,6 +37,7 @@ class TickflowKeyIn(BaseModel): def get_settings() -> dict: """返回当前配置概况(Key 脱敏)。""" from app.config import settings + from app.services import preferences key = secrets_store.get_tickflow_key() return { @@ -48,6 +49,8 @@ def get_settings() -> dict: "probe_log": probe_log(), "missing_caps": missing_caps(), "extras_caps": extras_caps(), + # 首次使用引导 + "onboarding_completed": preferences.get_onboarding_completed(), # AI 配置 "ai_provider": secrets_store.get_ai_config("ai_provider", settings.ai_provider), "ai_base_url": secrets_store.get_ai_config("ai_base_url", settings.ai_base_url), @@ -146,6 +149,18 @@ def clear_tickflow_key(request: Request) -> dict: } +@router.post("/onboarding/complete") +def complete_onboarding() -> dict: + """标记首次使用向导完成。 + + 写入 preferences.json,前端守卫据此判断是否需要再次展示向导。 + 跨设备/清缓存安全 —— 状态落在后端文件,不依赖浏览器本地存储。 + """ + from app.services import preferences + done = preferences.set_onboarding_completed(True) + return {"ok": True, "onboarding_completed": done} + + class AiSettingsIn(BaseModel): provider: str = "openai_compat" base_url: str = "" @@ -214,6 +229,9 @@ def get_preferences() -> dict: "nav_order": preferences.get_nav_order(), "nav_hidden": preferences.get_nav_hidden(), "screener_auto_run": preferences.get_screener_auto_run(), + "limit_ladder_monitor_enabled": preferences.get_limit_ladder_monitor_enabled(), + "depth_polling_interval": preferences.get_depth_polling_interval(), + "depth_finalize_time": preferences.get_depth_finalize_time(), } @@ -665,3 +683,83 @@ def update_index_daily_batch_size(req: IndexDailyBatchSizeIn) -> dict: from app.services import preferences size = preferences.set_index_daily_batch_size(req.size) return {"index_daily_batch_size": size} + + +# ── 五档盘口 sealed 配置 ────────────────────────────── + +class LimitLadderMonitorIn(BaseModel): + enabled: bool + + +@router.put("/preferences/limit-ladder-monitor") +def update_limit_ladder_monitor(req: LimitLadderMonitorIn, request: Request) -> dict: + """连板梯队 5 档监控开关。开启→启动 depth 轮询, 关闭→停止。""" + from app.services import preferences + preferences.save({"limit_ladder_monitor_enabled": req.enabled}) + + # 立即应用: 启停 depth 轮询线程 + depth_svc = getattr(request.app.state, "depth_service", None) + if depth_svc: + depth_svc.apply_monitor_toggle(req.enabled) + + return {"limit_ladder_monitor_enabled": req.enabled} + + +@router.post("/preferences/limit-ladder-monitor/run") +def run_limit_ladder_fix(request: Request) -> dict: + """立即手动修正一次真假板(拉取五档盘口 + 更新缓存)。需 Pro+。""" + from app.tickflow.capabilities import Cap + capset = request.app.state.capabilities + capset.require(Cap.DEPTH5_BATCH) # 无能力抛 CapabilityDenied(403) + + depth_svc = getattr(request.app.state, "depth_service", None) + if not depth_svc: + raise HTTPException(status_code=503, detail="depth 服务未初始化") + return depth_svc.run_once() + + +class DepthPollingIntervalIn(BaseModel): + interval: float + + +@router.put("/preferences/depth-polling-interval") +def update_depth_polling_interval(req: DepthPollingIntervalIn, request: Request) -> dict: + """保存五档盘口盘中轮询间隔(秒)。需 Pro+。""" + from app.tickflow.capabilities import Cap + request.app.state.capabilities.require(Cap.DEPTH5_BATCH) + + from app.services import preferences + interval = preferences.set_depth_polling_interval(req.interval) + return {"depth_polling_interval": interval} + + +class DepthFinalizeTimeIn(BaseModel): + hour: int + minute: int + + +@router.put("/preferences/depth-finalize-time") +def update_depth_finalize_time(req: DepthFinalizeTimeIn, request: Request) -> dict: + """保存盘后 sealed 定版时间(范围15:01~18:00)并立即 reschedule。需 Pro+。""" + from app.tickflow.capabilities import Cap + request.app.state.capabilities.require(Cap.DEPTH5_BATCH) + + from app.services import preferences + sched = preferences.set_depth_finalize_time(req.hour, req.minute) + + from apscheduler.triggers.cron import CronTrigger + scheduler = getattr(request.app.state, "scheduler", None) + if scheduler: + scheduler.reschedule_job( + "depth_finalize", + trigger=CronTrigger( + day_of_week="mon-fri", + hour=sched["hour"], + minute=sched["minute"], + timezone="Asia/Shanghai", + ), + ) + logger.info("depth_finalize rescheduled to %02d:%02d mon-fri", sched["hour"], sched["minute"]) + + return sched + diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py index fe8c13c..d22edfc 100644 --- a/backend/app/jobs/daily_pipeline.py +++ b/backend/app/jobs/daily_pipeline.py @@ -493,7 +493,40 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche replace_existing=True, ) + # 盘后: 五档盘口 sealed 定版(时间由偏好决定, 默认15:02, 范围15:01~18:00) + depth_sched = preferences.get_depth_finalize_time() + + def _depth_finalize(): + depth_svc = getattr(_get_app_state(), "depth_service", None) if _get_app_state() else None + if depth_svc: + depth_svc.finalize() + + scheduler.add_job( + _depth_finalize, + trigger=CronTrigger(day_of_week="mon-fri", + hour=depth_sched["hour"], minute=depth_sched["minute"], + timezone="Asia/Shanghai"), + id="depth_finalize", + misfire_grace_time=3600, + replace_existing=True, + ) + scheduler.start() - logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d mon-fri", - inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"]) + logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d, depth@%02d:%02d mon-fri", + inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"], + depth_sched["hour"], depth_sched["minute"]) return scheduler + + +# app_state 延迟引用(start_scheduler 在 lifespan 早期调用, app.state 可能还没就绪) +_app_state_ref = None + + +def set_app_state(app_state) -> None: + """lifespan 注册 app.state 引用, 供 scheduled job 访问 depth_service 等单例。""" + global _app_state_ref + _app_state_ref = app_state + + +def _get_app_state(): + return _app_state_ref diff --git a/backend/app/main.py b/backend/app/main.py index 6bf6110..882de6b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -60,14 +60,29 @@ async def lifespan(app: FastAPI): app.state.strategy_monitor = strategy_monitor qs.set_app_state(app.state) + # 五档盘口 sealed 服务(真假涨停/跌停, 独立旁路线) + from app.services.depth_service import DepthService + depth_service = DepthService() + depth_service.set_repo(repo) + depth_service.set_app_state(app.state) + app.state.depth_service = depth_service + # 启动调度器(若 enriched 数据为空,首次启动可手动 POST /api/pipeline/run) try: + daily_pipeline.set_app_state(app.state) # 供 depth_finalize job 访问 depth_service scheduler = daily_pipeline.start_scheduler(repo, capset) app.state.scheduler = scheduler except Exception as e: # noqa: BLE001 logger.warning("scheduler not started: %s", e) app.state.scheduler = None + # depth sealed: 启动补跑(当天文件不存在) + 盘中轮询(有能力时) + try: + depth_service.boot_check() + depth_service.start_polling() + except Exception as e: # noqa: BLE001 + logger.warning("depth_service init failed: %s", e) + # 扩展数据定时拉取 from app.services.ext_pull import pull_scheduler pull_scheduler.start(store.data_dir) @@ -111,6 +126,9 @@ async def lifespan(app: FastAPI): qs = getattr(app.state, "quote_service", None) if qs: qs.stop() + dsvc = getattr(app.state, "depth_service", None) + if dsvc: + dsvc.stop_polling() logger.info("shutdown") diff --git a/backend/app/services/depth_service.py b/backend/app/services/depth_service.py new file mode 100644 index 0000000..9567e2d --- /dev/null +++ b/backend/app/services/depth_service.py @@ -0,0 +1,560 @@ +"""五档盘口 sealed(真假涨停/跌停) 服务 — 独立旁路线。 + +架构(完全解耦): + - 只读 enriched(拿涨跌停名单), 不写回 enriched(14列不动) + - sealed 存独立 parquet(data/depth5/date=xxx/part.parquet) + - limit_ladder API 查询时 LEFT JOIN(同 ext_columns 机制) + - signal_limit_up 永远是"价格涨停", sealed 是叠加的真假判定层 + +数据流: + 盘中轮询线程(交易时段, 独立 sleep, 不绑行情轮询): + 读 enriched 内存缓存(线程安全) → 涨跌停名单 → tf.depth.batch + → 算 sealed → 更新内存缓存(不落盘) → sealed_ready=True + 盘后定版 job(可配置时间, 默认15:02): + 最后拉一次 → 落盘 depth5 parquet(定版) + +三层防护节流("设过大设上限, 设过小设最小值"): + ① 套餐范围 clamp: Pro 10~120s, Expert 3~300s + ② 限速安全 clamp: safe = 60/((rpm*0.8)/batches), 涨跌停多就自动放慢 + ③ 系统接管通知: 用户设置会超限时, 推 toast 告知已自动调整 +""" +from __future__ import annotations + +import logging +import math +import threading +import time +from datetime import date, datetime, time as dt_time +from pathlib import Path + +import polars as pl + +logger = logging.getLogger(__name__) + + +# 套餐 → (轮询间隔下限s, 上限s) +TIER_INTERVAL_RANGE: dict[str, tuple[float, float]] = { + "pro": (10.0, 120.0), + "expert": (3.0, 300.0), +} +# 兜底: 其他有 DEPTH5_BATCH 的套餐按 pro 范围 +DEFAULT_RANGE = (10.0, 120.0) + +# 限速余量: 只用 rpm 的 80%, 给系统其他 depth 调用留空间 +RPM_MARGIN = 0.8 +# 间隔硬下限/上限(任何套餐) +INTERVAL_HARD_MIN = 10.0 +INTERVAL_HARD_MAX = 300.0 + + +class DepthService: + """五档盘口 sealed 服务 — 单例。""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._running = False + self._thread: threading.Thread | None = None + self._repo = None # 延迟注入(KlineRepository) + self._app_state = None # 延迟注入(FastAPI app.state) + + # 内存缓存: {symbol: SealedEntry} + # SealedEntry = {sealed_up, sealed_down, ask1_vol, bid1_vol, status, fetched_ts} + self._sealed_cache: dict[str, dict] = {} + self._sealed_ready = False + self._sealed_date: date | None = None # sealed 数据对应的交易日(可能是昨天,如休市) + self._sealed_fetched_ts: float = 0.0 # 上次拉取的 perf_counter + self._sealed_fetched_at: float = 0.0 # 上次拉取的 wall-clock 时间戳 + self._persisted_date: date | None = None # 已落盘的日期 + + # 系统接管状态(防通知刷屏) + self._last_taken_over: bool | None = None + self._last_user_interval: float | None = None + + # ================================================================ + # 注入 + # ================================================================ + + def set_repo(self, repo) -> None: + self._repo = repo + + def set_app_state(self, app_state) -> None: + self._app_state = app_state + + # ================================================================ + # 生命周期 + # ================================================================ + + def boot_check(self) -> None: + """启动补跑: 当天 depth5 文件不存在则 finalize 一次; 已存在则恢复内存缓存。""" + if not self._has_capability(): + logger.info("depth sealed: 无 DEPTH5_BATCH 能力, 跳过启动补跑") + return + today = date.today() + if self._persisted_for_date(today): + # parquet 已存在: 恢复内存缓存(避免重启后每次查询都读 parquet) + self._restore_from_parquet(today) + return + logger.info("depth sealed: 启动补跑今天定版") + try: + self.finalize() + except Exception as e: # noqa: BLE001 + logger.warning("depth sealed 启动补跑失败: %s", e) + + def _restore_from_parquet(self, d: date) -> None: + """从 parquet 恢复内存缓存(服务重启后)。""" + if not self._repo: + return + out = self._repo.store.data_dir / "depth5" / f"date={d.isoformat()}" / "part.parquet" + if not out.exists(): + return + try: + df = pl.read_parquet(out) + cache: dict[str, dict] = {} + for row in df.to_dicts(): + sym = row.get("symbol") + if not sym: + continue + cache[sym] = { + "sealed_up": row.get("sealed_up"), + "sealed_down": row.get("sealed_down"), + "ask1_vol": row.get("ask1_vol"), + "bid1_vol": row.get("bid1_vol"), + "status": row.get("status"), + "fetched_ts": row.get("fetched_at"), + } + with self._lock: + self._sealed_cache = cache + self._sealed_ready = True + self._sealed_date = d + self._persisted_date = d + logger.info("depth sealed: 从 parquet 恢复 %d 只 (日期=%s)", len(cache), d) + except Exception as e: # noqa: BLE001 + logger.warning("depth sealed 从 parquet 恢复失败: %s", e) + + def start_polling(self) -> None: + """启动盘中轮询线程(连板梯队监控开启 + 有能力 + 交易时段)。""" + if self._running: + return + if not self._has_capability(): + return + from app.services import preferences + if not preferences.get_limit_ladder_monitor_enabled(): + return + self._running = True + self._thread = threading.Thread(target=self._poll_loop, daemon=True) + self._thread.start() + logger.info("depth sealed 盘中轮询已启动") + + def stop_polling(self) -> None: + """停止盘中轮询线程。""" + self._running = False + if self._thread: + self._thread.join(timeout=10) + self._thread = None + logger.info("depth sealed 盘中轮询已停止") + + def apply_monitor_toggle(self, enabled: bool) -> None: + """连板梯队监控开关切换时调用: 开启→启动轮询, 关闭→停止轮询。""" + if enabled: + self.start_polling() + else: + self.stop_polling() + + def run_once(self) -> dict: + """手动触发一次修正(立即拉取 depth + 更新内存缓存)。 + + 不受监控开关限制 — 用户可随时手动修正一次。 + 返回 {"ok": bool, "count": int, "msg": str} + """ + if not self._has_capability(): + return {"ok": False, "count": 0, "msg": "无五档盘口能力(需 Pro+)"} + try: + self._fetch_and_seal(persist=True) # 落盘, 刷新页面不丢 + with self._lock: + count = len(self._sealed_cache) + return {"ok": True, "count": count, "msg": f"已修正 {count} 只"} + except Exception as e: # noqa: BLE001 + logger.warning("depth run_once 失败: %s", e) + return {"ok": False, "count": 0, "msg": f"修正失败: {e}"} + + # ================================================================ + # 核心拉取 + # ================================================================ + + def _fetch_and_seal(self, persist: bool = False) -> None: + """拉一次 depth.batch, 算 sealed, 更新内存缓存(可选落盘)。 + + persist=True: 盘后定版, 写 depth5 parquet + persist=False: 盘中轮询, 只更新内存缓存 + """ + if not self._repo: + return + + # 只读 enriched 内存缓存(线程安全, 避免和 quote_service 写盘竞态) + enriched, enriched_date = self._repo.get_enriched_latest() + if enriched.is_empty(): + return + + # 筛涨跌停名单(用 fill_null 防止列缺失) + syms_up: list[str] = [] + syms_down: list[str] = [] + if "signal_limit_up" in enriched.columns: + syms_up = enriched.filter( + pl.col("signal_limit_up").fill_null(False) + )["symbol"].to_list() + if "signal_limit_down" in enriched.columns: + syms_down = enriched.filter( + pl.col("signal_limit_down").fill_null(False) + )["symbol"].to_list() + + all_syms = list(dict.fromkeys(syms_up + syms_down)) # 去重保序 + if not all_syms: + logger.debug("depth sealed: 当日无涨跌停股, 跳过") + return + + # 拉 depth(涨跌停一次拉, 按 capset batch 切片) + depth_data = self._call_depth_batch(all_syms) + if not depth_data: + logger.warning("depth sealed: depth.batch 返回空") + return + + up_set = set(syms_up) + down_set = set(syms_down) + now_perf = time.perf_counter() + now_wall = time.time() + + new_cache: dict[str, dict] = {} + for sym, d in depth_data.items(): + ask_vols = d.get("ask_volumes") or [] + bid_vols = d.get("bid_volumes") or [] + ask1 = ask_vols[0] if ask_vols else None + bid1 = bid_vols[0] if bid_vols else None + # depth 返回的 timestamp(毫秒 epoch), 回退到当前 wall-clock + depth_ts = d.get("timestamp") + fetched = (depth_ts / 1000.0) if isinstance(depth_ts, (int, float)) and depth_ts else now_wall + entry = { + # 涨停真封: 涨停价上卖一(主动卖压)为 0 + "sealed_up": (ask1 == 0) if sym in up_set and ask1 is not None else None, + # 跌停真封: 跌停价上买一为 0 + "sealed_down": (bid1 == 0) if sym in down_set and bid1 is not None else None, + "ask1_vol": ask1, + "bid1_vol": bid1, + "status": "limit_down" if sym in down_set and sym not in up_set else "limit_up", + "fetched_ts": fetched, + } + new_cache[sym] = entry + + with self._lock: + self._sealed_cache = new_cache + self._sealed_ready = True + self._sealed_date = enriched_date # 记录数据对应的交易日(可能是昨天,如休市) + self._sealed_fetched_ts = now_perf + self._sealed_fetched_at = now_wall + + logger.info("depth sealed: 拉取 %d 只 (涨停%d/跌停%d) 日期=%s%s", + len(new_cache), len(syms_up), len(syms_down), + enriched_date, " → 落盘" if persist else "") + + if persist and enriched_date: + self._persist(enriched_date) + + def _call_depth_batch(self, symbols: list[str]) -> dict: + """调 tf.depth.batch, 按 capset 的 batch 切片 + 节流。返回 {symbol: MarketDepth}。""" + from app.tickflow.client import get_client + tf = get_client() + + capset = self._get_capset() + lim = capset.limits(__import__("app.tickflow.capabilities", fromlist=["Cap"]).Cap.DEPTH5_BATCH) + batch_size = (lim.batch if lim and lim.batch else 100) + rpm = (lim.rpm if lim and lim.rpm else 30) + # 批间隔 = 60/rpm(匀速) + inter_batch = 60.0 / rpm if rpm > 0 else 2.0 + + result: dict = {} + chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)] + for i, chunk in enumerate(chunks): + if i > 0: + time.sleep(inter_batch) + try: + # SDK 的 batch 内部已按 batch_size 切, 这里再切一层防单请求过大 + data = tf.depth.batch(chunk) + if isinstance(data, dict): + result.update(data) + except Exception as e: # noqa: BLE001 + logger.warning("depth.batch 第 %d 批失败(%d 只): %s", i + 1, len(chunk), e) + # 单批失败不影响其他批 + return result + + def finalize(self) -> None: + """盘后定版: 拉一次 + 落盘。""" + if not self._has_capability(): + return + self._fetch_and_seal(persist=True) + + # ================================================================ + # 落盘 + # ================================================================ + + def _persist(self, today: date) -> None: + """把内存缓存写 depth5/date=今天/part.parquet。""" + with self._lock: + cache = dict(self._sealed_cache) + if not cache: + return + + rows = [] + for sym, e in cache.items(): + rows.append({ + "symbol": sym, + "sealed_up": e.get("sealed_up"), + "sealed_down": e.get("sealed_down"), + "ask1_vol": e.get("ask1_vol"), + "bid1_vol": e.get("bid1_vol"), + "status": e.get("status"), + "fetched_at": e.get("fetched_ts"), + }) + df = pl.DataFrame(rows) + ds = today.isoformat() + out = self._repo.store.data_dir / "depth5" / f"date={ds}" / "part.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + df.write_parquet(out) + self._persisted_date = today + logger.info("depth sealed 落盘: %d 行 → %s", df.height, out) + + def _persisted_for_date(self, d: date) -> bool: + """检查某日 depth5 文件是否已存在。""" + if not self._repo: + return False + out = self._repo.store.data_dir / "depth5" / f"date={d.isoformat()}" / "part.parquet" + return out.exists() + + # ================================================================ + # 查询(供 limit_ladder API 用) + # ================================================================ + + def get_sealed_map(self, target_date: date, is_down: bool) -> dict: + """返回 {symbol: {sealed, vol, ready, age}} 供 JOIN。 + + 优先内存缓存(盘中), 回退 parquet(历史/盘后)。 + sealed: bool | None (None=待确认或降级) + vol: 封单量(int) | None + ready: sealed 数据是否就绪(False→降级标识) + age: 距上次拉取秒数(盘后定版为 None) + """ + # 内存缓存(sealed 数据对应的交易日 = target_date 时才用) + if self._sealed_date and target_date == self._sealed_date and self._sealed_ready and self._sealed_cache: + return self._read_from_memory(is_down) + # parquet(历史或盘后定版) + return self._read_from_parquet(target_date, is_down) + + def _read_from_memory(self, is_down: bool) -> dict: + sealed_key = "sealed_down" if is_down else "sealed_up" + # 封单量: 涨停=买一量(涨停价买单堆积), 跌停=卖一量(跌停价卖单堆积) + vol_key = "ask1_vol" if is_down else "bid1_vol" + now = time.perf_counter() + with self._lock: + cache = dict(self._sealed_cache) + fetched_ts = self._sealed_fetched_ts + age = (now - fetched_ts) if fetched_ts else 0.0 + result = {} + for sym, e in cache.items(): + result[sym] = { + "sealed": e.get(sealed_key), + "vol": e.get(vol_key), + "ready": True, + "age": age, + } + return result + + def _read_from_parquet(self, target_date: date, is_down: bool) -> dict: + if not self._repo: + return {} + out = self._repo.store.data_dir / "depth5" / f"date={target_date.isoformat()}" / "part.parquet" + if not out.exists(): + return {} + try: + df = pl.read_parquet(out) + except Exception as e: # noqa: BLE001 + logger.warning("depth5 parquet 读取失败: %s", e) + return {} + sealed_key = "sealed_down" if is_down else "sealed_up" + # 封单量: 涨停=买一量, 跌停=卖一量 + vol_key = "ask1_vol" if is_down else "bid1_vol" + result = {} + for row in df.to_dicts(): + sym = row.get("symbol") + if not sym: + continue + result[sym] = { + "sealed": row.get(sealed_key), + "vol": row.get(vol_key), + "ready": True, + "age": None, # 盘后定版, 无 age + } + return result + + def is_sealed_ready(self, target_date: date) -> bool: + """sealed 数据是否就绪(供前端降级判定)。""" + # 内存缓存对应的数据日 == 查询日 → 看内存就绪状态 + if self._sealed_date and target_date == self._sealed_date: + return self._sealed_ready + # 其他日期: 有 parquet 就 ready + return self._persisted_for_date(target_date) + + def get_sealed_age(self, target_date: date) -> float | None: + """返回 sealed 数据 age(秒), 盘后定版为 None。""" + if self._sealed_date and target_date == self._sealed_date and self._sealed_ready and self._sealed_fetched_ts: + return time.perf_counter() - self._sealed_fetched_ts + return None + + # ================================================================ + # 盘中轮询线程 + # ================================================================ + + def _poll_loop(self) -> None: + """盘中轮询: 按 capset 自适应间隔拉 depth, 更新内存缓存。""" + while self._running: + try: + if self._is_trading_hours(): + self._poll_once() + else: + logger.debug("depth sealed: 非交易时段, 跳过") + except Exception as e: # noqa: BLE001 + logger.warning("depth sealed 轮询异常: %s", e) + + # 等待下一轮(用 _running 检查保证能及时退出) + interval = self._current_sleep_interval() + waited = 0.0 + while self._running and waited < interval: + time.sleep(0.5) + waited += 0.5 + + def _poll_once(self) -> None: + """单次轮询: 算间隔(三层防护) → 拉取 → 检测系统接管通知。""" + # 数当前涨跌停股 + n = self._count_limit_stocks() + if n == 0: + return + + interval, taken_over, user_interval = self._compute_interval(n) + + # 系统接管通知(状态切换时才推, 防刷屏) + if taken_over and (self._last_taken_over is False or self._last_user_interval != user_interval): + self._notify_takeover(n, user_interval, interval) + self._last_taken_over = taken_over + self._last_user_interval = user_interval + + self._fetch_and_seal(persist=False) + + def _current_sleep_interval(self) -> float: + """计算当前 sleep 间隔(供 _poll_loop 等待用)。""" + n = self._count_limit_stocks() + if n == 0: + return 30.0 # 无涨跌停, 慢轮询 + interval, _, _ = self._compute_interval(n) + return interval + + # ================================================================ + # 三层防护节流 + # ================================================================ + + def _compute_interval(self, n_symbols: int) -> tuple[float, bool, float]: + """三层防护计算实际轮询间隔。 + + 返回 (actual_interval, taken_over, user_interval) + - actual_interval: 实际使用的间隔(秒) + - taken_over: 是否被系统接管(用户设置会超限) + - user_interval: 用户设置(经套餐 clamp 后)的间隔 + """ + from app.services import preferences + from app.tickflow.policy import tier_label + + capset = self._get_capset() + lim = capset.limits(__import__("app.tickflow.capabilities", fromlist=["Cap"]).Cap.DEPTH5_BATCH) + batch_size = (lim.batch if lim and lim.batch else 100) + rpm = (lim.rpm if lim and lim.rpm else 30) + + # ① 套餐范围 clamp + tier = tier_label().split()[0].split("+")[0].strip().lower() + lo, hi = TIER_INTERVAL_RANGE.get(tier, DEFAULT_RANGE) + raw_user = preferences.get_depth_polling_interval() + user_interval = max(lo, min(hi, raw_user)) + + # ② 限速安全 clamp + batches = max(1, math.ceil(n_symbols / batch_size)) + usable_rpm = rpm * RPM_MARGIN + calls_per_min = usable_rpm / batches if batches > 0 else usable_rpm + safe_interval = 60.0 / calls_per_min if calls_per_min > 0 else INTERVAL_HARD_MAX + + # 实际: 取用户设置和安全的较大值 + actual = max(user_interval, safe_interval) + # 硬上下限 + actual = max(INTERVAL_HARD_MIN, min(actual, INTERVAL_HARD_MAX)) + taken_over = safe_interval > user_interval + + return actual, taken_over, user_interval + + def _count_limit_stocks(self) -> int: + """数当前涨跌停股总数(供节流计算)。""" + if not self._repo: + return 0 + enriched, _ = self._repo.get_enriched_latest() + if enriched.is_empty(): + return 0 + n = 0 + if "signal_limit_up" in enriched.columns: + n += enriched.filter(pl.col("signal_limit_up").fill_null(False)).height + if "signal_limit_down" in enriched.columns: + n += enriched.filter(pl.col("signal_limit_down").fill_null(False)).height + return n + + # ================================================================ + # 通知 + # ================================================================ + + def _notify_takeover(self, n_stocks: int, user_interval: float, actual_interval: float) -> None: + """系统接管通知: 复用 quote_service 的 _pending_alerts 通道。""" + if not self._app_state: + return + qs = getattr(self._app_state, "quote_service", None) + if not qs: + return + msg = (f"五档轮询: 当前涨跌停 {n_stocks} 只, 您设置的 {user_interval:.0f} 秒间隔会超限, " + f"系统已自动调整为 {actual_interval:.0f} 秒") + alert = { + "source": "depth", + "type": "takeover", + "message": msg, + } + try: + with qs._lock: + qs._pending_alerts.append(alert) + qs._alert_event.set() + except Exception as e: # noqa: BLE001 + logger.debug("depth 接管通知推送失败: %s", e) + + # ================================================================ + # 工具 + # ================================================================ + + def _has_capability(self) -> bool: + capset = self._get_capset() + from app.tickflow.capabilities import Cap + return capset.has(Cap.DEPTH5_BATCH) + + def _get_capset(self): + """获取当前 capset(优先 app.state, 回退 detect)。""" + if self._app_state: + cs = getattr(self._app_state, "capabilities", None) + if cs: + return cs + from app.tickflow.policy import detect_capabilities + return detect_capabilities() + + @staticmethod + def _is_trading_hours() -> bool: + now = datetime.now() + t = now.time() + morning = dt_time(9, 25) <= t <= dt_time(11, 35) + afternoon = dt_time(12, 55) <= t <= dt_time(15, 5) + return now.weekday() < 5 and (morning or afternoon) diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 29b1714..8f0f84e 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -127,6 +127,45 @@ def set_index_daily_batch_size(size: int) -> int: return size +# ── 五档盘口 sealed(真假涨停) 配置 ────────────────────── + +def get_limit_ladder_monitor_enabled() -> bool: + """连板梯队 5 档监控开关。关闭时 depth 不轮询(连板梯队降级显示)。""" + return load().get("limit_ladder_monitor_enabled", False) + + +def get_depth_polling_interval() -> float: + """depth 盘中轮询间隔(秒)。默认 20(Pro/Expert 都适用)。""" + return float(load().get("depth_polling_interval", 20.0)) + + +def set_depth_polling_interval(interval: float) -> float: + """保存 depth 轮询间隔。套餐范围 clamp 由 depth_service 按档位做。""" + interval = max(1.0, min(600.0, float(interval))) + save({"depth_polling_interval": interval}) + return interval + + +def get_depth_finalize_time() -> dict: + """盘后 sealed 定版时间 {"hour": 15, "minute": 2}。范围 15:01~18:00。""" + d = load().get("depth_finalize_time", {"hour": 15, "minute": 2}) + return {"hour": d.get("hour", 15), "minute": d.get("minute", 2)} + + +def set_depth_finalize_time(hour: int, minute: int) -> dict: + """保存盘后 sealed 定版时间,强制范围 15:01~18:00。""" + h = max(0, min(23, hour)) + m = max(0, min(59, minute)) + # 下限 15:01, 上限 18:00 + if h * 60 + m < 15 * 60 + 1: + h, m = 15, 1 + if h * 60 + m > 18 * 60: + h, m = 18, 0 + save({"depth_finalize_time": {"hour": h, "minute": m}}) + return {"hour": h, "minute": m} + + + # ===== 实时监控 ===== # 页面 SSE 刷新配置: { "watchlist": true, "monitor": true, ... } @@ -248,3 +287,16 @@ def set_screener_result_columns(columns: list[dict]) -> list[dict]: """保存策略结果列表列配置。""" save({"screener_result_columns": columns}) return columns + + +# ===== 首次使用引导 ===== + +def get_onboarding_completed() -> bool: + """是否已完成首次使用向导。默认 False(新用户)。""" + return bool(load().get("onboarding_completed", False)) + + +def set_onboarding_completed(done: bool = True) -> bool: + """标记首次使用向导完成状态。""" + save({"onboarding_completed": bool(done)}) + return bool(done) diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 8d0e731..c61ae66 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -40,8 +40,8 @@ class QuoteService: # 档位 → 最小轮询间隔 (秒) TIER_MIN_INTERVAL = { - "expert": 0.5, - "pro": 1.0, + "expert": 1.0, + "pro": 2.0, "starter": 3.0, } DEFAULT_INTERVAL = 10.0 diff --git a/backend/app/tickflow/capabilities.py b/backend/app/tickflow/capabilities.py index 7bee5e2..633cd46 100644 --- a/backend/app/tickflow/capabilities.py +++ b/backend/app/tickflow/capabilities.py @@ -21,6 +21,7 @@ class Cap(StrEnum): INTRADAY = "intraday" INTRADAY_BATCH = "intraday.batch" DEPTH5 = "depth5" + DEPTH5_BATCH = "depth5.batch" WEBSOCKET = "websocket" FINANCIAL = "financial" ADJ_FACTOR = "adj_factor" diff --git a/backend/app/tickflow/policy.py b/backend/app/tickflow/policy.py index 942f90f..9de3857 100644 --- a/backend/app/tickflow/policy.py +++ b/backend/app/tickflow/policy.py @@ -25,6 +25,12 @@ logger = logging.getLogger(__name__) _CAPSET_CACHE_FILE = "capabilities.json" +# 缓存 schema 版本。capabilities 模型有结构性变更时 bump(如新增/拆分 Cap), +# 旧缓存(无此字段或版本更低)会被判定过期,触发重新探测。 +# v2: 拆分 depth5 → depth5(单只) + depth5.batch(批量) +# v3: 探测补全 quote.batch(此前 tiers.yaml 声明了但 _probe_real 漏探测) +_CACHE_SCHEMA_VERSION = 3 + # 探测用最小代价请求:挑流通性最好的 1 只标的试 _PROBE_SYMBOL = "600000.SH" # 浦发银行,长期不会退市 @@ -149,6 +155,11 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]: lambda: tf.quotes.get(symbols=[_PROBE_SYMBOL], as_dataframe=False), defaults(Cap.QUOTE_BY_SYMBOL)) + # quote.batch — 批量行情(POST /v1/quotes)。用 get_by_symbols 试探。 + try_call(Cap.QUOTE_BATCH, + lambda: tf.quotes.get_by_symbols([_PROBE_SYMBOL], as_dataframe=False), + defaults(Cap.QUOTE_BATCH)) + # quote.pool — 用一个真实存在的 universe id 试探。 # universes.list() 在 Free 也开放,先拿任意一个 universe id 再用 get_by_universes 试。 def _probe_pool(): @@ -190,11 +201,16 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]: lambda: tf.klines.intraday_batch([_PROBE_SYMBOL], count=1, as_dataframe=False), defaults(Cap.INTRADAY_BATCH)) - # depth5 + # depth5 — 按标的查(单只) try_call(Cap.DEPTH5, lambda: tf.depth.get(_PROBE_SYMBOL), defaults(Cap.DEPTH5)) + # depth5.batch — 批量查(SDK 0.1.23+ 提供 depth.batch,对应官方 /v1/depth/batch 端点) + try_call(Cap.DEPTH5_BATCH, + lambda: tf.depth.batch([_PROBE_SYMBOL]), + defaults(Cap.DEPTH5_BATCH)) + # financial — SDK 提供 income / balance_sheet / cash_flow / metrics / shares # 用 metrics 探测(单据最小) try_call(Cap.FINANCIAL, @@ -223,7 +239,11 @@ def detect_capabilities(force: bool = False) -> CapabilitySet: if not force and cache_path.exists(): with cache_path.open(encoding="utf-8") as f: cached = json.load(f) - return _capset_from_json(cached) + # schema 版本校验:旧缓存或缺版本号 → 过期,丢弃后重新探测 + if cached.get("schema_version") == _CACHE_SCHEMA_VERSION: + return _capset_from_json(cached) + logger.info("capabilities 缓存 schema 版本过期(缓存=%s, 当前=%d), 重新探测", + cached.get("schema_version"), _CACHE_SCHEMA_VERSION) tiers = _load_tiers_yaml() if settings.use_free_mode: @@ -257,7 +277,7 @@ def detect_capabilities(force: bool = False) -> CapabilitySet: TIER_SIGNATURES: dict[str, set[Cap]] = { "expert": {Cap.FINANCIAL, Cap.INTRADAY_BATCH, Cap.WEBSOCKET}, "pro": {Cap.KLINE_MINUTE_BATCH, Cap.KLINE_MINUTE_BY_SYMBOL, - Cap.INTRADAY, Cap.DEPTH5}, + Cap.INTRADAY, Cap.DEPTH5, Cap.DEPTH5_BATCH}, "starter": {Cap.QUOTE_BATCH, Cap.KLINE_DAILY_BATCH, Cap.ADJ_FACTOR, Cap.QUOTE_POOL}, # free 不需 signature — 默认兜底 @@ -270,6 +290,7 @@ _CAP_ALIASES: dict[Cap, str] = { Cap.INTRADAY: "分时", Cap.INTRADAY_BATCH: "批量分时", Cap.DEPTH5: "五档", + Cap.DEPTH5_BATCH: "批量五档", Cap.WEBSOCKET: "WS", Cap.FINANCIAL: "财务", Cap.ADJ_FACTOR: "复权", @@ -378,6 +399,7 @@ def _persist( settings.data_dir.mkdir(parents=True, exist_ok=True) cache_path = settings.data_dir / _CAPSET_CACHE_FILE payload = { + "schema_version": _CACHE_SCHEMA_VERSION, "label": label, "capabilities": capset.to_dict(), "probe_log": log or [], diff --git a/backend/app/tickflow/repository.py b/backend/app/tickflow/repository.py index 9c2f630..32853d4 100644 --- a/backend/app/tickflow/repository.py +++ b/backend/app/tickflow/repository.py @@ -50,6 +50,7 @@ class DataStore: "screener_results", "ai_cache", "user_data", + "depth5", ): (self.data_dir / sub).mkdir(parents=True, exist_ok=True) @@ -94,6 +95,9 @@ class DataStore: SELECT * FROM read_parquet('{d}/financials/balance_sheet/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW financials_cash_flow AS SELECT * FROM read_parquet('{d}/financials/cash_flow/*.parquet', union_by_name=true)""", + # 五档盘口 sealed 真假涨停(独立旁路存储,不进 enriched) + f"""CREATE OR REPLACE VIEW depth5 AS + SELECT * FROM read_parquet('{d}/depth5/**/*.parquet', union_by_name=true)""", ] for sql in statements: try: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 068f4b2..f709c87 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tf-stocks-panel-backend" -version = "0.1.0" +version = "0.1.20" description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配" readme = "../README.md" requires-python = ">=3.11" @@ -20,7 +20,7 @@ dependencies = [ "pandas>=2.2", # 仅在 BacktestService 边界使用,见 §7.4 / ADR-19 "fastexcel>=0.10", # Polars 读取 xlsx/xls # TickFlow 官方 SDK - "tickflow[all]>=0.1.21", + "tickflow[all]>=0.1.23", # Scheduling "apscheduler>=3.10", # Config diff --git a/frontend/package.json b/frontend/package.json index 10f423c..287a0c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "tf-stocks-panel-frontend", "private": true, - "version": "0.1.0", + "version": "0.1.20", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx index 17d849e..3b7b6ef 100644 --- a/frontend/src/components/PageHeader.tsx +++ b/frontend/src/components/PageHeader.tsx @@ -3,11 +3,13 @@ import { cn } from '@/lib/cn' interface Props { title: string subtitle?: string + /** 标题右侧、subtitle 之前的额外节点(如状态徽标) */ + titleExtra?: React.ReactNode right?: React.ReactNode className?: string } -export function PageHeader({ title, subtitle, right, className }: Props) { +export function PageHeader({ title, subtitle, titleExtra, right, className }: Props) { return (
-
+

{title}

+ {titleExtra} {subtitle && {subtitle}}
{right} diff --git a/frontend/src/components/SealedBadge.tsx b/frontend/src/components/SealedBadge.tsx new file mode 100644 index 0000000..191ba6d --- /dev/null +++ b/frontend/src/components/SealedBadge.tsx @@ -0,0 +1,143 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { motion, AnimatePresence } from 'framer-motion' +import { HelpCircle } from 'lucide-react' +import { api } from '@/lib/api' +import { toast } from '@/components/Toast' + +/** 单方向(涨停/跌停)的修正明细块 */ +function SealedDirBlock({ title, color, counts, rawTotal }: { + title: string + color: 'bull' | 'bear' + counts?: { real: number; fake: number; pending: number } + rawTotal?: number +}) { + const real = counts?.real ?? 0 + const fake = counts?.fake ?? 0 + // pending 从原始总数推算(后端 pending 含另一方向票, 不可用) + const pending = Math.max(0, (rawTotal ?? 0) - real - fake) + const original = rawTotal ?? (real + fake + pending) + const fixed = real + pending + return ( +
+
+ {title} + + {original} + + {fixed} + +
+
+ 真封 {real} + 假 {fake} + {pending > 0 && ( + 待 {pending} + )} +
+
+ ) +} + +/** 修正/降级 标识 + 问号弹窗(连板梯队/看板共用) */ +export function SealedBadge({ degraded, hasDepth, isHistorical, sealedReady, sealedCountsUp, sealedCountsDown, rawUp, rawDown, invalidateKeys = ['limit-ladder'] }: { + degraded: boolean + hasDepth: boolean + isHistorical: boolean + sealedReady: boolean | undefined + sealedCountsUp?: { real: number; fake: number; pending: number } + sealedCountsDown?: { real: number; fake: number; pending: number } + rawUp?: number + rawDown?: number + /** 修正后要刷新的 queryKey 前缀(默认连板梯队) */ + invalidateKeys?: string[] +}) { + const [showHint, setShowHint] = useState(false) + const navigate = useNavigate() + const qc = useQueryClient() + const runFix = useMutation({ + mutationFn: () => api.runLimitLadderFix(), + onSuccess: (data) => { + toast(data.msg, data.ok ? 'success' : 'error') + if (data.ok) invalidateKeys.forEach(k => qc.invalidateQueries({ queryKey: [k] })) + }, + onError: () => toast('修正请求失败', 'error'), + }) + + // 组装原因文案(仅降级时用) + const reasons: string[] = [] + if (!hasDepth) reasons.push('当前套餐无五档盘口能力(需 Pro+),涨停判定基于收盘价,可能含假涨停') + if (isHistorical) reasons.push('历史日期的盘口快照不可获取,无法判定真假板') + if (hasDepth && !isHistorical && !sealedReady) reasons.push('盘中 sealed 数据尚未就绪,收盘后自动恢复') + + const label = degraded ? '降级' : '修正' + + return ( +
+ + + {showHint && ( + <> +
setShowHint(false)} /> + e.stopPropagation()} + > + {degraded ? ( + <> +
真假涨停判定降级
+ {reasons.map((r, i) => ( +
+ · + {r} +
+ ))} +
+ 真假板判定依赖五档盘口实时快照(卖一/买一量)。Pro+ 套餐的当天数据在收盘后自动恢复。 +
+ + ) : ( + <> +
五档盘口修正结果
+ + +
+ 真封板显示封单量,假涨停/假跌停已归入炸板/翘板视图。{sealedReady && '数据为盘中快照,收盘后自动定版。'} +
+ + )} +
+ {hasDepth && !isHistorical && ( + + )} + +
+
+ + )} + +
+ ) +} diff --git a/frontend/src/components/data/DepthConfigCard.tsx b/frontend/src/components/data/DepthConfigCard.tsx new file mode 100644 index 0000000..a058515 --- /dev/null +++ b/frontend/src/components/data/DepthConfigCard.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { api } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { usePreferences, useCapabilities } from '@/lib/useSharedQueries' +import { isExpertOrAbove } from '@/lib/capability-labels' + +/** + * 五档盘口 sealed(真假涨停) 配置内容(纯内容, 无外框, 由父级 Card 包裹)。 + * + * - 轮询间隔: Pro 10~120s / Expert 3~300s + * - 盘后定版时间: 15:01~18:00, 默认 15:02 + * - disabled 时(监控关闭)输入框禁用 + */ +// 注: 文件名保留 DepthConfigCard.tsx, 导出 DepthConfigContent(纯内容无外框) +export function DepthConfigContent({ disabled }: { disabled?: boolean }) { + const qc = useQueryClient() + const prefs = usePreferences() + const caps = useCapabilities() + + const hasDepth = !!caps.data?.capabilities?.['depth5.batch'] + const tierLabel = caps.data?.label ?? '' + const range = isExpertOrAbove(tierLabel) ? { lo: 3, hi: 300 } : { lo: 10, hi: 120 } + + const interval = prefs.data?.depth_polling_interval ?? 20 + const finalizeTime = prefs.data?.depth_finalize_time ?? { hour: 15, minute: 2 } + + const [intervalInput, setIntervalInput] = useState(String(Math.round(interval))) + const [finalizeHour, setFinalizeHour] = useState(String(finalizeTime.hour)) + const [finalizeMinute, setFinalizeMinute] = useState(String(finalizeTime.minute)) + + useEffect(() => { setIntervalInput(String(Math.round(interval))) }, [interval]) + useEffect(() => { + setFinalizeHour(String(finalizeTime.hour)) + setFinalizeMinute(String(finalizeTime.minute)) + }, [finalizeTime.hour, finalizeTime.minute]) + + const saveInterval = useMutation({ + mutationFn: (v: number) => api.updateDepthPollingInterval(v), + onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }), + }) + const saveFinalize = useMutation({ + mutationFn: ({ hour, minute }: { hour: number; minute: number }) => + api.updateDepthFinalizeTime(hour, minute), + onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }), + }) + + // 无能力: 显示升级提示 + if (!hasDepth) { + return ( +

+ 真假涨停判定依赖五档盘口实时快照,需 Pro 及以上套餐。 + 升级后连板梯队将自动区分真封板(显示封单量)与假涨停(归入炸板)。 +

+ ) + } + + const inputCls = `w-16 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}` + + return ( +
+ {/* 盘中轮询间隔 */} +
+
+
盘中轮询间隔
+
范围 {range.lo}~{range.hi} 秒 · 涨跌停过多时系统自动放慢
+
+
+ setIntervalInput(e.target.value)} + onBlur={() => { + if (disabled) return + let v = Number(intervalInput) + if (!Number.isFinite(v)) v = range.lo + v = Math.max(range.lo, Math.min(range.hi, v)) + saveInterval.mutate(v) + }} + className={inputCls} + /> + +
+
+ + {/* 盘后定版时间 */} +
+
+
盘后定版时间
+
范围 15:01~18:00 · 收盘后拉取最终盘口定版
+
+
+ setFinalizeHour(e.target.value)} + onBlur={() => { + if (disabled) return + let h = Number(finalizeHour) + if (!Number.isFinite(h)) h = 15 + h = Math.max(15, Math.min(18, h)) + let m = Number(finalizeMinute) + if (!Number.isFinite(m)) m = 2 + m = Math.max(0, Math.min(59, m)) + if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 } + if (h * 60 + m > 18 * 60) { h = 18; m = 0 } + saveFinalize.mutate({ hour: h, minute: m }) + }} + className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`} + /> + : + setFinalizeMinute(e.target.value)} + onBlur={() => { + if (disabled) return + let h = Number(finalizeHour) + if (!Number.isFinite(h)) h = 15 + h = Math.max(15, Math.min(18, h)) + let m = Number(finalizeMinute) + if (!Number.isFinite(m)) m = 2 + m = Math.max(0, Math.min(59, m)) + if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 } + if (h * 60 + m > 18 * 60) { h = 18; m = 0 } + saveFinalize.mutate({ hour: h, minute: m }) + }} + className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`} + /> +
+
+
+ ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c4e3895..76fa809 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -247,7 +247,7 @@ export interface OverviewMarket { } amount: { total: number; avg: number } boards: { board: string; count: number; up: number; down: number; up_pct: number; amount: number }[] - limit: { limit_up: number; broken: number; failed: number; limit_down: number; max_boards: number; seal_rate?: number; tiers: { boards: number; count: number }[] } + limit: { limit_up: number; broken: number; failed: number; limit_down: number; max_boards: number; seal_rate?: number; tiers: { boards: number; count: number }[]; sealed_ready?: boolean; fake_up?: number; fake_down?: number } distribution: { label: string; count: number; pct: number }[] trend: { above_ma5: number; above_ma20: number; above_ma60: number; above_ma5_pct: number; above_ma20_pct: number; above_ma60_pct: number; new_high: number; new_low: number } activity: { avg_turnover: number; high_turnover: number; high_vol_ratio: number; vol_ratio: number } @@ -323,9 +323,15 @@ export interface CustomSignalOptions { export interface LimitLadderStock { symbol: string name?: string | null + close?: number | null change_pct?: number | null consecutive_limit_ups?: number | null - status?: 'limit_up' | 'broken' | 'failed' | null + consecutive_limit_downs?: number | null + status?: 'limit_up' | 'broken' | 'failed' | 'limit_down' | 'recovery' | null + /** 五档 sealed: real=真封板, fake=假涨停(已归炸板), pending=待确认, null=降级/无能力 */ + sealed_status?: 'real' | 'fake' | 'pending' | null + /** 封单量(买一/卖一量), 仅真封板有值 */ + sealed_vol?: number | null } export interface LimitLadderTier { @@ -337,6 +343,20 @@ export interface LimitLadderTier { export interface LimitLadderResult { as_of: string tiers: LimitLadderTier[] + /** 双方向涨跌停计数(修正后, 不论当前 direction) */ + counts?: { up: number; down: number } + /** 双方向涨跌停原始计数(修正前, 供弹窗对比) */ + counts_raw?: { up: number; down: number } + /** sealed 数据是否就绪(false→前端显示降级标识) */ + sealed_ready?: boolean + /** sealed 数据 age(秒), null=盘后定版或无数据 */ + sealed_age?: number | null + /** sealed 修正统计: real=真封板, fake=假涨停(归炸板), pending=待确认 */ + sealed_counts?: { real: number; fake: number; pending: number } + /** 涨停侧 sealed 明细 */ + sealed_counts_up?: { real: number; fake: number; pending: number } + /** 跌停侧 sealed 明细 */ + sealed_counts_down?: { real: number; fake: number; pending: number } } // ===== Backtest ===== @@ -475,6 +495,8 @@ export interface SettingsState { probe_log: string[] missing_caps: string[] extras_caps: string[] + // 首次使用引导 + onboarding_completed: boolean // AI 配置 ai_provider: string ai_base_url: string @@ -493,6 +515,9 @@ export interface Preferences { instruments_schedule: { hour: number; minute: number } enriched_batch_size: number index_daily_batch_size: number + limit_ladder_monitor_enabled: boolean + depth_polling_interval: number + depth_finalize_time: { hour: number; minute: number } sse_refresh_pages: Record strategy_monitor_enabled: boolean strategy_monitor_ids: string[] @@ -504,10 +529,10 @@ export interface Preferences { // ===== Strategy Alert ===== export interface StrategyAlertEvent { - source: 'strategy' + source: 'strategy' | 'depth' type: string strategy_id?: string - symbol: string + symbol?: string name?: string | null message: string price?: number | null @@ -528,6 +553,12 @@ export const api = { clearTickflowKey: () => request('/api/settings/tickflow-key', { method: 'DELETE' }), + /** 标记首次使用向导完成(持久化到后端 preferences) */ + completeOnboarding: () => + request<{ ok: boolean; onboarding_completed: boolean }>( + '/api/settings/onboarding/complete', { method: 'POST' }, + ), + /** 保存 AI 配置 */ saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; daily_token_budget?: number }) => request<{ ok: boolean }>('/api/settings/ai', { @@ -598,6 +629,25 @@ export const api = { method: 'PUT', body: JSON.stringify({ hour, minute }), }), + updateDepthPollingInterval: (interval: number) => + request<{ depth_polling_interval: number }>('/api/settings/preferences/depth-polling-interval', { + method: 'PUT', + body: JSON.stringify({ interval }), + }), + updateLimitLadderMonitor: (enabled: boolean) => + request<{ limit_ladder_monitor_enabled: boolean }>('/api/settings/preferences/limit-ladder-monitor', { + method: 'PUT', + body: JSON.stringify({ enabled }), + }), + runLimitLadderFix: () => + request<{ ok: boolean; count: number; msg: string }>('/api/settings/preferences/limit-ladder-monitor/run', { + method: 'POST', + }), + updateDepthFinalizeTime: (hour: number, minute: number) => + request<{ hour: number; minute: number }>('/api/settings/preferences/depth-finalize-time', { + method: 'PUT', + body: JSON.stringify({ hour, minute }), + }), saveNavOrder: (nav_order: string[]) => request<{ nav_order: string[] }>('/api/settings/preferences/nav-order', { method: 'PUT', @@ -787,10 +837,11 @@ export const api = { request<{ as_of: string | null; rows: MarketSnapshotRow[] }>('/api/screener/market-snapshot'), overviewMarket: (asOf?: string) => request(`/api/overview/market${asOf ? `?as_of=${asOf}` : ''}`), - limitLadder: (asOf?: string, extColumns?: string) => { + limitLadder: (asOf?: string, extColumns?: string, direction?: 'up' | 'down') => { const params = new URLSearchParams() if (asOf) params.set('as_of', asOf) if (extColumns) params.set('ext_columns', extColumns) + if (direction === 'down') params.set('direction', 'down') const qs = params.toString() return request( `/api/screener/limit-ladder${qs ? `?${qs}` : ''}`, diff --git a/frontend/src/lib/capability-labels.ts b/frontend/src/lib/capability-labels.ts deleted file mode 100644 index 560ec63..0000000 --- a/frontend/src/lib/capability-labels.ts +++ /dev/null @@ -1,30 +0,0 @@ -// capability 内部名 → 用户能理解的中文标签 -export const CAP_LABELS: Record = { - 'quote.by_symbol': { name: '实时行情(按标的)', hint: '查询单只股票当前价' }, - 'quote.batch': { name: '实时行情(批量)', hint: '一次拿多只股票的价' }, - 'quote.pool': { name: '标的池查询', hint: '按沪深300等池子拿行情' }, - 'kline.daily.by_symbol': { name: '日 K(按标的)', hint: '单只股票历史日 K' }, - 'kline.daily.batch': { name: '日 K(批量)', hint: '一次拿多只股票的日 K — 选股 / 信号扫描 必需' }, - 'kline.minute.by_symbol': { name: '分钟 K(按标的)', hint: '单股 1m/5m/15m/30m/60m K 线' }, - 'kline.minute.batch': { name: '分钟 K(批量)', hint: '多股分钟 K' }, - - 'depth5': { name: '五档盘口', hint: '买卖五档报价' }, - 'websocket': { name: '实时推送(WS)', hint: '免轮询的实时行情订阅' }, - 'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' }, - 'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' }, -} - -// 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。 -// 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。 -export const TIER_RANK: Record = { free: 0, starter: 1, pro: 2, expert: 3 } -export const EXPERT_RANK = TIER_RANK.expert - -export function tierRank(label: string): number { - const base = (label.split(' ')[0] ?? '').split('+')[0].trim().toLowerCase() - return TIER_RANK[base] ?? -1 -} - -export function isExpertOrAbove(label: string): boolean { - return tierRank(label) >= EXPERT_RANK -} - diff --git a/frontend/src/lib/capability-labels.tsx b/frontend/src/lib/capability-labels.tsx new file mode 100644 index 0000000..c764723 --- /dev/null +++ b/frontend/src/lib/capability-labels.tsx @@ -0,0 +1,97 @@ +// capability 内部名 → 用户能理解的中文标签 +export const CAP_LABELS: Record = { + 'quote.by_symbol': { name: '实时行情(按标的)', hint: '查询单只股票当前价' }, + 'quote.batch': { name: '实时行情(批量)', hint: '一次拿多只股票的价' }, + 'quote.pool': { name: '标的池查询', hint: '按沪深300等池子拿行情' }, + 'kline.daily.by_symbol': { name: '日 K(按标的)', hint: '单只股票历史日 K' }, + 'kline.daily.batch': { name: '日 K(批量)', hint: '一次拿多只股票的日 K — 选股 / 信号扫描 必需' }, + 'kline.minute.by_symbol': { name: '分钟 K(按标的)', hint: '单股 1m/5m/15m/30m/60m K 线' }, + 'kline.minute.batch': { name: '分钟 K(批量)', hint: '多股分钟 K' }, + + 'depth5': { name: '五档盘口', hint: '买卖五档报价' }, + 'websocket': { name: '实时推送(WS)', hint: '免轮询的实时行情订阅' }, + 'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' }, + 'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' }, +} + +// 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。 +// 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。 +export const TIER_RANK: Record = { free: 0, starter: 1, pro: 2, expert: 3 } +export const EXPERT_RANK = TIER_RANK.expert + +export function tierRank(label: string): number { + const base = (label.split(' ')[0] ?? '').split('+')[0].trim().toLowerCase() + return TIER_RANK[base] ?? -1 +} + +export function isExpertOrAbove(label: string): boolean { + return tierRank(label) >= EXPERT_RANK +} + +/** 档位完整样式(tag 背景 + 圆点 + 文字渐变), 与左侧菜单 TierBadge 一致 */ +export interface TierStyle { + tagBg: { background: string } + dotStyle: { background: string } + labelTextStyle: { color?: string; background?: string; WebkitBackgroundClip?: string; backgroundClip?: string } + desc: string +} + +const TIER_STYLE: Record = { + free: { + desc: '基础日K · 单股查询', + tagBg: { background: 'rgba(113,113,122,0.3)' }, + dotStyle: { background: '#71717a' }, + labelTextStyle: { color: '#a1a1aa' }, + }, + starter: { + desc: '批量同步 · 行情池', + tagBg: { background: 'rgba(59,130,246,0.2)' }, + dotStyle: { background: '#3b82f6' }, + labelTextStyle: { color: '#60a5fa' }, + }, + pro: { + desc: '分钟K · 实时行情 · 盘口', + tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' }, + dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' }, + labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' }, + }, + expert: { + desc: 'WebSocket · 财务数据', + tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' }, + dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' }, + labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' }, + }, +} + +/** 从档位 label 提取基础档位名(小写): "Expert +" → "expert" */ +export function tierBaseName(label: string): string { + return (label.split(' ')[0] ?? '').split('+')[0].trim().toLowerCase() +} + +/** 返回档位完整样式 */ +export function tierStyle(label: string): TierStyle { + return TIER_STYLE[tierBaseName(label)] ?? TIER_STYLE.free +} + +/** 所有档位(有序, 供档位列表渲染) */ +export const ALL_TIERS = ['free', 'starter', 'pro', 'expert'] as const + +/** 返回档位标签的渐变文字样式(用于大字显示, 如 Keys 页档位) */ +export function tierTextStyle(label: string): { color?: string; background?: string; WebkitBackgroundClip?: string; backgroundClip?: string } { + return tierStyle(label).labelTextStyle +} + +/** 渲染档位 tag(与左侧菜单一致的胶囊样式) */ +export function TierTag({ label, className = '' }: { label: string; className?: string }) { + const t = tierStyle(label) + const base = tierBaseName(label) + return ( + + {base} + + ) +} + diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index cc99729..c5399fe 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -57,6 +57,12 @@ export const storage = { /** 连板梯队 概念/行业 显示开关 */ limitLadderShowExt: kv<{ concept: boolean; industry: boolean }>('limit-ladder-show-ext'), + /** 连板梯队 涨停/跌停 切换方向 */ + limitLadderDirection: kv<'up' | 'down'>('limit-ladder-direction'), + + /** 连板梯队 封单显示模式: vol=按成交量(手), amount=按金额(元) */ + limitLadderSealMode: kv<'vol' | 'amount'>('limit-ladder-seal-mode'), + /** 策略创建草稿(新建专用) */ strategyDraft: kv<{ name: string; description: string; direction: string; style?: string; rules: string; code: string; step: number; strategyId: string } | null>('strategy-draft'), diff --git a/frontend/src/lib/useQuoteStream.ts b/frontend/src/lib/useQuoteStream.ts index e60fe5c..cd09a01 100644 --- a/frontend/src/lib/useQuoteStream.ts +++ b/frontend/src/lib/useQuoteStream.ts @@ -25,16 +25,25 @@ export function useQuoteStream( pagesRef.current = sseRefreshPages const handleAlerts = useCallback((alerts: StrategyAlertEvent[]) => { - if (onAlert) { - onAlert(alerts) - } else { + // depth 系统接管通知: 单独处理, 不走 strategy 回调 + const depthAlerts = alerts.filter(a => a.source === 'depth') + const strategyAlerts = alerts.filter(a => a.source !== 'depth') + + // depth 通知直接 toast(防刷屏: 后端已在状态切换时才推) + for (const a of depthAlerts.slice(0, 1)) { + toast(a.message, 'success') + } + + if (onAlert && strategyAlerts.length > 0) { + onAlert(strategyAlerts) + } else if (strategyAlerts.length > 0) { // 默认: 弹 toast - for (const a of alerts.slice(0, 3)) { + for (const a of strategyAlerts.slice(0, 3)) { const label = a.name ? `${a.symbol} ${a.name}` : a.symbol toast(`[${a.strategy_id}] ${label} — ${a.message}`, 'success') } - if (alerts.length > 3) { - toast(`...以及另外 ${alerts.length - 3} 条告警`, 'success') + if (strategyAlerts.length > 3) { + toast(`...以及另外 ${strategyAlerts.length - 3} 条告警`, 'success') } } }, [onAlert]) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 757d70c..c47cbc1 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -6,7 +6,8 @@ import { DatePicker } from '@/components/DatePicker' import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { fmtBigNum } from '@/lib/format' -import { useDataStatus } from '@/lib/useSharedQueries' +import { useDataStatus, useCapabilities } from '@/lib/useSharedQueries' +import { SealedBadge } from '@/components/SealedBadge' function n(v: number | null | undefined) { return typeof v === 'number' && Number.isFinite(v) ? v : null @@ -59,7 +60,7 @@ function compactCount(v: number | null | undefined) { return x.toFixed(0) } -function SectionTitle({ icon: Icon, title, hint }: { icon: typeof Activity; title: string; hint?: string }) { +function SectionTitle({ icon: Icon, title, hint }: { icon: typeof Activity; title: string; hint?: ReactNode }) { return (
@@ -71,12 +72,12 @@ function SectionTitle({ icon: Icon, title, hint }: { icon: typeof Activity; titl ) } -function KpiCell({ label, value, sub, tone = 'neutral' }: { label: string; value: ReactNode; sub?: string; tone?: 'bull' | 'bear' | 'accent' | 'neutral' }) { +function KpiCell({ label, value, sub, tone = 'neutral' }: { label: ReactNode; value: ReactNode; sub?: string; tone?: 'bull' | 'bear' | 'accent' | 'neutral' }) { const isPlain = typeof value === 'string' || typeof value === 'number' const color = tone === 'bull' ? 'text-bull' : tone === 'bear' ? 'text-bear' : tone === 'accent' ? 'text-accent' : 'text-foreground' return (
-
{label}
+
{label}
{value}
{sub &&
{sub}
}
@@ -337,6 +338,10 @@ export function Dashboard() { placeholderData: (prev) => prev, }) const data = overview.data + const caps = useCapabilities() + const hasDepth = !!caps.data?.capabilities?.['depth5.batch'] + const sealedReady = !!data?.limit?.sealed_ready + const isSealedDegrade = !hasDepth || !sealedReady // 手动刷新: 显示旋转动画; SSE 自动刷新: 静默, 无体感 const handleRefresh = () => { @@ -420,7 +425,7 @@ export function Dashboard() {
{data.breadth.up}/{data.breadth.flat}/{data.breadth.down}} sub={`上涨率 ${data.breadth.up_pct.toFixed(1)}%`} /> {strongUp}/{strongDown}} sub="涨跌 ≥3%" /> - {data.limit.limit_up}/{data.limit.limit_down}} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}%`} /> + 涨停 / 跌停} value={<>{data.limit.limit_up}/{data.limit.limit_down}} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}%`} /> @@ -490,7 +495,7 @@ export function Dashboard() {