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

1534
api/qmt_api_new.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,204 @@
# big-qmt 代码复审报告2026-08-29
## 1. 审计范围与方法
- 审计范围:当前工作区中的 `api/``py-client/`、启动脚本及测试。
- 明确排除:`docs/todo.md`,本报告没有读取或引用该文件作为判断依据。
- 关注项:致命错误、交易业务逻辑错误、代码冗余、过度验证。
- 动态验证:`python -B -m unittest discover -s tests -v`;共 11 项8 项通过、3 项错误。
- 编译验证:`python -B -m compileall -q .` 通过。
- 限制:未连接真实 QMT未执行真实委托、撤单或成交回报验证。
## 2. 总结
当前版本不适合直接实盘运行。至少存在 4 条会使计划业务完全不执行或造成重复/超额下单的高风险路径:
1. IPO 功能在启用时必然先抛出 `TypeError`,而且其后仍有路径拼接、锁判断和调度生命周期错误。
2. 趋势策略的 `UNKNOWN` 状态虽然不会被删除,但并未参与开仓过滤,无法兑现“禁止自动重复下单”。
3. 多个开仓信号之间没有共享可用资金预算,且首笔开仓数量函数可能主动超过配置金额。
4. `OrderBook` 会覆盖同证券同方向的多笔订单,并可能反复撤销已终结的历史订单。
测试失败并非测试环境问题,而是直接命中了当前生产函数的确定性参数错误。
## 3. P0致命或可能造成错误交易
### 3.1 IPO 入口启用后必然报错,全部核心测试无法执行
位置:
- `py-client/strategy/ipo/boot.py:22-29`
- `py-client/libs/calc.py:5-7`
`AutoBuyIpo(now)` 调用 `trading_time()` 时没有传入必需的 `now` 参数。只要 `enable_auto_ipo=True`,函数便在创建客户端、读取候选和检查重复委托之前抛出 `TypeError`
复现结果:
- `test_local_record_prevents_duplicate_after_restart`ERROR
- `test_broker_order_prevents_duplicate`ERROR
- `test_one_rejection_does_not_stop_other_candidates`ERROR
影响:自动打新完全不可用;如果由主程序同步调用,异常还可能中止启动流程。
建议:传入 `now or datetime.now()`,并将交易日与交易时段判断统一成一个可测试入口。修复后必须重新运行现有 4 项 IPO 测试。
### 3.2 IPO 路径还有两层确定性错误:字符串路径相除、锁语义反向
位置:
- `py-client/strategy/ipo/boot.py:37-49`
- `py-client/config/__init__.py:28,109`
- `py-client/libs/lockfile.py:9-18`
即使修复 3.1,代码仍执行:
```python
Path(config.global_config.qmt_data_dir / f"{stock}.lock")
```
`qmt_data_dir``GlobalConfig` 中是 `str`,因此先计算 `str / str`,会再次抛出 `TypeError`。此外,`is_lock()` 的语义是“锁文件已存在”,当前代码却仅在其返回 `True` 时提交申购;首次运行没有锁文件,所有候选都会被跳过。正确业务语义应是“没有本地成功记录时才继续”,并且还要和券商订单/成交记录交叉核对。
影响修复第一个异常后IPO 仍无法正常首申购;若人工预建锁文件绕过判断,则反而允许对已锁定证券再次申购。
建议:使用 `Path(qmt_data_dir) / f"{stock}.lock"`;反转本地锁判断;提交成功后才写锁;提交失败不得写锁;恢复测试中已经表达但当前实现缺失的券商订单和成交去重。
### 3.3 `UNKNOWN` 只被保存在状态文件中,没有真正阻止再次开仓
位置:
- `py-client/strategy/trend/state.py:76-82,175-228`
- `py-client/strategy/trend/boot.py:136-164`
- `py-client/strategy/trend/open.py:17-70`
`State.delete()` 会静默保留 `ING/UNKNOWN`,但 `RunOnce()` 构造 `allow_open` 时只排除真实持仓代码,不排除 `State` 中的未决代码。`open_signal()` 也只检查 `OrderBook.busy()`。当重启对账无法找到真实活动委托并把订单转成 `UNKNOWN` 后,如果券商订单查询暂时缺失或委托已终结但结果不明,`OrderBook` 没有活动锁,下一次同证券信号仍可提交新买单。
另外,`RunOnce()` 无论 `State.delete()` 是否因保护而拒绝删除,都会执行 `open_watch.forget()``add_watch.forget()`,形成“状态保留但观察锁被清除”的不一致状态。
影响:前一笔订单结果无法确认时仍可能重复开仓;这与状态修复目标和验收标准直接冲突。
建议:开仓候选必须同时排除持仓代码、`ING/UNKNOWN` 状态代码以及活动买单代码。`delete()` 应返回是否实际删除,调用方只在删除成功后清理观察器。运行期每轮应将订单簿刷新结果用于状态对账,而不是仅在启动时对账一次。
## 4. P1重要业务逻辑错误
### 4.1 IPO 定时任务实际上不会在 10:00 被调度
位置:`py-client/main.py:111-115`
代码注册每日 10:00 任务后只调用一次 `schedule.run_pending()`,随后进入 `StartTrend()` 的永久循环,再也没有机会驱动调度器。除非程序恰好在任务已到期的极窄窗口启动,否则自动打新不会执行。
建议:把调度轮询合并进主循环,或使用独立受控线程/独立进程IPO 异常必须隔离,不能终止趋势策略。
### 4.2 样例账户没有启用任何趋势信号
位置:
- `py-client/config/__init__.py:48`
- `py-client/etc/dev.yaml`
- `py-client/libs/signal.py:23-28`
`signal_allow` 默认空列表,而当前 `dev.yaml` 没有该字段。`init_signals()` 只加载显式出现在允许列表中的信号,因此样例配置启动后趋势策略永远没有开仓候选,日志也不会提示“允许列表为空”。
建议:明确选择一种语义:空列表表示全部禁用,并在启动时显著告警;或空列表表示允许全部。样例配置应与预期业务一致。
### 4.3 同证券同方向的多笔委托会互相覆盖
位置:`py-client/strategy/trend/order.py:68-77,118-122`
订单缓存以 `BUY-code` / `SELL-code` 为唯一键。券商返回同一证券同方向的多笔委托时,字典推导只保留最后一笔。过期撤单、状态展示和锁判断无法看到被覆盖的订单。
建议:以系统委托号作为主键,另建 `(side, code) -> set[system_order_id]` 活动索引。
### 4.4 过期撤单没有过滤活动状态
位置:`py-client/strategy/trend/order.py:79-90`
`cancel_expired()` 遍历 `data.values()` 时只检查时间和系统委托号,没有要求 `order.status in BUSY_STATUSES`。如果接口返回历史委托,程序每轮都可能对已成交、已撤销或已失败订单调用撤单。
建议:只对活动状态且超过时限的订单撤单,并维护撤单请求中的冷却状态,避免每 30 秒重复请求。
### 4.5 状态落盘失败后的故障策略不一致
位置:
- `py-client/strategy/trend/open.py:59-70`
- `py-client/strategy/trend/positions.py:175-183`
开仓提交成功但保存失败时只记录日志,仍清除观察器并继续运行;补仓保存失败则异常向外传播,但真实订单已经提交。两条路径都可能出现“真实订单存在、本地恢复信息缺失”,处理策略却不一致。
建议:真实下单成功后若持久化失败,应将证券置于内存级熔断集合,保留订单簿锁并持续重试落盘;禁止该证券继续自动交易,直至对账确认。
### 4.6 启动脚本会杀死机器上的所有 Python 进程
位置:`run.bat:1-7`
`taskkill /IM python.exe /F` 不限定当前项目、PID 或命令行,会强制终止机器上所有 Python 工作负载。脚本也没有先切换到自身目录,`python main.py` 是否能找到文件取决于调用时当前目录;仓库根目录本身并不存在 `main.py`
影响:可能中断无关服务、研究任务或其他交易程序,同时自身仍可能因工作目录错误无法启动。
建议:保存本项目 PID 并只终止该 PID脚本开头使用 `cd /d "%~dp0py-client"` 或调用绝对脚本路径;不要在生产启动流程中无条件 `git pull`
## 5. P2代码冗余、失效代码和过度验证
### 5.1 API 服务文件完全重复且正式入口不明确
位置:
- `api/qmt_api_new.py`
- `api/qmt_api_rele.py`
- 工作区状态中的已删除 `api/QMT_API.py`
两个现存文件 SHA-256 完全一致,属于逐字节重复;原正式命名文件当前又处于删除状态。部署人员无法从启动脚本或文档可靠判断应加载哪个版本,后续修复也容易只改到其中一份。
建议:保留唯一正式入口,开发版本通过 Git 分支管理,不以 `_new``_rele` 复制整文件。
### 5.2 IPO 模块呈现“旧实现覆盖新设计”的死代码特征
位置:`py-client/strategy/ipo/boot.py:5-19`
`json``Any``IPO_STRATEGY_NAME``IPO_REMARKS``IPO_SESSIONS``TRADING_CALENDAR_SYMBOL` 均未使用;测试则期待交易日查询、券商对账、异常隔离、上下文管理器和返回成功数量,但当前函数都未实现。这不是单纯格式问题,而是实现与测试/设计发生大段脱节的信号。
建议:不要逐个删除常量掩盖问题;先恢复完整 IPO 流程,再移除确认无用的符号。
### 5.3 主程序存在不可达代码、拼写错误和未使用符号
位置:
- `py-client/main.py:5,15,100-101,115-116`
`StartTrend()` 正常情况下永久循环,因此其后的“策略启动成功”日志不可达;即便未来返回,使用的 `config.account_config.strateg` 也不存在。非 Windows 分支调用未定义的 `log.error``TimedRotatingFileHandler``GLOBAL_CONFIG_PATH` 没有使用。
建议:启动成功日志应放在进入循环前;统一使用 `logging`;清除未使用导入和常量。
### 5.4 `State.delete()` 的全局保护属于过度且不透明的验证
位置:`py-client/strategy/trend/state.py:76-82`
任何调用者请求删除 `ING/UNKNOWN` 状态都会被静默拒绝,没有返回值、日志、强制删除入口或订单证据参数。它确实避免了一类误删,但也会阻止人工确认后的清理,并使调用者误以为删除成功。当前 `RunOnce()` 正因此错误清除了观察器。
建议:将“是否可清理”的业务判断放在显式对账流程中;`delete()` 返回布尔值或抛出明确异常;如需保护,提供带审计原因的显式强制路径。验证应基于订单证据,不应仅基于状态字符串。
### 5.5 对账存在重复落盘
位置:`py-client/strategy/trend/state.py:84-108,110-137`
`reconcile()` 先调用会自行 `save()``sync_positions()`,完成订单对账和清理后又 `save()`。每次启动对账至少写两次同一状态文件,第一份还是未完成订单对账的中间状态。
建议:为 `sync_positions()` 增加不立即保存的内部版本,整个对账事务只在最终一致状态落盘一次。
## 6. 建议整改顺序
1. 先恢复 IPO 的可执行性,并让现有 4 项 IPO 测试全部通过。
2. 完成 `UNKNOWN/ING` 与开仓候选、订单簿之间的统一锁定,增加重启未成交端到端测试。
3. 修复开仓数量和同轮资金预留,增加高价股、多信号资金边界测试。
4. 重构 `OrderBook` 主键与活动索引,并限制撤单状态。
5. 修复调度生命周期和启动脚本的进程范围。
6. 最后清理重复 API 文件、死代码、重复落盘和无效日志。
## 7. 验收门槛
- 全部 11 项现有测试通过,且不通过跳过/删除失败测试达成。
- 新增IPO 首次申购、重复启动、券商已有委托、单只拒单不影响其他候选。
- 新增:开仓未成交重启、订单查询暂时缺失、部分成交活动、部分成交撤单、完全成交。
- 新增:`buy_value` 不足一手、多信号总金额超过现金、价格滑点场景。
- 新增:同证券同方向两笔活动委托都能被发现和撤销。
- 在模拟账户完成至少一次完整的下单、部分成交、撤单、重启对账闭环后,再考虑实盘。

