Files
big-qmt/py-client/tests/test_zt_boot.py
2026-09-15 20:02:05 +08:00

476 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ZT 新路径端到端建仓、正T、反T、T+1 隔夜、每日一轮、资金、启动与撤单范围。"""
import logging
import tempfile
import unittest
from datetime import datetime, timedelta
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import Mock, patch
from libs.signal import SignalItem
from libs.snapshot import get_collector_snapshot
from sdk import OP_BUY, OP_SELL, Assets, OrderItem, PositionItem
from strategy.zt import boot
from strategy.zt.rounds import Round, RoundStore, start_round
from tests.zt_harness import Fixture
CODE = '600000.SH'
OTHER = '600001.SH'
TODAY = '2026-09-15'
SIGNAL = [SignalItem(signal_key='dcm', code=CODE, last_close=10.0)]
class ZTBaseTests(unittest.TestCase):
def setUp(self):
self.fx = Fixture()
self.addCleanup(self.fx.cleanup)
logging.disable(logging.CRITICAL)
self.addCleanup(logging.disable, logging.NOTSET)
def test_existing_account_positions_are_never_taken_over(self):
self.fx.hold(CODE, volume=1000, price=37.72)
self.fx.quote(CODE, 37.72)
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual(item.base_qty, 0) # 不写基准
self.assertEqual(item.phase, 'IDLE')
self.assertEqual(self.fx.placed, [])
# 也不纳入管理,账户已有持仓原样保留
self.assertFalse(self.fx.store.rounds)
def test_unmanaged_positions_are_listed_each_tick(self):
self.fx.hold(CODE, volume=1000, price=37.72)
self.fx.quote(CODE, 37.72)
logging.disable(logging.NOTSET)
with self.assertLogs(level='INFO') as captured:
self.fx.tick()
text = '\n'.join(captured.output)
self.assertIn('[ZT跳过]', text)
self.assertIn('未接管持仓 1 只', text)
self.assertIn(CODE, text)
def test_decision_and_summary_lines_are_logged(self):
self.fx.hold(CODE, volume=1000, price=10.0)
self.fx.quote(CODE, 10.0)
logging.disable(logging.NOTSET)
with self.assertLogs(level='INFO') as captured:
self.fx.tick()
text = '\n'.join(captured.output)
self.assertIn('[ZT汇总]', text)
self.assertIn('未接管=1 新委托=0', text)
def test_open_base_needs_signal_and_rebound_then_uses_the_fill_price(self):
self.fx.quote(CODE, 10.0)
self.fx.tick(SIGNAL) # 第一次观察,不追
self.assertEqual(self.fx.placed, [])
self.fx.tick(SIGNAL) # 同一价位即满足反弹确认
self.assertEqual(len(self.fx.placed), 1)
order = self.fx.placed[0]
self.assertEqual((order['op_type'], order['volume']), (OP_BUY, 100))
self.assertTrue(order['order_id'].startswith('zt-base-'))
self.fx.deals = [self.fx.deal(order['order_id'], 100, 10.25, sys_id='b1')]
self.fx.tick(SIGNAL)
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'CLOSED')
self.assertEqual(item.outcome, 'base')
self.assertEqual((item.base_qty, item.base_cost), (100, 10.25))
self.assertEqual(item.base_source, 'opened')
def test_open_base_is_skipped_without_a_signal(self):
self.fx.quote(CODE, 10.0)
self.fx.prime(self.fx.run.open_watch, CODE, 10.0)
self.fx.tick([])
self.assertEqual(self.fx.placed, [])
def test_decision_and_summary_lines_are_logged(self):
self.fx.hold(CODE, volume=1000, price=10.0)
self.fx.quote(CODE, 10.0)
self.fx.open_round() # 本策略自有基准 -> 纳入管理
logging.disable(logging.NOTSET)
with self.assertLogs(level='INFO') as captured:
self.fx.tick()
text = '\n'.join(captured.output)
self.assertIn('[ZT决策]', text)
self.assertIn('[ZT汇总]', text)
self.assertIn('中性带内不做', text)
self.assertIn('未接管=0', text)
class ZTShortTTests(unittest.TestCase):
"""反T高抛后低吸买回。"""
def setUp(self):
self.fx = Fixture()
self.addCleanup(self.fx.cleanup)
logging.disable(logging.CRITICAL)
self.addCleanup(logging.disable, logging.NOTSET)
self.fx.hold(CODE, volume=1000, price=10.0)
self.fx.own_base(CODE, 1000, 10.0)
def enter(self):
self.fx.quote(CODE, 11.0)
self.fx.tick() # 网格首次观察
self.fx.quote(CODE, 10.5)
self.fx.tick() # 网格回撤 -> 高抛
def test_sell_high_then_buy_back(self):
self.enter()
self.assertEqual(len(self.fx.placed), 1)
entry = self.fx.placed[0]
self.assertEqual((entry['op_type'], entry['volume']), (OP_SELL, 500))
self.assertTrue(entry['order_id'].startswith('zt-entry-'))
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'OPENING')
self.assertEqual((item.kind, item.base_qty), ('SHORT_T', 1000))
self.fx.deals = [self.fx.deal(entry['order_id'], 500, 11.0, sys_id='s1')]
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'OPEN')
self.assertEqual(item.residual_qty, 500)
# 买回需要"较卖均价回落 + 反弹确认":上一轮 tick 已在上方建立观察点。
self.fx.quote(CODE, 10.8)
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'CLOSING')
exit_order = self.fx.placed[-1]
self.assertEqual((exit_order['op_type'], exit_order['volume']), (OP_BUY, 500))
self.assertTrue(exit_order['order_id'].startswith('zt-exit-'))
self.fx.deals = [self.fx.deal(entry['order_id'], 500, 11.0, sys_id='s1'),
self.fx.deal(exit_order['order_id'], 500, 10.8, sys_id='b1')]
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'CLOSED')
self.assertEqual(item.outcome, 'normal')
self.assertEqual(item.residual_qty, 0)
self.assertEqual(item.base_qty, 1000) # 成本基准数量不变
self.assertAlmostEqual(item.realized_amount, 100.0)
def test_no_sell_inside_the_neutral_band(self):
self.fx.hold(CODE, volume=1000, price=10.0)
self.fx.quote(CODE, 10.05)
self.fx.tick()
self.fx.tick()
self.assertEqual(self.fx.placed, [])
def test_sell_only_round_is_counted_in_the_tick_summary(self):
logging.disable(logging.NOTSET)
self.fx.quote(CODE, 11.0)
self.fx.tick() # 首次观察,不下单
self.fx.quote(CODE, 10.5)
with self.assertLogs(level='INFO') as captured:
self.fx.tick() # 网格回撤 -> 高抛
text = '\n'.join(captured.output)
self.assertIn('[ZT下单]', text)
self.assertIn('[ZT决策]', text)
# 卖出腿不预留资金,仍必须计入"新委托",否则日志会漏报卖出。
self.assertIn('新委托=1', text)
def test_price_above_the_cap_never_starts_a_round(self):
self.fx.hold(CODE, volume=1000, price=190.0)
self.fx.quote(CODE, 200.5)
self.fx.tick()
self.fx.tick()
self.assertEqual(self.fx.placed, [])
def test_position_not_sellable_cannot_open_a_short_t(self):
self.fx.hold(CODE, volume=1000, price=10.0, can_use=0)
self.enter()
self.assertEqual(self.fx.placed, [])
class ZTLongTTests(unittest.TestCase):
"""正T低吸后高抛当天买入受 T+1 限制。"""
def setUp(self):
self.fx = Fixture()
self.addCleanup(self.fx.cleanup)
logging.disable(logging.CRITICAL)
self.addCleanup(logging.disable, logging.NOTSET)
self.fx.hold(CODE, volume=1000, price=10.0)
self.fx.own_base(CODE, 1000, 10.0)
def enter(self, price=9.0):
self.fx.quote(CODE, price)
self.fx.prime(self.fx.run.open_watch, CODE, price)
self.fx.tick()
def test_buy_the_dip_then_wait_for_t_plus_1(self):
self.enter()
self.assertEqual(len(self.fx.placed), 1)
entry = self.fx.placed[0]
self.assertEqual((entry['op_type'], entry['volume']), (OP_BUY, 100))
self.assertTrue(entry['order_id'].startswith('zt-entry-'))
self.fx.deals = [self.fx.deal(entry['order_id'], 100, 9.0, sys_id='b1')]
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual((item.kind, item.phase), ('LONG_T', 'OPEN'))
self.assertEqual(item.residual_qty, 100)
# 当天买入不可卖:可卖库存仍为 0只能隔夜。
self.fx.hold(CODE, volume=1100, price=10.0, can_use=0)
self.fx.quote(CODE, 9.5)
self.fx.tick()
self.assertEqual(len(self.fx.placed), 1) # 没有新的卖单
self.assertEqual(self.fx.store.get(CODE).phase, 'OPEN')
# 可卖恢复后才能高抛平仓。
self.fx.hold(CODE, volume=1100, price=10.0, can_use=1100)
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'CLOSING')
exit_order = self.fx.placed[-1]
self.assertEqual((exit_order['op_type'], exit_order['volume']), (OP_SELL, 100))
def test_only_one_round_per_stock_per_day(self):
self.enter()
entry = self.fx.placed[0]
exit_order_id = None
self.fx.deals = [self.fx.deal(entry['order_id'], 100, 9.0, sys_id='b1')]
self.fx.tick()
self.fx.hold(CODE, volume=1100, price=10.0, can_use=1100)
self.fx.quote(CODE, 9.5)
self.fx.tick()
exit_order_id = self.fx.placed[-1]['order_id']
self.fx.deals = [self.fx.deal(entry['order_id'], 100, 9.0, sys_id='b1'),
self.fx.deal(exit_order_id, 100, 9.5, sys_id='s1')]
self.fx.tick()
self.assertEqual(self.fx.store.get(CODE).phase, 'CLOSED')
placed_after_close = len(self.fx.placed)
# 同一天价格再次满足低吸,也不允许开新轮。
self.fx.quote(CODE, 8.8)
self.fx.prime(self.fx.run.open_watch, CODE, 8.8)
self.fx.tick()
self.assertEqual(len(self.fx.placed), placed_after_close)
self.assertEqual(self.fx.store.get(CODE).phase, 'CLOSED')
class ZTRiskTests(unittest.TestCase):
def setUp(self):
self.fx = Fixture()
self.addCleanup(self.fx.cleanup)
logging.disable(logging.CRITICAL)
self.addCleanup(logging.disable, logging.NOTSET)
def test_cash_budget_is_shared_between_codes_in_one_tick(self):
self.fx.account_cfg.min_cash_ratio = 0.0
for code in (CODE, OTHER):
self.fx.hold(code, volume=1000, price=10.0)
self.fx.own_base(code, 1000, 10.0)
self.fx.quote(code, 9.0)
self.fx.prime(self.fx.run.open_watch, code, 9.0)
self.fx.assets.total = 1500.0
self.fx.assets.available = 1500.0
self.fx.tick()
self.assertEqual(len(self.fx.placed), 1) # 只够一手的钱
self.assertEqual(self.fx.placed[0]['stock_code'], CODE)
def test_failed_place_self_heals_on_the_next_tick(self):
from sdk import APIError
self.fx.hold(CODE, volume=1000, price=10.0)
self.fx.own_base(CODE, 1000, 10.0)
self.fx.quote(CODE, 9.0)
self.fx.prime(self.fx.run.open_watch, CODE, 9.0)
self.fx.client.passorder.side_effect = APIError(400, 'rejected')
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'OPENING') # 意图先落盘,请求被拒
self.fx.client.passorder.side_effect = None
self.fx.client.passorder.return_value = {'status': 'success'}
self.fx.tick() # 未受理且无成交 -> 判为作废
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'CLOSED')
self.assertEqual(item.outcome, 'aborted')
self.assertEqual(item.base_qty, 1000)
def test_only_zt_orders_are_cancelled(self):
stamp = datetime.now() - timedelta(minutes=30)
self.fx.orders = [
self._order('sys-tren', 'TREN-BUY-1|trend', stamp),
self._order('sys-zt', 'zt-entry-1', stamp),
self._order('sys-ipo', 'IPO-abc', stamp),
self._order('sys-manual', '', stamp),
]
self.fx.quote(CODE, 10.0)
self.fx.tick()
cancelled = [call.args[0] for call in self.fx.client.cancel_by_id.call_args_list]
self.assertEqual(cancelled, ['sys-zt'])
@staticmethod
def _order(sys_id, remark, stamp):
return OrderItem(stock_code=CODE, order_sys_id=sys_id, remark=remark,
order_status=50, offset_flag=23,
insert_date=stamp.strftime('%Y%m%d'),
insert_time=stamp.strftime('%H%M%S'))
def test_expired_round_folds_its_exposure_into_the_base(self):
self.fx.hold(CODE, volume=1000, price=10.0)
item = self.fx.own_base(CODE, 1000, 10.0)
start_round(item, 'SHORT_T', '2020-01-01')
item.entry_order_id = 'zt-entry-old'
item.entry_filled_qty, item.entry_amount = 500, 5500.0
item.phase = 'OPEN'
self.fx.store.put(item)
self.fx.store.save()
self.fx.quote(CODE, 11.0)
self.fx.tick()
item = self.fx.store.get(CODE)
self.assertEqual(item.outcome, 'expired')
self.assertEqual(item.base_qty, 500) # 卖出未买回,底仓变 500
self.assertEqual(item.base_cost, 10.0) # 成本仍是建仓价
def test_stale_order_id_disappearing_returns_the_round_to_open(self):
self.fx.hold(CODE, volume=1000, price=10.0)
item = self.fx.own_base(CODE, 1000, 10.0)
start_round(item, 'SHORT_T', TODAY)
item.entry_order_id = 'zt-entry-1'
item.entry_filled_qty, item.entry_amount = 500, 5500.0
item.exit_order_id = 'zt-exit-1'
item.phase = 'CLOSING'
self.fx.store.put(item)
self.fx.store.save()
self.fx.quote(CODE, 10.0)
self.fx.tick() # 平仓腿已不在途且无成交
item = self.fx.store.get(CODE)
self.assertEqual(item.phase, 'OPEN')
self.assertEqual(item.residual_qty, 500)
class ZTForeignBaseCleanupTests(unittest.TestCase):
"""升级清理:旧版本留下的"接管"基准不得继续参与做 T。"""
def setUp(self):
self.fx = Fixture()
self.addCleanup(self.fx.cleanup)
logging.disable(logging.CRITICAL)
self.addCleanup(logging.disable, logging.NOTSET)
def open_store(self):
with patch.object(boot.config, 'global_config',
NS(qmt_data_dir=str(self.fx.path), qmt_base_url='u',
qmt_token='')), \
patch.object(boot.config, 'account_config', self.fx.account_cfg):
return boot._open_store()
def test_stale_takeover_record_is_dropped(self):
self.fx.store.put(Round(code=CODE, base_qty=1000, base_cost=37.72,
base_source='adopted', phase='CLOSED'))
self.fx.store.save()
self.fx.own_base(OTHER, 500, 9.5)
store = self.open_store()
self.assertNotIn(CODE, store.rounds)
self.assertIn(OTHER, store.rounds)
def test_unclosed_round_is_kept_so_its_exposure_can_be_finished(self):
item = Round(code=CODE, base_qty=1000, base_cost=37.72, base_source='adopted')
start_round(item, 'SHORT_T', TODAY)
item.entry_order_id = 'zt-entry-1'
item.entry_filled_qty, item.entry_amount = 500, 5500.0
item.phase = 'OPEN'
self.fx.store.put(item)
self.fx.store.save()
self.assertIn(CODE, self.open_store().rounds)
def test_owned_and_empty_records_are_untouched(self):
self.fx.own_base(CODE, 500, 9.5)
self.fx.store.put(Round(code=OTHER)) # 无基准的空记录
self.fx.store.save()
store = self.open_store()
self.assertIn(CODE, store.rounds)
self.assertIn(OTHER, store.rounds)
class ZTStartTests(unittest.TestCase):
"""启动路径:使用新轮次文件、不碰旧账本、跨重启恢复未平轮次。"""
def start(self, client, directory):
account = NS(account_id='test', strategy='zt', grid_step_pct=1.0,
signal_allow=[], zt_open_hands=1, zt_max_hold_days=5,
zt_t_band_pct=1.0, zt_sell_ratio=0.5, zt_buy_fall_pct=1.0,
zt_max_price=200.0, excluded_codes=[],
min_cash_ratio=0.1)
global_cfg = NS(qmt_base_url='unused', qmt_token='', qmt_data_dir=directory)
with patch.object(boot, 'Client', return_value=client), \
patch.object(boot.config, 'global_config', global_cfg), \
patch.object(boot.config, 'account_config', account), \
patch.object(boot, 'init_signals', return_value=[]), \
patch.object(boot.time, 'localtime',
return_value=NS(tm_hour=15, tm_min=0, tm_sec=0)):
boot.StartZT()
return account
def test_uses_the_rounds_store_and_never_touches_the_old_ledger(self):
client = Mock()
with tempfile.TemporaryDirectory() as tmp:
self.start(client, tmp)
self.assertFalse((Path(tmp) / 'zt_test_state.db').exists())
self.assertEqual(client.deals.call_count, 0) # 15:00 直接退出,没跑 tick
self.assertEqual(client.portfolio.call_count, 0) # 也不再为接管读持仓
client.close.assert_called_once()
with patch.object(boot.config, 'global_config',
NS(qmt_data_dir=tmp, qmt_base_url='u', qmt_token='')), \
patch.object(boot.config, 'account_config', NS(account_id='test')):
store = boot._open_store()
self.assertEqual(store.path.name, 'zt_test_rounds.json')
self.assertEqual(store.rounds, {})
def test_start_does_not_read_positions_at_all(self):
# 不接管持仓,启动阶段不需要账户快照,第一次读盘发生在第一个 tick。
client = Mock()
with tempfile.TemporaryDirectory() as tmp:
self.start(client, tmp)
self.assertEqual(client.portfolio.call_count, 0)
self.assertEqual(client.deals.call_count, 0)
def test_restores_an_unclosed_round_across_restart(self):
client = Mock()
with tempfile.TemporaryDirectory() as tmp:
store = RoundStore(Path(tmp) / 'zt_test_rounds.json')
item = Round(code=CODE, base_qty=1000, base_cost=10.0)
start_round(item, 'SHORT_T', '2026-09-14')
item.entry_order_id = 'zt-entry-1'
item.entry_filled_qty, item.entry_amount = 500, 5500.0
item.phase = 'OPEN'
store.put(item)
store.save()
self.start(client, tmp)
restored = RoundStore(Path(tmp) / 'zt_test_rounds.json').get(CODE)
self.assertEqual(restored.phase, 'OPEN')
self.assertEqual(restored.residual_qty, 500)
self.assertEqual(restored.entry_avg_price, 11.0)
class ZTCollectorTests(unittest.TestCase):
def test_snapshot_is_cached_even_when_market_fetch_fails(self):
fx = Fixture()
self.addCleanup(fx.cleanup)
fx.assets = Assets(total=20000, available=10000)
fx.hold(CODE, volume=100, price=10.0)
fx.quote(CODE, 10.0)
fx.client.full_tick.side_effect = RuntimeError('no market data')
with patch.object(boot, 'trading_time', return_value=True), \
patch.object(boot, 'market_allow_open', return_value=True):
boot.RunOnce(fx.run, fx.store, [])
snapshot = get_collector_snapshot()
self.assertEqual(snapshot[0], 'zt-test')
self.assertEqual(snapshot[1].total, 20000)
self.assertEqual([p.stock_code for p in snapshot[2]], [CODE])
self.assertEqual(fx.placed, [])
if __name__ == '__main__':
unittest.main()