556 lines
29 KiB
Python
556 lines
29 KiB
Python
"""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 GridLot, Store, SymbolState
|
||
|
||
|
||
CODE, OTHER = '510300.SH', '159915.SZ'
|
||
NOW = datetime(2026, 9, 16, 10)
|
||
# ma60=10、grid=0.2,指标入场门槛 9.5;引擎再叠加 ma60-1格 = 9.8,最终入场价 9.5
|
||
IND = Indicators('20260915', 10, 0.2, 9.5, 10, 10.5, 0.2, entry_band=9.5,
|
||
donchian_lo=9.1, donchian_hi=10.9)
|
||
|
||
|
||
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)
|
||
|
||
|
||
def held(lots: dict, anchor: float, opened_days_ago=5):
|
||
"""构造一个已持仓的网格:lots = {档位: (数量, 成本)}。"""
|
||
state = SymbolState(anchor=anchor)
|
||
day = NOW.date() - timedelta(days=opened_days_ago)
|
||
for level, (volume, cost) in lots.items():
|
||
state.lots[str(level)] = GridLot(volume=volume, cost=cost, bought=day, buys=1)
|
||
state.last_buy = max(cost for _, cost in lots.values())
|
||
return state
|
||
|
||
|
||
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'}
|
||
# 测试用零佣金、零金额门槛、每格 1 手、3 档:真实默认值为最低佣金 5 元设计。
|
||
self.cfg = ETFConfig(codes=(CODE,), buy_hands=1, grid_levels=2, max_hands=3,
|
||
min_commission=0, commission_rate=0, min_order_value=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, engine=None):
|
||
portfolio = Portfolio(Assets(total=10000, available=cash),
|
||
{CODE: pos or position()}, list(orders))
|
||
(engine or self.engine).run(portfolio, {CODE: tick(price, now)}, {CODE: ind}, now)
|
||
|
||
def keep(self, low, high):
|
||
"""让 DipWatch 从 low 反弹到 high(反弹幅度必须 ≥ 0.61%)。"""
|
||
self.run_price(low)
|
||
self.run_price(round(low * 1.007, 3))
|
||
self.run_price(high)
|
||
|
||
def anchor_grid(self, low=9.3, high=9.38):
|
||
"""跌到 low 后反弹到 high(≥0.61%)确认,锚点取确认价 high,挂单挂在 low。"""
|
||
self.run_price(low)
|
||
self.run_price(high)
|
||
return self.newest()
|
||
|
||
def ack(self, pending, pos, price=None, side=None, cost=None, status=56, filled=None):
|
||
"""把一笔委托做成终态回报,并给出成交后的持仓快照。"""
|
||
volume = pending['volume'] if filled is None else filled
|
||
side = side or pending['side']
|
||
fill_price = pending['price'] if price is None else price
|
||
report = OrderItem(stock_code=CODE, remark=pending['id'] + '|etf',
|
||
offset_flag={'BUY': 23, 'SELL': 24}[side],
|
||
volume_traded=volume, volume_total_original=pending['volume'],
|
||
traded_price=fill_price, order_status=status)
|
||
after = PositionItem(stock_code=CODE,
|
||
volume=pos.volume + (volume if side == 'BUY' else -volume),
|
||
open_price=cost if cost is not None else pos.open_price,
|
||
can_use_volume=max(0, pos.can_use_volume - volume)
|
||
if side == 'SELL' else pos.can_use_volume)
|
||
return report, after
|
||
|
||
def fills(self, volume, cost, available=None):
|
||
return {level: (volume, cost, available) for level in self.store.get(CODE).lots}
|
||
|
||
def newest(self, side='BUY'):
|
||
pending = self.store.get(CODE).pending
|
||
rows = [p for p in pending.values() if p['side'] == side]
|
||
return max(rows, key=lambda p: p['id'])
|
||
|
||
def report(self, pending, status=56, filled=None, price=None, side=None):
|
||
volume = pending['volume'] if filled is None else filled
|
||
return OrderItem(stock_code=CODE, remark=pending['id'] + '|etf',
|
||
offset_flag={'BUY': 23, 'SELL': 24}[side or pending['side']],
|
||
volume_traded=volume, volume_total_original=pending['volume'],
|
||
traded_price=pending['price'] if price is None else price,
|
||
order_status=status)
|
||
|
||
# ------------------------------------------------------------- 入场
|
||
def test_entry_band_requires_rebound_then_ladder_order(self):
|
||
"""进入入场区只是开始观察;反弹确认后以 t0 价为锚点挂出锚点档。"""
|
||
self.run_price(9.8) # 未进入入场区
|
||
self.run_price(9.4) # 进入入场区,开始观察
|
||
self.run_price(9.3) # 刷新低点 t0=9.3
|
||
self.run_price(9.35) # 反弹不足 0.61%
|
||
self.client.passorder.assert_not_called()
|
||
self.run_price(9.38) # 反弹 (9.38-9.3)/9.3 = 0.86%,锚点取确认价
|
||
request = self.client.passorder.call_args.kwargs
|
||
self.assertEqual((request['volume'], request['price'], request['pr_type']), (100, 9.38, 11))
|
||
self.assertEqual(request['strategy_name'], 'etf')
|
||
self.assertEqual(self.store.get(CODE).anchor, 9.38)
|
||
self.assertEqual(set(self.store.get(CODE).pending), {'0'})
|
||
|
||
def test_leaving_entry_zone_restarts_observation(self):
|
||
"""价格弹回入场区上方后,旧低点作废,必须重新形成低点再确认。"""
|
||
self.run_price(9.4)
|
||
self.run_price(9.3) # t0=9.3
|
||
self.run_price(9.8) # 离开入场区,观察点作废
|
||
self.run_price(9.38) # 反弹不再基于 9.3
|
||
self.client.passorder.assert_not_called()
|
||
|
||
def test_ladder_places_one_order_per_level_below_anchor(self):
|
||
"""锚点 9.4、格距 0.2:价格每跌一格补一档;同一时刻只允许一笔在途委托。"""
|
||
cash = 50000
|
||
pending = self.anchor_grid(low=9.3, high=9.4) # 锚点 = 9.4
|
||
self.assertEqual((pending['level'], pending['price']), (0, 9.4))
|
||
report, after = self.ack(pending, position(), price=9.4)
|
||
self.run_price(9.4, after, [report], cash=cash) # 锚点档成交
|
||
self.assertEqual(self.store.get(CODE).lots['0'].volume, 100)
|
||
|
||
self.run_price(9.2, position(100, 9.4, 0), cash=cash) # 跌到第 1 档
|
||
self.assertEqual(self.store.get(CODE).pending['1']['price'], 9.2) # 锚点-1格
|
||
self.run_price(9.2, position(100, 9.4, 0), cash=cash) # 同档不重复挂单
|
||
self.assertEqual(sorted(self.store.get(CODE).pending), ['1'])
|
||
|
||
pending = self.store.get(CODE).pending['1']
|
||
report, after = self.ack(pending, position(100, 9.4, 0), price=9.2)
|
||
self.run_price(9.0, after, [report], cash=cash) # 第 1 档成交
|
||
self.assertEqual(self.store.get(CODE).lots['1'].volume, 100)
|
||
self.run_price(9.0, position(200, 9.3, 0), cash=cash) # 跌到第 2 档
|
||
self.assertEqual(self.store.get(CODE).pending['2']['price'], 9.0) # 锚点-2格
|
||
|
||
def test_per_level_fill_cost_and_target(self):
|
||
pending = self.anchor_grid()
|
||
report, after = self.ack(pending, position(), price=9.28)
|
||
self.run_price(9.28, after, [report])
|
||
state = self.store.get(CODE)
|
||
self.assertEqual(state.lots['0'].volume, 100)
|
||
self.assertAlmostEqual(state.lots['0'].cost, 9.28)
|
||
self.assertEqual(state.lots['0'].bought, NOW.date())
|
||
# 目标价 = 成本 + 格距×2 = 9.28 + 0.4
|
||
self.assertAlmostEqual(self.engine.sell_target(9.28, IND), 9.68)
|
||
|
||
# ------------------------------------------------------------- 卖出
|
||
def test_each_level_sells_independently_at_own_target(self):
|
||
"""两档成本不同,各自到价才卖,且只卖该档。"""
|
||
state = held({0: (100, 9.0), 1: (100, 10.0)}, anchor=10.0)
|
||
pos = position(200, 9.5, 200)
|
||
self.run_price(9.4, pos, engine=self.engine_with(state))
|
||
request = self.client.passorder.call_args.kwargs
|
||
self.assertEqual((request['op_type'], request['volume']), (24, 100))
|
||
self.assertEqual(self.store.get(CODE).pending['0']['price'], 9.4)
|
||
self.assertNotIn('1', self.store.get(CODE).pending)
|
||
|
||
def engine_with(self, state):
|
||
"""把预置状态挂到引擎上,跳过与券商快照的首次核对。"""
|
||
self.store.symbols[CODE] = state
|
||
engine = Engine(self.client, self.cfg, self.store, 0.1)
|
||
return engine
|
||
|
||
def test_sell_waits_for_target_then_clears_only_that_level(self):
|
||
state = held({0: (100, 9.0)}, anchor=9.0)
|
||
engine = self.engine_with(state)
|
||
pos = position(100, 9.0, 100)
|
||
target = engine.sell_target(9.0, IND)
|
||
self.run_price(target - 0.001, pos, engine=engine)
|
||
self.client.passorder.assert_not_called()
|
||
self.run_price(target, pos, engine=engine)
|
||
request = self.client.passorder.call_args.kwargs
|
||
self.assertEqual((request['op_type'], request['volume']), (24, 100))
|
||
pending = self.store.get(CODE).pending['0']
|
||
report, after = self.ack(pending, pos)
|
||
self.run_price(target, after, [report], engine=engine)
|
||
state = self.store.get(CODE)
|
||
self.assertEqual(state.lots, {})
|
||
self.assertEqual(state.pending, {})
|
||
|
||
def test_sold_level_is_rebought_when_price_returns(self):
|
||
state = held({0: (100, 9.0)}, anchor=9.0)
|
||
engine = self.engine_with(state)
|
||
pos = position(100, 9.0, 100)
|
||
target = engine.sell_target(9.0, IND)
|
||
self.run_price(target, pos, engine=engine)
|
||
report, after = self.ack(self.store.get(CODE).pending['0'], pos)
|
||
self.run_price(target, after, [report], engine=engine)
|
||
self.assertEqual(self.store.get(CODE).lots, {})
|
||
# 价格回到锚点下方:同一档重新挂买单
|
||
self.run_price(9.0, position(), engine=engine)
|
||
pending = self.store.get(CODE).pending
|
||
self.assertIn('0', pending)
|
||
self.assertEqual(pending['0']['side'], 'BUY')
|
||
|
||
def test_t_plus_one_lot_is_not_sold_same_day(self):
|
||
state = held({0: (100, 9.0)}, anchor=9.0, opened_days_ago=0)
|
||
self.run_price(10.7, position(100, 9.0, 100), engine=self.engine_with(state))
|
||
self.client.passorder.assert_not_called()
|
||
|
||
def test_partial_available_volume_limits_sell_to_whole_lots(self):
|
||
state = held({0: (300, 9.0)}, anchor=9.0)
|
||
self.run_price(10.7, position(300, 9.0, 250), engine=self.engine_with(state))
|
||
request = self.client.passorder.call_args.kwargs
|
||
self.assertEqual((request['op_type'], request['volume']), (24, 200))
|
||
|
||
def test_max_hold_days_clears_only_stale_level(self):
|
||
cfg = ETFConfig(codes=(CODE,), buy_hands=1, grid_levels=2, max_hands=3,
|
||
min_commission=0, commission_rate=0, min_order_value=0,
|
||
max_hold_days=3)
|
||
self.engine = Engine(self.client, cfg, self.store, 0.1)
|
||
state = held({0: (100, 9.0)}, anchor=9.0)
|
||
self.store.symbols[CODE] = state
|
||
with self.assertLogs(level='WARNING'):
|
||
self.run_price(9.0, position(100, 9.0, 100))
|
||
request = self.client.passorder.call_args.kwargs
|
||
self.assertEqual((request['op_type'], request['volume']), (24, 100))
|
||
|
||
# ------------------------------------------------------------- 核对
|
||
def test_fill_waits_for_position_snapshot(self):
|
||
pending = self.anchor_grid()
|
||
report = self.report(pending)
|
||
with self.assertLogs(level='WARNING'):
|
||
self.run_price(9.28, orders=[report])
|
||
self.assertIn('0', self.store.get(CODE).pending)
|
||
report, after = self.ack(pending, position(), price=9.28)
|
||
self.run_price(9.28, after, [report])
|
||
state = self.store.get(CODE)
|
||
self.assertEqual(state.pending, {})
|
||
self.assertEqual(state.lots['0'].volume, 100)
|
||
self.assertAlmostEqual(state.last_buy, 9.28)
|
||
self.client.passorder.assert_called_once()
|
||
|
||
def test_pending_written_before_network_and_retained_after_timeout(self):
|
||
"""提交前先落盘意图;网络异常不解除锁,重启后仍保留待确认状态。"""
|
||
def submit(**kwargs):
|
||
saved = Store(self.path, 'test').get(CODE).pending
|
||
self.assertEqual({p['id'] for p in saved.values()}, {kwargs['order_id']})
|
||
raise TimeoutError('unknown result')
|
||
self.client.passorder.side_effect = submit
|
||
engine = Engine(self.client, self.cfg, Store(self.path, 'test'), 0.1)
|
||
engine.orders.busy_cache.clear()
|
||
self.run_price(9.3, engine=engine) # 进入入场区
|
||
with self.assertLogs(level='ERROR'):
|
||
self.run_price(9.38, engine=engine) # 反弹确认,提交时网络异常
|
||
self.assertTrue(Store(self.path, 'test').get(CODE).pending)
|
||
engine = Engine(self.client, self.cfg, Store(self.path, 'test'), 0.1)
|
||
engine.orders.busy_cache.clear()
|
||
with self.assertLogs(level='WARNING'):
|
||
self.run_price(9.3, engine=engine, now=NOW + timedelta(minutes=10))
|
||
self.client.passorder.assert_called_once()
|
||
|
||
def test_rejected_order_releases_level_and_does_not_advance(self):
|
||
pending = self.anchor_grid()
|
||
report = self.report(pending, status=57, filled=0)
|
||
self.run_price(9.39, orders=[report]) # 价格已回到锚点上方,不会重挂
|
||
state = self.store.get(CODE)
|
||
self.assertEqual(state.pending, {})
|
||
self.assertEqual(state.lots, {})
|
||
self.assertEqual(state.last_buy, 0.0)
|
||
# 网格仍保留锚点,价格回到锚点档可重新挂单
|
||
self.run_price(9.38)
|
||
self.assertIn('0', self.store.get(CODE).pending)
|
||
|
||
def test_external_position_change_rebuilds_grid_from_broker_snapshot(self):
|
||
self.run_price(10.0, position(500, 9.9, 500))
|
||
state = self.store.get(CODE)
|
||
self.assertEqual(state.volume, 500)
|
||
self.assertEqual(state.anchor, 9.9)
|
||
self.assertEqual(state.lots['0'].volume, 500)
|
||
|
||
def test_full_position_blocks_further_levels(self):
|
||
state = self.store.get(CODE)
|
||
state.adopt(300, 9.0, NOW.date() - timedelta(days=5))
|
||
self.run_price(8.0, position(300, 9.0, 300))
|
||
self.client.passorder.assert_not_called()
|
||
|
||
def test_cash_reserve_and_fixed_lot_no_downsizing(self):
|
||
self.run_price(9.4, cash=1, now=NOW)
|
||
self.run_price(9.3, cash=1, now=NOW)
|
||
self.client.passorder.assert_not_called()
|
||
|
||
def test_min_order_value_skips_small_level_orders(self):
|
||
cfg = ETFConfig(codes=(CODE,), buy_hands=1, grid_levels=2, max_hands=3,
|
||
min_commission=5.0, commission_rate=0.0003, min_order_value=2000)
|
||
self.engine = Engine(self.client, cfg, self.store, 0.1)
|
||
with self.assertLogs(level='INFO'):
|
||
self.run_price(9.4)
|
||
self.run_price(9.3)
|
||
self.client.passorder.assert_not_called()
|
||
|
||
def test_multiple_symbols_share_one_cash_budget(self):
|
||
cfg = ETFConfig(codes=(CODE, OTHER), buy_hands=1, grid_levels=2, max_hands=3,
|
||
min_commission=0, commission_rate=0, min_order_value=0)
|
||
self.engine = Engine(self.client, cfg, self.store, 0.1)
|
||
portfolio = Portfolio(Assets(total=10000, available=2000), {}, [])
|
||
for price in (9.3, 9.38):
|
||
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()
|
||
self.assertIn('0', self.store.get(CODE).pending)
|
||
self.assertFalse(self.store.get(OTHER).pending)
|
||
|
||
def test_pending_later_symbol_reserves_cash_before_first_symbol(self):
|
||
cfg = ETFConfig(codes=(CODE, OTHER), buy_hands=1, grid_levels=2, max_hands=3,
|
||
min_commission=0, commission_rate=0, min_order_value=0)
|
||
self.store.get(OTHER).pending['0'] = dict(id='ETF-BUY-pending', side='BUY', volume=100,
|
||
base_volume=0, reserved=950, level=0,
|
||
price=9.5)
|
||
self.engine = Engine(self.client, cfg, self.store, 0.1)
|
||
portfolio = Portfolio(Assets(total=10000, available=2000), {}, [])
|
||
with self.assertLogs(level='WARNING'):
|
||
for price in (9.4, 9.3):
|
||
self.engine.run(portfolio, {CODE: tick(price)}, {CODE: IND}, NOW)
|
||
self.client.passorder.assert_not_called()
|
||
|
||
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.3, orders=[report])
|
||
self.client.passorder.assert_not_called()
|
||
self.client.cancel_by_id.assert_not_called()
|
||
|
||
def test_on_road_or_unknown_order_never_opens_grid(self):
|
||
pos = position()
|
||
pos.on_road_volume = 100
|
||
self.run_price(9.4, pos)
|
||
self.run_price(9.3, pos)
|
||
unknown = OrderItem(stock_code=CODE, order_status=255)
|
||
self.run_price(9.4, orders=[unknown])
|
||
self.run_price(9.3, 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.3)
|
||
self.run_price(10.7, position(100, 9.0, 100))
|
||
self.client.passorder.assert_not_called()
|
||
|
||
def test_entry_gate_uses_configured_band_not_boll_lower(self):
|
||
"""入场门槛取自 Indicators.entry_band;band_type 决定它由谁计算。"""
|
||
donchian_ind = Indicators('20260915', 10, 0.2, 9.5, 10, 10.5, 0.2,
|
||
entry_band=9.0, donchian_lo=8.8, donchian_hi=11.0)
|
||
self.run_price(9.4, ind=donchian_ind)
|
||
self.run_price(9.35, ind=donchian_ind) # 高于 Donchian 门槛 9.0
|
||
self.client.passorder.assert_not_called()
|
||
self.run_price(8.95, ind=donchian_ind) # 进入入场区
|
||
self.run_price(8.90, ind=donchian_ind) # 刷新低点
|
||
self.run_price(8.96, ind=donchian_ind) # 反弹 0.67% 确认
|
||
self.client.passorder.assert_called_once()
|
||
self.assertEqual(self.client.passorder.call_args.kwargs['price'], 8.96)
|
||
|
||
def test_band_type_switch_changes_entry_band(self):
|
||
rows = IndicatorTests().bars()
|
||
don = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,), band_type='donchian',
|
||
donchian_period=20, donchian_pct=15.0))
|
||
boll = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,), band_type='boll'))
|
||
self.assertEqual((don.donchian_lo, don.donchian_hi), (9.0, 11.0))
|
||
# 常数序列下 ma60=10、grid=2,两条通道都被 ma60-1格 压到 8.0;
|
||
# 因此这里验证通道本身确实换了,而不是只看最终门槛。
|
||
self.assertEqual(don.lower, boll.lower)
|
||
self.assertAlmostEqual(boll.entry_band, min(boll.lower, boll.ma60 - boll.grid))
|
||
widened = calculate(rows, NOW.date(), ETFConfig(codes=(CODE,), band_type='donchian',
|
||
donchian_period=20, donchian_pct=5.0))
|
||
self.assertAlmostEqual(widened.entry_band, min(9.0 + (11.0 - 9.0) * 0.05,
|
||
widened.ma60 - widened.grid))
|
||
|
||
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_state_roundtrip_keeps_lots_and_pending(self):
|
||
state = self.store.get(CODE)
|
||
state.anchor = 9.3
|
||
state.last_buy = 9.28
|
||
state.lot(0).volume = 200
|
||
state.lot(0).cost = 9.28
|
||
state.lot(0).bought = NOW.date()
|
||
state.lot(0).buys = 1
|
||
state.pending['1'] = dict(id='ETF-BUY-abc', side='BUY', volume=100, base_volume=200,
|
||
reserved=913.0, level=1, price=9.1)
|
||
self.store.save()
|
||
again = Store(self.path, 'test').get(CODE)
|
||
self.assertEqual(again.anchor, 9.3)
|
||
self.assertEqual(again.volume, 200)
|
||
self.assertEqual(again.lots['0'].bought, NOW.date())
|
||
self.assertEqual(again.pending['1']['level'], 1)
|
||
|
||
def test_v1_state_is_migrated_to_anchor_lot(self):
|
||
self.path.write_text(
|
||
'{"version": 1, "account": "test", "symbols": {"510300.SH": '
|
||
'{"volume": 200, "cost": 9.9, "last_buy": 9.8, "armed": true, "sell_grid": 0.2, '
|
||
'"peak": 3, "hold_days": 4, "pending": {}}}}', encoding='utf-8')
|
||
state = Store(self.path, 'test').get(CODE)
|
||
self.assertEqual(state.volume, 200)
|
||
self.assertEqual(state.anchor, 9.9)
|
||
self.assertEqual(state.lots['0'].volume, 200)
|
||
self.assertEqual(state.pending, {})
|
||
|
||
def test_corrupt_state_does_not_silently_start_empty(self):
|
||
for payload in ('{', '{"version": 9, "account": "test", "symbols": {}}',
|
||
'{"version": 2, "account": "other", "symbols": {}}',
|
||
'{"version": 2, "account": "test", "symbols": {"510300.SH": '
|
||
'{"anchor": 9.0, "lots": {"0": {"volume": 100, "cost": 0}}, "pending": {}}}}',
|
||
'{"version": 2, "account": "test", "symbols": {"510300.SH": '
|
||
'{"anchor": 0, "lots": {"0": {"volume": 100, "cost": 9.0}}, "pending": {}}}}',
|
||
'{"version": 2, "account": "test", "symbols": {"510300.SH": '
|
||
'{"anchor": 9.0, "lots": {"0": {"volume": 50, "cost": 9.0}}, "pending": {}}}}'):
|
||
with self.subTest(payload=payload):
|
||
self.path.write_text(payload, encoding='utf-8')
|
||
with self.assertRaises(ValueError):
|
||
Store(self.path, 'test')
|
||
|
||
def test_engine_rejects_symbol_removed_with_pending_order(self):
|
||
state = self.store.get(CODE)
|
||
state.pending['0'] = dict(id='ETF-BUY-x', side='BUY', volume=100, base_volume=0,
|
||
reserved=950.0, level=0, price=9.5)
|
||
with self.assertRaises(ValueError):
|
||
Engine(self.client, ETFConfig(codes=(OTHER,)), self.store, 0.1)
|
||
|
||
|
||
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))
|
||
self.assertAlmostEqual(ind.entry_band, min(ind.lower, ind.ma60 - ind.grid))
|
||
|
||
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': ()}, {'min_hold_days': -1},
|
||
{'max_hold_days': -1}, {'grid_levels': 0}, {'grid_levels': 10},
|
||
{'band_type': 'ma'}, {'donchian_pct': 0}):
|
||
with self.assertRaises(ValueError):
|
||
ETFConfig(**dict({'codes': (CODE,)}, **kwargs))
|
||
|
||
def test_default_file_loads(self):
|
||
cfg = load()
|
||
self.assertEqual(cfg.band_type, 'boll')
|
||
self.assertGreaterEqual(cfg.grid_levels, 1)
|
||
self.assertGreater(cfg.sell_grid_mult, 0)
|
||
self.assertGreaterEqual(cfg.min_order_value, 1000)
|
||
|
||
|
||
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_bare_list_response_is_supported(self):
|
||
"""线上 /etf/daily 直接返回一维数组(倒序),旧版是 {code, details} 包装。"""
|
||
def respond(request):
|
||
self.assertEqual(str(request.url), DAILY_URL + '?code=' + CODE)
|
||
self.assertNotIn('x-token', request.headers)
|
||
return httpx.Response(200, json=[self.row(20260915)])
|
||
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_legacy_envelope_response_still_supported(self):
|
||
payload = {'code': 0, 'message': '', 'details': [self.row(20260915)]}
|
||
self.assertEqual([b['date'] for b in parse_daily(payload, CODE, NOW.date())], ['20260915'])
|
||
|
||
def test_sort_filter_then_limit_and_numeric_strings(self):
|
||
payload = [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},
|
||
[self.row(20260916)]):
|
||
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(rows, CODE, NOW.date())
|
||
|
||
def test_external_history_flows_into_real_indicators(self):
|
||
rows = parse_daily([self.row(int(row['date'])) for row in IndicatorTests().bars()],
|
||
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()
|