optz
This commit is contained in:
66
labs/benchmarks/hotpaths.py
Normal file
66
labs/benchmarks/hotpaths.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Offline microbenchmarks; run with .venv/Scripts/python benchmarks/hotpaths.py."""
|
||||
|
||||
import sys
|
||||
from datetime import datetime, time
|
||||
from pathlib import Path
|
||||
from statistics import median
|
||||
from timeit import repeat
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from libs.calc import trading_time
|
||||
from sdk.models import _parse_datetime, _side
|
||||
from strategy.trend.open import _parse_minutes
|
||||
|
||||
|
||||
def original_date(date, clock):
|
||||
clock = clock.replace(':', '').zfill(6)
|
||||
try:
|
||||
return datetime.strptime(date.replace('-', '') + clock, '%Y%m%d%H%M%S')
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def original_minutes(value):
|
||||
try:
|
||||
hour_text, minute_text = value.strip().split(':')
|
||||
hour, minute = int(hour_text), int(minute_text)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not 0 <= hour <= 23 or not 0 <= minute <= 59:
|
||||
return None
|
||||
return hour * 60 + minute
|
||||
|
||||
|
||||
def original_trading_time(now):
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
return time(9, 30) <= now.time() <= time(11, 30) or time(13) <= now.time() <= time(15)
|
||||
|
||||
|
||||
def measure(name, before, after, number=10000):
|
||||
assert before() == after(), name
|
||||
old = median(repeat(before, number=number, repeat=5)) / number
|
||||
new = median(repeat(after, number=number, repeat=5)) / number
|
||||
print(f'{name:26} {old * 1e6:10.3f} -> {new * 1e6:10.3f} us {old / new:7.2f}x')
|
||||
|
||||
|
||||
def main():
|
||||
print(sys.version)
|
||||
print('Same interpreter, original versus optimized; cache timings are warm.')
|
||||
now = datetime(2026, 9, 7, 14)
|
||||
measure('order date', lambda: original_date('20260907', '100000'),
|
||||
lambda: _parse_datetime('20260907', '100000'))
|
||||
measure('signal time bound', lambda: original_minutes('9:30'), lambda: _parse_minutes('9:30'))
|
||||
measure('trading session', lambda: original_trading_time(now), lambda: trading_time(now))
|
||||
measure('order side', lambda: {'23': 'BUY', '24': 'SELL', '48': 'BUY', '49': 'SELL'}.get(str(23), ''),
|
||||
lambda: _side(23))
|
||||
positions = {f'{i:06}.SH': None for i in range(1000)}
|
||||
codes = list(positions)
|
||||
signals = [f'{i:06}.SH' for i in range(500, 2500)]
|
||||
measure('1000 positions/2000 signals', lambda: [c for c in signals if c not in codes],
|
||||
lambda: [c for c in signals if c not in positions], number=100)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
BIN
labs/tests/__pycache__/test_ipo.cpython-311.pyc
Normal file
BIN
labs/tests/__pycache__/test_ipo.cpython-311.pyc
Normal file
Binary file not shown.
BIN
labs/tests/__pycache__/test_market.cpython-311.pyc
Normal file
BIN
labs/tests/__pycache__/test_market.cpython-311.pyc
Normal file
Binary file not shown.
BIN
labs/tests/__pycache__/test_signal.cpython-311.pyc
Normal file
BIN
labs/tests/__pycache__/test_signal.cpython-311.pyc
Normal file
Binary file not shown.
BIN
labs/tests/__pycache__/test_trend.cpython-311.pyc
Normal file
BIN
labs/tests/__pycache__/test_trend.cpython-311.pyc
Normal file
Binary file not shown.
BIN
labs/tests/__pycache__/test_zt.cpython-311.pyc
Normal file
BIN
labs/tests/__pycache__/test_zt.cpython-311.pyc
Normal file
Binary file not shown.
7
labs/tests/ipo.py
Normal file
7
labs/tests/ipo.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from strategy.ipo import boot
|
||||
|
||||
def main():
|
||||
boot.AutoBuyIpo()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
78
labs/tests/test_deal_model.py
Normal file
78
labs/tests/test_deal_model.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import ast
|
||||
import unittest
|
||||
from dataclasses import asdict, fields
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
from libs.order import OrderBook as ActiveOrders
|
||||
from sdk.models import Assets, DealItem, OrderItem, PositionItem
|
||||
from sdk.portfolio import PortfolioMixin
|
||||
|
||||
# QMT 委托/成交的 offset_flag:48 买入、49 卖出。
|
||||
FLAG_BUY = 48
|
||||
|
||||
|
||||
class ApiModelTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
source = Path(__file__).resolve().parents[2] / 'api' / 'qmt_rest_new.py'
|
||||
names = {'format_assets', 'format_holding', 'format_orders', 'format_deals'}
|
||||
nodes = [n for n in ast.parse(source.read_text(encoding='utf-8')).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name in names]
|
||||
ns = {'HTTPError': RuntimeError}
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(source), 'exec'), ns)
|
||||
attrs = {n.attr: '' if n.attr.startswith('m_str') else 0
|
||||
for node in nodes for n in ast.walk(node)
|
||||
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=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',
|
||||
m_strInsertTime='100000', m_strTradeDate='20260907', m_strTradeTime='100000')
|
||||
obj = SimpleNamespace(**attrs)
|
||||
self.assets = ns['format_assets']([obj])
|
||||
self.positions = ns['format_holding']([obj])
|
||||
self.orders = ns['format_orders']([obj])
|
||||
self.deals = ns['format_deals']([obj])
|
||||
self.client = PortfolioMixin()
|
||||
self.client._get_json = {
|
||||
'/api/portfolio/assets': self.assets, '/api/portfolio/positions': self.positions,
|
||||
'/api/portfolio/order': self.orders, '/api/portfolio/deal': self.deals,
|
||||
'/api/portfolio': {'assets': self.assets, 'positions': self.positions, 'orders': self.orders},
|
||||
}.__getitem__
|
||||
|
||||
def test_models_exactly_match_api_keys_and_values(self):
|
||||
for model, row in ((Assets, self.assets), (PositionItem, self.positions['600000.SH']),
|
||||
(OrderItem, self.orders[0]), (DealItem, self.deals[0])):
|
||||
self.assertEqual({field.name for field in fields(model)}, set(row))
|
||||
self.assertEqual(asdict(model(**row)), row)
|
||||
|
||||
def test_all_endpoints(self):
|
||||
self.assertEqual(asdict(self.client.assets()), self.assets)
|
||||
codes, positions = self.client.positions()
|
||||
self.assertEqual(codes, ['600000.SH'])
|
||||
self.assertEqual(asdict(positions[0]), self.positions[codes[0]])
|
||||
self.assertEqual(asdict(self.client.orders()[0]), self.orders[0])
|
||||
self.assertEqual(asdict(self.client.deals()[0]), self.deals[0])
|
||||
portfolio = self.client.portfolio()
|
||||
self.assertEqual(asdict(portfolio.positions[codes[0]]), self.positions[codes[0]])
|
||||
self.assertEqual(asdict(portfolio.orders[0]), self.orders[0])
|
||||
|
||||
def test_derived_properties_and_order_cache(self):
|
||||
order = self.client.orders()[0]
|
||||
self.assertEqual(order.side, 'BUY')
|
||||
self.assertEqual(order.local_order_id, 'trend-BUY-1')
|
||||
self.assertEqual(order.created_at, datetime(2026, 9, 7, 10))
|
||||
order.order_status = 50
|
||||
order.insert_date = '20000101'
|
||||
client = Mock()
|
||||
book = ActiveOrders()
|
||||
book.refresh(client, [order])
|
||||
client.cancel_by_id.assert_called_once_with('sys1')
|
||||
self.assertTrue(book.busy('600000.SH', 'BUY'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
329
labs/tests/test_etf.py
Normal file
329
labs/tests/test_etf.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""ETF 离线回归:指标、真实防飞刀/网格算法、限仓、回报和持久化。"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
from sdk import Assets, OrderItem, Portfolio, PositionItem, Tick
|
||||
from strategy.etf.config import ETFConfig, load
|
||||
from strategy.etf.data import DAILY_URL, daily_bars, parse_daily
|
||||
from strategy.etf.engine import Engine
|
||||
from strategy.etf.indicators import Indicators, calculate
|
||||
from strategy.etf.state import Store
|
||||
|
||||
|
||||
CODE, OTHER = '510300.SH', '159915.SZ'
|
||||
NOW = datetime(2026, 9, 16, 10)
|
||||
IND = Indicators('20260915', 10, 0.2, 9.5, 10, 10.5, 0.2)
|
||||
|
||||
|
||||
def tick(price, now=NOW):
|
||||
return Tick(price, raw={'timetag': now.strftime('%Y%m%d %H:%M:%S')})
|
||||
|
||||
|
||||
def position(volume=0, cost=0, available=None):
|
||||
return PositionItem(stock_code=CODE, volume=volume, open_price=cost,
|
||||
can_use_volume=volume if available is None else available)
|
||||
|
||||
|
||||
class ETFTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temp.cleanup)
|
||||
self.path = Path(temp.name) / 'state.json'
|
||||
self.client = Mock()
|
||||
self.client.passorder.return_value = {'status': 'success'}
|
||||
self.cfg = ETFConfig(codes=(CODE,), min_commission=0, commission_rate=0)
|
||||
self.store = Store(self.path, 'test')
|
||||
self.engine = Engine(self.client, self.cfg, self.store, 0.1)
|
||||
|
||||
def run_price(self, price, pos=None, orders=(), cash=10000, now=NOW, ind=IND):
|
||||
portfolio = Portfolio(Assets(total=10000, available=cash), {CODE: pos or position()}, list(orders))
|
||||
self.engine.run(portfolio, {CODE: tick(price, now)}, {CODE: ind}, now)
|
||||
|
||||
def buy(self):
|
||||
self.run_price(9.4)
|
||||
self.run_price(9.46)
|
||||
self.client.passorder.assert_called_once()
|
||||
|
||||
def report(self, status=56, filled=100, side=23, price=9.46):
|
||||
pending = self.store.get(CODE).pending
|
||||
return OrderItem(stock_code=CODE, remark=pending['id'] + '|etf', offset_flag=side,
|
||||
volume_traded=filled, volume_total_original=pending['volume'],
|
||||
traded_price=price, order_status=status)
|
||||
|
||||
def test_boll_lower_requires_rebound_and_uses_fixed_limit_order(self):
|
||||
self.run_price(9.8)
|
||||
self.run_price(9.4)
|
||||
self.run_price(9.3)
|
||||
self.run_price(9.35)
|
||||
self.client.passorder.assert_not_called()
|
||||
self.run_price(9.36)
|
||||
request = self.client.passorder.call_args.kwargs
|
||||
self.assertEqual((request['volume'], request['price'], request['pr_type']), (100, 9.36, 11))
|
||||
self.assertEqual(request['strategy_name'], 'etf')
|
||||
self.assertTrue(self.store.get(CODE).pending)
|
||||
|
||||
def test_pending_written_before_network_and_retained_after_timeout(self):
|
||||
def submit(**kwargs):
|
||||
saved = Store(self.path, 'test').get(CODE).pending
|
||||
self.assertEqual(saved['id'], kwargs['order_id'])
|
||||
raise TimeoutError('unknown result')
|
||||
self.client.passorder.side_effect = submit
|
||||
self.run_price(9.4)
|
||||
with self.assertLogs(level='ERROR'):
|
||||
self.run_price(9.46)
|
||||
self.engine = Engine(self.client, self.cfg, Store(self.path, 'test'), 0.1)
|
||||
with self.assertLogs(level='WARNING'):
|
||||
self.run_price(9.3, now=NOW + timedelta(minutes=10))
|
||||
self.client.passorder.assert_called_once()
|
||||
|
||||
def test_filled_order_waits_for_position_snapshot(self):
|
||||
self.buy()
|
||||
report = self.report()
|
||||
with self.assertLogs(level='WARNING'):
|
||||
self.run_price(9.2, orders=[report])
|
||||
self.assertTrue(self.store.get(CODE).pending)
|
||||
self.run_price(9.2, position(100, 9.46, 0), [report])
|
||||
self.assertFalse(self.store.get(CODE).pending)
|
||||
self.assertEqual(self.store.get(CODE).last_buy, 9.46)
|
||||
self.client.passorder.assert_called_once()
|
||||
|
||||
def test_add_requires_another_grid_below_actual_fill(self):
|
||||
self.buy()
|
||||
report = self.report()
|
||||
pos = position(100, 9.46, 0)
|
||||
self.run_price(9.4, pos, [report])
|
||||
self.run_price(9.46, pos)
|
||||
self.client.passorder.assert_called_once()
|
||||
self.run_price(9.1, pos)
|
||||
self.run_price(9.16, pos)
|
||||
self.assertEqual(self.client.passorder.call_count, 2)
|
||||
|
||||
def test_partial_cancel_records_actual_fill_and_never_exceeds_cap(self):
|
||||
self.cfg = ETFConfig(codes=(CODE,), buy_hands=2, min_commission=0, commission_rate=0)
|
||||
self.engine = Engine(self.client, self.cfg, self.store, 0.1)
|
||||
pos = position(800, 10)
|
||||
self.run_price(9.4, pos)
|
||||
self.run_price(9.46, pos)
|
||||
report = self.report(status=53, filled=100)
|
||||
self.run_price(9.1, position(900, 9.94), [report])
|
||||
self.run_price(9.16, position(900, 9.94))
|
||||
self.client.passorder.assert_called_once()
|
||||
self.assertFalse(self.store.get(CODE).pending)
|
||||
|
||||
def test_full_position_blocks_buy_and_zero_position_is_not_a_warning(self):
|
||||
self.run_price(9.4, position(1000, 10))
|
||||
self.run_price(9.46, position(1000, 10))
|
||||
self.client.passorder.assert_not_called()
|
||||
with self.assertNoLogs(level='WARNING'):
|
||||
self.run_price(9.8, position())
|
||||
|
||||
def test_zero_position_with_retained_broker_cost_can_reopen(self):
|
||||
self.run_price(9.4, position(0, 10))
|
||||
self.run_price(9.46, position(0, 10))
|
||||
self.client.passorder.assert_called_once()
|
||||
|
||||
def test_rejected_order_is_logged_and_does_not_advance_anchor(self):
|
||||
self.buy()
|
||||
report = self.report(status=57, filled=0)
|
||||
self.run_price(9.8, orders=[report])
|
||||
self.assertEqual(self.store.get(CODE).last_buy, 0)
|
||||
self.assertFalse(self.store.get(CODE).pending)
|
||||
|
||||
def test_t_plus_one_tracks_peak_but_only_sells_available_whole_lots(self):
|
||||
self.run_price(10.7, position(200, 10, 0))
|
||||
self.run_price(10.55, position(200, 10, 0))
|
||||
self.client.passorder.assert_not_called()
|
||||
self.engine = Engine(self.client, self.cfg, Store(self.path, 'test'), 0.1)
|
||||
self.run_price(10.55, position(200, 10, 100))
|
||||
order = self.client.passorder.call_args.kwargs
|
||||
self.assertEqual((order['op_type'], order['volume']), (24, 100))
|
||||
|
||||
def test_drop_below_activation_price_still_triggers_profitable_retreat(self):
|
||||
self.run_price(10.7, position(100, 10))
|
||||
self.run_price(10.4, position(100, 10))
|
||||
self.assertEqual(self.client.passorder.call_args.kwargs['op_type'], 24)
|
||||
|
||||
def test_cost_change_and_flat_position_reset_peak(self):
|
||||
self.run_price(10.7, position(100, 10))
|
||||
self.assertTrue(self.store.get(CODE).armed)
|
||||
self.run_price(10.5, position(200, 10.4))
|
||||
self.assertFalse(self.store.get(CODE).armed)
|
||||
self.run_price(9.8, position())
|
||||
self.assertEqual(self.store.get(CODE).last_buy, 0)
|
||||
self.client.passorder.assert_not_called()
|
||||
|
||||
def test_fee_floor_prevents_loss_after_commission(self):
|
||||
cfg = ETFConfig(codes=(CODE,), min_commission=50, commission_rate=0)
|
||||
self.engine = Engine(self.client, cfg, self.store, 0.1)
|
||||
self.run_price(10.7, position(100, 10))
|
||||
self.run_price(10.55, position(100, 10))
|
||||
self.client.passorder.assert_not_called()
|
||||
|
||||
def test_cash_reserve_and_fixed_lot_no_downsizing(self):
|
||||
self.run_price(9.4, cash=1900)
|
||||
self.run_price(9.46, cash=1900)
|
||||
self.client.passorder.assert_not_called()
|
||||
|
||||
def test_multiple_symbols_share_one_cash_budget(self):
|
||||
cfg = ETFConfig(codes=(CODE, OTHER), min_commission=0, commission_rate=0)
|
||||
self.engine = Engine(self.client, cfg, self.store, 0.1)
|
||||
portfolio = Portfolio(Assets(total=10000, available=2500), {}, [])
|
||||
for price in (9.4, 9.46):
|
||||
self.engine.run(portfolio, {c: tick(price) for c in cfg.codes}, {c: IND for c in cfg.codes}, NOW)
|
||||
self.client.passorder.assert_called_once()
|
||||
|
||||
def test_other_strategy_order_blocks_same_symbol_without_cancel(self):
|
||||
report = OrderItem(stock_code=CODE, remark='TREN-BUY-other', offset_flag=23,
|
||||
order_status=50, insert_date='20260916', insert_time='093000')
|
||||
self.run_price(9.4, orders=[report])
|
||||
self.run_price(9.46, orders=[report])
|
||||
self.client.passorder.assert_not_called()
|
||||
self.client.cancel_by_id.assert_not_called()
|
||||
|
||||
def test_pending_later_symbol_reserves_cash_before_first_symbol(self):
|
||||
cfg = ETFConfig(codes=(CODE, OTHER), min_commission=0, commission_rate=0)
|
||||
self.store.get(OTHER).pending = dict(id='ETF-BUY-pending', side='BUY', volume=100,
|
||||
base_volume=0, reserved=950)
|
||||
self.engine = Engine(self.client, cfg, self.store, 0.1)
|
||||
portfolio = Portfolio(Assets(total=10000, available=2500), {}, [])
|
||||
with self.assertLogs(level='WARNING'):
|
||||
for price in (9.4, 9.46):
|
||||
self.engine.run(portfolio, {CODE: tick(price)}, {CODE: IND}, NOW)
|
||||
self.client.passorder.assert_not_called()
|
||||
|
||||
def test_on_road_or_unknown_order_never_opens_another_buy(self):
|
||||
pos = position()
|
||||
pos.on_road_volume = 100
|
||||
self.run_price(9.4, pos)
|
||||
self.run_price(9.46, pos)
|
||||
unknown = OrderItem(stock_code=CODE, order_status=255)
|
||||
self.run_price(9.4, orders=[unknown])
|
||||
self.run_price(9.46, orders=[unknown])
|
||||
self.client.passorder.assert_not_called()
|
||||
|
||||
def test_excluded_symbol_is_neither_bought_nor_sold(self):
|
||||
self.engine.excluded.add(CODE)
|
||||
self.run_price(9.4)
|
||||
self.run_price(9.46)
|
||||
self.run_price(10.7, position(100, 10))
|
||||
self.run_price(10.55, position(100, 10))
|
||||
self.client.passorder.assert_not_called()
|
||||
|
||||
def test_invalid_or_stale_tick_cannot_trade(self):
|
||||
for t in (None, Tick(10), tick(float('nan')), tick(9.4, NOW - timedelta(days=1)),
|
||||
tick(9.4, NOW - timedelta(seconds=91))):
|
||||
self.assertFalse(self.engine.fresh_tick(t, NOW))
|
||||
self.assertTrue(self.engine.fresh_tick(tick(9.4), NOW))
|
||||
|
||||
def test_corrupt_state_does_not_silently_start_empty(self):
|
||||
self.path.write_text('{', encoding='utf-8')
|
||||
with self.assertRaises(ValueError):
|
||||
Store(self.path, 'test')
|
||||
|
||||
|
||||
class IndicatorTests(unittest.TestCase):
|
||||
def bars(self):
|
||||
days = []
|
||||
day = date(2026, 9, 15)
|
||||
while len(days) < 80:
|
||||
if day.weekday() < 5:
|
||||
days.append(day)
|
||||
day -= timedelta(days=1)
|
||||
return [dict(date=d.strftime('%Y%m%d'), high=11, low=9, close=10) for d in reversed(days)]
|
||||
|
||||
def test_known_constant_series_and_exclusion_of_unfinished_day(self):
|
||||
cfg = ETFConfig(codes=(CODE,))
|
||||
rows = self.bars() + [dict(date='20260916', high=999, low=1, close=999)]
|
||||
ind = calculate(rows, NOW.date(), cfg)
|
||||
self.assertEqual((ind.ma60, ind.atr, ind.lower, ind.upper, ind.grid), (10, 2, 10, 10, 2))
|
||||
|
||||
def test_atr_accounts_for_gap_and_uses_wilder_smoothing(self):
|
||||
rows = self.bars()
|
||||
rows[-1].update(high=14, low=12, close=13)
|
||||
ind = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,)))
|
||||
self.assertAlmostEqual(ind.atr, (2 * 13 + 4) / 14)
|
||||
self.assertAlmostEqual(ind.ma60, 10.05)
|
||||
self.assertGreater(ind.upper, ind.middle)
|
||||
|
||||
def test_bad_or_insufficient_history_is_rejected(self):
|
||||
cfg = ETFConfig(codes=(CODE,))
|
||||
for rows in (self.bars()[:59], self.bars() + [self.bars()[-1]],
|
||||
self.bars()[:-1] + [dict(self.bars()[-1], close=float('nan'))]):
|
||||
with self.assertRaises(ValueError):
|
||||
calculate(rows, NOW.date(), cfg)
|
||||
|
||||
def test_grid_floor_and_tick_rounding(self):
|
||||
rows = [dict(row, high=10.001, low=9.999) for row in self.bars()]
|
||||
ind = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,), min_grid_pct=0.501))
|
||||
self.assertEqual(ind.grid, 0.051)
|
||||
|
||||
|
||||
class ConfigAndDataTests(unittest.TestCase):
|
||||
def test_config_rejects_excess_hands_and_invalid_codes(self):
|
||||
for kwargs in ({'max_hands': 11}, {'buy_hands': 11}, {'buy_hands': True},
|
||||
{'atr_multiplier': float('nan')}, {'codes': ('920202.BJ',)},
|
||||
{'codes': (CODE, CODE)}, {'codes': ()}):
|
||||
with self.assertRaises(ValueError):
|
||||
ETFConfig(**dict({'codes': (CODE,)}, **kwargs))
|
||||
|
||||
def test_default_file_loads(self):
|
||||
cfg = load()
|
||||
self.assertEqual((cfg.buy_hands, cfg.max_hands), (1, 10))
|
||||
|
||||
|
||||
|
||||
class DailyDataTests(unittest.TestCase):
|
||||
def row(self, day=20260915, **changes):
|
||||
return dict(dict(ts_code=CODE, trade_date=day, open=10, high=11, low=9, close=10), **changes)
|
||||
|
||||
def test_request_and_sample_shape(self):
|
||||
def respond(request):
|
||||
self.assertEqual(str(request.url), DAILY_URL + '?code=' + CODE)
|
||||
self.assertNotIn('x-token', request.headers)
|
||||
return httpx.Response(200, json={'code': 0, 'message': '', 'details': [self.row()]})
|
||||
with httpx.Client(transport=httpx.MockTransport(respond)) as client:
|
||||
self.assertEqual(daily_bars(client, CODE, NOW.date()),
|
||||
[dict(date='20260915', open=10.0, high=11.0, low=9.0, close=10.0)])
|
||||
|
||||
def test_sort_filter_then_limit_and_numeric_strings(self):
|
||||
payload = dict(code=0, details=[self.row(20260916), self.row(20260915, close='10.5'),
|
||||
self.row(20260914), self.row(20260917)])
|
||||
bars = parse_daily(payload, CODE, NOW.date(), count=1)
|
||||
self.assertEqual([b['date'] for b in bars], ['20260915'])
|
||||
self.assertEqual(bars[0]['close'], 10.5)
|
||||
|
||||
def test_http_error_and_invalid_json_propagate(self):
|
||||
for status, content in ((404, '{}'), (200, '<html>error</html>')):
|
||||
with httpx.Client(transport=httpx.MockTransport(
|
||||
lambda r: httpx.Response(status, text=content))) as client:
|
||||
with self.assertRaises((httpx.HTTPStatusError, ValueError)):
|
||||
daily_bars(client, CODE, NOW.date())
|
||||
|
||||
def test_bad_business_response_is_rejected(self):
|
||||
for payload in (None, [], {}, {'code': False, 'details': [self.row()]},
|
||||
{'code': 1, 'message': 'failed'}, {'code': 0, 'details': []},
|
||||
{'code': 0, 'details': {}}, {'code': 0, 'details': None}):
|
||||
with self.subTest(payload=payload), self.assertRaises(ValueError):
|
||||
parse_daily(payload, CODE, NOW.date())
|
||||
|
||||
def test_wrong_symbol_duplicate_dates_and_invalid_ohlc_are_rejected(self):
|
||||
for rows in ([self.row(ts_code=OTHER)], [self.row(), self.row()],
|
||||
[self.row(20260230)], [self.row(close=float('nan'))],
|
||||
[self.row(open=True)], [self.row(low=12)], [self.row(close=None)]):
|
||||
with self.subTest(rows=rows), self.assertRaises(ValueError):
|
||||
parse_daily(dict(code=0, details=rows), CODE, NOW.date())
|
||||
|
||||
def test_external_history_flows_into_real_indicators(self):
|
||||
details = [self.row(int(row['date'])) for row in IndicatorTests().bars()]
|
||||
rows = parse_daily(dict(code=0, details=details), CODE, NOW.date())
|
||||
ind = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,)))
|
||||
self.assertEqual((ind.ma60, ind.atr, ind.grid), (10, 2, 2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
264
labs/tests/test_ipo.py
Normal file
264
labs/tests/test_ipo.py
Normal file
@@ -0,0 +1,264 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from threading import Barrier
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from libs.lockfile import claim_json
|
||||
from sdk import OrderItem
|
||||
from sdk.trade import TradeMixin
|
||||
from strategy.ipo import boot
|
||||
|
||||
|
||||
class IPOResponseClient(TradeMixin):
|
||||
"""真实解析 + 模拟传输:用于验证 QMT 原始响应到下单的完整链路。"""
|
||||
|
||||
def __init__(self, payload, orders=None):
|
||||
self.payload = payload
|
||||
self._orders = orders or []
|
||||
self.submitted = []
|
||||
|
||||
def _post_json(self, path, body=None):
|
||||
return self.payload
|
||||
|
||||
def orders(self):
|
||||
return self._orders
|
||||
|
||||
def passorder(self, **kwargs):
|
||||
self.submitted.append(kwargs)
|
||||
return {'status': 'success'}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
|
||||
class IPOTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temp.cleanup)
|
||||
self.root = Path(temp.name)
|
||||
self.account = NS(account_id='account-a', enable_auto_ipo=True)
|
||||
self.global_cfg = NS(qmt_data_dir=temp.name, qmt_base_url='unused', qmt_token='')
|
||||
self.client = Mock()
|
||||
self.client.__enter__ = Mock(return_value=self.client)
|
||||
self.client.__exit__ = Mock(return_value=False)
|
||||
self.client.orders.return_value = []
|
||||
self.client.ipo_data.return_value = [dict(stock='600001.SH', issuePrice=10, maxPurchaseNum=100)]
|
||||
for target, value in [('account_config', self.account), ('global_config', self.global_cfg)]:
|
||||
ctx = patch.object(boot.config, target, value)
|
||||
ctx.start()
|
||||
self.addCleanup(ctx.stop)
|
||||
ctx = patch.object(boot, 'Client', return_value=self.client)
|
||||
ctx.start()
|
||||
self.addCleanup(ctx.stop)
|
||||
ctx = patch.object(boot, 'datetime')
|
||||
self.clock = ctx.start()
|
||||
self.clock.now.return_value = datetime(2026, 9, 11, 10)
|
||||
self.addCleanup(ctx.stop)
|
||||
|
||||
def records(self):
|
||||
return [json.loads(p.read_text(encoding='utf-8')) for p in sorted(self.root.rglob('*.json'))]
|
||||
|
||||
def order(self, status, traded=0):
|
||||
return OrderItem(stock_code='600001.SH', insert_date='20260911',
|
||||
remark=self.records()[-1]['order_id'] + '|ipo', offset_flag=23,
|
||||
order_status=status, volume_traded=traded)
|
||||
|
||||
def test_timeout_stays_pending_and_queries_before_next_attempt(self):
|
||||
self.client.passorder.side_effect = TimeoutError('response lost')
|
||||
self.assertEqual(boot.AutoBuyIpo(), 0)
|
||||
self.assertEqual(self.records()[0]['status'], 'pending')
|
||||
self.assertEqual(boot.AutoBuyIpo(), 0)
|
||||
self.assertEqual(self.client.passorder.call_count, 1)
|
||||
self.assertEqual(self.client.orders.call_count, 2)
|
||||
|
||||
def test_normal_http_response_is_not_confirmation(self):
|
||||
self.client.passorder.return_value = {'status': 'success'}
|
||||
self.assertEqual(boot.AutoBuyIpo(), 1)
|
||||
self.assertEqual(self.records()[0]['status'], 'pending')
|
||||
self.assertEqual(boot.AutoBuyIpo(), 0)
|
||||
self.client.passorder.assert_called_once()
|
||||
|
||||
def test_rejection_allows_one_new_attempt_with_new_identity(self):
|
||||
boot.AutoBuyIpo()
|
||||
old_id = self.records()[0]['order_id']
|
||||
self.client.orders.return_value = [self.order(57)]
|
||||
self.assertEqual(boot.AutoBuyIpo(), 1)
|
||||
self.assertEqual([r['status'] for r in self.records()], ['rejected', 'pending'])
|
||||
self.assertNotEqual(self.records()[1]['order_id'], old_id)
|
||||
boot.AutoBuyIpo() # Old rejection cannot authorize retry of the new attempt.
|
||||
self.assertEqual(self.client.passorder.call_count, 2)
|
||||
|
||||
def test_completed_order_confirms_and_prevents_resubmission(self):
|
||||
boot.AutoBuyIpo()
|
||||
self.client.orders.return_value = [self.order(56)]
|
||||
self.assertEqual(boot.AutoBuyIpo(), 0)
|
||||
self.assertEqual(self.records()[0]['status'], 'confirmed')
|
||||
self.client.orders.return_value = []
|
||||
boot.AutoBuyIpo()
|
||||
self.client.passorder.assert_called_once()
|
||||
|
||||
def test_active_unknown_and_partial_orders_never_retry(self):
|
||||
boot.AutoBuyIpo()
|
||||
for status, traded in [(50, 0), (255, 0), (55, 40), (57, 40), (54, 0)]:
|
||||
with self.subTest(status=status, traded=traded):
|
||||
self.client.orders.return_value = [self.order(status, traded)]
|
||||
self.assertEqual(boot.AutoBuyIpo(), 0)
|
||||
self.client.passorder.assert_called_once()
|
||||
|
||||
def test_orders_query_failure_does_not_submit(self):
|
||||
self.client.orders.side_effect = TimeoutError('unavailable')
|
||||
self.assertEqual(boot.AutoBuyIpo(), 0)
|
||||
self.client.passorder.assert_not_called()
|
||||
self.assertEqual(self.records(), [])
|
||||
|
||||
def test_accounts_do_not_share_reservations(self):
|
||||
boot.AutoBuyIpo()
|
||||
self.account.account_id = 'account-b'
|
||||
self.assertEqual(boot.AutoBuyIpo(), 1)
|
||||
self.assertEqual(self.client.passorder.call_count, 2)
|
||||
self.assertEqual({r['account'] for r in self.records()}, {'account-a', 'account-b'})
|
||||
|
||||
def test_manual_same_day_order_prevents_new_submission(self):
|
||||
self.client.orders.return_value = [OrderItem(stock_code='600001.SH', offset_flag=23,
|
||||
order_status=50, insert_date='2026-09-11')]
|
||||
boot.AutoBuyIpo()
|
||||
self.client.passorder.assert_not_called()
|
||||
|
||||
def test_duplicate_candidates_only_submit_once(self):
|
||||
self.client.ipo_data.return_value *= 2
|
||||
self.assertEqual(boot.AutoBuyIpo(), 1)
|
||||
self.client.passorder.assert_called_once()
|
||||
|
||||
def test_concurrent_initial_and_rejected_attempts_are_atomic(self):
|
||||
for orders in [[], None]:
|
||||
if orders is None:
|
||||
orders = [self.order(57)]
|
||||
barrier = Barrier(2)
|
||||
def claim(path, record):
|
||||
barrier.wait(timeout=5)
|
||||
return claim_json(path, record)
|
||||
with patch.object(boot, 'claim_json', side_effect=claim), ThreadPoolExecutor(2) as pool:
|
||||
futures = [pool.submit(boot._subscribe, self.client, orders, 'account-a',
|
||||
'20260911', '600001.SH', 10, 100) for _ in range(2)]
|
||||
self.assertEqual(sum(f.result() for f in futures), 1)
|
||||
self.assertEqual(self.client.passorder.call_count, 2)
|
||||
|
||||
def test_pending_record_is_durable_before_request(self):
|
||||
def submitted(**kwargs):
|
||||
record = self.records()[0]
|
||||
self.assertEqual(record['status'], 'pending')
|
||||
self.assertEqual(record['order_id'], kwargs['order_id'])
|
||||
self.client.passorder.side_effect = submitted
|
||||
self.assertEqual(boot.AutoBuyIpo(), 1)
|
||||
|
||||
def test_claim_failure_never_submits(self):
|
||||
with patch.object(boot, 'claim_json', side_effect=OSError('disk failed')):
|
||||
self.assertEqual(boot.AutoBuyIpo(), 0)
|
||||
self.client.passorder.assert_not_called()
|
||||
|
||||
def test_invalid_candidate_is_skipped_without_blocking_good_one(self):
|
||||
good = self.client.ipo_data.return_value[0]
|
||||
invalid = [dict(good, stock='600../x.SH'), dict(good, issuePrice=float('nan')),
|
||||
dict(good, issuePrice=float('inf')), dict(good, maxPurchaseNum=100.5),
|
||||
dict(good, maxPurchaseNum=True), dict(good, maxPurchaseNum='NaN'),
|
||||
dict(good, maxPurchaseNum=0), dict(good, issuePrice=False),
|
||||
dict(good, stock='600001.SZ'), None]
|
||||
self.client.ipo_data.return_value = invalid + [good]
|
||||
self.assertEqual(boot.AutoBuyIpo(), 1)
|
||||
self.client.passorder.assert_called_once()
|
||||
self.assertEqual(len(self.records()), 1)
|
||||
|
||||
def test_code_keyed_qmt_response_submits_subscription(self):
|
||||
client = IPOResponseClient(
|
||||
{'301001.SZ': {'issuePrice': 12.5, 'maxPurchaseNum': 15000}})
|
||||
with patch.object(boot, 'Client', return_value=client):
|
||||
self.assertEqual(boot.AutoBuyIpo(), 1)
|
||||
self.assertEqual(len(client.submitted), 1)
|
||||
self.assertEqual(client.submitted[0]['stock'], '301001.SZ')
|
||||
self.assertEqual(client.submitted[0]['volume'], 15000)
|
||||
self.assertEqual(client.submitted[0]['price'], 12.5)
|
||||
self.assertEqual([r['status'] for r in self.records()], ['pending'])
|
||||
|
||||
def test_beijing_candidate_is_excluded_without_error(self):
|
||||
client = IPOResponseClient({
|
||||
'920202.BJ': {'issuePrice': 7.55, 'maxPurchaseNum': 1190000},
|
||||
'301716.SZ': {'issuePrice': 10, 'maxPurchaseNum': 3500},
|
||||
})
|
||||
with patch.object(boot, 'Client', return_value=client), self.assertLogs(level='INFO') as logs:
|
||||
self.assertEqual(boot.AutoBuyIpo(), 1)
|
||||
self.assertTrue(all(record.levelno == 20 for record in logs.records))
|
||||
self.assertEqual([order['stock'] for order in client.submitted], ['301716.SZ'])
|
||||
self.assertEqual([record['stock'] for record in self.records()], ['301716.SZ'])
|
||||
|
||||
def test_supported_codes_and_numeric_strings(self):
|
||||
for stock in ['600001.SH', '688001.SH', '689001.SH', '000001.SZ',
|
||||
'001001.SZ', '002001.SZ', '003001.SZ', '300001.SZ', '301001.SZ']:
|
||||
self.assertEqual(boot._candidate(dict(stock=stock, issuePrice='10.5', maxPurchaseNum='100')),
|
||||
(stock, 10.5, 100))
|
||||
for stock in ['600abc.SH', '600001', '600001.SH/x', '688001.SZ', '300001.SH', None]:
|
||||
self.assertFalse(boot.is_target_stock(stock))
|
||||
|
||||
|
||||
class IPOResponseTests(unittest.TestCase):
|
||||
def parsed(self, value):
|
||||
client = TradeMixin()
|
||||
client._post_json = Mock(return_value=value)
|
||||
return client.ipo_data()
|
||||
|
||||
def test_code_keyed_mapping_is_parsed(self):
|
||||
payload = {
|
||||
'301001.SZ': {'issuePrice': 12.5, 'maxPurchaseNum': 15000, 'stockName': '示例'},
|
||||
'601127.SH': {'issuePrice': 4.09, 'maxPurchaseNum': 15000},
|
||||
}
|
||||
self.assertEqual(self.parsed(payload), [
|
||||
dict(stock='301001.SZ', issuePrice=12.5, maxPurchaseNum=15000, stockName='示例'),
|
||||
dict(stock='601127.SH', issuePrice=4.09, maxPurchaseNum=15000),
|
||||
])
|
||||
|
||||
def test_bare_code_uses_market_field(self):
|
||||
payload = {'301001': {'market': 'SZ', 'issuePrice': 12.5, 'maxPurchaseNum': 15000}}
|
||||
self.assertEqual(self.parsed(payload), [
|
||||
dict(stock='301001.SZ', market='SZ', issuePrice=12.5, maxPurchaseNum=15000),
|
||||
])
|
||||
|
||||
def test_market_bucketed_mapping_is_flattened(self):
|
||||
payload = {
|
||||
'SH': {'601127': {'issuePrice': 4.09, 'maxPurchaseNum': 15000}},
|
||||
'SZ': {'301001': {'issuePrice': 12.5, 'maxPurchaseNum': 15000}},
|
||||
}
|
||||
self.assertEqual(self.parsed(payload), [
|
||||
dict(stock='601127.SH', market='SH', issuePrice=4.09, maxPurchaseNum=15000),
|
||||
dict(stock='301001.SZ', market='SZ', issuePrice=12.5, maxPurchaseNum=15000),
|
||||
])
|
||||
|
||||
def test_legacy_data_wrapper_is_unwrapped(self):
|
||||
payload = {'data': {'301001.SZ': {'issuePrice': 12.5, 'maxPurchaseNum': 15000}}}
|
||||
self.assertEqual(self.parsed(payload), [
|
||||
dict(stock='301001.SZ', issuePrice=12.5, maxPurchaseNum=15000),
|
||||
])
|
||||
|
||||
def test_list_response_passes_through(self):
|
||||
payload = [dict(stock='600001.SH', issuePrice=10, maxPurchaseNum=100)]
|
||||
self.assertEqual(self.parsed(payload), payload)
|
||||
|
||||
def test_empty_response_means_no_candidate(self):
|
||||
for value in [None, {}, [], {'SH': {}, 'SZ': {}}]:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(self.parsed(value), [])
|
||||
|
||||
def test_unrecognised_structure_raises(self):
|
||||
for value in ['', 0, False, 'unexpected', {'error': 'bad'}, {'SH': 'x'}]:
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
self.parsed(value)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
86
labs/tests/test_orderbook.py
Normal file
86
labs/tests/test_orderbook.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""委托簿:在途状态、方向锁、以及撤单范围。"""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock
|
||||
|
||||
from libs.order import BUSY_STATUSES, OrderBook, TRACKED_STATUSES
|
||||
from sdk import OrderItem
|
||||
|
||||
|
||||
def order(index, remark, status=50, side=23, age_minutes=30):
|
||||
stamp = datetime.now() - timedelta(minutes=age_minutes)
|
||||
return OrderItem(stock_code=f'60000{index}.SH', order_sys_id=f'sys{index}',
|
||||
remark=remark, order_status=status, offset_flag=side,
|
||||
insert_date=stamp.strftime('%Y%m%d'),
|
||||
insert_time=stamp.strftime('%H%M%S'))
|
||||
|
||||
|
||||
def cancelled(client):
|
||||
return [call.args[0] for call in client.cancel_by_id.call_args_list]
|
||||
|
||||
|
||||
class CancelScopeTests(unittest.TestCase):
|
||||
"""撤单必须限本策略前缀;防重则继续看全账户在途。"""
|
||||
|
||||
def test_zt_prefix_cancels_only_its_own_orders(self):
|
||||
orders = [order(0, 'zt-base-own'), order(1, 'zt-SELL-own'),
|
||||
order(2, 'zt-entry-own'), order(3, 'IPO-new'),
|
||||
order(4, ''), order(5, 'TREN-BUY-other')]
|
||||
client = Mock()
|
||||
book = OrderBook()
|
||||
book.refresh(client, orders, cancel_prefix='zt-')
|
||||
self.assertEqual(cancelled(client), ['sys0', 'sys1', 'sys2'])
|
||||
self.assertEqual(book.data, orders) # 撤单后仍保留在途锁
|
||||
self.assertTrue(all(book.busy(o.stock_code, 'BUY') for o in orders))
|
||||
|
||||
def test_default_prefix_still_cancels_every_non_ipo_order(self):
|
||||
orders = [order(0, 'zt-base-own'), order(1, 'TREN-BUY-other'),
|
||||
order(2, 'IPO-new'), order(3, '')]
|
||||
client = Mock()
|
||||
OrderBook().refresh(client, orders)
|
||||
self.assertEqual(cancelled(client), ['sys0', 'sys1', 'sys3'])
|
||||
|
||||
def test_fresh_and_unreportable_orders_are_never_cancelled(self):
|
||||
# 48(未报)在跟踪集合内但不可撤;刚提交的委托也不撤。
|
||||
orders = [order(0, 'zt-entry-fresh', age_minutes=0),
|
||||
order(1, 'zt-entry-filled', status=56),
|
||||
order(2, 'zt-entry-unreported', status=48)]
|
||||
client = Mock()
|
||||
book = OrderBook()
|
||||
book.refresh(client, orders, cancel_prefix='zt-')
|
||||
client.cancel_by_id.assert_not_called()
|
||||
self.assertEqual(book.data, orders)
|
||||
|
||||
|
||||
class BusyLockTests(unittest.TestCase):
|
||||
def test_only_busy_statuses_lock_a_direction(self):
|
||||
book = OrderBook()
|
||||
client = Mock()
|
||||
book.refresh(client, [order(0, 'zt-entry-a', status=50)], cancel_prefix='zt-')
|
||||
self.assertTrue(book.busy('600000.SH', 'BUY'))
|
||||
book.refresh(client, [order(1, 'zt-entry-b', status=56)], cancel_prefix='zt-')
|
||||
self.assertFalse(book.busy('600001.SH', 'BUY'))
|
||||
|
||||
def test_busy_and_tracked_status_sets_are_disjoint_as_designed(self):
|
||||
self.assertNotIn('56', BUSY_STATUSES)
|
||||
self.assertTrue(BUSY_STATUSES <= TRACKED_STATUSES)
|
||||
self.assertIn('56', TRACKED_STATUSES)
|
||||
|
||||
def test_place_marks_the_direction_busy_before_submitting(self):
|
||||
book = OrderBook()
|
||||
book.busy_cache.set('BUY-600000.SH', True, timeout=180)
|
||||
self.assertTrue(book.busy('600000.SH', 'BUY'))
|
||||
self.assertFalse(book.busy('600000.SH', 'SELL'))
|
||||
|
||||
def test_unknown_offset_flag_never_places_an_order(self):
|
||||
from libs.order import PlaceOrderRequest
|
||||
book = OrderBook()
|
||||
client = Mock()
|
||||
self.assertFalse(book.place(client, PlaceOrderRequest(99, '600000.SH', 100,
|
||||
'zt-x', 'zt')))
|
||||
client.passorder.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
113
labs/tests/test_python314_performance.py
Normal file
113
labs/tests/test_python314_performance.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Offline regression checks for the Python 3.14 performance changes."""
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import unittest
|
||||
from annotationlib import Format, get_annotations
|
||||
from concurrent.futures import Future
|
||||
from datetime import datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from libs.calc import trading_time
|
||||
from libs.signal import SignalItem
|
||||
from sdk.models import OrderItem, _parse_datetime
|
||||
from strategy.trend import boot
|
||||
from strategy.trend.open import _parse_minutes, check_timezone
|
||||
|
||||
|
||||
class PerformanceRegressionTests(unittest.TestCase):
|
||||
def test_trading_time_matches_original_for_week_and_boundaries(self):
|
||||
start = datetime(2026, 9, 7)
|
||||
for minute in range(7 * 24 * 60):
|
||||
now = start + timedelta(minutes=minute)
|
||||
expected = now.weekday() < 5 and (
|
||||
time(9, 30) <= now.time() <= time(11, 30)
|
||||
or time(13) <= now.time() <= time(15)
|
||||
)
|
||||
self.assertEqual(trading_time(now), expected, now)
|
||||
for clock in ((11, 30), (15, 0)):
|
||||
self.assertFalse(trading_time(start.replace(hour=clock[0], minute=clock[1], microsecond=1)))
|
||||
|
||||
def test_date_parser_preserves_strptime_acceptance(self):
|
||||
for date in ('20260907', '2026-09-07', '', '20260229', '20240229', '202691'):
|
||||
for clock in ('100000', '10:00:00', '93000', '', '240000', 'bad', '1'):
|
||||
try:
|
||||
expected = datetime.strptime(date.replace('-', '') + clock.replace(':', '').zfill(6), '%Y%m%d%H%M%S')
|
||||
except ValueError:
|
||||
expected = None
|
||||
self.assertEqual(_parse_datetime(date, clock), expected, (date, clock))
|
||||
|
||||
def test_date_cache_tracks_mutable_order_fields(self):
|
||||
order = OrderItem(insert_date='20260907', insert_time='100000', remark='first|trend')
|
||||
self.assertEqual(order.created_at, datetime(2026, 9, 7, 10))
|
||||
order.insert_time = '110000'
|
||||
order.remark = 'second|trend'
|
||||
self.assertEqual(order.created_at, datetime(2026, 9, 7, 11))
|
||||
self.assertEqual(order.local_order_id, 'second')
|
||||
self.assertEqual(order.get_local_order_id, 'second')
|
||||
|
||||
def test_caches_are_bounded(self):
|
||||
_parse_minutes.cache_clear()
|
||||
_parse_datetime.cache_clear()
|
||||
for i in range(4200):
|
||||
_parse_datetime('invalid', str(i))
|
||||
_parse_minutes(str(i))
|
||||
self.assertLessEqual(_parse_datetime.cache_info().currsize, 4096)
|
||||
self.assertLessEqual(_parse_minutes.cache_info().currsize, 256)
|
||||
|
||||
def test_timezone_boundaries_and_current_time_not_cached(self):
|
||||
for hour in range(24):
|
||||
for minute in range(60):
|
||||
now = datetime(2026, 9, 7, hour, minute)
|
||||
m = hour * 60 + minute
|
||||
self.assertEqual(check_timezone('9:30-10:30,invalid,23:00-1:00', now),
|
||||
570 <= m <= 630 or m >= 1380 or m <= 60)
|
||||
self.assertTrue(check_timezone('*'))
|
||||
self.assertFalse(check_timezone('24:00-25:00'))
|
||||
self.assertFalse(check_timezone(''))
|
||||
|
||||
def test_signal_order_duplicates_and_request_order_preserved(self):
|
||||
future = Future()
|
||||
future.set_result(None)
|
||||
assets = SimpleNamespace(available=100, total=100)
|
||||
portfolio = SimpleNamespace(assets=assets, positions={'held': object()}, orders=[])
|
||||
run = SimpleNamespace(client=Mock(), orders=Mock(), executor=Mock(),
|
||||
account_cfg=SimpleNamespace(account_id='test', min_cash_ratio=0.1))
|
||||
run.client.portfolio.return_value = portfolio
|
||||
run.client.full_tick.return_value = {}
|
||||
run.executor.submit.return_value = future
|
||||
signals = [SignalItem(code=c) for c in ('new-b', 'held', 'new-a', 'new-b')]
|
||||
with patch.object(boot, 'trading_time', return_value=True), \
|
||||
patch.object(boot, 'market_allow_open', return_value=True), \
|
||||
patch.object(boot, 'cache_portfolio'), patch('builtins.print'):
|
||||
boot.RunOnce(run, signals)
|
||||
run.client.full_tick.assert_called_once_with(['held', 'new-b', 'new-a'])
|
||||
self.assertEqual(run.executor.submit.call_args_list[1].args,
|
||||
(boot.open_signal, run, {}, [signals[0], signals[2], signals[3]]))
|
||||
|
||||
def test_native_annotations_resolve_for_all_application_modules(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
paths = [p for name in ('config', 'sdk', 'libs', 'strategy')
|
||||
for p in (root / name).rglob('*.py')]
|
||||
for path in paths:
|
||||
parts = list(path.relative_to(root).with_suffix('').parts)
|
||||
if parts[-1] == '__init__':
|
||||
parts.pop()
|
||||
module = importlib.import_module('.'.join(parts))
|
||||
for obj in vars(module).values():
|
||||
if (inspect.isclass(obj) or inspect.isfunction(obj)) and obj.__module__ == module.__name__:
|
||||
get_annotations(obj, format=Format.VALUE)
|
||||
if inspect.isclass(obj):
|
||||
for member in vars(obj).values():
|
||||
if isinstance(member, (classmethod, staticmethod)):
|
||||
member = member.__func__
|
||||
elif isinstance(member, property):
|
||||
member = member.fget
|
||||
if inspect.isfunction(member):
|
||||
get_annotations(member, format=Format.VALUE)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
106
labs/tests/test_trend_collector.py
Normal file
106
labs/tests/test_trend_collector.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import importlib
|
||||
import io
|
||||
import logging
|
||||
import unittest
|
||||
from concurrent.futures import Future
|
||||
from contextlib import ExitStack, redirect_stdout
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from libs import collector, snapshot
|
||||
from sdk import Assets, DealItem, PositionItem
|
||||
from strategy.trend import boot
|
||||
|
||||
|
||||
class TrendCollectorTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
with patch('logging.FileHandler', return_value=logging.NullHandler()):
|
||||
cls.app = importlib.import_module('main')
|
||||
|
||||
def setUp(self):
|
||||
old_snapshot = snapshot._collector_snapshot
|
||||
self.addCleanup(setattr, snapshot, '_collector_snapshot', old_snapshot)
|
||||
snapshot._collector_snapshot = None
|
||||
|
||||
def test_submission_reads_latest_cache_and_skips_empty(self):
|
||||
with patch.object(collector, 'collector_push') as push:
|
||||
collector.submit_trend_data()
|
||||
push.assert_not_called()
|
||||
snapshot.cache_portfolio('account', Assets(available=100), [], [])
|
||||
assets = Assets(available=200)
|
||||
positions = [PositionItem(stock_code='600000.SH', volume=100)]
|
||||
deals = [DealItem(stock_code='600000.SH', volume=100)]
|
||||
snapshot.cache_portfolio('account', assets, positions, deals)
|
||||
collector.submit_trend_data()
|
||||
push.assert_called_once_with('account', assets, positions, deals)
|
||||
uploaded = push.call_args.args
|
||||
uploaded[1].available = 0
|
||||
uploaded[2].clear()
|
||||
uploaded[3][0].volume = 0
|
||||
self.assertEqual(snapshot.get_collector_snapshot()[3][0].volume, 100)
|
||||
self.assertEqual(snapshot.get_collector_snapshot()[1].available, 200)
|
||||
self.assertEqual(len(snapshot.get_collector_snapshot()[2]), 1)
|
||||
|
||||
def test_run_once_caches_portfolio_without_submitting_data(self):
|
||||
completed = Future()
|
||||
completed.set_result(None)
|
||||
run = SimpleNamespace(
|
||||
client=Mock(), orders=Mock(), executor=Mock(),
|
||||
account_cfg=SimpleNamespace(account_id='account', min_cash_ratio=0.1),
|
||||
)
|
||||
assets = Assets(available=100, total=1000)
|
||||
run.client.portfolio.return_value = SimpleNamespace(assets=assets, positions={}, orders=[])
|
||||
deals = [DealItem(stock_code='600000.SH', volume=100)]
|
||||
run.client.deals.return_value = deals
|
||||
run.client.full_tick.return_value = {}
|
||||
run.executor.submit.return_value = completed
|
||||
with patch.object(boot, 'trading_time', return_value=True), \
|
||||
patch.object(boot, 'market_allow_open', return_value=True), \
|
||||
patch.object(collector, 'collector_push') as push, redirect_stdout(io.StringIO()):
|
||||
boot.RunOnce(run, [])
|
||||
self.assertEqual(snapshot.get_collector_snapshot(), ('account', assets, [], deals))
|
||||
run.client.deals.assert_called_once_with()
|
||||
push.assert_not_called()
|
||||
run.executor.submit.assert_called_once_with(boot.manage_positions, run, {}, [], True, 100)
|
||||
|
||||
def test_submission_serializes_deals(self):
|
||||
snapshot.cache_portfolio(
|
||||
'account', Assets(available=100), [],
|
||||
[DealItem(stock_code='600000.SH', volume=100)],
|
||||
)
|
||||
with patch.object(collector.httpx, 'post') as post:
|
||||
collector.submit_trend_data()
|
||||
payload = post.call_args.kwargs['json']
|
||||
self.assertEqual(payload['account_id'], 'account')
|
||||
self.assertEqual(payload['deals'][0]['stock_code'], '600000.SH')
|
||||
self.assertEqual(payload['deals'][0]['volume'], 100)
|
||||
|
||||
def test_main_registers_five_minute_collector_job(self):
|
||||
for strategy in ('trend', 'zt'):
|
||||
with self.subTest(strategy=strategy), ExitStack() as stack:
|
||||
scheduler = Mock(running=True)
|
||||
stack.enter_context(patch.object(self.app, 'BackgroundScheduler', return_value=scheduler))
|
||||
stack.enter_context(patch.object(self.app, 'require_windows', return_value=True))
|
||||
stack.enter_context(patch.object(self.app, 'check_single_instance', return_value=True))
|
||||
stack.enter_context(patch.object(self.app, 'wait_for_qmt_api'))
|
||||
stack.enter_context(patch.object(self.app.config, 'load'))
|
||||
stack.enter_context(patch.object(self.app.config, 'global_config', SimpleNamespace(api_host='unused')))
|
||||
stack.enter_context(patch.object(self.app.config, 'account_config', SimpleNamespace(strategy=strategy)))
|
||||
stack.enter_context(patch.dict(self.app.STRATEGIES, {
|
||||
strategy: SimpleNamespace(start_strategy=Mock()),
|
||||
}))
|
||||
self.assertEqual(self.app.main(), 0)
|
||||
jobs = [call for call in scheduler.add_job.call_args_list
|
||||
if call.kwargs.get('id') == 'trend_collector']
|
||||
self.assertEqual(len(jobs), 1)
|
||||
if jobs:
|
||||
self.assertIs(jobs[0].args[0], collector.submit_trend_data)
|
||||
self.assertEqual(jobs[0].kwargs['trigger'], 'interval')
|
||||
self.assertEqual(jobs[0].kwargs['minutes'], 5)
|
||||
scheduler.start.assert_called_once()
|
||||
scheduler.shutdown.assert_called_once_with(wait=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
475
labs/tests/test_zt_boot.py
Normal file
475
labs/tests/test_zt_boot.py
Normal file
@@ -0,0 +1,475 @@
|
||||
"""ZT 新路径端到端:建仓、正T、反T、T+1 隔夜、每日一轮、资金、启动与撤单范围。"""
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from libs.signal import SignalItem
|
||||
from libs.snapshot import get_collector_snapshot
|
||||
from sdk import OP_BUY, OP_SELL, Assets, OrderItem, PositionItem
|
||||
from strategy.zt import boot
|
||||
from strategy.zt.rounds import Round, RoundStore, start_round
|
||||
from tests.zt_harness import Fixture
|
||||
|
||||
CODE = '600000.SH'
|
||||
OTHER = '600001.SH'
|
||||
TODAY = '2026-09-15'
|
||||
SIGNAL = [SignalItem(signal_key='dcm', code=CODE, last_close=10.0)]
|
||||
|
||||
|
||||
class ZTBaseTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fx = Fixture()
|
||||
self.addCleanup(self.fx.cleanup)
|
||||
logging.disable(logging.CRITICAL)
|
||||
self.addCleanup(logging.disable, logging.NOTSET)
|
||||
|
||||
def test_existing_account_positions_are_never_taken_over(self):
|
||||
self.fx.hold(CODE, volume=1000, price=37.72)
|
||||
self.fx.quote(CODE, 37.72)
|
||||
self.fx.tick()
|
||||
item = self.fx.store.get(CODE)
|
||||
self.assertEqual(item.base_qty, 0) # 不写基准
|
||||
self.assertEqual(item.phase, 'IDLE')
|
||||
self.assertEqual(self.fx.placed, [])
|
||||
# 也不纳入管理,账户已有持仓原样保留
|
||||
self.assertFalse(self.fx.store.rounds)
|
||||
|
||||
def test_unmanaged_positions_are_listed_each_tick(self):
|
||||
self.fx.hold(CODE, volume=1000, price=37.72)
|
||||
self.fx.quote(CODE, 37.72)
|
||||
logging.disable(logging.NOTSET)
|
||||
with self.assertLogs(level='INFO') as captured:
|
||||
self.fx.tick()
|
||||
text = '\n'.join(captured.output)
|
||||
self.assertIn('[ZT跳过]', text)
|
||||
self.assertIn('未接管持仓 1 只', text)
|
||||
self.assertIn(CODE, text)
|
||||
|
||||
def test_decision_and_summary_lines_are_logged(self):
|
||||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||||
self.fx.quote(CODE, 10.0)
|
||||
logging.disable(logging.NOTSET)
|
||||
with self.assertLogs(level='INFO') as captured:
|
||||
self.fx.tick()
|
||||
text = '\n'.join(captured.output)
|
||||
self.assertIn('[ZT汇总]', text)
|
||||
self.assertIn('未接管=1 新委托=0', text)
|
||||
|
||||
def test_open_base_needs_signal_and_rebound_then_uses_the_fill_price(self):
|
||||
self.fx.quote(CODE, 10.0)
|
||||
self.fx.tick(SIGNAL) # 第一次观察,不追
|
||||
self.assertEqual(self.fx.placed, [])
|
||||
|
||||
self.fx.tick(SIGNAL) # 同一价位即满足反弹确认
|
||||
self.assertEqual(len(self.fx.placed), 1)
|
||||
order = self.fx.placed[0]
|
||||
self.assertEqual((order['op_type'], order['volume']), (OP_BUY, 100))
|
||||
self.assertTrue(order['order_id'].startswith('zt-base-'))
|
||||
|
||||
self.fx.deals = [self.fx.deal(order['order_id'], 100, 10.25, sys_id='b1')]
|
||||
self.fx.tick(SIGNAL)
|
||||
item = self.fx.store.get(CODE)
|
||||
self.assertEqual(item.phase, 'CLOSED')
|
||||
self.assertEqual(item.outcome, 'base')
|
||||
self.assertEqual((item.base_qty, item.base_cost), (100, 10.25))
|
||||
self.assertEqual(item.base_source, 'opened')
|
||||
|
||||
def test_open_base_is_skipped_without_a_signal(self):
|
||||
self.fx.quote(CODE, 10.0)
|
||||
self.fx.prime(self.fx.run.open_watch, CODE, 10.0)
|
||||
self.fx.tick([])
|
||||
self.assertEqual(self.fx.placed, [])
|
||||
|
||||
def test_decision_and_summary_lines_are_logged(self):
|
||||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||||
self.fx.quote(CODE, 10.0)
|
||||
self.fx.open_round() # 本策略自有基准 -> 纳入管理
|
||||
logging.disable(logging.NOTSET)
|
||||
with self.assertLogs(level='INFO') as captured:
|
||||
self.fx.tick()
|
||||
text = '\n'.join(captured.output)
|
||||
self.assertIn('[ZT决策]', text)
|
||||
self.assertIn('[ZT汇总]', text)
|
||||
self.assertIn('中性带内不做', text)
|
||||
self.assertIn('未接管=0', text)
|
||||
|
||||
|
||||
class ZTShortTTests(unittest.TestCase):
|
||||
"""反T:高抛后低吸买回。"""
|
||||
|
||||
def setUp(self):
|
||||
self.fx = Fixture()
|
||||
self.addCleanup(self.fx.cleanup)
|
||||
logging.disable(logging.CRITICAL)
|
||||
self.addCleanup(logging.disable, logging.NOTSET)
|
||||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||||
self.fx.own_base(CODE, 1000, 10.0)
|
||||
|
||||
def enter(self):
|
||||
self.fx.quote(CODE, 11.0)
|
||||
self.fx.tick() # 网格首次观察
|
||||
self.fx.quote(CODE, 10.5)
|
||||
self.fx.tick() # 网格回撤 -> 高抛
|
||||
|
||||
def test_sell_high_then_buy_back(self):
|
||||
self.enter()
|
||||
self.assertEqual(len(self.fx.placed), 1)
|
||||
entry = self.fx.placed[0]
|
||||
self.assertEqual((entry['op_type'], entry['volume']), (OP_SELL, 500))
|
||||
self.assertTrue(entry['order_id'].startswith('zt-entry-'))
|
||||
|
||||
item = self.fx.store.get(CODE)
|
||||
self.assertEqual(item.phase, 'OPENING')
|
||||
self.assertEqual((item.kind, item.base_qty), ('SHORT_T', 1000))
|
||||
|
||||
self.fx.deals = [self.fx.deal(entry['order_id'], 500, 11.0, sys_id='s1')]
|
||||
self.fx.tick()
|
||||
item = self.fx.store.get(CODE)
|
||||
self.assertEqual(item.phase, 'OPEN')
|
||||
self.assertEqual(item.residual_qty, 500)
|
||||
|
||||
# 买回需要"较卖均价回落 + 反弹确认":上一轮 tick 已在上方建立观察点。
|
||||
self.fx.quote(CODE, 10.8)
|
||||
self.fx.tick()
|
||||
item = self.fx.store.get(CODE)
|
||||
self.assertEqual(item.phase, 'CLOSING')
|
||||
exit_order = self.fx.placed[-1]
|
||||
self.assertEqual((exit_order['op_type'], exit_order['volume']), (OP_BUY, 500))
|
||||
self.assertTrue(exit_order['order_id'].startswith('zt-exit-'))
|
||||
|
||||
self.fx.deals = [self.fx.deal(entry['order_id'], 500, 11.0, sys_id='s1'),
|
||||
self.fx.deal(exit_order['order_id'], 500, 10.8, sys_id='b1')]
|
||||
self.fx.tick()
|
||||
item = self.fx.store.get(CODE)
|
||||
self.assertEqual(item.phase, 'CLOSED')
|
||||
self.assertEqual(item.outcome, 'normal')
|
||||
self.assertEqual(item.residual_qty, 0)
|
||||
self.assertEqual(item.base_qty, 1000) # 成本基准数量不变
|
||||
self.assertAlmostEqual(item.realized_amount, 100.0)
|
||||
|
||||
def test_no_sell_inside_the_neutral_band(self):
|
||||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||||
self.fx.quote(CODE, 10.05)
|
||||
self.fx.tick()
|
||||
self.fx.tick()
|
||||
self.assertEqual(self.fx.placed, [])
|
||||
|
||||
def test_sell_only_round_is_counted_in_the_tick_summary(self):
|
||||
logging.disable(logging.NOTSET)
|
||||
self.fx.quote(CODE, 11.0)
|
||||
self.fx.tick() # 首次观察,不下单
|
||||
self.fx.quote(CODE, 10.5)
|
||||
with self.assertLogs(level='INFO') as captured:
|
||||
self.fx.tick() # 网格回撤 -> 高抛
|
||||
text = '\n'.join(captured.output)
|
||||
self.assertIn('[ZT下单]', text)
|
||||
self.assertIn('[ZT决策]', text)
|
||||
# 卖出腿不预留资金,仍必须计入"新委托",否则日志会漏报卖出。
|
||||
self.assertIn('新委托=1', text)
|
||||
|
||||
def test_price_above_the_cap_never_starts_a_round(self):
|
||||
self.fx.hold(CODE, volume=1000, price=190.0)
|
||||
self.fx.quote(CODE, 200.5)
|
||||
self.fx.tick()
|
||||
self.fx.tick()
|
||||
self.assertEqual(self.fx.placed, [])
|
||||
|
||||
def test_position_not_sellable_cannot_open_a_short_t(self):
|
||||
self.fx.hold(CODE, volume=1000, price=10.0, can_use=0)
|
||||
self.enter()
|
||||
self.assertEqual(self.fx.placed, [])
|
||||
|
||||
|
||||
class ZTLongTTests(unittest.TestCase):
|
||||
"""正T:低吸后高抛;当天买入受 T+1 限制。"""
|
||||
|
||||
def setUp(self):
|
||||
self.fx = Fixture()
|
||||
self.addCleanup(self.fx.cleanup)
|
||||
logging.disable(logging.CRITICAL)
|
||||
self.addCleanup(logging.disable, logging.NOTSET)
|
||||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||||
self.fx.own_base(CODE, 1000, 10.0)
|
||||
|
||||
def enter(self, price=9.0):
|
||||
self.fx.quote(CODE, price)
|
||||
self.fx.prime(self.fx.run.open_watch, CODE, price)
|
||||
self.fx.tick()
|
||||
|
||||
def test_buy_the_dip_then_wait_for_t_plus_1(self):
|
||||
self.enter()
|
||||
self.assertEqual(len(self.fx.placed), 1)
|
||||
entry = self.fx.placed[0]
|
||||
self.assertEqual((entry['op_type'], entry['volume']), (OP_BUY, 100))
|
||||
self.assertTrue(entry['order_id'].startswith('zt-entry-'))
|
||||
|
||||
self.fx.deals = [self.fx.deal(entry['order_id'], 100, 9.0, sys_id='b1')]
|
||||
self.fx.tick()
|
||||
item = self.fx.store.get(CODE)
|
||||
self.assertEqual((item.kind, item.phase), ('LONG_T', 'OPEN'))
|
||||
self.assertEqual(item.residual_qty, 100)
|
||||
|
||||
# 当天买入不可卖:可卖库存仍为 0,只能隔夜。
|
||||
self.fx.hold(CODE, volume=1100, price=10.0, can_use=0)
|
||||
self.fx.quote(CODE, 9.5)
|
||||
self.fx.tick()
|
||||
self.assertEqual(len(self.fx.placed), 1) # 没有新的卖单
|
||||
self.assertEqual(self.fx.store.get(CODE).phase, 'OPEN')
|
||||
|
||||
# 可卖恢复后才能高抛平仓。
|
||||
self.fx.hold(CODE, volume=1100, price=10.0, can_use=1100)
|
||||
self.fx.tick()
|
||||
item = self.fx.store.get(CODE)
|
||||
self.assertEqual(item.phase, 'CLOSING')
|
||||
exit_order = self.fx.placed[-1]
|
||||
self.assertEqual((exit_order['op_type'], exit_order['volume']), (OP_SELL, 100))
|
||||
|
||||
def test_only_one_round_per_stock_per_day(self):
|
||||
self.enter()
|
||||
entry = self.fx.placed[0]
|
||||
exit_order_id = None
|
||||
self.fx.deals = [self.fx.deal(entry['order_id'], 100, 9.0, sys_id='b1')]
|
||||
self.fx.tick()
|
||||
self.fx.hold(CODE, volume=1100, price=10.0, can_use=1100)
|
||||
self.fx.quote(CODE, 9.5)
|
||||
self.fx.tick()
|
||||
exit_order_id = self.fx.placed[-1]['order_id']
|
||||
self.fx.deals = [self.fx.deal(entry['order_id'], 100, 9.0, sys_id='b1'),
|
||||
self.fx.deal(exit_order_id, 100, 9.5, sys_id='s1')]
|
||||
self.fx.tick()
|
||||
self.assertEqual(self.fx.store.get(CODE).phase, 'CLOSED')
|
||||
placed_after_close = len(self.fx.placed)
|
||||
|
||||
# 同一天价格再次满足低吸,也不允许开新轮。
|
||||
self.fx.quote(CODE, 8.8)
|
||||
self.fx.prime(self.fx.run.open_watch, CODE, 8.8)
|
||||
self.fx.tick()
|
||||
self.assertEqual(len(self.fx.placed), placed_after_close)
|
||||
self.assertEqual(self.fx.store.get(CODE).phase, 'CLOSED')
|
||||
|
||||
|
||||
class ZTRiskTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fx = Fixture()
|
||||
self.addCleanup(self.fx.cleanup)
|
||||
logging.disable(logging.CRITICAL)
|
||||
self.addCleanup(logging.disable, logging.NOTSET)
|
||||
|
||||
def test_cash_budget_is_shared_between_codes_in_one_tick(self):
|
||||
self.fx.account_cfg.min_cash_ratio = 0.0
|
||||
for code in (CODE, OTHER):
|
||||
self.fx.hold(code, volume=1000, price=10.0)
|
||||
self.fx.own_base(code, 1000, 10.0)
|
||||
self.fx.quote(code, 9.0)
|
||||
self.fx.prime(self.fx.run.open_watch, code, 9.0)
|
||||
self.fx.assets.total = 1500.0
|
||||
self.fx.assets.available = 1500.0
|
||||
self.fx.tick()
|
||||
self.assertEqual(len(self.fx.placed), 1) # 只够一手的钱
|
||||
self.assertEqual(self.fx.placed[0]['stock_code'], CODE)
|
||||
|
||||
def test_failed_place_self_heals_on_the_next_tick(self):
|
||||
from sdk import APIError
|
||||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||||
self.fx.own_base(CODE, 1000, 10.0)
|
||||
self.fx.quote(CODE, 9.0)
|
||||
self.fx.prime(self.fx.run.open_watch, CODE, 9.0)
|
||||
self.fx.client.passorder.side_effect = APIError(400, 'rejected')
|
||||
self.fx.tick()
|
||||
item = self.fx.store.get(CODE)
|
||||
self.assertEqual(item.phase, 'OPENING') # 意图先落盘,请求被拒
|
||||
|
||||
self.fx.client.passorder.side_effect = None
|
||||
self.fx.client.passorder.return_value = {'status': 'success'}
|
||||
self.fx.tick() # 未受理且无成交 -> 判为作废
|
||||
item = self.fx.store.get(CODE)
|
||||
self.assertEqual(item.phase, 'CLOSED')
|
||||
self.assertEqual(item.outcome, 'aborted')
|
||||
self.assertEqual(item.base_qty, 1000)
|
||||
|
||||
def test_only_zt_orders_are_cancelled(self):
|
||||
stamp = datetime.now() - timedelta(minutes=30)
|
||||
self.fx.orders = [
|
||||
self._order('sys-tren', 'TREN-BUY-1|trend', stamp),
|
||||
self._order('sys-zt', 'zt-entry-1', stamp),
|
||||
self._order('sys-ipo', 'IPO-abc', stamp),
|
||||
self._order('sys-manual', '', stamp),
|
||||
]
|
||||
self.fx.quote(CODE, 10.0)
|
||||
self.fx.tick()
|
||||
cancelled = [call.args[0] for call in self.fx.client.cancel_by_id.call_args_list]
|
||||
self.assertEqual(cancelled, ['sys-zt'])
|
||||
|
||||
@staticmethod
|
||||
def _order(sys_id, remark, stamp):
|
||||
return OrderItem(stock_code=CODE, order_sys_id=sys_id, remark=remark,
|
||||
order_status=50, offset_flag=23,
|
||||
insert_date=stamp.strftime('%Y%m%d'),
|
||||
insert_time=stamp.strftime('%H%M%S'))
|
||||
|
||||
def test_expired_round_folds_its_exposure_into_the_base(self):
|
||||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||||
item = self.fx.own_base(CODE, 1000, 10.0)
|
||||
start_round(item, 'SHORT_T', '2020-01-01')
|
||||
item.entry_order_id = 'zt-entry-old'
|
||||
item.entry_filled_qty, item.entry_amount = 500, 5500.0
|
||||
item.phase = 'OPEN'
|
||||
self.fx.store.put(item)
|
||||
self.fx.store.save()
|
||||
|
||||
self.fx.quote(CODE, 11.0)
|
||||
self.fx.tick()
|
||||
item = self.fx.store.get(CODE)
|
||||
self.assertEqual(item.outcome, 'expired')
|
||||
self.assertEqual(item.base_qty, 500) # 卖出未买回,底仓变 500
|
||||
self.assertEqual(item.base_cost, 10.0) # 成本仍是建仓价
|
||||
|
||||
def test_stale_order_id_disappearing_returns_the_round_to_open(self):
|
||||
self.fx.hold(CODE, volume=1000, price=10.0)
|
||||
item = self.fx.own_base(CODE, 1000, 10.0)
|
||||
start_round(item, 'SHORT_T', TODAY)
|
||||
item.entry_order_id = 'zt-entry-1'
|
||||
item.entry_filled_qty, item.entry_amount = 500, 5500.0
|
||||
item.exit_order_id = 'zt-exit-1'
|
||||
item.phase = 'CLOSING'
|
||||
self.fx.store.put(item)
|
||||
self.fx.store.save()
|
||||
|
||||
self.fx.quote(CODE, 10.0)
|
||||
self.fx.tick() # 平仓腿已不在途且无成交
|
||||
item = self.fx.store.get(CODE)
|
||||
self.assertEqual(item.phase, 'OPEN')
|
||||
self.assertEqual(item.residual_qty, 500)
|
||||
|
||||
|
||||
class ZTForeignBaseCleanupTests(unittest.TestCase):
|
||||
"""升级清理:旧版本留下的"接管"基准不得继续参与做 T。"""
|
||||
|
||||
def setUp(self):
|
||||
self.fx = Fixture()
|
||||
self.addCleanup(self.fx.cleanup)
|
||||
logging.disable(logging.CRITICAL)
|
||||
self.addCleanup(logging.disable, logging.NOTSET)
|
||||
|
||||
def open_store(self):
|
||||
with patch.object(boot.config, 'global_config',
|
||||
NS(qmt_data_dir=str(self.fx.path), qmt_base_url='u',
|
||||
qmt_token='')), \
|
||||
patch.object(boot.config, 'account_config', self.fx.account_cfg):
|
||||
return boot._open_store()
|
||||
|
||||
def test_stale_takeover_record_is_dropped(self):
|
||||
self.fx.store.put(Round(code=CODE, base_qty=1000, base_cost=37.72,
|
||||
base_source='adopted', phase='CLOSED'))
|
||||
self.fx.store.save()
|
||||
self.fx.own_base(OTHER, 500, 9.5)
|
||||
store = self.open_store()
|
||||
self.assertNotIn(CODE, store.rounds)
|
||||
self.assertIn(OTHER, store.rounds)
|
||||
|
||||
def test_unclosed_round_is_kept_so_its_exposure_can_be_finished(self):
|
||||
item = Round(code=CODE, base_qty=1000, base_cost=37.72, base_source='adopted')
|
||||
start_round(item, 'SHORT_T', TODAY)
|
||||
item.entry_order_id = 'zt-entry-1'
|
||||
item.entry_filled_qty, item.entry_amount = 500, 5500.0
|
||||
item.phase = 'OPEN'
|
||||
self.fx.store.put(item)
|
||||
self.fx.store.save()
|
||||
self.assertIn(CODE, self.open_store().rounds)
|
||||
|
||||
def test_owned_and_empty_records_are_untouched(self):
|
||||
self.fx.own_base(CODE, 500, 9.5)
|
||||
self.fx.store.put(Round(code=OTHER)) # 无基准的空记录
|
||||
self.fx.store.save()
|
||||
store = self.open_store()
|
||||
self.assertIn(CODE, store.rounds)
|
||||
self.assertIn(OTHER, store.rounds)
|
||||
|
||||
|
||||
class ZTStartTests(unittest.TestCase):
|
||||
"""启动路径:使用新轮次文件、不碰旧账本、跨重启恢复未平轮次。"""
|
||||
|
||||
def start(self, client, directory):
|
||||
account = NS(account_id='test', strategy='zt', grid_step_pct=1.0,
|
||||
signal_allow=[], zt_open_hands=1, zt_max_hold_days=5,
|
||||
zt_t_band_pct=1.0, zt_sell_ratio=0.5, zt_buy_fall_pct=1.0,
|
||||
zt_max_price=200.0, excluded_codes=[],
|
||||
min_cash_ratio=0.1)
|
||||
global_cfg = NS(qmt_base_url='unused', qmt_token='', qmt_data_dir=directory)
|
||||
with patch.object(boot, 'Client', return_value=client), \
|
||||
patch.object(boot.config, 'global_config', global_cfg), \
|
||||
patch.object(boot.config, 'account_config', account), \
|
||||
patch.object(boot, 'init_signals', return_value=[]), \
|
||||
patch.object(boot.time, 'localtime',
|
||||
return_value=NS(tm_hour=15, tm_min=0, tm_sec=0)):
|
||||
boot.StartZT()
|
||||
return account
|
||||
|
||||
def test_uses_the_rounds_store_and_never_touches_the_old_ledger(self):
|
||||
client = Mock()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.start(client, tmp)
|
||||
self.assertFalse((Path(tmp) / 'zt_test_state.db').exists())
|
||||
self.assertEqual(client.deals.call_count, 0) # 15:00 直接退出,没跑 tick
|
||||
self.assertEqual(client.portfolio.call_count, 0) # 也不再为接管读持仓
|
||||
client.close.assert_called_once()
|
||||
with patch.object(boot.config, 'global_config',
|
||||
NS(qmt_data_dir=tmp, qmt_base_url='u', qmt_token='')), \
|
||||
patch.object(boot.config, 'account_config', NS(account_id='test')):
|
||||
store = boot._open_store()
|
||||
self.assertEqual(store.path.name, 'zt_test_rounds.json')
|
||||
self.assertEqual(store.rounds, {})
|
||||
|
||||
def test_start_does_not_read_positions_at_all(self):
|
||||
# 不接管持仓,启动阶段不需要账户快照,第一次读盘发生在第一个 tick。
|
||||
client = Mock()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.start(client, tmp)
|
||||
self.assertEqual(client.portfolio.call_count, 0)
|
||||
self.assertEqual(client.deals.call_count, 0)
|
||||
|
||||
def test_restores_an_unclosed_round_across_restart(self):
|
||||
client = Mock()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = RoundStore(Path(tmp) / 'zt_test_rounds.json')
|
||||
item = Round(code=CODE, base_qty=1000, base_cost=10.0)
|
||||
start_round(item, 'SHORT_T', '2026-09-14')
|
||||
item.entry_order_id = 'zt-entry-1'
|
||||
item.entry_filled_qty, item.entry_amount = 500, 5500.0
|
||||
item.phase = 'OPEN'
|
||||
store.put(item)
|
||||
store.save()
|
||||
|
||||
self.start(client, tmp)
|
||||
|
||||
restored = RoundStore(Path(tmp) / 'zt_test_rounds.json').get(CODE)
|
||||
self.assertEqual(restored.phase, 'OPEN')
|
||||
self.assertEqual(restored.residual_qty, 500)
|
||||
self.assertEqual(restored.entry_avg_price, 11.0)
|
||||
|
||||
|
||||
class ZTCollectorTests(unittest.TestCase):
|
||||
def test_snapshot_is_cached_even_when_market_fetch_fails(self):
|
||||
fx = Fixture()
|
||||
self.addCleanup(fx.cleanup)
|
||||
fx.assets = Assets(total=20000, available=10000)
|
||||
fx.hold(CODE, volume=100, price=10.0)
|
||||
fx.quote(CODE, 10.0)
|
||||
fx.client.full_tick.side_effect = RuntimeError('no market data')
|
||||
with patch.object(boot, 'trading_time', return_value=True), \
|
||||
patch.object(boot, 'market_allow_open', return_value=True):
|
||||
boot.RunOnce(fx.run, fx.store, [])
|
||||
snapshot = get_collector_snapshot()
|
||||
self.assertEqual(snapshot[0], 'zt-test')
|
||||
self.assertEqual(snapshot[1].total, 20000)
|
||||
self.assertEqual([p.stock_code for p in snapshot[2]], [CODE])
|
||||
self.assertEqual(fx.placed, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
104
labs/tests/test_zt_config.py
Normal file
104
labs/tests/test_zt_config.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""ZT 配置:手数、中性带、最长持有天数与开关的校验。"""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import yaml
|
||||
|
||||
import config
|
||||
from config import AccountConfig, GlobalConfig, SignalConfig
|
||||
from strategy.zt import boot
|
||||
|
||||
|
||||
class ZTConfigTests(unittest.TestCase):
|
||||
def zt_config(self, directory, **overrides):
|
||||
root = Path(directory)
|
||||
(root / '_global.yaml').write_text(yaml.safe_dump({
|
||||
'qmt_base_url': 'unused', 'api_host': 'unused',
|
||||
'qmt_data_dir': directory, 'hosts': {'test': 'account'},
|
||||
}), encoding='utf-8')
|
||||
account = dict(buy_value=1000, strategy='zt', signal_allow=['dcm'])
|
||||
account.update(overrides)
|
||||
(root / 'account.yaml').write_text(yaml.safe_dump(account), encoding='utf-8')
|
||||
return root
|
||||
|
||||
def test_config_accepts_only_nonnegative_integer_hands(self):
|
||||
with tempfile.TemporaryDirectory() as directory, \
|
||||
patch.object(config, 'global_config'), patch.object(config, 'account_config'):
|
||||
for hands in (None, 0, 3, -1, 1.5, '3', True):
|
||||
with self.subTest(hands=hands):
|
||||
account = dict(buy_value=1000, strategy='zt', signal_allow=['dcm'])
|
||||
if hands is not None:
|
||||
account['zt_open_hands'] = hands
|
||||
root = self.zt_config(directory, **{k: v for k, v in
|
||||
account.items()
|
||||
if k not in ('buy_value',
|
||||
'strategy',
|
||||
'signal_allow')})
|
||||
if hands is None or type(hands) is int and hands >= 0:
|
||||
_, loaded = config.load(root, 'test')
|
||||
self.assertEqual(loaded.zt_open_hands, hands or 0)
|
||||
else:
|
||||
with self.assertRaisesRegex(ValueError, 'zt_open_hands'):
|
||||
config.load(root, 'test')
|
||||
|
||||
def test_t_band_and_hold_days_defaults_and_validation(self):
|
||||
with tempfile.TemporaryDirectory() as directory, \
|
||||
patch.object(config, 'global_config'), patch.object(config, 'account_config'):
|
||||
_, loaded = config.load(self.zt_config(directory), 'test')
|
||||
self.assertEqual(loaded.zt_t_band_pct, 1.0)
|
||||
self.assertEqual(loaded.zt_max_hold_days, 5)
|
||||
|
||||
for band, valid in ((0, True), (0.5, True), (1.0, True), (-1, False)):
|
||||
with self.subTest(band=band):
|
||||
root = self.zt_config(directory, zt_t_band_pct=band)
|
||||
if valid:
|
||||
_, loaded = config.load(root, 'test')
|
||||
self.assertEqual(loaded.zt_t_band_pct, band)
|
||||
else:
|
||||
with self.assertRaisesRegex(ValueError, 'zt_t_band_pct'):
|
||||
config.load(root, 'test')
|
||||
|
||||
for days, valid in ((1, True), (5, True), (0, False), (-1, False),
|
||||
(1.5, False), ('5', False), (True, False)):
|
||||
with self.subTest(days=days):
|
||||
root = self.zt_config(directory, zt_max_hold_days=days)
|
||||
if valid:
|
||||
_, loaded = config.load(root, 'test')
|
||||
self.assertEqual(loaded.zt_max_hold_days, days)
|
||||
else:
|
||||
with self.assertRaisesRegex(ValueError, 'zt_max_hold_days'):
|
||||
config.load(root, 'test')
|
||||
|
||||
def test_zero_hands_does_not_initialize_strategy(self):
|
||||
with patch.object(config, 'account_config', AccountConfig()), \
|
||||
patch.object(boot, 'Client') as client, \
|
||||
patch.object(boot, '_open_store') as store, \
|
||||
patch.object(boot, 'init_signals') as signals:
|
||||
boot.StartZT()
|
||||
for dependency in (client, store, signals):
|
||||
dependency.assert_not_called()
|
||||
|
||||
def test_unknown_account_key_is_rejected_with_a_clear_error(self):
|
||||
with tempfile.TemporaryDirectory() as directory, \
|
||||
patch.object(config, 'global_config'), patch.object(config, 'account_config'):
|
||||
root = self.zt_config(directory, zt_sell_ratios=0.5) # 拼错的名字
|
||||
with self.assertRaisesRegex(ValueError, 'zt_sell_ratios'):
|
||||
config.load(root, 'test')
|
||||
|
||||
|
||||
class SignalConfigTests(unittest.TestCase):
|
||||
def test_signal_defaults(self):
|
||||
item = SignalConfig()
|
||||
self.assertEqual((item.url, item.timezone), ('', '*'))
|
||||
self.assertFalse(item.gt_last_price_is_open)
|
||||
self.assertEqual(GlobalConfig().signals, {})
|
||||
|
||||
def test_zero_hands_is_the_off_switch(self):
|
||||
self.assertEqual(AccountConfig().zt_open_hands, 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
177
labs/tests/test_zt_ownership.py
Normal file
177
labs/tests/test_zt_ownership.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""ZT 归属过滤:非本策略成交通知不得进入账本,也不得中断策略。"""
|
||||
|
||||
import logging
|
||||
import unittest
|
||||
|
||||
from sdk import DealItem
|
||||
from strategy.zt import boot
|
||||
from strategy.zt.ownership import OWNED_PREFIX, owned_deals, owns_local_order_id
|
||||
from strategy.zt.rounds import RoundStore, start_round
|
||||
from tests.zt_harness import Fixture
|
||||
|
||||
TODAY = '2026-09-15'
|
||||
|
||||
|
||||
class OwnershipPredicateTests(unittest.TestCase):
|
||||
def test_only_local_order_ids_generated_by_zt_are_owned(self):
|
||||
owned = ['zt-base-8e9da97a42e957408489', 'zt-added-9239083181eb39712994',
|
||||
'zt-entry-0b10c994b8242682983f', 'zt-exit-1']
|
||||
foreign = ['', None, ' ', 'TREN-BUY-1', 'MORN-2', 'IPO-abc', 'DCM-3',
|
||||
'zt', 'azt-base-1', 'ztbase-1']
|
||||
for value in owned:
|
||||
with self.subTest(value=value):
|
||||
self.assertTrue(owns_local_order_id(value))
|
||||
for value in foreign:
|
||||
with self.subTest(value=value):
|
||||
self.assertFalse(owns_local_order_id(value))
|
||||
self.assertEqual(OWNED_PREFIX, 'zt-')
|
||||
|
||||
def test_owned_deals_splits_and_counts(self):
|
||||
def deal(remark):
|
||||
return DealItem(stock_code='600000.SH', order_sys_id=remark or 'none',
|
||||
remark=remark)
|
||||
|
||||
deals = [deal('zt-entry-a'), deal(''), deal('TREN-BUY-1'), deal('zt-exit-b')]
|
||||
owned, ignored = owned_deals(deals)
|
||||
self.assertEqual([d.get_local_order_id for d in owned],
|
||||
['zt-entry-a', 'zt-exit-b'])
|
||||
self.assertEqual(ignored, 2)
|
||||
|
||||
|
||||
class ForeignDealIsolationTests(unittest.TestCase):
|
||||
"""手工单与其他策略单既不进轮次,也不影响本策略的判断。"""
|
||||
|
||||
def setUp(self):
|
||||
self.fx = Fixture()
|
||||
self.addCleanup(self.fx.cleanup)
|
||||
logging.disable(logging.CRITICAL)
|
||||
self.addCleanup(logging.disable, logging.NOTSET)
|
||||
self.fx.hold('600000.SH', volume=1000, price=10.0)
|
||||
self.fx.quote('600000.SH', 10.0)
|
||||
|
||||
def run_tick(self):
|
||||
self.fx.tick()
|
||||
return self.fx.store.get('600000.SH')
|
||||
|
||||
def test_manual_deal_without_remark_neither_raises_nor_blocks(self):
|
||||
self.fx.open_round()
|
||||
self.fx.deals = [DealItem(stock_code='600000.SH', order_sys_id='m1',
|
||||
remark='', offset_flag=48, volume=100, price=9.0,
|
||||
trade_amount=900.0, trade_date='20260915',
|
||||
trade_time='100000')]
|
||||
item = self.run_tick() # 旧实现在这里抛 IntegrityError
|
||||
self.assertEqual(item.entry_filled_qty, 0)
|
||||
self.assertEqual(item.outcome, 'aborted') # 开仓腿无成交且已不在途
|
||||
self.assertEqual(self.fx.placed, [])
|
||||
|
||||
def test_foreign_strategy_deal_cannot_touch_the_round(self):
|
||||
self.fx.open_round()
|
||||
self.fx.deals = [DealItem(stock_code='600000.SH', order_sys_id='t1',
|
||||
remark='TREN-BUY-9|trend', offset_flag=48,
|
||||
volume=500, price=20.0, trade_amount=10000.0,
|
||||
trade_date='20260915', trade_time='100000')]
|
||||
item = self.run_tick() # 旧实现把外部买入当补仓写进 added 桶
|
||||
self.assertEqual(item.entry_filled_qty, 0)
|
||||
self.assertEqual(item.entry_amount, 0.0)
|
||||
self.assertEqual(item.base_qty, 1000)
|
||||
|
||||
def test_owned_deals_are_still_counted(self):
|
||||
self.fx.open_round()
|
||||
self.fx.deals = [self.fx.deal('zt-entry-1', 300, 9.0)]
|
||||
item = self.run_tick()
|
||||
self.assertEqual(item.entry_filled_qty, 300)
|
||||
self.assertEqual(item.entry_avg_price, 9.0)
|
||||
# 现价 10.0 对买入均价 9.0 已超过一个网格步长,同一轮 tick 内即挂出卖单。
|
||||
self.assertEqual(item.phase, 'CLOSING')
|
||||
self.assertEqual([p['stock_code'] for p in self.fx.placed], ['600000.SH'])
|
||||
self.assertTrue(self.fx.placed[0]['order_id'].startswith('zt-exit-'))
|
||||
|
||||
def test_mixed_batch_keeps_only_owned_deals(self):
|
||||
self.fx.open_round()
|
||||
self.fx.deals = [
|
||||
self.fx.deal('zt-entry-1', 100, 9.0, sys_id='own'),
|
||||
DealItem(stock_code='600000.SH', order_sys_id='manual', remark='',
|
||||
offset_flag=48, volume=100, price=9.0, trade_amount=900.0,
|
||||
trade_date='20260915', trade_time='100000'),
|
||||
DealItem(stock_code='600000.SH', order_sys_id='trend',
|
||||
remark='TREN-BUY-1', offset_flag=48, volume=100, price=9.0,
|
||||
trade_amount=900.0, trade_date='20260915', trade_time='100000'),
|
||||
]
|
||||
item = self.run_tick()
|
||||
self.assertEqual(item.entry_filled_qty, 100)
|
||||
self.assertEqual(len(item.seen_deal_ids), 1)
|
||||
|
||||
def test_repeated_ticks_never_double_count(self):
|
||||
self.fx.open_round()
|
||||
self.fx.deals = [self.fx.deal('zt-entry-1', 300, 9.0)]
|
||||
self.assertEqual(self.run_tick().entry_filled_qty, 300)
|
||||
self.assertEqual(self.run_tick().entry_filled_qty, 300)
|
||||
|
||||
|
||||
class RunOnceResilienceTests(unittest.TestCase):
|
||||
"""任何单点失败都只能跳过本轮,不能打断唯一的交易定时线程。"""
|
||||
|
||||
def setUp(self):
|
||||
self.fx = Fixture()
|
||||
self.addCleanup(self.fx.cleanup)
|
||||
logging.disable(logging.CRITICAL)
|
||||
self.addCleanup(logging.disable, logging.NOTSET)
|
||||
self.fx.hold('600000.SH', volume=1000, price=10.0)
|
||||
self.fx.quote('600000.SH', 10.0)
|
||||
|
||||
def test_snapshot_failure_skips_the_round_quietly(self):
|
||||
from unittest.mock import patch
|
||||
with patch.object(boot, 'trading_time', return_value=True):
|
||||
self.fx.client.portfolio.side_effect = RuntimeError('api down')
|
||||
boot.RunOnce(self.fx.run, self.fx.store, [])
|
||||
self.assertEqual(self.fx.placed, [])
|
||||
|
||||
def test_round_advance_failure_skips_trading(self):
|
||||
from unittest.mock import patch
|
||||
with patch.object(boot, 'trading_time', return_value=True), \
|
||||
patch.object(boot, '_advance_rounds', side_effect=RuntimeError('broken')):
|
||||
boot.RunOnce(self.fx.run, self.fx.store, [])
|
||||
self.assertEqual(self.fx.placed, [])
|
||||
|
||||
def test_market_data_failure_skips_trading(self):
|
||||
from unittest.mock import patch
|
||||
with patch.object(boot, 'trading_time', return_value=True):
|
||||
self.fx.client.full_tick.side_effect = RuntimeError('no ticks')
|
||||
boot.RunOnce(self.fx.run, self.fx.store, [])
|
||||
self.assertEqual(self.fx.placed, [])
|
||||
|
||||
def test_startup_state_failure_closes_client_without_raising(self):
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import patch
|
||||
client = self.fx.client
|
||||
with patch.object(boot.config, 'account_config', self.fx.account_cfg), \
|
||||
patch.object(boot.config, 'global_config',
|
||||
NS(qmt_data_dir=str(self.fx.path), qmt_base_url='u',
|
||||
qmt_token='', api_host='u')), \
|
||||
patch.object(boot, 'Client', return_value=client), \
|
||||
patch.object(boot, '_open_store', side_effect=RuntimeError('disk')):
|
||||
boot.StartZT() # 不抛异常
|
||||
client.close.assert_called_once()
|
||||
|
||||
def test_corrupt_state_is_backed_up_and_rebuilt(self):
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import patch
|
||||
path = self.fx.rounds_path
|
||||
path.write_text('{not json', encoding='utf-8')
|
||||
client = self.fx.client
|
||||
with patch.object(boot.config, 'account_config', self.fx.account_cfg), \
|
||||
patch.object(boot.config, 'global_config',
|
||||
NS(qmt_data_dir=str(self.fx.path), qmt_base_url='u',
|
||||
qmt_token='', api_host='u')) , \
|
||||
patch.object(boot, 'Client', return_value=client), \
|
||||
patch.object(boot, 'init_signals', return_value=[]), \
|
||||
patch.object(boot.time, 'localtime',
|
||||
return_value=NS(tm_hour=15, tm_min=0, tm_sec=0)):
|
||||
boot.StartZT()
|
||||
self.assertTrue(path.with_name(path.name + '.corrupt').is_file())
|
||||
self.assertEqual(RoundStore(path).rounds, {})
|
||||
client.close.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
420
labs/tests/test_zt_rounds.py
Normal file
420
labs/tests/test_zt_rounds.py
Normal file
@@ -0,0 +1,420 @@
|
||||
"""ZT 轮次状态:幂等成交累计、阶段推进、跨日配额、超期放弃、持久化。"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from sdk import DealItem, OrderItem, PositionItem
|
||||
from strategy.zt.rounds import (
|
||||
BASE_SOURCE_OPENED,
|
||||
KIND_LONG_T,
|
||||
KIND_SHORT_T,
|
||||
OUTCOME_ABORTED,
|
||||
OUTCOME_BASE,
|
||||
OUTCOME_EXPIRED,
|
||||
OUTCOME_NORMAL,
|
||||
PHASE_CLOSED,
|
||||
PHASE_CLOSING,
|
||||
PHASE_IDLE,
|
||||
PHASE_OPEN,
|
||||
PHASE_OPENING,
|
||||
Round,
|
||||
RoundStore,
|
||||
RoundStoreError,
|
||||
advance,
|
||||
apply_deals,
|
||||
expire,
|
||||
in_flight_order_ids,
|
||||
is_owned_base,
|
||||
new_base_round,
|
||||
new_round,
|
||||
start_round,
|
||||
)
|
||||
|
||||
TODAY = '2026-09-15'
|
||||
|
||||
|
||||
def deal(order_sys_id, remark, volume=100, price=10.0):
|
||||
return DealItem(stock_code='600000.SH', order_sys_id=order_sys_id, remark=remark,
|
||||
offset_flag=48, volume=volume, price=price,
|
||||
trade_amount=price * volume,
|
||||
trade_date='20260915', trade_time='100000')
|
||||
|
||||
|
||||
def order(local_id, status):
|
||||
return OrderItem(stock_code='600000.SH', order_sys_id=local_id, remark=local_id,
|
||||
offset_flag=48, order_status=status,
|
||||
insert_date='20260915', insert_time='100000')
|
||||
|
||||
|
||||
class RoundModelTests(unittest.TestCase):
|
||||
def test_directions_are_mirrored_between_long_and_short_t(self):
|
||||
long_t = Round(code='600000.SH', kind=KIND_LONG_T)
|
||||
short_t = Round(code='600000.SH', kind=KIND_SHORT_T)
|
||||
self.assertEqual((long_t.entry_side, long_t.exit_side), ('BUY', 'SELL'))
|
||||
self.assertEqual((short_t.entry_side, short_t.exit_side), ('SELL', 'BUY'))
|
||||
|
||||
def test_residual_and_average_prices(self):
|
||||
item = Round(code='600000.SH', kind=KIND_LONG_T,
|
||||
entry_filled_qty=200, entry_amount=2000.0,
|
||||
exit_filled_qty=100, exit_amount=1100.0)
|
||||
self.assertEqual(item.residual_qty, 100)
|
||||
self.assertAlmostEqual(item.entry_avg_price, 10.0)
|
||||
self.assertAlmostEqual(item.exit_avg_price, 11.0)
|
||||
self.assertEqual(Round().entry_avg_price, 0.0)
|
||||
|
||||
def test_daily_quota_and_cross_day_recovery(self):
|
||||
item = Round(code='600000.SH')
|
||||
self.assertTrue(item.can_open(TODAY))
|
||||
item.open_date = TODAY # 今天已开过一轮
|
||||
self.assertFalse(item.can_open(TODAY))
|
||||
item.open_date = '2026-09-14'
|
||||
item.phase = PHASE_OPEN # 昨日未平的轮次继续持有
|
||||
self.assertFalse(item.can_open(TODAY))
|
||||
item.phase = PHASE_CLOSED
|
||||
self.assertTrue(item.can_open(TODAY))
|
||||
item.last_trade_date = TODAY # 今天已有腿成交
|
||||
self.assertFalse(item.can_open(TODAY))
|
||||
item.last_trade_date = '2026-09-14' # 昨日成交,今天可以做一轮
|
||||
self.assertTrue(item.can_open(TODAY))
|
||||
|
||||
def test_new_round_keeps_the_established_base(self):
|
||||
item = new_round('600000.SH', KIND_LONG_T, TODAY, 500, 26.89,
|
||||
base_date='2026-09-10', base_source='opened')
|
||||
self.assertEqual(item.phase, PHASE_OPENING)
|
||||
self.assertEqual((item.base_qty, item.base_cost), (500, 26.89))
|
||||
self.assertEqual((item.base_date, item.base_source), ('2026-09-10', 'opened'))
|
||||
|
||||
|
||||
class ApplyDealsTests(unittest.TestCase):
|
||||
def test_repeated_sync_never_double_counts(self):
|
||||
item = Round(code='600000.SH', kind=KIND_LONG_T, entry_order_id='zt-base-1')
|
||||
batch = [deal('s1', 'zt-base-1'), deal('s2', 'zt-base-1')]
|
||||
apply_deals(item, batch, TODAY)
|
||||
self.assertEqual(item.entry_filled_qty, 200)
|
||||
self.assertAlmostEqual(item.entry_amount, 2000.0)
|
||||
apply_deals(item, batch, TODAY) # 同一批再次同步
|
||||
self.assertEqual(item.entry_filled_qty, 200)
|
||||
apply_deals(item, batch + [deal('s3', 'zt-base-1')], TODAY)
|
||||
self.assertEqual(item.entry_filled_qty, 300)
|
||||
self.assertEqual(item.last_trade_date, TODAY)
|
||||
|
||||
def test_only_this_rounds_legs_are_counted(self):
|
||||
item = Round(code='600000.SH', kind=KIND_LONG_T,
|
||||
entry_order_id='zt-base-1', exit_order_id='zt-SELL-1')
|
||||
apply_deals(item, [
|
||||
deal('s1', 'zt-base-1'),
|
||||
deal('s2', ''), # 手工单
|
||||
deal('s3', 'TREN-BUY-1'), # 其他策略
|
||||
deal('s4', 'zt-added-other'), # 本策略但不是本轮
|
||||
deal('s5', 'zt-SELL-1', price=11.0),
|
||||
], TODAY)
|
||||
self.assertEqual(item.entry_filled_qty, 100)
|
||||
self.assertEqual(item.exit_filled_qty, 100)
|
||||
self.assertEqual(item.residual_qty, 0)
|
||||
self.assertEqual(sorted(item.seen_deal_ids), ['s1', 's5'])
|
||||
|
||||
|
||||
class AdvanceTests(unittest.TestCase):
|
||||
def test_entry_fully_filled_moves_to_open(self):
|
||||
item = Round(code='600000.SH', kind=KIND_LONG_T,
|
||||
phase=PHASE_OPENING, entry_order_id='zt-base-1',
|
||||
entry_filled_qty=300)
|
||||
advance(item, {'other'}, TODAY)
|
||||
self.assertEqual(item.phase, PHASE_OPEN)
|
||||
self.assertEqual(item.residual_qty, 300)
|
||||
|
||||
def test_entry_still_in_flight_does_not_move(self):
|
||||
item = Round(code='600000.SH', kind=KIND_LONG_T,
|
||||
phase=PHASE_OPENING, entry_order_id='zt-base-1',
|
||||
entry_filled_qty=100)
|
||||
advance(item, {'zt-base-1'}, TODAY)
|
||||
self.assertEqual(item.phase, PHASE_OPENING)
|
||||
|
||||
def test_aborted_entry_releases_the_daily_quota(self):
|
||||
item = Round(code='600000.SH', kind=KIND_LONG_T, phase=PHASE_OPENING,
|
||||
open_date=TODAY, entry_order_id='zt-base-1')
|
||||
advance(item, set(), TODAY)
|
||||
self.assertEqual(item.phase, PHASE_CLOSED)
|
||||
self.assertEqual(item.outcome, OUTCOME_ABORTED)
|
||||
self.assertEqual(item.open_date, '')
|
||||
self.assertTrue(item.can_open(TODAY))
|
||||
|
||||
def test_partially_closed_round_returns_to_open(self):
|
||||
item = Round(code='600000.SH', kind=KIND_LONG_T, phase=PHASE_CLOSING,
|
||||
entry_order_id='zt-base-1', exit_order_id='zt-SELL-1',
|
||||
entry_filled_qty=300, exit_filled_qty=100)
|
||||
advance(item, set(), TODAY)
|
||||
self.assertEqual(item.phase, PHASE_OPEN)
|
||||
self.assertEqual(item.residual_qty, 200)
|
||||
|
||||
def test_fully_closed_round_finishes(self):
|
||||
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_CLOSING,
|
||||
open_date=TODAY, entry_order_id='zt-SELL-1', exit_order_id='zt-added-1',
|
||||
entry_filled_qty=100, exit_filled_qty=100)
|
||||
advance(item, set(), TODAY)
|
||||
self.assertEqual(item.phase, PHASE_CLOSED)
|
||||
self.assertEqual(item.outcome, OUTCOME_NORMAL)
|
||||
self.assertEqual(item.close_date, TODAY)
|
||||
self.assertEqual(item.open_date, TODAY) # 完成轮次占用当日配额
|
||||
|
||||
def test_overnight_round_keeps_its_open_date(self):
|
||||
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_OPEN,
|
||||
open_date='2026-09-14', entry_order_id='zt-SELL-1',
|
||||
entry_filled_qty=100)
|
||||
advance(item, set(), TODAY)
|
||||
self.assertEqual(item.phase, PHASE_OPEN) # 仍待买回,允许隔夜
|
||||
self.assertFalse(item.can_open(TODAY))
|
||||
|
||||
|
||||
class BaseEstablishmentTests(unittest.TestCase):
|
||||
def test_base_cost_comes_from_the_actual_fill(self):
|
||||
item = new_base_round('600000.SH', TODAY, 300)
|
||||
item.entry_order_id = 'zt-base-1'
|
||||
apply_deals(item, [deal('s1', 'zt-base-1', volume=300, price=26.89)], TODAY)
|
||||
advance(item, set(), TODAY)
|
||||
self.assertEqual(item.phase, PHASE_CLOSED)
|
||||
self.assertEqual(item.outcome, OUTCOME_BASE)
|
||||
self.assertEqual(item.base_qty, 300)
|
||||
self.assertAlmostEqual(item.base_cost, 26.89)
|
||||
self.assertEqual(item.base_source, BASE_SOURCE_OPENED)
|
||||
self.assertEqual(item.base_date, TODAY)
|
||||
|
||||
def test_partial_base_fill_is_accepted(self):
|
||||
item = new_base_round('600000.SH', TODAY, 300)
|
||||
item.entry_order_id = 'zt-base-1'
|
||||
apply_deals(item, [deal('s1', 'zt-base-1', volume=100, price=26.0)], TODAY)
|
||||
advance(item, set(), TODAY)
|
||||
self.assertEqual((item.base_qty, item.base_cost), (100, 26.0))
|
||||
|
||||
def test_empty_base_fill_aborts_and_frees_the_quota(self):
|
||||
item = new_base_round('600000.SH', TODAY, 300)
|
||||
item.entry_order_id = 'zt-base-1'
|
||||
advance(item, set(), TODAY)
|
||||
self.assertEqual(item.outcome, OUTCOME_ABORTED)
|
||||
self.assertEqual(item.open_date, '')
|
||||
self.assertTrue(item.can_open(TODAY))
|
||||
|
||||
def test_base_round_settles_only_after_the_order_is_no_longer_in_flight(self):
|
||||
item = new_base_round('600000.SH', TODAY, 300)
|
||||
item.entry_order_id = 'zt-base-1'
|
||||
apply_deals(item, [deal('s1', 'zt-base-1', volume=300, price=26.89)], TODAY)
|
||||
advance(item, {'zt-base-1'}, TODAY)
|
||||
self.assertEqual(item.phase, PHASE_OPENING)
|
||||
self.assertEqual(item.base_qty, 0)
|
||||
|
||||
def test_adoption_is_not_supported(self):
|
||||
# 程序不接管账户已有持仓:Round 只认识自己建仓写下的基准。
|
||||
item = Round(code='600000.SH', base_qty=500, base_cost=37.72,
|
||||
base_source=BASE_SOURCE_OPENED)
|
||||
self.assertTrue(is_owned_base(item))
|
||||
for source in ('', 'adopted', 'configured'):
|
||||
with self.subTest(source=source):
|
||||
self.assertFalse(is_owned_base(Round(code='600000.SH', base_qty=500,
|
||||
base_cost=37.72,
|
||||
base_source=source)))
|
||||
self.assertFalse(is_owned_base(Round(code='600000.SH')))
|
||||
|
||||
def test_apply_deals_reports_applied_fills_for_logging(self):
|
||||
item = Round(code='600000.SH', kind=KIND_LONG_T,
|
||||
entry_order_id='zt-entry-1', exit_order_id='zt-exit-1')
|
||||
applied = apply_deals(item, [deal('s1', 'zt-entry-1'),
|
||||
deal('s2', 'TREN-BUY-1'),
|
||||
deal('s3', 'zt-exit-1')], TODAY)
|
||||
self.assertEqual([leg for leg, _ in applied], ['entry', 'exit'])
|
||||
self.assertEqual([entry.order_sys_id for _, entry in applied], ['s1', 's3'])
|
||||
self.assertEqual(apply_deals(item, [deal('s1', 'zt-entry-1')], TODAY), [])
|
||||
|
||||
|
||||
class StartRoundTests(unittest.TestCase):
|
||||
"""开新轮必须清空上一轮的两条腿,否则残量会静默把本轮判成作废。"""
|
||||
|
||||
def closed_round(self):
|
||||
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_CLOSED,
|
||||
open_date='2026-09-14', close_date='2026-09-14',
|
||||
outcome=OUTCOME_NORMAL, note='旧备注',
|
||||
entry_order_id='e1', entry_plan_qty=500, entry_filled_qty=500,
|
||||
entry_amount=5500.0, exit_order_id='x1', exit_plan_qty=500,
|
||||
exit_filled_qty=500, exit_amount=4900.0,
|
||||
seen_deal_ids=['s1', 's2'])
|
||||
return item
|
||||
|
||||
def test_start_round_clears_both_legs_and_audit_fields(self):
|
||||
item = self.closed_round()
|
||||
start_round(item, KIND_LONG_T, TODAY)
|
||||
self.assertEqual(item.phase, PHASE_OPENING)
|
||||
self.assertEqual(item.kind, KIND_LONG_T)
|
||||
self.assertEqual(item.open_date, TODAY)
|
||||
self.assertEqual((item.close_date, item.outcome, item.note), ('', '', ''))
|
||||
self.assertEqual((item.entry_order_id, item.exit_order_id), ('', ''))
|
||||
self.assertEqual((item.entry_filled_qty, item.exit_filled_qty), (0, 0))
|
||||
self.assertEqual((item.entry_amount, item.exit_amount), (0.0, 0.0))
|
||||
self.assertEqual(item.seen_deal_ids, [])
|
||||
self.assertEqual(item.residual_qty, 0)
|
||||
|
||||
def test_start_round_keeps_the_established_base(self):
|
||||
item = self.closed_round()
|
||||
item.base_qty, item.base_cost = 1000, 10.0
|
||||
item.base_date, item.base_source = '2026-09-10', BASE_SOURCE_OPENED
|
||||
start_round(item, KIND_SHORT_T, TODAY)
|
||||
self.assertEqual((item.base_qty, item.base_cost), (1000, 10.0))
|
||||
self.assertEqual((item.base_date, item.base_source),
|
||||
('2026-09-10', BASE_SOURCE_OPENED))
|
||||
|
||||
def test_stale_exit_counter_cannot_abort_a_new_round(self):
|
||||
# 复现:直接改字段开新轮,上一轮的 exit_filled_qty 让 residual 变负,
|
||||
# advance 会判成作废并立刻重开一轮。
|
||||
item = self.closed_round()
|
||||
item.kind = KIND_LONG_T
|
||||
item.phase = PHASE_OPENING
|
||||
item.open_date = TODAY
|
||||
item.entry_order_id = 'e2'
|
||||
item.entry_filled_qty, item.entry_amount = 0, 0.0
|
||||
self.assertEqual(item.residual_qty, -500)
|
||||
advance(item, set(), TODAY)
|
||||
self.assertEqual(item.phase, PHASE_CLOSED)
|
||||
self.assertEqual(item.note, '成交累计异常:平仓量超过开仓量,本轮作废')
|
||||
|
||||
fixed = self.closed_round()
|
||||
start_round(fixed, KIND_LONG_T, TODAY)
|
||||
fixed.entry_order_id = 'e2'
|
||||
apply_deals(fixed, [deal('s9', 'e2', volume=300, price=9.0)], TODAY)
|
||||
advance(fixed, set(), TODAY)
|
||||
self.assertEqual(fixed.phase, PHASE_OPEN)
|
||||
self.assertEqual(fixed.residual_qty, 300)
|
||||
|
||||
def test_new_base_round_starts_clean(self):
|
||||
item = new_base_round('600000.SH', TODAY, 300)
|
||||
self.assertEqual(item.phase, PHASE_OPENING)
|
||||
self.assertEqual(item.entry_plan_qty, 300)
|
||||
self.assertEqual(item.base_qty, 0)
|
||||
|
||||
|
||||
class ResidualAbsorptionTests(unittest.TestCase):
|
||||
"""超期放弃必须把敞口并回底仓,否则会在裸敞口上继续开新轮。"""
|
||||
|
||||
def test_unclosed_short_t_leg_reduces_the_base(self):
|
||||
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_OPEN,
|
||||
open_date='2026-09-09', base_qty=1000, base_cost=10.0,
|
||||
entry_filled_qty=500, entry_amount=5500.0)
|
||||
self.assertTrue(expire(item, TODAY, 5))
|
||||
self.assertEqual(item.base_qty, 500) # 卖出未买回,底仓变 500
|
||||
self.assertAlmostEqual(item.base_cost, 10.0) # 成本仍是建仓价
|
||||
self.assertEqual(item.residual_qty, 500) # 敞口数值保留在审计字段里
|
||||
|
||||
def test_unclosed_long_t_leg_increases_the_base(self):
|
||||
item = Round(code='600000.SH', kind=KIND_LONG_T, phase=PHASE_OPEN,
|
||||
open_date='2026-09-09', base_qty=1000, base_cost=10.0,
|
||||
entry_filled_qty=300, entry_amount=2700.0)
|
||||
self.assertTrue(expire(item, TODAY, 5))
|
||||
self.assertEqual(item.base_qty, 1300)
|
||||
|
||||
def test_normal_completion_leaves_the_base_untouched(self):
|
||||
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_CLOSING,
|
||||
open_date=TODAY, base_qty=1000, base_cost=10.0,
|
||||
entry_order_id='e1', exit_order_id='x1',
|
||||
entry_filled_qty=500, entry_amount=5500.0,
|
||||
exit_filled_qty=500, exit_amount=4900.0)
|
||||
advance(item, set(), TODAY)
|
||||
self.assertEqual(item.phase, PHASE_CLOSED)
|
||||
self.assertEqual(item.base_qty, 1000)
|
||||
|
||||
def test_aborted_round_never_touches_the_base(self):
|
||||
item = Round(code='600000.SH', kind=KIND_LONG_T, phase=PHASE_OPENING,
|
||||
open_date=TODAY, base_qty=1000, base_cost=10.0,
|
||||
entry_order_id='e1')
|
||||
advance(item, set(), TODAY)
|
||||
self.assertEqual(item.outcome, OUTCOME_ABORTED)
|
||||
self.assertEqual(item.base_qty, 1000)
|
||||
|
||||
|
||||
class ExpireTests(unittest.TestCase):
|
||||
def test_round_beyond_max_hold_days_is_abandoned_not_forced(self):
|
||||
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_OPEN,
|
||||
open_date='2026-09-09', entry_filled_qty=100)
|
||||
self.assertTrue(expire(item, TODAY, 5))
|
||||
self.assertEqual(item.phase, PHASE_CLOSED)
|
||||
self.assertEqual(item.outcome, OUTCOME_EXPIRED)
|
||||
self.assertEqual(item.residual_qty, 100) # 残量留作隔夜,不强平
|
||||
|
||||
def test_round_within_the_limit_is_kept(self):
|
||||
item = Round(code='600000.SH', kind=KIND_SHORT_T, phase=PHASE_OPEN,
|
||||
open_date='2026-09-14', entry_filled_qty=100)
|
||||
self.assertFalse(expire(item, TODAY, 5))
|
||||
self.assertEqual(item.phase, PHASE_OPEN)
|
||||
|
||||
def test_inactive_rounds_never_expire(self):
|
||||
for phase in (PHASE_OPENING, PHASE_CLOSED):
|
||||
with self.subTest(phase=phase):
|
||||
item = Round(code='600000.SH', phase=phase, open_date='2020-01-01')
|
||||
self.assertFalse(expire(item, TODAY, 5))
|
||||
|
||||
|
||||
class InFlightTests(unittest.TestCase):
|
||||
def test_only_busy_statuses_count_as_in_flight(self):
|
||||
orders = [order(f'zt-o{i}', status) for i, status in
|
||||
enumerate(['48', '49', '50', '51', '52', '55', '53', '54', '56', '57'])]
|
||||
self.assertEqual(in_flight_order_ids(orders),
|
||||
{'zt-o0', 'zt-o1', 'zt-o2', 'zt-o3', 'zt-o4', 'zt-o5'})
|
||||
|
||||
def test_empty_local_ids_are_ignored(self):
|
||||
self.assertEqual(in_flight_order_ids([order('', '50')]), set())
|
||||
|
||||
|
||||
class RoundStoreTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temp.cleanup)
|
||||
self.path = Path(temp.name) / 'zt_rounds.json'
|
||||
|
||||
def test_roundtrip_survives_restart(self):
|
||||
store = RoundStore(self.path)
|
||||
item = new_round('600000.SH', KIND_SHORT_T, TODAY, 500, 26.89)
|
||||
item.entry_order_id = 'zt-SELL-1'
|
||||
item.entry_filled_qty = 300
|
||||
item.entry_amount = 8067.0
|
||||
item.seen_deal_ids = ['s1', 's2']
|
||||
store.put(item)
|
||||
store.save()
|
||||
|
||||
reloaded = RoundStore(self.path)
|
||||
restored = reloaded.get('600000.SH')
|
||||
self.assertEqual(restored, item)
|
||||
self.assertEqual(restored.seen_deal_ids, ['s1', 's2'])
|
||||
|
||||
def test_missing_file_starts_empty_and_unknown_code_is_idle(self):
|
||||
store = RoundStore(self.path)
|
||||
self.assertEqual(store.rounds, {})
|
||||
self.assertEqual(store.get('600000.SH').phase, PHASE_IDLE)
|
||||
|
||||
def test_save_leaves_no_temporary_file(self):
|
||||
store = RoundStore(self.path)
|
||||
store.put(Round(code='600000.SH'))
|
||||
store.save()
|
||||
self.assertEqual([p.name for p in self.path.parent.iterdir()],
|
||||
['zt_rounds.json'])
|
||||
|
||||
def test_corrupt_or_foreign_state_raises_for_rebuild(self):
|
||||
cases = {
|
||||
'bad json': '{not json',
|
||||
'wrong root': '[]',
|
||||
'wrong item': '{"600000.SH": 3}',
|
||||
'unknown field': json.dumps({'600000.SH': {'code': '600000.SH', 'zzz': 1}}),
|
||||
}
|
||||
for label, text in cases.items():
|
||||
with self.subTest(label=label):
|
||||
self.path.write_text(text, encoding='utf-8')
|
||||
with self.assertRaises(RoundStoreError):
|
||||
RoundStore(self.path)
|
||||
|
||||
def test_drop_removes_a_code(self):
|
||||
store = RoundStore(self.path)
|
||||
store.put(Round(code='600000.SH'))
|
||||
store.drop('600000.SH')
|
||||
store.drop('600001.SH')
|
||||
self.assertEqual(store.rounds, {})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
152
labs/tests/test_zt_rules.py
Normal file
152
labs/tests/test_zt_rules.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""ZT 正T/反T 规则:方向选择、手数与资金/库存封顶、买卖触发条件。"""
|
||||
|
||||
import unittest
|
||||
|
||||
from strategy.zt.rounds import KIND_LONG_T, KIND_SHORT_T
|
||||
from strategy.zt.rules import (
|
||||
choose_kind,
|
||||
entry_triggered,
|
||||
entry_volume,
|
||||
exit_triggered,
|
||||
exit_volume,
|
||||
price_allowed,
|
||||
)
|
||||
|
||||
BASE_COST = 10.0
|
||||
|
||||
|
||||
class ChooseKindTests(unittest.TestCase):
|
||||
def test_band_decides_the_direction(self):
|
||||
self.assertEqual(choose_kind(9.0, BASE_COST, 1.0), KIND_LONG_T)
|
||||
self.assertEqual(choose_kind(11.0, BASE_COST, 1.0), KIND_SHORT_T)
|
||||
|
||||
def test_neutral_band_does_nothing(self):
|
||||
for price in (9.91, 10.0, 10.09):
|
||||
with self.subTest(price=price):
|
||||
self.assertIsNone(choose_kind(price, BASE_COST, 1.0))
|
||||
|
||||
def test_invalid_inputs_yield_no_direction(self):
|
||||
for price, cost, band in ((0, BASE_COST, 1.0), (-1, BASE_COST, 1.0),
|
||||
(9.0, 0, 1.0), (9.0, BASE_COST, -1)):
|
||||
with self.subTest(price=price, cost=cost, band=band):
|
||||
self.assertIsNone(choose_kind(price, cost, band))
|
||||
|
||||
def test_zero_band_picks_a_side_but_never_both(self):
|
||||
self.assertEqual(choose_kind(9.99, BASE_COST, 0), KIND_LONG_T)
|
||||
self.assertEqual(choose_kind(10.01, BASE_COST, 0), KIND_SHORT_T)
|
||||
|
||||
def test_price_cap(self):
|
||||
self.assertTrue(price_allowed(199.0, 200.0))
|
||||
self.assertFalse(price_allowed(200.01, 200.0))
|
||||
self.assertFalse(price_allowed(0, 200.0))
|
||||
|
||||
|
||||
class EntryVolumeTests(unittest.TestCase):
|
||||
def test_long_t_uses_hands_and_is_capped_by_cash(self):
|
||||
self.assertEqual(entry_volume(KIND_LONG_T, price=10.0, open_hands=3,
|
||||
sell_ratio=0.5, base_qty=0,
|
||||
can_use_volume=0, available=100000.0), 300)
|
||||
self.assertEqual(entry_volume(KIND_LONG_T, price=10.0, open_hands=3,
|
||||
sell_ratio=0.5, base_qty=0,
|
||||
can_use_volume=0, available=2500.0), 200)
|
||||
self.assertEqual(entry_volume(KIND_LONG_T, price=10.0, open_hands=3,
|
||||
sell_ratio=0.5, base_qty=0,
|
||||
can_use_volume=0, available=999.0), 0)
|
||||
|
||||
def test_long_t_never_forces_a_lot_when_cash_is_short(self):
|
||||
# 与 calc_buy_volume 的 max(1, ...) 不同:这里买不起就不买。
|
||||
self.assertEqual(entry_volume(KIND_LONG_T, price=1500.0, open_hands=1,
|
||||
sell_ratio=0.5, base_qty=0,
|
||||
can_use_volume=0, available=5000.0), 0)
|
||||
|
||||
def test_short_t_uses_ratio_and_is_capped_by_sellable_inventory(self):
|
||||
self.assertEqual(entry_volume(KIND_SHORT_T, price=10.0, open_hands=3,
|
||||
sell_ratio=0.5, base_qty=1000,
|
||||
can_use_volume=1000, available=0.0), 500)
|
||||
self.assertEqual(entry_volume(KIND_SHORT_T, price=10.0, open_hands=3,
|
||||
sell_ratio=0.5, base_qty=1000,
|
||||
can_use_volume=250, available=0.0), 200)
|
||||
self.assertEqual(entry_volume(KIND_SHORT_T, price=10.0, open_hands=3,
|
||||
sell_ratio=0.5, base_qty=1000,
|
||||
can_use_volume=99, available=0.0), 0)
|
||||
self.assertEqual(entry_volume(KIND_SHORT_T, price=10.0, open_hands=3,
|
||||
sell_ratio=0.5, base_qty=100,
|
||||
can_use_volume=100, available=0.0), 0)
|
||||
|
||||
def test_unknown_kind_or_bad_price_does_nothing(self):
|
||||
self.assertEqual(entry_volume('???', price=10.0, open_hands=3, sell_ratio=0.5,
|
||||
base_qty=100, can_use_volume=100, available=1e6), 0)
|
||||
self.assertEqual(entry_volume(KIND_LONG_T, price=0.0, open_hands=3, sell_ratio=0.5,
|
||||
base_qty=0, can_use_volume=0, available=1e6), 0)
|
||||
|
||||
|
||||
class ExitVolumeTests(unittest.TestCase):
|
||||
def test_long_t_exit_is_limited_by_sellable_inventory(self):
|
||||
# 正T 当天买入的份额 T+1 才可卖:可卖为 0 时只能留成隔夜。
|
||||
self.assertEqual(exit_volume(KIND_LONG_T, residual_qty=300, price=10.0,
|
||||
can_use_volume=0, available=1e6), 0)
|
||||
self.assertEqual(exit_volume(KIND_LONG_T, residual_qty=300, price=10.0,
|
||||
can_use_volume=300, available=1e6), 300)
|
||||
self.assertEqual(exit_volume(KIND_LONG_T, residual_qty=300, price=10.0,
|
||||
can_use_volume=250, available=1e6), 200)
|
||||
self.assertEqual(exit_volume(KIND_LONG_T, residual_qty=150, price=10.0,
|
||||
can_use_volume=100, available=1e6), 100)
|
||||
|
||||
def test_short_t_exit_is_limited_by_cash(self):
|
||||
self.assertEqual(exit_volume(KIND_SHORT_T, residual_qty=500, price=10.0,
|
||||
can_use_volume=0, available=3000.0), 300)
|
||||
self.assertEqual(exit_volume(KIND_SHORT_T, residual_qty=500, price=10.0,
|
||||
can_use_volume=0, available=100000.0), 500)
|
||||
self.assertEqual(exit_volume(KIND_SHORT_T, residual_qty=500, price=10.0,
|
||||
can_use_volume=0, available=50.0), 0)
|
||||
|
||||
def test_nothing_to_close(self):
|
||||
for residual in (0, -100):
|
||||
with self.subTest(residual=residual):
|
||||
self.assertEqual(exit_volume(KIND_LONG_T, residual_qty=residual, price=10.0,
|
||||
can_use_volume=1000, available=1e6), 0)
|
||||
|
||||
|
||||
class TriggerTests(unittest.TestCase):
|
||||
def test_entry_needs_direction_plus_confirmation(self):
|
||||
self.assertTrue(entry_triggered(KIND_LONG_T, 9.0, BASE_COST, band_pct=1.0,
|
||||
rebound_confirmed=True, retrace_confirmed=False))
|
||||
self.assertFalse(entry_triggered(KIND_LONG_T, 9.0, BASE_COST, band_pct=1.0,
|
||||
rebound_confirmed=False, retrace_confirmed=True))
|
||||
self.assertTrue(entry_triggered(KIND_SHORT_T, 11.0, BASE_COST, band_pct=1.0,
|
||||
rebound_confirmed=False, retrace_confirmed=True))
|
||||
# 方向与位置不符时即使确认也不触发
|
||||
self.assertFalse(entry_triggered(KIND_SHORT_T, 9.0, BASE_COST, band_pct=1.0,
|
||||
rebound_confirmed=True, retrace_confirmed=True))
|
||||
self.assertFalse(entry_triggered(KIND_LONG_T, 10.0, BASE_COST, band_pct=1.0,
|
||||
rebound_confirmed=True, retrace_confirmed=True))
|
||||
|
||||
def test_short_t_exit_needs_fall_and_rebound(self):
|
||||
kwargs = dict(buy_fall_pct=1.0, profit_step_pct=1.0)
|
||||
self.assertTrue(exit_triggered(KIND_SHORT_T, 9.8, 10.0,
|
||||
rebound_confirmed=True, **kwargs))
|
||||
self.assertFalse(exit_triggered(KIND_SHORT_T, 9.8, 10.0,
|
||||
rebound_confirmed=False, **kwargs))
|
||||
self.assertFalse(exit_triggered(KIND_SHORT_T, 9.95, 10.0,
|
||||
rebound_confirmed=True, **kwargs))
|
||||
|
||||
def test_long_t_exit_needs_a_profit_step(self):
|
||||
kwargs = dict(buy_fall_pct=1.0, profit_step_pct=1.0)
|
||||
self.assertTrue(exit_triggered(KIND_LONG_T, 10.1, 10.0,
|
||||
rebound_confirmed=False, **kwargs))
|
||||
self.assertFalse(exit_triggered(KIND_LONG_T, 10.0, 10.0,
|
||||
rebound_confirmed=True, **kwargs))
|
||||
# 正T 平仓不看回落,回落到成本之下不卖
|
||||
self.assertFalse(exit_triggered(KIND_LONG_T, 9.8, 10.0,
|
||||
rebound_confirmed=True, **kwargs))
|
||||
|
||||
def test_missing_basis_never_triggers(self):
|
||||
self.assertFalse(exit_triggered(KIND_LONG_T, 12.0, 0.0,
|
||||
buy_fall_pct=1.0, profit_step_pct=1.0,
|
||||
rebound_confirmed=True))
|
||||
self.assertFalse(exit_triggered('???', 12.0, 10.0, buy_fall_pct=1.0,
|
||||
profit_step_pct=1.0, rebound_confirmed=True))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
125
labs/tests/zt_harness.py
Normal file
125
labs/tests/zt_harness.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""ZT 新路径测试脚手架:真实 Runtime/OrderBook/DipWatch + 模拟客户端。"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import Mock
|
||||
|
||||
from libs.grid_take_profit import GridTrailingTracker
|
||||
from libs.order import OrderBook
|
||||
from libs.runtime import Runtime
|
||||
from libs.watch import DipWatch
|
||||
from sdk import Assets, DealItem, PositionItem, Tick
|
||||
from strategy.zt.rounds import Round, RoundStore, start_round
|
||||
|
||||
ACCOUNT = 'zt-test'
|
||||
|
||||
|
||||
def account_cfg(**overrides):
|
||||
cfg = NS(account_id=ACCOUNT, strategy='zt', host_key='test',
|
||||
grid_step_pct=1.0, zt_open_hands=1, zt_sell_ratio=0.5,
|
||||
zt_buy_fall_pct=1.0, zt_max_price=200.0, zt_t_band_pct=1.0,
|
||||
zt_max_hold_days=5, min_cash_ratio=0.1,
|
||||
excluded_codes=[], signal_allow=['dcm'], buy_value=10000.0)
|
||||
for key, value in overrides.items():
|
||||
setattr(cfg, key, value)
|
||||
return cfg
|
||||
|
||||
|
||||
def global_cfg(**overrides):
|
||||
cfg = NS(qmt_base_url='http://unused', qmt_token='', api_host='http://unused',
|
||||
qmt_data_dir='.', signals={})
|
||||
for key, value in overrides.items():
|
||||
setattr(cfg, key, value)
|
||||
return cfg
|
||||
|
||||
|
||||
class Fixture:
|
||||
"""一套隔离的账户快照、轮次存储与运行上下文。"""
|
||||
|
||||
def __init__(self, **cfg_overrides):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.path = Path(self.tmp.name)
|
||||
self.rounds_path = self.path / f'zt_{ACCOUNT}_rounds.json'
|
||||
self.store = RoundStore(self.rounds_path)
|
||||
self.account_cfg = account_cfg(**cfg_overrides)
|
||||
self.assets = Assets(total=100000.0, available=100000.0)
|
||||
self.positions = {}
|
||||
self.orders = []
|
||||
self.deals = []
|
||||
self.ticks = {}
|
||||
self.client = self._client()
|
||||
self.run = Runtime(
|
||||
client=self.client, global_cfg=global_cfg(),
|
||||
account_cfg=self.account_cfg,
|
||||
orders=OrderBook(cancel_timeout_sec=300),
|
||||
open_watch=DipWatch(expire_seconds=600, rebound_threshold=0.0),
|
||||
add_watch=DipWatch(expire_seconds=600, rebound_threshold=0.0),
|
||||
profit_tracker=GridTrailingTracker(self.account_cfg.grid_step_pct),
|
||||
)
|
||||
|
||||
def _client(self):
|
||||
client = Mock()
|
||||
client.deals.side_effect = lambda: self.deals
|
||||
client.portfolio.side_effect = lambda: NS(
|
||||
assets=self.assets, positions=self.positions, orders=self.orders)
|
||||
client.full_tick.side_effect = lambda codes: dict(self.ticks)
|
||||
return client
|
||||
|
||||
def cleanup(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
# ---- 便捷构造 ----
|
||||
def hold(self, code='600000.SH', volume=1000, price=10.0, can_use=None):
|
||||
position = PositionItem(stock_code=code, volume=volume, open_price=price,
|
||||
can_use_volume=volume if can_use is None else can_use)
|
||||
self.positions[code] = position
|
||||
return position
|
||||
|
||||
def quote(self, code='600000.SH', price=10.0):
|
||||
self.ticks[code] = Tick(last_price=price)
|
||||
return self.ticks[code]
|
||||
|
||||
def deal(self, local_id, volume, price, sys_id=None, code='600000.SH'):
|
||||
return DealItem(stock_code=code, order_sys_id=sys_id or f'{local_id}-{volume}',
|
||||
remark=local_id, offset_flag=48, volume=volume, price=price,
|
||||
trade_amount=price * volume, trade_date='20260915',
|
||||
trade_time='100000')
|
||||
|
||||
def prime(self, watch, code, price):
|
||||
"""让 DipWatch 先建立观察点,下一次同价或更高价即满足反弹确认。"""
|
||||
watch.triggered('prime', code, price)
|
||||
|
||||
def tick(self, signals=()):
|
||||
"""跑一轮 RunOnce,绕过真实时钟的交易时段判断。"""
|
||||
from unittest.mock import patch
|
||||
from strategy.zt import boot
|
||||
with patch.object(boot, 'trading_time', return_value=True):
|
||||
boot.RunOnce(self.run, self.store, list(signals))
|
||||
return self.store
|
||||
|
||||
def own_base(self, code='600000.SH', qty=1000, cost=10.0, today='2026-09-15'):
|
||||
"""把该证券标记为"本策略自己建仓"(模拟建仓腿已成交)。"""
|
||||
item = Round(code=code, base_qty=qty, base_cost=cost, base_date=today,
|
||||
base_source='opened', phase='CLOSED', outcome='base')
|
||||
self.store.put(item)
|
||||
self.store.save()
|
||||
return item
|
||||
|
||||
def open_round(self, code='600000.SH', kind='LONG_T', order_id='zt-entry-1',
|
||||
today='2026-09-15', **fields):
|
||||
"""写入一条已提交开仓腿的轮次记录。"""
|
||||
item = Round(code=code, base_qty=fields.pop('base_qty', 1000),
|
||||
base_cost=fields.pop('base_cost', 10.0),
|
||||
base_source=fields.pop('base_source', 'opened'))
|
||||
start_round(item, kind, today)
|
||||
item.entry_order_id = order_id
|
||||
for key, value in fields.items():
|
||||
setattr(item, key, value)
|
||||
self.store.put(item)
|
||||
self.store.save()
|
||||
return item
|
||||
|
||||
@property
|
||||
def placed(self):
|
||||
return [call.kwargs for call in self.client.passorder.call_args_list]
|
||||
Reference in New Issue
Block a user