View File

@@ -0,0 +1,176 @@
# big-qmt 手动修改后代码复审2026-08-29 V2
## 1. 范围与验证
- 基于当前未提交工作区重新审计,不直接继承上一版报告结论。
- 审计范围:`api/``py-client/`、启动脚本、配置和测试。
- 明确排除:`docs/todo.md`;未读取、未引用其内容。
- `python -B -m unittest discover -s tests -v`11 项中 7 项通过、4 项错误。
- 全部 Python 文件 AST 解析通过。
- 未连接真实 QMT未执行真实申购、买卖、撤单和成交回报测试。
## 2. 总体结论
当前版本仍不建议直接实盘运行。
手动修改已经修复了 IPO 路径中的三个表面问题:`trading_time()` 现在传入时间、数据目录能够正确转成 `Path`、首次申购的本地锁判断方向已经改正APScheduler 也能在趋势策略永久循环之外每日 10:00 触发任务。
但核心安全闭环仍未完成IPO 会在没有确认券商受理的情况下写入“已申购”锁,也没有使用券商委托/成交记录防重;趋势策略的 `UNKNOWN` 状态仍不能阻止再次开仓;开仓资金仍可能超预算。测试失败数量还从上一轮的 3 项变成了 4 项。
## 3. P0致命或可能导致错误交易
### 3.1 IPO 不校验下单结果就写入成功锁,可能永久漏申购
位置:`py-client/strategy/ipo/boot.py:37-51`
`client.passorder()` 的返回值被完全忽略,随后无条件执行 `write_lockfile(lp)`。如果服务端以正常 HTTP 响应返回 `{"status": "failed"}`、空订单号或其他业务拒绝结果,本地仍会记录为已申购,后续每天都会跳过该证券。
影响:券商实际没有接受订单,但本地永久认为已经提交,造成漏申购。
建议:统一校验 `status == "success"``order_ref` 有效;只有明确受理后才能写锁。未知响应应告警并保持可对账状态,不能直接标记成功。
### 3.3 单只 IPO 异常会中止当天全部后续候选
位置:`py-client/strategy/ipo/boot.py:37-51`
候选循环内部没有单只证券级异常隔离。第一只证券的字段缺失、下单拒绝抛异常或锁文件写入失败,都会直接退出 `AutoBuyIpo()`后面的候选不会再尝试。APScheduler 会记录任务异常,但当天 10:00 不会自动重新执行整个任务。
影响:一只异常证券导致当天其他所有新股漏申购。
建议:每只候选独立 `try/except` 并记录证券代码;失败继续处理下一只。任务结束后汇总成功、跳过、失败数量。
### 3.4 趋势策略 `UNKNOWN` 状态仍不能阻止自动重复开仓
位置:
- `py-client/strategy/trend/state.py:83-89,184-237`
- `py-client/strategy/trend/boot.py:145-164`
- `py-client/strategy/trend/open.py:17-70`
`State.delete()` 会保留 `ING/UNKNOWN`,但 `RunOnce()` 生成开仓候选时只排除真实持仓;`open_signal()` 只检查 `OrderBook.busy()`。如果启动对账将订单标记成 `UNKNOWN`,同时券商活动委托列表暂时没有该订单,状态文件虽然存在,下一轮信号仍可再次下单。
此外,运行期调用 `delete()` 后不检查是否真的删除,就清除两个观察器,造成未决状态与观察状态不一致。
影响订单结果无法确认时可能重复买入未达到“UNKNOWN 禁止自动重复下单”的目标。
建议:开仓候选同时排除 `ING/UNKNOWN` 状态代码和活动买单代码;`delete()` 返回实际删除结果;每轮用最新订单簿重新对账状态。
## 4. P1重要业务逻辑问题
### 4.1 IPO 函数契约退化,现有 4 项测试全部报错
位置:
- `py-client/strategy/ipo/boot.py:22`
- `py-client/tests/test_ipo.py:52-136`
`AutoBuyIpo(now: datetime | None = None)` 被改成无参数函数测试无法注入确定时间4 项测试全部以 `TypeError: AutoBuyIpo() takes 0 positional arguments but 1 was given` 结束。APScheduler 并不要求删除可选参数;保留可选 `now` 同样可以无参调度。
测试当前也明确要求但实现未满足:返回成功数量、关闭客户端、本地重启防重、券商记录防重、单只拒绝不影响其他候选。
建议:恢复可选 `now` 参数,内部使用 `current = now or datetime.now()`;不要修改测试来掩盖业务契约缺失。
### 4.3 IPO 客户端从不关闭
位置:`py-client/strategy/ipo/boot.py:31-51`
每日任务创建新的 `httpx.Client`,成功、跳过和异常路径都没有调用 `close()`。长期运行会累积未及时释放的连接池资源。
建议:使用 `with Client(...) as client:`,现有 `Client` 已实现上下文管理器。
### 4.4 IPO 返回类型与真实返回值不一致
位置:`py-client/strategy/ipo/boot.py:22-51`
函数标注返回 `int`,只有禁用和非交易时间返回 0正常处理完候选后隐式返回 `None`,也没有统计提交成功数量。
影响:日志、监控和测试无法知道任务到底提交了多少只证券。
建议:维护 `success_count` 并在所有出口返回整数。
### 4.5 同证券同方向的多笔趋势委托仍会互相覆盖
位置:`py-client/strategy/trend/order.py:70-79,121-125`
订单缓存仍以 `BUY-code` / `SELL-code` 为唯一键。同证券同方向多笔委托只保留最后一笔,其他活动订单无法撤销或对账。
建议:系统委托号作为主键,方向与证券组合作为一对多活动索引。
### 4.6 过期撤单仍会处理已终结历史订单
位置:`py-client/strategy/trend/order.py:81-93`
撤单条件没有检查 `order.status in BUSY_STATUSES`。接口若返回历史订单,程序会对已成交、已撤、已失败订单反复发送撤单请求。
建议:仅处理活动状态,并为已发送撤单请求增加冷却或本地状态。
### 4.7 下单成功但状态落盘失败的安全策略不统一
位置:
- `py-client/strategy/trend/open.py:59-70`
- `py-client/strategy/trend/positions.py:175-183`
开仓保存失败只记日志并继续;补仓保存失败则异常退出本轮。两者都可能已经存在真实订单,却缺少可恢复的本地状态。
建议:落盘失败后将证券加入内存熔断集合、保留订单锁并持续重试;在完成真实订单对账前禁止该证券继续自动交易。
### 5.3 主程序仍有不可达代码、拼写错误和无效符号
位置:
- `py-client/main.py:5,15,100-101,127-128`
`StartTrend()` 正常情况下永久循环,其后的日志不可达;即便返回,`config.account_config.strateg` 也不存在。非 Windows 分支使用未定义的 `log.error``TimedRotatingFileHandler``GLOBAL_CONFIG_PATH` 未使用。
建议:启动成功日志放在进入永久循环前;统一使用 `logging`;删除无效导入和常量。
### 5.4 `State.delete()` 的保护过宽且静默
位置:`py-client/strategy/trend/state.py:83-89`
任何 `ING/UNKNOWN` 都会让删除静默失效,没有返回值、日志、订单证据或人工强制清理入口。这是基于状态字符串的全局拦截,不是真实订单对账,并已造成调用方误清观察器。
建议:把清理许可放入显式对账决策;`delete()` 返回布尔值或明确拒绝原因;人工确认终结后应有可审计的清理路径。
### 5.5 状态对账重复写盘并暴露中间状态
位置:`py-client/strategy/trend/state.py:91-117,119-146`
`reconcile()` 调用会自行保存的 `sync_positions()`,完成订单对账后再次保存。一次启动对账至少写盘两次,第一次还是未完成订单状态恢复的中间结果。
建议:内部同步只改内存,完整对账结束后一次性原子落盘。
### 5.6 调度器配置包含当前进程内不必要的重复任务替换
位置:`py-client/main.py:116-123`
调度器每次启动都是新实例,只添加一次固定 ID 任务,因此 `replace_existing=True` 在当前结构下没有实际作用。它无害,但属于多余防御参数,容易让人误以为使用了持久化任务仓库或存在重复注册路径。
建议:若没有持久化 job store 或重复注册,删除该参数;若未来启用持久化,再保留并补充任务版本策略。
## 6. 本轮已确认改善
- APScheduler 后台调度不会被 `StartTrend()` 的永久循环阻塞。
- `timezone="Asia/Shanghai"` 明确了每日 10:00 的业务时区。
- `coalesce=True``max_instances=1` 能防止任务积压补跑和并发重叠。
- `dev.yaml` 已显式配置 `signal_allow`,趋势信号不再因默认空列表而全部禁用。
- IPO 本地路径拼接和首次锁判断方向已经修正。
- 趋势部分成交判定的四类状态逻辑仍保留。
## 7. 建议整改顺序与验收门槛
1. 恢复 IPO 可选时间参数、成功计数和上下文管理器,让现有 4 项 IPO 测试先全部通过。
2. 增加真实交易日、券商委托/成交防重、下单结果校验和单只异常隔离。
3.`ING/UNKNOWN` 真正参与趋势开仓过滤,并补充重启未成交端到端测试。
4. 修复单笔与同轮开仓资金预算。
5. 重构订单簿一对多索引并限制撤单状态。
6. 清理启动脚本、重复 API 文件和死代码。
最低验收要求:现有 11 项测试全部通过;新增 IPO 返回失败但不写锁、锁丢失但券商已有订单、第一只拒绝而第二只成功、节假日不申购;新增趋势 `UNKNOWN` 无持仓且无活动委托时仍不重复开仓;最后在模拟账户完成下单、部分成交、撤单、重启对账闭环。

