点股票看它今天的全部信号:按代码回扫接口、抽屉、遮罩空白四处修复(台账 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:
parent
3c186ac217
commit
0c8d2a7d6c
|
|
@ -55,6 +55,9 @@ RUNTIME_EXTRA = {
|
|||
"PMS_UPSTREAM_ALERT_SCAN": (300, int, "告警扫描窗: 每轮从告警流取最近 N 条再分类限量 (窗要大于各类上限之和, 防 price_notice 等高频源把风控告警挤出最新N)"),
|
||||
"PMS_UPSTREAM_ALERT_PER_CAT": (20, int, "告警每类上限: 同一类型最多显示最新 N 条 (砍单类刷屏, 不挤别的类)"),
|
||||
"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 每次扫描写入, 扫描层与策略层只读。
|
||||
# 用运行参数承载 (不进策略 state、不另建表) —— 与 PMS_STRATEGY_BUYPAUSE 同一手法。
|
||||
"PMS_MACRO_GATE_STATE": ("", str, "宏观闸状态 (macro_service 维护的 JSON: active/ymd/原因), 页面与扫描层只读, 勿手改"),
|
||||
|
|
@ -442,6 +445,7 @@ _RANGES = {
|
|||
# 任务会先被 celery 打死 (而且是在已经落了一部分表之后)。
|
||||
"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_BYCODE_SCAN": (100, 50000), # 下限 100 保证至少扫得到一页
|
||||
# 宏观择时层
|
||||
"PMS_MACRO_HOT_TH": (0, 100), "PMS_MACRO_COLD_TH": (-100, 0),
|
||||
"PMS_MACRO_EXIT_BAND": (0, 100), "PMS_MACRO_CONFIRM_DAYS": (1, 10),
|
||||
|
|
|
|||
|
|
@ -282,3 +282,308 @@ def snapshot(force: bool = False) -> dict:
|
|||
|
||||
_cache["at"], _cache["data"] = now, 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
|
||||
|
|
|
|||
|
|
@ -692,6 +692,20 @@ def api_upstream_signals():
|
|||
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)
|
||||
@app.get("/api/macro/status")
|
||||
def api_macro_status():
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
.sub2{color:var(--muted);font-size:12.5px;margin:-4px 0 12px;}
|
||||
.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;}
|
||||
.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;}
|
||||
|
|
@ -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 .up-cell{margin:0;}
|
||||
.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-row{padding:3px 0;border-bottom:1px solid var(--hair);}
|
||||
.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 .el-button{width:100%;}
|
||||
/* 登录遮罩与登录人胶囊 (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;
|
||||
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;
|
||||
|
|
@ -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>
|
||||
</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
|
||||
@close="err=''"></el-alert>
|
||||
<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="mb-kind" :class="'k-'+msgLatest.kind" style="width:auto;padding:1px 6px">{{ kindLabel(msgLatest.kind) }}</span>
|
||||
<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.dir" :class="dirCls(msgLatest.dir)">{{ msgLatest.dir }}</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="mb-kind" :class="'k-'+m.kind">{{ kindLabel(m.kind) }}</span>
|
||||
<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.dir" :class="dirCls(m.dir)">{{ m.dir }}</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'">
|
||||
<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">
|
||||
<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="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 v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</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}">
|
||||
<span class="t mono">{{ hm(g.time) || '—' }}</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 v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</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="pin" v-if="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 v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</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="sigrow2" v-for="(r,i) in SIG.metrics.slice(0,20)" :key="i"
|
||||
: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)"
|
||||
style="font-weight:600">{{ flowText(r.direction) }}</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"
|
||||
:class="dirCls(r.direction)">{{ zBand(r.z_dd).label }}</span>
|
||||
</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>
|
||||
</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="muted" style="margin:4px 0 3px">关注(分高)</div>
|
||||
<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>
|
||||
<div class="sig-none" v-if="!(SIG.mr.watch||[]).length">暂无</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">
|
||||
<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>
|
||||
<div class="sig-none" v-if="!(SIG.mr.avoid||[]).length">暂无</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>
|
||||
<div class="srcb" v-show="srcOpen.decision">
|
||||
<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="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 class="srcgroup">
|
||||
<div class="srch" @click="toggleSrc('qrs')">{{ srcOpen.qrs ? '▾' : '▸' }} 日线形态
|
||||
<span class="n">· {{ SIG.qrs.length }}</span>
|
||||
<span class="src-err" v-if="srcFail('alerts')"> · 读取失败</span>
|
||||
<span class="help" data-tip="决策系统的日线形态指标(内部代号 QRS),看多方和空方的力量是否对称。看涨表示多方占优。">?</span></div>
|
||||
<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}">
|
||||
<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)"
|
||||
style="font-weight:600">{{ r.direction }}</span>
|
||||
<span class="sp"></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>
|
||||
<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 class="srcgroup">
|
||||
<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="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)"
|
||||
style="font-weight:600">{{ r.direction }}</span>
|
||||
<span class="sp"></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>
|
||||
<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 class="srcgroup">
|
||||
<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="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)"
|
||||
style="font-weight:600">{{ r.direction }}</span>
|
||||
<span class="sp"></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>
|
||||
<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 class="srcgroup">
|
||||
<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="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="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"
|
||||
|
|
@ -745,7 +792,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
|||
:title="r.reason ? '点一下看理由' : '这条没有理由'">
|
||||
<span class="muted mono" style="font-size:10px;width:9px;flex:none"
|
||||
: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="muted" style="font-size:11.5px" v-if="confPct(r.confidence)!=null">把握 {{ confPct(r.confidence) }}</span>
|
||||
<span class="sp"></span>
|
||||
|
|
@ -755,7 +802,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
|||
</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}">
|
||||
<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)"
|
||||
style="font-weight:600">{{ r.direction }}</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>
|
||||
<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">
|
||||
<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)">{{ confPct(r.confidence)==null?'—':confPct(r.confidence) }}</span></span>
|
||||
<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="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)"
|
||||
style="font-weight:600">{{ r.direction }}</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">
|
||||
<template #default="s">
|
||||
<template v-if="heldSigOf(s.row.ts_code).length">
|
||||
<span style="cursor:default"
|
||||
:title="heldSigOf(s.row.ts_code).slice(0,3).map(g=>(g.time||'')+' '+g.cat+' '+(g.direction||'')).join('\n')">
|
||||
<span style="cursor:pointer;text-decoration:underline dotted"
|
||||
@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)">
|
||||
{{ 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
|
||||
v-if="heldSigOf(s.row.ts_code).length>1"> +{{ heldSigOf(s.row.ts_code).length-1 }}</template></span>
|
||||
</span>
|
||||
</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>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态 · 下一步" min-width="130">
|
||||
|
|
@ -1840,6 +1890,106 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
|||
</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%">
|
||||
<div class="muted" style="margin-bottom:10px">
|
||||
调度器 (--profile sched) 会自动按调度总表执行; 此处按钮用于未接调度或临时验证时手动触发。
|
||||
|
|
@ -1887,6 +2037,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
|||
计划不带价格与金额 —— 买多少/什么价一律 PMS 自己算; 行情取不到价的票会被剔除。
|
||||
</span>
|
||||
</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"
|
||||
:title="planStatus.hint || '不可用'" style="margin-bottom:8px"></el-alert>
|
||||
<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)));
|
||||
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): 昨天的风控卖出不再冒充今天
|
||||
const sigBriefHeldCount = computed(() => sigBrief.value.filter(x => x.held && x.today !== false).length);
|
||||
const heldSigMap = computed(() => {
|
||||
|
|
@ -3112,10 +3354,20 @@ createApp({
|
|||
});
|
||||
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() {
|
||||
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) {
|
||||
plansOf.value = cid;
|
||||
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,
|
||||
cmdArchivable, stratArchivable, archiveRow,
|
||||
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,
|
||||
loadMacro, macroScan, macroAdopt,
|
||||
mzSig, mzAdvicePending, mzChipCls, mzChipText, macroChipClick,
|
||||
|
|
|
|||
|
|
@ -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 侧仍先跑测试、避开开盘。
|
||||
|
|
@ -56,6 +56,9 @@
|
|||
强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱
|
||||
来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流/
|
||||
到价提议挂着时止损照落并作废该提议 (17 例)
|
||||
test_batch24_units.py 点股票看它今天全部信号 (2026-09-09 方案第三节): 代码归一与跨市场不误配/
|
||||
分页回扫边界与按今天零点截断/五源解析与超龄标陈旧/去重同源合并跨源不合并/
|
||||
单源失败逐源标明与按票缓存/只读守卫/页面三处入口与层级 (9 例)
|
||||
test_batch23_units.py 公司质地与建议方案接入 (2026-09-09 接入方案): 解析层收公司深度两键/整句送研判可关/建议
|
||||
方案矩阵与研判补档/持仓页公司块。
|
||||
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_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py",
|
||||
"test_batch21_units.py", "test_batch22_units.py", "test_batch23_units.py",
|
||||
"test_batch24_units.py",
|
||||
"test_page_enum_guard.py",
|
||||
"test_wiring.py"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -647,6 +647,7 @@ def _():
|
|||
"/api/ops/reconcile", "/api/ops/daily-settle", "/api/ops/report",
|
||||
"/api/ops/downstream-schema", "/api/industry",
|
||||
"/api/upstream/plan", "/api/ops/plan-refresh",
|
||||
"/api/upstream/signals/{ts_code}", # 2026-09-09 点股票看它今天全部信号
|
||||
"/api/ops/strategy-attach-scan"] # 2026-08-25 策略自动挂载试算位
|
||||
missing = [p for p in need if p not in paths]
|
||||
assert not missing, missing
|
||||
|
|
|
|||
Loading…
Reference in New Issue