Files

400 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""策略域体检检查函数库 — 数据面 monitor_checks 的策略镜像(目录内自含).
判据全只读: 主库(paper_accounts/live_trades/paper_trades/dbbardata/order_events)
+ pipeline.db(is_daily/strategy_weekly) + logs/live_*.log+shadow_*.log + wmic 进程快照
+ data/monitor/health_latest.json(data-monitor 存活外盯,只读不写)。
返回统一形状 {"key","status","expected","actual","evidence"}; status 取
green / 注册表项 severity(违例时)。
wmic 纪律(照 scripts/ci/vps_count_engines.py): 只在 name='python.exe' 的进程里数
cmdline 含 'sanguo_trader.shadow --account' 的条目——监控自身/其它 python 进程
不含该串, 天然无自匹配(无需 +1 修正)。
"""
import datetime as dt
import glob
import os
import sqlite3
import subprocess
from sanguo_portfolio import order_event_store # noqa: E402 monitor 先补 sys.path
NEEDLE = "sanguo_trader.shadow --account"
ERROR_TOKENS = ("ERROR", "Traceback")
LEFTOVER_STATUS = ("open", "ack", "partial") # expire 为终态不算残留
# 现役引擎日志=shadow_*.log(LIVE=0 时代);live_* 一并盯,live 桌回归日自动覆盖
LOG_GLOBS = ("live_*.log", "shadow_*.log")
def run_wmic():
"""wmic 进程快照 CSV(仅 python.exe); 非 Windows/无 wmic=异常上抛由调用方降黄."""
out = subprocess.run(
["wmic", "process", "where", "name='python.exe'",
"get", "commandline,creationdate", "/format:csv"],
capture_output=True, timeout=120, check=True)
return out.stdout.decode("utf-8", errors="replace")
def parse_wmic_csv(text):
"""CSV→[(cmdline, creation datetime|None)]; 首行表头跳过, 空字段行→("", None).
CreationDate=YYYYMMDDHHMMSS.ffffff+zzz 取前 14 位解析; 行尾逗号 rpartition
(cmdline 可能自带逗号), 首个逗号 partition 剥 NODE 列。
"""
rows = []
lines = [ln for ln in text.replace("\r\n", "\n").split("\n") if ln.strip()]
for line in lines[1:]:
head, _, raw_created = line.rpartition(",")
_, _, cmdline = head.partition(",")
created = None
raw_created = raw_created.strip()
if raw_created[:14].isdigit():
try:
created = dt.datetime.strptime(raw_created[:14], "%Y%m%d%H%M%S")
except ValueError:
created = None
rows.append((cmdline, created))
return rows
def shadow_processes(text):
"""shadow 引擎进程 [(cmdline, creation)]; 计数=自匹配免疫的口径."""
return [(cmd, c) for cmd, c in parse_wmic_csv(text) if NEEDLE in cmd]
def trading_days(db_path, today, lookback=50):
"""确认集=dbbardata 000300 实证日期(近 lookback 日, [:10] 归一为 ISO 日);
库不可达=[] 调用方降级工作日启发式(monitor_checks.trading_days 同款)."""
cutoff = (today - dt.timedelta(days=lookback)).isoformat()
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=10)
try:
rows = conn.execute(
"SELECT DISTINCT datetime FROM dbbardata "
"WHERE symbol='000300' AND exchange='SSE' AND interval='d' "
"AND datetime>=? ORDER BY datetime", (cutoff,)).fetchall()
finally:
conn.close()
return sorted({str(r[0])[:10] for r in rows if r[0]})
except sqlite3.Error:
return []
def _ro(path):
return sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=10)
def _count_running(db_path):
"""running 账户数, 排除 live 桌(mode='live', live_orchestrator 权威口径)——
本检查族的 wmic needle 数的是 shadow 进程, live 桌回归日口径不再混桌
(10-01 二次审计 P1-3 同族; mode 未标 NULL/其它=影子/eod 舰队都计入)."""
conn = _ro(db_path)
try:
return int(conn.execute(
"SELECT COUNT(*) FROM paper_accounts "
"WHERE status='running' AND (mode IS NULL OR mode!='live')"
).fetchone()[0])
finally:
conn.close()
def _desk_accounts(db_path, mode):
"""desk 账户行 [(id, instance_id, strategies_json)]; live 桌=mode='live'."""
conn = _ro(db_path)
try:
return conn.execute(
"SELECT id, instance_id, strategies FROM paper_accounts "
"WHERE mode=? AND status='running'", (mode,)).fetchall()
finally:
conn.close()
def _dual_track_pairs(db_path):
"""live↔shadow 配对 [(live_id, shadow_id)] + 未配对清单(10-05 审计 P3:
单边 instance_id 漂移曾静默消失在 pairs 外=reconcile 覆盖面暗洞——
返回清单供 evidence 点名, 灯色语义不动)(reconcile_report v2 语义镜像:
instance_id 优先精确配对, 缺 instance 回退 strategies 首名; 10-01 审计
P1-3——全表总量口径在 live 桌回归日必误红, 逐对账才是双轨本义)."""
import json as _json
def _first_name(s):
try:
return (_json.loads(s or "[]") or [{}])[0].get("name", "")
except (ValueError, IndexError, KeyError):
return ""
lives = [(r[0], r[1], _first_name(r[2]))
for r in _desk_accounts(db_path, "live")]
shadows = [(r[0], r[1], _first_name(r[2]))
for r in _desk_accounts(db_path, "shadow")]
pairs, used = [], set()
unpaired_live = []
for lid, linst, lname in lives:
cand = next(((s, ) for s, sinst, _ in shadows
if sinst and sinst == linst and s not in used), None) \
or next(((s, ) for s, _, sname in shadows
if not (linst or None) and lname and sname == lname
and s not in used), None)
if cand:
used.add(cand[0])
pairs.append((lid, cand[0]))
else:
unpaired_live.append(lid)
unpaired_shadow = [sid for sid, _, _ in shadows if sid not in used]
return pairs, unpaired_live, unpaired_shadow
def _day_fills(db_path, table, account_id, today):
"""当日某账户成交 (笔数, 金额)——live_trades 按 traded_at 前缀,
paper_trades 只数 rejected=0 按 bar_date."""
conn = _ro(db_path)
try:
if table == "live_trades":
row = conn.execute(
"SELECT COUNT(*), COALESCE(SUM(price*volume),0) FROM live_trades "
"WHERE account_id=? AND traded_at LIKE ?",
(account_id, today + "%")).fetchone()
else:
row = conn.execute(
"SELECT COUNT(*), COALESCE(SUM(price*volume),0) FROM paper_trades "
"WHERE account_id=? AND rejected=0 AND bar_date=?",
(account_id, today)).fetchone()
return int(row[0]), float(row[1] or 0)
finally:
conn.close()
def _pipeline_one(ctx, sql, args=()):
conn = _ro(ctx["pipeline_db"])
try:
return conn.execute(sql, args).fetchone()
finally:
conn.close()
def _done(e, status, expected, actual, evidence=None):
return {"key": e["key"], "status": status, "expected": expected,
"actual": actual, "evidence": evidence or {}}
# ---------- 盘前班: 引擎面 ----------
def engines_expected(ctx, e):
running = _count_running(ctx["main_db"])
want = e["expected"]
status = "green" if running == want else e["severity"]
return _done(e, status, want, running,
{"running": running, "expected": want})
def engines_actual(ctx, e):
shadows = shadow_processes(run_wmic())
running = _count_running(ctx["main_db"])
status = "green" if len(shadows) == running else e["severity"]
return _done(e, status, running, len(shadows),
{"wmic_shadow": len(shadows), "db_running": running})
def engines_no_restart(ctx, e):
"""近 N 个**确认**交易日盘窗(09:15~15:30)内出生的 shadow 进程=会话日重启
(§11.0; 10-01 二次审计 P2-3 重设计)。旧「creation>=当日00:00」双失效:
盘中重启次日必绿(只盖 00:00-08:05)且 08:05 当日必然未确认→red 恒降黄。
新语义: 违例日均为已确认交易日→red 可达, 不经未确认日降级; 夜间/盘前
出生=合法 deploy 窗不计; 连休无新增确认日=自然绿。"""
shadows = shadow_processes(run_wmic())
days = trading_days(ctx["main_db"], ctx["now"].date())
if not days:
# 空确认日历(库不可达/脏数据)=降黄 fail-safe:旧码 any(空窗)恒 False→
# 零违例→绿=vacuous 绿, 数据坏灯色假安(S-2; 正常连休期确认集仍有
# 历史交易日不触此枝; data 域 D-5/6 同哲学)
return _done(e, "yellow", 0, "no-confirmed-days",
{"violations": [], "procs": len(shadows), "windows": [],
"reason": "empty confirmed calendar"})
windows = []
for d in sorted(days)[-e["lookback_confirmed"]:]:
ymd = d.replace("-", "")
windows.append((ymd + "0915", ymd + "1530"))
bad = []
for cmd, c in shadows:
if c is None:
# 复核留痕(P3 fail-safe 审计项): CreationDate 解析失败按违例计=
# 保守向——wmic 口径变化若静默跳过, 盘中重启检测整列失明;
# 空字段行("HOST,,")不含 needle 已被 shadow_processes 过滤,
# 此枝只剩真行解析失败, created:null 与真违例(created 有值)可辨
bad.append({"cmd": cmd, "created": None})
continue
ts = c.strftime("%Y%m%d%H%M")
if any(a <= ts <= b for a, b in windows):
bad.append({"cmd": cmd, "created": c.isoformat()})
status = "green" if not bad else e["severity"]
return _done(e, status, 0, len(bad),
{"violations": bad[:20], "procs": len(shadows),
"windows": [f"{a[:8]}" for a, _ in windows]})
# ---------- 盘前班: 日志面 ----------
def logs_error_storm(ctx, e):
"""live_*+shadow_* 26h 窗(两班通用, 旧失败不重炒——数据面 _LOG_WINDOW_H 同值)
内 ERROR/Traceback 行数 ≤ 阈值. 09-30 修订: 修前只盯 live_*.log, LIVE=0 时代
全是 stopped 旧文件=窗内恒 0(vacuous 绿), 09-30 08:05 首班 logs_stale 误黄实证."""
window = ctx["now"] - dt.timedelta(hours=e["window_hours"])
total = files = unreadable = 0
for pattern in LOG_GLOBS:
for p in glob.glob(os.path.join(ctx["logs_dir"], pattern)):
try:
if dt.datetime.fromtimestamp(os.path.getmtime(p)) < window:
continue
with open(p, encoding="utf-8", errors="replace") as f:
text = f.read()
except OSError:
# 读不动不静默(P3 漏报向收敛): 计数入 evidence 留痕——
# 文件锁/权限异常时值班可见; 灯色公式不动(10-08 纪律)
unreadable += 1
continue
files += 1
total += sum(1 for ln in text.splitlines()
if any(tok in ln for tok in ERROR_TOKENS))
status = "green" if total <= e["max_errors"] else e["severity"]
return _done(e, status, f"<={e['max_errors']}", total,
{"errors": total, "files_in_window": files,
"unreadable_files": unreadable,
"window_hours": e["window_hours"]})
def logs_stale(ctx, e):
"""最新 live_*+shadow_* mtime 距今 ≤ max_age_days(连休容忍; 交易日班评——
休市表剔假日, 表外工作日照评=复市日 morn 一班诚实黄, P3-26 docstring 随迁)."""
mtimes = []
for pattern in LOG_GLOBS:
for p in glob.glob(os.path.join(ctx["logs_dir"], pattern)):
try:
mtimes.append(os.path.getmtime(p))
except OSError:
continue
if not mtimes:
return _done(e, e["severity"], "any", None, {"files": 0})
newest = dt.datetime.fromtimestamp(max(mtimes))
age_h = (ctx["now"] - newest).total_seconds() / 3600
status = "green" if age_h <= e["max_age_days"] * 24 else e["severity"]
return _done(e, status, f"<={e['max_age_days']}d", f"{age_h:.0f}h",
{"newest_age_h": round(age_h, 1), "files": len(mtimes)})
# ---------- 盘后班: 产物+订单链 ----------
def is_report_ran(ctx, e):
row = _pipeline_one(ctx, "SELECT 1 FROM is_daily WHERE day=? LIMIT 1",
(ctx["today"],))
status = "green" if row else e["severity"]
return _done(e, status, "present", bool(row), {"day": ctx["today"]})
def is_identity_resid(ctx, e):
"""当日行 |identity_resid| ≤ 容差; 无行可判=绿(缺失归 is_report_ran 管径)."""
conn = _ro(ctx["pipeline_db"])
try:
rows = conn.execute(
"SELECT ABS(identity_resid) FROM is_daily WHERE day=?",
(ctx["today"],)).fetchall()
finally:
conn.close()
vals = [r[0] for r in rows if r[0] is not None]
if not vals:
return _done(e, "green", f"|resid|<={e['tol']}", "n/a", {"rows": 0})
worst = max(vals)
status = "green" if worst <= e["tol"] else e["severity"]
return _done(e, status, f"|resid|<={e['tol']}", worst,
{"rows": len(vals), "max_abs": worst})
def orders_leftover(ctx, e):
"""重放当日无 open/ack/partial 终态残留(submitted 单独存在=初始 open 态)."""
states = order_event_store.replay_order_states(ctx["main_db"],
day=ctx["today"])
leftover = sorted(k for k, s in states.items()
if s.get("status") in LEFTOVER_STATUS)
status = "green" if not leftover else e["severity"]
return _done(e, status, 0, len(leftover),
{"leftover": leftover[:20], "orders": len(states)})
def fills_vs_paper(ctx, e):
"""当日双轨配对逐对账: live 账户 vs 其 shadow 镜像(笔数+金额, 容差内)。
10-01 二次审计 P1-3 重设计——全表总量口径无账户配对, live 桌回归日两桌
各跑各的策略必总量不平=误红; 逐对账才是「同策略同参数分别接真/假 broker」
的双轨本义。无 running live 账户=n/a 绿(09-30 修订保留: shadow 桌本地撮合
订单不出门, LIVE=0 时代 shadow 独走日不构成对账对象); 配对内 0:1 也算不平
(影子镜像未跟=真双轨分歧)。柜台↔台账恒等式仍归 15:10 仪式(infra 域)。"""
pairs, unpaired_l, unpaired_s = _dual_track_pairs(ctx["main_db"])
if not pairs:
if not unpaired_l:
return _done(e, "green", "n/a", "no-live-desk",
{"pairs": 0,
"note": "无 running live 账户, 双轨对账不适用"})
# live 桌在但零配对(10-05 P3): 不再伪装成 no-live-desk——绿灯但点名
# (instance_id 漂移嫌疑; 灯色不动=10-08 纪律, 升级与否复市后再议)
return _done(e, "green", "n/a", f"live-desk-unpaired({len(unpaired_l)})",
{"pairs": 0, "unpaired_live": unpaired_l,
"unpaired_shadow": unpaired_s,
"note": "live 桌存在但零配对——instance_id 漂移嫌疑, 人工核对"})
bad = []
for lid, sid in pairs:
ln, lam = _day_fills(ctx["main_db"], "live_trades", lid, ctx["today"])
pn, pam = _day_fills(ctx["main_db"], "paper_trades", sid, ctx["today"])
if not (ln == pn and abs(lam - pam) <= e["amount_tol"]):
bad.append({"live": lid, "shadow": sid,
"live_count": ln, "live_amount": round(lam, 2),
"paper_count": pn, "paper_amount": round(pam, 2)})
status = "green" if not bad else e["severity"]
return _done(e, status, f"逐对count相等 且 金额±{e['amount_tol']}",
f"{len(pairs) - len(bad)}/{len(pairs)}对平",
{"pairs": len(pairs), "mismatch": bad[:10],
"unpaired_live": unpaired_l, "unpaired_shadow": unpaired_s})
def weekly_ran(ctx, e):
"""strategy_weekly 本 ISO 周行存在(任一 strategy_id 即算)."""
d = dt.date.fromisoformat(ctx["today"])
monday = (d - dt.timedelta(days=d.weekday())).isoformat()
row = _pipeline_one(
ctx, "SELECT 1 FROM strategy_weekly WHERE week_start>=? LIMIT 1",
(monday,))
status = "green" if row else e["severity"]
return _done(e, status, f"week_start>={monday}", bool(row),
{"week_start": monday})
def data_monitor_alive(ctx, e):
"""data-monitor health_latest.json 新鲜度(互为外盯, 10-01 审计 P1-10 另一半).
data-monitor 每日两班(08:05/23:50)原子写 {data_root}/monitor/health_latest.json
(节假日 schtask 照跑, 日历降级在进程内); >max_age_hours 无新写=其两班全死,
而它的 SCHTASKS 自检自己也死了无人执行=全链静默——本检在 strategy 两班
侧外盯兜住该结构洞(反向: data-monitor SCHTASKS 已盯 strategy 两班)."""
p = ctx["data_monitor_health"]
try:
mtime = dt.datetime.fromtimestamp(os.path.getmtime(p))
except OSError:
return _done(e, e["severity"], f"<={e['max_age_hours']}h", "missing",
{"file": p})
age_h = (ctx["now"] - mtime).total_seconds() / 3600
status = "green" if age_h <= e["max_age_hours"] else e["severity"]
return _done(e, status, f"<={e['max_age_hours']}h", f"{age_h:.0f}h",
{"file": p, "age_h": round(age_h, 1)})
CHECK_FNS = {
"engines_expected": engines_expected,
"engines_actual": engines_actual,
"engines_no_restart": engines_no_restart,
"logs_error_storm": logs_error_storm,
"logs_stale": logs_stale,
"is_report_ran": is_report_ran,
"is_identity_resid": is_identity_resid,
"orders_leftover": orders_leftover,
"fills_vs_paper": fills_vs_paper,
"weekly_ran": weekly_ran,
"data_monitor_alive": data_monitor_alive,
}