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

@@ -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: