update api,libs

This commit is contained in:
2026-08-30 00:34:27 +08:00
parent 9a43aaba23
commit cdccc48d8c
21 changed files with 2616 additions and 179 deletions

View File

@@ -6,6 +6,7 @@ loss_trigger_pct: -30
grid_step_pct: 1
min_profit_pct: 2
strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:

View File

@@ -2,10 +2,9 @@
# -*- coding: utf-8 -*-
import logging
from logging.handlers import TimedRotatingFileHandler
import os
import sys
import schedule
from apscheduler.schedulers.background import BackgroundScheduler
import config
from dataclasses import dataclass
import yaml
@@ -98,7 +97,7 @@ def wait_for_any_key() -> None:
def main() -> int:
try:
if not require_windows():
log.error("本程序仅支持 Windows 环境运行")
logging.error("本程序仅支持 Windows 环境运行")
return 1
if not check_single_instance(PROJECT_ROOT):
return 1
@@ -108,10 +107,21 @@ def main() -> int:
raise RuntimeError("配置尚未加载,请先调用 config.load()")
wait_for_qmt_api()
# 自动打新与主策略隔离;申购服务失败不能阻止趋势策略启动
schedule.every().day.at("10:00").do(AutoBuyIpo)
schedule.run_pending()
logging.info("IPO 自动打新启动成功")
# 后台调度不受趋势策略永久循环阻塞;同一时刻最多执行一个实例
scheduler = BackgroundScheduler(
timezone="Asia/Shanghai",
job_defaults={"coalesce": True, "max_instances": 1},
)
scheduler.add_job(
AutoBuyIpo,
trigger="cron",
hour="10,14",
minute=0,
id="auto_buy_ipo",
replace_existing=True,
)
scheduler.start()
logging.info("IPO 自动打新定时任务已启动:每日 10:00、14:00")
STRATEGIES[config.account_config.strategy].start_strategy()
logging.info("%s 策略启动成功",config.account_config.strateg)

View File

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

View File

@@ -11,4 +11,4 @@ class Client(AccountMixin, DataMixin, TradeMixin, MiscMixin, _HTTPClient):
"""big-qmt 同步 HTTP 客户端。"""
__all__ = ["Client", "APIError", "BusinessError", "OP_BUY", "OP_SELL", "ORDER_TYPE_VOLUME", "PR_TYPE_LATEST", "QUICK_TRADE_NOW"]
__all__ = ["Client", "APIError", "BusinessError", "OP_BUY", "OP_SELL", "ORDER_TYPE_VOLUME", "PR_TYPE_LATEST", "QUICK_TRADE_NOW", "ORDER_SIDE_BY_OFFSET", "OrderItem", "PositionItem", "parse_order"]

View File

@@ -1,21 +1,26 @@
from typing import Any
from .models import Assets, Position
from .models import Assets, PositionItem
class AccountMixin:
account_type: str
def _positions(self, path: str) -> tuple[list[str], list[Position]]:
raw = self._post(path, {"account": self.account_type}) or {}
return list(raw), [Position.from_dict(value, code) for code, value in raw.items()]
def _positions(self, path: str) -> tuple[list[str], list[PositionItem]]:
payload = self._post(path, {"account": self.account_type}) or {}
raw = payload.get("data", payload) if isinstance(payload, dict) else payload
if isinstance(raw, list):
positions = [PositionItem.from_trade_detail(item) for item in raw]
return [item.stock_code for item in positions], positions
return list(raw), [PositionItem.from_dict(value, code) for code, value in raw.items()]
def positions(self): return self._positions("/api/v2/positions")
def holding(self): return self._positions("/api/holding")
def assets(self) -> Assets:
data = self._post("/api/v2/assets", {"account": self.account_type})
return Assets(float(data.get("total", 0)), float(data.get("available", 0)))
payload = self._post("/api/v2/assets", {"account": self.account_type}) or {}
data = payload.get("data", payload) if isinstance(payload, dict) else {}
return Assets.from_dict(data)
def total_money(self) -> float: return float(self._post("/api/money/total", {"account": self.account_type}).get("total_money", 0))
def available_money(self) -> float: return float(self._post("/api/money/available", {"account": self.account_type}).get("available_money", 0))

