This commit is contained in:
2026-09-04 23:08:21 +08:00
parent 8f97666d9e
commit 9f01eb8dc5
4 changed files with 92 additions and 39 deletions

29
docs/bug.md Normal file
View File

@@ -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 价格写为成本,没有根据成交回报更新实际成交数量与均价。
- 影响:滑点、部分成交或拆单时,后续盈亏率、止盈网格和补仓层级基于不准确成本。
- 建议:提交阶段只记录订单标识;订单完成对账后从成交或真实持仓均价更新成本和数量。

View File

@@ -2,59 +2,69 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from datetime import datetime, time from datetime import datetime, time
from pathlib import Path from pathlib import Path
from typing import Any
import config import config
from sdk import Client from sdk import Client
from libs.calc import trading_time from libs.calc import trading_time
from libs.lockfile import is_lock,write_lockfile 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))) 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: if not config.account_config.enable_auto_ipo:
logging.info("[IPO] 自动申购未启用") logging.info("[IPO] 自动申购未启用")
return 0 return
if not trading_time(datetime.now()): if not trading_time(datetime.now()):
logging.info("[IPO] 非交易时间") logging.info("[IPO] 非交易时间")
return 0 return
client = Client( try:
config.global_config.qmt_base_url, with Client(
config.global_config.qmt_token, config.global_config.qmt_base_url,
config.HTTP_TIMEOUT, 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()

View File

@@ -89,6 +89,8 @@ def StartTrend() -> None:
lt = time.localtime() lt = time.localtime()
if (lt.tm_hour, lt.tm_min, lt.tm_sec) >= (15, 0, 0): if (lt.tm_hour, lt.tm_min, lt.tm_sec) >= (15, 0, 0):
log.info("[Trend] 已到 15:00结束趋势策略") log.info("[Trend] 已到 15:00结束趋势策略")
run.client.close()
run.executor.shutdown()
return return
current_sec = lt.tm_sec current_sec = lt.tm_sec

View File

@@ -17,8 +17,20 @@ def open_signal(run:Runtime, ticks, open_signals) -> None:
for item in open_signals: for item in open_signals:
# 1. 验证信号配置允许开仓的时间区间。 # 1. 验证信号配置允许开仓的时间区间。
signal_config = run.global_cfg.signals.get(item.signal_key) signal_config = run.global_cfg.signals.get(item.signal_key)
if signal_config is None or not check_timezone(signal_config.timezone): if signal_config is None:
log.info("[Open] %s 信号=%s,跳过:不在信号时间段(%s)", item.code, item.signal_key,signal_config.timezone) 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 continue
# 2. 检查该证券是否已有买入委托锁,防止重复下单。 # 2. 检查该证券是否已有买入委托锁,防止重复下单。