diff --git a/docs/bug.md b/docs/bug.md new file mode 100644 index 0000000..890d60a --- /dev/null +++ b/docs/bug.md @@ -0,0 +1,29 @@ +### S1. 券商快照延迟时,本地委托锁会被清空并可能重复下单 + +- 位置:`strategy/trend/order.py:54-92`、`strategy/trend/boot.py:127-130,157-162`、`strategy/trend/open.py:23-25` +- 证据:下单成功后 `OrderBook.place()` 会立即加入本地 pending 和方向锁,但下一轮 `refresh()` 会完全用券商快照重建 `data` 与 `lock`。若券商快照尚未出现刚提交的订单,本地 pending 会直接丢失。开仓候选只排除当前持仓,不排除 `State` 中的待成交底仓,最终仅依赖已经被清掉的 `busy()` 锁。 +- 影响:接口存在可见性延迟时,同一证券可能在连续轮次重复提交买单;卖单和补仓也存在相同锁丢失窗口。 +- 建议:刷新时合并尚未超时且券商未确认的本地 pending,而不是覆盖;按本地订单号查询确认后才能移除。开仓筛选同时检查状态机中的 `base_status`。 + + +### H3. 撤单、拒单、废单和部分成交不能驱动状态机正确收敛 + +- 位置:`strategy/trend/state.py:142-162`、`strategy/trend/order.py:14-17,61-92` +- 证据:状态对账只把“匹配订单全部为状态 56”视为完成,其余均保持 `ING`;订单被过滤或消失时,已有持仓的补仓状态不会变为失败或撤销。文件中虽然定义了 `FAILED`、`CANCELED`、`UNKNOWN`,但没有完整迁移逻辑。 +- 影响:失败的补仓仍会消耗 `added_num`,状态可能永久停留在处理中,重启后也无法可靠恢复。 +- 建议:建立完整 QMT 委托状态映射,按实际成交数量处理全成、部成、已撤、废单、拒单和未知;消失订单需二次查询确认。 + + +### M1. IPO 仍只依赖本地锁,存在重复申购窗口 + +- 位置:`strategy/ipo/boot.py:49-65` +- 证据:下单成功后才写锁;未查询券商当日委托或成交记录。进程若在下单成功与写锁之间退出,或锁文件被删除,下一次调度会再次申购。 +- 影响:同一证券可能重复发送申购请求,安全性依赖券商端是否拒绝重复申购。 +- 建议:本地锁只作为快速缓存,下单前以券商委托/成交记录做最终幂等校验。 + +### M6. 策略成本使用提交时行情价,而非实际成交价 + +- 位置:`strategy/trend/open.py:80-86`、`strategy/trend/positions.py:186-192` +- 证据:底仓和补仓在订单刚提交成功时就把 tick 价格写为成本,没有根据成交回报更新实际成交数量与均价。 +- 影响:滑点、部分成交或拆单时,后续盈亏率、止盈网格和补仓层级基于不准确成本。 +- 建议:提交阶段只记录订单标识;订单完成对账后从成交或真实持仓均价更新成本和数量。 \ No newline at end of file diff --git a/py-client/strategy/ipo/boot.py b/py-client/strategy/ipo/boot.py index e3cccb5..7de8033 100644 --- a/py-client/strategy/ipo/boot.py +++ b/py-client/strategy/ipo/boot.py @@ -2,59 +2,69 @@ from __future__ import annotations -import json import logging from datetime import datetime, time from pathlib import Path -from typing import Any import config from sdk import Client from libs.calc import trading_time from libs.lockfile import is_lock,write_lockfile -IPO_STRATEGY_NAME = "IPO_SUBSCRIBE" -IPO_REMARKS = {IPO_STRATEGY_NAME, "新股申购"} IPO_SESSIONS = ((time(9, 30), time(11, 30)), (time(13, 0), time(15, 0))) -TRADING_CALENDAR_SYMBOL = "000001.SH" -def AutoBuyIpo() -> int: +def AutoBuyIpo() -> None: """安全执行一次新股申购,返回成功提交的证券数量。""" if not config.account_config.enable_auto_ipo: logging.info("[IPO] 自动申购未启用") - return 0 + return if not trading_time(datetime.now()): logging.info("[IPO] 非交易时间") - return 0 + return - client = Client( - config.global_config.qmt_base_url, - config.global_config.qmt_token, - config.HTTP_TIMEOUT, - ) + try: + with Client( + config.global_config.qmt_base_url, + config.global_config.qmt_token, + config.HTTP_TIMEOUT, + ) as client: + result = client.ipo_data("STOCK") + for item in result: + try: + if not isinstance(item, dict): + raise TypeError("IPO 数据项必须是字典") + + stock = str(item.get("stock", "")).strip() + if not stock or stock.endswith(".BJ"): + continue + + ipo_price = float(item["issuePrice"]) + max_purchase_num = int(item["maxPurchaseNum"]) + if ipo_price <= 0 or max_purchase_num <= 0: + raise ValueError("发行价或申购额度必须大于 0") + + lock_path = Path(config.global_config.qmt_data_dir) / f"{stock}.lock" + if is_lock(lock_path): + continue + + client.passorder( + op_type=23, + stock=stock, + volume=max_purchase_num, + pr_type=11, + price=ipo_price, + strategy_name="ipo", + ) + write_lockfile(lock_path) + logging.info( + "[IPO] %s 申购,发行价:%s 可申购额度:%s", + stock, + ipo_price, + max_purchase_num, + ) + except Exception: + logging.exception("[IPO] 单条申购处理失败,数据=%r", item) + except Exception: + logging.exception("[IPO] 自动申购任务失败") - result = client.ipo_data("STOCK") - for stock in result: - if ".BJ" in stock: - continue - 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'] # 可申购额度 - try: - client.passorder( - op_type=23, - stock=stock, - volume=maxPurchaseNum, - pr_type=11, - price=ipo_price, - strategy_name="新股申购", - ) - logging.info("[IPO] %s 申购,发行价:%s 可申购额度:%s", stock) - except Exception: - logging.info("[IPO] %s 申购失败,不写入锁文件", stock) - else: - write_lockfile(lp) - - client.close() diff --git a/py-client/strategy/trend/boot.py b/py-client/strategy/trend/boot.py index b248665..9afab61 100644 --- a/py-client/strategy/trend/boot.py +++ b/py-client/strategy/trend/boot.py @@ -89,6 +89,8 @@ def StartTrend() -> None: lt = time.localtime() if (lt.tm_hour, lt.tm_min, lt.tm_sec) >= (15, 0, 0): log.info("[Trend] 已到 15:00,结束趋势策略") + run.client.close() + run.executor.shutdown() return current_sec = lt.tm_sec diff --git a/py-client/strategy/trend/open.py b/py-client/strategy/trend/open.py index 8057a60..5597a90 100644 --- a/py-client/strategy/trend/open.py +++ b/py-client/strategy/trend/open.py @@ -17,8 +17,20 @@ def open_signal(run:Runtime, ticks, open_signals) -> None: for item in open_signals: # 1. 验证信号配置允许开仓的时间区间。 signal_config = run.global_cfg.signals.get(item.signal_key) - if signal_config is None or not check_timezone(signal_config.timezone): - log.info("[Open] %s 信号=%s,跳过:不在信号时间段(%s)", item.code, item.signal_key,signal_config.timezone) + if signal_config is None: + log.warning( + "[Open] %s 信号=%s,跳过:未找到信号配置", + item.code, + item.signal_key, + ) + continue + if not check_timezone(signal_config.timezone): + log.info( + "[Open] %s 信号=%s,跳过:不在信号时间段(%s)", + item.code, + item.signal_key, + signal_config.timezone, + ) continue # 2. 检查该证券是否已有买入委托锁,防止重复下单。