update api,libs
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
httpx>=0.27,<1
|
||||
PyYAML>=6.0
|
||||
APScheduler>=3.10,<4
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user