View File

@@ -0,0 +1,249 @@
# big-qmt 手动修改后代码复审2026-08-29 V3
## 1. 审计范围与验证
- 基于当前未提交工作区重新审计,不直接复制上一版报告。
- 范围:`api/``py-client/`、配置、启动脚本和测试。
- 明确排除:`docs/todo.md`;本轮未读取、未引用其内容。
- 全套测试:`python -B -m unittest discover -s tests -v`
- 测试结果12 项中 8 项通过、4 项错误;错误全部位于 IPO 测试。
- 全部 Python 文件 AST 解析通过。
- 未连接真实 QMT未执行真实委托、成交和撤单。
## 2. 总体结论
当前版本仍不建议直接实盘运行。
本轮有两项明确改善:趋势策略已在每轮重新对账,`ING/UNKNOWN` 同时参与候选过滤和下单前检查;订单方向锁也已经改成带秒级时间戳的字典并自动清理。新增的 `UNKNOWN` 无持仓防重复测试通过。
但订单锁的新过期规则会在真实委托仍活动时强制解锁券商刷新还会整体覆盖本地下单锁卖出订单没有持久化状态兜底因此存在重复卖出风险。IPO 仍然存在“业务失败响应也写成功锁”、券商侧防重缺失和函数契约错误。资金预算、订单覆盖及启动脚本风险也尚未解决。
## 3. P0致命或可能造成错误交易
### 3.1 活动委托达到 180 秒会被强制解锁,可能重复下单
位置:`py-client/strategy/trend/order.py:47-52,59-89,138-146`
业务锁是否有效只由创建时间和 `lock_timeout_sec` 决定,不再参考订单是否仍属于 `BUSY_STATUSES``refresh()` 明明从券商读到活动订单,仍会把创建时间超过 180 秒的锁立即删除。
定向复现:状态为 `50` 的活动卖单,创建 181 秒后,`busy("A", "SELL")` 返回 `False`
开仓和补仓还有 `State``ING/UNKNOWN` 兜底,但止盈卖单没有写入持久化状态。活动卖单超时或撤单失败后,下一轮止盈判断可能再次提交同一证券卖单。
建议:本地“提交防抖锁”可以超时,但券商明确返回活动状态时必须继续视为 busy将两者拆成 `local_locks``active_order_index``busy()` 对二者取并集。卖出订单也应进入可恢复状态或订单索引。
### 3.2 刷新券商订单会整体覆盖刚提交的本地锁
位置:`py-client/strategy/trend/order.py:72-89,132-135`
`refresh()` 使用新字典直接替换 `self.lock`。如果下单接口成功后,券商订单明细存在短暂可见性延迟,下一轮刷新会删除刚写入的本地锁。对于没有状态兜底的卖单,这同样可能导致重复委托。
建议:刷新时合并尚未超过防抖时限的本地锁;只有券商明确返回终结状态,或本地锁超时且券商持续不可见,才能移除,并记录告警。
### 3.3 IPO 不检查业务返回结果就写成功锁
位置:`py-client/strategy/ipo/boot.py:37-55`
当前 `try` 只能捕获 Python 异常。`client.passorder()` 返回 `{"status": "failed"}`、空订单号或其他业务拒绝响应时不会抛异常,代码仍进入 `else` 并写锁文件。
影响:券商没有接受申购,本地却永久标记为已申购,造成漏申购。
建议:捕获异常之外,还必须校验返回值为字典、`status == "success"``order_ref` 有效;满足全部条件后才能写锁。
### 3.4 IPO 没有券商委托/成交防重
位置:`py-client/strategy/ipo/boot.py:31-57`
当前只检查本地空文件,不查询当日券商委托和成交。下单成功后、写锁前崩溃,或锁目录被清理/切换时10:00 与 14:00 两次任务可能对同一证券再次提交申购。
建议:以账户、交易日、证券代码和 IPO 备注核对 `trade_detail_data("order")``deals()`;本地锁只能作为快速缓存,不能作为唯一事实来源。
### 3.5 趋势开仓仍可能超过单笔预算和账户可用现金
位置:
- `py-client/libs/calc.py:10-12`
- `py-client/strategy/trend/boot.py:127-185`
- `py-client/strategy/trend/open.py:41-74`
`calc_buy_volume()` 在预算不足一手时仍强制返回 100 股。趋势开仓只检查一次现金比例,没有校验单笔预计金额,也没有在同一轮多个信号之间预留资金。
影响:高价股会超过 `buy_value`;多个信号可能同时使用同一份可用现金,发送总额超限的委托。
建议:预算不足一手时返回 0开仓循环维护共享 `remaining_cash`,成功提交后立即扣减预留金额,并保留滑点余量。
## 4. P1重要业务逻辑错误
### 4.1 IPO 的 4 项现有测试全部无法进入业务逻辑
位置:
- `py-client/strategy/ipo/boot.py:22`
- `py-client/tests/test_ipo.py:52-136`
`AutoBuyIpo()` 删除了原有可选时间参数,测试传入固定时间时全部报:`TypeError: AutoBuyIpo() takes 0 positional arguments but 1 was given`。APScheduler 无参调用与保留可选 `now` 并不冲突。
测试还要求但当前实现没有满足:成功数量返回、上下文关闭、重启防重、券商记录防重、单只拒绝不影响其他候选。
建议:恢复 `now: datetime | None = None`,内部使用 `now or datetime.now()`;修复生产逻辑后让测试通过,不要删除失败测试。
### 4.2 IPO 正常完成时返回 `None`,与 `-> int` 不一致
位置:`py-client/strategy/ipo/boot.py:22-57`
只有禁用和非交易时间返回 0完成候选循环后没有返回值也没有统计成功数。
建议:维护 `success_count`,所有出口都返回整数。
### 4.3 IPO 资源关闭仍不具备异常安全性
位置:`py-client/strategy/ipo/boot.py:31-57`
`client.close()` 只在整个流程正常走到末尾时执行。`ipo_data()`、候选字段读取、锁写入等任何异常都会跳过关闭。单只下单异常虽然被捕获,但仅记录普通 info丢失堆栈和具体原因。
建议:使用 `with Client(...) as client:`;单只异常用 `logging.exception()` 并继续其他候选。
### 4.4 IPO 只判断工作日,不判断真实交易日
位置:
- `py-client/strategy/ipo/boot.py:27`
- `py-client/libs/calc.py:5-7`
法定节假日或临时休市仍会进入申购。模块中的 `TRADING_CALENDAR_SYMBOL` 未使用。
建议:用 SDK `trading_dates()` 确认当天交易日;查询失败时安全跳过。
### 4.5 同证券同方向多笔委托仍会互相覆盖
位置:`py-client/strategy/trend/order.py:74-89,132-135`
`data``lock` 都以 `SIDE-code` 为唯一键。两笔相同证券、相同方向的订单只保留最后一笔。
定向复现:传入两笔 `SELL-A`,刷新后 `len(data) == 1`
影响:被覆盖订单无法被撤销、展示或独立对账。
建议:订单数据以系统委托号为主键,另建 `(side, code) -> set[order_id]` 活动索引。
### 4.6 撤单状态集合与活动状态集合不一致
位置:`py-client/strategy/trend/order.py:15,97-104`
`BUSY_STATUSES` 包含 `48``55`,撤单逻辑只处理 `49``52`。如果 `48/55` 也是可继续成交且可撤的状态,它们永远不会被超时撤销;同时其锁仍可能在 180 秒后被删除。
建议:建立单一、有文档依据的状态映射,分别定义“活动”“可撤”“终结”,不要在不同函数中散落不一致的魔法集合。
### 4.7 撤单请求会每轮重复发送
位置:`py-client/strategy/trend/order.py:91-104`
默认 10 秒即触发撤单,主循环每 30 秒刷新一次;只要券商仍返回相同活动状态,每轮都会再次调用 `cancel_by_id()`,没有撤单中、本地冷却或最大重试次数。
建议:记录最近撤单请求时间和结果;处于撤单中的订单采用退避重试,并设置最大次数和告警。
### 4.8 状态保存失败后的安全策略仍不统一
位置:
- `py-client/strategy/trend/open.py:59-74`
- `py-client/strategy/trend/positions.py:175-183`
开仓保存失败只记录日志并继续;补仓保存失败抛出异常。两种情况都可能已经有真实订单,但恢复数据未落盘。
建议:落盘失败后保留内存订单锁并熔断该证券,持续重试保存;完成券商对账前禁止后续自动交易。
### 4.9 启动脚本会杀死机器上的全部 Python 进程
位置:`run.bat:1-7`
`taskkill /IM python.exe /F` 不区分 PID 或项目;脚本又没有先切换到 `py-client``python main.py` 依赖调用目录。
建议:仅管理本项目 PID使用 `%~dp0` 构造绝对路径;生产启动不要无条件 `git pull`
## 5. P2冗余、过度验证与维护风险
### 5.1 每轮重复查询两次委托明细
位置:
- `py-client/strategy/trend/boot.py:121-125,147-153`
- `py-client/strategy/trend/order.py:72-93`
`cancel_expired()` 内部先调用一次 `trade_detail_data("order")`,随后 `RunOnce()` 为状态对账再次调用同一接口。30 秒一轮时属于稳定的重复网络请求,两次快照还可能不一致。
建议:每轮只获取一次原始订单快照,同时传给订单簿刷新、撤单判断和状态对账。
### 5.2 状态对账重复落盘
位置:`py-client/strategy/trend/state.py:107-133,135-162`
`reconcile()` 先调用会保存的 `sync_positions()`,结束时再次保存。每轮至少写两次状态文件,第一份还是未完成订单对账的中间状态。
建议:内部同步只更新内存,完整对账成功后一次原子保存。
### 5.3 `State.delete()` 保护仍然过宽且静默
位置:`py-client/strategy/trend/state.py:93-105`
虽然已经返回布尔值,但任何 `ING/UNKNOWN` 都只按状态字符串拒绝删除,没有订单证据、日志或人工确认后的强制清理入口。当前内部清理也没有使用返回值记录拒绝原因。
建议:把删除许可作为明确的对账结果;为人工确认提供带原因的审计接口。
### 5.4 两份 API 文件逐字节重复,正式入口处于删除状态
位置:
- `api/qmt_api_new.py`
- `api/qmt_api_rele.py`
- 当前删除的 `api/QMT_API.py`
两个新文件 SHA-256 完全相同,正式加载哪个文件不明确。
建议:只保留一个正式入口,版本差异交由 Git 管理。
### 5.5 IPO 模块存在未使用的旧设计残留
位置:`py-client/strategy/ipo/boot.py:5-19`
`json``Any``IPO_STRATEGY_NAME``IPO_REMARKS``IPO_SESSIONS``TRADING_CALENDAR_SYMBOL` 当前均未使用。
建议:先完成券商对账和交易日逻辑,再删除确认无用的符号。
### 5.6 主程序仍有不可达代码和拼写错误
位置:`py-client/main.py:13-14,126-127`
`StartTrend()` 正常情况下永久循环,后面的日志不可达;即使返回,`config.account_config.strateg` 也不存在。`GLOBAL_CONFIG_PATH` 未使用。
建议:启动成功日志放在进入循环前;删除不可达代码和无效常量。
### 5.7 `replace_existing=True` 在当前内存调度器中是多余防御
位置:`py-client/main.py:115-122`
每次进程启动都创建全新内存调度器,并只注册一次固定任务,不存在同一调度器重复注册路径。该参数无害,但容易暗示存在持久化 job store。
建议:没有持久化任务仓库时可删除;未来启用持久化后再明确任务替换策略。
## 6. 本轮已确认改善
- APScheduler 在北京时间每日 10:00、14:00 触发,不受趋势永久循环阻塞。
- IPO 单只 `passorder()` 抛异常时不再写锁,并会继续处理后续候选。
- `dev.yaml` 已显式启用信号列表。
- 趋势每轮重新读取委托和成交进行状态对账;对账失败时本轮停止自动交易。
- `ING/UNKNOWN` 已同时进入候选过滤和提交前检查。
- `State.delete()` 已返回真实删除结果。
- 趋势新增的 `UNKNOWN` 防重复测试通过;趋势测试共 8 项全部通过。
- 订单方向锁已经改为 `dict[str, float]`,过期清理本身可工作。
## 7. 建议整改顺序与验收门槛
1. 先拆分“本地防抖锁”和“券商活动订单索引”,确保活动委托绝不因时间到期而变成不忙。
2. 修复 IPO 返回值校验、券商防重、可选时间参数、资源关闭和成功计数,使现有 4 项 IPO 测试通过。
3. 修复开仓单笔和同轮资金预算。
4. 将订单簿改为系统委托号主键,统一活动/可撤/终结状态表和撤单冷却。
5. 消除重复网络查询和重复状态写盘。
6. 最后清理启动脚本、重复 API 文件和死代码。
最低验收要求:现有 12 项测试全部通过;新增“活动卖单超过锁超时仍 busy”“券商刷新延迟不清除本地锁”“两笔同方向订单均可撤销”“IPO 业务失败响应不写锁”“锁丢失但券商已有 IPO 委托不重复申购”;最后在模拟账户完成买入、部分成交、撤单、卖出、重启对账闭环。

