38ffbf728b
Stop falling back to ATM quotes for unrealized/close PnL after ATM drifts or restart. Co-authored-by: Cursor <cursoragent@cursor.com>
225 lines
7.1 KiB
Python
225 lines
7.1 KiB
Python
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, 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_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_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
|
||
|
||
# 紧急垃圾买一 0.2,内在价值 22 → 抬到 22
|
||
assert (
|
||
resolve_option_close_bid(
|
||
bid=0.2, mark=0.2, intrinsic=22.0, bypass_liquidity=True
|
||
)
|
||
== 22.0
|
||
)
|
||
# 常规也有内在价值地板
|
||
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
|
||
)
|
||
# bypass 无买一,用标记与内在价值
|
||
assert (
|
||
resolve_option_close_bid(
|
||
bid=None, mark=3.0, intrinsic=22.0, bypass_liquidity=True
|
||
)
|
||
== 22.0
|
||
)
|