mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
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:
@@ -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
@@ -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]]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 [],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "tf-stocks-panel-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.20",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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}` : ''}`,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user