# -*- coding: utf-8 -*-
"""
联测监视盘 —— 一屏看完整条链 (只读, 一个字都不写)
==========================================================
运行:
    python scripts/watch.py            看一眼
    python scripts/watch.py -n 10      每 10 秒刷一次, Ctrl-C 退出
    python scripts/watch.py --wide     连评审账本与方案一起显示

为什么要这个
------------
`make t-ins` / `make t-gate` 这类目标回的是原始 JSON, 中文还被转义成 \\uXXXX, 一条指令
刷四十行屏。盯盘时要回答的其实只有五个问题, 它们分散在五个接口里:

    通道通不通 · 单发出去没有 · 出手/没出手为什么 · 成交回来没有 · 账本认领对不对

这个脚本把这五问拍成一屏。**它只读不写**, 随便跑, 跑多少次都不影响任何状态。

判读要点 (盯盘时最容易混的两件事)
--------------------------------
* **「没出手」和「出手失败」长得不一样。** 没出手 = 指令停在 PROPOSED、出口队列空、
  裁决栏写着 WAIT/STOP 及原因; 出手失败 = 裁决是 FIRE 但指令没进 DISPATCHED, 或者出口
  表有行却卡在 QUEUED/SEND_FAILED。本屏把两者的判据并排放, 不用去猜。
* **WAIT 与 STOP 是两回事。** WAIT 是"条件没到, 待会儿再看"; STOP 是"本日不再出手"
  (触发不追高闸那类)。STOP 的票今天不会再有动静, 别一直等它。
"""
import argparse
import os
import sys
import time

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

W = 78
LIVE_INS = ("PROPOSED", "RULE_PASSED", "DISPATCHED")
LIVE_ORD = ("QUEUED", "SENDING", "SENT", "ACCEPTED", "SUBMITTED", "PARTIAL")


def _rule(ch="═"):
    print(ch * W)


def _w(s):
    """显示宽度: 中日韩字符占两格。不算这个的话表格和分隔线全会跑偏。"""
    return sum(2 if ord(c) > 0x2E80 else 1 for c in str(s))


def _sec(title):
    print()
    print(f"─ {title} " + "─" * max(0, W - _w(title) - 3))


def _n(v, d=0):
    try:
        return float(v)
    except (TypeError, ValueError):
        return d


def _money(v):
    return "—" if v is None else f"{_n(v):,.0f}"


def _short(s, n):
    """按显示宽度截断 (中文按两格算), 避免表格错位。"""
    s = str(s or "")
    out, w = [], 0
    for c in s:
        cw = 2 if ord(c) > 0x2E80 else 1
        if w + cw > n:
            out.append("…")
            break
        out.append(c)
        w += cw
    return "".join(out)


def _pad(s, n):
    s = _short(s, n)
    return s + " " * max(0, n - _w(s))


# ================================================================ 各段
def head(now):
    from app.services import dispatcher, param_store
    mode = param_store.get("PMS_DISPATCH_MODE", "shadow")
    auto = param_store.get("PMS_AUTONOMY", "propose_only")
    halt_b = param_store.get_bool("PMS_GLOBAL_BUY_HALT", False)
    halt_e = param_store.get_bool("PMS_GLOBAL_EXEC_HALT", False)
    brake = param_store.get_int("PMS_BRAKE_UNTIL", 0)
    _rule()
    flag = ""
    if halt_e:
        flag += "  ⛔ 全局暂停执行"
    elif halt_b:
        flag += "  ⛔ 全局暂停买入"
    if brake:
        flag += f"  ⛔ 刹车至 {brake}"
    print(f" PMS 联测监视 · {now} · 下发 {mode} · 自主 {auto}{flag}")
    _rule()
    if mode != "ws":
        print(f" 通道   影子模式 —— 指令只记账不下发, 出口表不会有任何委托")
        return
    try:
        ch = dispatcher.channel_status()
    except Exception as e:
        print(f" 通道   读取失败: {type(e).__name__}: {e}")
        return
    q = ch.get("queue") or {}
    qs = " ".join(f"{k}={v}" for k, v in q.items() if v) or "空"
    st = ch.get("stat") or {}
    line = (f" 通道   {ch['conn_state']}"
            f"  心跳 {ch.get('heartbeat_age_sec')}s"
            f"  水位 {ch.get('last_seq')}/{ch.get('acked_seq')}"
            f"  出口 {qs}"
            f"  待入账 {ch.get('inbox_pending')}")
    print(line)
    warn = []
    if not ch.get("online"):
        warn.append(f"连接不在线 ({ch['conn_state']}) —— 买入一律拒发, 卖出仍可入队")
    if ch.get("resync_required"):
        warn.append("对端补发不全, 需走全量对账 (协议 §6.2)")
    if ch.get("orphan_held"):
        warn.append(f"{ch['orphan_held']} 笔孤儿成交挂起待人工 —— 有账没记")
    if st.get("orphan_updates"):
        warn.append(f"{st['orphan_updates']} 条回报在出口表找不到对应行")
    if st.get("last_reject"):
        warn.append(f"最近一条拒绝: {st['last_reject']}")
    for w in warn:
        print(f"        ⚠ {w}")