View File

@@ -1,5 +1,5 @@
from __future__ import annotations
from datetime import datetime
from dataclasses import dataclass, field
from typing import Any
@@ -12,7 +12,60 @@ def _number(value: Any, kind: type = float) -> Any:
@dataclass(slots=True)
class Position:
class OrderItem:
"""由 QMT 委托明细解析得到的标准订单记录。"""
id: str
code: str
side: str
remark: str
status: str
created_at: datetime | None
volume: int
local_order_id: str = ""
traded_volume: int = 0
remaining_volume: int = 0
exchange_id: str = ""
name: str = ""
price: float = 0.0
trade_price: float = 0.0
trade_amount: float = 0.0
@classmethod
def from_trade_detail(cls, data: dict[str, Any]) -> "OrderItem":
"""从 TradeDetailData 的 QMT 原始字段创建订单。"""
instrument_id = str(data.get("m_strInstrumentID") or "")
exchange_id = str(data.get("m_strExchangeID") or "")
code = (
f"{instrument_id}.{exchange_id}"
if instrument_id and exchange_id
else instrument_id
)
remaining_volume = _number(data.get("m_nVolumeTotal"), int)
traded_volume = _number(data.get("m_nVolumeTraded"), int)
remark = str(data.get("m_strRemark") or "")
return cls(
id=str(data.get("m_strOrderSysID") or ""),
code=code,
side={"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"}.get(
str(data.get("m_nOffsetFlag")), ""
),
remark=remark,
status=str(data.get("m_nOrderStatus") or ""),
created_at=_trade_datetime(data),
volume=remaining_volume + traded_volume,
local_order_id=remark.split("|", 1)[0] if remark else "",
traded_volume=traded_volume,
remaining_volume=remaining_volume,
exchange_id=exchange_id,
name=str(data.get("m_strInstrumentName") or ""),
price=_number(data.get("m_dPrice")),
trade_price=_number(data.get("m_dTradePrice")),
trade_amount=_number(data.get("m_dTradeAmount")),
)
@dataclass(slots=True)
class PositionItem:
stock_code: str = ""
stock_name: str = ""
direction: Any = None
@@ -31,7 +84,7 @@ class Position:
expire_date: str = ""
@classmethod
def from_dict(cls, data: dict[str, Any], code: str = "") -> "Position":
def from_dict(cls, data: dict[str, Any], code: str = "") -> "PositionItem":
return cls(
stock_code=str(data.get("StockCode") or code), stock_name=str(data.get("StockName") or ""),
direction=data.get("Direction"), volume=_number(data.get("Volume"), int),
@@ -43,12 +96,58 @@ class Position:
future_trade_type=data.get("FutureTradeType"), expire_date=str(data.get("ExpireDate") or ""),
)
@classmethod
def from_trade_detail(cls, data: dict[str, Any]) -> "PositionItem":
"""从 TradeDetailData/Holding 的 QMT 原始字段创建持仓。"""
instrument_id = str(data.get("m_strInstrumentID") or "")
exchange_id = str(data.get("m_strExchangeID") or "")
stock_code = (
f"{instrument_id}.{exchange_id}"
if instrument_id and exchange_id
else instrument_id
)
return cls(
stock_code=stock_code,
stock_name=str(data.get("m_strInstrumentName") or ""),
direction=data.get("m_nDirection"),
volume=_number(data.get("m_nVolume"), int),
open_price=_number(data.get("m_dOpenPrice")),
float_profit=_number(data.get("m_dFloatProfit")),
market_value=_number(data.get("m_dMarketValue")),
stock_holder=str(data.get("m_strStockHolder") or ""),
frozen_volume=_number(data.get("m_nFrozenVolume"), int),
can_use_volume=_number(data.get("m_nCanUseVolume"), int),
on_road_volume=_number(data.get("m_nOnRoadVolume"), int),
yesterday_volume=_number(data.get("m_nYesterdayVolume"), int),
last_price=_number(data.get("m_dLastPrice")),
profit_rate=_number(data.get("m_dProfitRate")),
future_trade_type=data.get("m_eFutureTradeType"),
expire_date=str(data.get("m_strExpireDate") or ""),
)
@dataclass(slots=True)
class Assets:
total: float = 0.0
available: float = 0.0
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Assets":
"""兼容新版 QMT 原始资金字段及旧版简化字段。"""
return cls(
total=_number(data.get("m_dBalance", data.get("total"))),
available=_number(data.get("m_dAvailable", data.get("available"))),
)
def _trade_datetime(data: dict[str, Any]) -> datetime | None:
date = str(data.get("m_strInsertDate") or "")
clock = str(data.get("m_strInsertTime") or "").replace(":", "").zfill(6)
try:
return datetime.strptime(date + clock, "%Y%m%d%H%M%S")
except ValueError:
return None
@dataclass(slots=True)
class Tick:

