This commit is contained in:
2026-09-15 20:02:05 +08:00
parent bfe89ba122
commit 04daeff141
37 changed files with 2674 additions and 1426 deletions

View File

@@ -1,6 +1,4 @@
import ast
import sqlite3
import tempfile
import unittest
from dataclasses import asdict, fields
from datetime import datetime
@@ -8,10 +6,12 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
from libs.order import OrderBook as ActiveOrders
from libs.state import FLAG_BUY, State
from sdk.models import Assets, DealItem, OrderItem, PositionItem
from sdk.portfolio import PortfolioMixin
# QMT 委托/成交的 offset_flag48 买入、49 卖出。
FLAG_BUY = 48
class ApiModelTests(unittest.TestCase):
def setUp(self):
@@ -73,26 +73,6 @@ class ApiModelTests(unittest.TestCase):
client.cancel_by_id.assert_called_once_with('sys1')
self.assertTrue(book.busy('600000.SH', 'BUY'))
def test_storage_and_price_fallback(self):
deal = self.client.deals()[0]
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'state.db'
book = State(path)
book.sync_deals([deal])
loaded = State(path).deals['sys1']
self.assertEqual(loaded['volume'], deal.volume)
self.assertEqual(loaded['trade_date'], '2026-09-07')
deal.order_sys_id = 'sys2'
deal.trade_amount = 0
book.sync_deals([deal, deal])
self.assertEqual(book.deals['sys2']['trade_amount'], 1000)
self.assertEqual(deal.trade_amount, 0)
deal.order_sys_id = 'sys3'
deal.price = 0
with self.assertRaises(sqlite3.IntegrityError):
book.sync_deals([deal])
self.assertEqual(set(State(path).deals), {'sys1', 'sys2'})
if __name__ == '__main__':
unittest.main()

View File

@@ -1,149 +1,85 @@
import sqlite3
import tempfile
"""委托簿:在途状态、方向锁、以及撤单范围。"""
import unittest
from contextlib import closing
from dataclasses import asdict, fields
from pathlib import Path
from unittest.mock import patch
from datetime import datetime, timedelta
from unittest.mock import Mock
from libs.state import FLAG_BUY, FLAG_SELL, State, StateItem
from sdk import DealItem, PositionItem
from libs.order import BUSY_STATUSES, OrderBook, TRACKED_STATUSES
from sdk import OrderItem
class OrderBookTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.path = Path(self.tmp.name) / 'state.db'
def deal(self, kind, sys_order_id, qty, price, date='2026-09-01'):
prefix = {'base': 'zt-base-', 'sell': 'zt-t-sell-', 'buy': 'zt-t-buy-'}[kind]
return DealItem(
order_sys_id=sys_order_id, stock_code='600000.SH',
offset_flag=FLAG_SELL if kind == 'sell' else FLAG_BUY,
volume=qty, price=price, trade_amount=qty * price,
trade_date=date, trade_time='10:00:00', remark=prefix + 'order1|zt',
)
def order(index, remark, status=50, side=23, age_minutes=30):
stamp = datetime.now() - timedelta(minutes=age_minutes)
return OrderItem(stock_code=f'60000{index}.SH', order_sys_id=f'sys{index}',
remark=remark, order_status=status, offset_flag=side,
insert_date=stamp.strftime('%Y%m%d'),
insert_time=stamp.strftime('%H%M%S'))
def test_json_is_never_read(self):
legacy = self.path.with_suffix('.json')
legacy.write_text('invalid JSON', encoding='utf-8')
book = State(self.path)
self.assertIsNone(book.load())
self.assertEqual((book.state, book.deals, book.deals_sys_ids), ({}, {}, set()))
self.assertEqual(legacy.read_text(encoding='utf-8'), 'invalid JSON')
def test_sync_deals_deduplicates_batch_and_restart(self):
book = State(self.path)
self.assertEqual((book.state, book.deals, book.deals_sys_ids), ({}, {}, set()))
first = self.deal('base', 'd1', 40, 10, '20260901')
second = self.deal('base', 'd2', 60, 12)
book.sync_deals([first, first, second])
self.assertEqual(book.deals_sys_ids, {'d1', 'd2'})
self.assertEqual(book.deals['d1']['trade_date'], '2026-09-01')
self.assertEqual(book.deals['d2']['volume'], 60)
self.assertEqual(book.deals['d2']['order_local_id'], 'zt-base-order1')
book = State(self.path)
self.assertEqual(book.deals_sys_ids, {'d1', 'd2'})
self.assertEqual(book.deals['d1']['order_local_id'], 'zt-base-order1')
with patch.object(book, '_connect') as connect:
book.sync_deals([first, second])
book.sync_deals([])
connect.assert_not_called()
self.assertEqual(len(book.deals), 2)
def test_sync_deals_failure_rolls_back_entire_batch_and_cache(self):
book = State(self.path)
first = self.deal('base', 'd1', 100, 10)
invalid = self.deal('base', 'd2', 100, 10)
invalid.offset_flag = -1
with self.assertRaises(sqlite3.IntegrityError):
book.sync_deals([first, invalid])
self.assertEqual(book.deals, {})
self.assertEqual(book.deals_sys_ids, set())
self.assertEqual(State(self.path).deals, {})
invalid.offset_flag = FLAG_BUY
book.sync_deals([first, invalid])
self.assertEqual(book.deals_sys_ids, {'d1', 'd2'})
def test_load_refreshes_all_caches(self):
book = State(self.path)
writer = State(self.path)
writer.sync_state([PositionItem(stock_code='600000.SH', volume=100)])
writer.sync_deals([self.deal('base', 'd1', 100, 10)])
book.load()
self.assertEqual(book.state['600000.SH']['base_qty'], 100)
self.assertEqual(book.deals_sys_ids, {'d1'})
self.assertEqual(book.deals['d1']['remark'], 'zt-base-order1|zt')
def cancelled(client):
return [call.args[0] for call in client.cancel_by_id.call_args_list]
class CancelScopeTests(unittest.TestCase):
"""撤单必须限本策略前缀;防重则继续看全账户在途。"""
def test_zt_prefix_cancels_only_its_own_orders(self):
orders = [order(0, 'zt-base-own'), order(1, 'zt-SELL-own'),
order(2, 'zt-entry-own'), order(3, 'IPO-new'),
order(4, ''), order(5, 'TREN-BUY-other')]
client = Mock()
book = OrderBook()
book.refresh(client, orders, cancel_prefix='zt-')
self.assertEqual(cancelled(client), ['sys0', 'sys1', 'sys2'])
self.assertEqual(book.data, orders) # 撤单后仍保留在途锁
self.assertTrue(all(book.busy(o.stock_code, 'BUY') for o in orders))
def test_default_prefix_still_cancels_every_non_ipo_order(self):
orders = [order(0, 'zt-base-own'), order(1, 'TREN-BUY-other'),
order(2, 'IPO-new'), order(3, '')]
client = Mock()
OrderBook().refresh(client, orders)
self.assertEqual(cancelled(client), ['sys0', 'sys1', 'sys3'])
def test_fresh_and_unreportable_orders_are_never_cancelled(self):
# 48未报在跟踪集合内但不可撤刚提交的委托也不撤。
orders = [order(0, 'zt-entry-fresh', age_minutes=0),
order(1, 'zt-entry-filled', status=56),
order(2, 'zt-entry-unreported', status=48)]
client = Mock()
book = OrderBook()
book.refresh(client, orders, cancel_prefix='zt-')
client.cancel_by_id.assert_not_called()
self.assertEqual(book.data, orders)
def test_position_columns_defaults_indexes_and_stable_id(self):
store = State(self.path)
store.sync_deals([self.deal('base', 'd1', 100, 10)])
saved_deals = dict(store.deals)
with closing(sqlite3.connect(self.path)) as db:
columns = {row[1] for row in db.execute('PRAGMA table_info(state)')}
self.assertEqual(columns, {'id', *(field.name for field in fields(StateItem))})
indexes = {row[1] for row in db.execute('PRAGMA index_list(state)')}
self.assertEqual(indexes, {'idx_state_stock_code'})
position = PositionItem(stock_code='600000.SH', volume=100, open_price=10,
stock_name='stock', can_use_volume=100, float_profit=-2.5)
store.sync_state([position])
saved = store.state[position.stock_code]
first_id = saved['id']
self.assertEqual(saved['base_qty'], 100)
self.assertEqual(saved['base_price'], 10)
self.assertEqual(saved['added_qty'], 0)
self.assertEqual(saved['base_order_local_id'], '')
self.assertTrue(saved['base_created_at'])
position.volume = 200
position.open_price = 12
store.sync_state([position])
self.assertEqual(store.state[position.stock_code]['id'], first_id)
self.assertEqual(store.state[position.stock_code], saved)
self.assertEqual(State(self.path).state[position.stock_code], saved)
store.sync_state([position, PositionItem(stock_code='600001.SH', volume=100)])
self.assertEqual(store.state[position.stock_code], saved)
self.assertEqual(store.state['600001.SH']['base_qty'], 100)
position.volume = 0
store.sync_state([position, PositionItem(stock_code='600002.SH')])
self.assertEqual(store.state, {})
self.assertEqual(State(self.path).state, {})
store.sync_state([PositionItem(stock_code='600001.SH', volume=100)])
self.assertGreater(store.state['600001.SH']['id'], first_id)
store.sync_state([])
self.assertEqual(store.state, {})
self.assertEqual(store.deals, saved_deals)
class BusyLockTests(unittest.TestCase):
def test_only_busy_statuses_lock_a_direction(self):
book = OrderBook()
client = Mock()
book.refresh(client, [order(0, 'zt-entry-a', status=50)], cancel_prefix='zt-')
self.assertTrue(book.busy('600000.SH', 'BUY'))
book.refresh(client, [order(1, 'zt-entry-b', status=56)], cancel_prefix='zt-')
self.assertFalse(book.busy('600001.SH', 'BUY'))
def test_state_fields_survive_restart_and_sync(self):
book = State(self.path)
row = asdict(StateItem(
stock_code='600000.SH', status='READY',
base_order_local_id='base-1', base_qty=100, base_price=10,
base_created_at='2026-09-08T09:30:00',
added_order_local_id='added-1', added_qty=50, added_price=9,
added_created_at='2026-09-08T10:30:00',
))
with closing(book._connect()) as db, db:
db.execute(
f"INSERT INTO state ({', '.join(row)}) VALUES ({', '.join(':' + key for key in row)})",
row,
)
book.load()
saved = book.state[row['stock_code']]
self.assertEqual({k: v for k, v in saved.items() if k != 'id'}, row)
book = State(self.path)
book.sync_state([PositionItem(stock_code=row['stock_code'], volume=150, open_price=9.5)])
self.assertEqual(book.state[row['stock_code']], saved)
with self.assertRaises(sqlite3.IntegrityError):
with closing(book._connect()) as db, db:
db.execute('UPDATE state SET added_qty = -1')
self.assertEqual(State(self.path).state[row['stock_code']], saved)
def test_busy_and_tracked_status_sets_are_disjoint_as_designed(self):
self.assertNotIn('56', BUSY_STATUSES)
self.assertTrue(BUSY_STATUSES <= TRACKED_STATUSES)
self.assertIn('56', TRACKED_STATUSES)
def test_place_marks_the_direction_busy_before_submitting(self):
book = OrderBook()
book.busy_cache.set('BUY-600000.SH', True, timeout=180)
self.assertTrue(book.busy('600000.SH', 'BUY'))
self.assertFalse(book.busy('600000.SH', 'SELL'))
def test_unknown_offset_flag_never_places_an_order(self):
from libs.order import PlaceOrderRequest
book = OrderBook()
client = Mock()
self.assertFalse(book.place(client, PlaceOrderRequest(99, '600000.SH', 100,
'zt-x', 'zt')))
client.passorder.assert_not_called()
if __name__ == '__main__':

