点股票看它今天的全部信号:按代码回扫接口、抽屉、遮罩空白四处修复(台账 055 第一件)

信号栏一行等于上游流里的一条原始消息,一只活跃票一天十来条散在好几个分组里。
现在点一下票名就能把它今天的全部信号按时间倒序摊开。

后端:
- upstream_signals.by_code() 按代码回扫五个上游源。快照口径注定不全(告警每类只留
  最新 20 条),到下午一只活跃票早盘的信号早被挤掉,所以这条路径分页倒序回扫(每页
  500 条,上限 PMS_UPSTREAM_BYCODE_SCAN 默认 5000):两条按日期分键的流整键就是今天,
  两条不分键的流扫到今天零点为止且不算截断。同源同分钟同值合并计数,跨源永不合并。
  按票缓存八秒防连点,单源失败不拖垮整体且逐源标明。实况分没有时间,只进快照段不
  编时间戳。只读纪律与快照相同:一律 XREVRANGE/ZREVRANGE,不建消费组、不 ACK、不写。
- GET /api/upstream/signals/{ts_code},只能点击触发,绝不进轮询。

前端:
- 抽屉两段取数:先用手上的快照渲染,第一帧就有内容;同时发回扫请求,回来替换成完整
  版并把口径说明从「可能不全」翻成「已回扫到今天开盘」。补全失败保留快照挂黄条带重试。
- 三处入口:信号栏每行票名(14 处,@click.stop 免得顺手翻开风控卖出的理由条)、持仓表
  「今天的信号」列(连「今天没有」也可点,速览口径下的「没有」可能是假的)、消息栏两处。
- 遮罩空白四处修复:整体失败不再抹平成空数据(原来失败时页面显示「今天没有信号」,
  在风控面板上这是最危险的一种谎);告警派生的三个分组补失败角标、各路空文案给失败
  态让位;失败角标的样式选择器漏了信号栏;错误横幅在遮罩之上再浮一份;抽屉层级写死
  3000 压住浮层 2010 与遮罩 2005,打开前先收浮层。选股计划抽屉补加载提示。

测试:新增 scripts/test_batch24_units.py(9 例),已在 run_tests.py 的清单与例数表、
test_wiring.py 的路由清单三处登记。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-09 14:53:29 +08:00
parent 3c186ac217
commit 0c8d2a7d6c
8 changed files with 1054 additions and 31 deletions

View File

