Compare commits
2 Commits
6c2eca8fdc
...
3689a7ad86
| Author | SHA1 | Date | |
|---|---|---|---|
| 3689a7ad86 | |||
| a53fc738cf |
16
docs/bug.md
16
docs/bug.md
@@ -1,16 +0,0 @@
|
||||
|
||||
|
||||
### H3. 撤单、拒单、废单和部分成交不能驱动状态机正确收敛
|
||||
|
||||
- 位置:`strategy/trend/state.py:142-162`、`strategy/trend/order.py:14-17,61-92`
|
||||
- 证据:状态对账只把“匹配订单全部为状态 56”视为完成,其余均保持 `ING`;订单被过滤或消失时,已有持仓的补仓状态不会变为失败或撤销。文件中虽然定义了 `FAILED`、`CANCELED`、`UNKNOWN`,但没有完整迁移逻辑。
|
||||
- 影响:失败的补仓仍会消耗 `added_num`,状态可能永久停留在处理中,重启后也无法可靠恢复。
|
||||
- 建议:建立完整 QMT 委托状态映射,按实际成交数量处理全成、部成、已撤、废单、拒单和未知;消失订单需二次查询确认。
|
||||
|
||||
|
||||
### M6. 策略成本使用提交时行情价,而非实际成交价
|
||||
|
||||
- 位置:`strategy/trend/open.py:80-86`、`strategy/trend/positions.py:186-192`
|
||||
- 证据:底仓和补仓在订单刚提交成功时就把 tick 价格写为成本,没有根据成交回报更新实际成交数量与均价。
|
||||
- 影响:滑点、部分成交或拆单时,后续盈亏率、止盈网格和补仓层级基于不准确成本。
|
||||
- 建议:提交阶段只记录订单标识;订单完成对账后从成交或真实持仓均价更新成本和数量。
|
||||
149
docs/trend-audit.md
Normal file
149
docs/trend-audit.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Trend 策略代码审计报告
|
||||
|
||||
审计日期:2026-09-05
|
||||
审计对象:`py-client/strategy/trend` 当前工作树版本。
|
||||
关联范围:仅核对直接影响 Trend 行为的 `sdk`、`libs`、`config` 和 Trend 测试。
|
||||
审计方法:重新读取当前代码,不沿用历史审计结论;本次只更新审计文档,不修改策略代码。
|
||||
|
||||
## 结论摘要
|
||||
|
||||
当前版本不建议直接用于无人值守实盘。发现 2 个严重问题、6 个高风险问题、6 个中风险问题和 2 个低风险/测试问题。最高优先级是本地防重与券商实际活动委托脱节,以及空订单快照会清除未决状态。另有 5 项 Trend 测试在目标断言前报错,当前测试结果不能为下单路径提供有效回归保障。
|
||||
|
||||
## 严重问题
|
||||
|
||||
### S1. 防重只依赖进程内 TTL,完全忽略券商活动委托
|
||||
|
||||
- 位置:`strategy/trend/order.py:45-48,80-92,97-104`
|
||||
- 证据:`refresh()` 虽统计 `busy_keys`,但没有写入 `SimpleCache`;`busy()` 和 `place()` 都只查询 `busy_cache`,没有检查 `self.data`。缓存也不会跨进程恢复。
|
||||
- 触发场景:程序重启后券商仍有活动订单;或本地 180 秒 TTL 到期但订单仍未终结。
|
||||
- 影响:同一证券、同一方向可能重复开仓、补仓或止盈。卖出路径可能再次按全部可用持仓提交委托。
|
||||
- 建议:`busy()` 和 `place()` 在同一互斥区内同时检查 TTL 缓存及 `self.data` 中的 `BUSY_STATUSES`。
|
||||
|
||||
### S2. 一次空订单快照会不可逆地清除待成交状态
|
||||
|
||||
- 位置:`strategy/trend/order.py:57-92`、`state.py:101-160`
|
||||
- 证据:`refresh()` 每轮用当前券商结果覆盖 `OrderBook.data`。订单刚提交但暂未出现在快照时,`reconcile()` 会把对应 `ING` 改为空字符串;若尚无持仓,随后还会删除整个 `StateItem`。
|
||||
- 影响:底仓状态可能丢失,补仓次数可能不增加;后续轮次可能重新开仓或重复使用同一亏损档位。
|
||||
- 建议:本地 pending 应保留至明确终态或确认超时;短暂缺席不能立即视为失败。
|
||||
|
||||
## 高风险问题
|
||||
|
||||
### H1. 大盘风控被固定为允许开仓
|
||||
|
||||
- 位置:`libs/market.py:34-38`;调用位置:`strategy/trend/boot.py:153,185-188`
|
||||
- 证据:`market_allow_open()` 无条件返回 `True`,不读取 `_market_status`。
|
||||
- 影响:市场状态为下跌、未知或刷新失败时,策略仍可开仓和亏损补仓。
|
||||
- 测试证据:`test_market.MarketCacheTests.test_refresh_failure_blocks_open` 失败。
|
||||
- 建议:仅在缓存状态明确为 `UP` 时允许开仓;未知状态采用 fail-closed。
|
||||
|
||||
### H2. 过滤异常订单后再对账,会把拆单结果判错
|
||||
|
||||
- 位置:`strategy/trend/order.py:62-65,80-92`、`state.py:112-139,211-220`
|
||||
- 证据:`refresh()` 丢弃取消、拒绝和异常订单,只把处理态及完成态写入 `data`。同一本地订单若一笔完成、一笔取消,传给 `State` 的只剩完成记录,`_order_status()` 会返回 `OK`。
|
||||
- 影响:部分成交可能被视为完整成交;状态数量、成本和补仓次数与真实结果不一致。
|
||||
- 建议:展示/防重列表可以过滤,但状态对账必须使用完整原始订单快照。
|
||||
|
||||
### H3. 柜台受理与状态落盘之间存在崩溃窗口
|
||||
|
||||
- 位置:`strategy/trend/open.py:78-98`、`positions.py:174-193`
|
||||
- 证据:开仓和补仓都先执行 `orders.place()`,成功返回后才写入并保存 `StateItem`。
|
||||
- 影响:委托受理后若进程退出或状态写入失败,本地没有对应状态;重启后容易重复下单。
|
||||
- 建议:提交前持久化订单意图,提交后记录柜台结果;不确定结果保留为可对账状态。
|
||||
|
||||
### H4. 开仓与补仓并发,资金预算彼此隔离
|
||||
|
||||
- 位置:`strategy/trend/boot.py:180-190`、`open.py:15-74`、`positions.py:28-91`
|
||||
- 证据:持仓管理与开仓并发执行。补仓只在自身循环扣减余额;每个开仓信号均独立使用完整 `buy_value`,两条路径没有账户级资金预留。
|
||||
- 影响:同轮总委托金额可能超过真实可用资金,成交组合取决于柜台顺序。
|
||||
- 建议:统一生成订单意图,用账户级单一预算预留资金后再提交。
|
||||
|
||||
### H5. 撤单结果未校验,活动订单却立即从跟踪列表删除
|
||||
|
||||
- 位置:`strategy/trend/order.py:67-78`
|
||||
- 证据:超过 10 秒即调用 `cancel_by_id()`,不检查响应是否成功,随后无条件 `continue`。
|
||||
- 影响:撤单失败或结果未知时,本地已不再跟踪仍有效的订单;结合 S1 可能重复提交。
|
||||
- 建议:仅在券商明确返回取消终态后移除;失败或不确定时继续保留活动状态。
|
||||
|
||||
### H6. 单笔预算不足一手时仍强制买入 100 股
|
||||
|
||||
- 位置:`libs/calc.py:10-12`;调用位置:`strategy/trend/open.py:49`、`positions.py:168`
|
||||
- 证据:`calc_buy_volume()` 使用 `max(1, floor(...)) * 100`。
|
||||
- 影响:`buy_value < price * 100` 时委托金额必然超过预算,并放大 H4。
|
||||
- 建议:不足一手时返回 `0`,由调用方记录并跳过。
|
||||
|
||||
## 中风险问题
|
||||
|
||||
### M1. 现金安全线只限制新开仓,不限制亏损补仓
|
||||
|
||||
- 位置:`strategy/trend/boot.py:145-148,183-188`、`positions.py:68-80`
|
||||
- 证据:`allow_open_by_cash` 只控制 `open_signal()`;`manage_positions()` 始终获得全部 `assets.available`。
|
||||
- 影响:账户已低于最小现金比例时仍可能增加亏损仓位。
|
||||
- 建议:若安全线也约束补仓,应只传递扣除安全储备后的预算。
|
||||
|
||||
### M2. 两个风控配置未参与 Trend 决策
|
||||
|
||||
- 位置:`config/__init__.py:48-50`、`strategy/trend/positions.py:16,52-59,157-160`
|
||||
- 证据:配置提供 `loss_trigger_pct` 和 `min_profit_pct`,但补仓使用固定 `LOSS_TIERS`,止盈门槛按股价区间硬编码。
|
||||
- 影响:修改配置不会改变实盘行为,运维人员可能误判实际参数。
|
||||
- 建议:让策略明确使用配置,或删除无效配置并输出最终生效参数。
|
||||
|
||||
### M3. 非 `APIError` 下单异常会终止整批持仓管理
|
||||
|
||||
- 位置:`strategy/trend/order.py:105-116`、`positions.py:38-91`
|
||||
- 证据:`OrderBook.place()` 只捕获 `APIError`;`manage_positions()` 没有逐持仓异常边界。
|
||||
- 影响:一只证券发生连接、超时等异常后,其余持仓当轮不再处理。
|
||||
- 建议:订单层捕获明确的传输异常,持仓循环增加逐证券隔离。
|
||||
|
||||
### M4. 启动阶段异常不会可靠释放 HTTP Client
|
||||
|
||||
- 位置:`strategy/trend/boot.py:47-84`
|
||||
- 证据:Client 创建后的组合查询、撤单、状态读取和信号加载不在统一 `try/finally` 中。
|
||||
- 影响:初始化失败时连接池不能确定及时释放。
|
||||
- 建议:建立统一资源生命周期,在 `finally` 中关闭已创建资源。
|
||||
|
||||
### M5. 信号只在启动时加载一次
|
||||
|
||||
- 位置:`strategy/trend/boot.py:67-71,88-110`
|
||||
- 证据:`init_signals()` 位于永久循环外。
|
||||
- 影响:运行期间新增、撤销或修正的远程信号不会生效。
|
||||
- 建议:按业务时效定期刷新,或明确“启动快照整日有效”的约束。
|
||||
|
||||
### M6. 明确拒单后仍保留 180 秒缓存锁
|
||||
|
||||
- 位置:`strategy/trend/order.py:97-126`
|
||||
- 证据:柜台调用前设置缓存,但 API 明确失败、响应格式无效或抛出 `APIError` 时均不删除键。
|
||||
- 影响:可立即纠正或重试的订单被无条件抑制 180 秒,可能错过窗口。
|
||||
- 建议:明确未受理时释放缓存;结果不确定时保留保护并等待对账。
|
||||
|
||||
## 低风险与测试问题
|
||||
|
||||
### L1. 结束时间条件与“大于 15:00”不一致
|
||||
|
||||
- 位置:`strategy/trend/boot.py:90-91`
|
||||
- 证据:当前使用 `>= (15, 0, 0)`,在恰好 `15:00:00` 时退出,而约定是大于 15:00 后退出。
|
||||
- 影响:边界秒行为与需求不一致。
|
||||
- 建议:使用严格大于比较,并让日志描述与条件一致。
|
||||
|
||||
### L2. Trend 测试夹具已与生产接口漂移
|
||||
|
||||
- 位置:`tests/test_trend.py:19-42,291-352`
|
||||
- 证据:生产代码调用 `client.passorder(...)`,但测试替身只实现已删除的 `passorder_latest_tagged()`;两个 `RunOnce` 夹具缺少采集任务需要的 `account_id`。
|
||||
- 影响:核心下单和调度测试在目标断言前报错,无法验证真实调用契约。
|
||||
- 建议:测试替身实现当前 `passorder` 签名并补齐 `account_id`;增加券商活动订单、TTL、空快照、撤单失败和并发预算测试。
|
||||
|
||||
## 回归结果
|
||||
|
||||
执行:`cd py-client && python -B -m unittest tests.test_trend tests.test_market -v`
|
||||
|
||||
结果:17 项测试中 11 项通过、1 项失败、5 项错误。
|
||||
|
||||
- 失败:市场状态刷新失败后仍允许开仓。
|
||||
- 错误:3 项下单测试使用旧接口替身;2 项 `RunOnce` 测试缺少 `account_id`。
|
||||
|
||||
## 建议处理顺序
|
||||
|
||||
1. S1、S2:恢复可靠的订单防重和未决状态对账。
|
||||
2. H2、H3、H5:确保订单生命周期、撤单和成交状态可信。
|
||||
3. H1、H4、H6、M1:恢复风控并统一账户预算。
|
||||
4. M2、M3、M4、M5、M6:处理配置、异常隔离、资源和刷新策略。
|
||||
5. 修复测试夹具并补齐跨轮、重启、空快照、撤单失败和并发预算回归。
|
||||
@@ -52,24 +52,6 @@ class TradeMixin:
|
||||
) or []
|
||||
return response if isinstance(response, list) else []
|
||||
|
||||
def passorder_latest(self, side: int, stock_code: str, volume: int) -> dict[str, Any]:
|
||||
return self.passorder(side, stock_code, volume)
|
||||
|
||||
def passorder_latest_tagged(
|
||||
self,
|
||||
side: int,
|
||||
stock_code: str,
|
||||
volume: int,
|
||||
strategy_name: str,
|
||||
order_id: str,
|
||||
) -> dict[str, Any]:
|
||||
return self.passorder(
|
||||
side,
|
||||
stock_code,
|
||||
volume,
|
||||
strategy_name=strategy_name,
|
||||
order_id=order_id,
|
||||
)
|
||||
|
||||
def cancel_by_id(self, order_id: str) -> dict[str, Any]:
|
||||
return self._post_json("/api/trade/cancel_by_id", {"order_id": order_id})
|
||||
|
||||
@@ -77,7 +77,7 @@ def open_signal(run:Runtime, ticks, open_signals) -> None:
|
||||
|
||||
def do_open(run: Runtime, code: str, volume: int, signal_key: str, price: float) -> None:
|
||||
"""生成本地订单号并按最新价提交开仓委托。"""
|
||||
order_id = run.orders.new_order_id("base")
|
||||
order_id = run.orders.new_order_id()
|
||||
request = PlaceOrderRequest(
|
||||
run.client,
|
||||
OP_BUY,
|
||||
@@ -92,8 +92,6 @@ def do_open(run: Runtime, code: str, volume: int, signal_key: str, price: float)
|
||||
run.state.set(StateItem(
|
||||
code=code,
|
||||
base_order_id=order_id,
|
||||
base_qty=volume,
|
||||
base_cost=round(price, 2),
|
||||
base_status=STATUS_ING,
|
||||
))
|
||||
run.state.save()
|
||||
|
||||
@@ -38,6 +38,7 @@ class OrderBook:
|
||||
self.lock_timeout_sec = max(1, lock_timeout_sec)
|
||||
self.cancel_timeout_sec = timedelta(seconds=cancel_timeout_sec)
|
||||
self.data: list[OrderItem] = []
|
||||
self.busy_keys: set[str] = set()
|
||||
self.busy_cache = SimpleCache(threshold=10_000, default_timeout=self.lock_timeout_sec)
|
||||
self.mutex = Lock()
|
||||
|
||||
@@ -49,7 +50,8 @@ class OrderBook:
|
||||
def busy(self, code: str, side: str) -> bool:
|
||||
"""判断证券是否存在仍在处理中的同方向委托。"""
|
||||
with self.mutex:
|
||||
return self.busy_cache.has(self._busy_key(side, code))
|
||||
key = self._busy_key(side, code)
|
||||
return key in self.busy_keys or self.busy_cache.has(key)
|
||||
|
||||
@staticmethod
|
||||
def _busy_key(side: str, code: str) -> str:
|
||||
@@ -66,6 +68,8 @@ class OrderBook:
|
||||
# 不处理状态不对的
|
||||
if item.status not in TRACKED_STATUSES:
|
||||
continue
|
||||
if item.status in BUSY_STATUSES:
|
||||
busy_keys.add(self._busy_key(item.side, item.code))
|
||||
# 清理过期的
|
||||
if (
|
||||
item.created_at is not None
|
||||
@@ -80,12 +84,11 @@ class OrderBook:
|
||||
# 缓存本次有效订单
|
||||
data.append(item)
|
||||
|
||||
if item.status in BUSY_STATUSES:
|
||||
busy_keys.add(self._busy_key(item.side, item.code))
|
||||
|
||||
|
||||
with self.mutex:
|
||||
self.data = data
|
||||
self.busy_keys = busy_keys
|
||||
log.info("[Order] 刷新完成,跟踪=%d,处理中=%d,撤销=%d", len(data), len(busy_keys), canceled)
|
||||
|
||||
def place(self, request: PlaceOrderRequest) -> bool:
|
||||
@@ -97,18 +100,18 @@ class OrderBook:
|
||||
|
||||
key = self._busy_key(side, request.code)
|
||||
with self.mutex:
|
||||
if self.busy_cache.has(key):
|
||||
if key in self.busy_keys or self.busy_cache.has(key):
|
||||
log.info("[Order] 跳过重复下单,代码=%s,方向=%s", request.code, side)
|
||||
return False
|
||||
self.busy_cache.set(key, True, timeout=self.lock_timeout_sec)
|
||||
|
||||
try:
|
||||
result = request.client.passorder_latest_tagged(
|
||||
request.op,
|
||||
request.code,
|
||||
request.volume,
|
||||
request.strategy_name,
|
||||
request.order_id,
|
||||
result = request.client.passorder(
|
||||
op_type=request.op,
|
||||
stock_code=request.code,
|
||||
volume=request.volume,
|
||||
strategy_name=request.strategy_name,
|
||||
order_id=request.order_id,
|
||||
)
|
||||
except APIError as exc:
|
||||
log.exception("[Order] 下单失败,代码=%s,本地订单=%s,HTTP状态=%d,错误=%s", request.code, request.order_id, exc.status_code, exc.message or str(exc))
|
||||
|
||||
@@ -13,8 +13,6 @@ from .runtime import Runtime
|
||||
from .state import STATUS_ING
|
||||
import logging as log
|
||||
|
||||
LEG_BASE = "base"
|
||||
LEG_ADDED = "add"
|
||||
LOSS_TIERS = (-30.0, -50.0)
|
||||
|
||||
|
||||
@@ -129,7 +127,7 @@ def handle_profit(
|
||||
volume = position.can_use_volume - position.can_use_volume % 100
|
||||
if volume <= 0:
|
||||
return TradeDecision(False, "无可用整手持仓")
|
||||
order_id = runtime.orders.new_order_id(LEG_BASE)
|
||||
order_id = runtime.orders.new_order_id()
|
||||
request = PlaceOrderRequest(
|
||||
client=runtime.client,
|
||||
op=OP_SELL,
|
||||
@@ -172,7 +170,7 @@ def handle_loss(
|
||||
if volume <= 0 or amount > available:
|
||||
return TradeDecision(False, "本轮可用资金不足")
|
||||
|
||||
order_id = runtime.orders.new_order_id(LEG_ADDED)
|
||||
order_id = runtime.orders.new_order_id()
|
||||
request = PlaceOrderRequest(
|
||||
client=runtime.client,
|
||||
op=OP_BUY,
|
||||
@@ -184,11 +182,8 @@ def handle_loss(
|
||||
if not runtime.orders.place(request):
|
||||
return TradeDecision(False, "补仓委托失败")
|
||||
|
||||
state.added_num += 1
|
||||
state.added_status = STATUS_ING
|
||||
state.added_order_id = order_id
|
||||
state.added_qty = volume
|
||||
state.added_cost = tick.last_price
|
||||
runtime.state.set(state)
|
||||
runtime.state.save()
|
||||
runtime.add_watch.forget(position.stock_code)
|
||||
|
||||
@@ -136,9 +136,9 @@ class State:
|
||||
|
||||
for code in list(self.codes):
|
||||
item = self.get(code)
|
||||
for order_id_attr, status_attr in (
|
||||
("base_order_id", "base_status"),
|
||||
("added_order_id", "added_status"),
|
||||
for order_id_attr, status_attr, qty_attr, cost_attr in (
|
||||
("base_order_id", "base_status", "base_qty", "base_cost"),
|
||||
("added_order_id", "added_status", "added_qty", "added_cost"),
|
||||
):
|
||||
local_order_id = getattr(item, order_id_attr)
|
||||
current_status = getattr(item, status_attr)
|
||||
@@ -150,6 +150,14 @@ class State:
|
||||
if status != current_status:
|
||||
log.info("[状态] %s 订单=%s,状态=%s->%s", code, local_order_id, current_status, status)
|
||||
setattr(item, status_attr, status)
|
||||
if status == STATUS_OK:
|
||||
quantity, cost = _filled_order(matching_orders)
|
||||
if quantity > 0:
|
||||
setattr(item, qty_attr, quantity)
|
||||
if cost > 0:
|
||||
setattr(item, cost_attr, cost)
|
||||
if status_attr == "added_status":
|
||||
item.added_num += 1
|
||||
self.set(item)
|
||||
|
||||
# Opening orders normally have no position until their first fill. Order
|
||||
@@ -212,3 +220,20 @@ def _order_status(orders: list[OrderItem] | None) -> str:
|
||||
if statuses <= BUSY_STATUSES | COMPLETED_STATUSES:
|
||||
return STATUS_ING
|
||||
return STATUS_NONE
|
||||
|
||||
|
||||
def _filled_order(orders: list[OrderItem] | None) -> tuple[int, float]:
|
||||
"""汇总已成交订单的实际数量和加权成交价。"""
|
||||
quantity = 0
|
||||
amount = 0.0
|
||||
for order in orders or []:
|
||||
filled = order.traded_volume if order.traded_volume > 0 else order.volume
|
||||
if filled <= 0:
|
||||
continue
|
||||
quantity += filled
|
||||
if order.trade_amount > 0:
|
||||
amount += order.trade_amount
|
||||
elif order.trade_price > 0:
|
||||
amount += order.trade_price * filled
|
||||
cost = round(amount / quantity, 4) if quantity > 0 and amount > 0 else 0.0
|
||||
return quantity, cost
|
||||
|
||||
@@ -44,9 +44,9 @@ class FailedOrderClient:
|
||||
|
||||
class TrendTests(unittest.TestCase):
|
||||
def test_trend_order_id_format(self):
|
||||
self.assertRegex(OrderBook.new_order_id("base"), r"^trend-[0-9a-f]{8}$")
|
||||
self.assertRegex(OrderBook.new_order_id(), r"^trend-[0-9a-f]{24}$")
|
||||
|
||||
def test_open_records_base_order_and_rounded_cost(self):
|
||||
def test_open_records_only_pending_order(self):
|
||||
with TemporaryDirectory() as directory:
|
||||
state = State.for_strategy(directory, "trend", "A")
|
||||
forgotten = []
|
||||
@@ -60,9 +60,9 @@ class TrendTests(unittest.TestCase):
|
||||
do_open(runtime, "000001.SZ", 100, "morning", 12.345)
|
||||
|
||||
item = state.get("000001.SZ")
|
||||
self.assertRegex(item.base_order_id, r"^trend-[0-9a-f]{8}$")
|
||||
self.assertEqual(item.base_qty, 100)
|
||||
self.assertEqual(item.base_cost, 12.35)
|
||||
self.assertRegex(item.base_order_id, r"^trend-[0-9a-f]{24}$")
|
||||
self.assertEqual(item.base_qty, 0)
|
||||
self.assertEqual(item.base_cost, 0)
|
||||
self.assertEqual(item.base_status, "ING")
|
||||
self.assertEqual(forgotten, ["000001.SZ"])
|
||||
|
||||
@@ -226,7 +226,10 @@ class TrendTests(unittest.TestCase):
|
||||
state = State.for_strategy(directory, "trend", "A")
|
||||
position = PositionItem(stock_code="A", volume=100, open_price=10)
|
||||
state.set(StateItem("A", base_order_id="local-1", base_status="ING"))
|
||||
completed = OrderItem("1", "A", "BUY", "", "56", None, 50, "local-1")
|
||||
completed = OrderItem(
|
||||
"1", "A", "BUY", "", "56", None, 50, "local-1",
|
||||
traded_volume=50, trade_price=10.1,
|
||||
)
|
||||
processing = OrderItem("2", "A", "BUY", "", "50", None, 50, "local-1")
|
||||
|
||||
state.reconcile([position], [completed, processing])
|
||||
@@ -234,9 +237,18 @@ class TrendTests(unittest.TestCase):
|
||||
|
||||
state.reconcile(
|
||||
[position],
|
||||
[completed, OrderItem("2", "A", "BUY", "", "56", None, 50, "local-1")],
|
||||
[
|
||||
completed,
|
||||
OrderItem(
|
||||
"2", "A", "BUY", "", "56", None, 50, "local-1",
|
||||
traded_volume=50, trade_price=10.3,
|
||||
),
|
||||
],
|
||||
)
|
||||
self.assertEqual(state.get("A").base_status, STATUS_OK)
|
||||
item = state.get("A")
|
||||
self.assertEqual(item.base_status, STATUS_OK)
|
||||
self.assertEqual(item.base_qty, 100)
|
||||
self.assertEqual(item.base_cost, 10.2)
|
||||
|
||||
canceled = OrderItem("2", "A", "BUY", "", "54", None, 50, "local-1")
|
||||
item = state.get("A")
|
||||
@@ -245,6 +257,31 @@ class TrendTests(unittest.TestCase):
|
||||
state.reconcile([position], [completed, canceled])
|
||||
self.assertEqual(state.get("A").base_status, "")
|
||||
|
||||
def test_reconcile_records_filled_add_order(self):
|
||||
with TemporaryDirectory() as directory:
|
||||
state = State.for_strategy(directory, "trend", "A")
|
||||
position = PositionItem(stock_code="A", volume=200, open_price=10)
|
||||
state.set(StateItem(
|
||||
"A",
|
||||
base_qty=100,
|
||||
base_cost=10,
|
||||
base_status=STATUS_OK,
|
||||
added_order_id="add-1",
|
||||
added_status="ING",
|
||||
))
|
||||
completed = OrderItem(
|
||||
"1", "A", "BUY", "", "56", None, 100, "add-1",
|
||||
traded_volume=100, trade_amount=950,
|
||||
)
|
||||
|
||||
state.reconcile([position], [completed])
|
||||
|
||||
item = state.get("A")
|
||||
self.assertEqual(item.added_status, STATUS_OK)
|
||||
self.assertEqual(item.added_num, 1)
|
||||
self.assertEqual(item.added_qty, 100)
|
||||
self.assertEqual(item.added_cost, 9.5)
|
||||
|
||||
def test_low_cash_still_runs_position_management(self):
|
||||
client = SimpleNamespace(
|
||||
portfolio=lambda: Portfolio(
|
||||
|
||||
42
py-client/tests/test_trend_busy.py
Normal file
42
py-client/tests/test_trend_busy.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock
|
||||
|
||||
from sdk import OrderItem
|
||||
from strategy.trend.order import OrderBook, PlaceOrderRequest
|
||||
|
||||
|
||||
class TrendBusyTests(unittest.TestCase):
|
||||
def test_snapshot_blocks_order_without_local_cache(self):
|
||||
book = OrderBook()
|
||||
client = Mock()
|
||||
order = OrderItem("1", "A", "BUY", "", "50", None, 100)
|
||||
book.refresh(client, [order])
|
||||
self.assertTrue(book.busy("A", "BUY"))
|
||||
self.assertFalse(book.busy("A", "SELL"))
|
||||
self.assertFalse(book.place(PlaceOrderRequest(client, 23, "A", 100, "local", "trend")))
|
||||
client.passorder.assert_not_called()
|
||||
book.refresh(client, [])
|
||||
self.assertFalse(book.busy("A", "BUY"))
|
||||
|
||||
def test_cancel_request_keeps_order_busy_until_terminal_snapshot(self):
|
||||
book = OrderBook()
|
||||
client = Mock()
|
||||
order = OrderItem("1", "A", "BUY", "", "50", datetime.now() - timedelta(seconds=20), 100)
|
||||
book.refresh(client, [order])
|
||||
client.cancel_by_id.assert_called_once_with("1")
|
||||
self.assertTrue(book.busy("A", "BUY"))
|
||||
order.status = "54"
|
||||
book.refresh(client, [order])
|
||||
self.assertFalse(book.busy("A", "BUY"))
|
||||
|
||||
def test_empty_snapshot_keeps_local_cache_protection(self):
|
||||
book = OrderBook()
|
||||
client = Mock()
|
||||
client.passorder.return_value = {"status": "success", "order_ref": "1"}
|
||||
request = PlaceOrderRequest(client, 23, "A", 100, "local", "trend")
|
||||
self.assertTrue(book.place(request))
|
||||
book.refresh(client, [])
|
||||
self.assertTrue(book.busy("A", "BUY"))
|
||||
self.assertFalse(book.place(request))
|
||||
client.passorder.assert_called_once()
|
||||
Reference in New Issue
Block a user