2026-07-27 17:12:09 +08:00
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""
|
|
|
|
|
|
账本服务: 成交回放 · 对账 · 除权 · 日终结算 (设计 §4「生命线」)
|
|
|
|
|
|
================================================================
|
|
|
|
|
|
纯逻辑在 app/core/recon.py, 本模块只负责取数、落库与状态推进。
|
|
|
|
|
|
|
|
|
|
|
|
摊薄成本口径 (与 core/cushion.PositionCost 等价, 但数据来自批次表):
|
|
|
|
|
|
cum_buy = Σ (剩余数量 + 已核销数量) × 开仓价
|
|
|
|
|
|
cum_sell = Σ 已核销数量 × 核销均价
|
|
|
|
|
|
avg_cost = max(0, (cum_buy − cum_sell) / 当前持股数)
|
|
|
|
|
|
做T利润通过 T0 批次的买卖流水自然摊入上式, 故 **不再另行扣减** realized_t_profit
|
|
|
|
|
|
(该列仅作展示统计, 重复扣减会把成本做低两次)。
|
|
|
|
|
|
|
|
|
|
|
|
铁律: 对账以下游为准; 修正一律走 RECON 批次留痕; 连续 N 日不一致升级 ERROR。
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
|
|
|
|
|
|
from app.core import cushion as cu
|
|
|
|
|
|
from app.core import recon as rc
|
|
|
|
|
|
from app.core import tradedays as td
|
|
|
|
|
|
from app.repo import downstream_repo, pms_repo
|
|
|
|
|
|
from app.services import market, param_store, portfolio
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger("pms.ledger")
|
|
|
|
|
|
|
|
|
|
|
|
CURSOR_KEY = "PMS_REPLAY_CURSOR"
|
2026-07-29 10:01:00 +08:00
|
|
|
|
CURSOR_ALL = "ALL" # 游标设成这个值 = 显式要求从头全量回放 (见 _seed_cursor)
|
2026-07-27 17:12:09 +08:00
|
|
|
|
STREAK_KEY = "PMS_RECON_STREAK"
|
|
|
|
|
|
LIVE_INSTR = ("DISPATCHED", "JUDGE_PASSED", "RULE_PASSED")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ================================================================ 回放
|
2026-07-29 10:01:00 +08:00
|
|
|
|
def _seed_cursor(out: dict) -> dict:
|
|
|
|
|
|
"""首次回放: 把游标对齐到当前最新成交, **不追认历史**。
|
|
|
|
|
|
|
|
|
|
|
|
`trading_order` 里躺着旧系统多年的成交记录。PMS 刚上线时一条在途指令都没有, 若从头
|
|
|
|
|
|
回放, 每一条历史成交都会被判成「外部成交」并入 BASE 批次 —— 账本上凭空长出一堆早就
|
|
|
|
|
|
清掉的持仓, 摊薄成本与安全垫全错, 日志还刷几百条 EXTERNAL_FILL 告警。而安全垫是补仓、
|
|
|
|
|
|
盈利加仓、保垫减仓共同的判断依据, 它一错整条纪律链跟着错。**账本必须从干净的起点开始。**
|
|
|
|
|
|
|
|
|
|
|
|
要补历史有两条路 (页面「参数设置」改 PMS_REPLAY_CURSOR):
|
|
|
|
|
|
* 设成某个 order_id → 从它之后开始回放
|
|
|
|
|
|
* 设成 ALL → 从头全量回放
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
anchor = downstream_repo.latest_filled_order_id()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out.update({"ok": False, "errors": [f"读下游最新成交失败: {type(e).__name__}: {e}"]})
|
|
|
|
|
|
return out
|
|
|
|
|
|
if not anchor:
|
|
|
|
|
|
out["note"] = "下游暂无已成交单, 游标待下次再对齐"
|
|
|
|
|
|
return out
|
|
|
|
|
|
pms_repo.set_param(CURSOR_KEY, anchor, updated_by="system")
|
|
|
|
|
|
out.update({"cursor": anchor, "seeded": True})
|
|
|
|
|
|
out["note"] = (f"首次回放: 游标已对齐到当前最新成交 {anchor}, **不追认历史** —— "
|
|
|
|
|
|
f"账本从现在起跟踪。需要补历史请在页面把 PMS_REPLAY_CURSOR 改成某个 "
|
|
|
|
|
|
f"order_id (从它之后开始) 或 {CURSOR_ALL} (从头全量)")
|
|
|
|
|
|
logger.warning("[replay_fills] %s", out["note"])
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 17:12:09 +08:00
|
|
|
|
def replay_fills(*, limit: int = 500) -> dict:
|
|
|
|
|
|
"""增量回放 trading_order 已成交单 → 批次入账 (每 5 分钟一跳, 幂等)。"""
|
|
|
|
|
|
out = {"ok": True, "fills": 0, "actions": 0, "alerts": [], "errors": [], "cursor": None}
|
2026-07-29 10:01:00 +08:00
|
|
|
|
cursor = pms_repo.get_param(CURSOR_KEY)
|
|
|
|
|
|
if not cursor: # 从未设过 (或被清空) —— 冷启动, 只对齐游标不入账
|
|
|
|
|
|
return _seed_cursor(out)
|
|
|
|
|
|
if str(cursor).strip().upper() == CURSOR_ALL:
|
|
|
|
|
|
cursor = None # 显式要求从头全量回放
|
2026-07-27 17:12:09 +08:00
|
|
|
|
try:
|
|
|
|
|
|
fills = downstream_repo.fetch_filled_orders(since_id=cursor, limit=limit)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out.update({"ok": False, "errors": [f"读 trading_order 失败: {type(e).__name__}: {e}"]})
|
|
|
|
|
|
return out
|
|
|
|
|
|
out["fills"] = len(fills)
|
|
|
|
|
|
if not fills:
|
|
|
|
|
|
out["cursor"] = cursor
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
instrs = _open_instructions()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
instrs = []
|
|
|
|
|
|
out["errors"].append(f"读在途指令失败(按外部成交处理): {e}")
|
|
|
|
|
|
|
|
|
|
|
|
mapped = rc.map_fills_to_book(fills, instrs)
|
|
|
|
|
|
for act in mapped["actions"]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
_apply_action(act)
|
|
|
|
|
|
out["actions"] += 1
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.exception("入账失败 %s", act)
|
|
|
|
|
|
out["errors"].append(f"{act.get('ts_code')} 入账失败: {type(e).__name__}: {e}")
|
|
|
|
|
|
out["alerts"] = mapped["alerts"]
|
|
|
|
|
|
|
|
|
|
|
|
for code in {a["ts_code"] for a in mapped["actions"]}:
|
|
|
|
|
|
try:
|
|
|
|
|
|
recompute_position(code)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out["errors"].append(f"{code} 成本重算失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
nc = rc.next_cursor(fills, cursor)
|
|
|
|
|
|
pms_repo.set_param(CURSOR_KEY, nc, "system")
|
|
|
|
|
|
out["cursor"] = nc
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out["errors"].append(f"游标推进失败: {e}")
|
|
|
|
|
|
out["ok"] = not out["errors"]
|
|
|
|
|
|
for a in out["alerts"]:
|
|
|
|
|
|
logger.warning("[回放告警] %s", a.get("message"))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _open_instructions() -> list:
|
2026-07-27 17:31:34 +08:00
|
|
|
|
"""在途指令 (回放认领的候选池)。
|
|
|
|
|
|
|
|
|
|
|
|
dispatched_at 取「下发时点 → 建单时点」, **不用 updated_at**:
|
|
|
|
|
|
updated_at 每次部分成交回写都会往前跳, 拿它当下发时点会让同一条指令的后续成交
|
|
|
|
|
|
被时间守卫挡在门外, 误判成外部成交。建单时点是天然的下界, 宁松勿紧。
|
|
|
|
|
|
"""
|
2026-07-27 17:12:09 +08:00
|
|
|
|
rows = pms_repo.list_instructions(statuses=list(LIVE_INSTR), limit=500)
|
2026-07-27 17:31:34 +08:00
|
|
|
|
out = []
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
prog = r.get("progress") or {}
|
|
|
|
|
|
out.append({"instruction_id": r["instruction_id"], "ts_code": r["ts_code"],
|
|
|
|
|
|
"side": r.get("side"), "qty": int(r.get("qty") or 0),
|
|
|
|
|
|
"exec_qty": int(r.get("exec_qty") or 0), "action": r.get("action"),
|
|
|
|
|
|
"dispatched_at": str(prog.get("dispatched_at")
|
|
|
|
|
|
or r.get("created_at") or "")})
|
|
|
|
|
|
return out
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_action(act: dict):
|
|
|
|
|
|
code, qty, px = act["ts_code"], int(act["qty"]), float(act["price"] or 0)
|
|
|
|
|
|
if qty <= 0:
|
|
|
|
|
|
return
|
|
|
|
|
|
pms_repo.ensure_position(code)
|
|
|
|
|
|
if act["kind"] == "BUY":
|
|
|
|
|
|
pms_repo.insert_lot(ts_code=code, lot_type=act.get("lot_type") or "BASE", qty=qty,
|
|
|
|
|
|
open_price=px, open_date=datetime.now().date(),
|
|
|
|
|
|
instruction_id=act.get("instruction_id"),
|
|
|
|
|
|
note="外部成交并入 BASE" if not act.get("instruction_id") else None)
|
|
|
|
|
|
pms_repo.bump_position_qty(code, total_delta=qty, avail_delta=0) # T+1: 当日买入不可卖
|
|
|
|
|
|
else:
|
|
|
|
|
|
lots = pms_repo.list_lots(code, status="OPEN")
|
|
|
|
|
|
res = rc.apply_sell_to_lots(
|
|
|
|
|
|
[{"lot_id": l["id"], "lot_type": l["lot_type"], "qty": int(l["qty"]),
|
|
|
|
|
|
"open_date": _date_key(l["open_date"])} for l in lots], qty)
|
|
|
|
|
|
by_id = {l["id"]: l for l in lots}
|
|
|
|
|
|
t_profit = 0.0
|
|
|
|
|
|
for a in res["alloc"]:
|
|
|
|
|
|
lot = by_id[a["lot_id"]]
|
|
|
|
|
|
pnl = (px - float(lot["open_price"] or 0)) * a["qty"]
|
|
|
|
|
|
pms_repo.close_lot_qty(a["lot_id"], qty=a["qty"], close_price=px, realized_pnl=pnl)
|
|
|
|
|
|
if lot["lot_type"] == "T0":
|
|
|
|
|
|
t_profit += pnl
|
|
|
|
|
|
pms_repo.bump_position_qty(code, total_delta=-qty, avail_delta=-qty)
|
|
|
|
|
|
if t_profit:
|
|
|
|
|
|
pos = pms_repo.get_position(code) or {}
|
|
|
|
|
|
pms_repo.update_position(
|
|
|
|
|
|
code, realized_t_profit=float(pos.get("realized_t_profit") or 0) + t_profit)
|
|
|
|
|
|
for al in res["alerts"]:
|
|
|
|
|
|
logger.warning("[卖出核销] %s %s", code, al.get("message"))
|
|
|
|
|
|
if act.get("instruction_id"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
pms_repo.add_instruction_exec(act["instruction_id"], qty)
|
|
|
|
|
|
_settle_instruction(act["instruction_id"])
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning("指令进度更新失败 %s: %s", act["instruction_id"], e)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _settle_instruction(instruction_id: str):
|
|
|
|
|
|
"""成交量达到指令数量 → 置 CONFIRMED (部分成交保持在途, 由窗口/过期规则收口)。"""
|
|
|
|
|
|
ins = pms_repo.get_instruction(instruction_id)
|
|
|
|
|
|
if ins and int(ins.get("exec_qty") or 0) >= int(ins.get("qty") or 0) > 0:
|
|
|
|
|
|
pms_repo.update_instruction(instruction_id, status="CONFIRMED")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _date_key(d):
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(str(d).replace("-", "")[:8])
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def recompute_position(ts_code: str) -> dict:
|
|
|
|
|
|
"""由批次表重算持仓数量/摊薄成本/安全垫/垫子峰值。"""
|
|
|
|
|
|
lots = pms_repo.list_lots(ts_code, status=None, limit=2000)
|
|
|
|
|
|
qty = sum(int(l["qty"] or 0) for l in lots)
|
|
|
|
|
|
cum_buy = sum((int(l["qty"] or 0) + int(l["closed_qty"] or 0)) * float(l["open_price"] or 0)
|
|
|
|
|
|
for l in lots)
|
|
|
|
|
|
cum_sell = sum(int(l["closed_qty"] or 0) * float(l["close_avg_price"] or 0) for l in lots)
|
|
|
|
|
|
by_type = {}
|
|
|
|
|
|
for l in lots:
|
|
|
|
|
|
if int(l["qty"] or 0) > 0:
|
|
|
|
|
|
by_type[l["lot_type"]] = by_type.get(l["lot_type"], 0) + int(l["qty"])
|
|
|
|
|
|
|
|
|
|
|
|
avg_cost = max(0.0, (cum_buy - cum_sell) / qty) if qty > 0 else None
|
|
|
|
|
|
px = market.get_price(ts_code) or avg_cost or 0
|
|
|
|
|
|
cp = (px / avg_cost - 1.0) if (avg_cost and avg_cost > 0 and px) else None
|
|
|
|
|
|
solid = param_store.get_float("PMS_CUSHION_SOLID", 0.03)
|
|
|
|
|
|
pos = pms_repo.get_position(ts_code) or {}
|
|
|
|
|
|
peak = max(float(pos.get("cushion_peak") or 0), cp or 0)
|
|
|
|
|
|
scale = param_store.get_float("PMS_TOTAL_SCALE", 0)
|
|
|
|
|
|
|
|
|
|
|
|
fields = {
|
|
|
|
|
|
"total_qty": qty, "base_qty": by_type.get("BASE", 0) + by_type.get("RECON", 0),
|
|
|
|
|
|
"fill_qty": by_type.get("FILL", 0), "add_qty": by_type.get("ADD", 0),
|
|
|
|
|
|
"dca_qty": by_type.get("DCA", 0), "t0_qty": by_type.get("T0", 0),
|
|
|
|
|
|
"avg_cost": round(avg_cost, 3) if avg_cost else None,
|
|
|
|
|
|
"cushion_pct": round(cp, 4) if cp is not None else None,
|
|
|
|
|
|
"cushion_state": cu.cushion_state(cp, solid), "cushion_peak": round(peak, 4),
|
|
|
|
|
|
"pct_of_scale": round(qty * px / scale, 4) if scale > 0 else None,
|
|
|
|
|
|
"status": "CLOSED" if qty <= 0 else (pos.get("status") or "HOLDING"),
|
|
|
|
|
|
}
|
|
|
|
|
|
if qty > 0 and (pos.get("status") in (None, "PLANNED", "CLOSED")):
|
|
|
|
|
|
fields["status"] = "HOLDING"
|
|
|
|
|
|
if qty > 0 and not pos.get("opened_date"):
|
|
|
|
|
|
fields["opened_date"] = datetime.now().date()
|
|
|
|
|
|
pms_repo.update_position(ts_code, **fields)
|
|
|
|
|
|
return fields
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ================================================================ 对账
|
|
|
|
|
|
def reconcile(*, apply_fix: bool = True) -> dict:
|
|
|
|
|
|
"""账本 vs 下游持仓, 以下游为准修正并留痕。"""
|
|
|
|
|
|
out = {"ok": True, "diffs": [], "fixes": [], "errors": [], "columns": {}, "severity": rc.SEV_OK}
|
|
|
|
|
|
try:
|
|
|
|
|
|
ds = downstream_repo.fetch_positions()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out.update({"ok": False, "errors": [f"读 trading_position 失败: {type(e).__name__}: {e}"]})
|
|
|
|
|
|
return out
|
|
|
|
|
|
out["columns"] = ds["columns"]
|
|
|
|
|
|
if ds["rows"] and ds["columns"].get("qty") is None:
|
|
|
|
|
|
out.update({"ok": False, "errors": [
|
|
|
|
|
|
"下游持仓表未识别出数量列 —— 请按 QMT_INTERFACE_REQUIREMENTS A1/D1 取得 DDL 后, "
|
|
|
|
|
|
"把列名补进 downstream_repo.QTY_CANDIDATES"]})
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
book = [{"ts_code": r["ts_code"], "total_qty": int(r.get("total_qty") or 0)}
|
|
|
|
|
|
for r in pms_repo.list_positions()]
|
|
|
|
|
|
diffs = rc.diff_positions(book, [{"ts_code": r["ts_code"], "qty": r["qty"]}
|
|
|
|
|
|
for r in ds["rows"]])
|
|
|
|
|
|
out["diffs"] = diffs
|
|
|
|
|
|
|
|
|
|
|
|
streak = param_store.get_int(STREAK_KEY, 0)
|
|
|
|
|
|
streak = streak + 1 if diffs else 0
|
|
|
|
|
|
# 必须走 ParamStore 写入: 直接写库不会失效缓存, 会导致连续天数一直读到旧值
|
|
|
|
|
|
param_store.set_param(STREAK_KEY, streak, "system")
|
|
|
|
|
|
out["severity"] = rc.recon_severity(streak if diffs else 0,
|
|
|
|
|
|
param_store.get_int("PMS_RECON_ALARM_DAYS", 3))
|
|
|
|
|
|
|
|
|
|
|
|
if not diffs or not apply_fix:
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
codes = [d["ts_code"] for d in diffs]
|
|
|
|
|
|
prices = market.get_prices(codes)
|
|
|
|
|
|
lots_map = {c: [{"lot_id": l["id"], "lot_type": l["lot_type"], "qty": int(l["qty"]),
|
|
|
|
|
|
"open_date": _date_key(l["open_date"])}
|
|
|
|
|
|
for l in pms_repo.list_lots(c, status="OPEN")] for c in codes}
|
|
|
|
|
|
fixes = rc.build_recon_fixes(diffs, price_map=prices, lots_map=lots_map)
|
|
|
|
|
|
for f in fixes:
|
|
|
|
|
|
try:
|
|
|
|
|
|
_apply_fix(f)
|
|
|
|
|
|
recompute_position(f["ts_code"])
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.exception("对账修正失败 %s", f)
|
|
|
|
|
|
out["errors"].append(f"{f['ts_code']} 修正失败: {type(e).__name__}: {e}")
|
|
|
|
|
|
out["fixes"] = fixes
|
|
|
|
|
|
out["ok"] = not out["errors"]
|
|
|
|
|
|
if out["severity"] == rc.SEV_ERROR:
|
|
|
|
|
|
logger.error("[对账] 连续 %s 日不一致, 升级 ERROR 待人工: %s 项差异", streak, len(diffs))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_fix(f: dict):
|
|
|
|
|
|
code = f["ts_code"]
|
|
|
|
|
|
pms_repo.ensure_position(code)
|
|
|
|
|
|
if f["op"] == "ADD_RECON_LOT":
|
|
|
|
|
|
px = float(f.get("price") or 0)
|
|
|
|
|
|
pms_repo.insert_lot(ts_code=code, lot_type="RECON", qty=int(f["qty"]),
|
|
|
|
|
|
open_price=px, open_date=datetime.now().date(),
|
|
|
|
|
|
note=f["note"] + ("" if px > 0 else " [缺现价, 成本待人工核]"))
|
|
|
|
|
|
else:
|
|
|
|
|
|
lots = {l["id"]: l for l in pms_repo.list_lots(code, status="OPEN")}
|
|
|
|
|
|
px = float(f.get("price") or 0)
|
|
|
|
|
|
for a in f.get("alloc") or []:
|
|
|
|
|
|
lot = lots.get(a["lot_id"])
|
|
|
|
|
|
pnl = (px - float(lot["open_price"] or 0)) * a["qty"] if (lot and px) else 0.0
|
|
|
|
|
|
pms_repo.close_lot_qty(a["lot_id"], qty=a["qty"], close_price=px or
|
|
|
|
|
|
float(lot["open_price"] or 0), realized_pnl=pnl)
|
|
|
|
|
|
pms_repo.insert_ledger(ts_code=code, action="RECON", arbiter="rule", verdict="PASS",
|
|
|
|
|
|
price_at=float(f.get("price") or 0),
|
|
|
|
|
|
hard_numbers={"op": f["op"], "qty": f["qty"]},
|
|
|
|
|
|
reason=f["note"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ================================================================ 除权
|
|
|
|
|
|
def detect_and_apply_ex_right() -> dict:
|
|
|
|
|
|
"""用昨日结算快照与今日持仓/价格比对, 识别送转股并按比例调整批次。"""
|
|
|
|
|
|
out = {"checked": 0, "ex_rights": [], "mismatches": [], "errors": []}
|
|
|
|
|
|
prev = _prev_snapshot()
|
|
|
|
|
|
if not prev:
|
|
|
|
|
|
out["errors"].append("无昨日结算快照, 本次跳过除权检测 (次日起生效)")
|
|
|
|
|
|
return out
|
|
|
|
|
|
for pos in pms_repo.list_positions(only_open=True):
|
|
|
|
|
|
code = pos["ts_code"]
|
|
|
|
|
|
old = prev.get(code)
|
|
|
|
|
|
if not old:
|
|
|
|
|
|
continue
|
|
|
|
|
|
out["checked"] += 1
|
|
|
|
|
|
px = market.get_price(code)
|
|
|
|
|
|
r = rc.detect_ex_right(int(old.get("qty") or 0), int(pos.get("total_qty") or 0),
|
|
|
|
|
|
float(old.get("price") or 0), float(px or 0))
|
|
|
|
|
|
if not r:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if r["kind"] == "EX_RIGHT":
|
|
|
|
|
|
try:
|
|
|
|
|
|
lots = pms_repo.list_lots(code, status="OPEN")
|
|
|
|
|
|
for l in rc.apply_ex_right(lots, r["ratio"]):
|
|
|
|
|
|
pms_repo.update_lot(l["id"], qty=l["qty"], open_price=l["open_price"],
|
|
|
|
|
|
note=l["note"])
|
|
|
|
|
|
recompute_position(code)
|
|
|
|
|
|
pms_repo.insert_ledger(ts_code=code, action="RECON", arbiter="rule",
|
|
|
|
|
|
verdict="PASS", price_at=px or 0, hard_numbers=r,
|
|
|
|
|
|
reason=f"除权调整 ×{r['ratio']}")
|
|
|
|
|
|
out["ex_rights"].append({"ts_code": code, **r})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out["errors"].append(f"{code} 除权调整失败: {e}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
out["mismatches"].append({"ts_code": code, **r})
|
|
|
|
|
|
logger.error("[除权] %s 比例不吻合, 待人工: %s", code, r.get("reason"))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _prev_snapshot() -> dict:
|
|
|
|
|
|
"""取最近一份日终快照 {code: {qty, price}} (存在 pms_daily_report 里, 不新增表)。"""
|
|
|
|
|
|
r = pms_repo.latest_report()
|
|
|
|
|
|
if not r:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
snap = (r.get("report") or {}).get("snapshot") or {}
|
|
|
|
|
|
return snap if isinstance(snap, dict) else {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ================================================================ 盘前 / 日终
|
|
|
|
|
|
def premarket() -> dict:
|
|
|
|
|
|
"""盘前准备 (08:50): T+1 可卖重置、参考位取数、刹车结算。"""
|
|
|
|
|
|
out = {"ok": True, "avail_reset": 0, "refs": 0, "brake": None, "errors": []}
|
|
|
|
|
|
try:
|
|
|
|
|
|
out["avail_reset"] = pms_repo.reset_avail_all()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out["errors"].append(f"可卖量重置失败: {e}")
|
|
|
|
|
|
for pos in pms_repo.list_positions(only_open=True):
|
|
|
|
|
|
code = pos["ts_code"]
|
|
|
|
|
|
try:
|
|
|
|
|
|
refs = market.get_refs(code, base_cost=pos.get("avg_cost"))
|
|
|
|
|
|
pms_repo.update_position(code, support_ref=refs.get("support"),
|
|
|
|
|
|
pressure_ref=refs.get("pressure"),
|
|
|
|
|
|
stop_ref=refs.get("stop"),
|
|
|
|
|
|
ref_source=refs.get("source"))
|
|
|
|
|
|
out["refs"] += 1
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out["errors"].append(f"{code} 参考位取数失败: {e}")
|
|
|
|
|
|
try:
|
|
|
|
|
|
out["brake"] = _settle_brake()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out["errors"].append(f"刹车结算失败: {e}")
|
|
|
|
|
|
out["ok"] = not out["errors"]
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _settle_brake() -> dict:
|
|
|
|
|
|
"""组合刹车: 自高水位回撤 ≥ 阈值 → 自主增持停 N 个交易日 (命令类不受限)。"""
|
|
|
|
|
|
v = portfolio.positions_view()
|
|
|
|
|
|
mv = v["totals"]["portfolio_mv"]
|
|
|
|
|
|
hw = param_store.get_float("PMS_HIGH_WATER", 0.0)
|
|
|
|
|
|
dd_limit = param_store.get_float("PMS_BRAKE_DRAWDOWN", 0.05)
|
|
|
|
|
|
days = param_store.get_int("PMS_BRAKE_DAYS", 3)
|
|
|
|
|
|
until = param_store.get_int("PMS_BRAKE_UNTIL", 0)
|
|
|
|
|
|
today = td.ymd()
|
|
|
|
|
|
if mv > hw:
|
|
|
|
|
|
param_store.set_param("PMS_HIGH_WATER", mv, "system")
|
|
|
|
|
|
hw = mv
|
|
|
|
|
|
drawdown = (1 - mv / hw) if hw > 0 else 0.0
|
|
|
|
|
|
if hw > 0 and drawdown >= dd_limit and today >= until:
|
|
|
|
|
|
until = td.ymd(td.next_trade_day(datetime.now().date(), days))
|
|
|
|
|
|
param_store.set_param("PMS_BRAKE_UNTIL", until, "system")
|
|
|
|
|
|
logger.warning("[刹车] 自高水位回撤 %.1f%% ≥ %.0f%%, 自主增持暂停至 %s",
|
|
|
|
|
|
drawdown * 100, dd_limit * 100, until)
|
|
|
|
|
|
return {"high_water": hw, "portfolio_mv": mv, "drawdown": round(drawdown, 4),
|
|
|
|
|
|
"brake_until": until, "active": today < until}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def daily_settle() -> dict:
|
|
|
|
|
|
"""日终结算 (15:10): 除权检测 → 全量对账 → 垫子峰值/连负天数 → 命令进度日结 → 快照留存。"""
|
|
|
|
|
|
from app.services import command_service
|
|
|
|
|
|
out = {"ok": True, "steps": {}, "errors": []}
|
|
|
|
|
|
try:
|
|
|
|
|
|
out["steps"]["ex_right"] = detect_and_apply_ex_right()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out["errors"].append(f"除权检测失败: {e}")
|
|
|
|
|
|
try:
|
|
|
|
|
|
out["steps"]["recon"] = reconcile()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out["errors"].append(f"对账失败: {e}")
|
|
|
|
|
|
try:
|
|
|
|
|
|
out["steps"]["cushion"] = _settle_cushion()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out["errors"].append(f"安全垫结算失败: {e}")
|
|
|
|
|
|
try:
|
|
|
|
|
|
out["steps"]["commands"] = command_service.refresh_progress()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out["errors"].append(f"命令进度结算失败: {e}")
|
|
|
|
|
|
try:
|
|
|
|
|
|
pms_repo.expire_proposals()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out["errors"].append(f"提议过期处理失败: {e}")
|
|
|
|
|
|
out["ok"] = not out["errors"]
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _settle_cushion() -> dict:
|
|
|
|
|
|
"""更新垫子峰值与「安全垫连续为负天数」(清弱票判定所需)。"""
|
|
|
|
|
|
v = portfolio.positions_view()
|
|
|
|
|
|
streak = portfolio.neg_streak_map()
|
|
|
|
|
|
updated = 0
|
|
|
|
|
|
for x in v["held"]:
|
|
|
|
|
|
code, cp = x["ts_code"], x["cushion_pct"]
|
|
|
|
|
|
streak[code] = (int(streak.get(code, 0)) + 1) if (cp is not None and cp < 0) else 0
|
|
|
|
|
|
peak = max(float(x["cushion_peak"] or 0), cp or 0)
|
|
|
|
|
|
pms_repo.update_position(code, cushion_pct=cp, cushion_peak=round(peak, 4),
|
|
|
|
|
|
cushion_state=x["cushion_state"])
|
|
|
|
|
|
updated += 1
|
|
|
|
|
|
held = {x["ts_code"] for x in v["held"]}
|
|
|
|
|
|
portfolio.save_neg_streak({k: v2 for k, v2 in streak.items() if k in held})
|
|
|
|
|
|
return {"updated": updated,
|
|
|
|
|
|
"neg_streak": {k: v2 for k, v2 in streak.items() if v2 > 0 and k in held}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ================================================================ 日报
|
|
|
|
|
|
def build_daily_report(ymd: int = None) -> dict:
|
|
|
|
|
|
"""运营日报 (15:30): 关注区 + 全量统计 + 当日快照 (快照供次日除权检测)。"""
|
|
|
|
|
|
from app.services import command_service
|
|
|
|
|
|
ymd = int(ymd or td.ymd())
|
|
|
|
|
|
v = portfolio.positions_view()
|
|
|
|
|
|
t = v["totals"]
|
|
|
|
|
|
try:
|
|
|
|
|
|
recon_state = {"streak": param_store.get_int(STREAK_KEY, 0)}
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
recon_state = {}
|
|
|
|
|
|
|
|
|
|
|
|
cmds = pms_repo.list_commands(statuses=["EXECUTING", "PARTIAL", "PENDING", "PLANNING"],
|
|
|
|
|
|
limit=100)
|
|
|
|
|
|
proposals = pms_repo.list_proposals(statuses=("WAIT_USER",), limit=100)
|
|
|
|
|
|
live_ins = pms_repo.list_instructions(statuses=list(LIVE_INSTR), limit=200)
|
|
|
|
|
|
attention = []
|
|
|
|
|
|
for c in cmds:
|
|
|
|
|
|
prog = c.get("progress") or {}
|
|
|
|
|
|
attention.append({"type": "命令进度", "command_id": c["command_id"],
|
|
|
|
|
|
"cmd_type": c["cmd_type"], "status": c["status"],
|
|
|
|
|
|
"done": prog.get("done_amount"), "target": prog.get("target_amount"),
|
|
|
|
|
|
"deadline": prog.get("deadline")})
|
|
|
|
|
|
if proposals:
|
|
|
|
|
|
attention.append({"type": "待确认提议", "count": len(proposals)})
|
|
|
|
|
|
if recon_state.get("streak"):
|
|
|
|
|
|
attention.append({"type": "对账差异", "streak": recon_state["streak"],
|
|
|
|
|
|
"severity": rc.recon_severity(
|
|
|
|
|
|
recon_state["streak"],
|
|
|
|
|
|
param_store.get_int("PMS_RECON_ALARM_DAYS", 3))})
|
|
|
|
|
|
brake_until = param_store.get_int("PMS_BRAKE_UNTIL", 0)
|
|
|
|
|
|
if brake_until > ymd:
|
|
|
|
|
|
attention.append({"type": "组合刹车", "until": brake_until})
|
|
|
|
|
|
if v["price_missing"]:
|
|
|
|
|
|
attention.append({"type": "行情缺失", "codes": v["price_missing"]})
|
|
|
|
|
|
if not v["sector_ready"]:
|
|
|
|
|
|
attention.append({"type": "行业约束停用", "hint": "PMS_SECTOR_SOURCE 未配置"})
|
|
|
|
|
|
if td.calendar_degraded():
|
|
|
|
|
|
attention.append({"type": "交易日历降级", "hint": "未安装 chinesecalendar, 节假日不可辨"})
|
|
|
|
|
|
|
|
|
|
|
|
report = {
|
|
|
|
|
|
"ymd": ymd, "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
|
|
|
"totals": t, "attention": attention,
|
|
|
|
|
|
"commands": [{"command_id": c["command_id"], "cmd_type": c["cmd_type"],
|
|
|
|
|
|
"status": c["status"], "progress": c.get("progress")} for c in cmds],
|
|
|
|
|
|
"proposals": len(proposals), "live_instructions": len(live_ins),
|
|
|
|
|
|
"positions": [{"ts_code": x["ts_code"], "qty": x["total_qty"], "price": x["price"],
|
|
|
|
|
|
"avg_cost": x["avg_cost"], "cushion_pct": x["cushion_pct"],
|
|
|
|
|
|
"cushion_state": x["cushion_state"], "mv": x["market_value"],
|
|
|
|
|
|
"pct_of_scale": x["pct_of_scale"]} for x in v["held"]],
|
|
|
|
|
|
# 次日除权检测用的快照 (数量 + 收盘价)
|
|
|
|
|
|
"snapshot": {x["ts_code"]: {"qty": x["total_qty"], "price": x["price"]}
|
|
|
|
|
|
for x in v["held"]},
|
|
|
|
|
|
"recon": recon_state,
|
|
|
|
|
|
}
|
|
|
|
|
|
try:
|
|
|
|
|
|
pms_repo.upsert_report(ymd, report)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error("日报落表失败: %s", e)
|
|
|
|
|
|
report["save_error"] = str(e)
|
|
|
|
|
|
return report
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def expire_stale_instructions() -> int:
|
|
|
|
|
|
"""下发后长时间未被接受的指令置过期 (不自动重发 —— 设计 §13)。"""
|
|
|
|
|
|
mins = param_store.get_int("PMS_DISPATCH_EXPIRE_MIN", 30)
|
|
|
|
|
|
cut = datetime.now() - timedelta(minutes=mins)
|
|
|
|
|
|
n = 0
|
|
|
|
|
|
for r in pms_repo.list_instructions(statuses=["DISPATCHED"], limit=500):
|
|
|
|
|
|
try:
|
|
|
|
|
|
if str(r.get("updated_at") or "") and str(r["updated_at"]) < str(cut):
|
|
|
|
|
|
pms_repo.update_instruction(r["instruction_id"], status="EXPIRED")
|
|
|
|
|
|
n += 1
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
return n
|