178 lines
7.6 KiB
Python
178 lines
7.6 KiB
Python
# -*- 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 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 "自主指令作废")}
|