"""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()