This commit is contained in:
2026-09-05 00:23:55 +08:00
parent 9f01eb8dc5
commit 6c2eca8fdc
18 changed files with 59 additions and 51 deletions

View File

@@ -313,6 +313,7 @@ def format_holding(positions):
stock = position.m_strInstrumentID + '.' + position.m_strExchangeID stock = position.m_strInstrumentID + '.' + position.m_strExchangeID
holding[stock] = { holding[stock] = {
'StockCode': stock, 'StockCode': stock,
'TradeID':position.m_strTradeID,
'StockName': position.m_strInstrumentName, 'StockName': position.m_strInstrumentName,
'Direction': position.m_nDirection, 'Direction': position.m_nDirection,
'Volume': position.m_nVolume, 'Volume': position.m_nVolume,

View File

@@ -1,9 +1,3 @@
### S1. 券商快照延迟时,本地委托锁会被清空并可能重复下单
- 位置:`strategy/trend/order.py:54-92``strategy/trend/boot.py:127-130,157-162``strategy/trend/open.py:23-25`
- 证据:下单成功后 `OrderBook.place()` 会立即加入本地 pending 和方向锁,但下一轮 `refresh()` 会完全用券商快照重建 `data``lock`。若券商快照尚未出现刚提交的订单,本地 pending 会直接丢失。开仓候选只排除当前持仓,不排除 `State` 中的待成交底仓,最终仅依赖已经被清掉的 `busy()` 锁。
- 影响:接口存在可见性延迟时,同一证券可能在连续轮次重复提交买单;卖单和补仓也存在相同锁丢失窗口。
- 建议:刷新时合并尚未超时且券商未确认的本地 pending而不是覆盖按本地订单号查询确认后才能移除。开仓筛选同时检查状态机中的 `base_status`
### H3. 撤单、拒单、废单和部分成交不能驱动状态机正确收敛 ### H3. 撤单、拒单、废单和部分成交不能驱动状态机正确收敛
@@ -14,13 +8,6 @@
- 建议:建立完整 QMT 委托状态映射,按实际成交数量处理全成、部成、已撤、废单、拒单和未知;消失订单需二次查询确认。 - 建议:建立完整 QMT 委托状态映射,按实际成交数量处理全成、部成、已撤、废单、拒单和未知;消失订单需二次查询确认。
### M1. IPO 仍只依赖本地锁,存在重复申购窗口
- 位置:`strategy/ipo/boot.py:49-65`
- 证据:下单成功后才写锁;未查询券商当日委托或成交记录。进程若在下单成功与写锁之间退出,或锁文件被删除,下一次调度会再次申购。
- 影响:同一证券可能重复发送申购请求,安全性依赖券商端是否拒绝重复申购。
- 建议:本地锁只作为快速缓存,下单前以券商委托/成交记录做最终幂等校验。
### M6. 策略成本使用提交时行情价,而非实际成交价 ### M6. 策略成本使用提交时行情价,而非实际成交价
- 位置:`strategy/trend/open.py:80-86``strategy/trend/positions.py:186-192` - 位置:`strategy/trend/open.py:80-86``strategy/trend/positions.py:186-192`

View File

@@ -1,3 +1,4 @@
httpx>=0.27,<1 httpx>=0.27,<1
PyYAML>=6.0 PyYAML>=6.0
APScheduler>=3.10,<4 APScheduler>=3.10,<4
CacheLib>=0.13,<1

View File

@@ -68,6 +68,7 @@ class OrderItem:
class PositionItem: class PositionItem:
stock_code: str = "" stock_code: str = ""
stock_name: str = "" stock_name: str = ""
trade_id:str = ""
direction: Any = None direction: Any = None
volume: int = 0 volume: int = 0
open_price: float = 0.0 open_price: float = 0.0
@@ -87,6 +88,7 @@ class PositionItem:
def from_dict(cls, data: dict[str, Any], code: str = "") -> "PositionItem": def from_dict(cls, data: dict[str, Any], code: str = "") -> "PositionItem":
return cls( return cls(
stock_code=str(data.get("StockCode") or code), stock_name=str(data.get("StockName") or ""), stock_code=str(data.get("StockCode") or code), stock_name=str(data.get("StockName") or ""),
trade_id=str(data.get("TradeID") or ""),
direction=data.get("Direction"), volume=_number(data.get("Volume"), int), direction=data.get("Direction"), volume=_number(data.get("Volume"), int),
open_price=_number(data.get("OpenPrice")), float_profit=_number(data.get("FloatProfit")), open_price=_number(data.get("OpenPrice")), float_profit=_number(data.get("FloatProfit")),
market_value=_number(data.get("MarketValue")), stock_holder=str(data.get("StockHolder") or ""), market_value=_number(data.get("MarketValue")), stock_holder=str(data.get("StockHolder") or ""),
@@ -102,6 +104,7 @@ class PositionItem:
return cls( return cls(
stock_code=str(data.get("StockCode") or ""), stock_code=str(data.get("StockCode") or ""),
stock_name=str(data.get("StockName") or ""), stock_name=str(data.get("StockName") or ""),
trade_id=str(data.get("TradeID") or ""),
direction=data.get("Direction"), direction=data.get("Direction"),
volume=_number(data.get("Volume"), int), volume=_number(data.get("Volume"), int),
open_price=_number(data.get("OpenPrice")), open_price=_number(data.get("OpenPrice")),

View File

@@ -8,6 +8,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from threading import Lock from threading import Lock
from typing import Any from typing import Any
from cachelib import SimpleCache
from sdk import APIError, ORDER_SIDE_BY_OFFSET, Client, OrderItem from sdk import APIError, ORDER_SIDE_BY_OFFSET, Client, OrderItem
@@ -33,30 +34,32 @@ class PlaceOrderRequest:
class OrderBook: class OrderBook:
"""线程安全的活动委托缓存。""" """线程安全的活动委托缓存。"""
def __init__(self, lock_timeout_sec: float = 180, cancel_timeout_sec: float = 10) -> None: def __init__(self, lock_timeout_sec: int = 180, cancel_timeout_sec: float = 10) -> None:
self.lock_timeout_sec = max(0.0, float(lock_timeout_sec)) self.lock_timeout_sec = max(1, lock_timeout_sec)
self.cancel_timeout_sec = timedelta(seconds=cancel_timeout_sec) self.cancel_timeout_sec = timedelta(seconds=cancel_timeout_sec)
self.data: list[OrderItem] = [] self.data: list[OrderItem] = []
self.lock: dict[str, float] = {} self.busy_cache = SimpleCache(threshold=10_000, default_timeout=self.lock_timeout_sec)
self.mutex = Lock() self.mutex = Lock()
@staticmethod @staticmethod
def new_order_id(_leg: str) -> str: def new_order_id() -> str:
"""生成 ``trend-xxxxxxxx`` 格式的本地订单号。""" """生成 ``trend-xxxxxxxx`` 格式的本地订单号。"""
return f"trend-{secrets.token_hex(4)}" return f"trend-{secrets.token_hex(12)}"
def busy(self, code: str, side: str) -> bool: def busy(self, code: str, side: str) -> bool:
"""判断证券是否存在仍在处理中的同方向委托。""" """判断证券是否存在仍在处理中的同方向委托。"""
with self.mutex: with self.mutex:
key = f"{side}-{code}" return self.busy_cache.has(self._busy_key(side, code))
return key in self.lock
@staticmethod
def _busy_key(side: str, code: str) -> str:
return f"{side}-{code}"
def refresh(self, client: Client, orders: list[OrderItem]) -> None: def refresh(self, client: Client, orders: list[OrderItem]) -> None:
"""用账户快照刷新委托,并撤销超时的活动委托。""" """用账户快照刷新委托,并撤销超时的活动委托。"""
current = datetime.now() current = datetime.now()
now_timestamp = current.timestamp()
data: list[OrderItem] = [] data: list[OrderItem] = []
lock: dict[str, float] = {} busy_keys: set[str] = set()
canceled = 0 canceled = 0
for item in orders: for item in orders:
@@ -78,21 +81,27 @@ class OrderBook:
data.append(item) data.append(item)
if item.status in BUSY_STATUSES: if item.status in BUSY_STATUSES:
key = f"{item.side}-{item.code}" busy_keys.add(self._busy_key(item.side, item.code))
lock[key] = (
item.created_at.timestamp()
if item.created_at is not None
else now_timestamp
)
with self.mutex: with self.mutex:
self.data = data self.data = data
self.lock = lock log.info("[Order] 刷新完成,跟踪=%d,处理中=%d,撤销=%d", len(data), len(busy_keys), canceled)
log.info("[Order] 刷新完成,跟踪=%d,处理中=%d,撤销=%d", len(data), len(lock), canceled)
def place(self, request: PlaceOrderRequest) -> bool: def place(self, request: PlaceOrderRequest) -> bool:
"""按最新价提交委托,并立即写入本地方向锁。""" """按最新价提交委托,并立即写入本地方向锁。"""
side = ORDER_SIDE_BY_OFFSET.get(str(request.op), "")
if not side:
log.warning("[Order] 下单失败,代码=%s,原因=未知买卖方向(%s)", request.code, request.op)
return False
key = self._busy_key(side, request.code)
with self.mutex:
if self.busy_cache.has(key):
log.info("[Order] 跳过重复下单,代码=%s,方向=%s", request.code, side)
return False
self.busy_cache.set(key, True, timeout=self.lock_timeout_sec)
try: try:
result = request.client.passorder_latest_tagged( result = request.client.passorder_latest_tagged(
request.op, request.op,
@@ -112,7 +121,6 @@ class OrderBook:
log.warning("[Order] 下单被拒绝,代码=%s,本地订单=%s,状态=%s,柜台订单=%s", request.code, request.order_id, result.get("status"), order_ref) log.warning("[Order] 下单被拒绝,代码=%s,本地订单=%s,状态=%s,柜台订单=%s", request.code, request.order_id, result.get("status"), order_ref)
return False return False
side = ORDER_SIDE_BY_OFFSET.get(str(request.op), "")
pending = OrderItem( pending = OrderItem(
id=order_ref, id=order_ref,
code=request.code, code=request.code,
@@ -124,8 +132,6 @@ class OrderBook:
local_order_id=request.order_id, local_order_id=request.order_id,
) )
with self.mutex: with self.mutex:
key = f"{side}-{request.code}"
self.data.append(pending) self.data.append(pending)
self.lock[key] = pending.created_at.timestamp()
log.info("[Order] 下单已受理,代码=%s,方向=%s,数量=%d,本地订单=%s,柜台订单=%s", request.code, side, request.volume, request.order_id, order_ref) log.info("[Order] 下单已受理,代码=%s,方向=%s,数量=%d,本地订单=%s,柜台订单=%s", request.code, side, request.volume, request.order_id, order_ref)
return True return True

View File

@@ -10,15 +10,13 @@ from threading import Lock
from typing import Iterable from typing import Iterable
from sdk import OrderItem, PositionItem from sdk import OrderItem, PositionItem
from .order import BUSY_STATUSES, COMPLETED_STATUSES
# 委托状态:无操作、处理中、已完成。 # 委托状态:无操作、处理中、已完成。
STATUS_NONE = "" STATUS_NONE = ""
STATUS_ING = "ING" STATUS_ING = "ING"
STATUS_OK = "OK" STATUS_OK = "OK"
STATUS_FAILED = "FAILED"
STATUS_CANCELED = "CANCELED"
STATUS_UNKNOWN = "UNKNOWN"
@dataclass(slots=True) @dataclass(slots=True)
@@ -106,6 +104,7 @@ class State:
self.set( self.set(
StateItem( StateItem(
base_order_id=position.trade_id,
code=position.stock_code, code=position.stock_code,
base_qty=position.volume, base_qty=position.volume,
base_cost=round(position.open_price, 2), base_cost=round(position.open_price, 2),
@@ -143,19 +142,11 @@ class State:
): ):
local_order_id = getattr(item, order_id_attr) local_order_id = getattr(item, order_id_attr)
current_status = getattr(item, status_attr) current_status = getattr(item, status_attr)
if ( if current_status != STATUS_ING or not local_order_id:
current_status not in {STATUS_ING, STATUS_UNKNOWN}
or not local_order_id
):
continue continue
matching_orders = orders_by_local_id.get(local_order_id) matching_orders = orders_by_local_id.get(local_order_id)
if matching_orders: status = _order_status(matching_orders)
status = (
STATUS_OK
if all(order.status == "56" for order in matching_orders)
else STATUS_ING
)
if status != current_status: if status != current_status:
log.info("[状态] %s 订单=%s,状态=%s->%s", code, local_order_id, current_status, status) log.info("[状态] %s 订单=%s,状态=%s->%s", code, local_order_id, current_status, status)
setattr(item, status_attr, status) setattr(item, status_attr, status)
@@ -209,3 +200,15 @@ class State:
} }
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise ValueError(f"[状态] 状态字段无效: {exc}") from exc raise ValueError(f"[状态] 状态字段无效: {exc}") from exc
def _order_status(orders: list[OrderItem] | None) -> str:
"""将柜台订单简化为无状态、处理中或已成交。"""
if not orders:
return STATUS_NONE
statuses = {order.status for order in orders}
if statuses <= COMPLETED_STATUSES:
return STATUS_OK
if statuses <= BUSY_STATUSES | COMPLETED_STATUSES:
return STATUS_ING
return STATUS_NONE

View File

@@ -13,7 +13,7 @@ from strategy.trend.order import OrderBook, PlaceOrderRequest
from strategy.trend.open import do_open from strategy.trend.open import do_open
from strategy.trend.positions import LOSS_TIERS, handle_loss, manage_positions from strategy.trend.positions import LOSS_TIERS, handle_loss, manage_positions
from strategy.trend.boot import RunOnce from strategy.trend.boot import RunOnce
from strategy.trend.state import STATUS_OK, STATUS_UNKNOWN, State, StateItem from strategy.trend.state import STATUS_OK, State, StateItem
class FakeClient: class FakeClient:
@@ -238,6 +238,13 @@ class TrendTests(unittest.TestCase):
) )
self.assertEqual(state.get("A").base_status, STATUS_OK) self.assertEqual(state.get("A").base_status, STATUS_OK)
canceled = OrderItem("2", "A", "BUY", "", "54", None, 50, "local-1")
item = state.get("A")
item.base_status = "ING"
state.set(item)
state.reconcile([position], [completed, canceled])
self.assertEqual(state.get("A").base_status, "")
def test_low_cash_still_runs_position_management(self): def test_low_cash_still_runs_position_management(self):
client = SimpleNamespace( client = SimpleNamespace(
portfolio=lambda: Portfolio( portfolio=lambda: Portfolio(
@@ -276,7 +283,7 @@ class TrendTests(unittest.TestCase):
"A", "A",
base_order_id="missing-order", base_order_id="missing-order",
base_qty=100, base_qty=100,
base_status=STATUS_UNKNOWN, base_status="ING",
)) ))
state.save() state.save()
client = SimpleNamespace( client = SimpleNamespace(