View File

@@ -1,5 +1,87 @@
### 4.2 IPO 没有校验真实交易日,只判断周一至周五和盘中时间
位置:
- `py-client/strategy/ipo/boot.py:27`
- `py-client/libs/calc.py:5-7`
`trading_time()` 只排除周末,法定节假日、临时休市仍会进入申购逻辑。模块中已有 `TRADING_CALENDAR_SYMBOL` 常量,但没有实际查询交易日接口。
建议:调用 SDK 的 `trading_dates()` 验证当天交易日;接口失败时按安全策略跳过,不应猜测为交易日。
### 4.8 启动脚本仍会强制终止机器上的所有 Python 进程
位置:`run.bat:1-7`
`taskkill /IM python.exe /F` 不区分项目和 PID`python main.py` 又依赖调用时工作目录。它可能杀死无关任务后仍因找不到根目录下的 `main.py` 而启动失败。
建议:只管理本项目 PID脚本先切换至 `%~dp0py-client`;生产启动不要无条件 `git pull`
## 5. P2代码冗余与过度验证
### 5.1 两份 API 文件逐字节重复,正式入口被删除
位置:
- `api/qmt_api_new.py`
- `api/qmt_api_rele.py`
- 当前处于删除状态的 `api/QMT_API.py`
两个新文件 SHA-256 相同,属于完整重复;原正式文件被删除,启动和部署入口不清晰。继续维护会造成修复只落在其中一份的风险。
建议:保留唯一正式文件,版本差异交给 Git 管理。
### 5.2 IPO 模块保留大量未使用的设计残留
位置:`py-client/strategy/ipo/boot.py:5-19`
`json``Any``IPO_STRATEGY_NAME``IPO_REMARKS``IPO_SESSIONS``TRADING_CALENDAR_SYMBOL` 当前均未使用。它们与测试要求一起表明完整对账实现被缩减,但残留符号没有同步整理。
建议:先恢复交易日、券商对账和统计逻辑,再清除确认无用的符号,不要只做表面删减。
### 3.5 开仓金额可能超过配置值,同轮多信号会重复使用现金
位置:
- `py-client/libs/calc.py:10-12`
- `py-client/strategy/trend/boot.py:119-164`
- `py-client/strategy/trend/open.py:41-70`
`calc_buy_volume()` 在预算不足一手时仍强制返回 100 股。趋势开仓仅检查一次现金比例,没有校验单笔预计金额,也没有为同一轮后续信号扣减已提交订单占用的资金。
影响:高价股单笔超出 `buy_value`;多信号集中出现时可能发送总额超过可用资金的委托。
建议:不足一手返回 0开仓入口维护本轮共享 `remaining_cash`,成功提交后立即预留资金,并考虑价格滑点。
### 3.2 IPO 仅依赖本地空文件防重,锁丢失或落盘失败会重复申购
位置:
- `py-client/strategy/ipo/boot.py:37-51`
- `py-client/libs/lockfile.py:9-18`
当前实现没有查询 `trade_detail_data("order")``deals()`,也没有按账户、交易日和申购备注对账。本地锁文件被清理、数据目录切换、写入失败或下单成功后进程崩溃时,下一次任务会再次提交同一新股。
影响:产生重复申购请求;实际结果取决于券商拦截,不能把安全性寄托在券商拒单上。
建议:本地记录只能作为快速幂等缓存,最终防重必须以“账户 + 交易日 + 证券代码”的券商委托/成交记录为准;下单前后二次核对。
### 3.4 开仓可能超过单笔配置金额,多信号还会重复使用同一份现金
位置:
- `py-client/libs/calc.py:10-12`
- `py-client/strategy/trend/boot.py:119-164`
- `py-client/strategy/trend/open.py:41-70`
`calc_buy_volume()` 使用 `max(1, floor(...)) * 100`。当 `buy_value < price * 100` 时,它不是返回 0而是强制购买 100 股,实际金额必然超过 `buy_value`。趋势开仓只检查一次总账户现金比例,没有检查单笔预计金额是否小于可用现金,也没有在同一轮多个信号之间扣减已预留资金。
影响:高价股或同轮多信号可能导致单笔超预算、连续发送超过可用资金的委托,产生券商拒单或非预期仓位。
建议:预算不足一手时返回 0像补仓路径一样维护本轮 `remaining_cash`;每次成功提交后立即预留 `price * volume`,并给价格滑点留安全余量。
### 2.1 每次启动都会无条件执行新股申购 ### 2.1 每次启动都会无条件执行新股申购
整改状态:已于 2026-08-29 按本节方案完成,新增 4 项专项测试。 整改状态:已于 2026-08-29 按本节方案完成,新增 4 项专项测试。

