update api,libs
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user