def account():
    from app.services import portfolio
    try:
        v = portfolio.positions_view()
    except Exception as e:
        print(f" 账户   读取失败: {type(e).__name__}: {e}")
        return
    t = v["totals"]
    src = "真钱" if t.get("cash_source") == "ws" else "估算"
    print(f" 账户   总资产 {_money(t.get('total_asset'))}"
          f"  可用 {_money(t.get('cash_avail'))} ({src})"
          f"  持仓 {t['names_count']} 只 / {_money(t['portfolio_mv'])}"
          f"  组合 {_n(t['portfolio_pct']):.1%}")
    if v.get("price_missing"):
        print(f"        ⚠ 取不到现价: {', '.join(v['price_missing'][:6])}")


def _left_today(ins: dict, used: int):
    """本日剩余额度 = 当日配额 − 今日已投放。用的是执行器那两个纯函数, 口径一致。"""
    from app.core import exec_timing as et, tradedays as td
    try:
        remaining = max(0, int(ins.get("qty") or 0) - int(ins.get("exec_qty") or 0))
        dl = (ins.get("progress") or {}).get("deadline")
        left_days = td.trade_days_left(dl, None) if dl else 1
        quota = et.daily_quota(remaining, left_days,
                               allow_odd_tail=(ins.get("action") == "EXIT"))
        return max(0, quota - int(used))
    except Exception:
        return "?"


def instructions():
    """今日一栏必须写成「投/余/废」三个数, 不能只印一个剩余量。

    2026-08-03 实机教训: 「当日配额已出完」这一句, 在两种完全相反的情形下长得一模一样 ——
    (a) 该投的都投出去了、单子正挂着 (正常)  (b) 分片到期作废、一股没成却把额度吃光了
    (bug)。用户盯着同一句话反复调试, 而真正的区别在**投出去的那些现在是什么下场**。
    三个数一摆就分得开: 作废那栏不是 0, 就说明有额度被白占过。
    """
    from app.repo import pms_repo
    from app.services import executor
    rows = pms_repo.list_instructions(statuses=list(LIVE_INS), limit=50)
    _sec(f"在途指令 {len(rows)} 条")
    if not rows:
        print("  (无) —— 方案还没转指令就跑 make t-mat; 一条方案都没有就先下命令")
        return
    from datetime import datetime
    ymd = int(datetime.now().strftime("%Y%m%d"))
    print("  " + _pad("代码", 11) + _pad("方向", 5) + _pad("总量", 8)
          + _pad("已成", 7) + _pad("状态", 11) + _pad("今日 投/余/废", 15) + "最近裁决")
    for r in rows:
        prog = r.get("progress") or {}
        d = prog.get("last_decision") or {}
        act = d.get("action") or "—"
        mark = {"FIRE": "▶", "WAIT": "·", "STOP": "■", "SKIP": "○"}.get(act, " ")
        why = _short(d.get("reason") or "(本轮还没轮到它)", 24)
        at = d.get("at") or ""
        kids = [c for c in (prog.get("children") or []) if int(c.get("ymd") or 0) == ymd]
        try:
            used = executor._consumed_today(kids, ymd)
        except Exception:
            used = sum(int(c.get("qty") or 0) for c in kids)
        void = sum(int(c.get("qty") or 0) for c in kids) - used
        # 「余」必须**现算**, 不能读 last_decision 里的 qty_hint —— 那是执行器上一跳
        # 存下来的快照, 而投/废是此刻现算的。两者取自不同时刻, 会拼出自相矛盾的一行:
        # 2026-08-03 实机 002518.SZ 显示 `0/0/200` —— 废了 200 却还说余 0, 因为执行器
        # 那一跳时第二张分片还挂着(算已投), 等 watch 来看时它刚到期。三个数必须同源。
        left = _left_today(r, used)
        q = f"{used}/{left}/{void}"
        print("  " + _pad(r["ts_code"], 11)
              + _pad("买" if r["side"] == "buy" else "卖", 5)
              + _pad(str(r["qty"]), 8)
              + _pad(str(r.get("exec_qty") or 0), 7)
              + _pad(r["status"], 11)
              + _pad(q, 15)
              + f"{mark} {at} {act} {why}")
    print("  裁决: ▶出手  ·等条件(待会儿再看)  ■本日停手(如不追高)  ○当日跳过(停牌/一字板)")
    print("  今日 投/余/废 = 今天已投放(仍在途或已成)/本日剩余额度/到期作废已退回额度的量")


