feat(hedge): add option-primary mode for perp+options plans
Add UI switch for Call+short/Put+long, premium x0.95 sizing, option-first open, and K+/-points exits with fee-aware net PnL. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
"""永期「以期权为主」定仓/方向/目标位/校验."""
|
||||
import unittest
|
||||
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import (
|
||||
PREMIUM_EXEC_FACTOR,
|
||||
build_option_primary_preview,
|
||||
estimate_combo_net_pnl,
|
||||
floor2,
|
||||
opt_type_for_view,
|
||||
option_bid_liquidity_ok,
|
||||
perp_direction_for_view,
|
||||
size_from_premium,
|
||||
target_hit,
|
||||
validate_option_primary_start,
|
||||
)
|
||||
from lib.hedge_plan.hedge_plan_orders_lib import build_po_path_plan, validate_start_body
|
||||
|
||||
|
||||
class TestOptionPrimary(unittest.TestCase):
|
||||
def test_direction_mapping(self):
|
||||
self.assertEqual(opt_type_for_view("long"), "C")
|
||||
self.assertEqual(opt_type_for_view("short"), "P")
|
||||
self.assertEqual(perp_direction_for_view("long"), "short")
|
||||
self.assertEqual(perp_direction_for_view("short"), "long")
|
||||
|
||||
def test_size_from_premium_095_and_eth_2dp(self):
|
||||
# ask=10 → 1 ETH 成本 10U; 预算 100 → usable 95 → eth=9.5 → sheets=950 (ct=0.01)
|
||||
sized = size_from_premium(
|
||||
premium_budget=100,
|
||||
ask=10,
|
||||
ct_mult=0.01,
|
||||
ratio=2,
|
||||
contract_size=0.01,
|
||||
)
|
||||
self.assertTrue(sized["ok"])
|
||||
self.assertAlmostEqual(sized["usable_premium"], 95.0)
|
||||
self.assertEqual(sized["eth_qty"], 9.5)
|
||||
self.assertEqual(sized["sheets"], 950.0)
|
||||
# perp_eth = 9.5/2=4.75; contracts=4.75/0.01=475
|
||||
self.assertAlmostEqual(sized["contracts"], 475.0)
|
||||
self.assertEqual(PREMIUM_EXEC_FACTOR, 0.95)
|
||||
|
||||
def test_floor2(self):
|
||||
self.assertEqual(floor2(1.239), 1.23)
|
||||
self.assertEqual(floor2(0.009), 0.0)
|
||||
|
||||
def test_target_hit(self):
|
||||
self.assertTrue(target_hit(view_side="long", index_px=1950, strike=1900, points=50))
|
||||
self.assertFalse(target_hit(view_side="long", index_px=1949, strike=1900, points=50))
|
||||
self.assertTrue(target_hit(view_side="short", index_px=1850, strike=1900, points=50))
|
||||
self.assertFalse(target_hit(view_side="short", index_px=1851, strike=1900, points=50))
|
||||
self.assertFalse(target_hit(view_side="long", index_px=1900, strike=1900, points=0))
|
||||
|
||||
def test_bid_liquidity(self):
|
||||
ok, _ = option_bid_liquidity_ok(1.2, 10, need_sheets=5)
|
||||
self.assertTrue(ok)
|
||||
ok2, msg = option_bid_liquidity_ok(None, 10, need_sheets=1)
|
||||
self.assertFalse(ok2)
|
||||
self.assertIn("买一", msg)
|
||||
|
||||
def test_net_pnl_uses_buy_fee_for_sell(self):
|
||||
net = estimate_combo_net_pnl(
|
||||
view_side="long",
|
||||
strike=1900,
|
||||
index_px=1950,
|
||||
ask_open=20,
|
||||
bid=30,
|
||||
sheets=2,
|
||||
ct_mult=0.01,
|
||||
perp_direction="short",
|
||||
perp_entry=1900,
|
||||
perp_mark=1950,
|
||||
contracts=10,
|
||||
contract_size=0.01,
|
||||
fee=0.001,
|
||||
)
|
||||
# opt: proceeds=30*2*0.01=0.6; premium=0.4; fees=0.0004+0.0006; opt_net=0.6-0.4-0.001=0.199
|
||||
self.assertIn("net", net)
|
||||
self.assertEqual(net["fee_rate"], 0.001)
|
||||
|
||||
def test_path_option_primary_no_tpsl_options_first(self):
|
||||
path = build_po_path_plan(
|
||||
{
|
||||
"option_primary": True,
|
||||
"direction": "long",
|
||||
"opt_inst_id": "ETH-USD-260831-1900-C",
|
||||
"sheets": 2,
|
||||
"exchange_symbol": "ETH/USDT:USDT",
|
||||
"contracts": 1,
|
||||
}
|
||||
)
|
||||
self.assertEqual(path[0]["step"], "options_buy_limit")
|
||||
self.assertEqual(path[1]["direction"], "short")
|
||||
self.assertFalse(path[1]["attach_tpsl"])
|
||||
|
||||
def test_validate_option_primary_start(self):
|
||||
body = {
|
||||
"option_primary": True,
|
||||
"direction": "long",
|
||||
"contracts": 1,
|
||||
"opt_inst_id": "ETH-USD-260831-1900-C",
|
||||
"opt_type": "C",
|
||||
"sheets": 2,
|
||||
"exchange_symbol": "ETH/USDT:USDT",
|
||||
"premium_budget": 100,
|
||||
"option_target_points": 50,
|
||||
"perp_target_points": 30,
|
||||
"option_perp_ratio": 2,
|
||||
"strike": 1900,
|
||||
"index_px": 1905,
|
||||
"ask": 10,
|
||||
"moneyness": "atm",
|
||||
"strike_interval": 15,
|
||||
"min_option_hours": 36,
|
||||
"hours_to_expiry": 40,
|
||||
"option_leverage": 100,
|
||||
}
|
||||
self.assertIsNone(validate_option_primary_start(body))
|
||||
self.assertIsNone(validate_start_body("perp_options", body))
|
||||
bad = dict(body, opt_type="P")
|
||||
self.assertIsNotNone(validate_start_body("perp_options", bad))
|
||||
|
||||
def test_preview_builds_scenarios(self):
|
||||
body = {
|
||||
"direction": "long",
|
||||
"strike": 1900,
|
||||
"option_target_points": 50,
|
||||
"perp_target_points": 30,
|
||||
"ask": 20,
|
||||
"sheets": 10,
|
||||
"ct_mult": 0.01,
|
||||
"contracts": 5,
|
||||
"contract_size": 0.01,
|
||||
"entry": 1900,
|
||||
"index_px": 1900,
|
||||
"premium_budget": 100,
|
||||
}
|
||||
out = build_option_primary_preview(body)
|
||||
self.assertTrue(out["option_primary"])
|
||||
self.assertEqual(len(out["scenarios"]), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user