【Python量化实盘工程 #05】信号来了就发单?订单状态机与防重单实战
策略信号每秒闪一次,你每秒都发单?状态机不建好,实盘就是灾难现场:重复成交、废单堆积、撤单混乱。
一、信号来了 ≠ 订单该发
很多量化策略的回测代码长这样:
if signal == "BUY":
order_target(symbol, 1000)
回测里这么写没问题,因为回测框架会自动帮你"去重"——同一根 K 线只执行一次。但实盘里:
- 信号可能连续 N 秒都满足条件,你发了 N 个买单
- 上一个订单还在撮合中,你又发了一个反向单
- 价格偏离市价 10% 的废单,券商直接拒了,但你不知道
- 订单挂了一下午没成交,收盘后状态还是 PENDING
没有状态机的订单管理,等于在黑暗里开车。
二、本文你将得到什么
- 一个完整的订单状态机(NEW / PENDING / FILLED / CANCELLED / REJECTED / EXPIRED)
- 信号去重机制(60 秒窗口防重单)
- 价格偏离废单拦截
- TTL 超时自动过期
- 可直接嵌入实盘系统的最小代码框架
三、订单生命周期:六个状态
NEW -> PENDING -> PARTIAL_FILLED -> FILLED
| | |
| +--> CANCELLED
| |
+--> REJECTED
|
+--> EXPIRED
- NEW:刚创建,尚未发往柜台
- PENDING:已发往柜台,等待成交
- PARTIAL_FILLED:部分成交,剩余还在撮合
- FILLED:全部成交,订单终结
- CANCELLED:主动撤单成功,订单终结
- REJECTED:柜台拒单(价格偏离、资金不足等),订单终结
- EXPIRED:超过 TTL 未成交,系统自动过期
四、状态机实现
import enum
import time
from dataclasses import dataclass, field
from typing import Dict, Optional
class OrderStatus(enum.Enum):
NEW = "NEW"
PENDING = "PENDING"
PARTIAL_FILLED = "PARTIAL_FILLED"
FILLED = "FILLED"
CANCELLED = "CANCELLED"
REJECTED = "REJECTED"
EXPIRED = "EXPIRED"
class OrderSide(enum.Enum):
BUY = "BUY"
SELL = "SELL"
@dataclass
class Order:
order_id: str
signal_id: str
symbol: str
side: OrderSide
qty: int
price: Optional[float] = None
status: OrderStatus = OrderStatus.NEW
filled_qty: int = 0
avg_price: float = 0.0
created_at: float = field(default_factory=time.time)
reject_reason: str = ""
class OrderStateMachine:
VALID_TRANSITIONS = {
OrderStatus.NEW: {OrderStatus.PENDING, OrderStatus.REJECTED},
OrderStatus.PENDING: {OrderStatus.PARTIAL_FILLED, OrderStatus.FILLED,
OrderStatus.CANCELLED, OrderStatus.EXPIRED, OrderStatus.REJECTED},
OrderStatus.PARTIAL_FILLED: {OrderStatus.FILLED, OrderStatus.CANCELLED},
OrderStatus.FILLED: set(),
OrderStatus.CANCELLED: set(),
OrderStatus.REJECTED: set(),
OrderStatus.EXPIRED: set(),
}
def init(self, price_tolerance_pct=0.05, ttl_seconds=300):
self.orders: Dict[str, Order] = {}
self.signal_window: Dict[str, float] = {}
self.dedup_window_sec = 60
self.price_tolerance_pct = price_tolerance_pct
self.ttl_seconds = ttl_seconds
self._id_counter = 0
def _next_id(self, prefix="ORD"):
self._id_counter += 1
return f"{prefix}-{int(time.time()*1000)%1000000:06d}-{self._id_counter:03d}"
def is_duplicate(self, signal_id: str) -> bool:
last = self.signal_window.get(signal_id)
if last and (time.time() - last) < self.dedup_window_sec:
return True
return False
def create_order(self, signal_id, symbol, side, qty, price=None):
if self.is_duplicate(signal_id):
raise ValueError(f"信号去重拦截: {signal_id} 已在去重窗口内")
if price is not None and price <= 0:
raise ValueError(f"废单拦截: 价格非法 price={price}")
if qty <= 0:
raise ValueError(f"废单拦截: 数量非法 qty={qty}")
oid = self._next_id()
order = Order(order_id=oid, signal_id=signal_id, symbol=symbol,
side=side, qty=qty, price=price, status=OrderStatus.NEW)
self.orders[oid] = order
self.signal_window[signal_id] = time.time()
return order
def transition(self, order_id, new_status, filled_qty=None, avg_price=None, reject_reason=None):
order = self.orders.get(order_id)
if not order:
raise ValueError(f"订单不存在: {order_id}")
valid = self.VALID_TRANSITIONS.get(order.status, set())
if new_status not in valid:
raise ValueError(f"非法状态转换: {order.status.value} -> {new_status.value}")
order.status = new_status
if filled_qty is not None:
order.filled_qty = filled_qty
if avg_price is not None:
order.avg_price = avg_price
if reject_reason is not None:
order.reject_reason = reject_reason
def cancel(self, order_id):
order = self.orders.get(order_id)
if not order:
raise ValueError(f"订单不存在: {order_id}")
if order.status not in {OrderStatus.PENDING, OrderStatus.PARTIAL_FILLED}:
raise ValueError(f"不可撤单,当前状态: {order.status.value}")
self.transition(order_id, OrderStatus.CANCELLED)
def expire_old_orders(self):
now = time.time()
expired = []
for oid, order in self.orders.items():
if order.status in {OrderStatus.NEW, OrderStatus.PENDING, OrderStatus.PARTIAL_FILLED}:
if now - order.created_at > self.ttl_seconds:
expired.append(oid)
for oid in expired:
self.transition(oid, OrderStatus.EXPIRED, reject_reason="TTL 超时自动过期")
return expired
五、六大场景实战
场景 1:正常限价单
fsm = OrderStateMachine()
order = fsm.create_order(
signal_id="SIG-001", symbol="600519",
side=OrderSide.BUY, qty=100, price=1690.00
)
fsm.transition(order.order_id, OrderStatus.PENDING)
fsm.transition(order.order_id, OrderStatus.FILLED,
filled_qty=100, avg_price=1689.00)
print(f"成交 {order.filled_qty}/{order.qty} @ {order.avg_price}")
场景 2:信号去重
同一 signal_id 60 秒内再次发单 → 拦截
fsm.create_order(signal_id="SIG-001", symbol="600519",
side=OrderSide.BUY, qty=200, price=1685.00)
# ValueError: 信号去重拦截
场景 3:价格偏离废单
order = fsm.create_order(
signal_id="SIG-004", symbol="600519",
side=OrderSide.BUY, qty=100, price=2000.00 # 偏离市价 18.5%
)
fsm.transition(order.order_id, OrderStatus.PENDING)
# 柜台检查发现偏离 > 5%,直接 REJECTED
fsm.transition(order.order_id, OrderStatus.REJECTED,
reject_reason="价格偏离市价 18.5% 超过容忍度 5.0%")
场景 4:撤单与二次撤单拦截
order = fsm.create_order(signal_id="SIG-005", symbol="600519",
side=OrderSide.BUY, qty=100, price=1688.00)
fsm.transition(order.order_id, OrderStatus.PENDING)
fsm.cancel(order.order_id) # 成功撤单
fsm.cancel(order.order_id) # 报错:不可撤单,当前状态 CANCELLED
场景 5:TTL 超时过期
order = fsm.create_order(signal_id="SIG-006", symbol="600519",
side=OrderSide.BUY, qty=100, price=1688.00)
fsm.transition(order.order_id, OrderStatus.PENDING)
# 模拟过了 300 秒仍未成交
order.created_at = time.time() - 400
fsm.expire_old_orders()
print(order.status.value) # EXPIRED
六、运行结果
--- 场景 1: 正常限价单 ---
创建订单 ORD-531141-001 | signal=SIG-20260915-001 | status=NEW
最终状态: FILLED | 成交 100/100 | 均价 1689.00
--- 场景 2: 信号去重拦截 ---
拦截成功: 信号去重拦截: signal_id=SIG-20260915-001 已在去重窗口内
--- 场景 3: 市价单快速成交 ---
订单 ORD-531141-002 | 最终状态: FILLED | 成交 50/50 | 均价 1688.00
--- 场景 4: 价格偏离废单 ---
订单 ORD-531141-003 | 最终状态: REJECTED | 原因: 价格偏离市价 18.5% 超过容忍度 5.0%
--- 场景 5: 主动撤单 ---
撤单后状态: CANCELLED
二次撤单拦截: 不可撤单,当前状态: CANCELLED
--- 场景 6: TTL 超时过期 ---
超时订单: ['ORD-531141-005'] | 状态: EXPIRED | 原因: TTL 超时自动过期
订单状态统计:
FILLED: 2 | REJECTED: 1 | CANCELLED: 1 | EXPIRED: 1
总计: 5 | 成交: 2 | 废单: 1 | 撤单: 1 | 过期: 1
七、常见坑与注意
- 去重窗口不是越长越好:60 秒对日线策略够用,但对分钟级策略可能太长,建议按信号周期调整
- 状态机必须单线程操作:多线程并发修改订单状态会出竞态,建议用 asyncio.Lock 或线程锁
- PARTIAL_FILLED 别忘了:很多新手只处理 FILLED 和 CANCELLED,部分成交的剩余订单会一直挂在那
- TTL 要区分标的:流动性差的股票 TTL 可以放宽到 600 秒,ETF 可以收紧到 60 秒
八、小结 + 下篇预告
本文实现了一个最小可用的订单状态机,覆盖去重、废单拦截、撤单、超时过期四大核心能力。把它接入你的策略引擎,配合上一篇的滑点模型,回测到实盘的差距已经缩小了一大半。
下篇预告:从模拟盘到实盘——上线前验收 checklist,6 项硬指标决定你的策略能不能真正上线。
免责声明
本文仅供技术学习交流,不构成任何投资建议。市场有风险,投资需谨慎。
了解更多

浙公网安备 33010602011771号