View File

@@ -1,75 +0,0 @@
import sqlite3
import tempfile
import unittest
from dataclasses import asdict
from pathlib import Path
from unittest.mock import patch
from libs.state import FLAG_BUY, State
from sdk import DealItem, PositionItem
class StateStorageTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.store = State(Path(tmp.name) / 'state.db')
def deal(self, identity='first'):
return DealItem(
stock_code='600000.SH', order_sys_id=identity,
remark=f'zt-base-{identity}|zt', offset_flag=FLAG_BUY,
volume=100, price=10, trade_amount=1000,
trade_date='20260912', trade_time='100000',
)
def test_load_failure_does_not_publish_partial_cache(self):
writer = State(self.store.path)
writer.sync_state([PositionItem(stock_code='600000.SH', volume=100)])
with patch.object(self.store, '_read_deals', side_effect=sqlite3.OperationalError('read failed')):
with self.assertRaises(sqlite3.OperationalError):
self.store.load()
self.assertEqual((self.store.state, self.store.deals, self.store.deals_sys_ids), ({}, {}, set()))
self.store.load()
self.assertEqual(self.store.state['600000.SH']['base_qty'], 100)
def test_cache_read_failure_rolls_back_archive_and_can_retry(self):
self.store.sync_deals([self.deal()])
with patch.object(self.store, '_read_deals', side_effect=sqlite3.OperationalError('read failed')):
with self.assertRaises(sqlite3.OperationalError):
self.store.archiving()
restarted = State(self.store.path)
self.assertEqual(restarted.state, {})
self.assertEqual(restarted.deals['first']['is_arch'], 0)
self.assertEqual(self.store.deals, restarted.deals)
self.store.archiving()
self.assertEqual(self.store.state['600000.SH']['base_qty'], 100)
self.assertEqual(self.store.deals['first']['is_arch'], 1)
def test_invalid_snapshot_preserves_existing_holdings(self):
self.store.sync_state([PositionItem(stock_code='600000.SH', volume=100)])
saved = self.store.state
with self.assertRaises(ValueError):
self.store.sync_state([PositionItem(stock_code='600001.SH', volume=100, open_price=float('inf'))])
self.assertEqual(self.store.state, saved)
self.assertEqual(State(self.store.path).state, saved)
def test_normalization_preserves_input_and_rejects_nonfinite_price(self):
deal = self.deal()
deal.trade_amount = 0
original = asdict(deal)
self.store.sync_deals([deal])
self.assertEqual(asdict(deal), original)
self.assertEqual(self.store.deals['first']['trade_amount'], 1000)
self.assertEqual(self.store.deals['first']['trade_date'], '2026-09-12')
for price in (float('inf'), float('-inf'), float('nan')):
with self.subTest(price=price):
invalid = self.deal('invalid')
invalid.price = price
with self.assertRaises(ValueError):
self.store.sync_deals([self.deal('second'), invalid])
self.assertEqual(State(self.store.path).deals_sys_ids, {'first'})
if __name__ == '__main__':
unittest.main()

View File