View File

@@ -1,7 +1,9 @@
from .models import *
from typing import Any
OP_BUY, OP_SELL = 23, 24
ORDER_TYPE_VOLUME, PR_TYPE_LATEST, QUICK_TRADE_NOW = 1101, 5, 2
ORDER_SIDE_BY_OFFSET = {"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"}
class TradeMixin:
@@ -53,7 +55,20 @@ class TradeMixin:
def pause_task(self, task_id): return self._task("pause", task_id)
def resume_task(self, task_id): return self._task("resume", task_id)
def do_order(self): return self._post("/api/trade/do_order")
def trade_detail_data(self, datatype): return self._post("/api/trade/trade_detail_data", {"account": self.account_type, "datatype": datatype}).get("data", [])
def trade_detail_data(self, datatype):
datatype = str(datatype).strip().lower()
data = self._post(
"/api/trade/trade_detail_data",
{"account": self.account_type, "datatype": datatype},
).get("data", [])
rows = data if isinstance(data, list) else [data] if isinstance(data, dict) else []
if datatype == "order":
return [OrderItem.from_trade_detail(row) for row in rows]
if datatype == "position":
return [PositionItem.from_trade_detail(row) for row in rows]
if datatype == "account":
return [Assets.from_dict(row) for row in rows]
return data
def value_by_order_id(self, order_id, datatype): return self._post("/api/trade/value_by_order_id", {"orderId": order_id, "accountType": self.account_type, "datatype": datatype}).get("data")
def last_order_id(self, datatype): return self._post("/api/trade/last_order_id", {"account": self.account_type, "datatype": datatype}).get("last_order_id")
def can_cancel_order(self, order_id): return self._post("/api/trade/can_cancel_order", {"orderId": order_id, "accountType": self.account_type}).get("can_cancel")

View File

@@ -19,12 +19,12 @@ IPO_SESSIONS = ((time(9, 30), time(11, 30)), (time(13, 0), time(15, 0)))
TRADING_CALENDAR_SYMBOL = "000001.SH"
def AutoBuyIpo(now: datetime | None = None) -> int:
def AutoBuyIpo() -> int:
"""安全执行一次新股申购,返回成功提交的证券数量。"""
if not config.account_config.enable_auto_ipo:
logging.info("[IPO] 自动申购未启用")
return 0
if not trading_time():
if not trading_time(datetime.now()):
logging.info("[IPO] 非交易时间")
return 0
@@ -36,17 +36,22 @@ def AutoBuyIpo(now: datetime | None = None) -> int:
result = client.ipo_data("STOCK")
for stock in result:
lp = Path(config.global_config.qmt_data_dir/f"{stock}.lock")
if is_lock(lp):
lp = Path(config.global_config.qmt_data_dir)/f"{stock}.lock"
if not is_lock(lp):
ipo_price = result[stock]['issuePrice'] # 发行价
maxPurchaseNum = result[stock]['maxPurchaseNum'] # 可申购额度
client.passorder(
op_type=23,
stock=stock,
volume=maxPurchaseNum,
pr_type=11,
price=ipo_price,
strategy_name="新股申购",
)
write_lockfile(lp)
try:
client.passorder(
op_type=23,
stock=stock,
volume=maxPurchaseNum,
pr_type=11,
price=ipo_price,
strategy_name="新股申购",
)
except Exception:
logging.info("[IPO] %s 申购失败,不写入锁文件", stock)
else:
write_lockfile(lp)
client.close()

