optz
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""ETF 离线回归:指标、真实防飞刀/网格算法、限仓、回报和持久化。"""
|
||||
"""ETF 离线回归:指标、分格网格(每格独立买卖)、费用门槛、限仓、回报与持久化。"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
import httpx
|
||||
@@ -12,12 +12,14 @@ 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
|
||||
from strategy.etf.state import GridLot, Store, SymbolState
|
||||
|
||||
|
||||
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)
|
||||
# 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):
|
||||
@@ -29,201 +31,414 @@ def position(volume=0, cost=0, available=None):
|
||||
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'}
|
||||
self.cfg = ETFConfig(codes=(CODE,), min_commission=0, commission_rate=0)
|
||||
# 测试用零佣金、零金额门槛、每格 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):
|
||||
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 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 buy(self):
|
||||
self.run_price(9.4)
|
||||
self.run_price(9.46)
|
||||
self.client.passorder.assert_called_once()
|
||||
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 report(self, status=56, filled=100, side=23, price=9.46):
|
||||
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
|
||||
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)
|
||||
rows = [p for p in pending.values() if p['side'] == side]
|
||||
return max(rows, key=lambda p: p['id'])
|
||||
|
||||
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)
|
||||
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.36)
|
||||
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.36, 11))
|
||||
self.assertEqual((request['volume'], request['price'], request['pr_type']), (100, 9.38, 11))
|
||||
self.assertEqual(request['strategy_name'], 'etf')
|
||||
self.assertTrue(self.store.get(CODE).pending)
|
||||
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(saved['id'], kwargs['order_id'])
|
||||
self.assertEqual({p['id'] for p in saved.values()}, {kwargs['order_id']})
|
||||
raise TimeoutError('unknown result')
|
||||
self.client.passorder.side_effect = submit
|
||||
self.run_price(9.4)
|
||||
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.46)
|
||||
self.engine = Engine(self.client, self.cfg, Store(self.path, 'test'), 0.1)
|
||||
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, now=NOW + timedelta(minutes=10))
|
||||
self.run_price(9.3, engine=engine, 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_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_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_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_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))
|
||||
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=1900)
|
||||
self.run_price(9.46, cash=1900)
|
||||
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), min_commission=0, commission_rate=0)
|
||||
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=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)
|
||||
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.46, 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_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):
|
||||
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.46, 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.46, 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.46)
|
||||
self.run_price(10.7, position(100, 10))
|
||||
self.run_price(10.55, position(100, 10))
|
||||
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):
|
||||
self.path.write_text('{', encoding='utf-8')
|
||||
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):
|
||||
Store(self.path, 'test')
|
||||
Engine(self.client, ETFConfig(codes=(OTHER,)), self.store, 0.1)
|
||||
|
||||
|
||||
class IndicatorTests(unittest.TestCase):
|
||||
@@ -241,6 +456,7 @@ class IndicatorTests(unittest.TestCase):
|
||||
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()
|
||||
@@ -267,32 +483,41 @@ 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': ()}):
|
||||
{'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.buy_hands, cfg.max_hands), (1, 10))
|
||||
|
||||
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_request_and_sample_shape(self):
|
||||
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={'code': 0, 'message': '', 'details': [self.row()]})
|
||||
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 = dict(code=0, details=[self.row(20260916), self.row(20260915, close='10.5'),
|
||||
self.row(20260914), self.row(20260917)])
|
||||
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)
|
||||
@@ -307,7 +532,8 @@ class DailyDataTests(unittest.TestCase):
|
||||
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}):
|
||||
{'code': 0, 'details': {}}, {'code': 0, 'details': None},
|
||||
[self.row(20260916)]):
|
||||
with self.subTest(payload=payload), self.assertRaises(ValueError):
|
||||
parse_daily(payload, CODE, NOW.date())
|
||||
|
||||
@@ -316,11 +542,11 @@ class DailyDataTests(unittest.TestCase):
|
||||
[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())
|
||||
parse_daily(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())
|
||||
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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user