From c8151f4a3f2ae50b05056bd3dc4eae49e5edd997 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 14 Sep 2026 19:11:58 +0800 Subject: [PATCH] add zt_open_hands --- py-client/config/__init__.py | 4 + py-client/etc/cai_cai.yaml | 1 + py-client/etc/dev.yaml | 1 + py-client/etc/fu_xing.yaml | 1 + py-client/etc/hu.yaml | 1 + py-client/etc/liao.yaml | 1 + py-client/etc/long.yaml | 1 + py-client/etc/test.yaml | 1 + py-client/etc/tong_zhao.yaml | 1 + py-client/etc/wen_ting.yaml | 1 + py-client/etc/xiao_dong.yaml | 1 + py-client/etc/yanweidong.yaml | 1 + py-client/etc/yin_fei.yaml | 1 + py-client/etc/zhang.yaml | 1 + py-client/strategy/zt/boot.py | 4 + py-client/strategy/zt/open.py | 5 +- py-client/strategy/zt/positions.py | 4 +- py-client/tests/test_deal_model.py | 6 +- py-client/tests/test_orderbook.py | 7 +- py-client/tests/test_state_archiving.py | 214 ------------------------ py-client/tests/test_state_snapshot.py | 92 ---------- py-client/tests/test_state_storage.py | 11 -- py-client/tests/test_zt_audit_fixes.py | 50 +----- py-client/tests/test_zt_open_hands.py | 91 ++++++++++ py-client/tests/test_zt_state.py | 131 --------------- py-client/tests/test_zt_trading.py | 44 +---- 26 files changed, 126 insertions(+), 550 deletions(-) delete mode 100644 py-client/tests/test_state_archiving.py delete mode 100644 py-client/tests/test_state_snapshot.py create mode 100644 py-client/tests/test_zt_open_hands.py delete mode 100644 py-client/tests/test_zt_state.py diff --git a/py-client/config/__init__.py b/py-client/config/__init__.py index 40857b4..3ebaaf9 100644 --- a/py-client/config/__init__.py +++ b/py-client/config/__init__.py @@ -50,6 +50,8 @@ class AccountConfig: enable_auto_ipo: bool = True signal_allow: list[str] = field(default_factory=list) excluded_codes: list[str] = field(default_factory=list) + # ZT 开仓及每次补仓手数(每手 100 股);0 表示不启动。 + zt_open_hands: int = 0 zt_sell_ratio: float = 0.5 zt_buy_fall_pct: float = 1.0 zt_max_price: float = 200.0 @@ -130,6 +132,8 @@ def load( account_config = AccountConfig(**_yaml(root / account_file)) if account_config.buy_value <= 0 or account_config.grid_step_pct <= 0: raise ValueError("buy_value、grid_step_pct 必须大于 0") + if type(account_config.zt_open_hands) is not int or account_config.zt_open_hands < 0: + raise ValueError("zt_open_hands 必须为非负整数,0 表示不启动 ZT 策略") if not 0 < account_config.zt_sell_ratio <= 1: raise ValueError("zt_sell_ratio 必须在 (0, 1] 区间") if account_config.zt_buy_fall_pct <= 0 or account_config.zt_max_price <= 0: diff --git a/py-client/etc/cai_cai.yaml b/py-client/etc/cai_cai.yaml index 0a4e119..ec1532f 100644 --- a/py-client/etc/cai_cai.yaml +++ b/py-client/etc/cai_cai.yaml @@ -11,3 +11,4 @@ enable_loss_add_position: True enable_auto_ipo: True excluded_codes: - "00000.SZ" +zt_open_hands: 0 diff --git a/py-client/etc/dev.yaml b/py-client/etc/dev.yaml index 22e8a7e..4da4df4 100644 --- a/py-client/etc/dev.yaml +++ b/py-client/etc/dev.yaml @@ -11,3 +11,4 @@ enable_loss_add_position: True enable_auto_ipo: True excluded_codes: - "00000.SZ" +zt_open_hands: 0 diff --git a/py-client/etc/fu_xing.yaml b/py-client/etc/fu_xing.yaml index 154d9d7..4389afa 100644 --- a/py-client/etc/fu_xing.yaml +++ b/py-client/etc/fu_xing.yaml @@ -11,3 +11,4 @@ enable_loss_add_position: True enable_auto_ipo: True excluded_codes: - "00000.SZ" +zt_open_hands: 0 diff --git a/py-client/etc/hu.yaml b/py-client/etc/hu.yaml index 32c83e0..ab9c4b4 100644 --- a/py-client/etc/hu.yaml +++ b/py-client/etc/hu.yaml @@ -11,3 +11,4 @@ enable_loss_add_position: True enable_auto_ipo: True excluded_codes: - "00000.SZ" +zt_open_hands: 0 diff --git a/py-client/etc/liao.yaml b/py-client/etc/liao.yaml index 852a3e8..6789efd 100644 --- a/py-client/etc/liao.yaml +++ b/py-client/etc/liao.yaml @@ -11,3 +11,4 @@ enable_loss_add_position: True enable_auto_ipo: True excluded_codes: - "00000.SZ" +zt_open_hands: 0 diff --git a/py-client/etc/long.yaml b/py-client/etc/long.yaml index 2d3f539..042e9bb 100644 --- a/py-client/etc/long.yaml +++ b/py-client/etc/long.yaml @@ -11,3 +11,4 @@ enable_loss_add_position: True enable_auto_ipo: True excluded_codes: - "00000.SZ" +zt_open_hands: 0 diff --git a/py-client/etc/test.yaml b/py-client/etc/test.yaml index 2f76efd..8818f83 100644 --- a/py-client/etc/test.yaml +++ b/py-client/etc/test.yaml @@ -11,3 +11,4 @@ enable_loss_add_position: True enable_auto_ipo: True excluded_codes: - "00000.SZ" +zt_open_hands: 0 diff --git a/py-client/etc/tong_zhao.yaml b/py-client/etc/tong_zhao.yaml index 346ecc3..10ce245 100644 --- a/py-client/etc/tong_zhao.yaml +++ b/py-client/etc/tong_zhao.yaml @@ -11,3 +11,4 @@ enable_loss_add_position: True enable_auto_ipo: True excluded_codes: - "00000.SZ" +zt_open_hands: 0 diff --git a/py-client/etc/wen_ting.yaml b/py-client/etc/wen_ting.yaml index 756ec8e..4602070 100644 --- a/py-client/etc/wen_ting.yaml +++ b/py-client/etc/wen_ting.yaml @@ -11,3 +11,4 @@ enable_loss_add_position: True enable_auto_ipo: True excluded_codes: - "00000.SZ" +zt_open_hands: 0 diff --git a/py-client/etc/xiao_dong.yaml b/py-client/etc/xiao_dong.yaml index 8a0854e..ea1932a 100644 --- a/py-client/etc/xiao_dong.yaml +++ b/py-client/etc/xiao_dong.yaml @@ -11,3 +11,4 @@ enable_loss_add_position: True enable_auto_ipo: True excluded_codes: - "00000.SZ" +zt_open_hands: 0 diff --git a/py-client/etc/yanweidong.yaml b/py-client/etc/yanweidong.yaml index b289d10..519bab4 100644 --- a/py-client/etc/yanweidong.yaml +++ b/py-client/etc/yanweidong.yaml @@ -11,3 +11,4 @@ enable_loss_add_position: True enable_auto_ipo: True excluded_codes: - "00000.SZ" +zt_open_hands: 0 diff --git a/py-client/etc/yin_fei.yaml b/py-client/etc/yin_fei.yaml index cccfef2..0e023d1 100644 --- a/py-client/etc/yin_fei.yaml +++ b/py-client/etc/yin_fei.yaml @@ -11,3 +11,4 @@ enable_loss_add_position: True enable_auto_ipo: True excluded_codes: - "00000.SZ" +zt_open_hands: 0 diff --git a/py-client/etc/zhang.yaml b/py-client/etc/zhang.yaml index 0526a0d..43e933d 100644 --- a/py-client/etc/zhang.yaml +++ b/py-client/etc/zhang.yaml @@ -11,3 +11,4 @@ enable_loss_add_position: True enable_auto_ipo: True excluded_codes: - "00000.SZ" +zt_open_hands: 0 diff --git a/py-client/strategy/zt/boot.py b/py-client/strategy/zt/boot.py index c8c2af4..56a30dd 100644 --- a/py-client/strategy/zt/boot.py +++ b/py-client/strategy/zt/boot.py @@ -22,6 +22,10 @@ from libs.snapshot import cache_portfolio def StartZT() -> None: + if config.account_config.zt_open_hands == 0: + log.info("[ZT] zt_open_hands=0,不启动策略") + return + client = Client( config.global_config.qmt_base_url, config.global_config.qmt_token, diff --git a/py-client/strategy/zt/open.py b/py-client/strategy/zt/open.py index 9f709ce..4a6497a 100644 --- a/py-client/strategy/zt/open.py +++ b/py-client/strategy/zt/open.py @@ -4,7 +4,6 @@ from datetime import datetime from functools import lru_cache import math -from libs import calc_buy_volume from sdk import OP_BUY from libs.runtime import Runtime from libs.order import PlaceOrderRequest @@ -45,8 +44,8 @@ def open_signal(run: Runtime, ticks, open_signals) -> None: log.info("[OpenSkip] %s 信号=%s,跳过:价格无效", item.code, item.signal_key) continue - # 5. 根据单笔买入金额计算整手开仓数量。 - volume = calc_buy_volume(price, run.account_cfg.buy_value) + # 5. 按配置的固定手数开仓,每手 100 股。 + volume = run.account_cfg.zt_open_hands * 100 if volume <= 0: log.info("[OpenSkip] %s 信号=%s,跳过:数量无效", item.code, item.signal_key) continue diff --git a/py-client/strategy/zt/positions.py b/py-client/strategy/zt/positions.py index 6747a2a..84981cf 100644 --- a/py-client/strategy/zt/positions.py +++ b/py-client/strategy/zt/positions.py @@ -3,7 +3,7 @@ from dataclasses import dataclass import math -from libs.calc import calc_buy_volume, calculate_min_profit_rate +from libs.calc import calculate_min_profit_rate from libs.grid_take_profit import GridState from sdk import OP_BUY, OP_SELL, PositionItem, Tick from libs.state import State @@ -190,7 +190,7 @@ def handle_loss( if runtime.orders.busy(stock_code, "BUY"): return TradeDecision(False, "买入委托处理中") - volume = calc_buy_volume(tick.last_price, runtime.account_cfg.buy_value) + volume = runtime.account_cfg.zt_open_hands * 100 amount = tick.last_price * volume if volume <= 0 or amount > available: return TradeDecision(False, "本轮可用资金不足") diff --git a/py-client/tests/test_deal_model.py b/py-client/tests/test_deal_model.py index 3e3bd23..d644789 100644 --- a/py-client/tests/test_deal_model.py +++ b/py-client/tests/test_deal_model.py @@ -8,7 +8,7 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock from libs.order import OrderBook as ActiveOrders -from libs.state import State +from libs.state import FLAG_BUY, State from sdk.models import Assets, DealItem, OrderItem, PositionItem from sdk.portfolio import PortfolioMixin @@ -26,7 +26,7 @@ class ApiModelTests(unittest.TestCase): if isinstance(n, ast.Attribute) and n.attr.startswith('m_')} attrs.update(m_strInstrumentID='600000', m_strExchangeID='SH', m_strOrderSysID='sys1', m_strRemark='trend-BUY-1|trend', - m_nOffsetFlag=23, m_nOrderStatus=56, m_nVolume=100, + m_nOffsetFlag=FLAG_BUY, m_nOrderStatus=56, m_nVolume=100, m_nVolumeTraded=100, m_nVolumeTotalOriginal=100, m_dPrice=10.0, m_dTradeAmount=1000.0, m_dBalance=2000.0, m_dAvailable=1000.0, m_strInsertDate='20260907', @@ -68,7 +68,7 @@ class ApiModelTests(unittest.TestCase): order.order_status = 50 order.insert_date = '20000101' client = Mock() - book = ActiveOrders('trend') + book = ActiveOrders() book.refresh(client, [order]) client.cancel_by_id.assert_called_once_with('sys1') self.assertTrue(book.busy('600000.SH', 'BUY')) diff --git a/py-client/tests/test_orderbook.py b/py-client/tests/test_orderbook.py index 054b563..b0ab492 100644 --- a/py-client/tests/test_orderbook.py +++ b/py-client/tests/test_orderbook.py @@ -4,10 +4,9 @@ import unittest from contextlib import closing from dataclasses import asdict, fields from pathlib import Path -from datetime import datetime from unittest.mock import patch -from libs.state import State, StateItem +from libs.state import FLAG_BUY, FLAG_SELL, State, StateItem from sdk import DealItem, PositionItem @@ -21,7 +20,7 @@ class OrderBookTests(unittest.TestCase): 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=24 if kind == 'sell' else 23, + 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', ) @@ -64,7 +63,7 @@ class OrderBookTests(unittest.TestCase): self.assertEqual(book.deals, {}) self.assertEqual(book.deals_sys_ids, set()) self.assertEqual(State(self.path).deals, {}) - invalid.offset_flag = 23 + invalid.offset_flag = FLAG_BUY book.sync_deals([first, invalid]) self.assertEqual(book.deals_sys_ids, {'d1', 'd2'}) diff --git a/py-client/tests/test_state_archiving.py b/py-client/tests/test_state_archiving.py deleted file mode 100644 index 91919a8..0000000 --- a/py-client/tests/test_state_archiving.py +++ /dev/null @@ -1,214 +0,0 @@ -import sqlite3 -import tempfile -import unittest -from contextlib import closing -from pathlib import Path - -from libs.state import FLAG_BUY, FLAG_SELL, State -from sdk import DealItem, PositionItem - - -class ArchivingTests(unittest.TestCase): - def setUp(self): - tmp = tempfile.TemporaryDirectory() - self.addCleanup(tmp.cleanup) - self.book = State(Path(tmp.name) / 'state.db') - - def insert_deal(self, order, qty, amount, time, code='600000.SH', flag=48): - with closing(self.book._connect()) as db, db: - db.execute( - 'INSERT INTO deals (stock_code, order_sys_id, order_local_id, offset_flag, ' - 'price, volume, trade_amount, trade_date, trade_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', - (code, order, order, flag, amount / qty, qty, amount, '2026-09-08', time), - ) - - def test_schema_only_has_state_and_deals(self): - with closing(self.book._connect()) as db: - tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")} - self.assertEqual(tables, {'state', 'deals', 'sqlite_sequence'}) - - def test_accumulates_once_and_preserves_base(self): - self.book.sync_state([PositionItem(stock_code='600000.SH', volume=200, open_price=8)]) - original = dict(self.book.state['600000.SH']) - self.insert_deal('first', 40, 400, '10:00:00') - self.insert_deal('second', 60, 720, '10:01:00') - self.book.archiving() - row = self.book.state['600000.SH'] - self.assertEqual(row['added_qty'], 100) - self.assertAlmostEqual(row['added_price'], 11.2) - self.assertEqual(row['added_order_local_id'], 'second') - self.assertEqual(row['added_created_at'], '2026-09-08 10:01:00') - for key in original: - if not key.startswith('added_'): - self.assertEqual(row[key], original[key]) - self.assertTrue(all(deal['is_arch'] == 1 for deal in self.book.deals.values())) - restarted = State(self.book.path) - restarted.archiving() - self.assertEqual(restarted.state, self.book.state) - self.insert_deal('late', 50, 500, '09:59:00') - self.book.archiving() - row = self.book.state['600000.SH'] - self.assertEqual(row['added_qty'], 150) - self.assertAlmostEqual(row['added_price'], 1620 / 150) - self.assertEqual(row['added_order_local_id'], 'late') - - def test_no_argument_archiving_recognizes_base_and_added_orders(self): - self.insert_deal('zt-base-first', 100, 1000, '10:00:00', flag=FLAG_BUY) - self.insert_deal('zt-base-second', 100, 1200, '10:01:00', flag=48) - self.insert_deal('zt-t-buy-first', 100, 900, '10:02:00', flag=FLAG_BUY) - self.assertIsNone(self.book.archiving()) - row = self.book.state['600000.SH'] - self.assertEqual((row['base_qty'], row['base_price']), (200, 11)) - self.assertEqual((row['added_qty'], row['added_price']), (100, 9)) - self.assertEqual(row['base_order_local_id'], 'zt-base-second') - self.assertTrue(all(deal['is_arch'] == 1 for deal in self.book.deals.values())) - saved = dict(row) - self.assertIsNone(self.book.archiving()) - self.assertEqual(self.book.state['600000.SH'], saved) - - def test_sell_added_then_clear_base(self): - self.book.sync_state([PositionItem(stock_code='600000.SH', volume=100, open_price=8)]) - self.insert_deal('buy1', 40, 400, '09:59:00') - self.insert_deal('buy', 60, 600, '10:00:00') - self.insert_deal('partial', 40, 480, '10:01:00', flag=49) - self.book.archiving() - row = self.book.state['600000.SH'] - self.assertEqual((row['base_qty'], row['base_price'], row['added_qty'], row['added_price']), - (100, 8, 60, 10)) - self.insert_deal('sell_added', 60, 720, '10:02:00', flag=49) - self.book.archiving() - row = self.book.state['600000.SH'] - self.assertEqual((row['base_qty'], row['added_qty'], row['added_price']), (100, 0, 0)) - self.assertEqual((row['added_order_local_id'], row['added_created_at']), ('', '')) - self.insert_deal('sell_base', 100, 1200, '10:03:00', flag=49) - self.book.archiving() - self.assertEqual(self.book.state, {}) - self.book.archiving() - self.assertEqual(State(self.book.path).state, {}) - self.assertTrue(all(deal['is_arch'] == 1 for deal in self.book.deals.values())) - - def test_sell_crosses_into_base_then_liquidates(self): - self.book.sync_state([PositionItem(stock_code='600000.SH', volume=100, open_price=8)]) - self.insert_deal('buy', 50, 500, '10:00:00') - self.insert_deal('sell', 80, 960, '10:01:00', flag=49) - self.book.archiving() - row = self.book.state['600000.SH'] - self.assertEqual((row['base_qty'], row['base_price'], row['added_qty']), (70, 8, 0)) - self.insert_deal('buy_again', 30, 300, '10:02:00') - self.insert_deal('sell_all', 100, 1200, '10:03:00', flag=49) - self.book.archiving() - self.assertEqual(self.book.state, {}) - - def test_excess_sell_rolls_back(self): - self.insert_deal('buy', 50, 500, '10:00:00') - self.insert_deal('sell', 100, 1200, '10:01:00', flag=49) - with self.assertLogs(level='WARNING') as logs: - self.assertIsNone(self.book.archiving()) - self.assertIn('600000.SH', '\n'.join(logs.output)) - restarted = State(self.book.path) - self.assertEqual(restarted.state, {}) - self.assertTrue(all(deal['is_arch'] == 0 for deal in restarted.deals.values())) - - def test_stock_buy_and_sell_flags(self): - self.book.sync_state([PositionItem(stock_code='600000.SH', volume=100, open_price=8)]) - self.insert_deal('buy', 100, 1000, '10:00:00', flag=FLAG_BUY) - self.insert_deal('sell', 50, 600, '10:01:00', flag=FLAG_SELL) - self.assertIsNone(self.book.archiving()) - row = self.book.state['600000.SH'] - self.assertEqual((row['base_qty'], row['added_qty']), (100, 50)) - self.assertEqual(self.book.deals['buy']['offset_flag'], FLAG_BUY) - self.assertEqual(self.book.deals['sell']['offset_flag'], FLAG_SELL) - self.assertTrue(all(deal['is_arch'] == 1 for deal in self.book.deals.values())) - - def test_new_state_and_failed_mark_roll_back_together(self): - self.insert_deal('first', 100, 1000, '10:00:00') - self.insert_deal('second', 100, 1200, '10:01:00', code='600001.SH') - self.book.load() - with closing(self.book._connect()) as db: - db.execute("""CREATE TRIGGER fail_archive BEFORE UPDATE OF is_arch ON deals - WHEN OLD.order_sys_id = 'second' - BEGIN SELECT RAISE(ABORT, 'test failure'); END""") - with self.assertLogs(level='WARNING') as logs: - self.assertIsNone(self.book.archiving()) - self.assertIn('600001.SH', '\n'.join(logs.output)) - restarted = State(self.book.path) - self.assertEqual(set(restarted.state), {'600000.SH'}) - self.assertEqual(restarted.state, self.book.state) - self.assertEqual(restarted.deals['first']['is_arch'], 1) - self.assertEqual(restarted.deals['second']['is_arch'], 0) - with closing(self.book._connect()) as db: - db.execute('DROP TRIGGER fail_archive') - self.book.archiving() - self.assertEqual(len(self.book.state), 2) - self.assertEqual(self.book.state['600000.SH']['base_qty'], 0) - self.assertEqual(self.book.state['600000.SH']['added_qty'], 100) - - def test_equal_quantity_buy_only_marks_and_preserves_status(self): - self.book.sync_state([PositionItem(stock_code='600000.SH', volume=100, open_price=8)]) - self.book.sync_deals([DealItem( - stock_code='600000.SH', order_sys_id='first', remark='base1|test', - offset_flag=48, volume=100, price=8, trade_amount=800, - trade_date='20260908', trade_time='100000', - )]) - self.assertIsNone(self.book.archiving()) - row = self.book.state['600000.SH'] - self.assertEqual((row['base_qty'], row['added_qty']), (100, 0)) - self.assertEqual(self.book.deals['first']['is_arch'], 1) - restarted = State(self.book.path) - self.assertIsNone(restarted.archiving()) - self.assertEqual(restarted.state, self.book.state) - self.insert_deal('new_buy', 50, 500, '10:01:00') - with closing(self.book._connect()) as db, db: - db.execute("UPDATE state SET status = 'CUSTOM' WHERE stock_code = '600000.SH'") - self.assertIsNone(self.book.archiving()) - row = self.book.state['600000.SH'] - self.assertEqual((row['base_qty'], row['added_qty'], row['status']), (100, 50, 'CUSTOM')) - - def test_archived_history_is_not_reapplied(self): - self.insert_deal('old', 100, 1000, '10:00:00') - with closing(self.book._connect()) as db, db: - db.execute('UPDATE deals SET is_arch = 1') - self.insert_deal('new', 50, 500, '10:01:00') - self.assertIsNone(self.book.archiving()) - self.assertEqual(self.book.state['600000.SH']['added_qty'], 50) - self.assertEqual(self.book.deals['old']['is_arch'], 1) - self.assertEqual(self.book.deals['new']['is_arch'], 1) - - def test_late_buy_is_incremental_after_liquidation_and_restart(self): - self.insert_deal('buy', 100, 1000, '10:00:00') - self.insert_deal('sell', 100, 1500, '10:02:00', flag=49) - self.book.archiving() - self.assertEqual(self.book.state, {}) - self.book = State(self.book.path) - self.insert_deal('late', 100, 2000, '100100') - self.assertIsNone(self.book.archiving()) - row = self.book.state['600000.SH'] - self.assertEqual(row['added_qty'], 100) - self.assertEqual(row['added_price'], 20) - - def test_archive_sell_before_syncing_empty_positions(self): - self.book.sync_state([PositionItem(stock_code='600000.SH', volume=100, open_price=8)]) - self.insert_deal('sell', 100, 1000, '10:01:00', flag=49) - self.assertIsNone(self.book.archiving()) - self.book.sync_state([]) - self.book = State(self.book.path) - self.assertIsNone(self.book.archiving()) - self.assertEqual(self.book.state, {}) - self.assertEqual(self.book.deals['sell']['is_arch'], 1) - - def test_bad_stock_does_not_block_good_stock_and_can_retry(self): - self.insert_deal('bad_sell', 100, 1500, '10:02:00', flag=49) - self.insert_deal('good_buy', 100, 1000, '10:00:00', code='600001.SH') - with self.assertLogs(level='WARNING') as logs: - self.assertIsNone(self.book.archiving()) - self.assertIn('600000.SH', '\n'.join(logs.output)) - self.assertEqual(self.book.deals['good_buy']['is_arch'], 1) - self.assertEqual(self.book.deals['bad_sell']['is_arch'], 0) - self.insert_deal('late_buy', 100, 1000, '10:01:00') - self.assertIsNone(self.book.archiving()) - self.assertEqual(self.book.deals['bad_sell']['is_arch'], 1) - self.assertNotIn('600000.SH', self.book.state) - - -if __name__ == '__main__': - unittest.main() diff --git a/py-client/tests/test_state_snapshot.py b/py-client/tests/test_state_snapshot.py deleted file mode 100644 index ed48a2c..0000000 --- a/py-client/tests/test_state_snapshot.py +++ /dev/null @@ -1,92 +0,0 @@ -import sqlite3 -import tempfile -import unittest -from contextlib import closing -from pathlib import Path - -from libs.state import FLAG_BUY, FLAG_SELL, State -from sdk import DealItem, PositionItem - - -class SnapshotArchiveTests(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 deal(self, identity, qty, flag=FLAG_BUY, code=None): - return DealItem( - stock_code=code or self.code, order_sys_id=identity, - remark=f'zt-base-{identity}|zt', offset_flag=flag, - volume=qty, price=12, trade_amount=qty * 12, - trade_date='2026-09-12', trade_time='100000', - ) - - def snapshot(self, qty): - self.store.sync_state([PositionItem(stock_code=self.code, volume=qty, open_price=10)]) - - def test_matching_partial_fills_only_mark_and_survive_restart(self): - self.snapshot(100) - saved = dict(self.store.state[self.code]) - self.store.sync_deals([self.deal('one', 40), self.deal('two', 60)]) - self.store.archiving() - self.assertEqual(self.store.state[self.code], saved) - self.assertTrue(all(d['is_arch'] == 1 for d in self.store.deals.values())) - restarted = State(self.store.path) - restarted.archiving() - self.assertEqual(restarted.state[self.code], saved) - - def test_total_includes_added_holdings(self): - self.snapshot(100) - with closing(self.store._connect()) as db, db: - db.execute("UPDATE state SET added_qty=50, added_price=9, status='CUSTOM'") - self.store.load() - saved = dict(self.store.state[self.code]) - self.store.sync_deals([self.deal('one', 150)]) - self.store.archiving() - self.assertEqual(self.store.state[self.code], saved) - self.assertEqual(self.store.deals['one']['is_arch'], 1) - - def test_nonmatching_and_other_stock_are_incremental(self): - self.snapshot(100) - self.store.sync_deals([self.deal('one', 40), self.deal('other', 60, code='600001.SH')]) - self.store.archiving() - self.assertEqual(self.store.state[self.code]['base_qty'], 140) - self.assertEqual(self.store.state['600001.SH']['base_qty'], 60) - - def test_matching_sell_still_liquidates(self): - self.snapshot(100) - self.store.sync_deals([self.deal('sell', 100, FLAG_SELL)]) - self.store.archiving() - self.assertNotIn(self.code, self.store.state) - self.assertEqual(self.store.deals['sell']['is_arch'], 1) - - def test_mixed_batch_with_matching_gross_volume_is_not_skipped(self): - self.snapshot(100) - self.store.sync_deals([self.deal('buy', 40), self.deal('sell', 60, FLAG_SELL)]) - self.store.archiving() - self.assertEqual(self.store.state[self.code]['base_qty'], 80) - self.assertTrue(all(d['is_arch'] == 1 for d in self.store.deals.values())) - - def test_failed_mark_rolls_back_entire_stock_and_retries(self): - self.snapshot(100) - saved = dict(self.store.state[self.code]) - self.store.sync_deals([self.deal('one', 40), self.deal('two', 60)]) - with closing(self.store._connect()) as db, db: - db.execute("""CREATE TRIGGER fail_mark BEFORE UPDATE OF is_arch ON deals - WHEN OLD.order_sys_id='two' - BEGIN SELECT RAISE(ABORT, 'test failure'); END""") - with self.assertLogs(level='WARNING'): - self.store.archiving() - self.assertEqual(self.store.state[self.code], saved) - self.assertTrue(all(d['is_arch'] == 0 for d in self.store.deals.values())) - with closing(self.store._connect()) as db, db: - db.execute('DROP TRIGGER fail_mark') - self.store.archiving() - self.assertEqual(self.store.state[self.code], saved) - self.assertTrue(all(d['is_arch'] == 1 for d in self.store.deals.values())) - - -if __name__ == '__main__': - unittest.main() diff --git a/py-client/tests/test_state_storage.py b/py-client/tests/test_state_storage.py index 0ee9005..9588302 100644 --- a/py-client/tests/test_state_storage.py +++ b/py-client/tests/test_state_storage.py @@ -23,17 +23,6 @@ class StateStorageTests(unittest.TestCase): trade_date='20260912', trade_time='100000', ) - def test_stale_cache_duplicate_preserves_original_and_imports_new_trade(self): - writer = State(self.store.path) - first = self.deal() - writer.sync_deals([first]) - first.price = 20 - first.trade_amount = 2000 - self.store.sync_deals([first, self.deal('second')]) - self.assertEqual(self.store.deals['first']['price'], 10) - self.assertEqual(self.store.deals_sys_ids, {'first', 'second'}) - self.assertEqual(State(self.store.path).deals, self.store.deals) - 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)]) diff --git a/py-client/tests/test_zt_audit_fixes.py b/py-client/tests/test_zt_audit_fixes.py index a365212..daf0a77 100644 --- a/py-client/tests/test_zt_audit_fixes.py +++ b/py-client/tests/test_zt_audit_fixes.py @@ -1,6 +1,5 @@ import tempfile import unittest -from contextlib import closing from dataclasses import replace from datetime import datetime, timedelta from pathlib import Path @@ -11,7 +10,7 @@ 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, DealItem, OrderItem, PositionItem +from sdk import Assets, OrderItem, PositionItem from strategy.zt import boot from strategy.zt.profit import ZTProfitTracker @@ -23,11 +22,6 @@ class ZTAuditFixTests(unittest.TestCase): self.store = State(Path(tmp.name) / 'state.db') self.code = '600000.SH' - def deal(self, code, identity): - return DealItem(stock_code=code, order_sys_id=identity, - remark=f'zt-base-{identity}|zt', offset_flag=48, - volume=100, price=10, trade_amount=1000) - def position(self, code): return PositionItem(stock_code=code, volume=100, open_price=10) @@ -48,46 +42,6 @@ class ZTAuditFixTests(unittest.TestCase): 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_invalid_trade_persists_blocks_only_its_stock_and_recovers(self): - good = '600001.SH' - bad = replace(self.deal(self.code, 'bad'), price=float('nan')) - valid = self.deal(good, 'good') - positions = [self.position(c) for c in (self.code, good)] - self.store.sync_account(positions, [bad, valid]) - self.assertEqual(self.store.blocked_codes, {self.code}) - self.assertEqual(self.store.state[good]['base_qty'], 100) - with closing(self.store._connect()) as db: - payload = db.execute('SELECT payload FROM zt_rejected_deals').fetchone()[0] - self.assertIn('bad', payload) - self.assertIn('NaN', payload) - self.store = State(self.store.path) - self.store.sync_account(positions, [valid]) - self.assertEqual(self.store.blocked_codes, {self.code}) - self.store.sync_account(positions, [replace(bad, price=10), valid]) - self.assertEqual(self.store.blocked_codes, set()) - self.assertEqual(self.store.state[self.code]['base_qty'], 100) - - def test_invalid_fields_do_not_block_other_stocks(self): - for field, value in [('volume', 0), ('volume', 1.5), ('offset_flag', 99), - ('trade_amount', float('inf')), ('price', -1)]: - with self.subTest(field=field): - bad = replace(self.deal(self.code, f'bad-{field}'), **{field: value}) - good = self.deal('600001.SH', 'good') - self.store.sync_account([self.position('600001.SH')], [bad, good]) - self.assertEqual(self.store.state['600001.SH']['base_qty'], 100) - self.assertIn(self.code, self.store.blocked_codes) - - def test_bad_stock_does_not_archive_other_trades_until_corrected(self): - first = self.deal(self.code, 'first') - bad = replace(self.deal(self.code, 'bad'), price=float('nan')) - position = replace(self.position(self.code), volume=200) - self.store.sync_account([position], [first, bad]) - self.assertEqual(self.store.deals['first']['is_arch'], 0) - self.assertNotIn(self.code, self.store.state) - self.store.sync_account([position], [replace(bad, price=10)]) - self.assertEqual(self.store.state[self.code]['base_qty'], 200) - self.assertEqual(self.store.blocked_codes, set()) - def test_profit_basis_changes_reset_peak_but_partial_sell_does_not(self): tracker = ZTProfitTracker() position = self.position(self.code) @@ -110,7 +64,7 @@ class ZTAuditFixTests(unittest.TestCase): def test_run_once_updates_collector_before_market_fetch(self): positions = [self.position(self.code)] - self.store.sync_account(positions, [], initialize=True) + 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()) diff --git a/py-client/tests/test_zt_open_hands.py b/py-client/tests/test_zt_open_hands.py new file mode 100644 index 0000000..13cdc0c --- /dev/null +++ b/py-client/tests/test_zt_open_hands.py @@ -0,0 +1,91 @@ +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() diff --git a/py-client/tests/test_zt_state.py b/py-client/tests/test_zt_state.py deleted file mode 100644 index defa667..0000000 --- a/py-client/tests/test_zt_state.py +++ /dev/null @@ -1,131 +0,0 @@ -import sqlite3 -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -from libs.state import FLAG_BUY, FLAG_SELL, State, UNATTRIBUTED_PREFIX -from sdk import DealItem, PositionItem - - -class ZTStateTests(unittest.TestCase): - def setUp(self): - tmp = tempfile.TemporaryDirectory() - self.addCleanup(tmp.cleanup) - self.state = State(Path(tmp.name) / 'state.db') - self.code = '600000.SH' - - def position(self, qty, code=None): - return PositionItem(stock_code=code or self.code, volume=qty, open_price=10) - - def deal(self, identity, qty=100, flag=FLAG_BUY, code=None, remark=None): - return DealItem(stock_code=code or self.code, order_sys_id=identity, - remark=f'zt-base-{identity}|zt' if remark is None else remark, - offset_flag=flag, volume=qty, price=10, trade_amount=qty*10, - trade_date='20260912', trade_time='100000') - - def test_initial_snapshot_and_equal_size_increment_are_distinct(self): - old = self.deal('old') - self.state.sync_account([self.position(100)], [old], initialize=True) - self.assertEqual(self.state.state[self.code]['base_qty'], 100) - self.assertEqual(self.state.deals['old']['is_arch'], 1) - self.state = State(self.state.path) - new = self.deal('new', remark='zt-added-new|zt') - for _ in range(2): - self.state.sync_account([self.position(200)], [old, new, new]) - row = self.state.state[self.code] - self.assertEqual((row['base_qty'], row['added_qty']), (100, 100)) - self.assertEqual(self.state.blocked_codes, set()) - - def test_initial_mixed_trades_are_already_in_snapshot(self): - self.state.sync_account([self.position(150)], - [self.deal('buy', 200), self.deal('sell', 50, FLAG_SELL)], - initialize=True) - self.assertEqual(self.state.state[self.code]['base_qty'], 150) - self.assertTrue(all(d['is_arch'] == 1 for d in self.state.deals.values())) - - def test_restart_full_sell_is_archived_before_reconciliation(self): - self.state.sync_account([self.position(100)], [], initialize=True) - self.state = State(self.state.path) - self.state.sync_account([], [self.deal('sell', flag=FLAG_SELL)]) - self.assertEqual(self.state.state, {}) - self.assertEqual(self.state.deals['sell']['is_arch'], 1) - self.assertEqual(self.state.blocked_codes, set()) - - def test_empty_initialized_account_survives_restart(self): - self.state.sync_account([], [], initialize=True) - self.state = State(self.state.path) - # 空账户重复初始化仍为空,无需额外标记表。 - self.state.sync_account([], [], initialize=True) - self.state.sync_account([self.position(100)], [self.deal('new')]) - self.assertEqual(self.state.state[self.code]['base_qty'], 100) - - def test_initialization_cannot_overwrite_existing_holdings(self): - self.state.sync_account([self.position(100)], [], initialize=True) - with self.assertRaises(ValueError): - self.state.sync_account([], [], initialize=True) - self.assertEqual(self.state.state[self.code]['base_qty'], 100) - - def test_initialization_failure_is_atomic(self): - invalid = self.position(100) - invalid.open_price = float('inf') - with self.assertRaises(ValueError): - self.state.sync_account([invalid], [self.deal('one')], initialize=True) - restarted = State(self.state.path) - self.assertEqual((restarted.state, restarted.deals), ({}, {})) - with patch.object(self.state, '_read_deals', side_effect=sqlite3.OperationalError('read failed')): - with self.assertRaises(sqlite3.OperationalError): - self.state.sync_account([self.position(100)], [self.deal('one')], initialize=True) - self.assertEqual(State(self.state.path).deals, {}) - self.assertEqual(self.state.state, {}) - - def test_lagging_snapshot_never_deletes_or_recreates_inventory(self): - self.state.sync_account([self.position(100)], [], initialize=True) - self.state.sync_account([], []) - self.assertEqual(self.state.state[self.code]['base_qty'], 100) - self.assertEqual(self.state.blocked_codes, {self.code}) - self.state.sync_account([self.position(100)], []) - self.assertEqual(self.state.blocked_codes, set()) - sell = self.deal('sell', flag=FLAG_SELL) - self.state.sync_account([self.position(100)], [sell]) - self.assertNotIn(self.code, self.state.state) - self.assertEqual(self.state.blocked_codes, {self.code}) - self.state.sync_account([], [sell]) - self.assertEqual(self.state.blocked_codes, set()) - - def test_blank_remark_persists_and_isolates_only_affected_stock(self): - self.state.sync_account([self.position(100)], [], initialize=True) - manual = self.deal('manual', remark=' |') - good = self.deal('good', code='600001.SH') - positions = [self.position(200), self.position(100, '600001.SH')] - for _ in range(2): - self.state.sync_account(positions, [manual, good]) - self.assertEqual(self.state.blocked_codes, {self.code}) - self.assertEqual(self.state.state[self.code]['base_qty'], 100) - self.assertEqual(self.state.state['600001.SH']['base_qty'], 100) - self.assertEqual(self.state.deals['manual']['is_arch'], 0) - self.assertTrue(self.state.deals['manual']['order_local_id'].startswith(UNATTRIBUTED_PREFIX)) - self.assertEqual(self.state.deals['manual']['remark'], ' |') - self.state = State(self.state.path) - - def test_legacy_database_is_restored_without_reinitialization(self): - self.state.sync_state([self.position(100)]) - self.state.sync_account([], [self.deal('sell', flag=FLAG_SELL)]) - self.assertEqual(self.state.state, {}) - self.assertEqual(self.state.deals['sell']['is_arch'], 1) - - def test_failed_archive_blocks_only_stock_and_retries(self): - self.state.sync_account([self.position(100)], [], initialize=True) - sell = self.deal('sell', qty=200, flag=FLAG_SELL) - self.state.sync_account([], [sell]) - self.assertEqual(self.state.state[self.code]['base_qty'], 100) - self.assertEqual(self.state.blocked_codes, {self.code}) - buy = self.deal('buy', remark='zt-added-buy|zt') - buy.trade_time = '095900' - self.state.sync_account([], [sell, buy]) - self.assertEqual(self.state.state, {}) - self.assertEqual(self.state.blocked_codes, set()) - - -if __name__ == '__main__': - unittest.main() diff --git a/py-client/tests/test_zt_trading.py b/py-client/tests/test_zt_trading.py index 65b80d3..2855f24 100644 --- a/py-client/tests/test_zt_trading.py +++ b/py-client/tests/test_zt_trading.py @@ -1,14 +1,12 @@ import tempfile import unittest -from concurrent.futures import ThreadPoolExecutor from contextlib import closing -from datetime import datetime 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 FLAG_BUY, State +from libs.state import State from sdk import Assets, DealItem, PositionItem, Tick from strategy.zt import boot from strategy.zt.positions import manage_positions @@ -17,7 +15,7 @@ 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, + 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()) @@ -66,44 +64,6 @@ class ZTTradingTests(unittest.TestCase): error.assert_not_called() self.run.orders.place.assert_not_called() - def test_run_once_quarantines_manual_trade_but_manages_good_stock(self): - with tempfile.TemporaryDirectory() as tmp, ThreadPoolExecutor(max_workers=2) as executor: - store = State(Path(tmp) / 'state.db') - good = '600001.SH' - positions = [PositionItem(stock_code=c, volume=100, can_use_volume=100, open_price=10) - for c in [self.code, good]] - store.sync_account(positions, [], initialize=True) - manual = DealItem(stock_code=self.code, order_sys_id='manual', remark='', - offset_flag=FLAG_BUY, volume=100, price=10, trade_amount=1000) - self.run.executor = executor - self.run.client.deals.return_value = [manual] - self.run.client.portfolio.return_value = NS( - assets=Assets(10000, 10000), positions={p.stock_code: p for p in positions}, orders=[]) - self.run.client.full_tick.return_value = {p.stock_code: Tick(last_price=11) for p in positions} - with patch.object(boot, 'datetime') as clock, patch.object(boot, 'market_allow_open', return_value=True): - clock.now.return_value = datetime(2026, 9, 11, 10) - boot.RunOnce(self.run, store, []) - self.assertEqual(store.blocked_codes, {self.code}) - self.run.orders.refresh.assert_called_once() - self.assertEqual(self.run.orders.place.call_count, 1) - self.assertEqual(self.run.orders.place.call_args.args[1].code, good) - - def test_run_once_does_not_reopen_quarantined_sold_out_code(self): - with tempfile.TemporaryDirectory() as tmp, ThreadPoolExecutor(max_workers=2) as executor: - store = State(Path(tmp) / 'state.db') - store.sync_account([], [], initialize=True) - self.run.executor = executor - self.run.client.deals.return_value = [DealItem( - stock_code=self.code, order_sys_id='manual', remark='', offset_flag=FLAG_BUY, - volume=100, price=10, trade_amount=1000)] - self.run.client.portfolio.return_value = NS(assets=Assets(10000, 10000), positions={}, orders=[]) - self.run.client.full_tick.return_value = {} - with patch.object(boot, 'datetime') as clock, patch.object(boot, 'market_allow_open', return_value=True), \ - patch.object(boot, 'open_signal') as opened: - clock.now.return_value = datetime(2026, 9, 11, 10) - boot.RunOnce(self.run, store, [NS(code=self.code)]) - opened.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)