add zt_open_hands
This commit is contained in:
@@ -50,6 +50,8 @@ class AccountConfig:
|
|||||||
enable_auto_ipo: bool = True
|
enable_auto_ipo: bool = True
|
||||||
signal_allow: list[str] = field(default_factory=list)
|
signal_allow: list[str] = field(default_factory=list)
|
||||||
excluded_codes: 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_sell_ratio: float = 0.5
|
||||||
zt_buy_fall_pct: float = 1.0
|
zt_buy_fall_pct: float = 1.0
|
||||||
zt_max_price: float = 200.0
|
zt_max_price: float = 200.0
|
||||||
@@ -130,6 +132,8 @@ def load(
|
|||||||
account_config = AccountConfig(**_yaml(root / account_file))
|
account_config = AccountConfig(**_yaml(root / account_file))
|
||||||
if account_config.buy_value <= 0 or account_config.grid_step_pct <= 0:
|
if account_config.buy_value <= 0 or account_config.grid_step_pct <= 0:
|
||||||
raise ValueError("buy_value、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:
|
if not 0 < account_config.zt_sell_ratio <= 1:
|
||||||
raise ValueError("zt_sell_ratio 必须在 (0, 1] 区间")
|
raise ValueError("zt_sell_ratio 必须在 (0, 1] 区间")
|
||||||
if account_config.zt_buy_fall_pct <= 0 or account_config.zt_max_price <= 0:
|
if account_config.zt_buy_fall_pct <= 0 or account_config.zt_max_price <= 0:
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ enable_loss_add_position: True
|
|||||||
enable_auto_ipo: True
|
enable_auto_ipo: True
|
||||||
excluded_codes:
|
excluded_codes:
|
||||||
- "00000.SZ"
|
- "00000.SZ"
|
||||||
|
zt_open_hands: 0
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ enable_loss_add_position: True
|
|||||||
enable_auto_ipo: True
|
enable_auto_ipo: True
|
||||||
excluded_codes:
|
excluded_codes:
|
||||||
- "00000.SZ"
|
- "00000.SZ"
|
||||||
|
zt_open_hands: 0
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ enable_loss_add_position: True
|
|||||||
enable_auto_ipo: True
|
enable_auto_ipo: True
|
||||||
excluded_codes:
|
excluded_codes:
|
||||||
- "00000.SZ"
|
- "00000.SZ"
|
||||||
|
zt_open_hands: 0
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ enable_loss_add_position: True
|
|||||||
enable_auto_ipo: True
|
enable_auto_ipo: True
|
||||||
excluded_codes:
|
excluded_codes:
|
||||||
- "00000.SZ"
|
- "00000.SZ"
|
||||||
|
zt_open_hands: 0
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ enable_loss_add_position: True
|
|||||||
enable_auto_ipo: True
|
enable_auto_ipo: True
|
||||||
excluded_codes:
|
excluded_codes:
|
||||||
- "00000.SZ"
|
- "00000.SZ"
|
||||||
|
zt_open_hands: 0
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ enable_loss_add_position: True
|
|||||||
enable_auto_ipo: True
|
enable_auto_ipo: True
|
||||||
excluded_codes:
|
excluded_codes:
|
||||||
- "00000.SZ"
|
- "00000.SZ"
|
||||||
|
zt_open_hands: 0
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ enable_loss_add_position: True
|
|||||||
enable_auto_ipo: True
|
enable_auto_ipo: True
|
||||||
excluded_codes:
|
excluded_codes:
|
||||||
- "00000.SZ"
|
- "00000.SZ"
|
||||||
|
zt_open_hands: 0
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ enable_loss_add_position: True
|
|||||||
enable_auto_ipo: True
|
enable_auto_ipo: True
|
||||||
excluded_codes:
|
excluded_codes:
|
||||||
- "00000.SZ"
|
- "00000.SZ"
|
||||||
|
zt_open_hands: 0
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ enable_loss_add_position: True
|
|||||||
enable_auto_ipo: True
|
enable_auto_ipo: True
|
||||||
excluded_codes:
|
excluded_codes:
|
||||||
- "00000.SZ"
|
- "00000.SZ"
|
||||||
|
zt_open_hands: 0
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ enable_loss_add_position: True
|
|||||||
enable_auto_ipo: True
|
enable_auto_ipo: True
|
||||||
excluded_codes:
|
excluded_codes:
|
||||||
- "00000.SZ"
|
- "00000.SZ"
|
||||||
|
zt_open_hands: 0
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ enable_loss_add_position: True
|
|||||||
enable_auto_ipo: True
|
enable_auto_ipo: True
|
||||||
excluded_codes:
|
excluded_codes:
|
||||||
- "00000.SZ"
|
- "00000.SZ"
|
||||||
|
zt_open_hands: 0
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ enable_loss_add_position: True
|
|||||||
enable_auto_ipo: True
|
enable_auto_ipo: True
|
||||||
excluded_codes:
|
excluded_codes:
|
||||||
- "00000.SZ"
|
- "00000.SZ"
|
||||||
|
zt_open_hands: 0
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ enable_loss_add_position: True
|
|||||||
enable_auto_ipo: True
|
enable_auto_ipo: True
|
||||||
excluded_codes:
|
excluded_codes:
|
||||||
- "00000.SZ"
|
- "00000.SZ"
|
||||||
|
zt_open_hands: 0
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ from libs.snapshot import cache_portfolio
|
|||||||
|
|
||||||
|
|
||||||
def StartZT() -> None:
|
def StartZT() -> None:
|
||||||
|
if config.account_config.zt_open_hands == 0:
|
||||||
|
log.info("[ZT] zt_open_hands=0,不启动策略")
|
||||||
|
return
|
||||||
|
|
||||||
client = Client(
|
client = Client(
|
||||||
config.global_config.qmt_base_url,
|
config.global_config.qmt_base_url,
|
||||||
config.global_config.qmt_token,
|
config.global_config.qmt_token,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from datetime import datetime
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
import math
|
import math
|
||||||
|
|
||||||
from libs import calc_buy_volume
|
|
||||||
from sdk import OP_BUY
|
from sdk import OP_BUY
|
||||||
from libs.runtime import Runtime
|
from libs.runtime import Runtime
|
||||||
from libs.order import PlaceOrderRequest
|
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)
|
log.info("[OpenSkip] %s 信号=%s,跳过:价格无效", item.code, item.signal_key)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 5. 根据单笔买入金额计算整手开仓数量。
|
# 5. 按配置的固定手数开仓,每手 100 股。
|
||||||
volume = calc_buy_volume(price, run.account_cfg.buy_value)
|
volume = run.account_cfg.zt_open_hands * 100
|
||||||
if volume <= 0:
|
if volume <= 0:
|
||||||
log.info("[OpenSkip] %s 信号=%s,跳过:数量无效", item.code, item.signal_key)
|
log.info("[OpenSkip] %s 信号=%s,跳过:数量无效", item.code, item.signal_key)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import math
|
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 libs.grid_take_profit import GridState
|
||||||
from sdk import OP_BUY, OP_SELL, PositionItem, Tick
|
from sdk import OP_BUY, OP_SELL, PositionItem, Tick
|
||||||
from libs.state import State
|
from libs.state import State
|
||||||
@@ -190,7 +190,7 @@ def handle_loss(
|
|||||||
if runtime.orders.busy(stock_code, "BUY"):
|
if runtime.orders.busy(stock_code, "BUY"):
|
||||||
return TradeDecision(False, "买入委托处理中")
|
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
|
amount = tick.last_price * volume
|
||||||
if volume <= 0 or amount > available:
|
if volume <= 0 or amount > available:
|
||||||
return TradeDecision(False, "本轮可用资金不足")
|
return TradeDecision(False, "本轮可用资金不足")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
from libs.order import OrderBook as ActiveOrders
|
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.models import Assets, DealItem, OrderItem, PositionItem
|
||||||
from sdk.portfolio import PortfolioMixin
|
from sdk.portfolio import PortfolioMixin
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ class ApiModelTests(unittest.TestCase):
|
|||||||
if isinstance(n, ast.Attribute) and n.attr.startswith('m_')}
|
if isinstance(n, ast.Attribute) and n.attr.startswith('m_')}
|
||||||
attrs.update(m_strInstrumentID='600000', m_strExchangeID='SH',
|
attrs.update(m_strInstrumentID='600000', m_strExchangeID='SH',
|
||||||
m_strOrderSysID='sys1', m_strRemark='trend-BUY-1|trend',
|
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_nVolumeTraded=100, m_nVolumeTotalOriginal=100,
|
||||||
m_dPrice=10.0, m_dTradeAmount=1000.0, m_dBalance=2000.0,
|
m_dPrice=10.0, m_dTradeAmount=1000.0, m_dBalance=2000.0,
|
||||||
m_dAvailable=1000.0, m_strInsertDate='20260907',
|
m_dAvailable=1000.0, m_strInsertDate='20260907',
|
||||||
@@ -68,7 +68,7 @@ class ApiModelTests(unittest.TestCase):
|
|||||||
order.order_status = 50
|
order.order_status = 50
|
||||||
order.insert_date = '20000101'
|
order.insert_date = '20000101'
|
||||||
client = Mock()
|
client = Mock()
|
||||||
book = ActiveOrders('trend')
|
book = ActiveOrders()
|
||||||
book.refresh(client, [order])
|
book.refresh(client, [order])
|
||||||
client.cancel_by_id.assert_called_once_with('sys1')
|
client.cancel_by_id.assert_called_once_with('sys1')
|
||||||
self.assertTrue(book.busy('600000.SH', 'BUY'))
|
self.assertTrue(book.busy('600000.SH', 'BUY'))
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import unittest
|
|||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from dataclasses import asdict, fields
|
from dataclasses import asdict, fields
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
|
||||||
from unittest.mock import patch
|
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
|
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]
|
prefix = {'base': 'zt-base-', 'sell': 'zt-t-sell-', 'buy': 'zt-t-buy-'}[kind]
|
||||||
return DealItem(
|
return DealItem(
|
||||||
order_sys_id=sys_order_id, stock_code='600000.SH',
|
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,
|
volume=qty, price=price, trade_amount=qty * price,
|
||||||
trade_date=date, trade_time='10:00:00', remark=prefix + 'order1|zt',
|
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, {})
|
||||||
self.assertEqual(book.deals_sys_ids, set())
|
self.assertEqual(book.deals_sys_ids, set())
|
||||||
self.assertEqual(State(self.path).deals, {})
|
self.assertEqual(State(self.path).deals, {})
|
||||||
invalid.offset_flag = 23
|
invalid.offset_flag = FLAG_BUY
|
||||||
book.sync_deals([first, invalid])
|
book.sync_deals([first, invalid])
|
||||||
self.assertEqual(book.deals_sys_ids, {'d1', 'd2'})
|
self.assertEqual(book.deals_sys_ids, {'d1', 'd2'})
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -23,17 +23,6 @@ class StateStorageTests(unittest.TestCase):
|
|||||||
trade_date='20260912', trade_time='100000',
|
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):
|
def test_load_failure_does_not_publish_partial_cache(self):
|
||||||
writer = State(self.store.path)
|
writer = State(self.store.path)
|
||||||
writer.sync_state([PositionItem(stock_code='600000.SH', volume=100)])
|
writer.sync_state([PositionItem(stock_code='600000.SH', volume=100)])
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from contextlib import closing
|
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -11,7 +10,7 @@ from libs.grid_take_profit import GridState
|
|||||||
from libs.order import OrderBook
|
from libs.order import OrderBook
|
||||||
from libs.snapshot import get_collector_snapshot
|
from libs.snapshot import get_collector_snapshot
|
||||||
from libs.state import State
|
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 import boot
|
||||||
from strategy.zt.profit import ZTProfitTracker
|
from strategy.zt.profit import ZTProfitTracker
|
||||||
|
|
||||||
@@ -23,11 +22,6 @@ class ZTAuditFixTests(unittest.TestCase):
|
|||||||
self.store = State(Path(tmp.name) / 'state.db')
|
self.store = State(Path(tmp.name) / 'state.db')
|
||||||
self.code = '600000.SH'
|
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):
|
def position(self, code):
|
||||||
return PositionItem(stock_code=code, volume=100, open_price=10)
|
return PositionItem(stock_code=code, volume=100, open_price=10)
|
||||||
|
|
||||||
@@ -48,46 +42,6 @@ class ZTAuditFixTests(unittest.TestCase):
|
|||||||
book.refresh(client, orders)
|
book.refresh(client, orders)
|
||||||
self.assertEqual([c.args[0] for c in client.cancel_by_id.call_args_list], ['0', '1', '2', '4', '5'])
|
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):
|
def test_profit_basis_changes_reset_peak_but_partial_sell_does_not(self):
|
||||||
tracker = ZTProfitTracker()
|
tracker = ZTProfitTracker()
|
||||||
position = self.position(self.code)
|
position = self.position(self.code)
|
||||||
@@ -110,7 +64,7 @@ class ZTAuditFixTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_run_once_updates_collector_before_market_fetch(self):
|
def test_run_once_updates_collector_before_market_fetch(self):
|
||||||
positions = [self.position(self.code)]
|
positions = [self.position(self.code)]
|
||||||
self.store.sync_account(positions, [], initialize=True)
|
self.store.sync_state(positions)
|
||||||
client = Mock()
|
client = Mock()
|
||||||
run = NS(client=client, account_cfg=NS(account_id='zt-test', min_cash_ratio=0.1),
|
run = NS(client=client, account_cfg=NS(account_id='zt-test', min_cash_ratio=0.1),
|
||||||
orders=Mock(), profit_tracker=ZTProfitTracker())
|
orders=Mock(), profit_tracker=ZTProfitTracker())
|
||||||
|
|||||||
91
py-client/tests/test_zt_open_hands.py
Normal file
91
py-client/tests/test_zt_open_hands.py
Normal 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()
|
||||||
@@ -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()
|
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace as NS
|
from types import SimpleNamespace as NS
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from libs.grid_take_profit import GridState
|
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 sdk import Assets, DealItem, PositionItem, Tick
|
||||||
from strategy.zt import boot
|
from strategy.zt import boot
|
||||||
from strategy.zt.positions import manage_positions
|
from strategy.zt.positions import manage_positions
|
||||||
@@ -17,7 +15,7 @@ from strategy.zt.positions import manage_positions
|
|||||||
class ZTTradingTests(unittest.TestCase):
|
class ZTTradingTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.code = '600000.SH'
|
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,
|
excluded_codes=[], enable_loss_add_position=False,
|
||||||
min_cash_ratio=0.1),
|
min_cash_ratio=0.1),
|
||||||
orders=Mock(), client=Mock(), profit_tracker=Mock(), add_watch=Mock())
|
orders=Mock(), client=Mock(), profit_tracker=Mock(), add_watch=Mock())
|
||||||
@@ -66,44 +64,6 @@ class ZTTradingTests(unittest.TestCase):
|
|||||||
error.assert_not_called()
|
error.assert_not_called()
|
||||||
self.run.orders.place.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):
|
def start(self, client, directory):
|
||||||
self.run.account_cfg.grid_step_pct = 1
|
self.run.account_cfg.grid_step_pct = 1
|
||||||
global_cfg = NS(qmt_base_url='unused', qmt_token='', qmt_data_dir=directory)
|
global_cfg = NS(qmt_base_url='unused', qmt_token='', qmt_data_dir=directory)
|
||||||
|
|||||||
Reference in New Issue
Block a user