View File

@@ -1,5 +0,0 @@
from .order import OrderBook, PlaceOrderRequest
from .state import State, StateItem
from .watch import DipWatch
from .open import check_timezone, open_signal
from .positions import manage_positions

View File

@@ -144,19 +144,26 @@ def RunOnce(run: Runtime, signals) -> None:
logging.exception("获取持仓失败")
return
active_codes = set(position_codes)
removed_codes = set(run.state.codes) - active_codes
# 每轮使用最新委托和成交恢复状态。查询或落盘失败时禁止继续开仓,
# 避免在订单结果不明确的情况下提交重复买单。
previous_state_codes = set(run.state.codes)
try:
broker_orders = run.client.trade_detail_data("order")
broker_deals = run.client.deals()
run.state.reconcile(positions, broker_orders, broker_deals)
except Exception:
logging.exception("订单状态对账失败,本轮禁止自动交易")
return
removed_codes = previous_state_codes - set(run.state.codes)
for code in removed_codes:
run.state.delete(code)
run.open_watch.forget(code)
run.add_watch.forget(code)
if removed_codes:
run.state.save()
# 5. 验证有效开仓信号:排除已有持仓,并按 signal_allow 过滤
# 5. 验证有效开仓信号:排除已有持仓和未决订单
position_code_set = set(position_codes)
allow_open = []
seen_codes = set(position_code_set)
seen_codes = position_code_set | set(run.state.unresolved_codes)
for signal in signals:
if signal.code not in seen_codes:
allow_open.append(signal)

View File

@@ -15,6 +15,10 @@ from .state import STATUS_ING, StateItem
def open_signal(run, ticks, open_signals) -> None:
"""逐个验证开仓信号并提交买入委托。"""
for item in open_signals:
# 候选生成后状态仍可能发生变化,提交前再次阻止未决订单重复开仓。
if run.state.has_unresolved_order(item.code):
continue
# 1. 验证信号配置允许开仓的时间区间。
signal_config = run.global_cfg.signals.get(item.signal_key)
if signal_config is None or not check_timezone(signal_config.timezone):

View File