View File

@@ -6,6 +6,7 @@ loss_trigger_pct: -30
grid_step_pct: 1 grid_step_pct: 1
min_profit_pct: 2 min_profit_pct: 2
strategy: trend strategy: trend
signal_allow: ["morning","tail","arbitrage"]
enable_loss_add_position: True enable_loss_add_position: True
enable_auto_ipo: True enable_auto_ipo: True
excluded_codes: excluded_codes:

View File

@@ -2,10 +2,9 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging import logging
from logging.handlers import TimedRotatingFileHandler
import os import os
import sys import sys
import schedule from apscheduler.schedulers.background import BackgroundScheduler
import config import config
from dataclasses import dataclass from dataclasses import dataclass
import yaml import yaml
@@ -98,7 +97,7 @@ def wait_for_any_key() -> None:
def main() -> int: def main() -> int:
try: try:
if not require_windows(): if not require_windows():
log.error("本程序仅支持 Windows 环境运行") logging.error("本程序仅支持 Windows 环境运行")
return 1 return 1
if not check_single_instance(PROJECT_ROOT): if not check_single_instance(PROJECT_ROOT):
return 1 return 1
@@ -108,10 +107,21 @@ def main() -> int:
raise RuntimeError("配置尚未加载,请先调用 config.load()") raise RuntimeError("配置尚未加载,请先调用 config.load()")
wait_for_qmt_api() wait_for_qmt_api()
# 自动打新与主策略隔离;申购服务失败不能阻止趋势策略启动 # 后台调度不受趋势策略永久循环阻塞;同一时刻最多执行一个实例
schedule.every().day.at("10:00").do(AutoBuyIpo) scheduler = BackgroundScheduler(
schedule.run_pending() timezone="Asia/Shanghai",
logging.info("IPO 自动打新启动成功") 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() STRATEGIES[config.account_config.strategy].start_strategy()
logging.info("%s 策略启动成功",config.account_config.strateg) logging.info("%s 策略启动成功",config.account_config.strateg)

View File

@@ -1,2 +1,3 @@
httpx>=0.27,<1 httpx>=0.27,<1
PyYAML>=6.0 PyYAML>=6.0
APScheduler>=3.10,<4

View File

@@ -11,4 +11,4 @@ class Client(AccountMixin, DataMixin, TradeMixin, MiscMixin, _HTTPClient):
"""big-qmt 同步 HTTP 客户端。""" """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"]

View File

@@ -1,21 +1,26 @@
from typing import Any from typing import Any
from .models import Assets, Position from .models import Assets, PositionItem
class AccountMixin: class AccountMixin:
account_type: str account_type: str
def _positions(self, path: str) -> tuple[list[str], list[Position]]: def _positions(self, path: str) -> tuple[list[str], list[PositionItem]]:
raw = self._post(path, {"account": self.account_type}) or {} payload = self._post(path, {"account": self.account_type}) or {}
return list(raw), [Position.from_dict(value, code) for code, value in raw.items()] 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 positions(self): return self._positions("/api/v2/positions")
def holding(self): return self._positions("/api/holding") def holding(self): return self._positions("/api/holding")
def assets(self) -> Assets: def assets(self) -> Assets:
data = self._post("/api/v2/assets", {"account": self.account_type}) payload = self._post("/api/v2/assets", {"account": self.account_type}) or {}
return Assets(float(data.get("total", 0)), float(data.get("available", 0))) 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 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)) def available_money(self) -> float: return float(self._post("/api/money/available", {"account": self.account_type}).get("available_money", 0))

View File

@@ -1,5 +1,5 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@@ -12,7 +12,60 @@ def _number(value: Any, kind: type = float) -> Any:
@dataclass(slots=True) @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_code: str = ""
stock_name: str = "" stock_name: str = ""
direction: Any = None direction: Any = None
@@ -31,7 +84,7 @@ class Position:
expire_date: str = "" expire_date: str = ""
@classmethod @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( return cls(
stock_code=str(data.get("StockCode") or code), stock_name=str(data.get("StockName") or ""), 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), 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 ""), 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) @dataclass(slots=True)
class Assets: class Assets:
total: float = 0.0 total: float = 0.0
available: 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) @dataclass(slots=True)
class Tick: class Tick:

View File

@@ -1,7 +1,9 @@
from .models import *
from typing import Any from typing import Any
OP_BUY, OP_SELL = 23, 24 OP_BUY, OP_SELL = 23, 24
ORDER_TYPE_VOLUME, PR_TYPE_LATEST, QUICK_TRADE_NOW = 1101, 5, 2 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: class TradeMixin:
@@ -53,7 +55,20 @@ class TradeMixin:
def pause_task(self, task_id): return self._task("pause", task_id) 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 resume_task(self, task_id): return self._task("resume", task_id)
def do_order(self): return self._post("/api/trade/do_order") 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 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 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") 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")

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" TRADING_CALENDAR_SYMBOL = "000001.SH"
def AutoBuyIpo(now: datetime | None = None) -> int: def AutoBuyIpo() -> int:
"""安全执行一次新股申购,返回成功提交的证券数量。""" """安全执行一次新股申购,返回成功提交的证券数量。"""
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 0
if not trading_time(): if not trading_time(datetime.now()):
logging.info("[IPO] 非交易时间") logging.info("[IPO] 非交易时间")
return 0 return 0
@@ -36,10 +36,11 @@ def AutoBuyIpo(now: datetime | None = None) -> int:
result = client.ipo_data("STOCK") result = client.ipo_data("STOCK")
for stock in result: for stock in result:
lp = Path(config.global_config.qmt_data_dir/f"{stock}.lock") lp = Path(config.global_config.qmt_data_dir)/f"{stock}.lock"
if is_lock(lp): if not is_lock(lp):
ipo_price = result[stock]['issuePrice'] # 发行价 ipo_price = result[stock]['issuePrice'] # 发行价
maxPurchaseNum = result[stock]['maxPurchaseNum'] # 可申购额度 maxPurchaseNum = result[stock]['maxPurchaseNum'] # 可申购额度
try:
client.passorder( client.passorder(
op_type=23, op_type=23,
stock=stock, stock=stock,
@@ -48,5 +49,9 @@ def AutoBuyIpo(now: datetime | None = None) -> int:
price=ipo_price, price=ipo_price,
strategy_name="新股申购", strategy_name="新股申购",
) )
except Exception:
logging.info("[IPO] %s 申购失败,不写入锁文件", stock)
else:
write_lockfile(lp) 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("获取持仓失败") logging.exception("获取持仓失败")
return 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: for code in removed_codes:
run.state.delete(code)
run.open_watch.forget(code) run.open_watch.forget(code)
run.add_watch.forget(code) run.add_watch.forget(code)
if removed_codes:
run.state.save()
# 5. 验证有效开仓信号:排除已有持仓,并按 signal_allow 过滤 # 5. 验证有效开仓信号:排除已有持仓和未决订单
position_code_set = set(position_codes) position_code_set = set(position_codes)
allow_open = [] allow_open = []
seen_codes = set(position_code_set) seen_codes = position_code_set | set(run.state.unresolved_codes)
for signal in signals: for signal in signals:
if signal.code not in seen_codes: if signal.code not in seen_codes:
allow_open.append(signal) allow_open.append(signal)

View File

@@ -15,6 +15,10 @@ from .state import STATUS_ING, StateItem
def open_signal(run, ticks, open_signals) -> None: def open_signal(run, ticks, open_signals) -> None:
"""逐个验证开仓信号并提交买入委托。""" """逐个验证开仓信号并提交买入委托。"""
for item in open_signals: for item in open_signals:
# 候选生成后状态仍可能发生变化,提交前再次阻止未决订单重复开仓。
if run.state.has_unresolved_order(item.code):
continue
# 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 or not check_timezone(signal_config.timezone):

View File

@@ -8,8 +8,7 @@ from datetime import datetime, timedelta
from threading import Lock from threading import Lock
from typing import Any from typing import Any
# QMT 开平方向字段到本地买卖方向的映射。 from sdk import ORDER_SIDE_BY_OFFSET, Client, OrderItem
OFFSET_FLAG = {"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"}
# 表示委托仍在处理、可能继续成交的 QMT 状态。 # 表示委托仍在处理、可能继续成交的 QMT 状态。
BUSY_STATUSES = {"48", "49", "50", "51", "52", "55"} BUSY_STATUSES = {"48", "49", "50", "51", "52", "55"}
@@ -27,56 +26,45 @@ class PlaceOrderRequest:
strategy_name: str 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: class OrderBook:
"""线程安全的活动委托缓存。""" """线程安全的活动委托缓存。"""
def __init__(self, timeout_seconds: float = 300) -> None: def __init__(self, lock_timeout_sec: float = 180, cancel_timeout_sec: float = 10) -> None:
self.timeout = timedelta(seconds=timeout_seconds) 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.data: dict[str, OrderItem] = {}
self.index: list[str] = [] self.lock: dict[str, float] = {}
self.lock = Lock() self.mutex = Lock()
@staticmethod @staticmethod
def new_order_id(leg: str) -> str: def new_order_id(leg: str) -> str:
"""生成短订单号,为 QMT 备注中的信号键预留空间。""" """生成短订单号,为 QMT 备注中的信号键预留空间。"""
return f"zt-{leg[:1]}-{secrets.token_hex(4)}" 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: 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}" key = f"{side}-{code}"
order = self.data.get(key) return key in self.lock
return key in self.index or bool(order and order.status in BUSY_STATUSES)
def refresh(self, client: Any) -> None: def refresh(self, client: Client) -> None:
"""从 QMT 刷新当前委托明细和方向索引。""" """从 QMT 刷新当前委托明细和方向索引。"""
parsed_orders = [ orders = client.trade_detail_data("order")
parse_order(row) for row in client.trade_detail_data("order") parsed_orders = [(f"{item.side}-{item.code}", item) for item in orders]
] now_timestamp = datetime.now().timestamp()
with self.lock: with self.mutex:
self.data = {key: item for key, item in parsed_orders} self.data = {key: item for key, item in parsed_orders}
self.index = [ self.lock = {
key for key, item in parsed_orders if item.status in BUSY_STATUSES 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: def cancel_expired(self, client: Any, now: datetime | None = None) -> None:
"""尝试撤销超过有效期且具有委托编号的订单。""" """尝试撤销超过有效期且具有委托编号的订单。"""
@@ -87,7 +75,8 @@ class OrderBook:
for order in list(self.data.values()): for order in list(self.data.values()):
if ( if (
order.created_at is not None 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 and order.id
): ):
client.cancel_by_id(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"}: if result.get("status") != "success" or order_ref in {"", "unknown", "none"}:
return False return False
side = OFFSET_FLAG.get(str(request.op), "") side = ORDER_SIDE_BY_OFFSET.get(str(request.op), "")
pending = OrderItem( pending = OrderItem(
id=order_ref, id=order_ref,
code=request.code, code=request.code,
@@ -118,61 +107,18 @@ class OrderBook:
volume=request.volume, volume=request.volume,
local_order_id=request.order_id, local_order_id=request.order_id,
) )
with self.lock: with self.mutex:
key = f"{side}-{request.code}" key = f"{side}-{request.code}"
self.data[key] = pending self.data[key] = pending
if key not in self.index: self.lock[key] = pending.created_at.timestamp()
self.index.append(key)
return True return True
def _clear_expired_locks(self, now_timestamp: float) -> None:
def parse_order(row: dict[str, Any]) -> tuple[str, OrderItem]: """清理过期方向锁;调用方必须已持有 ``mutex``。"""
"""把 QMT 原始委托字段转换为本地订单及其索引键。""" expired = [
volume = _as_int(row.get("m_nVolumeTotal")) + _as_int( key
row.get("m_nVolumeTraded") for key, created_at in self.lock.items()
) if now_timestamp - created_at >= self.lock_timeout_sec
]
timestamp = _as_int(row.get("m_nOrderTime")) for key in expired:
if timestamp > 100_000_000_000: self.lock.pop(key, None)
# 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 ""

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
from libs.calc import calc_buy_volume, calculate_min_profit_rate from libs.calc import calc_buy_volume, calculate_min_profit_rate
from libs.grid_take_profit import GridState 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 .order import PlaceOrderRequest
from .runtime import Runtime from .runtime import Runtime
@@ -30,7 +30,7 @@ class TradeDecision:
def manage_positions( def manage_positions(
runtime: Runtime, runtime: Runtime,
ticks: dict[str, Tick], ticks: dict[str, Tick],
positions: list[Position], positions: list[PositionItem],
market_ok: bool, market_ok: bool,
available: float, available: float,
) -> None: ) -> None:
@@ -88,7 +88,7 @@ def manage_positions(
def handle_profit( def handle_profit(
runtime: Runtime, runtime: Runtime,
position: Position, position: PositionItem,
tick: Tick, tick: Tick,
pnl_rate: float, pnl_rate: float,
minimum_profit: float, minimum_profit: float,
@@ -133,7 +133,7 @@ def handle_profit(
def handle_loss( def handle_loss(
runtime: Runtime, runtime: Runtime,
position: Position, position: PositionItem,
tick: Tick, tick: Tick,
pnl_rate: float, pnl_rate: float,
available: float, available: float,

View File

@@ -8,7 +8,7 @@ from pathlib import Path
from threading import Lock from threading import Lock
from typing import Iterable from typing import Iterable
from sdk import Position from sdk import OrderItem, PositionItem
# 委托状态:无操作、处理中、已完成。 # 委托状态:无操作、处理中、已完成。
@@ -70,6 +70,16 @@ class State:
with self.lock: with self.lock:
return list(self.items) 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: def get(self, code: str) -> StateItem:
"""获取指定证券的状态;不存在时抛出 KeyError。""" """获取指定证券的状态;不存在时抛出 KeyError。"""
with self.lock: with self.lock:
@@ -80,12 +90,21 @@ class State:
with self.lock: with self.lock:
self.items[item.code] = item self.items[item.code] = item
def delete(self, code: str) -> None: def delete(self, code: str) -> bool:
"""删除证券状态;证券不存在时不报错""" """删除已终结的证券状态,并返回是否实际删除"""
with self.lock: 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( def reconcile(
self, self,
positions: Iterable[Position], positions: Iterable[PositionItem],
orders: list[dict[str, str]], orders: list[OrderItem],
deals: list[dict[str, str]], deals: list[dict[str, str]],
) -> None: ) -> None:
"""用真实持仓、委托和成交恢复本地状态,不增加持久化字段。""" """用真实持仓、委托和成交恢复本地状态,不增加持久化字段。"""
position_list = list(positions) position_list = list(positions)
self.sync_positions(position_list) self.sync_positions(position_list)
active_codes = { position_codes = {
item.stock_code for item in position_list if item.volume > 0 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): for code in list(self.codes):
item = self.get(code) item = self.get(code)
item.base_status = _reconcile_leg( 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_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) 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() self.save()
def save(self) -> None: def save(self) -> None:
@@ -179,31 +200,70 @@ class State:
def _reconcile_leg( def _reconcile_leg(
local_order_id: str, local_order_id: str,
current_status: str, current_status: str,
orders: list[dict[str, str]], expected_qty: int,
orders: list[OrderItem],
deals: list[dict[str, str]], deals: list[dict[str, 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 return current_status
if any(local_order_id in row.get("m_strRemark", "") for row in deals): matching_orders = [
return STATUS_OK order for order in orders if order.local_order_id == local_order_id
order = next( ]
( order = matching_orders[-1] if matching_orders else None
row for row in orders
if local_order_id in row.get("m_strRemark", "")
),
None,
)
if order is None: if order is None:
return STATUS_UNKNOWN matching_deals = [
traded = _as_int(order.get("m_nVolumeTraded")) row for row in deals if _matches_local_order(row, local_order_id)
status = str(order.get("m_nOrderStatus", "")) ]
if traded > 0 and status not in {"48", "49", "50", "51", "52", "55"}: dealt = sum(_deal_volume(row) for row in matching_deals)
if expected_qty > 0 and dealt >= expected_qty:
return STATUS_OK return STATUS_OK
if status in {"54", "56"}: if expected_qty <= 0 and matching_deals:
return STATUS_CANCELED return STATUS_OK
if status in {"57", "58"}: return STATUS_UNKNOWN
return STATUS_FAILED
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 return STATUS_ING
if status in {"54", "56"}:
return STATUS_UNKNOWN if traded > 0 else STATUS_CANCELED
if status in {"57", "58"}:
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: def _as_int(value: object) -> int:

View File

@@ -6,11 +6,11 @@ from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from libs.grid_take_profit import GridState, GridTrailingTracker 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.order import OrderBook, PlaceOrderRequest
from strategy.trend.positions import LOSS_TIERS, handle_loss, manage_positions from strategy.trend.positions import LOSS_TIERS, handle_loss, manage_positions
from strategy.trend.boot import RunOnce 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: class FakeClient:
@@ -42,7 +42,7 @@ class TrendTests(unittest.TestCase):
def test_position_dataclasses_execute_without_type_error(self): def test_position_dataclasses_execute_without_type_error(self):
with TemporaryDirectory() as directory: with TemporaryDirectory() as directory:
state = State.for_strategy(directory, "trend", "A") state = State.for_strategy(directory, "trend", "A")
position = Position( position = PositionItem(
stock_code="000001.SZ", volume=100, can_use_volume=100, stock_code="000001.SZ", volume=100, can_use_volume=100,
open_price=10, market_value=1000, open_price=10, market_value=1000,
) )
@@ -64,7 +64,7 @@ class TrendTests(unittest.TestCase):
self.assertEqual(len(LOSS_TIERS), 2) self.assertEqual(len(LOSS_TIERS), 2)
with TemporaryDirectory() as directory: with TemporaryDirectory() as directory:
state = State.for_strategy(directory, "trend", "A") 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]) state.sync_positions([position])
item = state.get("A") item = state.get("A")
item.added_num = len(LOSS_TIERS) item.added_num = len(LOSS_TIERS)
@@ -80,7 +80,7 @@ class TrendTests(unittest.TestCase):
def test_loss_tiers_zero_and_one(self): def test_loss_tiers_zero_and_one(self):
with TemporaryDirectory() as directory: with TemporaryDirectory() as directory:
state = State.for_strategy(directory, "trend", "A") 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]) state.sync_positions([position])
runtime = SimpleNamespace( runtime = SimpleNamespace(
state=state, account_cfg=SimpleNamespace(buy_value=5000, strategy="trend"), 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): def test_reconcile_ing_order_from_deal(self):
with TemporaryDirectory() as directory: with TemporaryDirectory() as directory:
state = State.for_strategy(directory, "trend", "A") 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.set(StateItem("A", base_order_id="local-1", base_status="ING"))
state.reconcile( state.reconcile(
[position], [position],
@@ -112,7 +112,9 @@ class TrendTests(unittest.TestCase):
def test_low_cash_still_runs_position_management(self): def test_low_cash_still_runs_position_management(self):
client = SimpleNamespace( client = SimpleNamespace(
assets=lambda: Assets(total=10000, available=10), 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)}, full_tick=lambda _codes: {"A": Tick(last_price=11)},
) )
runtime = SimpleNamespace( runtime = SimpleNamespace(
@@ -120,7 +122,11 @@ class TrendTests(unittest.TestCase):
account_cfg=SimpleNamespace(min_cash_ratio=0.1), account_cfg=SimpleNamespace(min_cash_ratio=0.1),
global_cfg=SimpleNamespace(api_host="http://example"), global_cfg=SimpleNamespace(api_host="http://example"),
orders=SimpleNamespace(cancel_expired=lambda _client: None), orders=SimpleNamespace(cancel_expired=lambda _client: None),
state=SimpleNamespace(codes=["A"]), state=SimpleNamespace(
codes=["A"],
unresolved_codes=[],
reconcile=lambda *_args: None,
),
) )
with ( with (
patch("strategy.trend.boot.trading_time", return_value=True), patch("strategy.trend.boot.trading_time", return_value=True),
@@ -132,6 +138,44 @@ class TrendTests(unittest.TestCase):
open_mock.assert_not_called() open_mock.assert_not_called()
manage_mock.assert_called_once() 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__": if __name__ == "__main__":
unittest.main() unittest.main()