tradingSystem/app/core/exec_timing.py

315 lines
16 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 hard_gate(*, side: str, now, day: dict, params: dict, is_last_day: bool,
is_command: bool, fired_today: int = 0, quota: int = 0):
"""事实性检查 (实现A/B 共用的前置)。命中返回决策 dict, 未命中返回 None。
包含: 无价/停牌/配额尽/非时段/一字板/买入不追高(当日涨幅)/14:45 兜底与「兜底后
不新开买单」。**不含**避开开盘 N 分钟与均价/回踩 —— 那些是各实现自己的规则。
与拆分前 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, "当日配额已出完")
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 now_min >= eod_min:
return out(ACT_FIRE, f"{_fmt(eod_min)} 兜底: 限价 = 现价×{disc}",
limit=round(price * disc, 2), forced=True)
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) -> 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)
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) -> dict:
"""窗口耗尽时的收口 (设计 §8 末句)。
还有剩余且窗口已尽 → 命令置「部分完成」并告警; 命令类保留人工兜底提示,
自主类直接作废。
"""
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} 交易日"}
return {"verdict": "PARTIAL" if is_command else "EXPIRED",
"note": f"窗口耗尽仍剩 {remaining_qty} 股 —— "
+ ("命令置部分完成并告警, 请在页面决定顺延或人工完成"
if is_command else "自主指令作废")}