from datetime import datetime from zoneinfo import ZoneInfo from app.sim.liquidity import bid_mark_ok from app.sim.pricing import option_fill, perp_fill from app.strategy.clock import ( can_open_new, expiry_blocked_by_one_per_day, used_expiry_ymds_for_day, window_key, ) from app.strategy.exits import check_expiry_close, check_exits from app.strategy.signal import decide _SH = ZoneInfo("Asia/Shanghai") def test_signal_buy_call_short_perp() -> None: s = decide(20.0, 15.0) assert s is not None assert s.option_side == "call" assert s.perp_side == "short" def test_signal_buy_put_long_perp() -> None: s = decide(10.0, 16.0) assert s is not None assert s.option_side == "put" assert s.perp_side == "long" def test_signal_equal() -> None: assert decide(10.0, 10.0) is None def test_decide_fixed_long_put() -> None: from app.strategy.signal import decide_fixed s = decide_fixed(20.0, 15.0, perp_side="long") assert s is not None assert s.option_side == "put" assert s.perp_side == "long" assert s.bias == "fixed_long_put" def test_decide_fixed_short_call() -> None: from app.strategy.signal import decide_fixed s = decide_fixed(20.0, 15.0, perp_side="short") assert s is not None assert s.option_side == "call" assert s.perp_side == "short" assert s.bias == "fixed_short_call" def test_decide_fixed_needs_only_own_leg() -> None: from app.strategy.signal import decide_fixed # 半自动多/空:对侧卖一缺失仍可定方向 sc = decide_fixed(8.2, None, perp_side="short") assert sc is not None and sc.option_side == "call" sp = decide_fixed(None, 11.4, perp_side="long") assert sp is not None and sp.option_side == "put" assert decide_fixed(None, None, perp_side="short") is None def test_signal_strike_below_spot_call_short() -> None: # 现价 1859、ATM 1850:即使 Put 卖一更高,也走 Call+空 s = decide(10.0, 20.0, strike=1850, mark_px=1859) assert s is not None assert s.option_side == "call" assert s.perp_side == "short" assert s.bias == "strike_below_spot" def test_signal_strike_above_spot_put_long() -> None: # 现价 1859、ATM 1875:即使 Call 卖一更高,也走 Put+多 s = decide(20.0, 10.0, strike=1875, mark_px=1859) assert s is not None assert s.option_side == "put" assert s.perp_side == "long" assert s.bias == "strike_above_spot" def test_signal_strike_flat_falls_back_to_ask() -> None: s = decide(20.0, 15.0, strike=1860, mark_px=1860) assert s is not None assert s.bias == "call_ask_gt_put" assert s.option_side == "call" def test_exit_fixed_and_premium_multiple() -> None: fixed = check_exits( net_pnl=15.0, exit_mode="fixed_usdt", net_profit_target=15, premium_exit_multiple=1, initial_premium=40, ) assert fixed.reason == "fixed_usdt" assert fixed.target == 15 assert ( check_exits( net_pnl=14.9, exit_mode="fixed_usdt", net_profit_target=15, premium_exit_multiple=1, initial_premium=40, ).should_close is False ) prem = check_exits( net_pnl=40.0, exit_mode="premium_multiple", net_profit_target=15, premium_exit_multiple=1, initial_premium=40, ) assert prem.reason == "premium_multiple" assert prem.target == 40 half = check_exits( net_pnl=20.0, exit_mode="premium_multiple", net_profit_target=15, premium_exit_multiple=0.5, initial_premium=40, ) assert half.should_close is True assert half.target == 20 def test_perp_pricing() -> None: r = perp_fill(side="long", action="open", bid=100, ask=101, qty_eth=1, fee_rate=0.001) assert abs(r.fill_px - 101 * 1.001) < 1e-9 def test_option_open_close_pricing() -> None: o = option_fill(action="open", bid=10, ask=12, qty_eth=2, fee_rate=0.001) assert o.fill_px > 12 c = option_fill(action="close", bid=10, ask=12, qty_eth=2, fee_rate=0.001) assert c.fill_px < 10 def test_window_weekend_skip() -> None: # 2026-07-24 周五可开;25/26 周六日不可开 fri = datetime(2026, 7, 24, 17, 0, tzinfo=_SH) sat = datetime(2026, 7, 25, 12, 0, tzinfo=_SH) sun = datetime(2026, 7, 26, 10, 0, tzinfo=_SH) mon = datetime(2026, 7, 27, 9, 0, tzinfo=_SH) assert can_open_new(fri, skip_weekends=True) is True assert can_open_new(sat, skip_weekends=True) is False assert can_open_new(sun, skip_weekends=True) is False assert can_open_new(mon, skip_weekends=True) is True assert can_open_new(sat, skip_weekends=False) is True assert window_key(fri) == "20260724" def test_one_expiry_per_day(tmp_path, monkeypatch) -> None: monkeypatch.setenv("MODE", "SIM") from app.models.db import Database db = Database(tmp_path / "one_exp.db") # 日历已跨到 8.3,但 8.2 开过的 260803 仍须占用 day = datetime(2026, 8, 3, 0, 0, 2, tzinfo=_SH) with db._lock: db._conn.execute( """INSERT INTO groups(group_id, status, expiry_ymd, open_at_ms) VALUES ('G-20260802-01','closed','260803',1)""" ) db._conn.execute( """INSERT INTO groups(group_id, status, expiry_ymd, open_at_ms) VALUES ('G-20260801-01','closed','260802',1)""" ) db._conn.commit() used = used_expiry_ymds_for_day(db, day) assert used == {"260803", "260802"} assert expiry_blocked_by_one_per_day("260803", used, enabled=True) is True assert expiry_blocked_by_one_per_day("260804", used, enabled=True) is False assert expiry_blocked_by_one_per_day("260803", used, enabled=False) is False db.close() def test_bid_mark_deviation_30pct() -> None: # |7-10|/10 = 30% → 允许(≤30%) ok, _ = bid_mark_ok(bid=7.0, mark=10.0, max_dev_pct=30) assert ok is True ok2, _ = bid_mark_ok(bid=6.9, mark=10.0, max_dev_pct=30) assert ok2 is False ok3, why = bid_mark_ok(bid=None, mark=10.0, max_dev_pct=30) assert ok3 is False assert "买一" in why def test_expiry_close() -> None: assert check_expiry_close(expiry_ms=None).should_close is False d = check_expiry_close(expiry_ms=1_000, now_ms=999) assert d.should_close is False d2 = check_expiry_close(expiry_ms=1_000, now_ms=1_000) assert d2.should_close is True assert d2.reason == "expiry" d3 = check_expiry_close(expiry_ms=1_000, now_ms=1_001) assert d3.should_close is True def test_deep_otm_and_expiry_settle() -> None: from app.sim.pricing import is_deep_otm, option_expiry_settle, option_intrinsic assert is_deep_otm(option_side="call", strike=1860, spot=1840) is True assert is_deep_otm(option_side="call", strike=1860, spot=1882) is False assert is_deep_otm(option_side="put", strike=1860, spot=1882) is True assert option_intrinsic(option_side="call", strike=1860, spot=1840) == 0.0 settled = option_expiry_settle(intrinsic=0.0, qty_eth=2.0, fee_rate=0.0005) assert settled.fill_px == 0.0 assert settled.notional == 0.0 def test_pair_from_held_option_inst() -> None: from app.exchange.option_ids import flip_option_side, pair_from_option_inst put = "ETH-USD_UM-260727-1880-P" pair = pair_from_option_inst(put) assert pair is not None assert pair.strike == 1880 assert pair.expiry_ymd == "260727" assert pair.put_inst_id == put assert pair.call_inst_id == "ETH-USD_UM-260727-1880-C" assert flip_option_side(put) == pair.call_inst_id bn = "ETH-260727-1890-C" bp = pair_from_option_inst(bn) assert bp is not None assert bp.strike == 1890 assert bp.call_inst_id == bn assert bp.put_inst_id == "ETH-260727-1890-P" def test_option_intrinsic_and_close_bid_floor() -> None: from app.sim.pricing import ( option_expiry_settle, option_intrinsic, resolve_option_close_bid, ) assert option_intrinsic(option_side="call", strike=1860, spot=1882) == 22.0 assert option_intrinsic(option_side="put", strike=1860, spot=1882) == 0.0 assert option_intrinsic(option_side="put", strike=1860, spot=1840) == 20.0 # 到期:严格按内在价值,无滑点 settled = option_expiry_settle(intrinsic=22.0, qty_eth=2.0, fee_rate=0.0005) assert settled.fill_px == 22.0 assert settled.slip == 0.0 assert settled.notional == 44.0 assert abs(settled.fee - 44.0 * 0.0005) < 1e-12 # 紧急:对齐 OKX 市价卖,只按买一(垃圾买一不抬到内在价值/标记) assert ( resolve_option_close_bid( bid=0.2, mark=22.0, intrinsic=22.0, bypass_liquidity=True ) == 0.2 ) # 常规:内在价值地板 assert ( resolve_option_close_bid( bid=0.2, mark=0.2, intrinsic=22.0, bypass_liquidity=False ) == 22.0 ) # 买一高于内在价值,保留买一 assert ( resolve_option_close_bid( bid=25.0, mark=24.0, intrinsic=22.0, bypass_liquidity=True ) == 25.0 ) # 紧急无买一 → 无法定价(不能用标记) assert ( resolve_option_close_bid( bid=None, mark=3.0, intrinsic=22.0, bypass_liquidity=True ) is None )