add zt_open_hands

This commit is contained in:
2026-09-14 19:11:58 +08:00
parent 9eac1d2e10
commit c8151f4a3f
26 changed files with 126 additions and 550 deletions

View File

@@ -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:

View File

@@ -11,3 +11,4 @@ enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"
zt_open_hands: 0

View File

@@ -11,3 +11,4 @@ enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"
zt_open_hands: 0

View File

@@ -11,3 +11,4 @@ enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"
zt_open_hands: 0

View File

@@ -11,3 +11,4 @@ enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"
zt_open_hands: 0

View File

@@ -11,3 +11,4 @@ enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"
zt_open_hands: 0

View File

@@ -11,3 +11,4 @@ enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"
zt_open_hands: 0

View File

@@ -11,3 +11,4 @@ enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"
zt_open_hands: 0

View File

@@ -11,3 +11,4 @@ enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"
zt_open_hands: 0

View File

@@ -11,3 +11,4 @@ enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"
zt_open_hands: 0

View File

@@ -11,3 +11,4 @@ enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"
zt_open_hands: 0

View File

@@ -11,3 +11,4 @@ enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"
zt_open_hands: 0

View File

@@ -11,3 +11,4 @@ enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"
zt_open_hands: 0

View File

@@ -11,3 +11,4 @@ enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"
zt_open_hands: 0

View File

@@ -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,

View File

@@ -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

View File

@@ -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, "本轮可用资金不足")

View File

@@ -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'))

View File

@@ -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'})

View File

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

View File

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

View File

@@ -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)])

View File

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

View File

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

View File

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

View File

@@ -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)