tradingSystem/app/core/exec_timing.py

410 lines
21 KiB
Python
Raw 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 -*-
"""
择时执行器 · 实现B「内置保守择时」+ 实现A的纯逻辑部件 (零外部依赖, 可单测)
========================================================================
设计 POSITION_MGMT_DESIGN.md §8。两个实现的分工 (2026-08-03 接实现A时定下):
hard_gate() 事实性检查 —— 无价/停牌/配额尽/非时段/一字板/不追高(当日涨幅)/
14:45 兜底。这些检查不委托给任何人, 两个择时实现都必须先过它;
命中返回决策, 未命中返回 None。
decide() 实现B 全量判定 = hard_gate() + 内置保守规则 (避开开盘/均价/回踩)。
行为与拆分前完全一致, test_batch3 锁着。
apply_advice() 把决策系统的应答 (FIRE/WAIT + 建议价) 折算成与 decide() 同构的决策。
建议价通常是执行区间的边缘; 偏离现价超出保护幅度时按本地口径重定并留痕。
实现A的取数与降级在 services/exec_advisor.py: 决策系统不可用 → 整轮退 decide()
(设计 §13「择时退实现B」), 绝不因为对端故障停出手。
规则原文与落点:
每日配额 = 剩余量 ÷ 剩余窗口天数, 向上取整到一手 → daily_quota()
卖出: 避开开盘 30 分钟; 现价 ≥ 当日均价(VWAP) 时分笔卖 → decide() side=sell
14:45 未完成 → 现价 × 0.998 限价兜底
买入: 现价 ≤ 当日均价或进入回踩带时买; 当日涨幅 > 5% 停止买入 (不追高)
窗口末日 14:45 强制限价完成或按命令属性作废
停牌/一字板当日跳过顺延; 窗口耗尽未完成 → 命令置「部分完成」并告警
时点一律用「分钟数」比较 (hm_to_min), 免去跨时区与字符串比较的坑。
"""
from __future__ import annotations
from app.core.sizer import LOT
OPEN_MIN = 9 * 60 + 30 # 09:30 开盘
CLOSE_MIN = 15 * 60 # 15:00 收盘
LUNCH_START = 11 * 60 + 30
LUNCH_END = 13 * 60
# decide() 的动作词
ACT_FIRE = "FIRE" # 出手
ACT_WAIT = "WAIT" # 条件未到, 本轮不动
ACT_SKIP = "SKIP" # 当日跳过 (停牌/一字板), 顺延
ACT_STOP = "STOP" # 本日不再出手 (如买入触发不追高)
def hm_to_min(hm) -> int:
""""14:45" / datetime / (h, m) → 当日分钟数。"""
if hm is None:
return 0
if isinstance(hm, (tuple, list)):
return int(hm[0]) * 60 + int(hm[1])
if hasattr(hm, "hour"):
return hm.hour * 60 + hm.minute
s = str(hm).strip()
if ":" in s:
h, m = s.split(":")[:2]
return int(h) * 60 + int(m)
return int(s)
def in_session(now_min: int) -> bool:
return (OPEN_MIN <= now_min <= LUNCH_START) or (LUNCH_END <= now_min <= CLOSE_MIN)
def daily_quota(remaining_qty: int, tdays_left: int, *, total_qty: int = None,
lot: int = LOT, allow_odd_tail: bool = False) -> int:
"""当日配额 = 剩余量 ÷ 剩余交易日, 向上取整到一手。
* 最后一日 (tdays_left ≤ 1) 或剩余不足一手 → 全部剩余 (含零股尾巴)。
* allow_odd_tail=True (整票清仓) 时不做整百取整, 零股一并出。
"""
r = max(0, int(remaining_qty or 0))
if r <= 0:
return 0
d = max(1, int(tdays_left or 1))
if d <= 1 or r <= lot:
return r
raw = r / d
q = int(-(-raw // lot)) * lot # 向上取整到一手
q = min(q, r)
if not allow_odd_tail and q % lot and q != r:
q = (q // lot) * lot
# 若本次取整后剩下不足一手的尾巴, 并进本次一起出, 免得最后一天剩 30 股卡住
if 0 < r - q < lot:
q = r
return max(q, 0) if q > 0 else r
def slice_qty(quota: int, slices: int = 1, lot: int = LOT) -> list:
"""把当日配额切成 N 笔 (设计「分笔卖出配额」)。最后一笔兜底吃掉余数。"""
quota = max(0, int(quota or 0))
n = max(1, int(slices or 1))
if quota <= 0:
return []
if n == 1 or quota <= lot:
return [quota]
per = int(quota / n / lot) * lot
if per <= 0:
return [quota]
out = [per] * (n - 1)
out.append(quota - per * (n - 1))
return [q for q in out if q > 0]
def parse_bucket_times(raw) -> list:
""""11:30,14:00" → 升序的当日分钟数列表。空串或解析不出 = 不分桶 (旧行为)。
只收落在开收盘之间的时点; 去重排序, 保证桶序稳定。"""
out = set()
for part in str(raw or "").split(","):
part = part.strip()
if not part:
continue
try:
m = hm_to_min(part)
except (TypeError, ValueError):
continue
if OPEN_MIN < m < CLOSE_MIN:
out.add(m)
return sorted(out)
def _bucket_due(*, now_min: int, quota: int, fired: int, params: dict, eod_min: int):
"""卖出分桶收口 (2026-08-17 清仓择时改造): 已收口的桶累计应出量没跟上 → 补齐差额。
由来: 「现价不低于均价才卖」是只在强势时放行的条件, 而清仓多发生在弱势时。
下跌日这个条件全天不满足, 全部数量堆到 14:45 一笔打出 —— 冲击最大, 兜底限价
还追不上下跌。把日内切成几个时段桶, 每桶分一份当日配额: 桶内照旧择价 (价好
就提前多卖), 桶收口时无条件补齐该桶份额。跌日的强制完成由此分散到多个时点,
涨日的择价好处一点不丢。
口径三条: ① `PMS_SELL_BUCKET_TIMES` 留空 = 不分桶, 行为与改造前逐字一致;
② 最后一桶恒为 14:45 兜底 (它管收尾, 本函数只管中途的桶); ③ 补齐量向下取整
到一手 —— 部分减持必须整百 (规则闸会拦零股), 不足一手的差额留给下一桶或兜底。
"""
closes = [m for m in parse_bucket_times(params.get("sell_bucket_times")) if m < eod_min]
if not closes or int(quota or 0) <= 0:
return None
n = len(closes) + 1 # 兜底时点是最后一桶
passed = sum(1 for m in closes if now_min >= m)
if passed <= 0:
return None
due = int(int(quota) * passed / n)
short = due - int(fired or 0)
if short < LOT:
return None
qty = min(int(quota) - int(fired or 0), (short // LOT) * LOT)
if qty < LOT:
return None
return {"qty": qty,
"reason": f"分桶收口: 第 {passed}/{n} 桶已过 {_fmt(closes[passed - 1])}, "
f"累计应出 {due} 股、实出 {int(fired or 0)} 股, 补齐 {qty}"}
def quota_wait_reason(urgent: bool = False, is_last_day: bool = False) -> str:
"""left<=0 (可再投放为零) 这一句该怎么说 —— 同一个数, 两种完全相反的情形:
* 紧急单 / 窗口末日单: 「今日已投放量」按**在途量**算 (run_tick 走 _inflight_today),
于是 left=0 的含义是「该出的量全挂在券商、正等成交回来」, 并**不是**今天不再出手。
* 普通多日单: 「今日已投放量」按今日成交+在途算 (_consumed_today), left=0 才是真的
「当日节流到顶、今天不再补」。
行为两者一样 (都 ACT_WAIT), 但话必须分开 —— 2026-08-03/08-19 两次盘中都是盯着
「当日配额已出完」这一句反复排查, 真相却是单子正挂着等成交。措辞分开, 一眼看得出下场。"""
return "已全部在途, 等成交回来" if (urgent or is_last_day) else "当日配额已出完"
def hard_gate(*, side: str, now, day: dict, params: dict, is_last_day: bool,
is_command: bool, fired_today: int = 0, quota: int = 0,
urgent: bool = False):
"""事实性检查 (实现A/B 共用的前置)。命中返回决策 dict, 未命中返回 None。
包含: 无价/停牌/配额尽/非时段/一字板/买入不追高(当日涨幅)/14:45 兜底与「兜底后
不新开买单」。**不含**避开开盘 N 分钟与均价/回踩 —— 那些是各实现自己的规则。
卖出侧另有两条 PMS 自留地纪律 (2026-08-17, 实现A/B 一律生效): 紧急直通
(urgent=True 时不做择价博弈) 与分桶收口 (见 _bucket_due 的说明)。
与拆分前 decide() 的唯一语义差别: 卖出的 14:45 兜底现在排在「避开开盘 30 分钟」
之前判。两者只在 eod_force_time 被改到 10:00 之前这种病态配置下才会同时成立,
且真到那时也该是兜底赢 —— 强制完成这件事永远归 PMS 自己管。
**is_command 是必填的, 故意不给默认值** (2026-08-06): 漏传立刻 TypeError,
而不是悄悄按某一侧的口径走。它决定窗口末日要不要强制完成 —— 见下面买入分支的说明。
"""
now_min = hm_to_min(now)
price = float(day.get("price") or 0)
eod_min = hm_to_min(params.get("eod_force_time") or "14:45")
disc = float(params.get("eod_force_discount") or 0.998)
left = max(0, int(quota) - int(fired_today))
def out(action, reason, limit=None, forced=False):
return {"action": action, "qty_hint": left, "limit_price": limit,
"reason": reason, "forced": forced}
if price <= 0:
return out(ACT_SKIP, "取不到现价, 当日跳过")
if day.get("halted"):
return out(ACT_SKIP, "停牌, 当日跳过顺延")
if left <= 0:
return out(ACT_WAIT, quota_wait_reason(urgent, is_last_day))
if not in_session(now_min):
return out(ACT_WAIT, "非交易时段")
if side == "sell":
# **卖出侧不按命令/自主分岔, 一律兜底。** 这不是漏改, 是方向不同:
# 买入的强制完成是"多背一份风险", 卖出的强制完成是"少背一份风险"。
# 自主减仓 (保垫减仓、信号转来的清仓) 若也到期作废, 那是把该降的风险留在账上 ——
# 宁可买不上, 但不能卖不掉。减持方向不设门槛这条口径, 在这里同样成立。
if day.get("limit_down") and not is_last_day:
return out(ACT_SKIP, "跌停一字板, 当日跳过顺延")
if urgent:
# 紧急卖出直通 (2026-08-17): 一键清仓的方案注记本来就写着"紧急, 不做择时
# 优化", 高置信风控清仓同理 —— 但这个语义从前没传到执行层, 紧急清仓照样
# 避开开盘半小时、照样等均价。现在: 有价、在时段、有配额, 就出手。
# 限价用更激进的紧急系数, 且**不置 forced** —— 分片 TTL 到期撤掉重下,
# 每分钟按新现价重定限价, 下跌中追着走; 过了兜底时点才转 forced (挂到收盘)。
udisc = float(params.get("urgent_sell_discount") or 0.995)
return out(ACT_FIRE, f"紧急卖出直通: 限价 = 现价×{udisc}",
limit=round(price * udisc, 2), forced=(now_min >= eod_min))
if now_min >= eod_min:
return out(ACT_FIRE, f"{_fmt(eod_min)} 兜底: 限价 = 现价×{disc}",
limit=round(price * disc, 2), forced=True)
b = _bucket_due(now_min=now_min, quota=int(quota or 0),
fired=int(fired_today or 0), params=params, eod_min=eod_min)
if b is not None:
return {"action": ACT_FIRE, "qty_hint": b["qty"],
"limit_price": round(price * disc, 2),
"reason": b["reason"], "forced": False}
return None
if side == "buy":
if day.get("limit_up"):
return out(ACT_SKIP, "涨停一字板, 当日跳过顺延")
dayup = day.get("day_chg_from_open")
cap = float(params.get("buy_halt_dayup") or 0.05)
if dayup is not None and float(dayup) > cap:
return out(ACT_STOP, f"当日涨幅 {float(dayup):.2%} > {cap:.0%}, 停止买入 (不追高)")
premium = round(2 - disc, 4) # 买入兜底与卖出对称: 0.998 → 1.002
if now_min >= eod_min:
# 窗口末日的强制完成**只对命令驱动生效** (2026-08-06)。
#
# 由来: 2026-08-06 实机, 000063.SZ 那条建仓指令连日判「现价高于买入区间上沿,
# 不追」, 到窗口末日 14:46 照样按 现价×1.002 追进去 1700 股。那一笔是命令驱动的
# (用户下过「投这么多」的命令, 到期必须完成, 强制是对的), 但同一段代码等新建仓
# 上线就会作用在自主提议上 —— 系统自己挑的票, 连着三天判"不追", 第三天下午
# 无人值守地追进去, 与「可以接受买不上」那条原则直接冲突。
#
# 更要紧的是: window_verdict 早就把口径写死了 ——
# "PARTIAL" if is_command else "EXPIRED" (自主类窗口耗尽直接作废)
# 既然自主的到期就作废, 就不该在到期当天先被强制完成一遍。这两处本来是矛盾的,
# 这次是把它们对齐, 不是新增策略。
#
# 影响面要说破: 已有的 FILL / ADD / DCA 自主买单也跟着改 —— 它们从前也会在
# 末日强制完成, 现在到期作废。「回踩补足」在末日追高买本身就自相矛盾, 所以
# 这对它们同样是修正, 但确实是既有行为的改变。
if is_last_day and is_command:
return out(ACT_FIRE, f"窗口末日 {_fmt(eod_min)} 强制完成: 限价 = 现价×{premium}",
limit=round(price * premium, 2), forced=True)
if is_last_day:
return out(ACT_WAIT, f"窗口末日 {_fmt(eod_min)} 之后不追买 —— "
f"自主买入不做强制完成, 本条到期作废 (可以接受买不上)")
return out(ACT_WAIT, f"{_fmt(eod_min)} 后不新开买单, 顺延次日")
return None
return out(ACT_SKIP, f"方向 {side!r} 非法")
def decide(*, side: str, now, day: dict, params: dict, is_last_day: bool,
is_command: bool = True, fired_today: int = 0, quota: int = 0,
urgent: bool = False) -> dict:
"""实现B: 单条指令在「此刻」该不该出手 (= 硬闸 + 内置保守看法)。
day: {price, vwap, halted, limit_up, limit_down, day_chg_from_open, support}
params: {sell_avoid_open_min, buy_halt_dayup, eod_force_time, eod_force_discount}
返回 {"action", "qty_hint", "limit_price", "reason", "forced"}
is_command 默认 True (命令口径 = 2026-08-06 之前的行为), 只影响买入的窗口末日强制完成,
见 hard_gate 里那段说明。生产路径由 exec_advisor 从指令的 progress.is_command 显式传下来,
这里给默认值只是为了让既有单测与临时试算不必逐个改口径。
"""
h = hard_gate(side=side, now=now, day=day, params=params, is_last_day=is_last_day,
is_command=is_command, fired_today=fired_today, quota=quota,
urgent=urgent)
if h is not None:
return h
now_min = hm_to_min(now)
price = float(day.get("price") or 0)
vwap = float(day.get("vwap") or 0)
disc = float(params.get("eod_force_discount") or 0.998)
left = max(0, int(quota) - int(fired_today))
def out(action, reason, limit=None, forced=False):
return {"action": action, "qty_hint": left, "limit_price": limit,
"reason": reason, "forced": forced}
if side == "sell":
avoid = int(params.get("sell_avoid_open_min") or 30)
if now_min < OPEN_MIN + avoid:
return out(ACT_WAIT, f"避开开盘 {avoid} 分钟 (至 {_fmt(OPEN_MIN + avoid)})")
if vwap > 0 and price >= vwap:
return out(ACT_FIRE, f"现价 {price} ≥ 当日均价 {vwap}, 分笔卖出配额",
limit=round(price * disc, 2))
return out(ACT_WAIT, f"现价 {price} < 当日均价 {vwap or ''}, 等更好的价")
premium = round(2 - disc, 4)
support = float(day.get("support") or 0)
if vwap > 0 and price <= vwap:
return out(ACT_FIRE, f"现价 {price} ≤ 当日均价 {vwap}, 买入配额",
limit=round(price * premium, 2))
if support > 0 and price <= support * 1.01:
return out(ACT_FIRE, f"现价 {price} 进入回踩带 (支撑 {support}), 买入配额",
limit=round(price * premium, 2))
return out(ACT_WAIT, f"现价 {price} > 当日均价 {vwap or ''} 且未回踩, 等回调")
def apply_advice(*, side: str, day: dict, params: dict, advice: dict, left: int):
"""把决策系统的应答折算成与 decide() 同构的决策 (实现A的落地一跳)。
advice: {"verdict": FIRE|WAIT, "limit_price"?, "reason"?} —— 已过 hard_gate 才会走到这。
建议价通常是执行区间的边缘, 离现价百分之几属正常; 缺失/非法/偏离现价超过
advice_limit_band (默认 10%) 才视为异常数据, 按本地口径重定 (买 现价×premium /
卖 现价×disc) 并在 reason 里留痕 —— 限价最终要过出口表的参数校验, 离谱的建议价
与其被拒不如就地纠偏。verdict 无法识别返回 None, 由调用方退实现B。
"""
price = float(day.get("price") or 0)
disc = float(params.get("eod_force_discount") or 0.998)
premium = round(2 - disc, 4)
band = float(params.get("advice_limit_band") or 0.03)
v = str(advice.get("verdict") or "").strip().upper()
reason = f"[实现A] {advice.get('reason') or '决策系统未给理由'}"
if v == "WAIT":
return {"action": ACT_WAIT, "qty_hint": left, "limit_price": None,
"reason": reason, "forced": False}
if v == "FIRE":
fallback = round(price * (premium if side == "buy" else disc), 2)
try:
limit = float(advice.get("limit_price") or 0)
except (TypeError, ValueError):
limit = 0.0
if limit <= 0:
limit = fallback
elif price > 0 and abs(limit / price - 1) > band:
reason += f" (建议价 {limit} 偏离现价超 {band:.0%}, 按本地口径 {fallback})"
limit = fallback
return {"action": ACT_FIRE, "qty_hint": left, "limit_price": round(limit, 2),
"reason": reason, "forced": False}
return None
def _fmt(m: int) -> str:
return f"{m // 60:02d}:{m % 60:02d}"
def add_trade_minutes(start_min: int, minutes: int) -> int:
"""从 start_min 起推进 N 个「有效交易分钟」, 返回当日分钟数 (收盘封顶)。
午休 11:30~13:00 不计入 —— 挂单在午休不撮合, 把这 90 分钟算进有效期
等于凭空把有效期砍掉一大截。11:25 下的单给 10 分钟, 应该活到 13:05,
而不是 11:35 就被撤掉。
"""
m = max(int(start_min), OPEN_MIN)
left = max(0, int(minutes))
if LUNCH_START < m < LUNCH_END: # 起点落在午休里, 从下午开盘算
m = LUNCH_END
if m <= LUNCH_START and m + left > LUNCH_START:
left -= (LUNCH_START - m) # 用掉上午剩余部分
m = LUNCH_END
return min(m + left, CLOSE_MIN)
def slice_deadline(now, *, ttl_min: int = 10, forced: bool = False) -> int:
"""单个下发分片的有效期截止 (当日分钟数)。下游到点未成交即自动撤单。
普通分片只给 ttl_min 个交易分钟: run_tick 每分钟重评一次, 撤掉重下比挂着更好
—— 限价是按下发那一刻的价算的, 挂久了价已经不是那个价, 还白占可用资金/持仓。
兜底单 (14:45 之后的 forced) 直接给到收盘: 那是「今天必须走掉」的单, 不能被
TTL 撤回来。
"""
if forced:
return CLOSE_MIN
return add_trade_minutes(hm_to_min(now), ttl_min)
def window_verdict(*, remaining_qty: int, tdays_left: int, is_command: bool,
side: str = None) -> dict:
"""窗口耗尽时的收口 (设计 §8 末句)。
还有剩余且窗口已尽 → 命令置「部分完成」并告警; 命令类保留人工兜底提示,
自主类**买入**直接作废。
自主类**卖出不作废** (2026-08-17): 信号转来的清仓、保垫减仓若窗口耗尽就
作废, 是把该降的风险留在账上 —— 与 hard_gate 卖出分支"宁可买不上, 不能
卖不掉"是同一条口径, 这里把它从日内兜底延伸到跨日收口。指令保持在途并
告警, 之后每天都按"末日"节奏出手 (配额 = 全部剩余 + 14:45 兜底), 直到
卖完或人工撤销。side 不传时沿用旧口径 (兼容既有调用与单测)。
"""
if int(remaining_qty or 0) <= 0:
return {"verdict": "DONE", "note": "已足额完成"}
if int(tdays_left or 0) > 0:
return {"verdict": "RUNNING", "note": f"窗口内剩余 {tdays_left} 交易日"}
if not is_command and str(side or "").lower() == "sell":
return {"verdict": "PARTIAL",
"note": f"窗口耗尽仍剩 {remaining_qty} 股 —— 卖出指令不作废, "
f"继续按末日节奏出手并告警; 请关注可卖量、停牌与跌停状态, "
f"不想再卖请人工撤销该指令"}
return {"verdict": "PARTIAL" if is_command else "EXPIRED",
"note": f"窗口耗尽仍剩 {remaining_qty} 股 —— "
+ ("命令置部分完成并告警, 请在页面决定顺延或人工完成"
if is_command else "自主指令作废")}