feat: 五档盘口真假涨停修正 + 连板梯队涨跌停切换 + 自动版本管理

连板梯队:
- 新增涨停/跌停方向切换(胶囊式, 涨停红/跌停绿)
- 跌停侧三状态: 跌停/翘板/止跌(对称涨停侧)
- 涨跌停双计数 + 真假板修正/降级标识(问号弹窗)

五档盘口 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
This commit is contained in:
shy3130
2026-06-19 20:09:09 +08:00
parent a5a04b4dd8
commit d89b9068a7
28 changed files with 1949 additions and 145 deletions
+19 -1
View File
@@ -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,
+149 -18
View File
@@ -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]]:
+98
View File
@@ -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
+35 -2
View File
@@ -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
+18
View File
@@ -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")
+560
View File
@@ -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)
+52
View File
@@ -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)
+2 -2
View File
@@ -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
+1
View File
@@ -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"
+25 -3
View File
@@ -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 [],
+4
View File
@@ -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:
+2 -2
View File
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tf-stocks-panel-frontend",
"private": true,
"version": "0.1.0",
"version": "0.1.20",
"type": "module",
"scripts": {
"dev": "vite",
+5 -2
View File
@@ -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 (
<header
className={cn(
@@ -15,8 +17,9 @@ export function PageHeader({ title, subtitle, right, className }: Props) {
className,
)}
>
<div className="flex items-baseline gap-2">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold tracking-tight">{title}</h1>
{titleExtra}
{subtitle && <span className="text-xs text-muted">{subtitle}</span>}
</div>
{right}
+143
View File
@@ -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 (
<div className="mb-2 last:mb-0">
<div className={`flex items-center justify-between px-1 py-0.5 rounded bg-${color}/5 mb-1`}>
<span className={`text-[10px] font-medium text-${color}`}>{title}</span>
<span className="tabular-nums text-[10px]">
<span className="text-muted line-through">{original}</span>
<span className="text-muted/50 mx-1"></span>
<span className={`font-bold text-${color}`}>{fixed}</span>
</span>
</div>
<div className="flex gap-3 px-1 text-[10px]">
<span className={`flex items-center gap-0.5 text-${color}`}><span className={`h-1 w-1 rounded-full bg-${color}`} /> {real}</span>
<span className="flex items-center gap-0.5 text-yellow-500"><span className="h-1 w-1 rounded-full bg-yellow-500" /> {fake}</span>
{pending > 0 && (
<span className="flex items-center gap-0.5 text-muted"><span className="h-1 w-1 rounded-full bg-muted" /> {pending}</span>
)}
</div>
</div>
)
}
/** 修正/降级 标识 + 问号弹窗(连板梯队/看板共用) */
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 (
<div className="relative inline-flex items-center">
<button
onClick={() => setShowHint(v => !v)}
className="group inline-flex items-center gap-1 h-5 px-2 rounded-full bg-yellow-500/10 border border-yellow-500/30 cursor-help transition-all hover:bg-yellow-500/20 hover:border-yellow-500/50"
>
<span className="h-1.5 w-1.5 rounded-full bg-yellow-500" />
<span className="text-[10px] font-medium text-yellow-600 dark:text-yellow-500 leading-none">{label}</span>
<HelpCircle className="h-3 w-3 text-yellow-500/70 group-hover:text-yellow-500 transition-colors" />
</button>
<AnimatePresence>
{showHint && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowHint(false)} />
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.95 }}
className="absolute top-full left-0 mt-1 z-50 w-64 bg-surface border border-border rounded-md shadow-xl p-3 text-[11px] text-secondary leading-relaxed"
onClick={e => e.stopPropagation()}
>
{degraded ? (
<>
<div className="font-medium text-foreground mb-1.5"></div>
{reasons.map((r, i) => (
<div key={i} className="flex gap-1 mb-1">
<span className="text-yellow-500 shrink-0">·</span>
<span>{r}</span>
</div>
))}
<div className="mt-1.5 pt-1.5 border-t border-border text-muted">
(/)Pro+
</div>
</>
) : (
<>
<div className="font-medium text-foreground mb-1.5"></div>
<SealedDirBlock title="涨停" color="bull" counts={sealedCountsUp} rawTotal={rawUp} />
<SealedDirBlock title="跌停" color="bear" counts={sealedCountsDown} rawTotal={rawDown} />
<div className="mt-1.5 pt-1.5 border-t border-border text-muted">
,//{sealedReady && '数据为盘中快照,收盘后自动定版。'}
</div>
</>
)}
<div className="mt-2 flex gap-1.5">
{hasDepth && !isHistorical && (
<button
onClick={() => { runFix.mutate(); setShowHint(false) }}
disabled={runFix.isPending}
className="flex-1 px-2 py-1.5 rounded text-[11px] bg-accent/15 text-accent hover:bg-accent/25 transition-colors text-center disabled:opacity-50"
>
{runFix.isPending ? '修正中…' : '立即修正'}
</button>
)}
<button
onClick={() => { setShowHint(false); navigate('/settings?tab=monitoring&highlight=depth-fix') }}
className={`${hasDepth && !isHistorical ? '' : 'w-full'} px-2 py-1.5 rounded text-[11px] bg-elevated text-secondary hover:text-foreground transition-colors text-center`}
>
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
)
}
@@ -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 (
<p className="text-xs text-muted leading-relaxed">
, <span className="text-accent">Pro </span>
()()
</p>
)
}
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 (
<div className="space-y-3">
{/* 盘中轮询间隔 */}
<div className="flex items-center justify-between gap-2">
<div className={disabled ? 'opacity-50' : ''}>
<div className="text-xs text-secondary"></div>
<div className="text-[10px] text-muted"> {range.lo}~{range.hi} · </div>
</div>
<div className="flex items-center gap-1">
<input
type="number"
min={range.lo}
max={range.hi}
value={intervalInput}
disabled={disabled}
onChange={e => 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}
/>
<span className="text-xs text-muted"></span>
</div>
</div>
{/* 盘后定版时间 */}
<div className="flex items-center justify-between gap-2">
<div className={disabled ? 'opacity-50' : ''}>
<div className="text-xs text-secondary"></div>
<div className="text-[10px] text-muted"> 15:01~18:00 · </div>
</div>
<div className="flex items-center gap-1">
<input
type="number"
min={15}
max={18}
value={finalizeHour}
disabled={disabled}
onChange={e => 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' : ''}`}
/>
<span className="text-xs text-muted">:</span>
<input
type="number"
min={0}
max={59}
value={finalizeMinute}
disabled={disabled}
onChange={e => 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' : ''}`}
/>
</div>
</div>
</div>
)
}
+56 -5
View File
@@ -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<string, boolean>
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<any>('/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<OverviewMarket>(`/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<LimitLadderResult>(
`/api/screener/limit-ladder${qs ? `?${qs}` : ''}`,
-30
View File
@@ -1,30 +0,0 @@
// capability 内部名 → 用户能理解的中文标签
export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
'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<string, number> = { 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
}
+97
View File
@@ -0,0 +1,97 @@
// capability 内部名 → 用户能理解的中文标签
export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
'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<string, number> = { 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<string, TierStyle> = {
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 (
<span
className={`inline-flex h-[18px] max-w-[80px] shrink-0 items-center overflow-hidden rounded px-1.5 text-[10px] font-bold font-mono leading-none ${className}`}
style={t.tagBg}
>
<span className="truncate capitalize" style={t.labelTextStyle}>{base}</span>
</span>
)
}
+6
View File
@@ -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'),
+15 -6
View File
@@ -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])
+11 -6
View File
@@ -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 (
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
@@ -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 (
<div className="min-w-0 rounded-lg border border-border bg-surface/80 px-3 py-2">
<div className="truncate text-[11px] text-muted">{label}</div>
<div className="flex items-center gap-1 text-[11px] text-muted">{label}</div>
<div className={`mt-1 truncate font-mono text-lg font-semibold leading-none tabular-nums ${isPlain ? color : 'text-foreground'}`}>{value}</div>
{sub && <div className="mt-1 truncate text-[10px] text-muted">{sub}</div>}
</div>
@@ -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() {
<div className="mb-3 grid grid-cols-6 gap-2">
<KpiCell label="个股涨 / 平 / 跌" value={<><span className="text-bull">{data.breadth.up}</span><span className="text-muted">/</span><span className="text-muted">{data.breadth.flat}</span><span className="text-muted">/</span><span className="text-bear">{data.breadth.down}</span></>} sub={`上涨率 ${data.breadth.up_pct.toFixed(1)}%`} />
<KpiCell label="强势 / 弱势" value={<><span className="text-bull">{strongUp}</span><span className="text-muted">/</span><span className="text-bear">{strongDown}</span></>} sub="涨跌 ≥3%" />
<KpiCell label="涨停 / 跌停" value={<><span className="text-bull">{data.limit.limit_up}</span><span className="text-muted">/</span><span className="text-bear">{data.limit.limit_down}</span></>} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}%`} />
<KpiCell label={<span className="inline-flex items-center gap-1"> / <SealedBadge degraded={isSealedDegrade} hasDepth={hasDepth} isHistorical={false} sealedReady={sealedReady} sealedCountsUp={{ real: data.limit.limit_up, fake: data.limit.fake_up ?? 0, pending: 0 }} sealedCountsDown={{ real: data.limit.limit_down, fake: data.limit.fake_down ?? 0, pending: 0 }} rawUp={data.limit.limit_up + (data.limit.fake_up ?? 0)} rawDown={data.limit.limit_down + (data.limit.fake_down ?? 0)} invalidateKeys={['overview-market', 'limit-ladder']} /></span>} value={<><span className="text-bull">{data.limit.limit_up}</span><span className="text-muted">/</span><span className="text-bear">{data.limit.limit_down}</span></>} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}%`} />
<KpiCell label="最高连板" value={`${data.limit.max_boards || 0}`} sub={`梯队 ${data.limit.tiers.length}`} tone="accent" />
<KpiCell label="成交额" value={fmtBigNum(data.amount.total)} sub={`均额 ${fmtBigNum(data.amount.avg)}`} />
<KpiCell label="换手 / 量比" value={`${fmtPrice(data.activity.avg_turnover, 1)}% / ${fmtPrice(data.activity.vol_ratio, 2)}`} sub={`高换手 ${data.activity.high_turnover} · 放量 ${data.activity.high_vol_ratio}`} tone="accent" />
@@ -490,7 +495,7 @@ export function Dashboard() {
<aside className="min-w-0 space-y-3">
<section className="rounded-card border border-border bg-surface/80 p-3">
<SectionTitle icon={Flame} title="涨停梯队" hint={`涨停 ${data.limit.limit_up}`} />
<SectionTitle icon={Flame} title="涨停梯队" hint={<span className="inline-flex items-center gap-1">{`涨停 ${data.limit.limit_up}`}{isSealedDegrade && <span className="text-[9px] px-1 rounded bg-yellow-500/10 text-yellow-600 dark:text-yellow-500">{hasDepth ? '未修正' : '降级'}</span>}</span>} />
<LadderMini limit={data.limit} />
</section>
<section className="rounded-card border border-border bg-surface/80 p-3">
+254 -56
View File
@@ -10,6 +10,8 @@ import { storage } from '@/lib/storage'
import { fmtPct, priceColorClass } from '@/lib/format'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { useCapabilities } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import type { ExtColumnDisplayConfig } from '@/lib/watchlist-columns'
// ===== Ext 字段配置 =====
@@ -114,6 +116,24 @@ function getExtTags(stock: LimitLadderStock, item?: ExtFieldItem): string[] {
: sliced
}
// ===== 方向(涨停/跌停) =====
type Direction = 'up' | 'down'
/** 格式化封单量(手/股): 大数转万/亿 */
function fmtSealVol(v: number): string {
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'
if (v >= 1e4) return (v / 1e4).toFixed(1) + '万'
return v.toLocaleString()
}
/** 格式化封单额(元): 大数转万/亿 */
function fmtSealAmount(v: number): string {
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'
if (v >= 1e4) return (v / 1e4).toFixed(0) + '万'
return v.toFixed(0)
}
// ===== 板块标识 =====
function boardTag(symbol: string): { label: string; cls: string } | null {
@@ -125,7 +145,7 @@ function boardTag(symbol: string): { label: string; cls: string } | null {
// ===== 状态标识 + 卡片样式 =====
const STATUS_STYLE: Record<string, { bg: string; bar: string; nameCls: string; codeCls: string; badge: string; badgeText: string; cardStyle?: React.CSSProperties }> = {
const STATUS_STYLE: Record<string, { bg: string; bar: string; nameCls: string; codeCls: string; badge: string; badgeText: string | ((d: Direction) => string); cardStyle?: React.CSSProperties; hoverShadow?: string }> = {
limit_up: {
bg: '',
bar: 'border-l-2 border-bull/50',
@@ -137,6 +157,20 @@ const STATUS_STYLE: Record<string, { bg: string; bar: string; nameCls: string; c
background: 'linear-gradient(105deg, hsl(4 60% 45% / 0.14) 0%, hsl(6 50% 30% / 0.09) 40%, hsl(220 15% 12% / 0.0) 100%)',
boxShadow: 'inset 1px 0 0 hsl(4 80% 55% / 0.12), 0 0 10px -4px hsl(4 80% 50% / 0.10)',
},
hoverShadow: 'inset 1px 0 0 hsl(4 80% 55% / 0.30), 0 0 18px -4px hsl(4 80% 50% / 0.28)',
},
limit_down: {
bg: '',
bar: 'border-l-2 border-bear/50',
nameCls: 'text-green-50 text-[13px]',
codeCls: 'text-muted/80',
badge: '',
badgeText: '',
cardStyle: {
background: 'linear-gradient(105deg, hsl(152 60% 45% / 0.14) 0%, hsl(150 50% 30% / 0.09) 40%, hsl(220 15% 12% / 0.0) 100%)',
boxShadow: 'inset 1px 0 0 hsl(152 80% 45% / 0.12), 0 0 10px -4px hsl(152 80% 45% / 0.10)',
},
hoverShadow: 'inset 1px 0 0 hsl(152 80% 45% / 0.30), 0 0 18px -4px hsl(152 80% 45% / 0.28)',
},
broken: {
bg: 'opacity-75',
@@ -144,7 +178,15 @@ const STATUS_STYLE: Record<string, { bg: string; bar: string; nameCls: string; c
nameCls: 'text-foreground/70 text-xs',
codeCls: 'text-muted/60',
badge: 'text-purple-400',
badgeText: '炸',
badgeText: d => d === 'down' ? '撬' : '炸',
},
recovery: {
bg: 'opacity-75',
bar: 'border-l border-purple-400/30',
nameCls: 'text-foreground/70 text-xs',
codeCls: 'text-muted/60',
badge: 'text-purple-400',
badgeText: '撬',
},
failed: {
bg: 'opacity-75',
@@ -152,22 +194,40 @@ const STATUS_STYLE: Record<string, { bg: string; bar: string; nameCls: string; c
nameCls: 'text-foreground/70 text-xs',
codeCls: 'text-muted/60',
badge: 'text-muted/80',
badgeText: '断',
badgeText: d => d === 'down' ? '止' : '断',
},
}
// ===== sealed 降级标识 =====
/** 判定 sealed 是否处于降级状态。
* isHistorical 判定基于"用户选的日期是否早于数据最新日", 而非自然日今天
* (否则休市日/节假日会把最新交易日误判为历史)。
*/
function useSealedDegrade(asOf: string, latestDate: string | undefined, sealedReady: boolean | undefined, sealedCounts?: { real: number; fake: number; pending: number }) {
const { data: caps } = useCapabilities()
const hasDepth = !!caps?.capabilities?.['depth5.batch']
// 历史判定: 用户主动选了早于最新交易日的日期
const isHistorical = !!asOf && !!latestDate && asOf < latestDate
// 降级: 无能力 / 历史日期 / 最新日但 sealed 未就绪
const degraded = !hasDepth || isHistorical || !sealedReady
return { degraded, hasDepth, isHistorical, sealedReady, sealedCounts }
}
// ===== 单只股票卡片 =====
function StockCard({ stock, extFields, onClick }: {
function StockCard({ stock, extFields, direction, sealMode, onClick }: {
stock: LimitLadderStock
extFields: ExtFieldConfig
direction: Direction
sealMode: 'vol' | 'amount'
onClick: () => void
}) {
const code = stock.symbol.replace(/\.BJ$/, '').replace(/\.SZ$/, '').replace(/\.SH$/, '')
const tag = boardTag(stock.symbol)
const status = stock.status || 'limit_up'
const style = STATUS_STYLE[status] || STATUS_STYLE.limit_up
const isLimitUp = status === 'limit_up'
const status = stock.status || (direction === 'down' ? 'limit_down' : 'limit_up')
const style = STATUS_STYLE[status] || STATUS_STYLE[direction === 'down' ? 'limit_down' : 'limit_up']
const isLimitHit = status === 'limit_up' || status === 'limit_down'
const conceptTags = getExtTags(stock, extFields.concept)
const industryTags = getExtTags(stock, extFields.industry)
const isTextConcept = extFields.concept?.display?.displayMode === 'text'
@@ -175,6 +235,11 @@ function StockCard({ stock, extFields, onClick }: {
const conceptLayout = extFields.concept?.display?.tagLayout ?? 'horizontal'
const industryLayout = extFields.industry?.display?.tagLayout ?? 'horizontal'
// 连板数: 按 direction 选字段
const consecNum = direction === 'down' ? stock.consecutive_limit_downs : stock.consecutive_limit_ups
// badgeText 可能是函数(涨跌停共用 status 如 failed/broken)
const badgeText = typeof style.badgeText === 'function' ? style.badgeText(direction) : style.badgeText
const tagCls = 'text-[9px] leading-none px-1 py-px rounded-sm'
const conceptCls = 'text-[10px] leading-none px-1.5 py-0.5 rounded-sm text-orange-200/60 bg-orange-400/[0.05]'
const industryCls = 'text-[10px] leading-none px-1.5 py-0.5 rounded-sm text-sky-300/90 bg-sky-400/10'
@@ -188,8 +253,8 @@ function StockCard({ stock, extFields, onClick }: {
className={`group flex flex-col items-start gap-1 px-2.5 py-2 rounded-md transition-all duration-200 cursor-pointer hover:opacity-100 ${style.bg} ${style.bar}`}
style={style.cardStyle ? { ...style.cardStyle } : undefined}
onMouseEnter={e => {
if (!style.cardStyle) return
e.currentTarget.style.boxShadow = 'inset 1px 0 0 hsl(4 80% 55% / 0.30), 0 0 18px -4px hsl(4 80% 50% / 0.28)'
if (!style.cardStyle || !style.hoverShadow) return
e.currentTarget.style.boxShadow = style.hoverShadow
}}
onMouseLeave={e => {
if (!style.cardStyle) return
@@ -207,17 +272,28 @@ function StockCard({ stock, extFields, onClick }: {
<div className="flex items-center gap-1.5 w-full">
<span className={`${style.codeCls} font-mono text-[10px] tracking-tight`}>{code}</span>
<span className="ml-auto flex items-center gap-1">
{!isLimitUp ? (
{!isLimitHit ? (
<span className={`text-[10px] font-semibold tabular-nums ${priceColorClass(stock.change_pct)}`}>
{fmtPct(stock.change_pct)}
</span>
) : (
) : stock.sealed_status === 'real' && stock.sealed_vol != null ? (
/* 已修正真封板: 右侧显示封单(量或额, 替代连板数)。
sealed_vol 单位是手, 1手=100股, 算金额需 ×100 */
<span className="text-[10px] font-semibold tabular-nums text-accent/80">
{stock.consecutive_limit_ups}
{sealMode === 'amount' && stock.close
? fmtSealAmount(stock.sealed_vol * 100 * stock.close)
: fmtSealVol(stock.sealed_vol)}
</span>
) : stock.sealed_status === 'pending' ? (
<span className="text-[9px] text-yellow-500/60 leading-none"></span>
) : (
/* 未修正: 显示连板数 */
<span className="text-[10px] font-semibold tabular-nums text-accent/80">
{consecNum}
</span>
)}
{style.badgeText && (
<span className={`text-[9px] font-medium ${style.badge}`}>{style.badgeText}</span>
{badgeText && (
<span className={`text-[9px] font-medium ${style.badge}`}>{badgeText}</span>
)}
</span>
</div>
@@ -246,14 +322,24 @@ function StockCard({ stock, extFields, onClick }: {
// ===== 过滤(多选) =====
type FilterKey = 'limit_up' | 'broken' | 'failed' | 'main' | 'chinext' | 'star' | 'bj' | 'st'
type FilterKey = 'limit_up' | 'broken' | 'failed' | 'limit_down' | 'recovery' | 'main' | 'chinext' | 'star' | 'bj' | 'st'
const STATUS_TABS: { key: FilterKey; label: string }[] = [
const STATUS_TABS_UP: { key: FilterKey; label: string }[] = [
{ key: 'limit_up', label: '涨停' },
{ key: 'broken', label: '炸板' },
{ key: 'failed', label: '断板' },
]
const STATUS_TABS_DOWN: { key: FilterKey; label: string }[] = [
{ key: 'limit_down', label: '跌停' },
{ key: 'recovery', label: '翘板' },
{ key: 'failed', label: '止跌' },
]
function statusTabs(direction: Direction) {
return direction === 'down' ? STATUS_TABS_DOWN : STATUS_TABS_UP
}
const BOARD_TABS: { key: FilterKey; label: string }[] = [
{ key: 'main', label: 'A主板' },
{ key: 'chinext', label: '创业板' },
@@ -268,8 +354,12 @@ function matchFilter(stock: LimitLadderStock, key: FilterKey): boolean {
switch (key) {
case 'limit_up':
return stock.status === 'limit_up' || !stock.status
case 'limit_down':
return stock.status === 'limit_down'
case 'broken':
return stock.status === 'broken'
case 'recovery':
return stock.status === 'recovery'
case 'failed':
return stock.status === 'failed'
case 'main':
@@ -285,8 +375,8 @@ function matchFilter(stock: LimitLadderStock, key: FilterKey): boolean {
}
}
function isStatusKey(key: FilterKey): key is 'limit_up' | 'broken' | 'failed' {
return key === 'limit_up' || key === 'broken' || key === 'failed'
function isStatusKey(key: FilterKey): boolean {
return key === 'limit_up' || key === 'limit_down' || key === 'broken' || key === 'recovery' || key === 'failed'
}
function filterTiers(tiers: LimitLadderTier[], keys: Set<FilterKey>, bf?: BrokenFailedConfig): LimitLadderTier[] {
@@ -300,11 +390,14 @@ function filterTiers(tiers: LimitLadderTier[], keys: Set<FilterKey>, bf?: Broken
.map(t => ({
...t,
stocks: t.stocks.filter(s => {
// 炸板/板:先按 boards 阈值过滤
if (s.status === 'broken' && (cfg.brokenMinBoards ?? 0) > 0 && t.boards < (cfg.brokenMinBoards ?? 0)) return false
// 炸板/板:先按 boards 阈值过滤 (broken 涨停侧, recovery 跌停侧共用 broken 配置)
const isBrokenLike = s.status === 'broken' || s.status === 'recovery'
if (isBrokenLike && (cfg.brokenMinBoards ?? 0) > 0 && t.boards < (cfg.brokenMinBoards ?? 0)) return false
// 断板/止跌:按 boards 阈值过滤 (failed 涨跌停两侧共用)
if (s.status === 'failed' && (cfg.failedMinBoards ?? 0) > 0 && t.boards < (cfg.failedMinBoards ?? 0)) return false
// 炸板/板:是否显示
if (s.status === 'broken' && !cfg.brokenShow) return false
// 炸板/板:是否显示
if (isBrokenLike && !cfg.brokenShow) return false
// 断板/止跌:是否显示
if (s.status === 'failed' && !cfg.failedShow) return false
// 状态组 AND 板块组:两组各至少匹配一个
const statusOk = statusKeys.length === 0 || statusKeys.some(k => matchFilter(s, k))
@@ -324,7 +417,7 @@ const DEFAULT_FILTERS = new Set<FilterKey>(['limit_up', 'main', 'chinext', 'star
function loadFilterKeys(): Set<FilterKey> {
const arr = storage.limitLadderBoard.get([])
const allTabs = [...STATUS_TABS, ...BOARD_TABS]
const allTabs = [...STATUS_TABS_UP, ...BOARD_TABS]
const valid = arr.filter((k): k is FilterKey => allTabs.some(t => t.key === k))
return valid.length > 0 ? new Set(valid) : new Set(DEFAULT_FILTERS)
}
@@ -357,31 +450,38 @@ function tierTextCls(n: number): string {
return 'text-muted'
}
function tierLabel(n: number): string {
function tierLabel(n: number, direction: Direction): string {
if (direction === 'down') return n === 1 ? '首跌' : `${n}连跌`
return n === 1 ? '首板' : `${n}`
}
// ===== 梯队总览条 =====
function OverviewBar({ tiers, dateValue, onDateChange, filterKeys, bf }: {
function OverviewBar({ tiers, dateValue, onDateChange, filterKeys, bf, direction }: {
tiers: LimitLadderTier[]
dateValue: string
onDateChange: (v: string) => void
filterKeys: Set<FilterKey>
bf?: BrokenFailedConfig
direction: Direction
}) {
if (tiers.length === 0) return null
const cfg = { ...DEFAULT_BF, ...bf }
const limitUpCounts = tiers.map(t => t.stocks.filter(s => s.status === 'limit_up' || !s.status).length)
const mainStatus = direction === 'down' ? 'limit_down' : 'limit_up'
const brokenStatus = direction === 'down' ? 'recovery' : 'broken'
// 命中数: 涨停/跌停主状态(含无 status 兜底)
const limitUpCounts = tiers.map(t => t.stocks.filter(s => s.status === mainStatus || !s.status).length)
const maxCount = Math.max(...limitUpCounts, 1)
const showBroken = filterKeys.has('broken') && cfg.brokenShow
const showBroken = (filterKeys.has('broken') || filterKeys.has('recovery')) && cfg.brokenShow
const showFailed = filterKeys.has('failed') && cfg.failedShow
const totalBroken = cfg.brokenCount
? tiers.reduce((s, t) => s + t.stocks.filter(st => st.status === 'broken').length, 0)
? tiers.reduce((s, t) => s + t.stocks.filter(st => st.status === brokenStatus).length, 0)
: 0
const totalFailed = cfg.failedCount
? tiers.reduce((s, t) => s + t.stocks.filter(st => st.status === 'failed').length, 0)
: 0
const brokenLabel = direction === 'down' ? '翘板' : '炸板'
const failedLabel = direction === 'down' ? '止跌' : '断板'
return (
<div className="flex items-center gap-4 px-5 py-2">
@@ -390,7 +490,7 @@ function OverviewBar({ tiers, dateValue, onDateChange, filterKeys, bf }: {
const luCount = limitUpCounts[idx]
return (
<div key={t.boards} className="flex items-center gap-1">
<span className={`font-medium ${tierTextCls(t.boards)}`}>{tierLabel(t.boards)}</span>
<span className={`font-medium ${tierTextCls(t.boards)}`}>{tierLabel(t.boards, direction)}</span>
<div
className="h-2 rounded-sm bg-accent/40"
style={{ width: `${Math.max(8, (luCount / maxCount) * 48)}px` }}
@@ -400,10 +500,10 @@ function OverviewBar({ tiers, dateValue, onDateChange, filterKeys, bf }: {
)
})}
{showBroken && totalBroken > 0 && (
<span className="text-purple-400 font-medium"> {totalBroken}</span>
<span className="text-purple-400 font-medium">{brokenLabel} {totalBroken}</span>
)}
{showFailed && totalFailed > 0 && (
<span className="text-yellow-500 font-medium"> {totalFailed}</span>
<span className="text-yellow-500 font-medium">{failedLabel} {totalFailed}</span>
)}
</div>
<div className="ml-auto">
@@ -415,7 +515,7 @@ function OverviewBar({ tiers, dateValue, onDateChange, filterKeys, bf }: {
// ===== 标签统计面板 =====
function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSelect }: {
function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSelect, direction }: {
title: string
tiers: LimitLadderTier[]
extFields: ExtFieldConfig
@@ -423,8 +523,10 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
color: { text: [number, number, number]; bg: [number, number, number] }
selectedTag: { fieldKey: 'concept' | 'industry'; tag: string } | null
onSelect: (sel: { fieldKey: 'concept' | 'industry'; tag: string } | null) => void
direction: Direction
}) {
const [expanded, setExpanded] = useState(false)
const mainStatus = direction === 'down' ? 'limit_down' : 'limit_up'
const stats = useMemo(() => {
const item = extFields[fieldKey]
@@ -432,7 +534,7 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
const counts = new Map<string, number>()
for (const t of tiers) {
for (const s of t.stocks) {
if (s.status && s.status !== 'limit_up') continue
if (s.status && s.status !== mainStatus) continue
const tags = getExtTags(s, item)
for (const tag of tags) {
counts.set(tag, (counts.get(tag) || 0) + 1)
@@ -440,7 +542,7 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
}
}
return [...counts.entries()].sort((a, b) => b[1] - a[1])
}, [tiers, extFields, fieldKey])
}, [tiers, extFields, fieldKey, mainStatus])
if (stats.length === 0) return null
@@ -507,7 +609,7 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
// ===== 梯队分组 =====
function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag }: {
function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, direction, sealMode }: {
tier: LimitLadderTier
defaultOpen: boolean
extFields: ExtFieldConfig
@@ -516,14 +618,20 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
onStockClick: (symbol: string, name?: string) => void
selectedTag: { fieldKey: 'concept' | 'industry'; tag: string } | null
onSelectTag: (sel: { fieldKey: 'concept' | 'industry'; tag: string } | null) => void
direction: Direction
sealMode: 'vol' | 'amount'
}) {
const [open, setOpen] = useState(defaultOpen)
const cfg = { ...DEFAULT_BF, ...bf }
const showBroken = filterKeys.has('broken') && cfg.brokenShow
const mainStatus = direction === 'down' ? 'limit_down' : 'limit_up'
const brokenStatus = direction === 'down' ? 'recovery' : 'broken'
const brokenBadge = direction === 'down' ? '撬' : '炸'
const failedBadge = direction === 'down' ? '止' : '断'
const showBroken = (filterKeys.has('broken') || filterKeys.has('recovery')) && cfg.brokenShow
const showFailed = filterKeys.has('failed') && cfg.failedShow
const luCount = tier.stocks.filter(s => s.status === 'limit_up' || !s.status).length
const brCount = cfg.brokenCount ? tier.stocks.filter(s => s.status === 'broken').length : 0
const luCount = tier.stocks.filter(s => s.status === mainStatus || !s.status).length
const brCount = cfg.brokenCount ? tier.stocks.filter(s => s.status === brokenStatus).length : 0
const faCount = cfg.failedCount ? tier.stocks.filter(s => s.status === 'failed').length : 0
// 分组概念/行业统计
@@ -531,25 +639,25 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
if (!extFields.showConceptGroupStats || !extFields.concept?.field) return []
const counts = new Map<string, number>()
for (const s of tier.stocks) {
if (s.status && s.status !== 'limit_up') continue
if (s.status && s.status !== mainStatus) continue
for (const tag of getExtTags(s, extFields.concept)) {
counts.set(tag, (counts.get(tag) || 0) + 1)
}
}
return [...counts.entries()].sort((a, b) => b[1] - a[1])
}, [tier.stocks, extFields])
}, [tier.stocks, extFields, mainStatus])
const groupIndustryStats = useMemo(() => {
if (!extFields.showIndustryGroupStats || !extFields.industry?.field) return []
const counts = new Map<string, number>()
for (const s of tier.stocks) {
if (s.status && s.status !== 'limit_up') continue
if (s.status && s.status !== mainStatus) continue
for (const tag of getExtTags(s, extFields.industry)) {
counts.set(tag, (counts.get(tag) || 0) + 1)
}
}
return [...counts.entries()].sort((a, b) => b[1] - a[1])
}, [tier.stocks, extFields])
}, [tier.stocks, extFields, mainStatus])
const hasGroupStats = groupConceptStats.length > 0 || groupIndustryStats.length > 0
@@ -565,12 +673,12 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-surface/80 transition-colors"
>
<Flame className={`h-3.5 w-3.5 ${tier.boards >= 5 ? 'text-orange-500' : tier.boards >= 3 ? 'text-yellow-500' : 'text-muted'}`} />
<span className={`text-sm font-bold tabular-nums ${tierTextCls(tier.boards)}`}>{tierLabel(tier.boards)}<span className="text-muted/40 mx-1">·</span>{luCount}</span>
<span className={`text-sm font-bold tabular-nums ${tierTextCls(tier.boards)}`}>{tierLabel(tier.boards, direction)}<span className="text-muted/40 mx-1">·</span>{luCount}</span>
{(showBroken && brCount > 0) || (showFailed && faCount > 0) ? (
<span className="text-[11px] text-muted/60">
{showBroken && brCount > 0 && <span className="text-purple-400">{brCount}</span>}
{showBroken && brCount > 0 && <span className="text-purple-400">{brCount}{brokenBadge}</span>}
{showBroken && brCount > 0 && showFailed && faCount > 0 && <span className="text-muted/40"> · </span>}
{showFailed && faCount > 0 && <span className="text-muted/80">{faCount}</span>}
{showFailed && faCount > 0 && <span className="text-muted/80">{faCount}{failedBadge}</span>}
</span>
) : null}
<ChevronDown
@@ -649,13 +757,19 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
return tags.includes(selectedTag.tag)
})
.sort((a, b) => {
const ord = (s: string) => s === 'limit_up' || !s ? 0 : s === 'broken' ? 1 : 2
const ord = (s: string) => {
if (s === 'limit_up' || s === 'limit_down' || !s) return 0
if (s === 'broken' || s === 'recovery') return 1
return 2
}
return ord(a.status ?? '') - ord(b.status ?? '')
}).map(s => (
<StockCard
key={`${s.symbol}-${s.status}`}
stock={s}
extFields={extFields}
direction={direction}
sealMode={sealMode}
onClick={() => onStockClick(s.symbol, s.name ?? undefined)}
/>
))}
@@ -960,12 +1074,27 @@ function ExtConfigDialog({ fields, onSave, onClose }: {
export function LimitUpLadder() {
const [asOf, setAsOf] = useState('')
const [direction, setDirection] = useState<Direction>(() => storage.limitLadderDirection.get('up'))
const [sealMode, setSealMode] = useState<'vol' | 'amount'>(() => storage.limitLadderSealMode.get('vol'))
const [filterKeys, setFilterKeys] = useState<Set<FilterKey>>(loadFilterKeys)
const [extFields, setExtFields] = useState<ExtFieldConfig>(loadExtFields)
const [showExtConfig, setShowExtConfig] = useState(false)
const [showConcept, setShowConcept] = useState(() => storage.limitLadderShowExt.get({ concept: true, industry: true }).concept)
const [showIndustry, setShowIndustry] = useState(() => storage.limitLadderShowExt.get({ concept: true, industry: true }).industry)
const toggleDirection = useCallback((d: Direction) => {
setDirection(d)
storage.limitLadderDirection.set(d)
// 切换方向时重置状态筛选为该方向默认集(避免涨跌状态键错配)
const defaultKeys = d === 'down'
? ['limit_down', 'main', 'chinext', 'star', 'bj']
: ['limit_up', 'main', 'chinext', 'star', 'bj']
const allTabs = [...statusTabs(d), ...BOARD_TABS]
const valid = defaultKeys.filter(k => allTabs.some(t => t.key === k)) as FilterKey[]
setFilterKeys(new Set(valid))
storage.limitLadderBoard.set(valid)
}, [])
const toggleConcept = useCallback(() => {
setShowConcept(prev => {
const next = !prev
@@ -1010,16 +1139,18 @@ export function LimitUpLadder() {
const extColumnsParam = useMemo(() => buildExtColumnsParam(extFields), [extFields])
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: [QK.limitLadder(asOf || undefined), extColumnsParam],
queryFn: () => api.limitLadder(asOf || undefined, extColumnsParam),
queryKey: [QK.limitLadder(asOf || undefined), extColumnsParam, direction],
queryFn: () => api.limitLadder(asOf || undefined, extColumnsParam, direction),
staleTime: 5 * 60_000,
})
const rawTiers = data?.tiers ?? []
const tiers = filterTiers(rawTiers, filterKeys, extFields.bf)
const totalStocks = tiers.reduce((sum, t) => sum + t.stocks.filter(s => s.status === 'limit_up' || !s.status).length, 0)
const displayDate = data?.as_of ?? asOf
// sealed 降级判定
const sealedDegrade = useSealedDegrade(asOf, data?.as_of, data?.sealed_ready, data?.sealed_counts)
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
@@ -1033,8 +1164,8 @@ export function LimitUpLadder() {
if (!data || rawTiers.length === 0) {
return (
<div className="flex flex-col h-full">
<PageHeader title="连板梯队" />
<EmptyState icon={Flame} title="暂无连板数据" hint="该日期无涨停股或 enriched 数据未就绪" />
<PageHeader title={direction === 'down' ? '连跌梯队' : '连板梯队'} />
<EmptyState icon={Flame} title={direction === 'down' ? '暂无连跌数据' : '暂无连板数据'} hint={direction === 'down' ? '该日期无跌停股或 enriched 数据未就绪' : '该日期无涨停股或 enriched 数据未就绪'} />
</div>
)
}
@@ -1042,12 +1173,75 @@ export function LimitUpLadder() {
return (
<div className="flex flex-col h-full">
<PageHeader
title="连板梯队"
subtitle={`${totalStocks}`}
title={direction === 'down' ? '连跌梯队' : '连板梯队'}
titleExtra={
<div className="flex items-center gap-2">
<SealedBadge
degraded={sealedDegrade.degraded}
hasDepth={sealedDegrade.hasDepth}
isHistorical={sealedDegrade.isHistorical}
sealedReady={sealedDegrade.sealedReady}
sealedCountsUp={data?.sealed_counts_up}
sealedCountsDown={data?.sealed_counts_down}
rawUp={data?.counts_raw?.up}
rawDown={data?.counts_raw?.down}
/>
{/* 涨跌停切换(胶囊式): 点击切换方向, 当前方向有背景 */}
<div className="flex items-center rounded-full bg-elevated/60 p-0.5">
<button
onClick={() => direction !== 'up' && toggleDirection('up')}
className={`flex items-center gap-1 px-2.5 h-7 rounded-full text-xs tabular-nums transition-all ${
direction === 'up'
? 'bg-bull/15 text-bull font-semibold'
: 'text-muted hover:text-bull/70'
}`}
>
<span></span>
<span>{data?.counts?.up ?? 0}</span>
</button>
<button
onClick={() => direction !== 'down' && toggleDirection('down')}
className={`flex items-center gap-1 px-2.5 h-7 rounded-full text-xs tabular-nums transition-all ${
direction === 'down'
? 'bg-bear/15 text-bear font-semibold'
: 'text-muted hover:text-bear/70'
}`}
>
<span></span>
<span>{data?.counts?.down ?? 0}</span>
</button>
</div>
</div>
}
right={
<div className="flex items-center gap-1">
{/* 状态组: 涨停/炸板/断板 */}
{STATUS_TABS.map(tab => (
{/* 封单模式: 成交量/金额(仅 sealed 就绪时显示) — 胶囊式 */}
{data?.sealed_ready && (
<>
<div className="flex items-center rounded-full bg-elevated/60 p-0.5">
{(['vol', 'amount'] as const).map(m => (
<button
key={m}
onClick={() => {
setSealMode(m)
storage.limitLadderSealMode.set(m)
}}
className={`flex items-center px-2 py-1 rounded-full text-xs transition-all ${
sealMode === m
? 'bg-accent/15 text-accent font-medium'
: 'text-muted hover:text-secondary'
}`}
>
{m === 'vol' ? '封单量' : '封单额'}
</button>
))}
</div>
<div className="w-px h-4 bg-border mx-1" />
</>
)}
{/* 状态组: 涨停/炸板/断板 或 跌停/翘板/止跌 */}
{statusTabs(direction).map(tab => (
<button
key={tab.key}
onClick={() => toggleFilter(tab.key)}
@@ -1122,7 +1316,7 @@ export function LimitUpLadder() {
/>
{/* 总览条 + 日期 */}
<OverviewBar tiers={tiers} dateValue={dateValue} onDateChange={setAsOf} filterKeys={filterKeys} bf={extFields.bf} />
<OverviewBar tiers={tiers} dateValue={dateValue} onDateChange={setAsOf} filterKeys={filterKeys} bf={extFields.bf} direction={direction} />
{/* 概念统计 */}
{(extFields.showConceptStats ?? true) && (
@@ -1134,6 +1328,7 @@ export function LimitUpLadder() {
color={{ text: [250, 204, 21], bg: [234, 179, 8] }}
selectedTag={selectedTag}
onSelect={handleSelectTag}
direction={direction}
/>
)}
{/* 行业统计 */}
@@ -1146,6 +1341,7 @@ export function LimitUpLadder() {
color={{ text: [96, 165, 250], bg: [59, 130, 246] }}
selectedTag={selectedTag}
onSelect={handleSelectTag}
direction={direction}
/>
)}
@@ -1162,6 +1358,8 @@ export function LimitUpLadder() {
onStockClick={handleStockClick}
selectedTag={selectedTag}
onSelectTag={handleSelectTag}
direction={direction}
sealMode={sealMode}
/>
))}
</div>
+4 -1
View File
@@ -34,6 +34,7 @@ export function Settings() {
const [searchParams, setSearchParams] = useSearchParams()
const tabParam = searchParams.get('tab') as TabKey | null
const activeTab = TABS.find((t) => t.key === tabParam) ?? TABS[0]
const highlight = searchParams.get('highlight') ?? ''
return (
<>
@@ -73,7 +74,9 @@ export function Settings() {
transition={{ duration: 0.15 }}
className="min-w-0 flex-1"
>
<activeTab.panel />
{activeTab.key === 'monitoring'
? <SettingsMonitoringPanel highlight={highlight} />
: <activeTab.panel />}
</motion.div>
</div>
</div>
+70 -4
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { motion, AnimatePresence } from 'framer-motion'
import {
Key,
Eye,
@@ -15,11 +15,12 @@ import {
Save,
Check,
Copy,
HelpCircle,
} from 'lucide-react'
import { api } from '@/lib/api'
import { useCapabilities, useSettings } from '@/lib/useSharedQueries'
import { QK } from '@/lib/queryKeys'
import { CAP_LABELS } from '@/lib/capability-labels'
import { CAP_LABELS, tierTextStyle, tierStyle, tierBaseName, ALL_TIERS, TierTag } from '@/lib/capability-labels'
// ===== 导出为 Panel 组件 (由 Settings.tsx 嵌入) =====
@@ -224,8 +225,11 @@ export function SettingsKeysPanel() {
>
{caps.data ? (
<>
<div className="font-mono text-3xl font-bold tracking-tight text-foreground">
{caps.data.label}
<div className="flex items-center gap-1.5">
<div className="font-mono text-3xl font-bold tracking-tight" style={tierTextStyle(caps.data.label)}>
{caps.data.label}
</div>
<TierHelpPopover currentLabel={caps.data.label} />
</div>
<div className="mt-1 text-xs text-muted">
API Key · "代表性 capability"
@@ -344,6 +348,68 @@ export function SettingsKeysPanel() {
// ===== 通用卡片 =====
// ===== 档位说明弹窗 =====
function TierHelpPopover({ currentLabel }: { currentLabel: string }) {
const [open, setOpen] = useState(false)
const currentBase = tierBaseName(currentLabel)
return (
<div className="relative inline-flex items-center">
<HelpCircle
className="h-4 w-4 text-muted/60 cursor-help hover:text-muted transition-colors"
onClick={() => setOpen(v => !v)}
/>
<AnimatePresence>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="absolute top-full left-0 mt-1 z-50 w-72 bg-surface border border-border rounded-lg shadow-xl p-3.5 text-[11px] leading-relaxed"
onClick={e => e.stopPropagation()}
>
{/* 4 档位 tag 横排 */}
<div className="flex items-center gap-1.5 mb-3">
{ALL_TIERS.map(t => (
<div key={t} className={`flex flex-col items-center gap-1 ${t === currentBase ? '' : 'opacity-60'}`}>
<TierTag label={t} />
</div>
))}
</div>
{/* 每档说明 */}
<div className="space-y-1 mb-3 pb-3 border-b border-border">
{ALL_TIERS.map(t => {
const s = tierStyle(t)
return (
<div key={t} className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={s.dotStyle} />
<span className="capitalize font-mono font-bold w-12 shrink-0" style={s.labelTextStyle}>{t}</span>
<span className="text-secondary">{s.desc}</span>
</div>
)
})}
</div>
{/* 检测说明 */}
<div className="text-secondary space-y-1.5">
<div className="font-medium text-foreground"></div>
<p> API Key ,"代表性能力"( Expert )</p>
<p className="text-muted">"代表性能力",( Pro + K) + </p>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
)
}
interface CardProps {
icon: React.ComponentType<{ className?: string }>
title: string
+86 -3
View File
@@ -1,10 +1,12 @@
import { useState, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useState, useCallback, useEffect, useRef } from 'react'
import { useQueryClient, useMutation } from '@tanstack/react-query'
import {
Activity,
Shield,
Wifi,
BarChart3,
Flame,
Zap,
Plus,
X,
} from 'lucide-react'
@@ -17,6 +19,8 @@ import {
import { useUpdateQuoteInterval, useToggleRealtimeQuotes } from '@/lib/useSharedMutations'
import { api, type StrategyDetail } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { toast } from '@/components/Toast'
import { DepthConfigContent } from '@/components/data/DepthConfigCard'
// 页面 → 显示名
const PAGE_LABELS: Record<string, string> = {
@@ -34,7 +38,7 @@ const SIDEBAR_INDEX_OPTIONS = [
// ===== 导出为 Panel 组件 (由 Settings.tsx 嵌入) =====
export function SettingsMonitoringPanel() {
export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = {}) {
const qc = useQueryClient()
const { data: prefs } = usePreferences()
const { data: caps } = useCapabilities()
@@ -49,6 +53,8 @@ export function SettingsMonitoringPanel() {
const refreshPages = prefs?.sse_refresh_pages ?? {}
const monitorEnabled = prefs?.strategy_monitor_enabled ?? false
const monitorIds = prefs?.strategy_monitor_ids ?? []
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
const hasDepth = !!caps?.capabilities?.['depth5.batch']
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol)
const indicesPinned = prefs?.indices_nav_pinned ?? true
const isRunning = quoteStatus?.running ?? false
@@ -87,6 +93,36 @@ export function SettingsMonitoringPanel() {
api.updateIndicesNavPinned(pinned).then(() => qc.invalidateQueries({ queryKey: QK.preferences }))
}, [qc])
const toggleLimitLadderMonitor = useCallback(async (enabled: boolean) => {
await api.updateLimitLadderMonitor(enabled)
qc.invalidateQueries({ queryKey: QK.preferences })
}, [qc])
const runFix = useMutation({
mutationFn: () => api.runLimitLadderFix(),
onSuccess: (data) => {
toast(data.msg, data.ok ? 'success' : 'error')
// 修正后连板梯队数据变了, 刷新相关缓存
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
},
onError: () => toast('修正请求失败', 'error'),
})
// highlight=depth-fix 时闪烁高亮连板梯队修正卡片
const [flash, setFlash] = useState(false)
const flashedRef = useRef(false)
useEffect(() => {
if (highlight === 'depth-fix' && !flashedRef.current) {
flashedRef.current = true
// 延迟一帧确保 DOM 已渲染, 再触发闪烁
requestAnimationFrame(() => {
setFlash(true)
const t = setTimeout(() => setFlash(false), 2000)
return () => clearTimeout(t)
})
}
}, [highlight])
// Free 档位 — 显示升级提示
if (isFreeTier) {
return (
@@ -222,6 +258,53 @@ export function SettingsMonitoringPanel() {
/>
</div>
</Card>
{/* 连板梯队降级修正 */}
<div
id="depth-fix"
className={`rounded-card transition-all duration-500 ${flash ? 'ring-2 ring-accent/60 ring-offset-2 ring-offset-base scale-[1.01]' : 'ring-0 ring-transparent'}`}
>
<Card
icon={Flame}
title="连板梯队降级修正"
badge={!hasDepth ? '需 Pro+' : undefined}
right={hasDepth ? (
<button
onClick={() => runFix.mutate()}
disabled={runFix.isPending}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px]
bg-accent/15 text-accent hover:bg-accent/25 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
<Zap className="h-3 w-3" />
{runFix.isPending ? '修正中…' : '立即修正'}
</button>
) : undefined}
>
{hasDepth ? (
<>
<p className="text-xs text-secondary mb-4">
/,(=)
,
</p>
<ToggleRow
label="启用真假板修正"
desc="开启后盘中自动拉取五档盘口修正真假板"
checked={limitLadderMonitor}
onChange={toggleLimitLadderMonitor}
/>
<div className="mt-4 pt-3 border-t border-border">
<div className="text-[10px] uppercase tracking-widest text-muted mb-3">
</div>
<DepthConfigContent disabled={!limitLadderMonitor} />
</div>
</>
) : (
<DepthConfigContent disabled />
)}
</Card>
</div>
</div>
</div>
)
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""版本号自增脚本 — 由 git prepare-commit-msg hook 调用。
读取当前版本, +0.0.1, 写回 frontend/package.json 和 backend/pyproject.toml。
跳过条件(避免 merge/rebase/amend 误触发):
- 非 master/main 分支(可选, 当前不限制)
- commit message 以 merge/squash/rebase 开头
- 环境变量 SKIP_VERSION_BUMP=1
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PKG = ROOT / "frontend" / "package.json"
PYPROJECT = ROOT / "backend" / "pyproject.toml"
def parse_version(v: str) -> tuple[int, int, int]:
parts = v.strip().split(".")
while len(parts) < 3:
parts.append("0")
return int(parts[0]), int(parts[1]), int(parts[2])
def bump(v: str) -> str:
major, minor, patch = parse_version(v)
return f"{major}.{minor}.{patch + 1}"
def read_pkg_version() -> str:
data = json.loads(PKG.read_text(encoding="utf-8"))
return data["version"]
def write_pkg_version(v: str) -> None:
data = json.loads(PKG.read_text(encoding="utf-8"))
data["version"] = v
PKG.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def read_pyproject_version() -> str:
text = PYPROJECT.read_text(encoding="utf-8")
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
return m.group(1) if m else "0.0.0"
def write_pyproject_version(v: str) -> None:
text = PYPROJECT.read_text(encoding="utf-8")
text = re.sub(r'^version\s*=\s*"[^"]+"', f'version = "{v}"', text, count=1, flags=re.MULTILINE)
PYPROJECT.write_text(text, encoding="utf-8")
def main() -> int:
# 环境变量跳过
if __import__("os").environ.get("SKIP_VERSION_BUMP") == "1":
return 0
cur = read_pkg_version()
new = bump(cur)
# 两个文件版本可能不一致, 统一用 pkg 的为准
write_pkg_version(new)
write_pyproject_version(new)
# 暂存版本文件改动(并入本次 commit)
import subprocess
subprocess.run(["git", "add", str(PKG), str(PYPROJECT)], check=True, cwd=str(ROOT))
# 输出新版本号供 hook 读用
print(f"[bump] {cur} -> {new}")
return 0
if __name__ == "__main__":
sys.exit(main())
+14 -3
View File
@@ -6,8 +6,14 @@
# 3. 加购建议时的价格预估
#
# 业务代码永远不读这张表,只读运行时探测出的 CapabilitySet。
# 来源:https://tickflow.org/pricing/ (2026-05-21 抓取)
# 来源:https://tickflow.org/pricing/
# 频率单位:次/分钟。batch 单位:标的/次。
#
# 说明(2026-06-19 核对):
# pricing 页为前端动态渲染,自动抓取只能拿到 FAQ 文字,无法取得精确数值表。
# 下表数值除明确标注「推断」的条目外,均沿用 2026-05-21 旧版抓取。
# FAQ 印证的能力归属:免费=行情+日K;Starter+=标的池+批量;Pro+=分钟K;Expert+=财务+WS。
# 标注「推断」的条目按 tiers.yaml 既有 batch 类接口规律推得,建议后续用真实套餐核对。
free:
quote.by_symbol: { rpm: 10, batch: 5 }
@@ -15,6 +21,7 @@ free:
starter:
quote.by_symbol: { rpm: 60, batch: 50 }
quote.batch: { rpm: 60, batch: 50 } # [新增-推断] 批量行情(同 by_symbol 限速,FAQ:Starter+ 含批量)
quote.pool: { rpm: 20 }
kline.daily.batch: { rpm: 30, batch: 100 }
kline.daily.by_symbol: { rpm: 60, batch: 1 }
@@ -22,17 +29,20 @@ starter:
pro:
quote.by_symbol: { rpm: 120, batch: 100 }
quote.batch: { rpm: 120, batch: 100 } # [新增-推断] 批量行情(同 by_symbol 限速)
quote.pool: { rpm: 60 }
kline.daily.batch: { rpm: 60, batch: 100 }
kline.daily.by_symbol: { rpm: 120, batch: 1 }
kline.minute.batch: { rpm: 30, batch: 100 }
kline.minute.by_symbol: { rpm: 60, batch: 1 }
intraday: { rpm: 30, batch: 1 }
depth5: { rpm: 60, batch: 1 }
depth5: { rpm: 60, batch: 1 } # 按标的查(单只):官方 60rpm/1
depth5.batch: { rpm: 30, batch: 100 } # 批量查(新增):官方 30rpm/100
adj_factor: { rpm: 60, batch: 100 }
expert:
quote.by_symbol: { rpm: 300, batch: 500 }
quote.batch: { rpm: 300, batch: 500 } # [新增-推断] 批量行情(同 by_symbol 限速)
quote.pool: { rpm: 120 }
kline.daily.batch: { rpm: 120, batch: 200 }
kline.daily.by_symbol: { rpm: 300, batch: 1 }
@@ -40,7 +50,8 @@ expert:
kline.minute.by_symbol: { rpm: 120, batch: 1 }
intraday: { rpm: 120, batch: 1 }
intraday.batch: { rpm: 60, batch: 200 }
depth5: { rpm: 120, batch: 1 }
depth5: { rpm: 120, batch: 1 } # 按标的查(单只):官方 120rpm/1
depth5.batch: { rpm: 60, batch: 200 } # 批量查(新增):官方 60rpm/200
adj_factor: { rpm: 120, batch: 200 }
websocket: { subscribe: 100 }
financial: { rpm: 120, batch: 100 }