fix bug
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
13
docs/bug.md
13
docs/bug.md
@@ -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`
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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")),
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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,22 +142,14 @@ 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 = (
|
if status != current_status:
|
||||||
STATUS_OK
|
log.info("[状态] %s 订单=%s,状态=%s->%s", code, local_order_id, current_status, status)
|
||||||
if all(order.status == "56" for order in matching_orders)
|
setattr(item, status_attr, status)
|
||||||
else STATUS_ING
|
|
||||||
)
|
|
||||||
if status != current_status:
|
|
||||||
log.info("[状态] %s 订单=%s,状态=%s->%s", code, local_order_id, current_status, status)
|
|
||||||
setattr(item, status_attr, status)
|
|
||||||
self.set(item)
|
self.set(item)
|
||||||
|
|
||||||
# Opening orders normally have no position until their first fill. Order
|
# Opening orders normally have no position until their first fill. Order
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user