tradingSystem/app/core/exec_timing.py

208 lines
9.0 KiB
Python
Raw Permalink 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「内置保守择时」(纯逻辑, 无外部依赖, 可单测)
================================================================
设计 POSITION_MGMT_DESIGN.md §8。实现A (委托决策系统盘中择时研判) 走同一个
`decide()` 接口, 研判接通后在 services 层换实现即可, 本模块是兜底也是一期主力。
规则原文与落点:
每日配额 = 剩余量 ÷ 剩余窗口天数, 向上取整到一手 → 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 decide(*, side: str, now, day: dict, params: dict, is_last_day: bool,
fired_today: int = 0, quota: int = 0) -> dict:
"""单条指令在「此刻」该不该出手。
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"}
"""
now_min = hm_to_min(now)
price = float(day.get("price") or 0)
vwap = float(day.get("vwap") 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, "跌停一字板, 当日跳过顺延")
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 now_min >= eod_min:
return out(ACT_FIRE, f"{_fmt(eod_min)} 兜底: 限价 = 现价×{disc}",
limit=round(price * disc, 2), forced=True)
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 ''}, 等更好的价")
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:
if is_last_day:
return out(ACT_FIRE, f"窗口末日 {_fmt(eod_min)} 强制完成: 限价 = 现价×{premium}",
limit=round(price * premium, 2), forced=True)
return out(ACT_WAIT, f"{_fmt(eod_min)} 后不新开买单, 顺延次日")
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 ''} 且未回踩, 等回调")
return out(ACT_SKIP, f"方向 {side!r} 非法")
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 "自主指令作废")}