@@ -8,8 +8,7 @@ from datetime import datetime, timedelta
from threading import Lock
from typing import Any
# QMT 开平方向字段到本地买卖方向的映射。
OFFSET_FLAG = {"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"}
from sdk import ORDER_SIDE_BY_OFFSET, Client, OrderItem
# 表示委托仍在处理、可能继续成交的 QMT 状态。
BUSY_STATUSES = {"48", "49", "50", "51", "52", "55"}
@@ -27,56 +26,45 @@ class PlaceOrderRequest:
strategy_name: str
@dataclass(slots=True)
class OrderItem:
"""从 QMT 委托明细转换得到的本地订单记录。"""
id: str
code: str
side: str
remark: str
status: str
created_at: datetime | None
volume: int
local_order_id: str = ""
class OrderBook:
"""线程安全的活动委托缓存。"""
def __init__(self, timeout_seconds: float = 300) -> None:
self.timeout = timedelta(seconds=timeout_seconds)
def __init__(self, lock_timeout_sec: float = 180, cancel_timeout_sec: float = 10) -> None:
self.lock_timeout_sec = max(0.0, float(lock_timeout_sec))
self.cancel_timeout_sec = timedelta(seconds=cancel_timeout_sec)
self.data: dict[str, OrderItem] = {}
self.index: list[str] = []
self.lock = Lock()
self.lock: dict[str, float] = {}
self.mutex = Lock()
@staticmethod
def new_order_id(leg: str) -> str:
"""生成短订单号,为 QMT 备注中的信号键预留空间。"""
return f"zt-{leg[:1]}-{secrets.token_hex(4)}"
def is_lock(self, side: str, code: str) -> bool:
"""判断证券在指定买卖方向上是否已经被委托锁定。"""
with self.lock:
return f"{side}-{code}" in self.index
def busy(self, code: str, side: str) -> bool:
"""判断证券是否存在仍在处理中的同方向委托。"""
with self.lock:
with self.mutex:
self._clear_expired_locks(datetime.now().timestamp())
key = f"{side}-{code}"
order = self.data.get(key)
return key in self.index or bool(order and order.status in BUSY_STATUSES)
return key in self.lock
def refresh(self, client: Any) -> None:
def refresh(self, client: Client) -> None:
"""从 QMT 刷新当前委托明细和方向索引。"""
parsed_orders = [
parse_order(row) for row in client.trade_detail_data("order")
]
with self.lock:
orders = client.trade_detail_data("order")
parsed_orders = [(f"{item.side}-{item.code}", item) for item in orders]
now_timestamp = datetime.now().timestamp()
with self.mutex:
self.data = {key: item for key, item in parsed_orders}
self.index = [
key for key, item in parsed_orders if item.status in BUSY_STATUSES
]
self.lock = {
key: (
item.created_at.timestamp()
if item.created_at is not None
else now_timestamp
)
for key, item in parsed_orders
if item.status in BUSY_STATUSES
}
self._clear_expired_locks(now_timestamp)
def cancel_expired(self, client: Any, now: datetime | None = None) -> None:
"""尝试撤销超过有效期且具有委托编号的订单。"""
@@ -87,7 +75,8 @@ class OrderBook:
for order in list(self.data.values()):
if (
order.created_at is not None
and current - order.created_at > self.timeout
and order.status in {"49", "50", "51", "52"}
and current - order.created_at > self.cancel_timeout_sec
and order.id
):
client.cancel_by_id(order.id)
@@ -107,7 +96,7 @@ class OrderBook:
if result.get("status") != "success" or order_ref in {"", "unknown", "none"}:
return False
side = OFFSET_FLAG.get(str(request.op), "")
side = ORDER_SIDE_BY_OFFSET.get(str(request.op), "")
pending = OrderItem(
id=order_ref,
code=request.code,
@@ -118,61 +107,18 @@ class OrderBook:
volume=request.volume,
local_order_id=request.order_id,
)
with self.lock:
with self.mutex:
key = f"{side}-{request.code}"
self.data[key] = pending
if key not in self.index:
self.index.append(key)
self.lock[key] = pending.created_at.timestamp()
return True
def parse_order(row: dict[str, Any]) -> tuple[str, OrderItem]:
"""把 QMT 原始委托字段转换为本地订单及其索引键。"""
volume = _as_int(row.get("m_nVolumeTotal")) + _as_int(
row.get("m_nVolumeTraded")
)
timestamp = _as_int(row.get("m_nOrderTime"))
if timestamp > 100_000_000_000:
# QMT 某些版本返回毫秒时间戳。
timestamp /= 1000
created_at = (
datetime.fromtimestamp(timestamp)
if timestamp
else _parse_insert_datetime(row)
)
item = OrderItem(
id=str(row.get("m_strOrderSysID") or ""),
code=str(row.get("m_strInstrumentID") or ""),
side=OFFSET_FLAG.get(str(row.get("m_nOffsetFlag")), ""),
remark=str(row.get("m_strRemark") or ""),
status=str(row.get("m_nOrderStatus") or ""),
created_at=created_at,
volume=volume,
local_order_id=_local_order_id(str(row.get("m_strRemark") or "")),
)
return f"{item.side}-{item.code}", item
def _as_int(value: Any) -> int:
"""安全转换整数,无效值按 0 处理。"""
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def _parse_insert_datetime(row: dict[str, Any]) -> datetime | None:
"""使用委托日期和时间字段构造本地时间。"""
date = str(row.get("m_strInsertDate") or "")
clock = str(row.get("m_strInsertTime") or "").replace(":", "").zfill(6)
try:
return datetime.strptime(date + clock, "%Y%m%d%H%M%S")
except ValueError:
return None
def _local_order_id(remark: str) -> str:
"""兼容 ``local_order_id|signal_key`` 形式的 QMT 备注。"""
return remark.split("|", 1)[0] if remark else ""
def _clear_expired_locks(self, now_timestamp: float) -> None:
"""清理过期方向锁;调用方必须已持有 ``mutex``。"""
expired = [
key
for key, created_at in self.lock.items()
if now_timestamp - created_at >= self.lock_timeout_sec
]
for key in expired:
self.lock.pop(key, None)

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
from libs.calc import calc_buy_volume, calculate_min_profit_rate
from libs.grid_take_profit import GridState
from sdk import OP_BUY, OP_SELL, Position, Tick
from sdk import OP_BUY, OP_SELL, PositionItem, Tick
from .order import PlaceOrderRequest
from .runtime import Runtime
@@ -30,7 +30,7 @@ class TradeDecision:
def manage_positions(
runtime: Runtime,
ticks: dict[str, Tick],
positions: list[Position],
positions: list[PositionItem],
market_ok: bool,
available: float,
) -> None:
@@ -88,7 +88,7 @@ def manage_positions(
def handle_profit(
runtime: Runtime,
position: Position,
position: PositionItem,
tick: Tick,
pnl_rate: float,
minimum_profit: float,
@@ -133,7 +133,7 @@ def handle_profit(
def handle_loss(
runtime: Runtime,
position: Position,
position: PositionItem,
tick: Tick,
pnl_rate: float,
available: float,

View File

@@ -8,7 +8,7 @@ from pathlib import Path
from threading import Lock
from typing import Iterable
from sdk import Position
from sdk import OrderItem, PositionItem
# 委托状态:无操作、处理中、已完成。
@@ -70,6 +70,16 @@ class State:
with self.lock:
return list(self.items)
@property
def unresolved_codes(self) -> list[str]:
"""返回存在处理中或未知订单状态的证券代码快照。"""
with self.lock:
return [
code
for code, item in self.items.items()
if _has_unresolved_order(item)
]
def get(self, code: str) -> StateItem:
"""获取指定证券的状态;不存在时抛出 KeyError。"""
with self.lock:
@@ -80,12 +90,21 @@ class State:
with self.lock:
self.items[item.code] = item
def delete(self, code: str) -> None:
"""删除证券状态;证券不存在时不报错"""
def delete(self, code: str) -> bool:
"""删除已终结的证券状态,并返回是否实际删除"""
with self.lock:
self.items.pop(code, None)
item = self.items.get(code)
if item is not None and _has_unresolved_order(item):
return False
return self.items.pop(code, None) is not None
def sync_positions(self, positions: Iterable[Position]) -> None:
def has_unresolved_order(self, code: str) -> bool:
"""判断证券是否存在必须阻止自动下单的未决订单。"""
with self.lock:
item = self.items.get(code)
return item is not None and _has_unresolved_order(item)
def sync_positions(self, positions: Iterable[PositionItem]) -> None:
"""把尚未接管的真实持仓初始化为已完成底仓。
无证券代码、无持仓数量或成本无效的记录会被忽略。同步结束后
@@ -115,29 +134,31 @@ class State:
def reconcile(
self,
positions: Iterable[Position],
orders: list[dict[str, str]],
positions: Iterable[PositionItem],
orders: list[OrderItem],
deals: list[dict[str, str]],
) -> None:
"""用真实持仓、委托和成交恢复本地状态,不增加持久化字段。"""
position_list = list(positions)
self.sync_positions(position_list)
active_codes = {
position_codes = {
item.stock_code for item in position_list if item.volume > 0
}
for code in list(self.codes):
if code not in active_codes:
self.delete(code)
for code in list(self.codes):
item = self.get(code)
item.base_status = _reconcile_leg(
item.base_order_id, item.base_status, orders, deals
item.base_order_id, item.base_status, item.base_qty, orders, deals
)
item.added_status = _reconcile_leg(
item.added_order_id, item.added_status, orders, deals
item.added_order_id, item.added_status, item.added_qty, orders, deals
)
self.set(item)
# Opening orders normally have no position until their first fill. Order
# reconciliation must therefore happen before stale state is removed.
for code in list(self.codes):
if code not in position_codes:
self.delete(code)
self.save()
def save(self) -> None:
@@ -179,31 +200,70 @@ class State:
def _reconcile_leg(
local_order_id: str,
current_status: str,
orders: list[dict[str, str]],
expected_qty: int,
orders: list[OrderItem],
deals: list[dict[str, str]],
) -> str:
if current_status != STATUS_ING or not local_order_id:
if current_status not in {STATUS_ING, STATUS_UNKNOWN} or not local_order_id:
return current_status
if any(local_order_id in row.get("m_strRemark", "") for row in deals):
return STATUS_OK
order = next(
(
row for row in orders
if local_order_id in row.get("m_strRemark", "")
),
None,
)
matching_orders = [
order for order in orders if order.local_order_id == local_order_id
]
order = matching_orders[-1] if matching_orders else None
if order is None:
matching_deals = [
row for row in deals if _matches_local_order(row, local_order_id)
]
dealt = sum(_deal_volume(row) for row in matching_deals)
if expected_qty > 0 and dealt >= expected_qty:
return STATUS_OK
if expected_qty <= 0 and matching_deals:
return STATUS_OK
return STATUS_UNKNOWN
traded = _as_int(order.get("m_nVolumeTraded"))
status = str(order.get("m_nOrderStatus", ""))
if traded > 0 and status not in {"48", "49", "50", "51", "52", "55"}:
system_order_id = order.id.strip()
matching_deals = [
row
for row in deals
if (
system_order_id
and str(row.get("m_strOrderSysID") or "").strip() == system_order_id
)
or (not system_order_id and _matches_local_order(row, local_order_id))
]
dealt = sum(_deal_volume(row) for row in matching_deals)
traded = max(order.traded_volume, dealt)
ordered = order.volume or expected_qty
status = order.status
if ordered > 0 and traded >= ordered:
return STATUS_OK
if status in {"48", "49", "50", "51", "52", "55"}:
return STATUS_ING
if status in {"54", "56"}:
return STATUS_CANCELED
return STATUS_UNKNOWN if traded > 0 else STATUS_CANCELED
if status in {"57", "58"}:
return STATUS_FAILED
return STATUS_ING
return STATUS_UNKNOWN if traded > 0 else STATUS_FAILED
return STATUS_UNKNOWN
def _has_unresolved_order(item: StateItem) -> bool:
return item.base_status in {STATUS_ING, STATUS_UNKNOWN} or item.added_status in {
STATUS_ING,
STATUS_UNKNOWN,
}
def _matches_local_order(row: dict[str, str], local_order_id: str) -> bool:
remark = str(row.get("m_strRemark") or "")
return remark.split("|", 1)[0] == local_order_id
def _deal_volume(deal: dict[str, str]) -> int:
for key in ("m_nVolume", "m_nTradeVolume", "m_nVolumeTraded"):
volume = _as_int(deal.get(key))
if volume > 0:
return volume
return 0
def _as_int(value: object) -> int:

View File

@@ -6,11 +6,11 @@ from types import SimpleNamespace
from unittest.mock import patch
from libs.grid_take_profit import GridState, GridTrailingTracker
from sdk import Assets, Position, Tick
from sdk import Assets, PositionItem, Tick
from strategy.trend.order import OrderBook, PlaceOrderRequest
from strategy.trend.positions import LOSS_TIERS, handle_loss, manage_positions
from strategy.trend.boot import RunOnce
from strategy.trend.state import STATUS_OK, State, StateItem
from strategy.trend.state import STATUS_OK, STATUS_UNKNOWN, State, StateItem
class FakeClient:
@@ -42,7 +42,7 @@ class TrendTests(unittest.TestCase):
def test_position_dataclasses_execute_without_type_error(self):
with TemporaryDirectory() as directory:
state = State.for_strategy(directory, "trend", "A")
position = Position(
position = PositionItem(
stock_code="000001.SZ", volume=100, can_use_volume=100,
open_price=10, market_value=1000,
)
@@ -64,7 +64,7 @@ class TrendTests(unittest.TestCase):
self.assertEqual(len(LOSS_TIERS), 2)
with TemporaryDirectory() as directory:
state = State.for_strategy(directory, "trend", "A")
position = Position(stock_code="A", volume=100, open_price=10, market_value=1000)
position = PositionItem(stock_code="A", volume=100, open_price=10, market_value=1000)
state.sync_positions([position])
item = state.get("A")
item.added_num = len(LOSS_TIERS)
@@ -80,7 +80,7 @@ class TrendTests(unittest.TestCase):
def test_loss_tiers_zero_and_one(self):
with TemporaryDirectory() as directory:
state = State.for_strategy(directory, "trend", "A")
position = Position(stock_code="A", volume=100, open_price=10, market_value=1000)
position = PositionItem(stock_code="A", volume=100, open_price=10, market_value=1000)
state.sync_positions([position])
runtime = SimpleNamespace(
state=state, account_cfg=SimpleNamespace(buy_value=5000, strategy="trend"),
@@ -100,7 +100,7 @@ class TrendTests(unittest.TestCase):
def test_reconcile_ing_order_from_deal(self):
with TemporaryDirectory() as directory:
state = State.for_strategy(directory, "trend", "A")
position = Position(stock_code="A", volume=100, open_price=10)
position = PositionItem(stock_code="A", volume=100, open_price=10)
state.set(StateItem("A", base_order_id="local-1", base_status="ING"))
state.reconcile(
[position],
@@ -112,7 +112,9 @@ class TrendTests(unittest.TestCase):
def test_low_cash_still_runs_position_management(self):
client = SimpleNamespace(
assets=lambda: Assets(total=10000, available=10),
positions=lambda: (["A"], [Position(stock_code="A", volume=100, open_price=10)]),
positions=lambda: (["A"], [PositionItem(stock_code="A", volume=100, open_price=10)]),
trade_detail_data=lambda _datatype: [],
deals=lambda: [],
full_tick=lambda _codes: {"A": Tick(last_price=11)},
)
runtime = SimpleNamespace(
@@ -120,7 +122,11 @@ class TrendTests(unittest.TestCase):
account_cfg=SimpleNamespace(min_cash_ratio=0.1),
global_cfg=SimpleNamespace(api_host="http://example"),
orders=SimpleNamespace(cancel_expired=lambda _client: None),
state=SimpleNamespace(codes=["A"]),
state=SimpleNamespace(
codes=["A"],
unresolved_codes=[],
reconcile=lambda *_args: None,
),
)
with (
patch("strategy.trend.boot.trading_time", return_value=True),
@@ -132,6 +138,44 @@ class TrendTests(unittest.TestCase):
open_mock.assert_not_called()
manage_mock.assert_called_once()
def test_unknown_order_without_position_blocks_reopen(self):
with TemporaryDirectory() as directory:
state = State.for_strategy(directory, "trend", "A")
state.set(StateItem(
"A",
base_order_id="missing-order",
base_qty=100,
base_status=STATUS_UNKNOWN,
))
state.save()
client = SimpleNamespace(
assets=lambda: Assets(total=10000, available=5000),
positions=lambda: ([], []),
trade_detail_data=lambda _datatype: [],
deals=lambda: [],
full_tick=lambda _codes: {"A": Tick(last_price=10)},
)
runtime = SimpleNamespace(
client=client,
account_cfg=SimpleNamespace(min_cash_ratio=0.1),
global_cfg=SimpleNamespace(api_host="http://example"),
orders=OrderBook(),
state=state,
open_watch=SimpleNamespace(forget=lambda _code: None),
add_watch=SimpleNamespace(forget=lambda _code: None),
)
signal = SimpleNamespace(code="A", signal_key="morning")
with (
patch("strategy.trend.boot.trading_time", return_value=True),
patch("strategy.trend.boot.market_allow_open", return_value=True),
patch("strategy.trend.boot.open_signal") as open_mock,
patch("strategy.trend.boot.manage_positions"),
):
RunOnce(runtime, [signal])
open_mock.assert_not_called()
self.assertTrue(state.has_unresolved_order("A"))
if __name__ == "__main__":
unittest.main()