def outbound():
    from app.repo import qmt_repo
    try:
        rows = qmt_repo.list_orders(limit=30)
    except Exception as e:
        _sec("出口委托")
        print(f"  读取失败: {type(e).__name__}: {e}")
        return
    live = [r for r in rows if r["status"] in LIVE_ORD]
    _sec(f"出口委托 (pms_qmt_order)  在途 {len(live)} / 最近 {len(rows)}")
    if not rows:
        print("  (空) —— 还没有任何委托发往 QMT。切到 ws 之后这里一直空, "
              "说明是**没到出手条件**, 不是发失败了")
        return
    print("  " + _pad("代码", 11) + _pad("方向", 5) + _pad("数量", 8)
          + _pad("限价", 9) + _pad("状态", 12) + _pad("成交", 8) + "备注")
    for r in rows[:12]:
        note = ""
        if r.get("reject_code"):
            note = f"拒绝 {r['reject_code']}: {_short(r.get('reject_reason'), 26)}"
        elif r.get("broker_order_id"):
            note = f"券商单号 {_short(r['broker_order_id'], 22)}"
        if r.get("cancel_state") and r["cancel_state"] != "NONE":
            note = f"[撤单 {r['cancel_state']}] " + note
        print("  " + _pad(r["ts_code"], 11)
              + _pad("买" if r["side"] == "buy" else "卖", 5)
              + _pad(str(r["qty"]), 8)
              + _pad(f"{_n(r.get('limit_price')):.2f}", 9)
              + _pad(r["status"], 12)
              + _pad(str(r.get("cum_qty") or 0), 8) + note)
    stuck = [r for r in rows if r["status"] in ("QUEUED", "SEND_FAILED")]
    if stuck:
        print(f"  ⚠ {len(stuck)} 张卡在 {stuck[0]['status']} —— "
              f"这才是「发不出去」, 看 pms-ws 日志")


def crosscheck():
    """出口表与父指令必须对得上 —— 对不上是**本端记账掉了一环**, 比发不出去更隐蔽。

    正常次序 (设计「先记账后动作」): executor 判 FIRE → dispatcher 落出口表 →
    立刻把父指令置 DISPATCHED 并把这一笔记进 progress.children。三件事要么都成,
    要么都没发生。只成了第一件的话:

      * 父指令不知道自己今天已经发过 → 下一跳可能重复出手 (出口表 instruction_id
        唯一索引会拦住, 于是它转而卡死: 每一跳都撞重复键, 再也发不出去)
      * 当日配额 fired_today 恒为 0 → 分日节奏失效
      * 页面看到的是「PROPOSED / 已成 0」, 而 QMT 那边单子挂着 —— 两边说的不是一回事

    2026-08-03 实机撞到过一次, 所以做成常驻检查。
    """
    from app.repo import pms_repo, qmt_repo
    from app.services import param_store
    if param_store.get("PMS_DISPATCH_MODE", "shadow") != "ws":
        return
    try:
        orders = qmt_repo.list_orders(limit=200)
        ins = {r["instruction_id"]: r for r in pms_repo.list_instructions(limit=200)}
    except Exception:
        return
    bad = []
    by_parent = {}
    for o in orders:
        by_parent.setdefault(o.get("parent_id"), []).append(o)
    for pid, os_ in by_parent.items():
        p = ins.get(pid)
        if not p:
            bad.append(f"出口表有 {len(os_)} 张 {os_[0]['ts_code']} 的委托, "
                       f"但本端找不到父指令 {pid}")
            continue
        n_child = len((p.get("progress") or {}).get("children") or [])
        if p["status"] in ("PROPOSED", "RULE_PASSED"):
            bad.append(f"{p['ts_code']} 出口表已有 {len(os_)} 张委托 "
                       f"({os_[0]['status']}), 而父指令还停在 {p['status']} —— "
                       f"下发成了但**本端没记上**")
        if n_child != len(os_):
            bad.append(f"{p['ts_code']} 父指令记着 {n_child} 笔子单, "
                       f"出口表实际有 {len(os_)} 张 —— 分日配额会算错")
        if not p.get("dispatch_ref"):
            bad.append(f"{p['ts_code']} 出口表有委托但父指令 dispatch_ref 是空的")
    if not bad:
        return
    _sec("⚠ 出口表与本端指令对不上")
    for b in dict.fromkeys(bad):
        print(f"  ✗ {b}")
    print("  这一类不是「发不出去」, 是**发出去了但账没记全**。查 executor.run_tick 里")
    print("  dispatcher.dispatch 之后那几行 (update_instruction / insert_ledger) 报了什么。")