@ -55,6 +55,9 @@ RUNTIME_EXTRA = {
"PMS_UPSTREAM_ALERT_SCAN": (300, int, "告警扫描窗: 每轮从告警流取最近 N 条再分类限量 (窗要大于各类上限之和, 防 price_notice 等高频源把风控告警挤出最新N)"), "PMS_UPSTREAM_ALERT_SCAN": (300, int, "告警扫描窗: 每轮从告警流取最近 N 条再分类限量 (窗要大于各类上限之和, 防 price_notice 等高频源把风控告警挤出最新N)"),
"PMS_UPSTREAM_ALERT_PER_CAT": (20, int, "告警每类上限: 同一类型最多显示最新 N 条 (砍单类刷屏, 不挤别的类)"), "PMS_UPSTREAM_ALERT_PER_CAT": (20, int, "告警每类上限: 同一类型最多显示最新 N 条 (砍单类刷屏, 不挤别的类)"),
"PMS_UPSTREAM_ALERT_MAX_AGE_MIN": (60, int, "告警时间窗(分钟): 超过此龄判为陈旧并在页面置灰标注(不删除, 风控不丢信息), 0=不限龄。与每类上限正交, 先按龄标记再每类限量"), "PMS_UPSTREAM_ALERT_MAX_AGE_MIN": (60, int, "告警时间窗(分钟): 超过此龄判为陈旧并在页面置灰标注(不删除, 风控不丢信息), 0=不限龄。与每类上限正交, 先按龄标记再每类限量"),
# 2026-09-09: 点股票看它今天全部信号 (台账 055)。快照口径只留每类最新 N 条, 到下午
# 一只活跃票早盘的信号早被挤掉; 按代码回扫是另一条路径, 上限单独可调。
"PMS_UPSTREAM_BYCODE_SCAN": (5000, int, "单票信号回扫上限: 点开一只票时每条上游流最多倒序回扫 N 条 (分页 500/次)。只在点击时触发, 不进轮询; 调大更全但更慢"),
# 2026-08-18 宏观择时层: 闸状态由 macro_service 每次扫描写入, 扫描层与策略层只读。 # 2026-08-18 宏观择时层: 闸状态由 macro_service 每次扫描写入, 扫描层与策略层只读。
# 用运行参数承载 (不进策略 state、不另建表) —— 与 PMS_STRATEGY_BUYPAUSE 同一手法。 # 用运行参数承载 (不进策略 state、不另建表) —— 与 PMS_STRATEGY_BUYPAUSE 同一手法。
"PMS_MACRO_GATE_STATE": ("", str, "宏观闸状态 (macro_service 维护的 JSON: active/ymd/原因), 页面与扫描层只读, 勿手改"), "PMS_MACRO_GATE_STATE": ("", str, "宏观闸状态 (macro_service 维护的 JSON: active/ymd/原因), 页面与扫描层只读, 勿手改"),
@ -442,6 +445,7 @@ _RANGES = {
# 任务会先被 celery 打死 (而且是在已经落了一部分表之后)。 # 任务会先被 celery 打死 (而且是在已经落了一部分表之后)。
"PMS_UPSTREAM_ALERT_SCAN": (1, 20000), "PMS_UPSTREAM_ALERT_PER_CAT": (1, 1000), "PMS_UPSTREAM_ALERT_SCAN": (1, 20000), "PMS_UPSTREAM_ALERT_PER_CAT": (1, 1000),
"PMS_UPSTREAM_ALERT_MAX_AGE_MIN": (0, 1440), # 0=不限龄, 上限 24 小时 "PMS_UPSTREAM_ALERT_MAX_AGE_MIN": (0, 1440), # 0=不限龄, 上限 24 小时
"PMS_UPSTREAM_BYCODE_SCAN": (100, 50000), # 下限 100 保证至少扫得到一页
# 宏观择时层 # 宏观择时层
"PMS_MACRO_HOT_TH": (0, 100), "PMS_MACRO_COLD_TH": (-100, 0), "PMS_MACRO_HOT_TH": (0, 100), "PMS_MACRO_COLD_TH": (-100, 0),
"PMS_MACRO_EXIT_BAND": (0, 100), "PMS_MACRO_CONFIRM_DAYS": (1, 10), "PMS_MACRO_EXIT_BAND": (0, 100), "PMS_MACRO_CONFIRM_DAYS": (1, 10),

View File

@ -282,3 +282,308 @@ def snapshot(force: bool = False) -> dict:
_cache["at"], _cache["data"] = now, out _cache["at"], _cache["data"] = now, out
return out return out
# ================================================================ 按代码回扫 (2026-09-09)
# 信号栏一行 = 上游流里的一条原始消息, 同一只票一天出现很多次 (实测活跃票一天十来条)。
# 这里按代码把五个源汇到一起, 给页面的「这只票今天的信号」抽屉用。
#
# 与 snapshot() 的分工: 那个是**全场最近 N 条**的快照 (告警每类只留最新 20 条), 到下午
# 一只活跃票早盘的信号早被挤掉了; 这个是**单票回扫到当天开盘**, 所以才叫"全部"。
# 两份缓存不合并 —— 口径不同, 合并会让单票结果被全场快照污染。
#
# 只读纪律与 snapshot() 逐字相同: 一律 XREVRANGE / ZREVRANGE, 不建消费组、不 ACK、不写。
# **这个接口只能点击触发, 绝不能加进页面轮询** —— 加进去就是每 30 秒对上游做一次全流扫描,
# 把只读观察变成压力源。
_BYCODE_CACHE = {}
_BYCODE_MAX = 32
_SCAN_PAGE = 500
def _bycode_scan() -> int:
return max(100, param_store.get_int("PMS_UPSTREAM_BYCODE_SCAN", 5000))
def _code6(v) -> str:
"""任意写法里抽六位数字。抽不到回空串。"""
import re
m = re.search(r"(\d{6})", str(v or ""))
return m.group(1) if m else ""
def _norm_code(v) -> str:
"""SH600000 / 600000.SH / sh600000 / 600000 -> 600000.SH; 认不出后缀就只回六位。"""
s = str(v or "").strip().upper()
six = _code6(s)
if not six:
return ""
for ex in ("SH", "SZ", "BJ"):
if ex in s:
return "%s.%s" % (six, ex)
return six
def _same_code(a, b) -> bool:
"""两边都带交易所后缀时要求后缀一致; 只有一边带就退回六位匹配。
六位在 A 股跨市场唯一, 够用; 这道二次校验是给将来可能出现的别的品种留的"""
sa, sb = str(a or "").upper(), str(b or "").upper()
if _code6(sa) != _code6(sb) or not _code6(sa):
return False
exa = next((x for x in ("SH", "SZ", "BJ") if x in sa), "")
exb = next((x for x in ("SH", "SZ", "BJ") if x in sb), "")
return (not exa) or (not exb) or exa == exb
def _id_ms(sid) -> int:
try:
return int(str(sid).split("-")[0])
except Exception:
return 0
def _prev_id(sid) -> str:
"""比 sid 严格小的边界 id。不用 Redis 6.2 的 `(id` 排他区间 —— 本模块已经因为上游
可能低于 6.0 而强制走 RESP2, 排他区间同样不能假设手工减一在所有版本上都成立"""
try:
ms, seq = str(sid).split("-")
ms, seq = int(ms), int(seq)
except Exception:
return "-"
if seq > 0:
return "%d-%d" % (ms, seq - 1)
if ms > 0:
return "%d-18446744073709551615" % (ms - 1)
return "-"
def _scan_stream(c, key, *, cap, page=_SCAN_PAGE, stop_before_ms=None):
"""分页倒序回扫一条流, 返回 (条目列表, 是否还没见底)。
一次 XREVRANGE COUNT 5000 会把几兆塞进一个应答且没法中途停, 所以分页
stop_before_ms **不按日期分键**的流用 (风控卖出资金异动): 一旦某条早于今天零点
就立刻收手, 且不算截断 今天的已经扫全了
"""
entries, cur, truncated = [], "+", False
while len(entries) < cap:
want = min(page, cap - len(entries))
batch = c.xrevrange(key, max=cur, min="-", count=want)
if not batch:
break
stop = False
for sid, f in batch:
if stop_before_ms is not None and _id_ms(sid) < stop_before_ms:
stop = True
break
entries.append((sid, f))
if stop:
return entries, False
if len(batch) < want:
break
cur = _prev_id(batch[-1][0])
if cur == "-":
break
else:
truncated = True
return entries, truncated
def _today_start_ms() -> int:
t = time.localtime()
return int(time.mktime((t.tm_year, t.tm_mon, t.tm_mday, 0, 0, 0, 0, 0, -1)) * 1000)
def _row(src, src_label, ms, **kw) -> dict:
"""时间线一行的统一形状。缺的字段给 None 不缺键 —— 模板最怕键时有时无。"""
base = {"src": src, "src_label": src_label, "ts": int(ms or 0), "time": _hm(ms),
"ymd": _ymd_of(ms), "cat": None, "cat_label": None, "direction": None,
"level": None, "value": None, "price": None, "target": None, "confidence": None,
"entry_score": None, "z_dd": None, "window_net": None, "window_ret": None,
"action": None, "dominant_signal": None, "reason": None, "stale": False,
"age_min": None, "dup": 1, "ts_from": "trigger"}
base.update(kw)
return base
def _dedup(rows) -> list:
"""同源 + 同分钟 + 同值的合并成一条并累加 dup; **跨源永不合并** ——
决策系统与择时层在同一分钟都看多, 是两条独立证据, 合并就是删信息"""
seen, out = {}, []
for r in rows:
ident = {
"alert": (r.get("cat"), r.get("level"), r.get("value")),
"decision": (r.get("action"), r.get("price")),
"timing": (r.get("price"), r.get("entry_score")),
"sell": (r.get("dominant_signal"), r.get("confidence")),
"metrics": (r.get("z_dd"), r.get("window_net")),
}.get(r.get("src"), (r.get("value"),))
key = (r.get("src"), r.get("time"), ident)
if key in seen:
seen[key]["dup"] += 1
continue
seen[key] = r
out.append(r)
return out
def _bycode_intraday(c, ymd, code, cap):
ent, trunc = _scan_stream(c, "intraday_signals:%s" % ymd, cap=cap)
rows = []
for _sid, f in ent:
if not _same_code(f.get("ts_code"), code):
continue
producer = str(f.get("producer_id") or "")
is_itd = ("intraday_timing" in producer) or (f.get("entry_score") is not None)
rows.append(_row("timing" if is_itd else "decision",
"盘中择时层入场" if is_itd else "决策系统广播",
f.get("trigger_time"),
cat=f.get("signal_type"),
cat_label="择时层入场" if is_itd else ("建议买入" if str(f.get("action") or "").upper() == "BUY" else "建议卖出"),
direction="看涨" if str(f.get("action") or "").upper() == "BUY" else "看跌",
action=f.get("action"), price=_num(f.get("suggested_price")),
target=_num(f.get("target_price")), confidence=_num(f.get("confidence")),
entry_score=_num(f.get("entry_score"))))
return rows, len(ent), trunc
def _bycode_alerts(c, ymd, code, cap):
ent, trunc = _scan_stream(c, "intraday_alerts:%s" % ymd, cap=cap)
max_age, now, rows = _alert_max_age_sec(), time.time(), []
for _sid, f in ent:
if not _same_code(f.get("ts_code"), code):
continue
src = str(f.get("source") or "")
meta = f.get("metadata")
if not isinstance(meta, dict):
try:
meta = json.loads(meta)
except Exception:
meta = {}
dir_fixed, cat, cat_label = _ALERT_META.get(src, (None, "other", "其他"))
if dir_fixed is None:
mdir = str((meta or {}).get("direction") or "").lower()
d = "看跌" if mdir in ("down", "short", "bear") else ("看涨" if mdir in ("up", "long", "bull") else "")
else:
d = dir_fixed
try:
age_sec = now - float(f.get("trigger_time")) / 1000.0
age_min = int(age_sec // 60)
except (TypeError, ValueError):
age_sec, age_min = None, None
rows.append(_row("alert", "盘中告警", f.get("trigger_time"), cat=cat, cat_label=cat_label,
direction=d, level=f.get("level"), value=_num(f.get("value")),
age_min=age_min,
stale=bool(max_age) and age_sec is not None and age_sec > max_age,
reason=str(f.get("source") or "")))
return rows, len(ent), trunc
def _bycode_sell(c, code, cap):
ent, trunc = _scan_stream(c, "bionic:signals:llm_sell_actions", cap=cap,
stop_before_ms=_today_start_ms())
rows = []
for _sid, f in ent:
raw = f.get("data")
d = f
if raw:
try:
d = json.loads(raw) if isinstance(raw, str) else dict(raw)
except Exception:
d = {}
if not _same_code(d.get("ts_code") or f.get("ts_code"), code):
continue
ts = d.get("timestamp") or d.get("trigger_time") or f.get("ts")
rows.append(_row("sell", "风控卖出动作", ts, cat="risk_sell", cat_label="风控卖出",
direction="看跌", action=d.get("action"),
confidence=_num(d.get("confidence")),
dominant_signal=d.get("dominant_signal"),
reason=d.get("llm_reason") or d.get("reason") or f.get("reason")))
return rows, len(ent), trunc
def _bycode_metrics(c, code, cap):
"""资金异动进时间线 (2026-09-09): 时间取流 id 的毫秒部分, 那是**写入时刻**不是上游触发时刻,
所以每行标 ts_from=stream_id, 页面写写入 13:41而不是触发 13:41"""
ent, trunc = _scan_stream(c, "mtf:intraday:stream:metrics", cap=cap,
stop_before_ms=_today_start_ms())
rows = []
for sid, f in ent:
cd = f.get("ts_code") or f.get("code")
payload = f.get("data")
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
payload = {}
if not isinstance(payload, dict):
payload = {}
if not _same_code(cd or payload.get("ts_code"), code):
continue
g = lambda k, dv=None: payload.get(k, f.get(k, dv))
rows.append(_row("metrics", "盘中资金异动", _id_ms(sid), cat="fund_flow", cat_label="资金异动",
direction=g("direction"), level=g("level"),
value=_num(g("value") if g("value") is not None else g("net")),
z_dd=_num(g("z_dd")), window_net=_num(g("window_net")),
window_ret=_num(g("window_ret")), ts_from="stream_id"))
return rows, len(ent), trunc
def by_code(ts_code: str, force: bool = False) -> dict:
"""一只票今天的全部上游信号。点击触发, 不进任何轮询。"""
code = _norm_code(ts_code)
if not code:
return {"ok": False, "error": "认不出的股票代码: %s" % ts_code, "timeline": [],
"snapshot": {"metrics": [], "mr": {"watch": None, "avoid": None}}}
six = _code6(code)
ttl = max(2, param_store.get_int("PMS_UPSTREAM_CACHE_SEC", 8))
now = time.time()
hit = _BYCODE_CACHE.get(six)
if not force and hit and (now - hit[0]) < ttl:
return hit[1]
ymd = datetime.now().strftime("%Y-%m-%d")
cap = _bycode_scan()
out = {"ok": True, "ts_code": code, "code6": six, "ymd": ymd,
"at": datetime.now().isoformat(timespec="seconds"),
"timeline": [], "snapshot": {"metrics": [], "mr": {"watch": None, "avoid": None}},
"scanned": {}, "truncated": {}, "sources": {}}
rows = []
def _try(name, fn):
try:
got, scanned, trunc = fn()
rows.extend(got)
out["scanned"][name] = scanned
out["truncated"][name] = bool(trunc)
out["sources"][name] = {"ok": True}
except Exception as e:
logger.warning("[单票信号] %s %s 读取失败: %s", code, name, e)
out["sources"][name] = {"ok": False, "error": "%s: %s" % (type(e).__name__, e)}
dbi, dba = settings.SIGNAL_REDIS_DB_INTRADAY, settings.SIGNAL_REDIS_DB_ACTIONS
_try("intraday", lambda: _bycode_intraday(_c208(dbi), ymd, code, cap))
_try("alerts", lambda: _bycode_alerts(_c208(dbi), ymd, code, cap))
_try("sell_actions", lambda: _bycode_sell(_c208(dba), code, cap))
_try("metrics", lambda: _bycode_metrics(_c208(dbi), code, cap))
# 实况分是一个按分数排的有序集合, 连写入时刻都没有 —— **绝不给它编时间**,
# 排进时间线的假时间戳比放在快照段里说"没有时间"危险得多。
try:
mr = _read_mr(_c214())
out["snapshot"]["mr"] = {
"watch": next((x for x in mr.get("watch") or [] if _same_code(x.get("ts_code"), code)), None),
"avoid": next((x for x in mr.get("avoid") or [] if _same_code(x.get("ts_code"), code)), None)}
out["sources"]["mr"] = {"ok": True}
except Exception as e:
logger.warning("[单票信号] %s mr 读取失败: %s", code, e)
out["sources"]["mr"] = {"ok": False, "error": "%s: %s" % (type(e).__name__, e)}
# 资金异动同时进时间线与快照段: 时间线给"什么时候进的钱", 快照段给"现在是什么状态"。
out["snapshot"]["metrics"] = [r for r in rows if r["src"] == "metrics"][:5]
rows = _dedup(rows)
rows.sort(key=lambda r: (r.get("ts") or 0), reverse=True)
out["timeline"] = rows
_BYCODE_CACHE[six] = (now, out)
if len(_BYCODE_CACHE) > _BYCODE_MAX:
for k in sorted(_BYCODE_CACHE, key=lambda x: _BYCODE_CACHE[x][0])[:-_BYCODE_MAX]:
_BYCODE_CACHE.pop(k, None)
return out

View File

@ -692,6 +692,20 @@ def api_upstream_signals():
return ok(upstream_signals.snapshot) return ok(upstream_signals.snapshot)
@app.get("/api/upstream/signals/{ts_code}")
def api_upstream_signals_by_code(ts_code: str):
"""一只票**今天的全部**上游信号 (2026-09-09, 台账 055)。
/api/upstream-signals 的分工: 那个是全场最近 N 条的快照, 告警每类只留最新 20 ,
到下午一只活跃票早盘的信号早被挤掉; 这个按代码倒序回扫到当天开盘, 所以才叫"全部"
**只能点击触发, 绝不能进页面轮询** 进轮询就是每 30 秒对上游做一次全流扫描,
把只读观察变成压力源内部纪律与快照相同: 一律 XREVRANGE/ZREVRANGE, 不建消费组 ACK不写
"""
from app.services import upstream_signals
return ok(upstream_signals.by_code, ts_code)
# ================================================================ 宏观择时 (MACRO_TIMING_PLAN.md) # ================================================================ 宏观择时 (MACRO_TIMING_PLAN.md)
@app.get("/api/macro/status") @app.get("/api/macro/status")
def api_macro_status(): def api_macro_status():

View File

@ -70,6 +70,10 @@ body{margin:0;background:var(--plane);color:var(--ink);
.panel h3,.tblk h3{margin:0 0 10px;font-size:15px;color:var(--ink);font-weight:660;} .panel h3,.tblk h3{margin:0 0 10px;font-size:15px;color:var(--ink);font-weight:660;}
.sub2{color:var(--muted);font-size:12.5px;margin:-4px 0 12px;} .sub2{color:var(--muted);font-size:12.5px;margin:-4px 0 12px;}
.banner{margin:10px 0;} .banner{margin:10px 0;}
/* 2026-09-09: 顶部那条横幅在文档流里, 浮层(2010)、遮罩(2005)、弹层都压得住它, 往下滚一屏
就彻底看不见 —— 于是"报错了"在页面上表现成"点了没反应"。再浮一份固定在顶上。 */
.banner-float{position:fixed;top:8px;left:50%;transform:translateX(-50%);z-index:99995;
width:min(760px,92vw);box-shadow:0 8px 28px rgba(17,17,16,.28);}
.attn{color:var(--warn);padding:4px 0;} .attn{color:var(--warn);padding:4px 0;}
.tcard{background:var(--surface-2);border:1px solid var(--hair);border-radius:11px;padding:12px 14px;margin-bottom:10px;} .tcard{background:var(--surface-2);border:1px solid var(--hair);border-radius:11px;padding:12px 14px;margin-bottom:10px;}
.feed-row{padding:5px 0;border-bottom:1px solid var(--hair);font-size:13px;} .feed-row{padding:5px 0;border-bottom:1px solid var(--hair);font-size:13px;}
@ -148,7 +152,10 @@ pre.json{background:var(--surface-2);border:1px solid var(--hair);border-radius:
.sig-ev{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px;margin-top:14px;} .sig-ev{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px;margin-top:14px;}
.sig-ev .up-cell{margin:0;} .sig-ev .up-cell{margin:0;}
.sig-none{color:var(--ink-2);padding:10px 0;font-size:12.5px;} .sig-none{color:var(--ink-2);padding:10px 0;font-size:12.5px;}
.up-h .src-err{color:var(--stop);font-weight:400;font-size:11.5px;} /* 2026-09-09: 原来只写了 .up-h, 而信号栏的分组头是 .srch —— 角标即使渲染出来也没有颜色,
在风控面板上看起来就跟普通文字一样。两处都要。 */
.up-h .src-err,.srch .src-err{color:var(--stop);font-weight:400;font-size:11.5px;}
.sig-fail{color:var(--stop);font-size:11.5px;padding:3px 0;}
.mr-two{display:grid;grid-template-columns:1fr 1fr;gap:16px;font-size:12.5px;} .mr-two{display:grid;grid-template-columns:1fr 1fr;gap:16px;font-size:12.5px;}
.mr-row{padding:3px 0;border-bottom:1px solid var(--hair);} .mr-row{padding:3px 0;border-bottom:1px solid var(--hair);}
.mr-row:last-child{border-bottom:none;} .mr-row:last-child{border-bottom:none;}
@ -365,6 +372,26 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
.oprow2 .b{flex:none;width:92px;} .oprow2 .b{flex:none;width:92px;}
.oprow2 .b .el-button{width:100%;} .oprow2 .b .el-button{width:100%;}
/* 登录遮罩与登录人胶囊 (2026-08 加权限) */ /* 登录遮罩与登录人胶囊 (2026-08 加权限) */
/* ── 单票信号抽屉 (2026-09-09): 点一只票看它今天的全部信号 ──
层级 3000/2995 是显式写死的, 不能删也不能改小: 见 .banner-float 那条注释。 */
.cs-scrim{position:fixed;inset:0;background:rgba(17,17,16,.34);z-index:2995;}
.cs-drawer{position:fixed;top:0;right:0;bottom:0;width:min(620px,94vw);z-index:3000;
background:var(--surface);border-left:1px solid var(--hair);display:flex;flex-direction:column;
box-shadow:-12px 0 40px rgba(17,17,16,.22);}
.cs-h{display:flex;align-items:center;gap:8px;padding:11px 13px;border-bottom:1px solid var(--hair);flex:none;flex-wrap:wrap;}
.cs-h .ttl{font-size:14px;font-weight:660;}
.cs-b{flex:1;overflow:auto;padding:10px 13px 16px;}
.cs-sect{margin:0 0 12px;}
.cs-sect>.h{font-size:12px;font-weight:660;color:var(--muted);margin:0 0 5px;
border-bottom:1px dashed var(--hair);padding-bottom:4px;}
.cs-row{display:flex;align-items:baseline;gap:7px;padding:4px 0;font-size:12.5px;
border-bottom:1px solid rgba(0,0,0,.04);flex-wrap:wrap;}
.cs-row .t{font-size:11.5px;color:var(--muted);width:40px;flex:none;}
.cs-row .s{font-size:11px;padding:0 5px;border-radius:5px;background:var(--chip);color:var(--muted);flex:none;}
.cs-row.old{opacity:.5;}
.cs-dup{font-size:11px;color:var(--muted);}
.cs-warn{background:#fff8e6;border:1px solid #f0d9a0;border-radius:8px;padding:7px 9px;
font-size:12px;margin:0 0 10px;line-height:1.5;}
.login-mask{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center; .login-mask{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;
background:rgba(20,20,18,.55);backdrop-filter:blur(4px);} background:rgba(20,20,18,.55);backdrop-filter:blur(4px);}
.login-card{width:320px;background:var(--surface);border:1px solid var(--hair);border-radius:14px; .login-card{width:320px;background:var(--surface);border:1px solid var(--hair);border-radius:14px;
@ -457,6 +484,10 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<button class="tbtn sm" v-if="authed" @click="doLogout">退出</button> <button class="tbtn sm" v-if="authed" @click="doLogout">退出</button>
</div> </div>
<teleport to="body">
<el-alert v-if="err && (railOpen || csOpen)" class="banner-float" :title="err" type="error"
effect="dark" show-icon @close="err=''"></el-alert>
</teleport>
<el-alert v-if="err" class="banner" :title="err" type="error" effect="dark" show-icon <el-alert v-if="err" class="banner" :title="err" type="error" effect="dark" show-icon
@close="err=''"></el-alert> @close="err=''"></el-alert>
<el-alert v-if="planStatus.ok===false" class="banner" type="error" effect="dark" show-icon <el-alert v-if="planStatus.ok===false" class="banner" type="error" effect="dark" show-icon
@ -502,7 +533,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<span class="mono muted" style="font-size:11.5px">{{ msgLatest.time || '—' }}</span> <span class="mono muted" style="font-size:11.5px">{{ msgLatest.time || '—' }}</span>
<span class="mb-kind" :class="'k-'+msgLatest.kind" style="width:auto;padding:1px 6px">{{ kindLabel(msgLatest.kind) }}</span> <span class="mb-kind" :class="'k-'+msgLatest.kind" style="width:auto;padding:1px 6px">{{ kindLabel(msgLatest.kind) }}</span>
<span class="txt"> <span class="txt">
<b v-if="msgLatest.ts_code">{{ nm(msgLatest.ts_code) }}</b> <b v-if="msgLatest.ts_code" style="cursor:pointer;text-decoration:underline dotted"
title="点一下看这只票今天的全部信号"
@click.stop="openCodeSignals(msgLatest.ts_code)">{{ nm(msgLatest.ts_code) }}</b>
<span v-if="msgLatest.ts_code" class="muted mono" style="font-size:11px">{{ msgLatest.ts_code }}</span> <span v-if="msgLatest.ts_code" class="muted mono" style="font-size:11px">{{ msgLatest.ts_code }}</span>
<span v-if="msgLatest.dir" :class="dirCls(msgLatest.dir)">{{ msgLatest.dir }}</span> <span v-if="msgLatest.dir" :class="dirCls(msgLatest.dir)">{{ msgLatest.dir }}</span>
<span class="muted">{{ msgLatest.cat }}</span> <span class="muted">{{ msgLatest.cat }}</span>
@ -527,7 +560,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<span class="t mono">{{ m.time || '—' }}</span> <span class="t mono">{{ m.time || '—' }}</span>
<span class="mb-kind" :class="'k-'+m.kind">{{ kindLabel(m.kind) }}</span> <span class="mb-kind" :class="'k-'+m.kind">{{ kindLabel(m.kind) }}</span>
<div class="body"> <div class="body">
<b v-if="m.ts_code">{{ nm(m.ts_code) }}</b> <b v-if="m.ts_code" style="cursor:pointer;text-decoration:underline dotted"
title="点一下看这只票今天的全部信号"
@click.stop="openCodeSignals(m.ts_code)">{{ nm(m.ts_code) }}</b>
<span v-if="m.ts_code" class="muted mono" style="font-size:11px">{{ m.ts_code }}</span> <span v-if="m.ts_code" class="muted mono" style="font-size:11px">{{ m.ts_code }}</span>
<span v-if="m.dir" :class="dirCls(m.dir)">{{ m.dir }}</span> <span v-if="m.dir" :class="dirCls(m.dir)">{{ m.dir }}</span>
<span :style="m.strong ? 'font-weight:600' : ''" :class="m.strong ? '' : 'muted'">{{ m.cat }}</span> <span :style="m.strong ? 'font-weight:600' : ''" :class="m.strong ? '' : 'muted'">{{ m.cat }}</span>
@ -584,12 +619,15 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<!-- 速览: 持仓票置顶, 其余只收重要和警告 --> <!-- 速览: 持仓票置顶, 其余只收重要和警告 -->
<template v-if="sigTab==='brief'"> <template v-if="sigTab==='brief'">
<div class="muted" v-if="!sigToday.length">持仓的票今天没有信号,全场也没有重要或警告级的信号。</div> <div class="sig-fail" v-if="srcFail('alerts') || srcFail('intraday')">
上游信号读不到({{ (srcFail('alerts')||srcFail('intraday')).error }})。
这不是「今天没有信号」,是这一路取不到数——别当成平安无事。</div>
<div class="muted" v-else-if="!sigToday.length">持仓的票今天没有信号,全场也没有重要或警告级的信号。</div>
<template v-if="sigToday.length"> <template v-if="sigToday.length">
<div v-for="(g,i) in sigBriefHeld.slice(0,60)" :key="'h'+i" class="sigrow2" :class="{old:g.stale}"> <div v-for="(g,i) in sigBriefHeld.slice(0,60)" :key="'h'+i" class="sigrow2" :class="{old:g.stale}">
<span class="t mono">{{ hm(g.time) || '—' }}</span> <span class="t mono">{{ hm(g.time) || '—' }}</span>
<span class="pin">持仓</span> <span class="pin">持仓</span>
<span class="nm">{{ nm(g.ts_code) }}</span> <span class="nm" style="cursor:pointer;text-decoration:underline dotted" title="点一下看这只票今天的全部信号" @click.stop="openCodeSignals(g.ts_code)">{{ nm(g.ts_code) }}</span>
<span class="cat">{{ g.cat }}</span> <span class="cat">{{ g.cat }}</span>
<span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span> <span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span>
<span class="sp"></span> <span class="sp"></span>
@ -601,7 +639,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div v-for="(g,i) in sigBriefOther.slice(0,60)" :key="'o'+i" class="sigrow2" :class="{old:g.stale}"> <div v-for="(g,i) in sigBriefOther.slice(0,60)" :key="'o'+i" class="sigrow2" :class="{old:g.stale}">
<span class="t mono">{{ hm(g.time) || '—' }}</span> <span class="t mono">{{ hm(g.time) || '—' }}</span>
<span class="pin plan" v-if="g.plan">计划</span> <span class="pin plan" v-if="g.plan">计划</span>
<span class="nm">{{ nm(g.ts_code) }}</span> <span class="nm" style="cursor:pointer;text-decoration:underline dotted" title="点一下看这只票今天的全部信号" @click.stop="openCodeSignals(g.ts_code)">{{ nm(g.ts_code) }}</span>
<span class="cat">{{ g.cat }}</span> <span class="cat">{{ g.cat }}</span>
<span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span> <span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span>
<span class="sp"></span> <span class="sp"></span>
@ -618,7 +656,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<span class="t mono" style="width:auto">{{ (g.ymd||'').slice(5) }} {{ hm(g.time) || '—' }}</span> <span class="t mono" style="width:auto">{{ (g.ymd||'').slice(5) }} {{ hm(g.time) || '—' }}</span>
<span class="pin" v-if="g.held">持仓</span> <span class="pin" v-if="g.held">持仓</span>
<span class="pin plan" v-if="g.plan && !g.held">计划</span> <span class="pin plan" v-if="g.plan && !g.held">计划</span>
<span class="nm">{{ nm(g.ts_code) }}</span> <span class="nm" style="cursor:pointer;text-decoration:underline dotted" title="点一下看这只票今天的全部信号" @click.stop="openCodeSignals(g.ts_code)">{{ nm(g.ts_code) }}</span>
<span class="cat">{{ g.cat }}</span> <span class="cat">{{ g.cat }}</span>
<span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span> <span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span>
<span class="sp"></span> <span class="sp"></span>
@ -640,7 +678,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div class="srcb" v-show="srcOpen.metrics"> <div class="srcb" v-show="srcOpen.metrics">
<div class="sigrow2" v-for="(r,i) in SIG.metrics.slice(0,20)" :key="i" <div class="sigrow2" v-for="(r,i) in SIG.metrics.slice(0,20)" :key="i"
:title="'原始偏离值 = '+(r.z_dd==null?'—':r.z_dd)"> :title="'原始偏离值 = '+(r.z_dd==null?'—':r.z_dd)">
<span class="nm">{{ nm(r.ts_code) }}</span> <span class="nm" style="cursor:pointer;text-decoration:underline dotted" title="点一下看这只票今天的全部信号" @click.stop="openCodeSignals(r.ts_code)">{{ nm(r.ts_code) }}</span>
<span v-if="flowText(r.direction)!=='—'" :class="dirCls(r.direction)" <span v-if="flowText(r.direction)!=='—'" :class="dirCls(r.direction)"
style="font-weight:600">{{ flowText(r.direction) }}</span> style="font-weight:600">{{ flowText(r.direction) }}</span>
<span class="sp"></span> <span class="sp"></span>
@ -649,7 +687,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<span class="mono" style="font-weight:700;font-size:11px" <span class="mono" style="font-weight:700;font-size:11px"
:class="dirCls(r.direction)">{{ zBand(r.z_dd).label }}</span> :class="dirCls(r.direction)">{{ zBand(r.z_dd).label }}</span>
</div> </div>
<div class="sig-none" v-if="!SIG.metrics.length">今天没有资金异动</div> <div class="sig-fail" v-if="srcFail('metrics')">读取失败:{{ srcFail('metrics').error }}</div>
<div class="sig-none" v-else-if="!SIG.metrics.length">今天没有资金异动</div>
<div class="muted" style="font-size:11px;margin-top:6px"> <div class="muted" style="font-size:11px;margin-top:6px">
强度分四档:一般、显著、强、极强。鼠标停在行上能看到原始数值。</div> 强度分四档:一般、显著、强、极强。鼠标停在行上能看到原始数值。</div>
</div> </div>
@ -663,12 +702,12 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div class="srcb" v-show="srcOpen.mr"> <div class="srcb" v-show="srcOpen.mr">
<div class="muted" style="margin:4px 0 3px">关注(分高)</div> <div class="muted" style="margin:4px 0 3px">关注(分高)</div>
<div v-for="(r,i) in (SIG.mr.watch||[]).slice(0,12)" :key="'w'+i" class="sigrow2"> <div v-for="(r,i) in (SIG.mr.watch||[]).slice(0,12)" :key="'w'+i" class="sigrow2">
<span class="nm">{{ nm(r.ts_code) }}</span><span class="sp"></span> <span class="nm" style="cursor:pointer;text-decoration:underline dotted" title="点一下看这只票今天的全部信号" @click.stop="openCodeSignals(r.ts_code)">{{ nm(r.ts_code) }}</span><span class="sp"></span>
<span class="mono" style="color:var(--accent)">{{ r.score }}</span></div> <span class="mono" style="color:var(--accent)">{{ r.score }}</span></div>
<div class="sig-none" v-if="!(SIG.mr.watch||[]).length">暂无</div> <div class="sig-none" v-if="!(SIG.mr.watch||[]).length">暂无</div>
<div class="muted" style="margin:8px 0 3px">回避(分低)</div> <div class="muted" style="margin:8px 0 3px">回避(分低)</div>
<div v-for="(r,i) in (SIG.mr.avoid||[]).slice(0,12)" :key="'a'+i" class="sigrow2"> <div v-for="(r,i) in (SIG.mr.avoid||[]).slice(0,12)" :key="'a'+i" class="sigrow2">
<span class="nm">{{ nm(r.ts_code) }}</span><span class="sp"></span> <span class="nm" style="cursor:pointer;text-decoration:underline dotted" title="点一下看这只票今天的全部信号" @click.stop="openCodeSignals(r.ts_code)">{{ nm(r.ts_code) }}</span><span class="sp"></span>
<span class="mono" style="color:var(--warn)">{{ r.score }}</span></div> <span class="mono" style="color:var(--warn)">{{ r.score }}</span></div>
<div class="sig-none" v-if="!(SIG.mr.avoid||[]).length">暂无</div> <div class="sig-none" v-if="!(SIG.mr.avoid||[]).length">暂无</div>
</div> </div>
@ -680,63 +719,71 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<span class="src-err" v-if="usrc('intraday') && usrc('intraday').ok===false"> · 读取失败</span></div> <span class="src-err" v-if="usrc('intraday') && usrc('intraday').ok===false"> · 读取失败</span></div>
<div class="srcb" v-show="srcOpen.decision"> <div class="srcb" v-show="srcOpen.decision">
<div class="sigrow2" v-for="(r,i) in SIG.decision.slice(0,16)" :key="i"> <div class="sigrow2" v-for="(r,i) in SIG.decision.slice(0,16)" :key="i">
<span class="nm">{{ nm(r.ts_code) }}</span><span class="sp"></span> <span class="nm" style="cursor:pointer;text-decoration:underline dotted" title="点一下看这只票今天的全部信号" @click.stop="openCodeSignals(r.ts_code)">{{ nm(r.ts_code) }}</span><span class="sp"></span>
<span class="disp" :class="r.action==='SELL'?'sig-down':'sig-up'">{{ r.action==='SELL'?'建议卖出':'建议买入' }}</span> <span class="disp" :class="r.action==='SELL'?'sig-down':'sig-up'">{{ r.action==='SELL'?'建议卖出':'建议买入' }}</span>
<span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div> <span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div>
<div class="sig-none" v-if="!SIG.decision.length">今天没有买卖广播</div> <div class="sig-fail" v-if="srcFail('intraday')">读取失败:{{ srcFail('intraday').error }}</div>
<div class="sig-none" v-else-if="!SIG.decision.length">今天没有买卖广播</div>
</div> </div>
</div> </div>
<div class="srcgroup"> <div class="srcgroup">
<div class="srch" @click="toggleSrc('qrs')">{{ srcOpen.qrs ? '▾' : '▸' }} 日线形态 <div class="srch" @click="toggleSrc('qrs')">{{ srcOpen.qrs ? '▾' : '▸' }} 日线形态
<span class="n">· {{ SIG.qrs.length }}</span> <span class="n">· {{ SIG.qrs.length }}</span>
<span class="src-err" v-if="srcFail('alerts')"> · 读取失败</span>
<span class="help" data-tip="决策系统的日线形态指标(内部代号 QRS看多方和空方的力量是否对称。看涨表示多方占优。">?</span></div> <span class="help" data-tip="决策系统的日线形态指标(内部代号 QRS看多方和空方的力量是否对称。看涨表示多方占优。">?</span></div>
<div class="srcb" v-show="srcOpen.qrs"> <div class="srcb" v-show="srcOpen.qrs">
<div class="sigrow2" v-for="(r,i) in SIG.qrs.slice(0,16)" :key="i" :class="{old:r.stale}"> <div class="sigrow2" v-for="(r,i) in SIG.qrs.slice(0,16)" :key="i" :class="{old:r.stale}">
<span class="nm">{{ nm(r.ts_code) }}</span> <span class="nm" style="cursor:pointer;text-decoration:underline dotted" title="点一下看这只票今天的全部信号" @click.stop="openCodeSignals(r.ts_code)">{{ nm(r.ts_code) }}</span>
<span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)" <span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)"
style="font-weight:600">{{ r.direction }}</span> style="font-weight:600">{{ r.direction }}</span>
<span class="sp"></span> <span class="sp"></span>
<span class="disp" :class="lvClass(r.level)" style="padding:0 6px">{{ lvLabel(r.level) }}</span> <span class="disp" :class="lvClass(r.level)" style="padding:0 6px">{{ lvLabel(r.level) }}</span>
<span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div> <span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div>
<div class="sig-none" v-if="!SIG.qrs.length">今天没有日线形态告警</div> <div class="sig-fail" v-if="srcFail('alerts')">读取失败:{{ srcFail('alerts').error }}</div>
<div class="sig-none" v-else-if="!SIG.qrs.length">今天没有日线形态告警</div>
</div> </div>
</div> </div>
<div class="srcgroup"> <div class="srcgroup">
<div class="srch" @click="toggleSrc('capital')">{{ srcOpen.capital ? '▾' : '▸' }} 资金分布 <div class="srch" @click="toggleSrc('capital')">{{ srcOpen.capital ? '▾' : '▸' }} 资金分布
<span class="n">· {{ SIG.capital.length }}</span></div> <span class="n">· {{ SIG.capital.length }}</span>
<span class="src-err" v-if="srcFail('alerts')"> · 读取失败</span></div>
<div class="srcb" v-show="srcOpen.capital"> <div class="srcb" v-show="srcOpen.capital">
<div class="sigrow2" v-for="(r,i) in SIG.capital.slice(0,16)" :key="i" :class="{old:r.stale}"> <div class="sigrow2" v-for="(r,i) in SIG.capital.slice(0,16)" :key="i" :class="{old:r.stale}">
<span class="nm">{{ nm(r.ts_code) }}</span> <span class="nm" style="cursor:pointer;text-decoration:underline dotted" title="点一下看这只票今天的全部信号" @click.stop="openCodeSignals(r.ts_code)">{{ nm(r.ts_code) }}</span>
<span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)" <span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)"
style="font-weight:600">{{ r.direction }}</span> style="font-weight:600">{{ r.direction }}</span>
<span class="sp"></span> <span class="sp"></span>
<span class="disp" :class="lvClass(r.level)" style="padding:0 6px">{{ lvLabel(r.level) }}</span> <span class="disp" :class="lvClass(r.level)" style="padding:0 6px">{{ lvLabel(r.level) }}</span>
<span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div> <span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div>
<div class="sig-none" v-if="!SIG.capital.length">今天没有资金分布告警</div> <div class="sig-fail" v-if="srcFail('alerts')">读取失败:{{ srcFail('alerts').error }}</div>
<div class="sig-none" v-else-if="!SIG.capital.length">今天没有资金分布告警</div>
</div> </div>
</div> </div>
<div class="srcgroup"> <div class="srcgroup">
<div class="srch" @click="toggleSrc('price')">{{ srcOpen.price ? '▾' : '▸' }} 股价异动 <div class="srch" @click="toggleSrc('price')">{{ srcOpen.price ? '▾' : '▸' }} 股价异动
<span class="n">· {{ SIG.price_notice.length }}</span></div> <span class="n">· {{ SIG.price_notice.length }}</span>
<span class="src-err" v-if="srcFail('alerts')"> · 读取失败</span></div>
<div class="srcb" v-show="srcOpen.price"> <div class="srcb" v-show="srcOpen.price">
<div class="sigrow2" v-for="(r,i) in SIG.price_notice.slice(0,16)" :key="i" :class="{old:r.stale}"> <div class="sigrow2" v-for="(r,i) in SIG.price_notice.slice(0,16)" :key="i" :class="{old:r.stale}">
<span class="nm">{{ nm(r.ts_code) }}</span> <span class="nm" style="cursor:pointer;text-decoration:underline dotted" title="点一下看这只票今天的全部信号" @click.stop="openCodeSignals(r.ts_code)">{{ nm(r.ts_code) }}</span>
<span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)" <span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)"
style="font-weight:600">{{ r.direction }}</span> style="font-weight:600">{{ r.direction }}</span>
<span class="sp"></span> <span class="sp"></span>
<span class="disp" :class="lvClass(r.level)" style="padding:0 6px">{{ lvLabel(r.level) }}</span> <span class="disp" :class="lvClass(r.level)" style="padding:0 6px">{{ lvLabel(r.level) }}</span>
<span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div> <span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div>
<div class="sig-none" v-if="!SIG.price_notice.length">今天没有股价异动</div> <div class="sig-fail" v-if="srcFail('alerts')">读取失败:{{ srcFail('alerts').error }}</div>
<div class="sig-none" v-else-if="!SIG.price_notice.length">今天没有股价异动</div>
</div> </div>
</div> </div>
<div class="srcgroup"> <div class="srcgroup">
<div class="srch" @click="toggleSrc('rare')">{{ srcOpen.rare ? '▾' : '▸' }} 少见信号 <div class="srch" @click="toggleSrc('rare')">{{ srcOpen.rare ? '▾' : '▸' }} 少见信号
<span class="n">· {{ SIG.sell.length + SIG.multi.length + SIG.timing.length + SIG.money_flow.length }}</span> <span class="n">· {{ SIG.sell.length + SIG.multi.length + SIG.timing.length + SIG.money_flow.length }}</span>
<span class="help" data-tip="这几类平时不出现,一出现就要紧:决策系统建议卖出、多个来源同时报警、买入时机、资金流强度。">?</span></div> <span class="help" data-tip="这几类平时不出现,一出现就要紧:决策系统建议卖出、多个来源同时报警、买入时机、资金流强度。">?</span>
<span class="src-err" v-if="srcFail('alerts') || srcFail('sell_actions') || srcFail('intraday')"> · 读取失败</span></div>
<div class="srcb" v-show="srcOpen.rare"> <div class="srcb" v-show="srcOpen.rare">
<div class="muted" style="margin:4px 0 3px" v-if="SIG.sell.length">决策系统建议卖出</div> <div class="muted" style="margin:4px 0 3px" v-if="SIG.sell.length">决策系统建议卖出</div>
<div class="sigrow2" v-for="(r,i) in SIG.sell.slice(0,12)" :key="'s'+i" <div class="sigrow2" v-for="(r,i) in SIG.sell.slice(0,12)" :key="'s'+i"
@ -745,7 +792,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
:title="r.reason ? '点一下看理由' : '这条没有理由'"> :title="r.reason ? '点一下看理由' : '这条没有理由'">
<span class="muted mono" style="font-size:10px;width:9px;flex:none" <span class="muted mono" style="font-size:10px;width:9px;flex:none"
:style="{visibility: r.reason ? 'visible' : 'hidden'}">{{ sellOpen[r.ts_code+'_'+i] ? '▾' : '▸' }}</span> :style="{visibility: r.reason ? 'visible' : 'hidden'}">{{ sellOpen[r.ts_code+'_'+i] ? '▾' : '▸' }}</span>
<span class="nm">{{ nm(r.ts_code) }}</span> <span class="nm" style="cursor:pointer;text-decoration:underline dotted" title="点一下看这只票今天的全部信号" @click.stop="openCodeSignals(r.ts_code)">{{ nm(r.ts_code) }}</span>
<span class="disp sig-down">{{ r.dominant_signal==='take_profit'?'止盈':'建议卖出' }}</span> <span class="disp sig-down">{{ r.dominant_signal==='take_profit'?'止盈':'建议卖出' }}</span>
<span class="muted" style="font-size:11.5px" v-if="confPct(r.confidence)!=null">把握 {{ confPct(r.confidence) }}</span> <span class="muted" style="font-size:11.5px" v-if="confPct(r.confidence)!=null">把握 {{ confPct(r.confidence) }}</span>
<span class="sp"></span> <span class="sp"></span>
@ -755,7 +802,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</div> </div>
<div class="muted" style="margin:8px 0 3px" v-if="SIG.multi.length">多个来源同时报警</div> <div class="muted" style="margin:8px 0 3px" v-if="SIG.multi.length">多个来源同时报警</div>
<div class="sigrow2" v-for="(r,i) in SIG.multi.slice(0,10)" :key="'m'+i" :class="{old:r.stale}"> <div class="sigrow2" v-for="(r,i) in SIG.multi.slice(0,10)" :key="'m'+i" :class="{old:r.stale}">
<span class="nm">{{ nm(r.ts_code) }}</span> <span class="nm" style="cursor:pointer;text-decoration:underline dotted" title="点一下看这只票今天的全部信号" @click.stop="openCodeSignals(r.ts_code)">{{ nm(r.ts_code) }}</span>
<span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)" <span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)"
style="font-weight:600">{{ r.direction }}</span> style="font-weight:600">{{ r.direction }}</span>
<span class="sp"></span> <span class="sp"></span>
@ -763,13 +810,13 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div> <span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div>
<div class="muted" style="margin:8px 0 3px" v-if="SIG.timing.length">买入时机</div> <div class="muted" style="margin:8px 0 3px" v-if="SIG.timing.length">买入时机</div>
<div class="sigrow2" v-for="(r,i) in SIG.timing.slice(0,10)" :key="'t'+i"> <div class="sigrow2" v-for="(r,i) in SIG.timing.slice(0,10)" :key="'t'+i">
<span class="nm">{{ nm(r.ts_code) }}</span><span class="sp"></span> <span class="nm" style="cursor:pointer;text-decoration:underline dotted" title="点一下看这只票今天的全部信号" @click.stop="openCodeSignals(r.ts_code)">{{ nm(r.ts_code) }}</span><span class="sp"></span>
<span class="muted" style="font-size:11.5px">参考价 <span class="mono" style="color:var(--ink)">{{ r.price==null?'—':r.price }}</span></span> <span class="muted" style="font-size:11.5px">参考价 <span class="mono" style="color:var(--ink)">{{ r.price==null?'—':r.price }}</span></span>
<span class="muted" style="font-size:11.5px">把握 <span class="mono" style="color:var(--ink)">{{ confPct(r.confidence)==null?'—':confPct(r.confidence) }}</span></span> <span class="muted" style="font-size:11.5px">把握 <span class="mono" style="color:var(--ink)">{{ confPct(r.confidence)==null?'—':confPct(r.confidence) }}</span></span>
<span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div> <span class="mono muted" style="font-size:11px">{{ hm(r.time) }}</span></div>
<div class="muted" style="margin:8px 0 3px" v-if="SIG.money_flow.length">资金流强度</div> <div class="muted" style="margin:8px 0 3px" v-if="SIG.money_flow.length">资金流强度</div>
<div class="sigrow2" v-for="(r,i) in SIG.money_flow.slice(0,10)" :key="'f'+i" :class="{old:r.stale}"> <div class="sigrow2" v-for="(r,i) in SIG.money_flow.slice(0,10)" :key="'f'+i" :class="{old:r.stale}">
<span class="nm">{{ nm(r.ts_code) }}</span> <span class="nm" style="cursor:pointer;text-decoration:underline dotted" title="点一下看这只票今天的全部信号" @click.stop="openCodeSignals(r.ts_code)">{{ nm(r.ts_code) }}</span>
<span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)" <span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)"
style="font-weight:600">{{ r.direction }}</span> style="font-weight:600">{{ r.direction }}</span>
<span class="sp"></span> <span class="sp"></span>
@ -882,15 +929,18 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-table-column label="今天的信号" width="108"> <el-table-column label="今天的信号" width="108">
<template #default="s"> <template #default="s">
<template v-if="heldSigOf(s.row.ts_code).length"> <template v-if="heldSigOf(s.row.ts_code).length">
<span style="cursor:default" <span style="cursor:pointer;text-decoration:underline dotted"
:title="heldSigOf(s.row.ts_code).slice(0,3).map(g=>(g.time||'')+' '+g.cat+' '+(g.direction||'')).join('\n')"> @click.stop="openCodeSignals(s.row.ts_code)"
:title="'点一下看今天的全部信号\n' + heldSigOf(s.row.ts_code).slice(0,3).map(g=>(g.time||'')+' '+g.cat+' '+(g.direction||'')).join('\n')">
<span :class="dirCls(heldSigOf(s.row.ts_code)[0].direction)"> <span :class="dirCls(heldSigOf(s.row.ts_code)[0].direction)">
{{ heldSigOf(s.row.ts_code)[0].cat }} {{ heldSigOf(s.row.ts_code)[0].direction || '' }}</span> {{ heldSigOf(s.row.ts_code)[0].cat }} {{ heldSigOf(s.row.ts_code)[0].direction || '' }}</span>
<br><span class="muted mono" style="font-size:11px">{{ (heldSigOf(s.row.ts_code)[0].time||'').slice(0,5) }}<template <br><span class="muted mono" style="font-size:11px">{{ (heldSigOf(s.row.ts_code)[0].time||'').slice(0,5) }}<template
v-if="heldSigOf(s.row.ts_code).length>1"> +{{ heldSigOf(s.row.ts_code).length-1 }}</template></span> v-if="heldSigOf(s.row.ts_code).length>1"> +{{ heldSigOf(s.row.ts_code).length-1 }}</template></span>
</span> </span>
</template> </template>
<span v-else class="muted">今天没有</span> <span v-else class="muted" style="cursor:pointer;text-decoration:underline dotted"
@click.stop="openCodeSignals(s.row.ts_code)"
title="速览只留了每类最新的若干条,这里的「没有」可能是假的。点一下按代码回扫今天全部。">今天没有</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态 · 下一步" min-width="130"> <el-table-column label="状态 · 下一步" min-width="130">
@ -1840,6 +1890,106 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</div> </div>
<!-- 运维抽屉 --> <!-- 运维抽屉 -->
<!-- ═══ 单票信号抽屉 (2026-09-09): 点一只票看它今天的全部信号 ═══
不用 el-drawer 是因为弹层库的层级从 2000 起自增, 信号栏浮层写死 2010、遮罩 2005,
靠自增未必盖得住。这里的 3000/2995 是写死的, 见样式表里那两条注释。 -->
<teleport to="body">
<div v-if="csOpen">
<div class="cs-scrim" @click="closeCodeSignals"></div>
<aside class="cs-drawer">
<div class="cs-h">
<span class="ttl">{{ nm(csCode) }} 今天的信号</span>
<span class="muted mono" style="font-size:11.5px">{{ csCode }}</span>
<span class="pin" v-if="csHeld(csCode)">持仓</span>
<span class="pin plan" v-if="!csHeld(csCode) && csInPlan(csCode)">计划</span>
<span class="sp" style="flex:1"></span>
<span class="muted" style="font-size:11.5px" v-if="csLoading">正在回扫…</span>
<span class="muted" style="font-size:11.5px" v-else-if="csFromSnap">最近快照,可能不全</span>
<span class="muted" style="font-size:11.5px" v-else>完整,已回扫到今天开盘</span>
<button class="iconbtn" @click="closeCodeSignals" title="关闭"></button>
</div>
<div class="cs-b">
<div class="cs-warn" v-if="csErr">
完整信号没取到({{ csErr }})。下面是页面手上已有的那份快照,它只留了每类最新的若干条,
早盘的信号可能已经被挤掉。
<el-button size="small" style="margin-left:6px" @click="csLoadFull">重试</el-button>
</div>
<div class="cs-warn" v-if="csTruncated.length">
回扫到了上限还没见底({{ csTruncated.map(k=>csSrcLabel[k]||k).join('、') }}),更早的信号没列出来。
要看全,把设置里的「单票信号回扫上限」调大。
</div>
<div class="cs-warn" v-if="csSrcFails.length">
这几路读不到:{{ csSrcFails.map(k=>csSrcLabel[k]||k).join('、') }}。
下面列出的不是这只票的全部,是能读到的那部分。
</div>
<div class="cs-sect">
<div class="h">时间线 · {{ csTimeline.length }} 条</div>
<div class="muted" v-if="!csTimeline.length && !csLoading">
这只票今天没有任何上游信号。</div>
<div v-for="(r,i) in csTimeline" :key="i" class="cs-row" :class="{old:r.stale}">
<span class="t mono">{{ r.time || '—' }}</span>
<span class="s">{{ r.src_label }}</span>
<span>{{ r.cat_label || r.cat || '—' }}</span>
<span v-if="r.direction && r.direction!=='—'" :class="dirCls(r.direction)">{{ r.direction }}</span>
<span v-if="r.level" class="disp" :class="lvClass(r.level)" style="padding:0 6px">{{ lvLabel(r.level) }}</span>
<span v-if="r.price!=null" class="muted">参考价 <span class="mono">{{ r.price }}</span></span>
<span v-if="r.entry_score!=null" class="muted">入场分 <span class="mono">{{ r.entry_score }}</span></span>
<span v-if="confPct(r.confidence)!=null" class="muted">把握 {{ confPct(r.confidence) }}</span>
<span v-if="r.window_net!=null" class="mono" :class="(r.window_net||0)>=0?'clr-up':'clr-down'">{{ moneyCn(r.window_net) }}</span>
<span class="sp" style="flex:1"></span>
<span class="cs-dup" v-if="r.dup>1">同样的又来了 {{ r.dup }} 次</span>
<span class="cs-dup" v-if="r.ts_from==='stream_id'">写入时刻</span>
<span class="ab-stale" v-if="r.stale">已过时</span>
<div style="flex-basis:100%;font-size:12px;line-height:1.45" class="muted" v-if="r.reason">{{ r.reason }}</div>
</div>
</div>
<div class="cs-sect">
<div class="h">当前快照 · 这两路上游只给当前值,不带时间</div>
<div class="cs-row" v-for="(r,i) in ((csData&&csData.snapshot&&csData.snapshot.metrics)||[])" :key="'m'+i">
<span class="s">资金异动</span>
<span v-if="flowText(r.direction)!=='—'" :class="dirCls(r.direction)">{{ flowText(r.direction) }}</span>
<span class="mono" v-if="r.window_net!=null" :class="(r.window_net||0)>=0?'clr-up':'clr-down'">{{ moneyCn(r.window_net) }}</span>
<span class="muted" v-if="r.z_dd!=null">偏离 <span class="mono">{{ r.z_dd }}</span></span>
</div>
<div class="cs-row" v-if="csData&&csData.snapshot&&csData.snapshot.mr&&csData.snapshot.mr.watch">
<span class="s">实况评分</span><span>在关注名单</span>
<span class="mono" style="color:var(--accent)">{{ csData.snapshot.mr.watch.score }}</span>
</div>
<div class="cs-row" v-if="csData&&csData.snapshot&&csData.snapshot.mr&&csData.snapshot.mr.avoid">
<span class="s">实况评分</span><span>在回避名单</span>
<span class="mono" style="color:var(--warn)">{{ csData.snapshot.mr.avoid.score }}</span>
</div>
<div class="muted" v-if="!((csData&&csData.snapshot&&csData.snapshot.metrics)||[]).length
&& !(csData&&csData.snapshot&&csData.snapshot.mr&&(csData.snapshot.mr.watch||csData.snapshot.mr.avoid))">
这只票现在不在资金异动和实况评分里。</div>
</div>
<div class="cs-sect">
<div class="h">今天的账本记录 · {{ csLedger.length }} 条</div>
<div class="muted" v-if="csLedgerErr">读不到账本:{{ csLedgerErr }}</div>
<div class="muted" v-else-if="!csLedger.length">今天这只票没有账本记录。</div>
<div v-for="(r,i) in csLedger" :key="'l'+i" class="cs-row">
<span class="t mono">{{ String(r.created_at||r.decided_at||'').slice(11,16) }}</span>
<span class="s">{{ tx('action', r.action) || r.action }}</span>
<span>{{ tx('verdict', r.verdict) || r.verdict }}</span>
<span class="muted" v-if="r.arbiter">{{ tx('arbiter', r.arbiter) || r.arbiter }}</span>
<div style="flex-basis:100%;font-size:12px;line-height:1.45" class="muted" v-if="r.reason">{{ r.reason }}</div>
</div>
</div>
<div class="cs-sect">
<div class="h">处置</div>
<div class="cs-row"><span>{{ csHeld(csCode) ? '在持仓里' : '没有持仓' }}</span>
<span class="muted">·</span>
<span>{{ csInPlan(csCode) ? '在今天的选股计划里' : '不在今天的选股计划里' }}</span></div>
</div>
</div>
</aside>
</div>
</teleport>
<el-drawer v-model="opsDrawer" title="运维操作 (与调度器同一份实现)" size="46%"> <el-drawer v-model="opsDrawer" title="运维操作 (与调度器同一份实现)" size="46%">
<div class="muted" style="margin-bottom:10px"> <div class="muted" style="margin-bottom:10px">
调度器 (--profile sched) 会自动按调度总表执行; 此处按钮用于未接调度或临时验证时手动触发。 调度器 (--profile sched) 会自动按调度总表执行; 此处按钮用于未接调度或临时验证时手动触发。
@ -1887,6 +2037,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
计划不带价格与金额 —— 买多少/什么价一律 PMS 自己算; 行情取不到价的票会被剔除。 计划不带价格与金额 —— 买多少/什么价一律 PMS 自己算; 行情取不到价的票会被剔除。
</span> </span>
</div> </div>
<div class="muted" v-if="planLoading" style="margin-bottom:8px">
正在读上游选股计划…… 上游装配这份计划要读论断与催化事件,慢的时候要几十秒。
这段时间抽屉是空的,属正常;真读不到会在这里显示原因。</div>
<el-alert v-if="planStatus.ok===false" type="error" effect="dark" show-icon :closable="false" <el-alert v-if="planStatus.ok===false" type="error" effect="dark" show-icon :closable="false"
:title="planStatus.hint || '不可用'" style="margin-bottom:8px"></el-alert> :title="planStatus.hint || '不可用'" style="margin-bottom:8px"></el-alert>
<div class="muted" v-else style="margin-bottom:8px"> <div class="muted" v-else style="margin-bottom:8px">
@ -3027,6 +3180,95 @@ createApp({
out.sort((a, b) => (b.held - a.held) || String(b.time).localeCompare(String(a.time))); out.sort((a, b) => (b.held - a.held) || String(b.time).localeCompare(String(a.time)));
return out; return out;
}); });
// ══════════════ 单票信号抽屉 (2026-09-09, 台账 055) ══════════════
// 信号栏一行 = 上游流里的一条原始消息, 一只活跃票一天十来条, 散在好几个分组里。
// 点一下票名, 把它今天的全部信号按时间倒序摊开。
//
// 两段取数, 顺序不能反:
// 第一段 —— 用手上已有的快照就地渲染, 所以**第一帧就有内容**, 永远不会出现
// "弹了个遮罩但什么都没有"。
// 第二段 —— 同时发按代码回扫的请求, 回来后整份替换成完整版。
// 快照口径注定不全 (告警每类只留最新 20 条), 到下午一只活跃票早盘的信号早被挤掉了,
// 所以第一段只是垫场, 表头会写明它是"可能不全"的。
// 补全失败就保留快照并挂一条黄条, 不清空 —— 有一半总比没有强。
const csOpen = ref(false), csCode = ref(''), csLoading = ref(false),
csErr = ref(''), csData = ref(null), csFromSnap = ref(false);
function _csSnapRows(code) {
const k = code6(code), out = [];
const push = (src, label, r, extra) => out.push(Object.assign(
{ src, src_label: label, time: r.time, ts: 0, dup: 1, ts_code: r.ts_code }, extra || {}));
for (const r of (SIG.value.decision || []))
if (code6(r.ts_code) === k) push('decision', '决策系统广播', r,
{ cat_label: r.action === 'SELL' ? '建议卖出' : '建议买入', direction: r.action === 'SELL' ? '看跌' : '看涨' });
for (const r of (SIG.value.timing || []))
if (code6(r.ts_code) === k) push('timing', '盘中择时层入场', r, { cat_label: '择时层入场', price: r.price, confidence: r.confidence });
for (const r of (SIG.value.sell || []))
if (code6(r.ts_code) === k && r.today !== false) push('sell', '风控卖出动作', r,
{ cat_label: '风控卖出', direction: '看跌', reason: r.reason, confidence: r.confidence });
for (const r of (alertItems.value || []))
if (code6(r.ts_code) === k) push('alert', '盘中告警', r,
{ cat: r.cat, cat_label: r.cat, direction: r.direction, level: r.level, value: r.value, stale: r.stale, age_min: r.age_min });
out.sort((a, b) => String(b.time || '').localeCompare(String(a.time || '')));
return out;
}
function openCodeSignals(code) {
if (!code) return;
csCode.value = String(code);
csErr.value = '';
csFromSnap.value = true;
csData.value = { ts_code: String(code), timeline: _csSnapRows(code), sources: {},
snapshot: { metrics: (SIG.value.metrics || []).filter(r => code6(r.ts_code) === code6(code)),
mr: { watch: ((SIG.value.mr || {}).watch || []).find(r => code6(r.ts_code) === code6(code)) || null,
avoid: ((SIG.value.mr || {}).avoid || []).find(r => code6(r.ts_code) === code6(code)) || null } } };
// 浮层写死 2010、遮罩 2005, 抽屉 3000 盖得住; 但窄屏上浮层铺满整块, 底下再压一层
// 抽屉读起来很乱, 所以先把浮层收掉。双保险的另一半是那条写死的层级。
if (railOpen.value) closeRail();
csOpen.value = true;
csLoadFull();
csLoadLedger();
}
function closeCodeSignals() { csOpen.value = false; }
async function csLoadFull() {
const code = csCode.value;
csLoading.value = true; csErr.value = '';
try {
const d = await call('get', '/api/upstream/signals/' + encodeURIComponent(code));
const body = (d && d.data) ? d.data : d;
if (csCode.value !== code) return; // 期间换了票, 丢弃这份回包
if (body && body.ok && Array.isArray(body.timeline)) {
csData.value = Object.assign({}, csData.value, body);
csFromSnap.value = false;
} else {
csErr.value = (body && body.error) || (d && d.error) || '补全失败';
}
} catch (e) { csErr.value = e.message || String(e); }
finally { if (csCode.value === code) csLoading.value = false; }
}
// 账本按代码查的接口早就支持, 页面从没用过 —— 后端零改动。
const csLedger = ref([]), csLedgerErr = ref('');
async function csLoadLedger() {
const code = csCode.value;
csLedger.value = []; csLedgerErr.value = '';
const d = await call('get', '/api/ledger?ts_code=' + encodeURIComponent(code) + '&limit=40');
if (csCode.value !== code) return;
if (d && d.ok === false) { csLedgerErr.value = d.error || '读不到账本'; return; }
const ymd = new Date().toISOString().slice(0, 10);
csLedger.value = (d.data || []).filter(r => String(r.created_at || r.decided_at || '').slice(0, 10) === ymd);
}
const csTimeline = computed(() => (csData.value && csData.value.timeline) || []);
const csSrcFails = computed(() => {
const src = (csData.value && csData.value.sources) || {};
return Object.keys(src).filter(k => src[k] && src[k].ok === false);
});
const csTruncated = computed(() => {
const t = (csData.value && csData.value.truncated) || {};
return Object.keys(t).filter(k => t[k]);
});
const csSrcLabel = { intraday: '决策与择时层', alerts: '盘中告警', sell_actions: '风控卖出',
metrics: '资金异动', mr: '实况评分' };
function csHeld(code) { return positions.value.some(p => code6(p.ts_code) === code6(code)); }
function csInPlan(code) { return planCodeSet.value.has(code6(code)); }
// 「今天的信号」列与持仓角标都只数今天的 (2026-08-28): 昨天的风控卖出不再冒充今天 // 「今天的信号」列与持仓角标都只数今天的 (2026-08-28): 昨天的风控卖出不再冒充今天
const sigBriefHeldCount = computed(() => sigBrief.value.filter(x => x.held && x.today !== false).length); const sigBriefHeldCount = computed(() => sigBrief.value.filter(x => x.held && x.today !== false).length);
const heldSigMap = computed(() => { const heldSigMap = computed(() => {
@ -3112,10 +3354,20 @@ createApp({
}); });
function macroChipClick() { if (mzAdvicePending.value && mzSig.value) macroAdopt(mzSig.value.key); } function macroChipClick() { if (mzAdvicePending.value && mzSig.value) macroAdopt(mzSig.value.key); }
// 上游信号六路: 五个 Redis 源 + 实况分。**整体失败时不许抹平成空数据** ——
// 原来失败时 sources 被换成 {}, 模板里的"读取失败"角标一个都渲染不出来,
// 页面于是显示"今天没有信号"。风控面板上"没有信号"和"读不到信号"是两回事。
const UP_SRC_KEYS = ['intraday', 'alerts', 'metrics', 'sell_actions', 'mr'];
async function loadUpstream() { async function loadUpstream() {
const d = await call('get', '/api/upstream-signals'); const d = await call('get', '/api/upstream-signals');
upstream.value = (d && d.sources) ? d : { sources: {} }; if (d && d.sources) { upstream.value = d; return; }
const why = (d && d.error) || '接口没有返回数据';
const src = {};
for (const k of UP_SRC_KEYS) src[k] = { ok: false, error: why };
upstream.value = { sources: src, at: '', fail: why };
} }
// 某一路读不到时返回它的错误对象, 否则 null。模板用它把"今天没有"换成"读取失败"。
function srcFail(k) { const a = usrc(k); return (a && a.ok === false) ? a : null; }
async function loadPlans(cid) { async function loadPlans(cid) {
plansOf.value = cid; plansOf.value = cid;
const d = await call('get', '/api/plans?command_id=' + cid); plans.value = d.data || []; const d = await call('get', '/api/plans?command_id=' + cid); plans.value = d.data || [];
@ -3665,7 +3917,10 @@ createApp({
commandsShown, strategiesShown, instrLive, showAllCommands, showAllStrategies, showAllInstr, commandsShown, strategiesShown, instrLive, showAllCommands, showAllStrategies, showAllInstr,
cmdArchivable, stratArchivable, archiveRow, cmdArchivable, stratArchivable, archiveRow,
showOpLog, showSettings, watchCandidates, marketOpen, marketState, lastRefresh, autoRefresh, showOpLog, showSettings, watchCandidates, marketOpen, marketState, lastRefresh, autoRefresh,
upstream, showUpstream, usrc, loadUpstream, upstream, showUpstream, usrc, loadUpstream, srcFail,
csOpen, csCode, csLoading, csErr, csData, csFromSnap, csTimeline, csSrcFails,
csTruncated, csSrcLabel, csLedger, csLedgerErr, csHeld, csInPlan,
openCodeSignals, closeCodeSignals, csLoadFull,
macro, macroGateOn, macroUnavailable, mzLabel, mzColor, mzBarStyle, macro, macroGateOn, macroUnavailable, mzLabel, mzColor, mzBarStyle,
loadMacro, macroScan, macroAdopt, loadMacro, macroScan, macroAdopt,
mzSig, mzAdvicePending, mzChipCls, mzChipText, macroChipClick, mzSig, mzAdvicePending, mzChipCls, mzChipText, macroChipClick,

View File

@ -0,0 +1,159 @@
# 盘中改判与信号聚合方案2026-09-09已批准
> 两件事一份方案。批准后落盘为 PMS 与择时决策系统两个仓库的 docs/盘中改判与信号聚合方案_2026-09-09.md台账记 055。
> 09-09 三条拍板已吸收:盘中改判做到**解锁重问加主动扫描**两层,不改追高原则;重问后仍驳回**静默记账**,页面不单列;择时侧盘中资金流分发**一并打开**。
---
## 一、上下文:为什么做
你提了两件事。第一件是信号栏里同一只股票反复出现,想点开看这只票今天的全部信号。第二件是四方股份的"决策系统判暂不建仓"看起来是一次性的,而这只票今天资金流多次转强、涨幅超过六个点,系统该不该在盘中改判。
我把 09-09 这只票的全过程查了一遍:
| 时间 | 发生了什么 | 谁做的 |
|---|---|---|
| 10:04 | 资金流强度告警触发盘中审计,判**反转买入**(主力净占比由 -12.5% 翻正到 +13.41%),广播买入信号置信 95% | 择时决策系统 |
| 10:05 | 消化这条信号,写留痕 | PMS |
| 10:12 | 送建仓研判,现价 43.15 → **驳回**(把握度 72理由是量能未确认、提纯量不足 | 择时决策系统 |
| 10:12 起 | 当日死锁,扫描每分钟仍在跑但直接跳过这只票 | PMS |
| 13:02 | 资金流告警再次触发,现价 42.47 → **维持**(价格虽破 42.0,但量能闸 1.5 没过) | 择时决策系统 |
**结论分两半。** 择时侧盘中复查了两次,昨夜布下的关注条件也在工作,所以不是完全没复查;但 PMS 的建仓侧确实是一次性的驳回后当天不再问13:02 那次复查的结论进不了建仓这条路。
**而且这次系统没判错。** 从驳回到下午,这只票从 43.15 跌到 42.44,跌了 1.65%;今天被驳回的 11 只票里 5 只在跌、4 只基本没动、只有 1 只涨超四个点。前一天09-08被驳回的那批更是 11 只里 10 只下跌。研判连续三天百分之百驳回,在这个弱市里是合理的,不是过严。
所以要修的是机制,不是结论:
1. **研判驳回后的当日死锁没有解除条件。** 这道闸的注释写明了它的假设——"这只票今天不该从零建仓这个结论当天基本不会翻转"以及防账本刷屏2026-07-29 那次每分钟写一行、一天几千行的教训)。假设合理但不是绝对,缺一个"确实有新信息"的例外。
2. **盘中转多信号只改候选排序、不改建仓资格。** 今天三只票被判转多,一只被死锁挡住、一只候选卡判决是仅展示、一只不在候选池,一个出口都没有。
3. **择时侧没有任何任务主动扫"今天谁在涨、谁在进钱"。** 四个盘中定时任务分别只看持仓、已有意向单的票、昨夜布过防的票、门控白名单。强势票要被看见,必须上游先推告警。
---
## 二、一个必须先讲清的事实:改完之后不会立刻追高买入
这决定了整个方案的取向。我把两只票的择时买入区间算了出来:
| 股票 | 昨夜支撑 / 压力 | 买入区间 | 现价 | 择时会怎么说 |
|---|---|---|---|---|
| 四方股份 | 40.00 / 42.00 | 39.60 到 40.60 | 42.60 | 高于上沿,等待不追 |
| 长源电力 | 24.50 / 27.42 | 24.25 到 25.38 | 26.18 | 高于上沿,等待不追 |
也就是说,即使解锁重问、研判放行、你在页面上点了头,落成指令之后择时仍会说"现价高于买入区间上沿,不追",指令会在窗口期里挂着等价格回落到区间内才真的下单。系统本身也是只提议不自动执行的档位。
**所以解锁重问买到的不是"立刻追高",而是"让这只票进入待办、等回调时自动买入"。** 这与系统写死的那条原则(可以接受买不上)不冲突,也正是你要的"重新评估"。追高那条线本方案不动。
顺带澄清一个容易误会的地方:不追高那道闸算的是**较今日开盘**的涨幅,不是较昨收。四方股份今天较昨收涨 6.2%,但较开盘是 -1.5%,所以它其实没有被这道闸拦过,是研判驳的。
---
## 三、第一件:点股票看它今天的全部信号
### 3.1 现状与量级
信号栏是左侧浮层里的扁平行列表,一行等于上游流里的一条原始消息,不按股票聚合。数据来自 Redis 五个源:盘中告警、决策系统买卖广播、择时层入场、风控卖出动作、资金异动,外加一份实况评分榜。今天告警流 157 条,四方股份一只就占 11 条,正是你说的反复出现。
页面已经有一个按代码分组的实现(持仓表那列"今天的信号"),但被限死在持仓票、只看今天、只能悬停看前三条,而且明确不可点。
### 3.2 做法:抽屉,两段取数
**后端加一个按代码回扫的接口。** 快照口径注定不全(告警每类只留最新 20 条),到下午一只活跃票早盘的信号早被挤掉了。新增 `upstream_signals.by_code(ts_code)``GET /api/upstream/signals/{ts_code}`:分页回扫(每页 500 条,上限可配,默认 5000两条按日期分键的流整键就是今天两条不分键的流风控卖出、资金异动扫到今天零点为止同源同分钟同值的合并成一条并显示重复次数跨源永不合并按票缓存八秒防连点单源失败不拖垮整体返回里逐源标明哪路读不到。只用倒序读取不建消费组、不写任何东西且**只能点击触发,绝不能进轮询**。
**前端抽屉两段取数。** 点开的瞬间用手上的快照渲染,所以第一帧就有内容,永远不会变成"只有遮罩没有信息";同时发按代码回扫的请求,回来后替换成完整版,表头的口径说明从"最近快照,可能不全"翻成"完整,已回扫到今天开盘"。补全失败就保留快照并在抽屉里挂一条黄条,带重试按钮。
抽屉里分七段:补全失败提示、回扫未见底提示、分源失败角标、时间线、当前快照(资金异动与实况评分这两路上游只给当前值不带时间,单独成段,不编造时间戳)、这只票今天的账本记录、持仓与候选池处置。账本那条接口早就支持按代码查,只是页面从没用过,后端零改动。
**三处入口可点**:信号栏每行的股票名、持仓表那列(现在不可点,连"今天没有"也要可点,因为速览口径下的"没有"可能是假的)、消息栏的股票名。风控卖出那一行的股票名要用阻止冒泡,否则点名字会顺手把理由折叠条也翻一下。
### 3.3 顺带修遮罩空白
你看到的"弹出遮罩层但没有任何信息",成因是四条,一并修:
1. **浮层打开时不等数据、不看空、失败不兜底。** 改成:面板先开(关闭随时可用),再发请求,加载态显示在面板里,失败显示原因加重试按钮,面板不自动关(自动关看起来像"点了没反应",比空面板更糟)。
2. **接口整体失败时把错误抹平成空数据**,导致模板里那些"读取失败"角标一个都显示不出来,页面看起来就是"今天什么信号都没有"——在风控面板上这是最危险的一种谎。改成保留错误并给每一路造一条失败标记,模板一个字不用改,角标立刻就亮。另外有三处配套:告警派生的四个分组本来就没有失败角标要补上;各组的空文案要让位给失败态;那个失败角标的样式选择器写漏了信号栏,即使显示出来也看不出是错误。
3. **错误横幅被遮罩压住。** 顶部那条横幅在文档流里,遮罩、浮层、弹层的层级全都压得住它,而且往下滚一屏就看不见。改成在遮罩之上再显示一份浮动的。
4. **新抽屉必须显式抬层级。** 弹层库的层级从 2000 起自增,而信号栏浮层写死 2010、遮罩 2005新抽屉不抬层就会落在它们底下屏幕上正好只剩一层灰——这是同一个症状的另一张脸。双保险打开抽屉前先收浮层抽屉本身写死更高的层级。选股计划抽屉也是同一种病先开抽屉再等数据上游不通时空白挂满一分钟一并加加载提示。
---
## 四、第二件:盘中改判
### 4.1 解锁重问PMS 侧)
**插入点**是那个返回"今天已被研判驳回"键集合的函数。它现在无条件返回被驳回的票,改成在返回前逐只判一次解锁条件,满足的从集合里剔掉——没有解锁条件时行为逐字相同。
**解锁条件一次定死**,四条满足任一即解锁(写台账,不按读数回调):
| 条件 | 判据 | 为什么 |
|---|---|---|
| 择时判转多 | 当日账本里这只票有转多留痕,且时间**晚于**驳回时刻 | 驳回之前就有的信号研判已经看过了,不算新信息 |
| 价格显著上行 | 现价较驳回时涨幅不低于 2%,且当前有看涨方向的资金流告警 | 单看价格容易被噪音触发,要资金面同时确认 |
| 关注条件升级 | 择时侧审计表当日出现反转买入或升级判定,且晚于驳回时刻 | 昨夜布下的预案在盘中被兑现,是最硬的新信息 |
| 主动扫描命中 | 第二件里新增的强势扫描判定这只票转强,且晚于驳回时刻 | 与上一条同源,走新入口 |
**限次:每票每天最多解一次。** 判据是当日账本里这只票的新建仓驳回行数——第一次驳回一行,重问后再驳回就是两行,两行及以上不再解锁。不用新表、不用新参数。这直接回应了原设计防账本刷屏的顾虑:最坏情况每票每天多写一行。
**试算过今天的实际量级**11 只被驳回的票里只有 1 只会被重问(长源电力,较驳回涨 4.05%,三条资金流看涨告警)。四方股份虽然有五条看涨告警,但价格较驳回时跌了 1.5%,不会被重问——这是对的,它的情况没有变好。
**重问要绕过择时侧的三十分钟缓存。** 那层缓存的键里没有任何输入指纹,价格资金全变了三十分钟内仍回同一份裁决。做法是研判请求里带一个强制标记,择时侧读缓存前先看这个标记。
**重问仍驳回就静默记账**(你的拍板):账本记一行并标明是重问后的结论,页面不单列。
**开关** `PMS_JUDGE_RECHECK_ENABLED`,默认开;解锁条件的阈值各有参数,都要同时登记进运行参数白名单,否则页面上改不了。
### 4.2 主动扫描(择时侧)
新增一个每五分钟的盘中定时任务,扫两件事:当日涨幅与资金流转强。命中的走已有的盘中审计入口,终点还是那条已经通了的管道(判反转买入 → 广播信号 → 触发重算 → 翻案上游拒单)。
**扫哪些票。** 择时侧读不到 PMS 的候选池(不同库),取它自己的:昨夜有结论且定性不是卖出、回避、剔除三类的票。昨天这批是 506 只去掉卖出类还剩四百多只每轮读四百多个实时快照Redis 上是秒级,可接受。这个池天然覆盖了 PMS 的候选(候选来自同一批昨夜结论)。
**当日涨幅要自己算**(现在系统里没有):现价从已有的实时快照函数取,昨收从日线取最近一根。
**阈值一次定死**:当日涨幅不低于 3%、当日量比不低于 1.5、资金流方向为看涨,三条同时满足才派单。节流用现有的锁族,同一只票两小时内不重复上大脑;每轮派单数量另设上限(初值 10防止普涨日一次性打出几十次模型调用。
**扫描结果分两种落法**:达到阈值的派单上研判;只达到涨幅一条、资金没跟上的只写一行日志,不占模型额度。
### 4.3 资金流分发打开(择时侧)
把盘中资金异动的分发模式从"只写时间线"改成全量,让资金流转强能直接触发碰撞与研判。这是原设计里标注的灰度状态,注释写着"观察一天量级正常后改 full"。
**风险要正视**:资金异动流是上游每十五分钟推一整批全市场,今天累计已有十万条。打开后要盯三个读数:每天触发研判的次数、模型调用量、有没有把别的链路挤慢。回退就是把开关改回去、重启一个容器。建议与前两件分开一天上线,出问题分得清是谁。
### 4.4 顺带修三处小问题
- **驳回行没记两个期限的头。** 提议行记了,驳回行只记了把握度。补上,顺便把基线快照(驳回时的当日涨幅、量比、资金流方向)一起记进去,解锁判断要用。
- **低把握驳回转交人的阈值参数没登记进运行参数白名单**09-08 就发现了),页面上改不了、写不进去,代码走默认值 60。补登记。
- **不追高那道闸的口径名不副实**:变量名与错误信息都说"当日涨幅",实际算的是较今日开盘的涨幅。高开八个点然后横盘的票会被放行,低开三个点涨到四个点的票反而被拦。这次**只记台账不改**——改它会影响所有买入,要单独评估。
---
## 五、判收
**第一件**:抽屉从三处入口打开都拿到同一份内容;补全失败时抽屉里有内容加黄条,不是空白;拦掉上游接口后每一路都显示"读取失败"、没有任何一处说"今天没有";窄屏从浮层里点股票名,抽屉盖在最上面而不是只剩一层灰。
**第二件**:连续三个交易日观察——每天解锁重问几次(预期个位数)、重问后放行几次、放行的票最终有没有成交;主动扫描每天命中几只、其中几只本来就在候选池里;资金流打开后每天触发研判的次数与模型用量。两周后拿读数决定要不要调阈值。
---
## 六、风险与回退
- **解锁重问导致账本刷屏**:每票每天限一次,最坏情况多写一行。这是原设计最担心的事,限次是硬约束。
- **重问放行后买不进**:这是预期行为(现价高于买入区间上沿),指令挂在窗口期里等回调。要在页面上说清楚,不然人会以为系统没执行。
- **主动扫描增加模型调用**:只扫候选池、三条阈值同时满足、两小时节流,预计每天个位数。
- **资金流分发打开量级失控**:单独一天上线,盯三个读数,回退是改开关加重启。
- **前端没有测试框架**:新增静态断言把三处入口与层级设置钉死,另有六条回归手工步骤。
每件事各有开关,全部可以单独关掉回到今天的行为。
---
## 附录 · 实现要点(可不读)
**第一件PMS**`app/services/upstream_signals.py` 加 `by_code` 与分页回扫辅助函数;`app/web/main.py` 加一个路由;`app/services/param_store.py` 登记回扫上限;`app/web/static/index.html` 加抽屉、三处入口、遮罩四处修复与一条链接样式;新增 `scripts/test_batch24_units.py`(代码归一、分页边界、按今天截断、去重口径、单源失败、缓存、只读守卫、页面静态断言),在 `scripts/run_tests.py` 的清单与例数表登记,路由清单也要加。
**第二件PMS**`app/services/proposal_service.py` 的驳回键函数加解锁判断、驳回落账补基线快照与两个头;`app/services/judge.py` 的请求体加强制标记;`app/services/param_store.py` 登记开关与阈值;新增单测批次。
**第二件(择时决策系统)**`workers/` 下新增强势扫描任务并在调度表登记;当日涨幅自算复用现有的实时快照函数与日线取数;`workers/tasks_intraday.py` 的缓存读取处认强制标记;`config/settings.py` 改分发模式与新增阈值;新增 `scripts/selftest_*.py`
**验证命令**PMS 在 155 跑 `make test` 与页面手工步骤;择时在 188 跑自检脚本与看日志读数。容器重启与镜像重建各自需要你点头PMS 侧仍先跑测试、避开开盘。

View File

@ -56,6 +56,9 @@
强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱 强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱
来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流/ 来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流/
到价提议挂着时止损照落并作废该提议 (17 ) 到价提议挂着时止损照落并作废该提议 (17 )
test_batch24_units.py 点股票看它今天全部信号 (2026-09-09 方案第三节): 代码归一与跨市场不误配/
分页回扫边界与按今天零点截断/五源解析与超龄标陈旧/去重同源合并跨源不合并/
单源失败逐源标明与按票缓存/只读守卫/页面三处入口与层级 (9 )
test_batch23_units.py 公司质地与建议方案接入 (2026-09-09 接入方案): 解析层收公司深度两键/整句送研判可关/建议 test_batch23_units.py 公司质地与建议方案接入 (2026-09-09 接入方案): 解析层收公司深度两键/整句送研判可关/建议
方案矩阵与研判补档/持仓页公司块 方案矩阵与研判补档/持仓页公司块
test_batch22_units.py 逻辑状态四态接入 (2026-09-07 第三件): 解析层收逻辑状态/硬数字带它而研判 test_batch22_units.py 逻辑状态四态接入 (2026-09-07 第三件): 解析层收逻辑状态/硬数字带它而研判
@ -113,6 +116,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch16_units.py", "test_batch17_units.py", "test_batch16_units.py", "test_batch17_units.py",
"test_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py", "test_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py",
"test_batch21_units.py", "test_batch22_units.py", "test_batch23_units.py", "test_batch21_units.py", "test_batch22_units.py", "test_batch23_units.py",
"test_batch24_units.py",
"test_page_enum_guard.py", "test_page_enum_guard.py",
"test_wiring.py"] "test_wiring.py"]

View File

@ -0,0 +1,281 @@
# -*- coding: utf-8 -*-
"""点股票看它今天全部信号 (2026-09-09 方案第三节, 台账 055)。全部离线, 不连 Redis 不连库。
1. 代码归一与匹配: 四种写法归一跨市场同六位不误配认不出的回错误不抛
2. 分页回扫边界: 手工减一的流 id 边界按今天零点截断上限截断标记
3. 五源解析: 决策与择时层分流告警超龄标陈旧风控卖出载荷在 data 资金异动时间取流 id
4. 去重口径: 同源同分钟同值合并并计数跨源永不合并
5. 韧性: 单源失败不拖垮整体且逐源标明按票缓存实况分不编时间戳
6. 只读守卫与页面静态断言
"""
import io
import os
import re
import sys
import time
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services import param_store # noqa: E402
from app.services import upstream_signals as us # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
class _Patch:
def __init__(self): self._saved = []
def __enter__(self): return self
def __call__(self, obj, name, value):
self._saved.append((obj, name, getattr(obj, name))); setattr(obj, name, value)
def __exit__(self, *a):
for obj, name, v in reversed(self._saved):
setattr(obj, name, v)
def _today_ms(h, m):
t = time.localtime()
return int(time.mktime((t.tm_year, t.tm_mon, t.tm_mday, h, m, 0, 0, 0, -1)) * 1000)
class _FakeRedis:
"""只实现 xrevrange 的假客户端。streams: {key: [(id, fields), ...]} 按 id 升序存, 读时倒序。"""
def __init__(self, streams=None, zsets=None, boom=None):
self.streams = streams or {}
self.zsets = zsets or {}
self.boom = boom or set()
self.calls = []
def xrevrange(self, key, max="+", min="-", count=None):
if key in self.boom:
raise RuntimeError("stream down: %s" % key)
self.calls.append((key, max, count))
rows = sorted(self.streams.get(key, []), key=lambda r: _idk(r[0]), reverse=True)
if max != "+":
rows = [r for r in rows if _idk(r[0]) <= _idk(max)]
return rows[:count] if count else rows
def zrevrange(self, key, s, e, withscores=False):
return self.zsets.get(key, [])
def _idk(sid):
a, b = str(sid).split("-")
return (int(a), int(b))
@case("代码归一: 四种写法同归、跨市场同六位不误配、认不出回错误")
def test_code_norm():
assert us._norm_code("SH601126") == "601126.SH"
assert us._norm_code("601126.SH") == "601126.SH"
assert us._norm_code("sh601126") == "601126.SH"
assert us._norm_code("601126") == "601126", us._norm_code("601126")
assert us._norm_code("洋垃圾") == "" and us._norm_code(None) == ""
assert us._same_code("SH601126", "601126.SH") and us._same_code("601126", "601126.SZ")
assert not us._same_code("601126.SH", "601126.SZ"), "带后缀且不同市场必须判不同"
assert not us._same_code("601126.SH", "000001.SZ") and not us._same_code("", "601126.SH")
out = us.by_code("洋垃圾")
assert out["ok"] is False and out["timeline"] == [], out
@case("分页回扫边界: 手工减一、翻页不重不漏、上限截断标记")
def test_scan_paging():
rows = [("%d-0" % (1000 + i), {"i": i}) for i in range(12)]
c = _FakeRedis({"k": rows})
got, trunc = us._scan_stream(c, "k", cap=100, page=5)
assert len(got) == 12 and not trunc, (len(got), trunc)
assert [f["i"] for _s, f in got] == list(range(11, -1, -1)), "必须倒序且不重不漏"
assert len(c.calls) == 3 and c.calls[1][1] == "1006-18446744073709551615", c.calls
got2, trunc2 = us._scan_stream(c, "k", cap=7, page=5)
assert len(got2) == 7 and trunc2 is True, (len(got2), trunc2)
assert us._prev_id("100-3") == "100-2" and us._prev_id("100-0") == "99-18446744073709551615"
assert us._prev_id("0-0") == "-" and us._prev_id("garbage") == "-"
assert us._id_ms("1757000000000-5") == 1757000000000 and us._id_ms("x") == 0
got3, trunc3 = us._scan_stream(c, "缺这条流", cap=100)
assert got3 == [] and not trunc3
@case("不分键的流扫到今天零点为止, 且不算截断")
def test_stop_before_today():
y = us._today_start_ms() - 3600_000
rows = [("%d-0" % y, {"a": "昨天"}), ("%d-0" % _today_ms(9, 40), {"a": "今天"})]
c = _FakeRedis({"k": rows})
got, trunc = us._scan_stream(c, "k", cap=100, page=50, stop_before_ms=us._today_start_ms())
assert len(got) == 1 and got[0][1]["a"] == "今天", got
assert trunc is False, "今天的已经扫全, 不能报截断"
@case("五源解析: 决策与择时层分流、告警超龄标陈旧、卖出载荷在 data 里、资金异动取流 id")
def test_parse_sources():
ymd = "2026-09-09"
itd = [("1-0", {"ts_code": "601126.SH", "producer_id": "bionic_decider", "action": "BUY",
"trigger_time": _today_ms(10, 4), "suggested_price": "43.15", "confidence": "0.95",
"signal_type": "REVERSAL_BUY"}),
("2-0", {"ts_code": "601126.SH", "producer_id": "intraday_timing_v2", "action": "BUY",
"trigger_time": _today_ms(10, 30), "suggested_price": "43.0", "entry_score": "78"}),
("3-0", {"ts_code": "000001.SZ", "producer_id": "bionic_decider", "action": "BUY",
"trigger_time": _today_ms(10, 31)})]
rows, n, _t = us._bycode_intraday(_FakeRedis({"intraday_signals:%s" % ymd: itd}), ymd, "601126.SH", 5000)
assert n == 3 and len(rows) == 2, (n, len(rows))
assert rows[0]["src"] == "decision" and rows[0]["time"] == "10:04" and rows[0]["confidence"] == 0.95
assert rows[1]["src"] == "timing" and rows[1]["entry_score"] == 78.0, rows[1]
old = _today_ms(9, 35)
with _Patch() as p:
p(us, "_alert_max_age_sec", lambda: 60)
al = [("1-0", {"ts_code": "601126.SH", "source": "fund_flow_strength", "level": "HIGH",
"value": "13.41", "trigger_time": old}),
("2-0", {"ts_code": "601126.SH", "source": "未知源", "level": "MID", "value": "1",
"trigger_time": int(time.time() * 1000), "metadata": '{"direction": "down"}'})]
arows, an, _ = us._bycode_alerts(_FakeRedis({"intraday_alerts:%s" % ymd: al}), ymd, "SH601126", 5000)
assert an == 2 and len(arows) == 2
stale = [r for r in arows if r["time"] == "09:35"][0]
assert stale["stale"] is True and stale["cat"] and stale["age_min"] >= 0, stale
unknown = [r for r in arows if not r["stale"]][0]
assert unknown["cat"] == "other" and unknown["direction"] == "看跌", unknown
sell = [("1-0", {"data": '{"ts_code": "601126.SH", "action": "SELL", "confidence": 0.8,'
' "dominant_signal": "BREAK_DOWN", "llm_reason": "跌破支撑",'
' "timestamp": %d}' % _today_ms(14, 10)})]
srows, _n, _t = us._bycode_sell(_FakeRedis({"bionic:signals:llm_sell_actions": sell}), "601126.SH", 5000)
assert len(srows) == 1 and srows[0]["time"] == "14:10" and srows[0]["reason"] == "跌破支撑", srows
mid = _today_ms(13, 41)
met = [("%d-0" % mid, {"data": '{"ts_code": "601126.SH", "direction": "up", "z_dd": 2.4, "window_net": 1200}'})]
mrows, _n, _t = us._bycode_metrics(_FakeRedis({"mtf:intraday:stream:metrics": met}), "601126.SH", 5000)
assert len(mrows) == 1 and mrows[0]["time"] == "13:41" and mrows[0]["z_dd"] == 2.4
assert mrows[0]["ts_from"] == "stream_id", "时间取自流 id 是写入时刻, 必须标出来"
@case("去重: 同源同分钟同值合并并计数; 跨源永不合并")
def test_dedup():
a = us._row("alert", "盘中告警", _today_ms(10, 4), cat="fund_flow_strength", level="HIGH", value=13.41)
b = us._row("alert", "盘中告警", _today_ms(10, 4), cat="fund_flow_strength", level="HIGH", value=13.41)
c = us._row("alert", "盘中告警", _today_ms(10, 4), cat="fund_flow_strength", level="HIGH", value=20.0)
d = us._row("decision", "决策系统广播", _today_ms(10, 4), action="BUY", price=43.15)
out = us._dedup([a, b, c, d])
assert len(out) == 3, [(x["src"], x["value"], x["dup"]) for x in out]
assert out[0]["dup"] == 2 and out[1]["dup"] == 1 and out[2]["src"] == "decision"
@case("单源失败不拖垮整体且逐源标明; 缓存按票生效; 实况分不编时间戳")
def test_resilience_and_cache():
ymd = time.strftime("%Y-%m-%d")
good = _FakeRedis({"intraday_signals:%s" % ymd: [
("1-0", {"ts_code": "601126.SH", "producer_id": "bionic_decider", "action": "BUY",
"trigger_time": _today_ms(10, 4), "suggested_price": "43.15"})]},
boom={"intraday_alerts:%s" % ymd, "mtf:intraday:stream:metrics"})
hits = {"n": 0}
def fake_c208(db):
hits["n"] += 1
return good
def fake_mr(_c):
return {"watch": [{"ts_code": "601126.SH", "score": 88}], "avoid": []}
us._BYCODE_CACHE.clear()
with _Patch() as p:
p(us, "_c208", fake_c208)
p(us, "_c214", lambda: object())
p(us, "_read_mr", fake_mr)
out = us.by_code("SH601126")
n1 = hits["n"]
out2 = us.by_code("601126.SH") # 归一后同一只票, 走缓存
n2 = hits["n"]
out3 = us.by_code("601126.SH", force=True)
assert out["ok"] and len(out["timeline"]) == 1, out
assert out["sources"]["intraday"]["ok"] and out["sources"]["sell_actions"]["ok"]
assert out["sources"]["alerts"]["ok"] is False and "error" in out["sources"]["alerts"], out["sources"]
assert out["sources"]["metrics"]["ok"] is False and out["sources"]["mr"]["ok"] is True
assert n2 == n1 and out2 is out, "同一只票八秒内必须命中缓存"
assert out3 is not out, "force 必须绕开缓存"
assert out["snapshot"]["mr"]["watch"]["score"] == 88 and out["snapshot"]["mr"]["avoid"] is None
assert all(r.get("ts") for r in out["timeline"]), "时间线每行都要有真实时间戳"
assert "mr" not in {r["src"] for r in out["timeline"]}, "实况分没有时间, 绝不许排进时间线"
@case("回扫上限参数已登记 (页面能改) 且有范围")
def test_param_registered():
assert "PMS_UPSTREAM_BYCODE_SCAN" in param_store.RUNTIME_EXTRA
assert "PMS_UPSTREAM_BYCODE_SCAN" in param_store._RANGES
lo, hi = param_store._RANGES["PMS_UPSTREAM_BYCODE_SCAN"]
assert lo >= 100 and hi <= 50000
with _Patch() as p:
p(param_store, "get_int", lambda k, d=0: 1)
assert us._bycode_scan() >= 100, "下限兜底防把回扫调成零"
@case("只读守卫: 按代码回扫这段不许出现任何写入或消费组调用")
def test_readonly_guard():
src = io.open(us.__file__.replace(".pyc", ".py"), encoding="utf-8").read()
seg = src[src.index("# ================================================================ 按代码回扫"):]
for bad in ("xadd", "xack", "xgroup", "xreadgroup", "xdel", "xtrim", "delete(", ".set(", "zadd"):
assert bad not in seg.lower().replace("_bycode_cache.pop", ""), bad
assert "xrevrange" in seg and "zrevrange" not in seg
@case("页面静态断言: 三处入口、抽屉层级压得住浮层与遮罩、不进轮询、失败态不说今天没有")
def test_page_static():
from app.web import main as web
html = io.open(os.path.join(web.STATIC_DIR, "index.html"), encoding="utf-8").read()
assert "/api/upstream/signals/" in html, "页面没接单票信号接口"
assert html.count("openCodeSignals(") >= 6, "信号栏、持仓表、消息栏三处入口都要接上"
# 层级: 弹层库从 2000 起自增, 浮层写死 2010、遮罩 2005。抽屉不显式抬层就落在它们底下,
# 屏幕上正好只剩一层灰 —— 这就是遮罩空白那个症状的另一张脸。
rail = int(re.search(r"\.rail\.open\{[^}]*z-index:\s*(\d+)", html).group(1))
scrim = int(re.search(r"\.rail-scrim\{[^}]*z-index:\s*(\d+)", html).group(1))
dz = int(re.search(r"\.cs-drawer\{[^}]*z-index:\s*(\d+)", html).group(1))
sz = int(re.search(r"\.cs-scrim\{[^}]*z-index:\s*(\d+)", html).group(1))
assert dz > rail and dz > scrim and sz > rail, (dz, sz, rail, scrim)
assert "if (railOpen.value) closeRail();" in html, "打开抽屉前要先收浮层 (双保险的另一半)"
# 只能点击触发: 回扫接口全页只出现一次, 且不在整页刷新与任何定时器里
assert html.count("'/api/upstream/signals/'") == 1
load_all = html[html.index("async function loadAll()"):][:1600]
assert "csLoadFull" not in load_all and "openCodeSignals" not in load_all, "回扫绝不许进整页刷新"
for fn in ("startAutoRefresh", "wsPollSync"):
if fn in html:
seg = html[html.index("function " + fn):][:1200]
assert "csLoadFull" not in seg, fn + " 里不许调回扫"
# 读不到时不许说"今天没有"
assert html.count("srcFail(") >= 8, "各路空文案都要给失败态让位"
assert "别当成平安无事" in html
@case("路由清单收了新接口")
def test_route_registered():
from app.web.main import app
paths = {r.path for r in app.routes}
assert "/api/upstream/signals/{ts_code}" in paths
src = io.open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "test_wiring.py"), encoding="utf-8").read()
assert "/api/upstream/signals/{ts_code}" in src, "test_wiring 的路由清单也要加, 否则漏了没人发现"
def main():
ok = 0
for name, fn in RESULTS:
try:
fn()
ok += 1
print(" ok " + name)
except Exception:
print(" FAIL " + name)
traceback.print_exc()
print("-" * 60)
if ok == len(RESULTS):
print("ALL PASS (%d cases)" % ok)
return 0
print("FAILED %d/%d" % (len(RESULTS) - ok, len(RESULTS)))
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -647,6 +647,7 @@ def _():
"/api/ops/reconcile", "/api/ops/daily-settle", "/api/ops/report", "/api/ops/reconcile", "/api/ops/daily-settle", "/api/ops/report",
"/api/ops/downstream-schema", "/api/industry", "/api/ops/downstream-schema", "/api/industry",
"/api/upstream/plan", "/api/ops/plan-refresh", "/api/upstream/plan", "/api/ops/plan-refresh",
"/api/upstream/signals/{ts_code}", # 2026-09-09 点股票看它今天全部信号
"/api/ops/strategy-attach-scan"] # 2026-08-25 策略自动挂载试算位 "/api/ops/strategy-attach-scan"] # 2026-08-25 策略自动挂载试算位
missing = [p for p in need if p not in paths] missing = [p for p in need if p not in paths]
assert not missing, missing assert not missing, missing