472 lines
21 KiB
Python
472 lines
21 KiB
Python
"""迟到成交对账(a 窄修 + b 宽修)——2026-08-25 P1 根治。
|
|
|
|
事故(前后端 session 21:35 定罪):LiveEngine 同步等待 16s(TRADE_MAX_WAIT_TIME)
|
|
超时后弃跟踪,迟到 fill 永不进 ``engine.get_trades`` → 归因链
|
|
(runner_live._sync_instance_trades → ledger.apply_trade → save_trade)见不到
|
|
→ 实例账本幻影持仓(500+700 股,-18060 元)且无自愈。
|
|
|
|
两条修法都**直查 QMT 原始行**(broker.get_trades,与 engine 视图无关):
|
|
- a 窄修 ``watch_pending_order`` + ``reconcile_pending``:下单返回非终态
|
|
(超时/部分成交)→ 进程内待对账名单;归因轮询每轮对名单按券商订单号
|
|
直查 QMT 成交,见到即 apply_trade+save_trade;订单终态出名单,隔夜出清
|
|
(A股委托当日有效)。
|
|
- b 宽修 ``eod_reconcile``(15:05,先于 15:10 恒等式):QMT 当日全账户成交
|
|
vs live_trades 已落库行逐笔比对,按 trade_id 或 时间+代码+方向+价+量
|
|
对齐,缺失行按归因规则补插(vt_tradeid 加 ``eod:`` 前缀留痕)——兜住
|
|
一切「引擎没报」形态。
|
|
|
|
归因边界:本实例订单 = engine.get_orders() 里的 ``_broker_order_id``(进程
|
|
提交过的委托);别家实例/手动单的成交只统计不归因。进程重启后名单与订单表
|
|
随进程消失,当日孤儿单只能靠 15:10 恒等式报警走人工(次日 QMT 即查不到
|
|
当日成交,无法自动回溯)。全在 sanguo_portfolio 层,bullet_trade 零改动。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
from typing import Any, Dict, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 引擎 _maybe_wait 的终态集(canceled 两种拼写都对齐)
|
|
_TERMINAL = {"filled", "cancelled", "canceled", "partly_canceled",
|
|
"rejected", "failed", "error"}
|
|
|
|
# EOD 对账窗口:15:05 起(60s 轮询首落在 15:05-15:06),赶在 15:10 恒等式前
|
|
EOD_AFTER = (15, 5)
|
|
# 重试窗口上界:QMT 当日成交 API 零点翻篇,23:00 后再查无意义 → 放弃留证。
|
|
# 08-28 003011 实锤:15:05 快照缺第 10 笔成交,用户 22 点导出已齐全
|
|
# (=QMT 当日视图会滞后补全)——守恒缺口非空时持续重试即可当晚自愈
|
|
EOD_RETRY_UNTIL = (23, 0)
|
|
|
|
# broker_oid -> {"engine_oid", "is_buy", "amount", "watched_date"}
|
|
# 一个进程一个实例(runner_live 单引擎),模块级即实例级
|
|
_PENDING: Dict[str, Dict[str, Any]] = {}
|
|
_LAST_EOD_DATE: str = ""
|
|
# 缺口/废单告警签名:重试轮里内容不变降级 INFO,防 60s×8h 刷屏
|
|
_LAST_GAP_LOG_SIG: str = ""
|
|
_LAST_REJ_LOG_SIG: str = ""
|
|
|
|
|
|
def _status_str(status: Any) -> str:
|
|
return str(getattr(status, "value", status) or "").strip().lower()
|
|
|
|
|
|
def _row_time(row: Dict[str, Any]) -> datetime:
|
|
"""QMT 原始行时间守卫(对齐 runner_live._effective_trade_time 哲学):
|
|
datetime 有效原样;YYYYMMDD 数字串/纯数字解析;其余回退当前时刻。"""
|
|
v = row.get("time")
|
|
if isinstance(v, datetime):
|
|
return v if v.year >= 2000 else datetime.now()
|
|
digits = None
|
|
if isinstance(v, str):
|
|
if len(v) >= 14 and v[:14].isdigit():
|
|
digits = v[:14]
|
|
else:
|
|
try:
|
|
return datetime.fromisoformat(v.replace("T", " ")[:19])
|
|
except ValueError:
|
|
return datetime.now()
|
|
elif isinstance(v, (int, float)):
|
|
digits = str(int(v))
|
|
if digits:
|
|
digits = digits.zfill(14)[:14]
|
|
try:
|
|
return datetime.strptime(digits, "%Y%m%d%H%M%S")
|
|
except ValueError:
|
|
pass
|
|
return datetime.now()
|
|
|
|
|
|
# ------------------ a 窄修:待对账名单 ------------------
|
|
def watch_pending_order(order: Any) -> bool:
|
|
"""下单返回后登记待对账名单(实时线程调用,绝不抛)。
|
|
|
|
非终态 或 终态但未足额(撤单残量)→ 入名单;None/本地拒单不入。
|
|
``_broker_order_id`` 异步路径下可能尚未回填 → 先按 engine 订单号入,
|
|
``reconcile_pending`` 下轮从 engine 订单表解析。
|
|
"""
|
|
try:
|
|
if order is None:
|
|
return False
|
|
status = _status_str(getattr(order, "status", None))
|
|
filled = int(getattr(order, "filled", 0) or 0)
|
|
amount = int(getattr(order, "amount", 0) or 0)
|
|
if status in _TERMINAL and not (0 < filled < amount):
|
|
return False # 终态:足额或零成交,无需对账;部分成交残量仍挂
|
|
key = str(getattr(order, "_broker_order_id", None) or
|
|
getattr(order, "order_id", "") or "")
|
|
if not key:
|
|
return False
|
|
_PENDING[key] = {
|
|
"engine_oid": str(getattr(order, "order_id", "") or ""),
|
|
"is_buy": bool(getattr(order, "is_buy", True)),
|
|
"amount": amount,
|
|
"watched_date": datetime.now().strftime("%Y-%m-%d"),
|
|
}
|
|
logger.info("[live-reconcile] 挂对账名单 %s(%s filled=%s/%s)",
|
|
key, status, filled, amount)
|
|
return True
|
|
except Exception as e: # noqa: BLE001 - 名单失败不阻断下单主流程
|
|
logger.warning("[live-reconcile] 登记对账名单失败: %s", e)
|
|
return False
|
|
|
|
|
|
def _apply_rows(rows, ledger, db, account_id, strategy_name, is_buy,
|
|
trade_id_prefix: str = "") -> int:
|
|
"""把 QMT 原始成交行按归因规则入账本+落库,返回新入账笔数。
|
|
|
|
幂等由 ledger.apply_trade 的 trade_id 判重保证(与即时归因/归因轮询
|
|
并发安全);trade_id 用 QMT deal_no → 三条路径见同一笔只记一次。
|
|
"""
|
|
from sanguo_live.persistence import save_trade
|
|
|
|
n = 0
|
|
for r in rows:
|
|
t_time = _row_time(r)
|
|
tid = trade_id_prefix + str(r.get("trade_id") or "")
|
|
applied = ledger.apply_trade(
|
|
is_buy=is_buy,
|
|
symbol=str(r.get("security") or ""),
|
|
price=float(r.get("price") or 0),
|
|
volume=int(r.get("amount") or 0),
|
|
trade_id=tid,
|
|
trade_date=t_time.strftime("%Y-%m-%d"),
|
|
fee=(float(r.get("commission") or 0)
|
|
+ float(r.get("tax") or 0)) or None,
|
|
)
|
|
if not applied:
|
|
continue
|
|
save_trade(db, account_id, {
|
|
"strategy_name": strategy_name,
|
|
"symbol": str(r.get("security") or ""),
|
|
"direction": "buy" if is_buy else "sell",
|
|
"offset": "open" if is_buy else "close",
|
|
"price": float(r.get("price") or 0),
|
|
"volume": int(r.get("amount") or 0),
|
|
"traded_at": t_time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"vt_tradeid": tid,
|
|
})
|
|
logger.info("[live-reconcile] 迟到成交入账 (account=%s %s %s x%s@%s)",
|
|
account_id, "买入" if is_buy else "卖出",
|
|
r.get("security"), r.get("amount"), r.get("price"))
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def reconcile_pending(engine: Any, ledger: Any, db: str, account_id: int,
|
|
strategy_name: str) -> int:
|
|
"""归因轮询每轮调用:对名单直查 QMT 成交,新见即入账;终态出名单。
|
|
|
|
名单空时零开销(不查 QMT);任何异常上抛由轮询统一 warning(下轮再来)。
|
|
"""
|
|
if not _PENDING:
|
|
return 0
|
|
orders = engine.get_orders() or {}
|
|
by_broker: Dict[str, Any] = {}
|
|
for o in orders.values():
|
|
boid = getattr(o, "_broker_order_id", None)
|
|
if boid:
|
|
by_broker[str(boid)] = o
|
|
today = datetime.now().strftime("%Y-%m-%d")
|
|
# 隔夜出清(A股委托当日有效)
|
|
for key in [k for k, v in _PENDING.items()
|
|
if v.get("watched_date") != today]:
|
|
logger.warning("[live-reconcile] 隔夜名单出清 %s(残量未对账,恒等式兜底)",
|
|
key)
|
|
del _PENDING[key]
|
|
# 异步路径 broker_oid 回填:按 engine 订单号解析(迁移到 broker_oid 键)
|
|
for key, entry in list(_PENDING.items()):
|
|
if key not in by_broker and entry.get("engine_oid"):
|
|
o = orders.get(entry["engine_oid"])
|
|
boid = getattr(o, "_broker_order_id", None) if o else None
|
|
if boid:
|
|
entry["engine_oid"] = ""
|
|
_PENDING[str(boid)] = entry
|
|
del _PENDING[key]
|
|
broker = getattr(engine, "broker", None)
|
|
trades_all = []
|
|
getter = getattr(broker, "get_trades", None)
|
|
if callable(getter):
|
|
trades_all = getter() or []
|
|
total = 0
|
|
for key in list(_PENDING):
|
|
entry = _PENDING.get(key)
|
|
if entry is None:
|
|
continue
|
|
rows = [r for r in trades_all if str(r.get("order_id") or "") == key]
|
|
total += _apply_rows(rows, ledger, db, account_id, strategy_name,
|
|
entry["is_buy"])
|
|
order_obj = by_broker.get(key)
|
|
status = _status_str(getattr(order_obj, "status", None)) \
|
|
if order_obj is not None else ""
|
|
if status in _TERMINAL:
|
|
logger.info("[live-reconcile] 订单终态(%s)出名单 %s", status, key)
|
|
_PENDING.pop(key, None)
|
|
return total
|
|
|
|
|
|
# ------------------ 重启兜底:remark 指纹重建本实例订单集 ------------------
|
|
def _own_orders_remarked(engine: Any, account_id: int) -> Dict[str, Any]:
|
|
"""(2026-09-01 002646 事故)按 remark 指纹从 QMT 当日订单行重建本实例订单集。
|
|
|
|
engine 订单注册表是**进程内存态**——轮换/崩溃重启后 ``engine.get_orders()``
|
|
为空,EOD 报「本实例=0」→ 无守恒缺口 → 记日,当日孤儿成交永久失去自动
|
|
补插(当日 15:05 快照后视图才补全的迟到成交正是这形态)。QMT 当日订单行
|
|
(``broker.get_orders`` → query_stock_orders)带 remark=``bt:{label}:{hash}``,
|
|
label=runner_live 注入的 ``live_{account_id}``,跨重启可查——以它为兜底
|
|
归因源,trades 行自身不带 remark,靠 order_id join 本表。
|
|
|
|
仅对注入后提交的订单生效(注入前的历史单 label 六实例共享不可分);
|
|
``is_buy`` 缺失的行弃用——方向不明宁可漏归因,不可错归因(错方向入账
|
|
会反向污染台账)。状态映射复用 broker 自己的 ``_map_order_status``,
|
|
缺失时保 raw(EOD 守恒只认终态字符串,raw 码只致少计不误计)。
|
|
"""
|
|
broker = getattr(engine, "broker", None)
|
|
getter = getattr(broker, "get_orders", None)
|
|
if not callable(getter):
|
|
return {}
|
|
try:
|
|
rows = getter() or []
|
|
except Exception as e: # noqa: BLE001 - 兜底源失败不阻断主 EOD 流程
|
|
logger.warning("[live-reconcile] remark 重建查 QMT 订单失败: %s", e)
|
|
return {}
|
|
prefix = f"bt:live_{account_id}:"
|
|
mapper = getattr(broker, "_map_order_status", None)
|
|
out: Dict[str, Any] = {}
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
if not str(row.get("order_remark") or "").startswith(prefix):
|
|
continue
|
|
oid = str(row.get("order_id") or "")
|
|
is_buy = row.get("is_buy")
|
|
security = row.get("security")
|
|
if not oid or is_buy is None or not security:
|
|
logger.warning(
|
|
"[live-reconcile] remark 重建弃用行(方向/代码缺失) order=%s", oid)
|
|
continue
|
|
status = row.get("status")
|
|
if callable(mapper):
|
|
try:
|
|
mapped = mapper(status)
|
|
status = getattr(mapped, "value", mapped)
|
|
except Exception: # noqa: BLE001 - 映射失败保 raw
|
|
pass
|
|
out[oid] = SimpleNamespace(
|
|
order_id=oid, _broker_order_id=oid, is_buy=bool(is_buy),
|
|
amount=int(row.get("amount") or 0),
|
|
filled=int(row.get("filled") or 0),
|
|
status=status, security=str(security), add_time=None,
|
|
)
|
|
return out
|
|
|
|
|
|
# ------------------ b 宽修:EOD 对账回填 ------------------
|
|
def eod_reconcile(engine: Any, ledger: Any, db: str, account_id: int,
|
|
strategy_name: str) -> Dict[str, int]:
|
|
"""收盘对账:QMT 当日全账户成交 vs live_trades 已落库行,缺失补插。
|
|
|
|
已覆盖判定(任一即覆盖):① trade_id(vt_tradeid/账本已见)一致
|
|
② 时间(分钟)+代码+方向+价+量 五元组一致——**计数感知**:同五元组的
|
|
第 N 笔 QMT 成交消耗一条台账记录,超出部分视为缺失补插(08-27 001234
|
|
实锤:同分钟同价同量的孪生成交仅成交编号不同,集合式判重把第二笔吞成
|
|
"已覆盖"→账本短 100 股幻影)。别家实例/手动单只统计。
|
|
|
|
守恒校验:本实例当日终态订单 filled 合计 vs 台账当日成交量合计,
|
|
不平即告警(连 QMT 流水都缺行的极端形态,补插无从下手时的最后防线)。
|
|
"""
|
|
from collections import Counter
|
|
|
|
from sanguo_live.persistence import list_trades
|
|
|
|
orders = engine.get_orders() or {}
|
|
own_by_broker: Dict[str, Any] = {}
|
|
for o in orders.values():
|
|
boid = getattr(o, "_broker_order_id", None)
|
|
if boid:
|
|
own_by_broker[str(boid)] = o
|
|
# 重启兜底:engine 订单表是进程态,缺的按 remark 指纹从 QMT 当日订单补
|
|
# (engine 自己的单信息更全,优先;setdefault 只填洞)
|
|
for oid, shim in _own_orders_remarked(engine, account_id).items():
|
|
own_by_broker.setdefault(oid, shim)
|
|
broker = getattr(engine, "broker", None)
|
|
trades_all = []
|
|
getter = getattr(broker, "get_trades", None)
|
|
if callable(getter):
|
|
trades_all = getter() or []
|
|
|
|
today = datetime.now().strftime("%Y-%m-%d")
|
|
seen_ids = ledger.seen_trade_ids()
|
|
tuple_counts: Counter = Counter()
|
|
ledger_vol: Dict[tuple, int] = {}
|
|
for r in (list_trades(db, account_id) if db else []):
|
|
traded_at = str(r.get("traded_at") or "")
|
|
if not traded_at.startswith(today):
|
|
continue
|
|
seen_ids.add(str(r.get("vt_tradeid") or ""))
|
|
tuple_counts[(
|
|
traded_at[:16], str(r.get("symbol") or ""),
|
|
str(r.get("direction") or ""), round(float(r.get("price") or 0), 4),
|
|
int(float(r.get("volume") or 0)),
|
|
)] += 1
|
|
vkey = (str(r.get("symbol") or "")[:6],
|
|
str(r.get("direction") or "") == "buy")
|
|
ledger_vol[vkey] = ledger_vol.get(vkey, 0) + int(float(r.get("volume") or 0))
|
|
|
|
ours, backfilled = 0, 0
|
|
for r in trades_all:
|
|
boid = str(r.get("order_id") or "")
|
|
order_obj = own_by_broker.get(boid)
|
|
if order_obj is None:
|
|
continue # 别家实例/手动单
|
|
ours += 1
|
|
is_buy = bool(getattr(order_obj, "is_buy", True))
|
|
tid = str(r.get("trade_id") or "")
|
|
t_time = _row_time(r)
|
|
tkey = (
|
|
t_time.strftime("%Y-%m-%d %H:%M"),
|
|
str(r.get("security") or ""), "buy" if is_buy else "sell",
|
|
round(float(r.get("price") or 0), 4), int(r.get("amount") or 0),
|
|
)
|
|
if tid and tid in seen_ids:
|
|
continue
|
|
if tuple_counts.get(tkey, 0) > 0:
|
|
tuple_counts[tkey] -= 1 # 消费一条同名记录;孪生第二笔不再被吞
|
|
continue
|
|
n = _apply_rows([r], ledger, db, account_id, strategy_name, is_buy,
|
|
trade_id_prefix="eod:")
|
|
backfilled += n
|
|
if n:
|
|
seen_ids.add(f"eod:{tid}")
|
|
tuple_counts[tkey] += 1
|
|
vkey = (str(r.get("security") or "")[:6], is_buy)
|
|
ledger_vol[vkey] = ledger_vol.get(vkey, 0) + int(r.get("amount") or 0)
|
|
|
|
# 守恒校验:订单成交合计 vs 台账记录合计(前缀 6 位码归一,跨日订单不计)
|
|
# ⚠️ 订单时间字段=bullet_trade Order.add_time(08-31 修:原误读不存在的
|
|
# ``datetime`` 属性恒 None → 跨日过滤从不生效,引擎跨交易日存活时历史
|
|
# 订单全进对比 → 每日假缺口告警;测试替身当时被喂了 datetime= 字段,
|
|
# 把 bug 行为锁进了断言——替身必须贴真实字段名)
|
|
order_vol: Dict[tuple, int] = {}
|
|
for o in own_by_broker.values():
|
|
if _status_str(getattr(o, "status", None)) not in _TERMINAL:
|
|
continue
|
|
o_dt = getattr(o, "add_time", None)
|
|
if o_dt is not None and not str(o_dt).startswith(today):
|
|
continue
|
|
filled = int(getattr(o, "filled", 0) or 0)
|
|
if filled <= 0:
|
|
continue
|
|
okey = (str(getattr(o, "security", "") or "")[:6],
|
|
bool(getattr(o, "is_buy", True)))
|
|
order_vol[okey] = order_vol.get(okey, 0) + filled
|
|
conservation_gaps = []
|
|
for (sym, is_buy), ov in sorted(order_vol.items()):
|
|
lv = ledger_vol.get((sym, is_buy), 0)
|
|
if ov != lv:
|
|
conservation_gaps.append({
|
|
"symbol": sym, "side": "buy" if is_buy else "sell",
|
|
"order_volume": ov, "ledger_volume": lv, "gap": ov - lv,
|
|
})
|
|
|
|
# 当日废单告警:08-28 600818 实锤——盘中废单只有 ERROR 日志躺在文件里,
|
|
# 复盘才发现当日目标缺仓 ~8 万;EOD 汇总让它当天可见。部分成交残量不进
|
|
# 此清单(守恒/补插路径已覆盖)。两肢:
|
|
# ① 柜台报 rejected/failed/error(交易所级拒单,QMT 订单视图可见);
|
|
# ② EOD 仍非终态且零成交的当日自提交订单 = 疑似废单——柜台级拒单
|
|
# 不进 QMT 当日订单 API(09-01 前实测 08-31 T+1 探针:query_stock_orders
|
|
# 15 笔全 SUCCEEDED,被拒的卖单根本不在列表),状态永停非终态,等
|
|
# 柜台报状态等不到,只能提交侧自证:下过的单收盘零成交又没到终态,
|
|
# 就是废单/挂死(当日目标缺仓同形态;正常撤单已到终态不计)。
|
|
rejected_orders = []
|
|
for o in own_by_broker.values():
|
|
st = _status_str(getattr(o, "status", None))
|
|
o_dt = getattr(o, "add_time", None)
|
|
if o_dt is not None and not str(o_dt).startswith(today):
|
|
continue
|
|
if int(getattr(o, "filled", 0) or 0) > 0:
|
|
continue
|
|
if st in ("rejected", "failed", "error"):
|
|
why = "废单(柜台报rejected)"
|
|
elif st not in _TERMINAL:
|
|
why = "疑似废单(EOD非终态零成交)"
|
|
else:
|
|
continue
|
|
rejected_orders.append({
|
|
"symbol": str(getattr(o, "security", "") or "")[:6],
|
|
"side": "buy" if bool(getattr(o, "is_buy", True)) else "sell",
|
|
"amount": int(getattr(o, "amount", 0) or 0),
|
|
"why": why})
|
|
|
|
summary = {"qmt_trades": len(trades_all), "ours": ours,
|
|
"backfilled": backfilled,
|
|
"foreign": len(trades_all) - ours,
|
|
"conservation_gaps": conservation_gaps,
|
|
"rejected_orders": rejected_orders}
|
|
global _LAST_GAP_LOG_SIG, _LAST_REJ_LOG_SIG
|
|
if backfilled or conservation_gaps:
|
|
# 取证:缺口标的的 QMT 原始行直接打进告警(08-28 教训——15:05 快照
|
|
# 内容事后不可回溯,当时没 dump 就只能靠猜哪条通道吞了行)
|
|
detail = ""
|
|
if conservation_gaps:
|
|
gap_syms = {str(g["symbol"]) for g in conservation_gaps}
|
|
rows_txt = []
|
|
for r in trades_all:
|
|
if str(r.get("security") or "")[:6] in gap_syms:
|
|
rows_txt.append("%s x%s@%s#%s" % (
|
|
_row_time(r).strftime("%H:%M:%S"),
|
|
r.get("amount"), r.get("price"),
|
|
str(r.get("trade_id") or "")[-6:]))
|
|
if rows_txt:
|
|
detail = " QMT行[%s]" % ";".join(rows_txt[:12])
|
|
sig = "%s|%d|%s|%d" % (today, backfilled, conservation_gaps, len(trades_all))
|
|
log_fn = logger.warning if sig != _LAST_GAP_LOG_SIG else logger.info
|
|
log_fn(
|
|
"[live-reconcile] EOD对账补插 %d 笔 (account=%s QMT全量%d 本实例%d "
|
|
"别家%d) 守恒缺口=%s%s——存在引擎未报形态,查当日名单/日志",
|
|
backfilled, account_id, summary["qmt_trades"], ours,
|
|
summary["foreign"], conservation_gaps, detail)
|
|
_LAST_GAP_LOG_SIG = sig
|
|
else:
|
|
logger.info("[live-reconcile] EOD对账干净 (account=%s QMT全量%d 本实例%d)",
|
|
account_id, summary["qmt_trades"], ours)
|
|
if rejected_orders:
|
|
rsig = "%s|%s" % (today, rejected_orders)
|
|
if rsig != _LAST_REJ_LOG_SIG:
|
|
logger.warning(
|
|
"[live-reconcile] 当日废单/疑似废单 %d 笔 (account=%s): %s——当日"
|
|
"目标缺仓,查柜台废单原因(600818/柜台拒单不进API 两形态)",
|
|
len(rejected_orders), account_id, rejected_orders)
|
|
_LAST_REJ_LOG_SIG = rsig
|
|
return summary
|
|
|
|
|
|
def maybe_eod_reconcile(engine: Any, ledger: Any, db: str, account_id: int,
|
|
strategy_name: str,
|
|
now: Optional[datetime] = None) -> Optional[Dict[str, int]]:
|
|
"""归因轮询每轮调用:15:05 后当日首跑;**守恒缺口非空不记日**,下轮重试。
|
|
|
|
重试依据(08-28 003011 实锤):15:05 快照缺第 10 笔成交 → 自动补插
|
|
没生效,但用户 22 点导出已齐全 = QMT「当日成交」视图会滞后补全
|
|
(零点才翻篇)——缺口敞开时每轮重查,期间补全即补插自愈,当晚闭环。
|
|
23:00(EOD_RETRY_UNTIL)后仍不平 → 记日放弃,缺口留给恒等式/人工。
|
|
查询抛错同样不记日(原有语义),下轮再试。
|
|
"""
|
|
global _LAST_EOD_DATE
|
|
now = now or datetime.now()
|
|
today = now.strftime("%Y-%m-%d")
|
|
if _LAST_EOD_DATE == today:
|
|
return None
|
|
hm = (now.hour, now.minute)
|
|
if hm < EOD_AFTER:
|
|
return None
|
|
if hm >= EOD_RETRY_UNTIL:
|
|
_LAST_EOD_DATE = today
|
|
return None
|
|
summary = eod_reconcile(engine, ledger, db, account_id, strategy_name)
|
|
if not summary.get("conservation_gaps"):
|
|
_LAST_EOD_DATE = today
|
|
return summary
|