def positions():
    from app.services import portfolio
    try:
        held = portfolio.positions_view()["held"]
    except Exception as e:
        _sec("账本持仓")
        print(f"  读取失败: {type(e).__name__}: {e}")
        return
    _sec(f"账本持仓 {len(held)} 只")
    if not held:
        print("  (空) —— 有成交回来之后这里才会长出批次")
        return
    print("  " + _pad("代码", 11) + _pad("数量", 8) + _pad("可卖", 8)
          + _pad("摊薄成本", 10) + _pad("现价", 9) + _pad("安全垫", 9)
          + _pad("批次 底/补/加/摊", 18) + "行业")
    for p in held:
        cu = p.get("cushion_pct")
        cu_s = "—" if cu is None else f"{_n(cu):+.2%}"
        batch = "/".join(str(p.get(k) or 0) for k in
                         ("base_qty", "fill_qty", "add_qty", "dca_qty"))
        print("  " + _pad(p["ts_code"], 11)
              + _pad(str(p["total_qty"]), 8)
              + _pad(str(p.get("avail_qty") or 0), 8)
              + _pad(f"{_n(p.get('avg_cost')):.3f}", 10)
              + _pad(f"{_n(p.get('price')):.3f}", 9)
              + _pad(cu_s, 9)
              + _pad(batch, 18)
              + _short(p.get("sector") or "—", 16))


def ledger(n=8):
    from app.repo import pms_repo
    rows = pms_repo.list_ledger(limit=n)
    _sec(f"评审账本 最近 {len(rows)} 条 (谁拒了什么、为什么)")
    if not rows:
        print("  (无)")
        return
    for r in rows:
        t = (str(r.get("decided_at") or "")[-8:] or "--:--:--")
        v = r.get("verdict")
        mark = {"PASS": "✓", "REJECT": "✗", "WARN": "⚠"}.get(v, "·")
        why = _short(r.get("reason"), 40)
        fc = r.get("failed_checks") or []
        tail = ("  ← " + _short(fc[0], 46)) if fc else ""
        print(f"  {mark} {t}  {_pad(r.get('ts_code') or '—', 11)}"
              f"{_pad(r.get('action') or '', 8)}{why}{tail}")


def plans():
    from app.repo import pms_repo
    rows = pms_repo.list_plans(limit=200)
    by = {}
    for r in rows:
        by[r["status"]] = by.get(r["status"], 0) + 1
    _sec("方案分布")
    if not rows:
        print("  (无) —— 先下命令 (make t-issue), 命令才会排出方案")
        return
    print("  " + "  ".join(f"{k} {v} 条" for k, v in sorted(by.items()))
          + "     PENDING=可转指令  GATED=等动作引擎解锁  EXECUTING=已转过")


def render(wide, clear=False):
    from datetime import datetime
    if clear:
        os.system("clear" if os.name != "nt" else "cls")
    head(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
    account()
    instructions()
    outbound()
    crosscheck()
    positions()
    if wide:
        plans()
        ledger()
    print()


def main():
    ap = argparse.ArgumentParser(description="PMS 联测监视盘 (只读)")
    ap.add_argument("-n", "--interval", type=int, default=0,
                    help="每 N 秒刷新一次 (缺省只看一眼)")
    ap.add_argument("--wide", action="store_true", help="连方案分布与评审账本一起显示")
    args = ap.parse_args()
    if not args.interval:
        render(args.wide)
        return
    try:
        while True:
            render(args.wide, clear=True)
            print(f" (每 {args.interval}s 刷新, Ctrl-C 退出)")
            time.sleep(args.interval)
    except KeyboardInterrupt:
        print("\n已退出。")


if __name__ == "__main__":
    main()