@@ -1,86 +0,0 @@
import tempfile
import unittest
from dataclasses import replace
from datetime import datetime, timedelta
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import Mock, patch
from libs.grid_take_profit import GridState
from libs.order import OrderBook
from libs.snapshot import get_collector_snapshot
from libs.state import State
from sdk import Assets, OrderItem, PositionItem
from strategy.zt import boot
from strategy.zt.profit import ZTProfitTracker
class ZTAuditFixTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.store = State(Path(tmp.name) / 'state.db')
self.code = '600000.SH'
def position(self, code):
return PositionItem(stock_code=code, volume=100, open_price=10)
def test_zt_cancels_only_owned_orders_and_tracks_all(self):
stamp = datetime.now() - timedelta(minutes=2)
orders = [OrderItem(stock_code=f'60000{i}.SH', order_sys_id=str(i), remark=remark,
order_status=50, offset_flag=23,
insert_date=stamp.strftime('%Y%m%d'), insert_time=stamp.strftime('%H%M%S'))
for i, remark in enumerate(['zt-base-own|zt', 'zt-SELL-own|zt',
'zt-added-own|zt', 'IPO-new|ipo', '', 'TREN-BUY-other'])]
client = Mock()
book = OrderBook()
book.refresh(client, orders, cancel_prefix='zt-')
self.assertEqual([c.args[0] for c in client.cancel_by_id.call_args_list], ['0', '1', '2'])
self.assertEqual(book.data, orders)
self.assertTrue(all(book.busy(o.stock_code, 'BUY') for o in orders))
client.reset_mock()
book.refresh(client, orders)
self.assertEqual([c.args[0] for c in client.cancel_by_id.call_args_list], ['0', '1', '2', '4', '5'])
def test_profit_basis_changes_reset_peak_but_partial_sell_does_not(self):
tracker = ZTProfitTracker()
position = self.position(self.code)
row = dict(base_qty=100, base_order_local_id='one', base_created_at='now', added_qty=0)
state = NS(blocked_codes=set(), get_by_code=lambda code: row)
tracker.sync_positions([position], state)
self.assertEqual(tracker.observe(self.code, 20).state, GridState.ARMED)
row['base_qty'] = 50
tracker.sync_positions([replace(position, volume=50)], state)
self.assertEqual(tracker.observe(self.code, 19).state, GridState.RETREAT)
for update, cost in [({'added_qty': 100, 'added_price': 11}, 10),
({'added_qty': 0}, 10), ({}, 12),
({'base_order_local_id': 'reopened'}, 12)]:
row.update(update)
tracker.sync_positions([replace(position, open_price=cost)], state)
self.assertEqual(tracker.observe(self.code, 10).state, GridState.ARMED)
tracker.sync_positions([], state)
tracker.sync_positions([position], state)
self.assertEqual(tracker.observe(self.code, 5).state, GridState.ARMED)
def test_run_once_updates_collector_before_market_fetch(self):
positions = [self.position(self.code)]
self.store.sync_state(positions)
client = Mock()
run = NS(client=client, account_cfg=NS(account_id='zt-test', min_cash_ratio=0.1),
orders=Mock(), profit_tracker=ZTProfitTracker())
client.deals.return_value = []
client.portfolio.return_value = NS(assets=Assets(20000, 10000),
positions={self.code: positions[0]}, orders=[])
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(run, self.store, [])
snapshot = get_collector_snapshot()
self.assertEqual(snapshot[0], 'zt-test')
self.assertEqual(snapshot[1].total, 20000)
self.assertEqual(snapshot[2], positions)
run.orders.refresh.assert_called_once_with(client, [], cancel_prefix='zt-')
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,475 @@
"""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()

View File

@@ -0,0 +1,104 @@
"""ZT 配置:手数、中性带、最长持有天数与开关的校验。"""
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
import yaml
import config
from config import AccountConfig, GlobalConfig, SignalConfig
from strategy.zt import boot
class ZTConfigTests(unittest.TestCase):
def zt_config(self, directory, **overrides):
root = Path(directory)
(root / '_global.yaml').write_text(yaml.safe_dump({
'qmt_base_url': 'unused', 'api_host': 'unused',
'qmt_data_dir': directory, 'hosts': {'test': 'account'},
}), encoding='utf-8')
account = dict(buy_value=1000, strategy='zt', signal_allow=['dcm'])
account.update(overrides)
(root / 'account.yaml').write_text(yaml.safe_dump(account), encoding='utf-8')
return root
def test_config_accepts_only_nonnegative_integer_hands(self):
with tempfile.TemporaryDirectory() as directory, \
patch.object(config, 'global_config'), patch.object(config, 'account_config'):
for hands in (None, 0, 3, -1, 1.5, '3', True):
with self.subTest(hands=hands):
account = dict(buy_value=1000, strategy='zt', signal_allow=['dcm'])
if hands is not None:
account['zt_open_hands'] = hands
root = self.zt_config(directory, **{k: v for k, v in
account.items()
if k not in ('buy_value',
'strategy',
'signal_allow')})
if hands is None or type(hands) is int and hands >= 0:
_, loaded = config.load(root, 'test')
self.assertEqual(loaded.zt_open_hands, hands or 0)
else:
with self.assertRaisesRegex(ValueError, 'zt_open_hands'):
config.load(root, 'test')
def test_t_band_and_hold_days_defaults_and_validation(self):
with tempfile.TemporaryDirectory() as directory, \
patch.object(config, 'global_config'), patch.object(config, 'account_config'):
_, loaded = config.load(self.zt_config(directory), 'test')
self.assertEqual(loaded.zt_t_band_pct, 1.0)
self.assertEqual(loaded.zt_max_hold_days, 5)
for band, valid in ((0, True), (0.5, True), (1.0, True), (-1, False)):
with self.subTest(band=band):
root = self.zt_config(directory, zt_t_band_pct=band)
if valid:
_, loaded = config.load(root, 'test')
self.assertEqual(loaded.zt_t_band_pct, band)
else:
with self.assertRaisesRegex(ValueError, 'zt_t_band_pct'):
config.load(root, 'test')
for days, valid in ((1, True), (5, True), (0, False), (-1, False),
(1.5, False), ('5', False), (True, False)):
with self.subTest(days=days):
root = self.zt_config(directory, zt_max_hold_days=days)
if valid:
_, loaded = config.load(root, 'test')
self.assertEqual(loaded.zt_max_hold_days, days)
else:
with self.assertRaisesRegex(ValueError, 'zt_max_hold_days'):
config.load(root, 'test')
def test_zero_hands_does_not_initialize_strategy(self):
with patch.object(config, 'account_config', AccountConfig()), \
patch.object(boot, 'Client') as client, \
patch.object(boot, '_open_store') as store, \
patch.object(boot, 'init_signals') as signals:
boot.StartZT()
for dependency in (client, store, signals):
dependency.assert_not_called()
def test_unknown_account_key_is_rejected_with_a_clear_error(self):
with tempfile.TemporaryDirectory() as directory, \
patch.object(config, 'global_config'), patch.object(config, 'account_config'):
root = self.zt_config(directory, zt_sell_ratios=0.5) # 拼错的名字
with self.assertRaisesRegex(ValueError, 'zt_sell_ratios'):
config.load(root, 'test')
class SignalConfigTests(unittest.TestCase):
def test_signal_defaults(self):
item = SignalConfig()
self.assertEqual((item.url, item.timezone), ('', '*'))
self.assertFalse(item.gt_last_price_is_open)
self.assertEqual(GlobalConfig().signals, {})
def test_zero_hands_is_the_off_switch(self):
self.assertEqual(AccountConfig().zt_open_hands, 0)
if __name__ == '__main__':
unittest.main()

View File

@@ -1,91 +0,0 @@
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import Mock, patch
import yaml
import config
from config import AccountConfig, GlobalConfig, SignalConfig
from sdk import Tick
from strategy.zt import boot
from strategy.zt.open import open_signal
from strategy.zt.positions import handle_loss
class ZTOpenHandsTests(unittest.TestCase):
def runtime(self, hands):
run = NS(
account_cfg=AccountConfig(zt_open_hands=hands, buy_value=10000, strategy='zt'),
global_cfg=GlobalConfig(signals={'dcm': SignalConfig()}),
client=Mock(), orders=Mock(), open_watch=Mock(), add_watch=Mock(),
)
run.orders.busy.return_value = False
run.orders.place.return_value = True
run.open_watch.triggered.return_value = True
run.add_watch.triggered.return_value = True
return run
def test_open_and_add_use_same_hands_at_different_prices(self):
run = self.runtime(3)
code = '600000.SH'
for price in (8, 12):
with self.subTest(price=price):
open_signal(run, {code: Tick(last_price=price)},
[NS(code=code, signal_key='dcm', last_close=10)])
self.assertEqual(run.orders.place.call_args.args[1].volume, 300)
decision = handle_loss(run, code, 100, Tick(last_price=price), -20, 5000)
self.assertTrue(decision.submitted)
self.assertEqual(run.orders.place.call_args.args[1].volume, 300)
self.assertEqual(decision.reserved_cash, price * 300)
def test_add_does_not_reduce_hands_when_cash_is_insufficient(self):
run = self.runtime(3)
decision = handle_loss(run, '600000.SH', 100, Tick(last_price=10), -20, 2999)
self.assertFalse(decision.submitted)
run.orders.place.assert_not_called()
def test_zero_hands_does_not_initialize_strategy(self):
with patch.object(config, 'account_config', AccountConfig()), \
patch.object(boot, 'Client') as client, \
patch.object(boot, 'State') as state, \
patch.object(boot, 'ThreadPoolExecutor') as executor, \
patch.object(boot, 'init_signals') as signals:
boot.StartZT()
for dependency in (client, state, executor, signals):
dependency.assert_not_called()
def test_zero_hands_never_submits_open_or_add_orders(self):
run = self.runtime(0)
code = '600000.SH'
open_signal(run, {code: Tick(last_price=10)},
[NS(code=code, signal_key='dcm', last_close=10)])
decision = handle_loss(run, code, 100, Tick(last_price=10), -20, 10000)
self.assertFalse(decision.submitted)
run.orders.place.assert_not_called()
def test_config_accepts_only_nonnegative_integer_hands(self):
with tempfile.TemporaryDirectory() as directory, \
patch.object(config, 'global_config'), patch.object(config, 'account_config'):
root = Path(directory)
(root / '_global.yaml').write_text(yaml.safe_dump({
'qmt_base_url': 'unused', 'api_host': 'unused',
'qmt_data_dir': directory, 'hosts': {'test': 'account'},
}), encoding='utf-8')
for hands in (None, 0, 3, -1, 1.5, '3', True):
with self.subTest(hands=hands):
account = dict(buy_value=1000, strategy='zt', signal_allow=['dcm'])
if hands is not None:
account['zt_open_hands'] = hands
(root / 'account.yaml').write_text(yaml.safe_dump(account), encoding='utf-8')
if hands is None or type(hands) is int and hands >= 0:
_, loaded = config.load(root, 'test')
self.assertEqual(loaded.zt_open_hands, hands or 0)
else:
with self.assertRaisesRegex(ValueError, 'zt_open_hands'):
config.load(root, 'test')
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,177 @@
"""ZT 归属过滤:非本策略成交通知不得进入账本,也不得中断策略。"""
import logging
import unittest
from sdk import DealItem
from strategy.zt import boot
from strategy.zt.ownership import OWNED_PREFIX, owned_deals, owns_local_order_id
from strategy.zt.rounds import RoundStore, start_round
from tests.zt_harness import Fixture
TODAY = '2026-09-15'
class OwnershipPredicateTests(unittest.TestCase):
def test_only_local_order_ids_generated_by_zt_are_owned(self):
owned = ['zt-base-8e9da97a42e957408489', 'zt-added-9239083181eb39712994',
'zt-entry-0b10c994b8242682983f', 'zt-exit-1']
foreign = ['', None, ' ', 'TREN-BUY-1', 'MORN-2', 'IPO-abc', 'DCM-3',
'zt', 'azt-base-1', 'ztbase-1']
for value in owned:
with self.subTest(value=value):
self.assertTrue(owns_local_order_id(value))
for value in foreign:
with self.subTest(value=value):
self.assertFalse(owns_local_order_id(value))
self.assertEqual(OWNED_PREFIX, 'zt-')
def test_owned_deals_splits_and_counts(self):
def deal(remark):
return DealItem(stock_code='600000.SH', order_sys_id=remark or 'none',
remark=remark)
deals = [deal('zt-entry-a'), deal(''), deal('TREN-BUY-1'), deal('zt-exit-b')]
owned, ignored = owned_deals(deals)
self.assertEqual([d.get_local_order_id for d in owned],
['zt-entry-a', 'zt-exit-b'])
self.assertEqual(ignored, 2)
class ForeignDealIsolationTests(unittest.TestCase):
"""手工单与其他策略单既不进轮次,也不影响本策略的判断。"""
def setUp(self):
self.fx = Fixture()
self.addCleanup(self.fx.cleanup)
logging.disable(logging.CRITICAL)
self.addCleanup(logging.disable, logging.NOTSET)
self.fx.hold('600000.SH', volume=1000, price=10.0)
self.fx.quote('600000.SH', 10.0)
def run_tick(self):
self.fx.tick()
return self.fx.store.get('600000.SH')
def test_manual_deal_without_remark_neither_raises_nor_blocks(self):
self.fx.open_round()
self.fx.deals = [DealItem(stock_code='600000.SH', order_sys_id='m1',
remark='', offset_flag=48, volume=100, price=9.0,
trade_amount=900.0, trade_date='20260915',
trade_time='100000')]
item = self.run_tick() # 旧实现在这里抛 IntegrityError
self.assertEqual(item.entry_filled_qty, 0)
self.assertEqual(item.outcome, 'aborted') # 开仓腿无成交且已不在途
self.assertEqual(self.fx.placed, [])
def test_foreign_strategy_deal_cannot_touch_the_round(self):
self.fx.open_round()
self.fx.deals = [DealItem(stock_code='600000.SH', order_sys_id='t1',
remark='TREN-BUY-9|trend', offset_flag=48,
volume=500, price=20.0, trade_amount=10000.0,
trade_date='20260915', trade_time='100000')]
item = self.run_tick() # 旧实现把外部买入当补仓写进 added 桶
self.assertEqual(item.entry_filled_qty, 0)
self.assertEqual(item.entry_amount, 0.0)
self.assertEqual(item.base_qty, 1000)
def test_owned_deals_are_still_counted(self):
self.fx.open_round()
self.fx.deals = [self.fx.deal('zt-entry-1', 300, 9.0)]
item = self.run_tick()
self.assertEqual(item.entry_filled_qty, 300)
self.assertEqual(item.entry_avg_price, 9.0)
# 现价 10.0 对买入均价 9.0 已超过一个网格步长,同一轮 tick 内即挂出卖单。
self.assertEqual(item.phase, 'CLOSING')
self.assertEqual([p['stock_code'] for p in self.fx.placed], ['600000.SH'])
self.assertTrue(self.fx.placed[0]['order_id'].startswith('zt-exit-'))
def test_mixed_batch_keeps_only_owned_deals(self):
self.fx.open_round()
self.fx.deals = [
self.fx.deal('zt-entry-1', 100, 9.0, sys_id='own'),
DealItem(stock_code='600000.SH', order_sys_id='manual', remark='',
offset_flag=48, volume=100, price=9.0, trade_amount=900.0,
trade_date='20260915', trade_time='100000'),
DealItem(stock_code='600000.SH', order_sys_id='trend',
remark='TREN-BUY-1', offset_flag=48, volume=100, price=9.0,
trade_amount=900.0, trade_date='20260915', trade_time='100000'),
]
item = self.run_tick()
self.assertEqual(item.entry_filled_qty, 100)
self.assertEqual(len(item.seen_deal_ids), 1)
def test_repeated_ticks_never_double_count(self):
self.fx.open_round()
self.fx.deals = [self.fx.deal('zt-entry-1', 300, 9.0)]
self.assertEqual(self.run_tick().entry_filled_qty, 300)
self.assertEqual(self.run_tick().entry_filled_qty, 300)
class RunOnceResilienceTests(unittest.TestCase):
"""任何单点失败都只能跳过本轮,不能打断唯一的交易定时线程。"""
def setUp(self):
self.fx = Fixture()
self.addCleanup(self.fx.cleanup)
logging.disable(logging.CRITICAL)
self.addCleanup(logging.disable, logging.NOTSET)
self.fx.hold('600000.SH', volume=1000, price=10.0)
self.fx.quote('600000.SH', 10.0)
def test_snapshot_failure_skips_the_round_quietly(self):
from unittest.mock import patch
with patch.object(boot, 'trading_time', return_value=True):
self.fx.client.portfolio.side_effect = RuntimeError('api down')
boot.RunOnce(self.fx.run, self.fx.store, [])
self.assertEqual(self.fx.placed, [])
def test_round_advance_failure_skips_trading(self):
from unittest.mock import patch
with patch.object(boot, 'trading_time', return_value=True), \
patch.object(boot, '_advance_rounds', side_effect=RuntimeError('broken')):
boot.RunOnce(self.fx.run, self.fx.store, [])
self.assertEqual(self.fx.placed, [])
def test_market_data_failure_skips_trading(self):
from unittest.mock import patch
with patch.object(boot, 'trading_time', return_value=True):
self.fx.client.full_tick.side_effect = RuntimeError('no ticks')
boot.RunOnce(self.fx.run, self.fx.store, [])
self.assertEqual(self.fx.placed, [])
def test_startup_state_failure_closes_client_without_raising(self):
from types import SimpleNamespace as NS
from unittest.mock import patch
client = self.fx.client
with patch.object(boot.config, 'account_config', self.fx.account_cfg), \
patch.object(boot.config, 'global_config',
NS(qmt_data_dir=str(self.fx.path), qmt_base_url='u',
qmt_token='', api_host='u')), \
patch.object(boot, 'Client', return_value=client), \
patch.object(boot, '_open_store', side_effect=RuntimeError('disk')):
boot.StartZT() # 不抛异常
client.close.assert_called_once()
def test_corrupt_state_is_backed_up_and_rebuilt(self):
from types import SimpleNamespace as NS
from unittest.mock import patch
path = self.fx.rounds_path
path.write_text('{not json', encoding='utf-8')
client = self.fx.client
with patch.object(boot.config, 'account_config', self.fx.account_cfg), \
patch.object(boot.config, 'global_config',
NS(qmt_data_dir=str(self.fx.path), qmt_base_url='u',
qmt_token='', api_host='u')) , \
patch.object(boot, 'Client', return_value=client), \
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()
self.assertTrue(path.with_name(path.name + '.corrupt').is_file())
self.assertEqual(RoundStore(path).rounds, {})
client.close.assert_called_once()
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,420 @@
"""ZT 轮次状态:幂等成交累计、阶段推进、跨日配额、超期放弃、持久化。"""
import json
import tempfile
import unittest
from pathlib import Path
from sdk import DealItem, OrderItem, PositionItem
from strategy.zt.rounds import (
BASE_SOURCE_OPENED,
KIND_LONG_T,
KIND_SHORT_T,
OUTCOME_ABORTED,
OUTCOME_BASE,
OUTCOME_EXPIRED,
OUTCOME_NORMAL,
PHASE_CLOSED,
PHASE_CLOSING,
PHASE_IDLE,
PHASE_OPEN,
PHASE_OPENING,
Round,
RoundStore,
RoundStoreError,
advance,
apply_deals,
expire,
in_flight_order_ids,
is_owned_base,
new_base_round,
new_round,
start_round,
)
TODAY = '2026-09-15'
def deal(order_sys_id, remark, volume=100, price=10.0):
return DealItem(stock_code='600000.SH', order_sys_id=order_sys_id, remark=remark,
offset_flag=48, volume=volume, price=price,
trade_amount=price * volume,
trade_date='20260915', trade_time='100000')
def order(local_id, status):
return OrderItem(stock_code='600000.SH', order_sys_id=local_id, remark=local_id,
offset_flag=48, order_status=status,
insert_date='20260915', insert_time='100000')
class RoundModelTests(unittest.TestCase):
def test_directions_are_mirrored_between_long_and_short_t(self):
long_t = Round(code='600000.SH', kind=KIND_LONG_T)
short_t = Round(code='600000.SH', kind=KIND_SHORT_T)
self.assertEqual((long_t.entry_side, long_t.exit_side), ('BUY', 'SELL'))
self.assertEqual((short_t.entry_side, short_t.exit_side), ('SELL', 'BUY'))
def test_residual_and_average_prices(self):
item = Round(code='600000.SH', kind=KIND_LONG_T,
entry_filled_qty=200, entry_amount=2000.0,
exit_filled_qty=100, exit_amount=1100.0)
self.assertEqual(item.residual_qty, 100)
self.assertAlmostEqual(item.entry_avg_price, 10.0)
self.assertAlmostEqual(item.exit_avg_price, 11.0)
self.assertEqual(Round().entry_avg_price, 0.0)
def test_daily_quota_and_cross_day_recovery(self):
item = Round(code='600000.SH')
self.assertTrue(item.can_open(TODAY))
item.open_date = TODAY # 今天已开过一轮
self.assertFalse(item.can_open(TODAY))
item.open_date = '2026-09-14'
item.phase = PHASE_OPEN # 昨日未平的轮次继续持有
self.assertFalse(item.can_open(TODAY))
item.phase = PHASE_CLOSED
self.assertTrue(item.can_open(TODAY))
item.last_trade_date = TODAY # 今天已有腿成交
self.assertFalse(item.can_open(TODAY))
item.last_trade_date = '2026-09-14' # 昨日成交,今天可以做一轮
self.assertTrue(item.can_open(TODAY))
def test_new_round_keeps_the_established_base(self):
item = new_round('600000.SH', KIND_LONG_T, TODAY, 500, 26.89,
base_date='2026-09-10', base_source='opened')
self.assertEqual(item.phase, PHASE_OPENING)
self.assertEqual((item.base_qty, item.base_cost), (500, 26.89))
self.assertEqual((item.base_date, item.base_source), ('2026-09-10', 'opened'))
class ApplyDealsTests(unittest.TestCase):
def test_repeated_sync_never_double_counts(self):
item = Round(code='600000.SH', kind=KIND_LONG_T, entry_order_id='zt-base-1')
batch = [deal('s1', 'zt-base-1'), deal('s2', 'zt-base-1')]
apply_deals(item, batch, TODAY)
self.assertEqual(item.entry_filled_qty, 200)
self.assertAlmostEqual(item.entry_amount, 2000.0)
apply_deals(item, batch, TODAY) # 同一批再次同步
self.assertEqual(item.entry_filled_qty, 200)
apply_deals(item, batch + [deal('s3', 'zt-base-1')], TODAY)
self.assertEqual(item.entry_filled_qty, 300)
self.assertEqual(item.last_trade_date, TODAY)
def test_only_this_rounds_legs_are_counted(self):
item = Round(code='600000.SH', kind=KIND_LONG_T,
entry_order_id='zt-base-1', exit_order_id='zt-SELL-1')
apply_deals(item, [
deal('s1', 'zt-base-1'),
deal('s2', ''), # 手工单
deal('s3', 'TREN-BUY-1'), # 其他策略
deal('s4', 'zt-added-other'), # 本策略但不是本轮
deal('s5', 'zt-SELL-1', price=11.0),
], TODAY)
self.assertEqual(item.entry_filled_qty, 100)
self.assertEqual(item.exit_filled_qty, 100)
self.assertEqual(item.residual_qty, 0)
self.assertEqual(sorted(item.seen_deal_ids), ['s1', 's5'])
class AdvanceTests(unittest.TestCase):
def test_entry_fully_filled_moves_to_open(self):
item = Round(code='600000.SH', kind=KIND_LONG_T,
phase=PHASE_OPENING, entry_order_id='zt-base-1',
entry_filled_qty=300)
advance(item, {'other'}, TODAY)
self.assertEqual(item.phase, PHASE_OPEN)
self.assertEqual(item.residual_qty, 300)
def test_entry_still_in_flight_does_not_move(self):
item = Round(code='600000.SH', kind=KIND_LONG_T,
phase=PHASE_OPENING, entry_order_id='zt-base-1',
entry_filled_qty=100)
advance(item, {'zt-base-1'}, TODAY)
self.assertEqual(item.phase, PHASE_OPENING)
def test_aborted_entry_releases_the_daily_quota(self):
item = Round(code='600000.SH', kind=KIND_LONG_T, phase=PHASE_OPENING,
open_date=TODAY, entry_order_id='zt-base-1')
advance(item, set(), TODAY)
self.assertEqual(item.phase, PHASE_CLOSED)
self.assertEqual(item.outcome, OUTCOME_ABORTED)
self.assertEqual(item.open_date, '')
self.assertTrue(item.can_open(TODAY))
def test_partially_closed_round_returns_to_open(self):
item = Round(code='600000.SH', kind=KIND_LONG_T, phase=PHASE_CLOSING,
entry_order_id='zt-base-1', exit_order_id='zt-SELL-1',
entry_filled_qty=300, exit_filled_qty=100)
advance(item, set(), TODAY)
self.assertEqual(item.phase, PHASE_OPEN)
self.assertEqual(item.residual_qty, 200)
def test_fully_closed_round_finishes(self):
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_CLOSING,
open_date=TODAY, entry_order_id='zt-SELL-1', exit_order_id='zt-added-1',
entry_filled_qty=100, exit_filled_qty=100)
advance(item, set(), TODAY)
self.assertEqual(item.phase, PHASE_CLOSED)
self.assertEqual(item.outcome, OUTCOME_NORMAL)
self.assertEqual(item.close_date, TODAY)
self.assertEqual(item.open_date, TODAY) # 完成轮次占用当日配额
def test_overnight_round_keeps_its_open_date(self):
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_OPEN,
open_date='2026-09-14', entry_order_id='zt-SELL-1',
entry_filled_qty=100)
advance(item, set(), TODAY)
self.assertEqual(item.phase, PHASE_OPEN) # 仍待买回,允许隔夜
self.assertFalse(item.can_open(TODAY))
class BaseEstablishmentTests(unittest.TestCase):
def test_base_cost_comes_from_the_actual_fill(self):
item = new_base_round('600000.SH', TODAY, 300)
item.entry_order_id = 'zt-base-1'
apply_deals(item, [deal('s1', 'zt-base-1', volume=300, price=26.89)], TODAY)
advance(item, set(), TODAY)
self.assertEqual(item.phase, PHASE_CLOSED)
self.assertEqual(item.outcome, OUTCOME_BASE)
self.assertEqual(item.base_qty, 300)
self.assertAlmostEqual(item.base_cost, 26.89)
self.assertEqual(item.base_source, BASE_SOURCE_OPENED)
self.assertEqual(item.base_date, TODAY)
def test_partial_base_fill_is_accepted(self):
item = new_base_round('600000.SH', TODAY, 300)
item.entry_order_id = 'zt-base-1'
apply_deals(item, [deal('s1', 'zt-base-1', volume=100, price=26.0)], TODAY)
advance(item, set(), TODAY)
self.assertEqual((item.base_qty, item.base_cost), (100, 26.0))
def test_empty_base_fill_aborts_and_frees_the_quota(self):
item = new_base_round('600000.SH', TODAY, 300)
item.entry_order_id = 'zt-base-1'
advance(item, set(), TODAY)
self.assertEqual(item.outcome, OUTCOME_ABORTED)
self.assertEqual(item.open_date, '')
self.assertTrue(item.can_open(TODAY))
def test_base_round_settles_only_after_the_order_is_no_longer_in_flight(self):
item = new_base_round('600000.SH', TODAY, 300)
item.entry_order_id = 'zt-base-1'
apply_deals(item, [deal('s1', 'zt-base-1', volume=300, price=26.89)], TODAY)
advance(item, {'zt-base-1'}, TODAY)
self.assertEqual(item.phase, PHASE_OPENING)
self.assertEqual(item.base_qty, 0)
def test_adoption_is_not_supported(self):
# 程序不接管账户已有持仓Round 只认识自己建仓写下的基准。
item = Round(code='600000.SH', base_qty=500, base_cost=37.72,
base_source=BASE_SOURCE_OPENED)
self.assertTrue(is_owned_base(item))
for source in ('', 'adopted', 'configured'):
with self.subTest(source=source):
self.assertFalse(is_owned_base(Round(code='600000.SH', base_qty=500,
base_cost=37.72,
base_source=source)))
self.assertFalse(is_owned_base(Round(code='600000.SH')))
def test_apply_deals_reports_applied_fills_for_logging(self):
item = Round(code='600000.SH', kind=KIND_LONG_T,
entry_order_id='zt-entry-1', exit_order_id='zt-exit-1')
applied = apply_deals(item, [deal('s1', 'zt-entry-1'),
deal('s2', 'TREN-BUY-1'),
deal('s3', 'zt-exit-1')], TODAY)
self.assertEqual([leg for leg, _ in applied], ['entry', 'exit'])
self.assertEqual([entry.order_sys_id for _, entry in applied], ['s1', 's3'])
self.assertEqual(apply_deals(item, [deal('s1', 'zt-entry-1')], TODAY), [])
class StartRoundTests(unittest.TestCase):
"""开新轮必须清空上一轮的两条腿,否则残量会静默把本轮判成作废。"""
def closed_round(self):
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_CLOSED,
open_date='2026-09-14', close_date='2026-09-14',
outcome=OUTCOME_NORMAL, note='旧备注',
entry_order_id='e1', entry_plan_qty=500, entry_filled_qty=500,
entry_amount=5500.0, exit_order_id='x1', exit_plan_qty=500,
exit_filled_qty=500, exit_amount=4900.0,
seen_deal_ids=['s1', 's2'])
return item
def test_start_round_clears_both_legs_and_audit_fields(self):
item = self.closed_round()
start_round(item, KIND_LONG_T, TODAY)
self.assertEqual(item.phase, PHASE_OPENING)
self.assertEqual(item.kind, KIND_LONG_T)
self.assertEqual(item.open_date, TODAY)
self.assertEqual((item.close_date, item.outcome, item.note), ('', '', ''))
self.assertEqual((item.entry_order_id, item.exit_order_id), ('', ''))
self.assertEqual((item.entry_filled_qty, item.exit_filled_qty), (0, 0))
self.assertEqual((item.entry_amount, item.exit_amount), (0.0, 0.0))
self.assertEqual(item.seen_deal_ids, [])
self.assertEqual(item.residual_qty, 0)
def test_start_round_keeps_the_established_base(self):
item = self.closed_round()
item.base_qty, item.base_cost = 1000, 10.0
item.base_date, item.base_source = '2026-09-10', BASE_SOURCE_OPENED
start_round(item, KIND_SHORT_T, TODAY)
self.assertEqual((item.base_qty, item.base_cost), (1000, 10.0))
self.assertEqual((item.base_date, item.base_source),
('2026-09-10', BASE_SOURCE_OPENED))
def test_stale_exit_counter_cannot_abort_a_new_round(self):
# 复现:直接改字段开新轮,上一轮的 exit_filled_qty 让 residual 变负,
# advance 会判成作废并立刻重开一轮。
item = self.closed_round()
item.kind = KIND_LONG_T
item.phase = PHASE_OPENING
item.open_date = TODAY
item.entry_order_id = 'e2'
item.entry_filled_qty, item.entry_amount = 0, 0.0
self.assertEqual(item.residual_qty, -500)
advance(item, set(), TODAY)
self.assertEqual(item.phase, PHASE_CLOSED)
self.assertEqual(item.note, '成交累计异常:平仓量超过开仓量,本轮作废')
fixed = self.closed_round()
start_round(fixed, KIND_LONG_T, TODAY)
fixed.entry_order_id = 'e2'
apply_deals(fixed, [deal('s9', 'e2', volume=300, price=9.0)], TODAY)
advance(fixed, set(), TODAY)
self.assertEqual(fixed.phase, PHASE_OPEN)
self.assertEqual(fixed.residual_qty, 300)
def test_new_base_round_starts_clean(self):
item = new_base_round('600000.SH', TODAY, 300)
self.assertEqual(item.phase, PHASE_OPENING)
self.assertEqual(item.entry_plan_qty, 300)
self.assertEqual(item.base_qty, 0)
class ResidualAbsorptionTests(unittest.TestCase):
"""超期放弃必须把敞口并回底仓,否则会在裸敞口上继续开新轮。"""
def test_unclosed_short_t_leg_reduces_the_base(self):
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_OPEN,
open_date='2026-09-09', base_qty=1000, base_cost=10.0,
entry_filled_qty=500, entry_amount=5500.0)
self.assertTrue(expire(item, TODAY, 5))
self.assertEqual(item.base_qty, 500) # 卖出未买回,底仓变 500
self.assertAlmostEqual(item.base_cost, 10.0) # 成本仍是建仓价
self.assertEqual(item.residual_qty, 500) # 敞口数值保留在审计字段里
def test_unclosed_long_t_leg_increases_the_base(self):
item = Round(code='600000.SH', kind=KIND_LONG_T, phase=PHASE_OPEN,
open_date='2026-09-09', base_qty=1000, base_cost=10.0,
entry_filled_qty=300, entry_amount=2700.0)
self.assertTrue(expire(item, TODAY, 5))
self.assertEqual(item.base_qty, 1300)
def test_normal_completion_leaves_the_base_untouched(self):
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_CLOSING,
open_date=TODAY, base_qty=1000, base_cost=10.0,
entry_order_id='e1', exit_order_id='x1',
entry_filled_qty=500, entry_amount=5500.0,
exit_filled_qty=500, exit_amount=4900.0)
advance(item, set(), TODAY)
self.assertEqual(item.phase, PHASE_CLOSED)
self.assertEqual(item.base_qty, 1000)
def test_aborted_round_never_touches_the_base(self):
item = Round(code='600000.SH', kind=KIND_LONG_T, phase=PHASE_OPENING,
open_date=TODAY, base_qty=1000, base_cost=10.0,
entry_order_id='e1')
advance(item, set(), TODAY)
self.assertEqual(item.outcome, OUTCOME_ABORTED)
self.assertEqual(item.base_qty, 1000)
class ExpireTests(unittest.TestCase):
def test_round_beyond_max_hold_days_is_abandoned_not_forced(self):
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_OPEN,
open_date='2026-09-09', entry_filled_qty=100)
self.assertTrue(expire(item, TODAY, 5))
self.assertEqual(item.phase, PHASE_CLOSED)
self.assertEqual(item.outcome, OUTCOME_EXPIRED)
self.assertEqual(item.residual_qty, 100) # 残量留作隔夜,不强平
def test_round_within_the_limit_is_kept(self):
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_OPEN,
open_date='2026-09-14', entry_filled_qty=100)
self.assertFalse(expire(item, TODAY, 5))
self.assertEqual(item.phase, PHASE_OPEN)
def test_inactive_rounds_never_expire(self):
for phase in (PHASE_OPENING, PHASE_CLOSED):
with self.subTest(phase=phase):
item = Round(code='600000.SH', phase=phase, open_date='2020-01-01')
self.assertFalse(expire(item, TODAY, 5))
class InFlightTests(unittest.TestCase):
def test_only_busy_statuses_count_as_in_flight(self):
orders = [order(f'zt-o{i}', status) for i, status in
enumerate(['48', '49', '50', '51', '52', '55', '53', '54', '56', '57'])]
self.assertEqual(in_flight_order_ids(orders),
{'zt-o0', 'zt-o1', 'zt-o2', 'zt-o3', 'zt-o4', 'zt-o5'})
def test_empty_local_ids_are_ignored(self):
self.assertEqual(in_flight_order_ids([order('', '50')]), set())
class RoundStoreTests(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
self.path = Path(temp.name) / 'zt_rounds.json'
def test_roundtrip_survives_restart(self):
store = RoundStore(self.path)
item = new_round('600000.SH', KIND_SHORT_T, TODAY, 500, 26.89)
item.entry_order_id = 'zt-SELL-1'
item.entry_filled_qty = 300
item.entry_amount = 8067.0
item.seen_deal_ids = ['s1', 's2']
store.put(item)
store.save()
reloaded = RoundStore(self.path)
restored = reloaded.get('600000.SH')
self.assertEqual(restored, item)
self.assertEqual(restored.seen_deal_ids, ['s1', 's2'])
def test_missing_file_starts_empty_and_unknown_code_is_idle(self):
store = RoundStore(self.path)
self.assertEqual(store.rounds, {})
self.assertEqual(store.get('600000.SH').phase, PHASE_IDLE)
def test_save_leaves_no_temporary_file(self):
store = RoundStore(self.path)
store.put(Round(code='600000.SH'))
store.save()
self.assertEqual([p.name for p in self.path.parent.iterdir()],
['zt_rounds.json'])
def test_corrupt_or_foreign_state_raises_for_rebuild(self):
cases = {
'bad json': '{not json',
'wrong root': '[]',
'wrong item': '{"600000.SH": 3}',
'unknown field': json.dumps({'600000.SH': {'code': '600000.SH', 'zzz': 1}}),
}
for label, text in cases.items():
with self.subTest(label=label):
self.path.write_text(text, encoding='utf-8')
with self.assertRaises(RoundStoreError):
RoundStore(self.path)
def test_drop_removes_a_code(self):
store = RoundStore(self.path)
store.put(Round(code='600000.SH'))
store.drop('600000.SH')
store.drop('600001.SH')
self.assertEqual(store.rounds, {})
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,152 @@
"""ZT 正T/反T 规则:方向选择、手数与资金/库存封顶、买卖触发条件。"""
import unittest
from strategy.zt.rounds import KIND_LONG_T, KIND_SHORT_T
from strategy.zt.rules import (
choose_kind,
entry_triggered,
entry_volume,
exit_triggered,
exit_volume,
price_allowed,
)
BASE_COST = 10.0
class ChooseKindTests(unittest.TestCase):
def test_band_decides_the_direction(self):
self.assertEqual(choose_kind(9.0, BASE_COST, 1.0), KIND_LONG_T)
self.assertEqual(choose_kind(11.0, BASE_COST, 1.0), KIND_SHORT_T)
def test_neutral_band_does_nothing(self):
for price in (9.91, 10.0, 10.09):
with self.subTest(price=price):
self.assertIsNone(choose_kind(price, BASE_COST, 1.0))
def test_invalid_inputs_yield_no_direction(self):
for price, cost, band in ((0, BASE_COST, 1.0), (-1, BASE_COST, 1.0),
(9.0, 0, 1.0), (9.0, BASE_COST, -1)):
with self.subTest(price=price, cost=cost, band=band):
self.assertIsNone(choose_kind(price, cost, band))
def test_zero_band_picks_a_side_but_never_both(self):
self.assertEqual(choose_kind(9.99, BASE_COST, 0), KIND_LONG_T)
self.assertEqual(choose_kind(10.01, BASE_COST, 0), KIND_SHORT_T)
def test_price_cap(self):
self.assertTrue(price_allowed(199.0, 200.0))
self.assertFalse(price_allowed(200.01, 200.0))
self.assertFalse(price_allowed(0, 200.0))
class EntryVolumeTests(unittest.TestCase):
def test_long_t_uses_hands_and_is_capped_by_cash(self):
self.assertEqual(entry_volume(KIND_LONG_T, price=10.0, open_hands=3,
sell_ratio=0.5, base_qty=0,
can_use_volume=0, available=100000.0), 300)
self.assertEqual(entry_volume(KIND_LONG_T, price=10.0, open_hands=3,
sell_ratio=0.5, base_qty=0,
can_use_volume=0, available=2500.0), 200)
self.assertEqual(entry_volume(KIND_LONG_T, price=10.0, open_hands=3,
sell_ratio=0.5, base_qty=0,
can_use_volume=0, available=999.0), 0)
def test_long_t_never_forces_a_lot_when_cash_is_short(self):
# 与 calc_buy_volume 的 max(1, ...) 不同:这里买不起就不买。
self.assertEqual(entry_volume(KIND_LONG_T, price=1500.0, open_hands=1,
sell_ratio=0.5, base_qty=0,
can_use_volume=0, available=5000.0), 0)
def test_short_t_uses_ratio_and_is_capped_by_sellable_inventory(self):
self.assertEqual(entry_volume(KIND_SHORT_T, price=10.0, open_hands=3,
sell_ratio=0.5, base_qty=1000,
can_use_volume=1000, available=0.0), 500)
self.assertEqual(entry_volume(KIND_SHORT_T, price=10.0, open_hands=3,
sell_ratio=0.5, base_qty=1000,
can_use_volume=250, available=0.0), 200)
self.assertEqual(entry_volume(KIND_SHORT_T, price=10.0, open_hands=3,
sell_ratio=0.5, base_qty=1000,
can_use_volume=99, available=0.0), 0)
self.assertEqual(entry_volume(KIND_SHORT_T, price=10.0, open_hands=3,
sell_ratio=0.5, base_qty=100,
can_use_volume=100, available=0.0), 0)
def test_unknown_kind_or_bad_price_does_nothing(self):
self.assertEqual(entry_volume('???', price=10.0, open_hands=3, sell_ratio=0.5,
base_qty=100, can_use_volume=100, available=1e6), 0)
self.assertEqual(entry_volume(KIND_LONG_T, price=0.0, open_hands=3, sell_ratio=0.5,
base_qty=0, can_use_volume=0, available=1e6), 0)
class ExitVolumeTests(unittest.TestCase):
def test_long_t_exit_is_limited_by_sellable_inventory(self):
# 正T 当天买入的份额 T+1 才可卖:可卖为 0 时只能留成隔夜。
self.assertEqual(exit_volume(KIND_LONG_T, residual_qty=300, price=10.0,
can_use_volume=0, available=1e6), 0)
self.assertEqual(exit_volume(KIND_LONG_T, residual_qty=300, price=10.0,
can_use_volume=300, available=1e6), 300)
self.assertEqual(exit_volume(KIND_LONG_T, residual_qty=300, price=10.0,
can_use_volume=250, available=1e6), 200)
self.assertEqual(exit_volume(KIND_LONG_T, residual_qty=150, price=10.0,
can_use_volume=100, available=1e6), 100)
def test_short_t_exit_is_limited_by_cash(self):
self.assertEqual(exit_volume(KIND_SHORT_T, residual_qty=500, price=10.0,
can_use_volume=0, available=3000.0), 300)
self.assertEqual(exit_volume(KIND_SHORT_T, residual_qty=500, price=10.0,
can_use_volume=0, available=100000.0), 500)
self.assertEqual(exit_volume(KIND_SHORT_T, residual_qty=500, price=10.0,
can_use_volume=0, available=50.0), 0)
def test_nothing_to_close(self):
for residual in (0, -100):
with self.subTest(residual=residual):
self.assertEqual(exit_volume(KIND_LONG_T, residual_qty=residual, price=10.0,
can_use_volume=1000, available=1e6), 0)
class TriggerTests(unittest.TestCase):
def test_entry_needs_direction_plus_confirmation(self):
self.assertTrue(entry_triggered(KIND_LONG_T, 9.0, BASE_COST, band_pct=1.0,
rebound_confirmed=True, retrace_confirmed=False))
self.assertFalse(entry_triggered(KIND_LONG_T, 9.0, BASE_COST, band_pct=1.0,
rebound_confirmed=False, retrace_confirmed=True))
self.assertTrue(entry_triggered(KIND_SHORT_T, 11.0, BASE_COST, band_pct=1.0,
rebound_confirmed=False, retrace_confirmed=True))
# 方向与位置不符时即使确认也不触发
self.assertFalse(entry_triggered(KIND_SHORT_T, 9.0, BASE_COST, band_pct=1.0,
rebound_confirmed=True, retrace_confirmed=True))
self.assertFalse(entry_triggered(KIND_LONG_T, 10.0, BASE_COST, band_pct=1.0,
rebound_confirmed=True, retrace_confirmed=True))
def test_short_t_exit_needs_fall_and_rebound(self):
kwargs = dict(buy_fall_pct=1.0, profit_step_pct=1.0)
self.assertTrue(exit_triggered(KIND_SHORT_T, 9.8, 10.0,
rebound_confirmed=True, **kwargs))
self.assertFalse(exit_triggered(KIND_SHORT_T, 9.8, 10.0,
rebound_confirmed=False, **kwargs))
self.assertFalse(exit_triggered(KIND_SHORT_T, 9.95, 10.0,
rebound_confirmed=True, **kwargs))
def test_long_t_exit_needs_a_profit_step(self):
kwargs = dict(buy_fall_pct=1.0, profit_step_pct=1.0)
self.assertTrue(exit_triggered(KIND_LONG_T, 10.1, 10.0,
rebound_confirmed=False, **kwargs))
self.assertFalse(exit_triggered(KIND_LONG_T, 10.0, 10.0,
rebound_confirmed=True, **kwargs))
# 正T 平仓不看回落,回落到成本之下不卖
self.assertFalse(exit_triggered(KIND_LONG_T, 9.8, 10.0,
rebound_confirmed=True, **kwargs))
def test_missing_basis_never_triggers(self):
self.assertFalse(exit_triggered(KIND_LONG_T, 12.0, 0.0,
buy_fall_pct=1.0, profit_step_pct=1.0,
rebound_confirmed=True))
self.assertFalse(exit_triggered('???', 12.0, 10.0, buy_fall_pct=1.0,
profit_step_pct=1.0, rebound_confirmed=True))
if __name__ == '__main__':
unittest.main()

View File

@@ -1,110 +0,0 @@
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import Mock, patch
from libs.grid_take_profit import GridState
from libs.state import State
from sdk import Assets, DealItem, PositionItem, Tick
from strategy.zt import boot
from strategy.zt.positions import manage_positions
class ZTTradingTests(unittest.TestCase):
def setUp(self):
self.code = '600000.SH'
self.run = NS(account_cfg=NS(account_id='test', strategy='zt', buy_value=1000, zt_open_hands=1,
excluded_codes=[], enable_loss_add_position=False,
min_cash_ratio=0.1),
orders=Mock(), client=Mock(), profit_tracker=Mock(), add_watch=Mock())
self.run.orders.busy.return_value = False
self.run.orders.place.return_value = True
self.run.profit_tracker.observe.return_value.state = GridState.RETREAT
self.run.add_watch.triggered.return_value = True
def manage(self, added=0, usable=500, road=0, cost=10, added_cost=10, price=11):
position = PositionItem(stock_code=self.code, volume=1000, can_use_volume=usable,
on_road_volume=road, open_price=cost)
state = NS(blocked_codes=set(), get_by_code=lambda code: dict(
base_qty=500, added_qty=added, added_price=added_cost))
manage_positions(self.run, {self.code: Tick(last_price=price)}, [position], True, 1500, state)
def test_added_position_is_capped_by_sellable_inventory(self):
for added, usable, expected in [(500, 100, 100), (100, 500, 100), (0, 500, 500)]:
with self.subTest(added=added, usable=usable):
self.run.orders.place.reset_mock()
self.manage(added=added, usable=usable)
self.assertEqual(self.run.orders.place.call_args.args[1].volume, expected)
def test_zero_sellable_does_not_divide_by_default_added_cost(self):
with patch('strategy.zt.positions.log.exception') as error:
self.manage(usable=0, added_cost=0)
error.assert_not_called()
self.run.orders.place.assert_not_called()
def test_unavailable_shares_do_not_disable_loss_management(self):
self.run.account_cfg.enable_loss_add_position = True
self.manage(usable=0, cost=20, price=10, added_cost=0)
self.assertEqual(self.run.orders.place.call_args.args[1].op, 23)
def test_on_road_shares_do_not_disable_available_base(self):
self.manage(road=100)
self.assertEqual(self.run.orders.place.call_args.args[1].volume, 500)
def test_added_cost_is_used_even_if_base_cost_is_higher(self):
self.manage(added=100, cost=20, added_cost=10, price=11)
self.assertEqual(self.run.orders.place.call_args.args[1].volume, 100)
def test_invalid_selected_cost_never_trades(self):
for cost in [0, -1, float('nan'), float('inf')]:
with self.subTest(cost=cost), patch('strategy.zt.positions.log.exception') as error:
self.manage(added=100, added_cost=cost)
error.assert_not_called()
self.run.orders.place.assert_not_called()
def start(self, client, directory):
self.run.account_cfg.grid_step_pct = 1
global_cfg = NS(qmt_base_url='unused', qmt_token='', qmt_data_dir=directory)
self.run.account_cfg.signal_allow = []
with patch.object(boot, 'Client', return_value=client), \
patch.object(boot.config, 'global_config', global_cfg), \
patch.object(boot.config, 'account_config', self.run.account_cfg), \
patch.object(boot, 'init_signals', return_value=[]), \
patch.object(boot, 'cache_portfolio'), patch.object(boot, 'Overview'), \
patch.object(boot.time, 'localtime', return_value=NS(tm_hour=15, tm_min=0, tm_sec=0)):
boot.StartZT()
def test_start_initializes_once_without_snapshot_retry_loop(self):
client = Mock()
client.deals.return_value = []
client.portfolio.return_value = NS(assets=Assets(10000, 10000),
positions={self.code: PositionItem(stock_code=self.code, volume=100, open_price=10)}, orders=[])
with tempfile.TemporaryDirectory() as tmp:
self.start(client, tmp)
store = State(Path(tmp) / 'zt_test_state.db')
self.assertEqual(store.state[self.code]['base_qty'], 100)
with closing(store._connect()) as db:
self.assertIsNone(db.execute("SELECT 1 FROM sqlite_master WHERE name='state_meta'").fetchone())
self.assertEqual(client.portfolio.call_count, 1)
self.assertEqual(client.deals.call_count, 2)
client.reset_mock()
self.start(client, tmp)
self.assertEqual(client.portfolio.call_count, 1)
self.assertEqual(client.deals.call_count, 1)
def test_start_rejects_changed_deals_without_writing_baseline(self):
client = Mock()
client.deals.side_effect = [[], [DealItem(order_sys_id='new')]]
client.portfolio.return_value = NS(assets=Assets(10000, 10000), positions={}, orders=[])
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(RuntimeError):
self.start(client, tmp)
store = State(Path(tmp) / 'zt_test_state.db')
self.assertEqual((store.state, store.deals), ({}, {}))
client.close.assert_called_once()
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,125 @@
"""ZT 新路径测试脚手架:真实 Runtime/OrderBook/DipWatch + 模拟客户端。"""
import tempfile
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import Mock
from libs.grid_take_profit import GridTrailingTracker
from libs.order import OrderBook
from libs.runtime import Runtime
from libs.watch import DipWatch
from sdk import Assets, DealItem, PositionItem, Tick
from strategy.zt.rounds import Round, RoundStore, start_round
ACCOUNT = 'zt-test'
def account_cfg(**overrides):
cfg = NS(account_id=ACCOUNT, strategy='zt', host_key='test',
grid_step_pct=1.0, zt_open_hands=1, zt_sell_ratio=0.5,
zt_buy_fall_pct=1.0, zt_max_price=200.0, zt_t_band_pct=1.0,
zt_max_hold_days=5, min_cash_ratio=0.1,
excluded_codes=[], signal_allow=['dcm'], buy_value=10000.0)
for key, value in overrides.items():
setattr(cfg, key, value)
return cfg
def global_cfg(**overrides):
cfg = NS(qmt_base_url='http://unused', qmt_token='', api_host='http://unused',
qmt_data_dir='.', signals={})
for key, value in overrides.items():
setattr(cfg, key, value)
return cfg
class Fixture:
"""一套隔离的账户快照、轮次存储与运行上下文。"""
def __init__(self, **cfg_overrides):
self.tmp = tempfile.TemporaryDirectory()
self.path = Path(self.tmp.name)
self.rounds_path = self.path / f'zt_{ACCOUNT}_rounds.json'
self.store = RoundStore(self.rounds_path)
self.account_cfg = account_cfg(**cfg_overrides)
self.assets = Assets(total=100000.0, available=100000.0)
self.positions = {}
self.orders = []
self.deals = []
self.ticks = {}
self.client = self._client()
self.run = Runtime(
client=self.client, global_cfg=global_cfg(),
account_cfg=self.account_cfg,
orders=OrderBook(cancel_timeout_sec=300),
open_watch=DipWatch(expire_seconds=600, rebound_threshold=0.0),
add_watch=DipWatch(expire_seconds=600, rebound_threshold=0.0),
profit_tracker=GridTrailingTracker(self.account_cfg.grid_step_pct),
)
def _client(self):
client = Mock()
client.deals.side_effect = lambda: self.deals
client.portfolio.side_effect = lambda: NS(
assets=self.assets, positions=self.positions, orders=self.orders)
client.full_tick.side_effect = lambda codes: dict(self.ticks)
return client
def cleanup(self):
self.tmp.cleanup()
# ---- 便捷构造 ----
def hold(self, code='600000.SH', volume=1000, price=10.0, can_use=None):
position = PositionItem(stock_code=code, volume=volume, open_price=price,
can_use_volume=volume if can_use is None else can_use)
self.positions[code] = position
return position
def quote(self, code='600000.SH', price=10.0):
self.ticks[code] = Tick(last_price=price)
return self.ticks[code]
def deal(self, local_id, volume, price, sys_id=None, code='600000.SH'):
return DealItem(stock_code=code, order_sys_id=sys_id or f'{local_id}-{volume}',
remark=local_id, offset_flag=48, volume=volume, price=price,
trade_amount=price * volume, trade_date='20260915',
trade_time='100000')
def prime(self, watch, code, price):
"""让 DipWatch 先建立观察点,下一次同价或更高价即满足反弹确认。"""
watch.triggered('prime', code, price)
def tick(self, signals=()):
"""跑一轮 RunOnce绕过真实时钟的交易时段判断。"""
from unittest.mock import patch
from strategy.zt import boot
with patch.object(boot, 'trading_time', return_value=True):
boot.RunOnce(self.run, self.store, list(signals))
return self.store
def own_base(self, code='600000.SH', qty=1000, cost=10.0, today='2026-09-15'):
"""把该证券标记为"本策略自己建仓"(模拟建仓腿已成交)。"""
item = Round(code=code, base_qty=qty, base_cost=cost, base_date=today,
base_source='opened', phase='CLOSED', outcome='base')
self.store.put(item)
self.store.save()
return item
def open_round(self, code='600000.SH', kind='LONG_T', order_id='zt-entry-1',
today='2026-09-15', **fields):
"""写入一条已提交开仓腿的轮次记录。"""
item = Round(code=code, base_qty=fields.pop('base_qty', 1000),
base_cost=fields.pop('base_cost', 10.0),
base_source=fields.pop('base_source', 'opened'))
start_round(item, kind, today)
item.entry_order_id = order_id
for key, value in fields.items():
setattr(item, key, value)
self.store.put(item)
self.store.save()
return item
@property
def placed(self):
return [call.kwargs for call in self.client.passorder.call_args_list]