Initial CoinOX instance: coin-margined options + USDT hedge.
Forked from crypto_monitor OKX instance (copy-only). Keeps standalone options, perp-options and options-options hedge; drops standalone perp entrypoints; forces coin margin; adds sim wallets; default port 5010. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
"""对冲计划期权腿盈亏与交易所对齐."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from lib.hedge_plan.hedge_plan_db import get_plan, init_hedge_plan_tables, insert_leg, insert_plan
|
||||
from lib.hedge_plan.hedge_plan_settle_lib import (
|
||||
_parse_opened_ms,
|
||||
backfill_hedge_option_legs_realized_pnl,
|
||||
resolve_option_leg_realized_pnl,
|
||||
)
|
||||
|
||||
|
||||
class HedgeExchangePnlTest(unittest.TestCase):
|
||||
def test_resolve_prefers_exchange(self):
|
||||
leg = {
|
||||
"inst_id": "ETH-USD_UM-260719-1850-P",
|
||||
"opened_at": "2026-07-17 06:59:18",
|
||||
"premium": 5.0,
|
||||
}
|
||||
hist = [
|
||||
{
|
||||
"instId": "ETH-USD_UM-260719-1850-P",
|
||||
"uTime": "1784400000000",
|
||||
"realizedPnl": "12.00",
|
||||
"closeAvgPx": "30",
|
||||
}
|
||||
]
|
||||
pnl, src = resolve_option_leg_realized_pnl(
|
||||
leg=leg, hist_rows=hist, fallback=-5.0
|
||||
)
|
||||
self.assertEqual(src, "exchange")
|
||||
self.assertEqual(pnl, 12.0)
|
||||
|
||||
def test_opened_at_beijing_wall_clock_not_utc(self):
|
||||
# 北京 09:19 开仓 → UTC 01:19; 到期结算北京 16:00:31 = UTC 08:00:31
|
||||
open_ms = _parse_opened_ms("2026-07-20 09:19:30")
|
||||
self.assertEqual(open_ms, 1784510370000)
|
||||
leg = {
|
||||
"inst_id": "ETH-USD_UM-260720-1870-C",
|
||||
"opened_at": "2026-07-20 09:19:30",
|
||||
"premium": 6.9,
|
||||
}
|
||||
hist = [
|
||||
{
|
||||
"instId": "ETH-USD_UM-260720-1870-C",
|
||||
"uTime": "1784534431512",
|
||||
"realizedPnl": "-7.1812815",
|
||||
"pnl": "-6.9",
|
||||
"type": "2",
|
||||
}
|
||||
]
|
||||
pnl, src = resolve_option_leg_realized_pnl(
|
||||
leg=leg, hist_rows=hist, fallback=-6.9
|
||||
)
|
||||
self.assertEqual(src, "exchange")
|
||||
self.assertAlmostEqual(pnl, -7.1813, places=4)
|
||||
|
||||
def test_backfill_updates_plan_total(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_hedge_plan_tables(conn)
|
||||
pid = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "closed",
|
||||
"underlying": "ETH",
|
||||
"premium_total": 10,
|
||||
"realized_pnl_options": 6.08,
|
||||
"realized_pnl_total": 6.08,
|
||||
"opened_at": "2026-07-17 06:59:18",
|
||||
"closed_at": "2026-07-19 16:00:13",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_a",
|
||||
"inst_id": "ETH-USD_UM-260719-1890-C",
|
||||
"opt_type": "C",
|
||||
"strike": 1890,
|
||||
"size": 10,
|
||||
"premium": 5.92,
|
||||
"status": "closed",
|
||||
"realized_pnl": -5.92,
|
||||
"opened_at": "2026-07-17 06:59:18",
|
||||
"closed_at": "2026-07-19 16:00:13",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_b",
|
||||
"inst_id": "ETH-USD_UM-260719-1850-P",
|
||||
"opt_type": "P",
|
||||
"strike": 1850,
|
||||
"size": 10,
|
||||
"premium": 4.0,
|
||||
"status": "closed",
|
||||
"realized_pnl": 12.0,
|
||||
"opened_at": "2026-07-17 06:59:18",
|
||||
"closed_at": "2026-07-19 08:00:00",
|
||||
},
|
||||
)
|
||||
hist = [
|
||||
{
|
||||
"instId": "ETH-USD_UM-260719-1890-C",
|
||||
"uTime": "1784476813000",
|
||||
"realizedPnl": "-5.50",
|
||||
},
|
||||
{
|
||||
"instId": "ETH-USD_UM-260719-1850-P",
|
||||
"uTime": "1784448000000",
|
||||
"realizedPnl": "11.80",
|
||||
},
|
||||
]
|
||||
out = backfill_hedge_option_legs_realized_pnl(conn, hist)
|
||||
self.assertEqual(out["legs"], 2)
|
||||
self.assertEqual(out["plans"], 1)
|
||||
plan = get_plan(conn, pid)
|
||||
self.assertAlmostEqual(float(plan["realized_pnl_options"]), 6.3, places=4)
|
||||
self.assertAlmostEqual(float(plan["realized_pnl_total"]), 6.3, places=4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""对冲计划与单独期权互斥门控."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from lib.hedge_plan.hedge_options_exclusive_lib import (
|
||||
block_hedge_plan_start_msg,
|
||||
block_standalone_option_open_msg,
|
||||
has_standalone_option_position,
|
||||
mutual_exclusive_enabled,
|
||||
)
|
||||
from lib.hedge_plan.hedge_plan_calc_lib import gate_status
|
||||
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables
|
||||
|
||||
|
||||
class HedgeOptionsExclusiveTests(unittest.TestCase):
|
||||
def test_mutual_default_true(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", None)
|
||||
self.assertTrue(mutual_exclusive_enabled())
|
||||
|
||||
def test_mutual_can_disable(self):
|
||||
with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "false"}):
|
||||
self.assertFalse(mutual_exclusive_enabled())
|
||||
|
||||
def test_block_open_when_active_plan(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_hedge_plan_tables(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO hedge_plans (plan_type, underlying, status) "
|
||||
"VALUES ('options_options', 'ETH', 'active')"
|
||||
)
|
||||
conn.commit()
|
||||
with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true"}):
|
||||
msg = block_standalone_option_open_msg(conn)
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertIn("对冲计划", msg or "")
|
||||
with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "false"}):
|
||||
self.assertIsNone(block_standalone_option_open_msg(conn))
|
||||
conn.close()
|
||||
|
||||
def test_standalone_position_detection(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_hedge_plan_tables(conn)
|
||||
raw = [{"instId": "ETH-USD_UM-260719-1890-C", "pos": "1"}]
|
||||
with mock.patch(
|
||||
"lib.instance.instance_dashboard_lib._resolve_options_source",
|
||||
return_value=("option", "纯期权", None),
|
||||
):
|
||||
self.assertTrue(has_standalone_option_position(conn, raw))
|
||||
with mock.patch(
|
||||
"lib.instance.instance_dashboard_lib._resolve_options_source",
|
||||
return_value=("options_options", "期期对冲", 2),
|
||||
):
|
||||
self.assertFalse(has_standalone_option_position(conn, raw))
|
||||
conn.close()
|
||||
|
||||
def test_block_hedge_start_msg(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_hedge_plan_tables(conn)
|
||||
raw = [{"instId": "ETH-USD_UM-260719-1890-C", "pos": "2"}]
|
||||
with mock.patch.dict(os.environ, {"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true"}):
|
||||
with mock.patch(
|
||||
"lib.instance.instance_dashboard_lib._resolve_options_source",
|
||||
return_value=("option", "纯期权", None),
|
||||
):
|
||||
msg = block_hedge_plan_start_msg(conn, raw_positions=raw)
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertIn("单独期权", msg or "")
|
||||
conn.close()
|
||||
|
||||
def test_gate_status_blocks_start(self):
|
||||
g = gate_status(
|
||||
hedge_enabled=True,
|
||||
sizing_mode="full_margin",
|
||||
plan_type="options_options",
|
||||
options_enabled=True,
|
||||
live_order=True,
|
||||
mutual_exclusive=True,
|
||||
has_standalone_option=True,
|
||||
)
|
||||
self.assertFalse(g["can_start"])
|
||||
self.assertTrue(any("单独期权" in r for r in g["reasons"]))
|
||||
g2 = gate_status(
|
||||
hedge_enabled=True,
|
||||
sizing_mode="full_margin",
|
||||
plan_type="options_options",
|
||||
options_enabled=True,
|
||||
live_order=True,
|
||||
mutual_exclusive=False,
|
||||
has_standalone_option=True,
|
||||
)
|
||||
self.assertTrue(g2["can_start"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""半腿失败:手动补开 vs 自动平."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from lib.hedge_plan.hedge_plan_orders_lib import (
|
||||
manual_complete_on_partial,
|
||||
partial_auto_close_enabled,
|
||||
)
|
||||
|
||||
|
||||
class PartialManualTests(unittest.TestCase):
|
||||
def test_manual_default_forces_auto_close_off(self):
|
||||
with mock.patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", None)
|
||||
os.environ["HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION"] = "true"
|
||||
self.assertTrue(manual_complete_on_partial())
|
||||
self.assertFalse(partial_auto_close_enabled())
|
||||
|
||||
def test_manual_off_allows_auto_close(self):
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "false",
|
||||
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION": "true",
|
||||
},
|
||||
):
|
||||
self.assertFalse(manual_complete_on_partial())
|
||||
self.assertTrue(partial_auto_close_enabled())
|
||||
|
||||
def test_both_off(self):
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "false",
|
||||
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION": "false",
|
||||
},
|
||||
):
|
||||
self.assertFalse(partial_auto_close_enabled())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""对冲计划 P0 测算口径单测."""
|
||||
import unittest
|
||||
|
||||
from lib.hedge_plan.hedge_plan_calc_lib import (
|
||||
build_options_options_preview,
|
||||
build_perp_options_preview,
|
||||
floor_contracts_to_precision,
|
||||
gate_status,
|
||||
option_expiry_pnl,
|
||||
option_premium_total,
|
||||
perp_pnl,
|
||||
resolve_oo_budget_usdc,
|
||||
suggest_oo_sheets,
|
||||
)
|
||||
from lib.hedge_plan.hedge_plan_monitor_lib import resolve_oo_rest_close_mode
|
||||
|
||||
|
||||
class TestHedgePlanCalc(unittest.TestCase):
|
||||
def test_perp_tp_accounting_is_profit_minus_premium(self):
|
||||
p = build_perp_options_preview(
|
||||
direction="long",
|
||||
entry=3200,
|
||||
tp=3400,
|
||||
sl=3000,
|
||||
contracts=50,
|
||||
contract_size=0.01,
|
||||
opt_type="P",
|
||||
strike=3100,
|
||||
sheets=10,
|
||||
ct_mult=0.01,
|
||||
premium_paid=8,
|
||||
index_px=3200,
|
||||
)
|
||||
self.assertEqual(p["summary"]["tp_total"], 92.0)
|
||||
self.assertEqual(p["scenarios"][0]["options_pnl"], -8.0)
|
||||
|
||||
def test_perp_sl_accounting_is_option_plus_perp_signed(self):
|
||||
p = build_perp_options_preview(
|
||||
direction="long",
|
||||
entry=3200,
|
||||
tp=3400,
|
||||
sl=3000,
|
||||
contracts=50,
|
||||
contract_size=0.01,
|
||||
opt_type="P",
|
||||
strike=3100,
|
||||
sheets=10,
|
||||
ct_mult=0.01,
|
||||
premium_paid=8,
|
||||
index_px=3200,
|
||||
)
|
||||
self.assertEqual(p["summary"]["sl_total"], -98.0)
|
||||
self.assertEqual(p["summary"]["hedge_ratio_at_sl"], 2.0)
|
||||
|
||||
def test_option_premium_and_expiry(self):
|
||||
self.assertEqual(option_premium_total(ask=80, sheets=1, ct_mult=0.01), 0.8)
|
||||
self.assertEqual(
|
||||
option_expiry_pnl(
|
||||
opt_type="P", strike=3100, spot=3000, sheets=10, ct_mult=0.01, premium_paid=8
|
||||
),
|
||||
2.0,
|
||||
)
|
||||
|
||||
def test_gate_perp_requires_full_margin_for_start_message(self):
|
||||
g = gate_status(
|
||||
hedge_enabled=True,
|
||||
sizing_mode="risk",
|
||||
plan_type="perp_options",
|
||||
options_enabled=True,
|
||||
)
|
||||
self.assertTrue(g["can_preview"])
|
||||
self.assertFalse(g["can_start"])
|
||||
self.assertTrue(any("全仓" in r for r in g["reasons"]))
|
||||
|
||||
def test_gate_hidden_plan_type_blocks_preview(self):
|
||||
g = gate_status(
|
||||
hedge_enabled=True,
|
||||
sizing_mode="full_margin",
|
||||
plan_type="perp_options",
|
||||
options_enabled=True,
|
||||
live_order=True,
|
||||
live_trading=True,
|
||||
show_perp_options=False,
|
||||
)
|
||||
self.assertFalse(g["can_preview"])
|
||||
self.assertFalse(g["can_start"])
|
||||
self.assertTrue(any("隐藏" in r for r in g["reasons"]))
|
||||
self.assertFalse(g["show_perp_options"])
|
||||
self.assertTrue(g["show_options_options"])
|
||||
|
||||
g2 = gate_status(
|
||||
hedge_enabled=True,
|
||||
sizing_mode="risk",
|
||||
plan_type="options_options",
|
||||
options_enabled=True,
|
||||
show_options_options=False,
|
||||
)
|
||||
self.assertFalse(g2["can_preview"])
|
||||
self.assertTrue(any("期期" in r for r in g2["reasons"]))
|
||||
|
||||
def test_oo_expiry_loss_flag(self):
|
||||
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
||||
b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
||||
p = build_options_options_preview(
|
||||
profit_rr=2,
|
||||
index_px=3200,
|
||||
leg_a=a,
|
||||
leg_b=b,
|
||||
)
|
||||
self.assertEqual(p["summary"]["premium_paid"], 10)
|
||||
self.assertTrue(p["summary"]["expiry_is_loss"])
|
||||
self.assertEqual(p["summary"]["profit_rr"], 2)
|
||||
self.assertEqual(p["summary"]["at_rr_a_full_total"], 15) # 盈利=2*10, 亏腿-5
|
||||
self.assertEqual(len(p["scenarios"]), 5)
|
||||
self.assertEqual(p["scenarios"][0]["id"], "rr_leg_a_full")
|
||||
self.assertEqual(p["scenarios"][1]["id"], "rr_leg_b_full")
|
||||
# 到期实值反推:Call 盈利20 → 价值25 → 每币2500 → spot=3300+2500
|
||||
self.assertEqual(p["scenarios"][0]["spot"], 5800.0)
|
||||
# Put 盈利20 → spot=3100-2500
|
||||
self.assertEqual(p["scenarios"][1]["spot"], 600.0)
|
||||
self.assertEqual(p["scenarios"][2]["spot"], 5800.0) # 残值情景同腿A反推
|
||||
|
||||
def test_oo_legacy_single_target_still_works(self):
|
||||
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
||||
b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
||||
p = build_options_options_preview(target_price=3500, index_px=3200, leg_a=a, leg_b=b)
|
||||
self.assertEqual(p["target_price_up"], 3500)
|
||||
self.assertEqual(p["target_price_down"], 3500)
|
||||
|
||||
def test_perp_short_pnl(self):
|
||||
self.assertEqual(
|
||||
perp_pnl(direction="short", entry=100, exit_px=90, contracts=1, contract_size=1),
|
||||
10,
|
||||
)
|
||||
|
||||
def test_floor_contracts_to_precision(self):
|
||||
self.assertEqual(floor_contracts_to_precision(4.569713, 4), 4.5697)
|
||||
self.assertEqual(floor_contracts_to_precision(4.569713, 0), 4.0)
|
||||
self.assertEqual(floor_contracts_to_precision(0, 4), 0.0)
|
||||
|
||||
def test_oo_budget_min_trading_and_cap(self):
|
||||
b = resolve_oo_budget_usdc(trading_usdc=11.07, trade_budget_usdc=10, buffer_ratio=0.95)
|
||||
self.assertTrue(b["ok"])
|
||||
self.assertAlmostEqual(b["trading_cap"], 11.07 * 0.95, places=4)
|
||||
self.assertEqual(b["budget_usdc"], 10.0)
|
||||
|
||||
def test_suggest_oo_same_sheets_default(self):
|
||||
# cost_a=1, cost_b=1 → pair=2; budget=10 → n=5
|
||||
s = suggest_oo_sheets(
|
||||
mode="same_sheets",
|
||||
budget_usdc=10,
|
||||
ask_a=100,
|
||||
ct_mult_a=0.01,
|
||||
ask_b=100,
|
||||
ct_mult_b=0.01,
|
||||
)
|
||||
self.assertEqual(s["mode"], "same_sheets")
|
||||
self.assertEqual(s["sheets_a"], 5)
|
||||
self.assertEqual(s["sheets_b"], 5)
|
||||
self.assertTrue(s["ok"])
|
||||
|
||||
def test_suggest_oo_long_bias_budget(self):
|
||||
# cost_call=1, cost_put=1; budget 10 → call 7U / put 3U → 7 / 3
|
||||
s = suggest_oo_sheets(
|
||||
mode="long_bias",
|
||||
budget_usdc=10,
|
||||
ask_a=100,
|
||||
ct_mult_a=0.01,
|
||||
opt_type_a="C",
|
||||
ask_b=100,
|
||||
ct_mult_b=0.01,
|
||||
opt_type_b="P",
|
||||
bias_split_by="budget",
|
||||
bias_ratio=0.7,
|
||||
)
|
||||
self.assertEqual(s["mode"], "long_bias")
|
||||
self.assertEqual(s["sheets_a"], 7)
|
||||
self.assertEqual(s["sheets_b"], 3)
|
||||
self.assertTrue(s["ok"])
|
||||
|
||||
def test_suggest_oo_short_bias_sheets(self):
|
||||
# 同张数 n=5 → 总张数 10; short → put 7 / call 3
|
||||
s = suggest_oo_sheets(
|
||||
mode="short_bias",
|
||||
budget_usdc=10,
|
||||
ask_a=100,
|
||||
ct_mult_a=0.01,
|
||||
opt_type_a="C",
|
||||
ask_b=100,
|
||||
ct_mult_b=0.01,
|
||||
opt_type_b="P",
|
||||
bias_split_by="sheets",
|
||||
bias_ratio=0.7,
|
||||
)
|
||||
self.assertEqual(s["mode"], "short_bias")
|
||||
self.assertEqual(s["sheets_a"], 3)
|
||||
self.assertEqual(s["sheets_b"], 7)
|
||||
|
||||
def test_suggest_oo_bias_requires_call_put(self):
|
||||
s = suggest_oo_sheets(
|
||||
mode="long_bias",
|
||||
budget_usdc=10,
|
||||
ask_a=100,
|
||||
ct_mult_a=0.01,
|
||||
opt_type_a="C",
|
||||
ask_b=100,
|
||||
ct_mult_b=0.01,
|
||||
opt_type_b="C",
|
||||
bias_split_by="budget",
|
||||
)
|
||||
self.assertFalse(s["ok"])
|
||||
self.assertIn("Call", s["msg"])
|
||||
|
||||
def test_suggest_oo_depth_cap(self):
|
||||
s = suggest_oo_sheets(
|
||||
mode="same_sheets",
|
||||
budget_usdc=100,
|
||||
ask_a=100,
|
||||
ct_mult_a=0.01,
|
||||
ask_sz_a=2,
|
||||
ask_b=100,
|
||||
ct_mult_b=0.01,
|
||||
ask_sz_b=50,
|
||||
)
|
||||
self.assertEqual(s["sheets_a"], 2)
|
||||
self.assertEqual(s["sheets_b"], 2)
|
||||
|
||||
def test_oo_rest_close_mode_default_close_all(self):
|
||||
self.assertEqual(resolve_oo_rest_close_mode({"oo_close_mode": "close_all"}), "close_all")
|
||||
self.assertEqual(resolve_oo_rest_close_mode({"oo_close_mode": "hold_expiry"}), "hold_expiry")
|
||||
# 旧计划无字段:保持到期平,避免部署后误清残腿
|
||||
self.assertEqual(resolve_oo_rest_close_mode({}), "hold_expiry")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""对冲计划:人工结束 + 未成交不显示 open."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from lib.hedge_plan.hedge_plan_db import (
|
||||
get_plan,
|
||||
get_plan_legs,
|
||||
init_hedge_plan_tables,
|
||||
insert_leg,
|
||||
insert_plan,
|
||||
legs_contract_summary,
|
||||
)
|
||||
from lib.hedge_plan.hedge_plan_orders_lib import (
|
||||
execute_manual_end_plan,
|
||||
reconcile_unfilled_option_legs,
|
||||
)
|
||||
|
||||
|
||||
def _mem():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_hedge_plan_tables(conn)
|
||||
return conn
|
||||
|
||||
|
||||
class TestHedgePlanEnd(unittest.TestCase):
|
||||
def test_legs_summary_marks_unfilled(self):
|
||||
s = legs_contract_summary(
|
||||
[
|
||||
{"leg_role": "option_a", "inst_id": "ETH-C", "status": "pending"},
|
||||
{"leg_role": "option_b", "inst_id": "ETH-P", "status": "open"},
|
||||
{"leg_role": "option_a", "inst_id": "X", "status": "cancelled"},
|
||||
]
|
||||
)
|
||||
self.assertIn("(待补)", s)
|
||||
self.assertIn("(未成交)", s)
|
||||
|
||||
def test_reconcile_ghost_open_to_pending(self):
|
||||
conn = _mem()
|
||||
pid = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "active",
|
||||
"underlying": "ETH",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_a",
|
||||
"inst_id": "ETH-USD_UM-260720-1870-C",
|
||||
"status": "open",
|
||||
"exchange_ord_id": "1",
|
||||
"avg_open": 0.01,
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_b",
|
||||
"inst_id": "ETH-USD_UM-260720-1870-P",
|
||||
"status": "open",
|
||||
"exchange_ord_id": "2",
|
||||
"avg_open": 0.02,
|
||||
},
|
||||
)
|
||||
cfg = {"exchange_options": object()}
|
||||
with mock.patch(
|
||||
"lib.hedge_plan.hedge_plan_orders_lib._live_option_pos_sheets",
|
||||
side_effect=lambda ex, inst: 0.0 if "C" in inst else 50.0,
|
||||
), mock.patch(
|
||||
"lib.exchange.okx_options_lib.fetch_option_order",
|
||||
return_value={"ok": True, "state": "canceled", "acc_fill_sz": 0},
|
||||
):
|
||||
notes = reconcile_unfilled_option_legs(cfg, conn, pid)
|
||||
self.assertTrue(notes)
|
||||
legs = {l["leg_role"]: l for l in get_plan_legs(conn, pid)}
|
||||
self.assertEqual(legs["option_a"]["status"], "pending")
|
||||
self.assertEqual(legs["option_b"]["status"], "open")
|
||||
self.assertEqual(get_plan(conn, pid)["status"], "partial")
|
||||
|
||||
def test_manual_end_no_flat(self):
|
||||
conn = _mem()
|
||||
pid = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "partial",
|
||||
"underlying": "ETH",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_a",
|
||||
"inst_id": "ETH-C",
|
||||
"status": "pending",
|
||||
"exchange_ord_id": "9",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_b",
|
||||
"inst_id": "ETH-P",
|
||||
"status": "open",
|
||||
"exchange_ord_id": "8",
|
||||
},
|
||||
)
|
||||
cfg = {"exchange_options": object()}
|
||||
with mock.patch(
|
||||
"lib.hedge_plan.hedge_plan_orders_lib.reconcile_unfilled_option_legs",
|
||||
return_value=[],
|
||||
), mock.patch(
|
||||
"lib.exchange.okx_options_lib.cancel_option_order",
|
||||
return_value={"ok": True},
|
||||
) as cancel, mock.patch(
|
||||
"lib.hedge_plan.hedge_plan_notify_lib.notify_plan_end",
|
||||
return_value=True,
|
||||
):
|
||||
out = execute_manual_end_plan(cfg, conn, pid)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertEqual(get_plan(conn, pid)["status"], "closed")
|
||||
self.assertEqual(get_plan(conn, pid)["close_reason"], "manual")
|
||||
legs = {l["leg_role"]: l for l in get_plan_legs(conn, pid)}
|
||||
self.assertEqual(legs["option_a"]["status"], "cancelled")
|
||||
self.assertEqual(legs["option_b"]["status"], "open") # 已有持仓不动
|
||||
cancel.assert_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,186 @@
|
||||
"""对冲计划历史删除与分类型统计."""
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from lib.hedge_plan.hedge_plan_db import (
|
||||
_metrics_from_pnls,
|
||||
active_options_targets_by_inst,
|
||||
delete_plan,
|
||||
init_hedge_plan_tables,
|
||||
insert_leg,
|
||||
insert_plan,
|
||||
legs_contract_summary,
|
||||
stats_summary,
|
||||
)
|
||||
|
||||
|
||||
def _mem():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_hedge_plan_tables(conn)
|
||||
return conn
|
||||
|
||||
|
||||
class TestHedgeHistoryStats(unittest.TestCase):
|
||||
def test_metrics_win_rate_pf_dd(self):
|
||||
rows = [
|
||||
{"realized_pnl_total": 10, "closed_at": "2026-01-01", "premium_total": 1},
|
||||
{"realized_pnl_total": -4, "closed_at": "2026-01-02", "premium_total": 1},
|
||||
{"realized_pnl_total": 6, "closed_at": "2026-01-03", "premium_total": 1},
|
||||
{"realized_pnl_total": -12, "closed_at": "2026-01-04", "premium_total": 1},
|
||||
]
|
||||
m = _metrics_from_pnls(rows)
|
||||
self.assertEqual(m["count"], 4)
|
||||
self.assertEqual(m["wins"], 2)
|
||||
self.assertAlmostEqual(m["win_rate"], 0.5)
|
||||
# gross win 16 / gross loss 16 = 1
|
||||
self.assertAlmostEqual(m["profit_factor"], 1.0)
|
||||
self.assertAlmostEqual(m["max_profit"], 10)
|
||||
self.assertAlmostEqual(m["max_loss"], -12)
|
||||
# equity: 10 → 6 → 12 → 0; peak 12, dd to 0 = 12
|
||||
self.assertAlmostEqual(m["max_drawdown"], 12)
|
||||
|
||||
def test_stats_by_type_and_delete(self):
|
||||
conn = _mem()
|
||||
po = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "perp_options",
|
||||
"status": "closed",
|
||||
"underlying": "ETH",
|
||||
"realized_pnl_total": 5,
|
||||
"premium_total": 1,
|
||||
"close_reason": "perp_tp",
|
||||
"closed_at": "2026-07-01 10:00:00",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": po,
|
||||
"leg_role": "perp",
|
||||
"symbol": "ETH/USDT:USDT",
|
||||
"status": "closed",
|
||||
},
|
||||
)
|
||||
oo = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "closed",
|
||||
"underlying": "ETH",
|
||||
"realized_pnl_total": -2,
|
||||
"premium_total": 0.02,
|
||||
"close_reason": "oo_expiry_loss",
|
||||
"closed_at": "2026-07-02 10:00:00",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": oo,
|
||||
"leg_role": "option_a",
|
||||
"inst_id": "ETH-USD_UM-260715-1900-C",
|
||||
"status": "closed",
|
||||
},
|
||||
)
|
||||
active = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "perp_options",
|
||||
"status": "active",
|
||||
"underlying": "BTC",
|
||||
"realized_pnl_total": None,
|
||||
},
|
||||
)
|
||||
s = stats_summary(conn)
|
||||
self.assertEqual(s["closed_count"], 2)
|
||||
self.assertEqual(s["by_type"]["perp_options"]["count"], 1)
|
||||
self.assertEqual(s["by_type"]["options_options"]["count"], 1)
|
||||
self.assertAlmostEqual(s["by_type"]["perp_options"]["win_rate"], 1.0)
|
||||
self.assertAlmostEqual(s["by_type"]["options_options"]["max_loss"], -2)
|
||||
|
||||
bad = delete_plan(conn, active)
|
||||
self.assertFalse(bad["ok"])
|
||||
ok = delete_plan(conn, oo)
|
||||
self.assertTrue(ok["ok"])
|
||||
s2 = stats_summary(conn)
|
||||
self.assertEqual(s2["closed_count"], 1)
|
||||
|
||||
def test_contract_summary(self):
|
||||
s = legs_contract_summary(
|
||||
[
|
||||
{"leg_role": "perp", "symbol": "ETH/USDT:USDT"},
|
||||
{"leg_role": "option_hedge", "inst_id": "ETH-USD_UM-260715-1790-P"},
|
||||
]
|
||||
)
|
||||
self.assertIn("永续 ETH/USDT:USDT", s)
|
||||
self.assertIn("ETH-USD_UM-260715-1790-P", s)
|
||||
|
||||
def test_active_options_targets_are_read_only_plan_targets(self):
|
||||
conn = _mem()
|
||||
pid = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "active",
|
||||
"underlying": "ETH",
|
||||
"target_price_up": 1950,
|
||||
"target_price_down": 1800,
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_a",
|
||||
"inst_id": "ETH-USD_UM-260719-1890-C",
|
||||
"opt_type": "C",
|
||||
"status": "open",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_b",
|
||||
"inst_id": "ETH-USD_UM-260719-1850-P",
|
||||
"opt_type": "P",
|
||||
"status": "open",
|
||||
},
|
||||
)
|
||||
|
||||
targets = active_options_targets_by_inst(conn)
|
||||
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["target_index"], 1950)
|
||||
self.assertEqual(targets["ETH-USD_UM-260719-1850-P"]["target_index"], 1800)
|
||||
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan")
|
||||
|
||||
def test_active_options_targets_profit_rr(self):
|
||||
conn = _mem()
|
||||
pid = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "active",
|
||||
"underlying": "ETH",
|
||||
"profit_rr": 2,
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_a",
|
||||
"inst_id": "ETH-USD_UM-260719-1890-C",
|
||||
"opt_type": "C",
|
||||
"status": "open",
|
||||
},
|
||||
)
|
||||
targets = active_options_targets_by_inst(conn)
|
||||
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["profit_rr"], 2)
|
||||
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["exit_mode"], "profit_rr")
|
||||
self.assertIsNone(targets["ETH-USD_UM-260719-1890-C"]["target_index"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""对冲计划虚实值选约与校验."""
|
||||
import unittest
|
||||
|
||||
from lib.hedge_plan.hedge_plan_moneyness_lib import (
|
||||
is_atm_or_otm,
|
||||
is_itm_or_atm,
|
||||
parse_strike_from_inst,
|
||||
pick_itm_or_atm_contract,
|
||||
recommend_oo_legs,
|
||||
validate_oo_legs_moneyness,
|
||||
validate_po_option_moneyness,
|
||||
)
|
||||
from lib.hedge_plan.hedge_plan_orders_lib import validate_start_body
|
||||
|
||||
|
||||
class TestHedgeMoneyness(unittest.TestCase):
|
||||
def test_parse_strike(self):
|
||||
self.assertEqual(parse_strike_from_inst("ETH-USD-260731-1800-P"), 1800.0)
|
||||
self.assertIsNone(parse_strike_from_inst("bad"))
|
||||
|
||||
def test_po_geometry(self):
|
||||
# Call: K<=S 实/平
|
||||
self.assertTrue(is_itm_or_atm(opt_type="C", strike=3500, index_px=3510))
|
||||
self.assertFalse(is_itm_or_atm(opt_type="C", strike=3600, index_px=3510))
|
||||
# Put: K>=S
|
||||
self.assertTrue(is_itm_or_atm(opt_type="P", strike=3600, index_px=3510))
|
||||
self.assertFalse(is_itm_or_atm(opt_type="P", strike=3400, index_px=3510))
|
||||
|
||||
def test_oo_geometry(self):
|
||||
self.assertTrue(is_atm_or_otm(opt_type="C", strike=3600, index_px=3510))
|
||||
self.assertFalse(is_atm_or_otm(opt_type="C", strike=3400, index_px=3510))
|
||||
self.assertTrue(is_atm_or_otm(opt_type="P", strike=3400, index_px=3510))
|
||||
self.assertFalse(is_atm_or_otm(opt_type="P", strike=3600, index_px=3510))
|
||||
|
||||
def test_validate_po_rejects_otm(self):
|
||||
err = validate_po_option_moneyness(opt_type="P", strike=1700, index_px=1800)
|
||||
self.assertIsNotNone(err)
|
||||
self.assertIn("虚值", err)
|
||||
self.assertIsNone(
|
||||
validate_po_option_moneyness(opt_type="P", strike=1800, index_px=1800)
|
||||
)
|
||||
|
||||
def test_validate_oo_rejects_itm(self):
|
||||
err = validate_oo_legs_moneyness(
|
||||
{"opt_type": "C", "strike": 1700},
|
||||
{"opt_type": "P", "strike": 1900},
|
||||
index_px=1800,
|
||||
)
|
||||
self.assertIsNotNone(err)
|
||||
self.assertIn("实值", err)
|
||||
self.assertIsNone(
|
||||
validate_oo_legs_moneyness(
|
||||
{"opt_type": "C", "strike": 1850},
|
||||
{"opt_type": "P", "strike": 1750},
|
||||
index_px=1800,
|
||||
)
|
||||
)
|
||||
|
||||
def test_pick_itm_or_atm(self):
|
||||
contracts = [
|
||||
{"inst_id": "E-C-3600", "opt_type": "C", "strike": 3600},
|
||||
{"inst_id": "E-C-3500", "opt_type": "C", "strike": 3500},
|
||||
{"inst_id": "E-C-3400", "opt_type": "C", "strike": 3400},
|
||||
]
|
||||
picked = pick_itm_or_atm_contract(
|
||||
contracts, opt_type="C", index_px=3510, itm_max_dist=200
|
||||
)
|
||||
self.assertIsNotNone(picked)
|
||||
self.assertEqual(picked["strike"], 3500)
|
||||
|
||||
def test_recommend_oo(self):
|
||||
contracts = [
|
||||
{"inst_id": "E-C-1800", "opt_type": "C", "strike": 1800},
|
||||
{"inst_id": "E-P-1800", "opt_type": "P", "strike": 1800},
|
||||
{"inst_id": "E-C-1900", "opt_type": "C", "strike": 1900},
|
||||
{"inst_id": "E-P-1700", "opt_type": "P", "strike": 1700},
|
||||
]
|
||||
pair = recommend_oo_legs(contracts, index_px=1800, template="atm_straddle")
|
||||
self.assertIsNotNone(pair)
|
||||
self.assertEqual(pair[0]["opt_type"], "C")
|
||||
self.assertEqual(pair[1]["opt_type"], "P")
|
||||
|
||||
def test_validate_start_body_po_otm(self):
|
||||
err = validate_start_body(
|
||||
"perp_options",
|
||||
{
|
||||
"direction": "long",
|
||||
"entry": 1800,
|
||||
"tp": 1900,
|
||||
"sl": 1700,
|
||||
"contracts": 1,
|
||||
"opt_inst_id": "ETH-USD-260731-1700-P",
|
||||
"opt_type": "P",
|
||||
"strike": 1700,
|
||||
"index_px": 1800,
|
||||
"sheets": 1,
|
||||
"exchange_symbol": "ETH/USDT:USDT",
|
||||
},
|
||||
)
|
||||
self.assertIsNotNone(err)
|
||||
self.assertIn("虚值", err)
|
||||
|
||||
def test_validate_start_body_oo_itm(self):
|
||||
err = validate_start_body(
|
||||
"options_options",
|
||||
{
|
||||
"profit_rr": 2,
|
||||
"index_px": 1800,
|
||||
"leg_a": {"inst_id": "ETH-USD-260731-1700-C", "opt_type": "C", "strike": 1700},
|
||||
"leg_b": {"inst_id": "ETH-USD-260731-1900-P", "opt_type": "P", "strike": 1900},
|
||||
},
|
||||
)
|
||||
self.assertIsNotNone(err)
|
||||
self.assertIn("实值", err)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,220 @@
|
||||
"""对冲计划微信文案与到期结算."""
|
||||
import sqlite3
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from lib.exchange.okx_options_lib import expiry_ms_from_inst_id
|
||||
from lib.hedge_plan.hedge_plan_db import get_plan, init_hedge_plan_tables, insert_leg, insert_plan
|
||||
from lib.hedge_plan.hedge_plan_monitor_lib import _tick_oo_expiry, _settle_orphaned_after_tp
|
||||
from lib.hedge_plan.hedge_plan_notify_lib import (
|
||||
build_hedge_end_message,
|
||||
build_hedge_start_message,
|
||||
notify_plan_end,
|
||||
notify_plan_start,
|
||||
)
|
||||
from lib.hedge_plan.hedge_plan_settle_lib import leg_is_expired, settle_option_leg_at_spot
|
||||
|
||||
|
||||
def _mem_db():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_hedge_plan_tables(conn)
|
||||
return conn
|
||||
|
||||
|
||||
class TestHedgeNotify(unittest.TestCase):
|
||||
def test_start_end_copy(self):
|
||||
plan = {
|
||||
"id": 7,
|
||||
"plan_type": "perp_options",
|
||||
"underlying": "ETH",
|
||||
"direction": "long",
|
||||
"entry_mark": 1800,
|
||||
"tp": 1900,
|
||||
"sl": 1700,
|
||||
"perp_size": 2,
|
||||
"leverage": 10,
|
||||
"premium_total": 1.5,
|
||||
"close_reason": "perp_tp",
|
||||
"realized_pnl_total": 12.3,
|
||||
"realized_pnl_perp": 15,
|
||||
"realized_pnl_options": -1.5,
|
||||
"opened_at": "2026-07-01 10:00:00",
|
||||
"closed_at": "2026-07-01 12:00:00",
|
||||
}
|
||||
s = build_hedge_start_message(plan)
|
||||
self.assertIn("启动 #7", s)
|
||||
self.assertIn("永期", s)
|
||||
e = build_hedge_end_message(plan)
|
||||
self.assertIn("结束 #7", e)
|
||||
self.assertIn("止盈", e)
|
||||
|
||||
def test_idempotent_flags(self):
|
||||
conn = _mem_db()
|
||||
sent = []
|
||||
cfg = {"send_wechat": lambda c: sent.append(c)}
|
||||
pid = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "active",
|
||||
"underlying": "ETH",
|
||||
"target_price": 2000,
|
||||
"premium_total": 2.0,
|
||||
"opened_at": "t0",
|
||||
},
|
||||
)
|
||||
plan = get_plan(conn, pid)
|
||||
self.assertTrue(notify_plan_start(cfg, conn, plan, []))
|
||||
plan = get_plan(conn, pid)
|
||||
self.assertEqual(int(plan["wechat_start_sent"]), 1)
|
||||
self.assertFalse(notify_plan_start(cfg, conn, plan, []))
|
||||
self.assertEqual(len(sent), 1)
|
||||
|
||||
plan["status"] = "closed"
|
||||
plan["close_reason"] = "oo_expiry_loss"
|
||||
plan["realized_pnl_total"] = -2
|
||||
plan["closed_at"] = "t1"
|
||||
self.assertTrue(notify_plan_end(cfg, conn, plan))
|
||||
plan = get_plan(conn, pid)
|
||||
self.assertEqual(int(plan["wechat_end_sent"]), 1)
|
||||
self.assertFalse(notify_plan_end(cfg, conn, plan))
|
||||
self.assertEqual(len(sent), 2)
|
||||
|
||||
|
||||
class TestHedgeSettle(unittest.TestCase):
|
||||
def test_put_expiry_otm(self):
|
||||
pnl = settle_option_leg_at_spot(
|
||||
{"opt_type": "P", "strike": 1700, "size": 2, "premium": 1.2, "ct_mult": 0.01},
|
||||
spot=1800,
|
||||
)
|
||||
self.assertAlmostEqual(pnl, -1.2)
|
||||
|
||||
def test_call_expiry_itm(self):
|
||||
# intrinsic (1900-1800)*2*0.01 - 0.5 = 2 - 0.5
|
||||
pnl = settle_option_leg_at_spot(
|
||||
{"opt_type": "C", "strike": 1800, "size": 2, "premium": 0.5, "ct_mult": 0.01},
|
||||
spot=1900,
|
||||
)
|
||||
self.assertAlmostEqual(pnl, 1.5)
|
||||
|
||||
def test_leg_expired_from_inst(self):
|
||||
# past date in inst_id
|
||||
past = datetime.now(timezone.utc) - timedelta(days=3)
|
||||
yy = past.year % 100
|
||||
tag = f"ETH-USD-{yy:02d}{past.month:02d}{past.day:02d}-1800-P"
|
||||
self.assertTrue(leg_is_expired({"inst_id": tag}))
|
||||
future = datetime.now(timezone.utc) + timedelta(days=10)
|
||||
tag2 = f"ETH-USD-{future.year % 100:02d}{future.month:02d}{future.day:02d}-1800-P"
|
||||
self.assertFalse(leg_is_expired({"inst_id": tag2}))
|
||||
self.assertIsNotNone(expiry_ms_from_inst_id(tag))
|
||||
|
||||
|
||||
class TestHedgeMonitorExpiry(unittest.TestCase):
|
||||
def test_oo_expiry_loss_closes_plan(self):
|
||||
conn = _mem_db()
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
tag = f"ETH-USD-{past.year % 100:02d}{past.month:02d}{past.day:02d}-1800-P"
|
||||
pid = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "options_options",
|
||||
"status": "active",
|
||||
"underlying": "ETH",
|
||||
"target_price": 2000,
|
||||
"premium_total": 2.0,
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_a",
|
||||
"inst_id": tag,
|
||||
"opt_type": "P",
|
||||
"strike": 1800,
|
||||
"size": 1,
|
||||
"premium": 1.0,
|
||||
"status": "open",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_b",
|
||||
"inst_id": tag.replace("-P", "-C").replace("1800", "1900"),
|
||||
"opt_type": "C",
|
||||
"strike": 1900,
|
||||
"size": 1,
|
||||
"premium": 1.0,
|
||||
"status": "open",
|
||||
},
|
||||
)
|
||||
sent = []
|
||||
cfg = {
|
||||
"send_wechat": lambda c: sent.append(c),
|
||||
"fetch_index_price": lambda ex, u: 1850.0,
|
||||
"exchange_options": object(),
|
||||
}
|
||||
plan = get_plan(conn, pid)
|
||||
legs = [
|
||||
dict(r)
|
||||
for r in conn.execute("SELECT * FROM hedge_plan_legs WHERE plan_id=?", (pid,)).fetchall()
|
||||
]
|
||||
r = _tick_oo_expiry(cfg, conn, plan, legs)
|
||||
self.assertIsNotNone(r)
|
||||
self.assertEqual(r["close_reason"], "oo_expiry_loss")
|
||||
plan2 = get_plan(conn, pid)
|
||||
self.assertEqual(plan2["status"], "closed")
|
||||
self.assertLessEqual(float(plan2["realized_pnl_total"]), 0)
|
||||
self.assertTrue(any("结束" in x for x in sent))
|
||||
|
||||
def test_orphaned_option_does_not_rewrite_plan_total(self):
|
||||
conn = _mem_db()
|
||||
past = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
tag = f"ETH-USD-{past.year % 100:02d}{past.month:02d}{past.day:02d}-1700-P"
|
||||
pid = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": "perp_options",
|
||||
"status": "closed",
|
||||
"underlying": "ETH",
|
||||
"direction": "long",
|
||||
"close_reason": "perp_tp",
|
||||
"realized_pnl_total": 10.0,
|
||||
"realized_pnl_options": -1.0,
|
||||
"stats_bucket": "tp",
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": pid,
|
||||
"leg_role": "option_hedge",
|
||||
"inst_id": tag,
|
||||
"opt_type": "P",
|
||||
"strike": 1700,
|
||||
"size": 1,
|
||||
"premium": 1.0,
|
||||
"status": "hold_to_expiry",
|
||||
"close_reason": "orphaned_after_tp",
|
||||
},
|
||||
)
|
||||
cfg = {
|
||||
"fetch_index_price": lambda ex, u: 1800.0,
|
||||
"exchange_options": object(),
|
||||
}
|
||||
acted = _settle_orphaned_after_tp(cfg, conn)
|
||||
self.assertEqual(len(acted), 1)
|
||||
plan = get_plan(conn, pid)
|
||||
self.assertAlmostEqual(float(plan["realized_pnl_total"]), 10.0)
|
||||
leg = conn.execute("SELECT * FROM hedge_plan_legs WHERE plan_id=?", (pid,)).fetchone()
|
||||
self.assertEqual(leg["status"], "closed")
|
||||
self.assertEqual(leg["close_reason"], "expiry")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,195 @@
|
||||
"""永期「以期权为主」定仓/方向/目标位/校验."""
|
||||
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,
|
||||
pick_option_primary_candidate,
|
||||
size_from_premium,
|
||||
target_hit,
|
||||
validate_option_primary_start,
|
||||
validate_option_primary_watch,
|
||||
)
|
||||
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_watch_and_start(self):
|
||||
watch_body = {
|
||||
"option_primary": True,
|
||||
"watch_entry": 1,
|
||||
"direction": "long",
|
||||
"exchange_symbol": "ETH/USDT:USDT",
|
||||
"premium_budget": 100,
|
||||
"option_target_points": 50,
|
||||
"perp_target_points": 30,
|
||||
"option_perp_ratio": 4,
|
||||
"option_leverage": 200,
|
||||
"moneyness": "otm",
|
||||
}
|
||||
self.assertIsNone(validate_option_primary_watch(watch_body))
|
||||
self.assertIsNone(validate_start_body("perp_options", watch_body))
|
||||
|
||||
body = {
|
||||
"option_primary": True,
|
||||
"watch_entry": 0,
|
||||
"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_pick_candidate_respects_leverage(self):
|
||||
chain = {
|
||||
"index_px": 1900,
|
||||
"expiries": [
|
||||
{
|
||||
"exp_time": 9_999_999_999_999,
|
||||
"contracts": [
|
||||
{"inst_id": "LOW", "opt_type": "C", "strike": 1920, "ask": 20, "ct_mult": 0.01},
|
||||
{"inst_id": "OK", "opt_type": "C", "strike": 1925, "ask": 8, "ct_mult": 0.01},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
# 1900/20=95 < 200; 1900/8=237.5 ≥ 200
|
||||
picked = pick_option_primary_candidate(
|
||||
chain,
|
||||
direction="long",
|
||||
moneyness="otm",
|
||||
strike_interval=50,
|
||||
min_hours=1,
|
||||
min_opt_leverage=200,
|
||||
)
|
||||
self.assertIsNotNone(picked)
|
||||
self.assertEqual(picked["inst_id"], "OK")
|
||||
|
||||
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)
|
||||
|
||||
def test_hours_to_expiry_from_ms(self):
|
||||
from lib.hedge_plan.hedge_plan_option_primary_lib import hours_to_expiry_from_ms
|
||||
|
||||
now = 1_700_000_000_000.0
|
||||
# +40h in ms
|
||||
h = hours_to_expiry_from_ms(now + 40 * 3600 * 1000, now_ms=now)
|
||||
self.assertAlmostEqual(h, 40.0, places=3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,240 @@
|
||||
"""对冲计划下单路径校验(dry_run + 门禁)."""
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from lib.hedge_plan.hedge_plan_calc_lib import gate_status
|
||||
from lib.hedge_plan.hedge_plan_orders_lib import (
|
||||
build_oo_path_plan,
|
||||
build_po_path_plan,
|
||||
execute_options_options_start,
|
||||
execute_perp_options_start,
|
||||
validate_start_body,
|
||||
)
|
||||
|
||||
|
||||
class TestHedgePlanOrderPath(unittest.TestCase):
|
||||
def test_po_path_options_first(self):
|
||||
body = {
|
||||
"opt_inst_id": "ETH-USD-260731-1800-P",
|
||||
"sheets": 2,
|
||||
"exchange_symbol": "ETH/USDT:USDT",
|
||||
"direction": "long",
|
||||
"contracts": 4.5,
|
||||
"tp": 1900,
|
||||
"sl": 1700,
|
||||
}
|
||||
path = build_po_path_plan(body)
|
||||
self.assertEqual(path[0]["step"], "options_buy_limit")
|
||||
self.assertEqual(path[0]["account"], "options")
|
||||
self.assertEqual(path[1]["step"], "perp_market_open")
|
||||
self.assertEqual(path[1]["account"], "swap")
|
||||
self.assertTrue(path[1]["attach_tpsl"])
|
||||
|
||||
def test_oo_path_two_option_buys(self):
|
||||
body = {
|
||||
"leg_a": {"inst_id": "ETH-USD-260731-1800-C", "sheets": 1},
|
||||
"leg_b": {"inst_id": "ETH-USD-260731-1700-P", "sheets": 3},
|
||||
}
|
||||
path = build_oo_path_plan(body)
|
||||
self.assertEqual(len(path), 2)
|
||||
self.assertEqual(path[0]["leg"], "a")
|
||||
self.assertEqual(path[1]["sheets"], 3)
|
||||
|
||||
def test_validate_body(self):
|
||||
self.assertIsNotNone(validate_start_body("perp_options", {}))
|
||||
ok = validate_start_body(
|
||||
"perp_options",
|
||||
{
|
||||
"direction": "long",
|
||||
"entry": 1800,
|
||||
"tp": 1900,
|
||||
"sl": 1700,
|
||||
"contracts": 1,
|
||||
"opt_inst_id": "ETH-USD-260731-1800-P",
|
||||
"opt_type": "P",
|
||||
"sheets": 1,
|
||||
"exchange_symbol": "ETH/USDT:USDT",
|
||||
},
|
||||
)
|
||||
self.assertIsNone(ok)
|
||||
bad_type = validate_start_body(
|
||||
"perp_options",
|
||||
{
|
||||
"direction": "long",
|
||||
"entry": 1800,
|
||||
"tp": 1900,
|
||||
"sl": 1700,
|
||||
"contracts": 1,
|
||||
"opt_inst_id": "ETH-USD-260731-1800-C",
|
||||
"opt_type": "C",
|
||||
"sheets": 1,
|
||||
"exchange_symbol": "ETH/USDT:USDT",
|
||||
},
|
||||
)
|
||||
self.assertIsNotNone(bad_type)
|
||||
|
||||
def test_gate_can_start_when_live(self):
|
||||
g = gate_status(
|
||||
hedge_enabled=True,
|
||||
sizing_mode="full_margin",
|
||||
plan_type="perp_options",
|
||||
options_enabled=True,
|
||||
live_order=True,
|
||||
live_trading=True,
|
||||
active_count=0,
|
||||
max_active=1,
|
||||
)
|
||||
self.assertTrue(g["can_start"])
|
||||
self.assertEqual(g["reasons"], [])
|
||||
|
||||
def test_gate_oo_without_live_trading(self):
|
||||
g = gate_status(
|
||||
hedge_enabled=True,
|
||||
sizing_mode="risk",
|
||||
plan_type="options_options",
|
||||
options_enabled=True,
|
||||
live_order=True,
|
||||
live_trading=False,
|
||||
active_count=0,
|
||||
max_active=1,
|
||||
)
|
||||
self.assertTrue(g["can_start"])
|
||||
|
||||
def test_dry_run_po_calls_quote_not_place(self):
|
||||
quote = MagicMock(
|
||||
return_value={
|
||||
"ok": True,
|
||||
"ask": 12.5,
|
||||
"ask_sz": 10,
|
||||
"can_open": True,
|
||||
"ct_mult": 0.01,
|
||||
"tick_sz": "0.1",
|
||||
"strike": 1800,
|
||||
"exp_time": 1,
|
||||
"meta": {"optType": "P"},
|
||||
}
|
||||
)
|
||||
place_opt = MagicMock()
|
||||
place_perp = MagicMock()
|
||||
cfg = {
|
||||
"exchange_options": object(),
|
||||
"exchange": object(),
|
||||
"quote_option_contract": quote,
|
||||
"place_option_limit_order": place_opt,
|
||||
"place_exchange_order": place_perp,
|
||||
"td_mode_for_option_buy": lambda x: "isolated",
|
||||
"amount_to_precision": lambda s, a: a,
|
||||
"ensure_okx_live_ready": lambda: (True, ""),
|
||||
}
|
||||
body = {
|
||||
"direction": "long",
|
||||
"entry": 1800,
|
||||
"tp": 1900,
|
||||
"sl": 1700,
|
||||
"contracts": 4.5,
|
||||
"opt_inst_id": "ETH-USD-260731-1800-P",
|
||||
"sheets": 2,
|
||||
"exchange_symbol": "ETH/USDT:USDT",
|
||||
"leverage": 10,
|
||||
"underlying": "ETH",
|
||||
}
|
||||
out = execute_perp_options_start(cfg, body, dry_run=True)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertTrue(out["dry_run"])
|
||||
place_opt.assert_not_called()
|
||||
place_perp.assert_not_called()
|
||||
quote.assert_called()
|
||||
self.assertEqual(out["path"][0]["account"], "options")
|
||||
self.assertEqual(out["path"][1]["account"], "swap")
|
||||
|
||||
def test_dry_run_oo(self):
|
||||
quote = MagicMock(
|
||||
side_effect=lambda _ex, inst_id: {
|
||||
"ok": True,
|
||||
"ask": 10,
|
||||
"ask_sz": 50,
|
||||
"can_open": True,
|
||||
"ct_mult": 0.01,
|
||||
"tick_sz": "0.1",
|
||||
"strike": 1800,
|
||||
"meta": {"optType": "C" if inst_id == "A" else "P"},
|
||||
}
|
||||
)
|
||||
cfg = {
|
||||
"exchange_options": object(),
|
||||
"quote_option_contract": quote,
|
||||
"place_option_limit_order": MagicMock(),
|
||||
"td_mode_for_option_buy": lambda x: "isolated",
|
||||
"trade_budget_usdc": 10,
|
||||
"budget_buffer": 0.95,
|
||||
}
|
||||
body = {
|
||||
"profit_rr": 2,
|
||||
"oo_sheets_mode": "same_sheets",
|
||||
"leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"},
|
||||
"leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"},
|
||||
}
|
||||
from unittest import mock
|
||||
|
||||
with mock.patch(
|
||||
"lib.exchange.okx_options_lib.fetch_options_trading_usdc",
|
||||
return_value=100.0,
|
||||
):
|
||||
out = execute_options_options_start(cfg, body, dry_run=True)
|
||||
self.assertTrue(out["ok"], out)
|
||||
self.assertEqual(len(out["results"]), 2)
|
||||
self.assertTrue(out.get("refresh", {}).get("ok"))
|
||||
|
||||
def test_buy_rejects_without_ask_depth(self):
|
||||
from lib.hedge_plan.hedge_plan_orders_lib import _buy_option
|
||||
|
||||
quote = MagicMock(
|
||||
return_value={
|
||||
"ok": True,
|
||||
"ask": None,
|
||||
"ask_sz": None,
|
||||
"mark": 11.2,
|
||||
"ref_ask": 11.2,
|
||||
"can_open": False,
|
||||
"open_block_msg": "暂无卖一深度,无法买入",
|
||||
"ct_mult": 0.01,
|
||||
}
|
||||
)
|
||||
cfg = {
|
||||
"exchange_options": object(),
|
||||
"quote_option_contract": quote,
|
||||
"place_option_limit_order": MagicMock(),
|
||||
}
|
||||
out = _buy_option(cfg, inst_id="ETH-USD_UM-260717-1900-C", sheets=1, dry_run=True)
|
||||
self.assertFalse(out["ok"])
|
||||
self.assertIn("卖一", out["msg"])
|
||||
cfg["place_option_limit_order"].assert_not_called()
|
||||
|
||||
def test_buy_rejects_when_ask_depth_below_request(self):
|
||||
from lib.hedge_plan.hedge_plan_orders_lib import _buy_option
|
||||
|
||||
quote = MagicMock(
|
||||
return_value={
|
||||
"ok": True,
|
||||
"ask": 10,
|
||||
"ask_sz": 2,
|
||||
"can_open": True,
|
||||
"ct_mult": 0.01,
|
||||
"tick_sz": "0.1",
|
||||
"meta": {"optType": "C"},
|
||||
}
|
||||
)
|
||||
cfg = {
|
||||
"exchange_options": object(),
|
||||
"quote_option_contract": quote,
|
||||
"place_option_limit_order": MagicMock(),
|
||||
"td_mode_for_option_buy": lambda x: "isolated",
|
||||
}
|
||||
out = _buy_option(cfg, inst_id="X", sheets=9, dry_run=True)
|
||||
self.assertFalse(out["ok"])
|
||||
self.assertIn("不足请求", out["msg"])
|
||||
cfg["place_option_limit_order"].assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""永期监控:假平仓防护与 TP/SL 分类."""
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from lib.hedge_plan.hedge_plan_monitor_lib import (
|
||||
_classify_po_flat_reason,
|
||||
_tick_po,
|
||||
_within_open_grace,
|
||||
)
|
||||
|
||||
|
||||
class TestPoMonitorSafety(unittest.TestCase):
|
||||
def test_classify_sl_before_tp(self):
|
||||
self.assertEqual(
|
||||
_classify_po_flat_reason(
|
||||
direction="long", entry=1800, mark=1690, tp=1900, sl=1700
|
||||
),
|
||||
"perp_sl",
|
||||
)
|
||||
self.assertEqual(
|
||||
_classify_po_flat_reason(
|
||||
direction="long", entry=1800, mark=1910, tp=1900, sl=1700
|
||||
),
|
||||
"perp_tp",
|
||||
)
|
||||
|
||||
def test_classify_ambiguous_prefers_sl(self):
|
||||
# mark 在中间:偏 SL,避免误判 TP 跳过强平期权
|
||||
self.assertEqual(
|
||||
_classify_po_flat_reason(
|
||||
direction="long", entry=1800, mark=1800, tp=1900, sl=1700
|
||||
),
|
||||
"perp_sl",
|
||||
)
|
||||
|
||||
def test_classify_unknown_without_mark(self):
|
||||
self.assertEqual(
|
||||
_classify_po_flat_reason(
|
||||
direction="long", entry=1800, mark=None, tp=1900, sl=1700
|
||||
),
|
||||
"perp_flat_unknown",
|
||||
)
|
||||
|
||||
def test_tick_po_skips_when_live_none(self):
|
||||
cfg = {"get_live_position_contracts": lambda *_a, **_k: None}
|
||||
plan = {"id": 1, "direction": "long", "entry_mark": 1800, "tp": 1900, "sl": 1700}
|
||||
legs = [{"leg_role": "perp", "status": "open", "symbol": "ETH/USDT:USDT", "size": 1}]
|
||||
self.assertIsNone(_tick_po(cfg, MagicMock(), plan, legs))
|
||||
|
||||
def test_tick_po_skips_when_live_positive(self):
|
||||
cfg = {"get_live_position_contracts": lambda *_a, **_k: 2.0}
|
||||
plan = {"id": 1, "direction": "long", "entry_mark": 1800, "tp": 1900, "sl": 1700}
|
||||
legs = [{"leg_role": "perp", "status": "open", "symbol": "ETH/USDT:USDT", "size": 1}]
|
||||
self.assertIsNone(_tick_po(cfg, MagicMock(), plan, legs))
|
||||
|
||||
def test_within_grace_true_without_opened_at(self):
|
||||
self.assertTrue(_within_open_grace({}))
|
||||
|
||||
def test_pending_sl_sticky(self):
|
||||
"""perp_sl_pending_opt 粘滞,不因 mark 反弹改判 TP."""
|
||||
cfg = {
|
||||
"get_live_position_contracts": lambda *_a, **_k: 0.0,
|
||||
"get_contract_size": lambda *_a, **_k: 0.01,
|
||||
"exchange": None,
|
||||
}
|
||||
plan = {
|
||||
"id": 9,
|
||||
"direction": "long",
|
||||
"entry_mark": 1800,
|
||||
"tp": 1900,
|
||||
"sl": 1700,
|
||||
"premium_total": 5,
|
||||
"opened_at": "2000-01-01 00:00:00",
|
||||
"close_reason": "perp_sl_pending_opt",
|
||||
}
|
||||
legs = [
|
||||
{"id": 1, "leg_role": "perp", "status": "open", "symbol": "ETH/USDT:USDT", "size": 1, "avg_open": 1800},
|
||||
{
|
||||
"id": 2,
|
||||
"leg_role": "option_hedge",
|
||||
"status": "open",
|
||||
"inst_id": "ETH-USD-260731-1700-P",
|
||||
"size": 1,
|
||||
"avg_open": 10,
|
||||
},
|
||||
]
|
||||
with patch("lib.hedge_plan.hedge_plan_monitor_lib._sell_option") as sell:
|
||||
sell.return_value = {"ok": False, "msg": "thin book", "fully_closed": False}
|
||||
with patch("lib.hedge_plan.hedge_plan_monitor_lib.notify_hedge"):
|
||||
with patch("lib.hedge_plan.hedge_plan_monitor_lib.update_plan") as up:
|
||||
conn = MagicMock()
|
||||
out = _tick_po(cfg, conn, plan, legs)
|
||||
self.assertTrue(out and out.get("retry"))
|
||||
up.assert_called()
|
||||
self.assertEqual(up.call_args.kwargs.get("close_reason"), "perp_sl_pending_opt")
|
||||
self.assertNotEqual(up.call_args.kwargs.get("status"), "closed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""对冲启动:再拉卖一 + 对冲专用预算缓冲重算张数."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from lib.hedge_plan.hedge_plan_orders_lib import (
|
||||
_hedge_budget_buffer,
|
||||
execute_options_options_start,
|
||||
refresh_oo_sizing_before_start,
|
||||
refresh_po_option_quote_before_start,
|
||||
)
|
||||
|
||||
|
||||
class TestHedgeStartRefresh(unittest.TestCase):
|
||||
def test_hedge_budget_buffer_independent(self):
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"HEDGE_PLAN_BUDGET_BUFFER": "0.9",
|
||||
"OKX_OPTIONS_BUDGET_BUFFER": "0.5",
|
||||
},
|
||||
):
|
||||
self.assertAlmostEqual(_hedge_budget_buffer(None), 0.9)
|
||||
self.assertAlmostEqual(_hedge_budget_buffer({"budget_buffer": 0.88}), 0.88)
|
||||
|
||||
def test_refresh_oo_resizes_from_fresh_ask(self):
|
||||
def quote(_ex, inst_id):
|
||||
if inst_id == "A":
|
||||
return {
|
||||
"ok": True,
|
||||
"ask": 20,
|
||||
"ask_sz": 100,
|
||||
"ct_mult": 0.01,
|
||||
"meta": {"optType": "C"},
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"ask": 20,
|
||||
"ask_sz": 100,
|
||||
"ct_mult": 0.01,
|
||||
"meta": {"optType": "P"},
|
||||
}
|
||||
|
||||
cfg = {
|
||||
"exchange_options": object(),
|
||||
"quote_option_contract": quote,
|
||||
"trade_budget_usdc": 10,
|
||||
"budget_buffer": 0.95,
|
||||
}
|
||||
body = {
|
||||
"oo_sheets_mode": "same_sheets",
|
||||
"leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"},
|
||||
"leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"},
|
||||
}
|
||||
# unit cost = 20*0.01=0.2 each → pair 0.4; budget min(100*0.95,10)=10 → n=25
|
||||
with mock.patch(
|
||||
"lib.exchange.okx_options_lib.fetch_options_trading_usdc",
|
||||
return_value=100.0,
|
||||
):
|
||||
out = refresh_oo_sizing_before_start(cfg, body)
|
||||
self.assertTrue(out.get("ok"), out)
|
||||
self.assertEqual(body["leg_a"]["sheets"], 25)
|
||||
self.assertEqual(body["leg_b"]["sheets"], 25)
|
||||
self.assertEqual(body["leg_a"]["ask"], 20)
|
||||
self.assertEqual(out["sheets_a"], 25)
|
||||
|
||||
def test_execute_oo_start_uses_refreshed_sheets(self):
|
||||
quote = MagicMock(
|
||||
side_effect=lambda _ex, inst_id: {
|
||||
"ok": True,
|
||||
"ask": 10,
|
||||
"ask_sz": 50,
|
||||
"ct_mult": 0.01,
|
||||
"tick_sz": "0.1",
|
||||
"meta": {"optType": "C" if inst_id == "A" else "P"},
|
||||
}
|
||||
)
|
||||
cfg = {
|
||||
"exchange_options": object(),
|
||||
"quote_option_contract": quote,
|
||||
"place_option_limit_order": MagicMock(),
|
||||
"td_mode_for_option_buy": lambda x: "isolated",
|
||||
"trade_budget_usdc": 4,
|
||||
"budget_buffer": 0.95,
|
||||
}
|
||||
body = {
|
||||
"oo_sheets_mode": "same_sheets",
|
||||
"leg_a": {"inst_id": "A", "sheets": 1, "opt_type": "C"},
|
||||
"leg_b": {"inst_id": "B", "sheets": 1, "opt_type": "P"},
|
||||
}
|
||||
# cost 0.1+0.1=0.2; budget 4 → 20 sheets each
|
||||
with mock.patch(
|
||||
"lib.exchange.okx_options_lib.fetch_options_trading_usdc",
|
||||
return_value=100.0,
|
||||
):
|
||||
out = execute_options_options_start(cfg, body, dry_run=True)
|
||||
self.assertTrue(out["ok"], out)
|
||||
self.assertEqual(body["leg_a"]["sheets"], 20)
|
||||
self.assertEqual(out["results"][0]["sheets"], 20)
|
||||
self.assertIn("refresh", out)
|
||||
self.assertTrue(out["refresh"]["ok"])
|
||||
|
||||
def test_refresh_po_keeps_sheets(self):
|
||||
quote = MagicMock(
|
||||
return_value={
|
||||
"ok": True,
|
||||
"ask": 12.5,
|
||||
"ask_sz": 8,
|
||||
"ct_mult": 0.01,
|
||||
"meta": {"optType": "P"},
|
||||
}
|
||||
)
|
||||
cfg = {"exchange_options": object(), "quote_option_contract": quote}
|
||||
body = {"opt_inst_id": "X", "sheets": 3}
|
||||
out = refresh_po_option_quote_before_start(cfg, body)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(body["ask"], 12.5)
|
||||
self.assertEqual(body["sheets"], 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,39 @@
|
||||
"""OKX 资金账户余额(asset/balances)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from lib.exchange.okx_options_lib import (
|
||||
fetch_funding_balances_via_asset_api,
|
||||
fetch_options_balances,
|
||||
)
|
||||
|
||||
|
||||
class TestOkxFundingBalances(unittest.TestCase):
|
||||
def test_asset_balances_parsed(self):
|
||||
ex = MagicMock()
|
||||
ex.private_get_asset_balances.return_value = {
|
||||
"data": [
|
||||
{"ccy": "USDT", "availBal": "25.5", "bal": "30"},
|
||||
{"ccy": "USDC", "availBal": "10", "bal": "10"},
|
||||
]
|
||||
}
|
||||
total, avail = fetch_funding_balances_via_asset_api(ex)
|
||||
self.assertEqual(avail["USDT"], 25.5)
|
||||
self.assertEqual(total["USDT"], 30.0)
|
||||
self.assertEqual(avail["USDC"], 10.0)
|
||||
|
||||
def test_fetch_options_balances_merges_asset_api(self):
|
||||
ex = MagicMock()
|
||||
ex.fetch_balance.return_value = {"free": {}, "total": {}}
|
||||
ex.private_get_asset_balances.return_value = {
|
||||
"data": [{"ccy": "USDT", "availBal": "18.2", "bal": "18.2"}]
|
||||
}
|
||||
bal = fetch_options_balances(ex, force=True)
|
||||
self.assertEqual(bal["funding_usdt_avail"], 18.2)
|
||||
self.assertEqual(bal["funding_usdt"], 18.2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,38 @@
|
||||
"""OKX 持仓指标解析:未实现盈亏须支持负数."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class TestOkxPositionMetrics(unittest.TestCase):
|
||||
def test_parse_unrealized_pnl_negative(self):
|
||||
from crypto_monitor_okx.app import parse_ccxt_position_metrics
|
||||
|
||||
pos = {
|
||||
"side": "long",
|
||||
"contracts": 10,
|
||||
"markPrice": 0.43,
|
||||
"unrealizedPnl": -1.25,
|
||||
"info": {"upl": "-1.25", "markPx": "0.43"},
|
||||
}
|
||||
out = parse_ccxt_position_metrics(pos, order_leverage=5)
|
||||
self.assertIsNotNone(out)
|
||||
self.assertAlmostEqual(out["unrealized_pnl"], -1.25)
|
||||
self.assertAlmostEqual(out["mark_price"], 0.43)
|
||||
|
||||
def test_parse_unrealized_pnl_zero(self):
|
||||
from crypto_monitor_okx.app import parse_ccxt_position_metrics
|
||||
|
||||
pos = {
|
||||
"side": "long",
|
||||
"contracts": 1,
|
||||
"unrealizedPnl": 0,
|
||||
"info": {"upl": "0"},
|
||||
}
|
||||
out = parse_ccxt_position_metrics(pos)
|
||||
self.assertIsNotNone(out)
|
||||
self.assertEqual(out["unrealized_pnl"], 0.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,60 @@
|
||||
"""OKX USDT/USDC 现货市价兑换参数."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from lib.exchange.okx_options_lib import spot_market_swap_usdt_usdc
|
||||
|
||||
|
||||
class TestOkxSpotSwap(unittest.TestCase):
|
||||
def test_usdt_to_usdc_uses_quote_ccy(self):
|
||||
ex = MagicMock()
|
||||
ex.private_post_trade_order.return_value = {
|
||||
"data": [{"sCode": "0", "ordId": "1"}],
|
||||
}
|
||||
result = spot_market_swap_usdt_usdc(ex, direction="usdt_to_usdc", amount=10)
|
||||
self.assertTrue(result["ok"])
|
||||
body = ex.private_post_trade_order.call_args[0][0]
|
||||
self.assertEqual(body["tgtCcy"], "quote_ccy")
|
||||
self.assertEqual(body["side"], "buy")
|
||||
|
||||
def test_usdc_to_usdt_uses_base_ccy(self):
|
||||
ex = MagicMock()
|
||||
ex.private_post_trade_order.return_value = {
|
||||
"data": [{"sCode": "0", "ordId": "2"}],
|
||||
}
|
||||
result = spot_market_swap_usdt_usdc(ex, direction="usdc_to_usdt", amount=5)
|
||||
self.assertTrue(result["ok"])
|
||||
body = ex.private_post_trade_order.call_args[0][0]
|
||||
self.assertEqual(body["tgtCcy"], "base_ccy")
|
||||
self.assertEqual(body["side"], "sell")
|
||||
|
||||
def test_insufficient_balance_returns_chinese_message(self):
|
||||
ex = MagicMock()
|
||||
ex.private_post_trade_order.side_effect = Exception(
|
||||
'okx {"code":"1","data":[{"sCode":"51008","sMsg":"Order failed. Your available USDT balance is insufficient."}]}'
|
||||
)
|
||||
result = spot_market_swap_usdt_usdc(ex, direction="usdt_to_usdc", amount=20)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["msg"], "USDT 可用余额不足(期权请先兑成 USDC 并划入交易账户)")
|
||||
self.assertNotIn("{", result["msg"])
|
||||
|
||||
def test_insufficient_usdc_message(self):
|
||||
from lib.exchange.okx_options_lib import _okx_trade_error_message
|
||||
|
||||
msg = _okx_trade_error_message(
|
||||
resp={
|
||||
"data": [
|
||||
{
|
||||
"sCode": "51008",
|
||||
"sMsg": "Order failed. Insufficient USDC balance in account.",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
self.assertEqual(msg, "交易账户 USDC 可用余额不足")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""OKX_TRADE_MODE 三选一."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from lib.hedge_plan.okx_trade_mode_lib import (
|
||||
MODE_OO,
|
||||
MODE_OPTIONS,
|
||||
MODE_PERP,
|
||||
block_standalone_open_by_mode_msg,
|
||||
get_okx_trade_mode,
|
||||
hedge_module_enabled,
|
||||
show_options_options,
|
||||
show_perp_options,
|
||||
standalone_options_open_allowed,
|
||||
)
|
||||
|
||||
|
||||
class OkxTradeModeTests(unittest.TestCase):
|
||||
def test_explicit_modes(self):
|
||||
for mode, hedge, po, oo in (
|
||||
(MODE_OPTIONS, False, False, False),
|
||||
(MODE_PERP, True, True, False),
|
||||
(MODE_OO, True, False, True),
|
||||
):
|
||||
with patch.dict(os.environ, {"OKX_TRADE_MODE": mode}, clear=False):
|
||||
self.assertEqual(get_okx_trade_mode(), mode)
|
||||
self.assertEqual(hedge_module_enabled(), hedge)
|
||||
self.assertEqual(show_perp_options(), po)
|
||||
self.assertEqual(show_options_options(), oo)
|
||||
self.assertEqual(standalone_options_open_allowed(), mode == MODE_OPTIONS)
|
||||
|
||||
def test_legacy_infer_options(self):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"HEDGE_PLAN_ENABLED": "false"},
|
||||
clear=False,
|
||||
):
|
||||
os.environ.pop("OKX_TRADE_MODE", None)
|
||||
self.assertEqual(get_okx_trade_mode(), MODE_OPTIONS)
|
||||
|
||||
def test_legacy_infer_perp(self):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"HEDGE_PLAN_ENABLED": "true",
|
||||
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "true",
|
||||
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "false",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
os.environ.pop("OKX_TRADE_MODE", None)
|
||||
self.assertEqual(get_okx_trade_mode(), MODE_PERP)
|
||||
|
||||
def test_block_standalone_in_hedge_mode(self):
|
||||
with patch.dict(os.environ, {"OKX_TRADE_MODE": MODE_OO}):
|
||||
msg = block_standalone_open_by_mode_msg()
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertIn("期期", msg or "")
|
||||
|
||||
|
||||
class EnvUiModeSectionsTests(unittest.TestCase):
|
||||
def test_options_mode_hides_hedge_section(self):
|
||||
from lib.env.env_ui_manifest import ui_sections_for_exchange
|
||||
|
||||
titles = [s["title"] for s in ui_sections_for_exchange("okx", mode="options")]
|
||||
self.assertIn("期权/对冲模式", titles)
|
||||
self.assertIn("期权账户", titles)
|
||||
self.assertTrue(all("对冲" not in t for t in titles if t != "期权/对冲模式"))
|
||||
|
||||
def test_perp_mode_shows_po_fields(self):
|
||||
from lib.env.env_ui_manifest import ui_sections_for_exchange
|
||||
|
||||
secs = {s["title"]: s["fields"] for s in ui_sections_for_exchange("okx", mode="perp_options")}
|
||||
self.assertIn("对冲计划·永期", secs)
|
||||
keys = {f[0] for f in secs["对冲计划·永期"]}
|
||||
self.assertIn("MAX_ACTIVE_HEDGE_PLANS", keys)
|
||||
self.assertIn("HEDGE_PLAN_OPEN_ORDER", keys)
|
||||
self.assertNotIn("HEDGE_PLAN_OO_BIAS_RATIO", keys)
|
||||
opt_keys = {f[0] for f in secs["期权账户"]}
|
||||
self.assertNotIn("OKX_OPTIONS_MAX_ACTIVE_POSITIONS", opt_keys)
|
||||
|
||||
def test_oo_mode_shows_oo_fields(self):
|
||||
from lib.env.env_ui_manifest import ui_sections_for_exchange
|
||||
|
||||
secs = {s["title"]: s["fields"] for s in ui_sections_for_exchange("okx", mode="options_options")}
|
||||
self.assertIn("对冲计划·期期", secs)
|
||||
keys = {f[0] for f in secs["对冲计划·期期"]}
|
||||
self.assertIn("HEDGE_PLAN_OO_BIAS_RATIO", keys)
|
||||
self.assertNotIn("HEDGE_PLAN_OPEN_ORDER", keys)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""期权加仓后权利金汇总."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from lib.options.options_db import init_options_tables, sum_open_premium_paid, sum_open_sheets
|
||||
|
||||
|
||||
def _mem_db() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def test_sum_open_premium_after_add():
|
||||
conn = _mem_db()
|
||||
inst = "BTC-USD_UM-260717-65500-C"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, sheets, eth_amount, open_quote, premium_paid, status)
|
||||
VALUES (?, 'BTC-USD_UM', 'C', 1, 0.01, 530, 5.3, 'open')
|
||||
""",
|
||||
(inst,),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, sheets, eth_amount, open_quote, premium_paid, status)
|
||||
VALUES (?, 'BTC-USD_UM', 'C', 1, 0.01, 140, 1.4, 'open')
|
||||
""",
|
||||
(inst,),
|
||||
)
|
||||
conn.commit()
|
||||
assert sum_open_premium_paid(conn, inst) == 6.7
|
||||
assert sum_open_sheets(conn, inst) == 2
|
||||
# 最新一笔单独是 1.4,汇总不能只取最新
|
||||
latest = conn.execute(
|
||||
"SELECT premium_paid FROM options_trades WHERE inst_id=? AND status='open' ORDER BY id DESC LIMIT 1",
|
||||
(inst,),
|
||||
).fetchone()
|
||||
assert float(latest["premium_paid"]) == 1.4
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_sum_open_premium_ignores_closed():
|
||||
conn = _mem_db()
|
||||
inst = "ETH-USD_UM-260101-2000-C"
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status)
|
||||
VALUES (?, 'ETH-USD_UM', 'C', 1, 0.01, 2.0, 'closed')
|
||||
""",
|
||||
(inst,),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status)
|
||||
VALUES (?, 'ETH-USD_UM', 'C', 2, 0.02, 3.5, 'open')
|
||||
""",
|
||||
(inst,),
|
||||
)
|
||||
conn.commit()
|
||||
assert sum_open_premium_paid(conn, inst) == 3.5
|
||||
assert sum_open_sheets(conn, inst) == 2
|
||||
conn.close()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""币本位期权浮盈折 U(BTC/ETH 不得把币数量直接当 U)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
|
||||
|
||||
|
||||
class DisplayPnlUsdtTests(unittest.TestCase):
|
||||
def test_btc_coin_pnl_times_index(self):
|
||||
row = {
|
||||
"inst_id": "BTC-USD-260327-100000-P",
|
||||
"premium_ccy": "BTC",
|
||||
"margin_mode": "coin",
|
||||
"idx_px": 100000.0,
|
||||
"upl": -0.001,
|
||||
"close_preview": {"bid_invalid": True},
|
||||
}
|
||||
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), -100.0, places=4)
|
||||
|
||||
def test_btc_infers_ccy_when_premium_ccy_missing(self):
|
||||
row = {
|
||||
"inst_id": "BTC-USD-260327-100000-C",
|
||||
"margin_mode": "coin",
|
||||
"underlying": "BTC",
|
||||
"idx_px": 95000.0,
|
||||
"upl": 0.002,
|
||||
"close_preview": {"bid_invalid": True},
|
||||
}
|
||||
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), 190.0, places=4)
|
||||
|
||||
def test_usdc_unchanged(self):
|
||||
row = {
|
||||
"inst_id": "BTC-USD_UM-260327-100000-C",
|
||||
"premium_ccy": "USDC",
|
||||
"margin_mode": "usdc",
|
||||
"upl": -12.5,
|
||||
"close_preview": {"bid_invalid": True},
|
||||
}
|
||||
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), -12.5, places=4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""按可用余额打满 / 全仓复利定仓."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from lib.options.options_pricing_lib import (
|
||||
resolve_budget_full_usdc,
|
||||
resolve_compound_full_usdc,
|
||||
)
|
||||
from lib.options.options_position_limit_lib import (
|
||||
compound_full_single_position_block_msg,
|
||||
count_live_option_positions,
|
||||
)
|
||||
|
||||
|
||||
class TestOptionsBudgetModes(unittest.TestCase):
|
||||
def test_balance_above_budget_uses_budget(self):
|
||||
self.assertEqual(resolve_budget_full_usdc(100.0, 10.0), 10.0)
|
||||
|
||||
def test_balance_below_budget_uses_balance(self):
|
||||
self.assertEqual(resolve_budget_full_usdc(5.0, 10.0), 5.0)
|
||||
|
||||
def test_balance_equals_budget(self):
|
||||
self.assertEqual(resolve_budget_full_usdc(10.0, 10.0), 10.0)
|
||||
|
||||
def test_compound_full_no_cap_uses_all(self):
|
||||
self.assertEqual(
|
||||
resolve_compound_full_usdc(200.0, cap_enabled=False, cap_usdc=50.0),
|
||||
200.0,
|
||||
)
|
||||
|
||||
def test_compound_full_cap_on(self):
|
||||
self.assertEqual(
|
||||
resolve_compound_full_usdc(200.0, cap_enabled=True, cap_usdc=50.0),
|
||||
50.0,
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_compound_full_usdc(30.0, cap_enabled=True, cap_usdc=50.0),
|
||||
30.0,
|
||||
)
|
||||
|
||||
def test_compound_full_cap_invalid_falls_back_to_balance(self):
|
||||
self.assertEqual(
|
||||
resolve_compound_full_usdc(80.0, cap_enabled=True, cap_usdc=0),
|
||||
80.0,
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_compound_full_usdc(80.0, cap_enabled=True, cap_usdc=None),
|
||||
80.0,
|
||||
)
|
||||
|
||||
def test_compound_full_blocks_when_position_open(self):
|
||||
rows = [{"instId": "ETH-USD_UM-260812-1870-P", "pos": "1"}]
|
||||
msg = compound_full_single_position_block_msg(
|
||||
object(), fetch_positions=lambda _ex: rows
|
||||
)
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertIn("1 笔", msg or "")
|
||||
|
||||
def test_compound_full_allows_when_flat(self):
|
||||
msg = compound_full_single_position_block_msg(
|
||||
object(), fetch_positions=lambda _ex: []
|
||||
)
|
||||
self.assertIsNone(msg)
|
||||
self.assertEqual(count_live_option_positions([]), 0)
|
||||
|
||||
def test_normalize_size_mode_when_compound_off(self):
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from lib.options import options_register as reg
|
||||
|
||||
with patch.dict(os.environ, {"OKX_OPTIONS_COMPOUND_FULL_ENABLED": "false"}):
|
||||
mode, note = reg._normalize_size_mode("compound_full")
|
||||
self.assertEqual(mode, "sheets")
|
||||
self.assertIsNotNone(note)
|
||||
mode2, note2 = reg._normalize_size_mode("budget_full")
|
||||
self.assertEqual(mode2, "budget_full")
|
||||
self.assertIsNone(note2)
|
||||
mode3, _ = reg._normalize_size_mode("sheets")
|
||||
self.assertEqual(mode3, "sheets")
|
||||
|
||||
def test_normalize_size_mode_when_compound_on(self):
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from lib.options import options_register as reg
|
||||
|
||||
with patch.dict(os.environ, {"OKX_OPTIONS_COMPOUND_FULL_ENABLED": "true"}):
|
||||
mode, note = reg._normalize_size_mode("budget_full")
|
||||
self.assertEqual(mode, "compound_full")
|
||||
self.assertIsNone(note)
|
||||
mode2, _ = reg._normalize_size_mode("compound_full")
|
||||
self.assertEqual(mode2, "compound_full")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,191 @@
|
||||
"""期权平仓门控:USDT 口径(权利金×倍数 / 净盈亏阈值)且持续持有."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from lib.options.options_close_gate_lib import (
|
||||
clear_close_gate,
|
||||
is_close_gate_passed,
|
||||
mark_close_gate_passed,
|
||||
update_close_gate,
|
||||
)
|
||||
|
||||
|
||||
class OptionsCloseGateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
clear_close_gate()
|
||||
self._env_backup = {
|
||||
k: os.environ.get(k)
|
||||
for k in (
|
||||
"OKX_OPTIONS_CLOSE_GATE_MODE",
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
|
||||
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC",
|
||||
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_U",
|
||||
)
|
||||
}
|
||||
for k in self._env_backup:
|
||||
os.environ.pop(k, None)
|
||||
|
||||
def tearDown(self):
|
||||
clear_close_gate()
|
||||
for k, v in self._env_backup.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
def test_usdc_below_2x_not_ready(self):
|
||||
g = update_close_gate(
|
||||
"ETH-X",
|
||||
recycle_usdc=15.0,
|
||||
premium_paid=10.0,
|
||||
premium_ccy="USDC",
|
||||
now=1000.0,
|
||||
)
|
||||
self.assertFalse(g["recycle_ok"])
|
||||
self.assertFalse(g["ready"])
|
||||
self.assertIn("U(估)", g["msg"])
|
||||
|
||||
def test_usdc_meets_2x_needs_hold(self):
|
||||
g1 = update_close_gate(
|
||||
"ETH-X",
|
||||
recycle_usdc=20.0,
|
||||
premium_paid=10.0,
|
||||
premium_ccy="USDC",
|
||||
now=1000.0,
|
||||
)
|
||||
self.assertTrue(g1["recycle_ok"])
|
||||
self.assertFalse(g1["ready"])
|
||||
self.assertAlmostEqual(g1["remain_seconds"], 120.0)
|
||||
|
||||
g2 = update_close_gate(
|
||||
"ETH-X",
|
||||
recycle_usdc=21.0,
|
||||
premium_paid=10.0,
|
||||
premium_ccy="USDC",
|
||||
now=1120.0,
|
||||
)
|
||||
self.assertTrue(g2["ready"])
|
||||
|
||||
def test_coin_premium_gate_uses_usdt_and_default_105(self):
|
||||
# 0.0384 ETH * 2500 = 96U; ×1.05 = 100.8U
|
||||
g = update_close_gate(
|
||||
"ETH-P",
|
||||
recycle_usdc=0.036,
|
||||
premium_paid=0.0384,
|
||||
premium_ccy="ETH",
|
||||
index_px=2500.0,
|
||||
now=1000.0,
|
||||
)
|
||||
self.assertFalse(g["recycle_ok"])
|
||||
self.assertAlmostEqual(g["premium_usdt"], 96.0)
|
||||
self.assertAlmostEqual(g["need_recycle_usdt"], 100.8)
|
||||
self.assertIn("U(估)", g["msg"])
|
||||
|
||||
g_ok = update_close_gate(
|
||||
"ETH-P",
|
||||
recycle_usdc=0.041,
|
||||
premium_paid=0.0384,
|
||||
premium_ccy="ETH",
|
||||
index_px=2500.0,
|
||||
now=1000.0,
|
||||
)
|
||||
self.assertTrue(g_ok["recycle_ok"])
|
||||
self.assertAlmostEqual(g_ok["recycle_usdt"], 102.5)
|
||||
|
||||
def test_net_pnl_gate_mode(self):
|
||||
os.environ["OKX_OPTIONS_CLOSE_GATE_MODE"] = "net_pnl"
|
||||
os.environ["OKX_OPTIONS_CLOSE_NET_PNL_MIN_U"] = "1"
|
||||
g = update_close_gate(
|
||||
"ETH-N",
|
||||
recycle_usdc=0.039,
|
||||
premium_paid=0.0384,
|
||||
premium_ccy="ETH",
|
||||
index_px=2500.0,
|
||||
now=1000.0,
|
||||
)
|
||||
self.assertTrue(g["recycle_ok"])
|
||||
self.assertAlmostEqual(g["net_pnl_usdt"], 1.5)
|
||||
|
||||
g2 = update_close_gate(
|
||||
"ETH-N2",
|
||||
recycle_usdc=0.0385,
|
||||
premium_paid=0.0384,
|
||||
premium_ccy="ETH",
|
||||
index_px=2500.0,
|
||||
now=1000.0,
|
||||
)
|
||||
self.assertFalse(g2["recycle_ok"])
|
||||
|
||||
def test_break_resets_timer(self):
|
||||
update_close_gate(
|
||||
"ETH-X",
|
||||
recycle_usdc=20.0,
|
||||
premium_paid=10.0,
|
||||
premium_ccy="USDC",
|
||||
now=1000.0,
|
||||
)
|
||||
update_close_gate(
|
||||
"ETH-X",
|
||||
recycle_usdc=21.0,
|
||||
premium_paid=10.0,
|
||||
premium_ccy="USDC",
|
||||
now=1100.0,
|
||||
)
|
||||
g_break = update_close_gate(
|
||||
"ETH-X",
|
||||
recycle_usdc=12.0,
|
||||
premium_paid=10.0,
|
||||
premium_ccy="USDC",
|
||||
now=1110.0,
|
||||
)
|
||||
self.assertFalse(g_break["recycle_ok"])
|
||||
g_again = update_close_gate(
|
||||
"ETH-X",
|
||||
recycle_usdc=22.0,
|
||||
premium_paid=10.0,
|
||||
premium_ccy="USDC",
|
||||
now=1111.0,
|
||||
)
|
||||
self.assertTrue(g_again["recycle_ok"])
|
||||
self.assertFalse(g_again["ready"])
|
||||
self.assertAlmostEqual(g_again["held_seconds"], 0.0)
|
||||
|
||||
def test_passed_latches_after_ready(self):
|
||||
update_close_gate(
|
||||
"ETH-Y",
|
||||
recycle_usdc=20.0,
|
||||
premium_paid=10.0,
|
||||
premium_ccy="USDC",
|
||||
now=1000.0,
|
||||
)
|
||||
g_ready = update_close_gate(
|
||||
"ETH-Y",
|
||||
recycle_usdc=21.0,
|
||||
premium_paid=10.0,
|
||||
premium_ccy="USDC",
|
||||
now=1120.0,
|
||||
)
|
||||
self.assertTrue(g_ready["ready"])
|
||||
self.assertTrue(g_ready["passed"])
|
||||
self.assertTrue(is_close_gate_passed("ETH-Y"))
|
||||
g_drop = update_close_gate(
|
||||
"ETH-Y",
|
||||
recycle_usdc=5.0,
|
||||
premium_paid=10.0,
|
||||
premium_ccy="USDC",
|
||||
now=1130.0,
|
||||
)
|
||||
self.assertFalse(g_drop["recycle_ok"])
|
||||
self.assertTrue(g_drop["passed"])
|
||||
self.assertFalse(g_drop["auto_close_blocked"])
|
||||
|
||||
def test_mark_passed_manual(self):
|
||||
mark_close_gate_passed("ETH-Z")
|
||||
self.assertTrue(is_close_gate_passed("ETH-Z"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""期权历史/复盘币本位金额."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from lib.exchange.okx_options_lib import format_option_history_row
|
||||
from lib.options.options_review_lib import convert_option_amounts_to_usdt
|
||||
|
||||
|
||||
class TestOptionsHistoryCoin(unittest.TestCase):
|
||||
def test_format_option_history_row_coin_premium_fmt(self) -> None:
|
||||
raw = {
|
||||
"instId": "ETH-USD-260822-2250-C",
|
||||
"openAvgPx": "0.02",
|
||||
"closeAvgPx": "0.03",
|
||||
"closeTotalPos": "2",
|
||||
"realizedPnl": "0.002",
|
||||
"pnlRatio": "0.5",
|
||||
"type": "2",
|
||||
"uTime": "1724146497000",
|
||||
"cTime": "1724126855000",
|
||||
"posId": "123",
|
||||
"uly": "ETH-USD",
|
||||
}
|
||||
row = format_option_history_row(raw, tick_sz="0.0001", ct_mult=0.1)
|
||||
self.assertEqual(row["margin_mode"], "coin")
|
||||
self.assertEqual(row["premium_ccy"], "ETH")
|
||||
self.assertAlmostEqual(float(row["premium_paid"]), 0.004, places=6)
|
||||
self.assertNotEqual(row["premium_paid_fmt"], "0.00")
|
||||
self.assertIn("0.004", str(row["premium_paid_fmt"]))
|
||||
|
||||
def test_convert_option_amounts_to_usdt(self) -> None:
|
||||
out = convert_option_amounts_to_usdt(
|
||||
{
|
||||
"inst_id": "ETH-USD-260822-2250-C",
|
||||
"premium_ccy": "ETH",
|
||||
"margin_mode": "coin",
|
||||
"premium_paid": 0.004,
|
||||
"realized_pnl": 0.002,
|
||||
},
|
||||
index_px=2000.0,
|
||||
)
|
||||
self.assertEqual(out["pnl_quote_ccy"], "USDT")
|
||||
self.assertEqual(out["premium_paid"], 8.0)
|
||||
self.assertEqual(out["realized_pnl"], 4.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,54 @@
|
||||
from unittest import TestCase
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from lib.options.options_hub_lib import build_options_hub_snapshot
|
||||
|
||||
|
||||
class OptionsHubLibTests(TestCase):
|
||||
def test_build_options_hub_snapshot_disabled(self):
|
||||
out = build_options_hub_snapshot({"enabled": False})
|
||||
self.assertFalse(out["enabled"])
|
||||
self.assertTrue(out["ok"])
|
||||
|
||||
@patch("lib.options.options_history_lib.load_options_history")
|
||||
@patch("lib.options.options_positions_lib.build_display_option_positions")
|
||||
def test_build_options_hub_snapshot_positions(self, mock_positions, mock_history):
|
||||
mock_positions.return_value = [
|
||||
{
|
||||
"inst_id": "ETH-USD_UM-260703-1800-C",
|
||||
"pos": 2,
|
||||
"upl": 9.9,
|
||||
"mark_px": 0.1,
|
||||
"close_preview": {"estimated_pnl": 1.5},
|
||||
}
|
||||
]
|
||||
conn = MagicMock()
|
||||
conn.__enter__ = MagicMock(return_value=conn)
|
||||
conn.__exit__ = MagicMock(return_value=False)
|
||||
cfg = {
|
||||
"enabled": True,
|
||||
"exchange_options": object(),
|
||||
"options_api_ready": lambda ex: (True, ""),
|
||||
"fetch_option_positions": lambda ex: [
|
||||
{"instId": "ETH-USD_UM-260703-1800-C", "pos": "2", "upl": "1.5", "markPx": "0.1"}
|
||||
],
|
||||
"fetch_options_balances": lambda ex: {"trading_usdc": 9.5, "funding_usdc": 12.0},
|
||||
"get_db": MagicMock(return_value=conn),
|
||||
"trade_budget": 10,
|
||||
"account_label": "OKX期权",
|
||||
}
|
||||
with patch("lib.options.options_target_lib.list_active_targets", return_value=[]):
|
||||
with patch("lib.options.options_target_lib.list_closing_targets", return_value=[]):
|
||||
with patch("lib.options.options_target_lib.targets_by_inst", return_value={}):
|
||||
with patch(
|
||||
"lib.hedge_plan.hedge_plan_db.active_options_targets_by_inst",
|
||||
return_value={},
|
||||
):
|
||||
out = build_options_hub_snapshot(cfg)
|
||||
self.assertTrue(out["ok"], out.get("msg"))
|
||||
self.assertEqual(out["position_count"], 1)
|
||||
self.assertEqual(out["upl_total_usdc"], 1.5)
|
||||
self.assertEqual(out["trading_usdc"], 9.5)
|
||||
self.assertEqual(out.get("target_monitors"), [])
|
||||
self.assertEqual(out.get("stats"), {})
|
||||
mock_history.assert_not_called()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""币本位模式预算与合约族单测."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestOptionsMarginMode(unittest.TestCase):
|
||||
def test_normalize_mode(self):
|
||||
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
|
||||
|
||||
self.assertEqual(normalize_options_margin_mode("usdc"), "usdc")
|
||||
self.assertEqual(normalize_options_margin_mode("coin"), "coin")
|
||||
self.assertEqual(normalize_options_margin_mode("币本位"), "coin")
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("OKX_OPTIONS_MARGIN_MODE", None)
|
||||
self.assertEqual(normalize_options_margin_mode(None), "coin")
|
||||
self.assertEqual(normalize_options_margin_mode(""), "coin")
|
||||
|
||||
def test_inst_family(self):
|
||||
from lib.options.options_margin_mode_lib import inst_family_for_underlying
|
||||
|
||||
self.assertEqual(inst_family_for_underlying("ETH", margin_mode="usdc"), "ETH-USD_UM")
|
||||
self.assertEqual(inst_family_for_underlying("ETH", margin_mode="coin"), "ETH-USD")
|
||||
|
||||
def test_margin_mode_from_inst_id(self):
|
||||
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id
|
||||
|
||||
self.assertEqual(margin_mode_from_inst_id("ETH-USD_UM-260701-2500-C"), "usdc")
|
||||
self.assertEqual(margin_mode_from_inst_id("ETH-USD-260701-2500-C"), "coin")
|
||||
|
||||
def test_coin_budget_compound(self):
|
||||
from lib.options.options_margin_mode_lib import compute_coin_budget_usdt
|
||||
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
r = compute_coin_budget_usdt(
|
||||
20.0,
|
||||
compound=True,
|
||||
buffer=0.95,
|
||||
max_enabled=False,
|
||||
)
|
||||
self.assertTrue(r["ok"])
|
||||
self.assertAlmostEqual(r["budget_usdt"], 19.0, places=6)
|
||||
|
||||
def test_coin_budget_max_cap(self):
|
||||
from lib.options.options_margin_mode_lib import compute_coin_budget_usdt
|
||||
|
||||
r = compute_coin_budget_usdt(
|
||||
100.0,
|
||||
compound=True,
|
||||
buffer=0.95,
|
||||
max_enabled=True,
|
||||
max_usdt=50.0,
|
||||
)
|
||||
self.assertTrue(r["ok"])
|
||||
self.assertAlmostEqual(r["budget_usdt"], 50.0, places=6)
|
||||
self.assertTrue(r["capped_by_max"])
|
||||
|
||||
def test_coin_budget_fixed(self):
|
||||
from lib.options.options_margin_mode_lib import compute_coin_budget_usdt
|
||||
|
||||
r = compute_coin_budget_usdt(
|
||||
100.0,
|
||||
compound=False,
|
||||
buffer=0.95,
|
||||
fixed_budget_usdt=10.0,
|
||||
max_enabled=False,
|
||||
)
|
||||
self.assertAlmostEqual(r["budget_usdt"], 9.5, places=6)
|
||||
|
||||
def test_sheets_from_coin(self):
|
||||
from lib.options.options_margin_mode_lib import calc_sheets_from_coin_balance
|
||||
|
||||
# ask 0.01 ETH per 1 ETH, ctMult 0.01 → 每张 0.0001 ETH; 0.01 ETH×0.97 缓冲可开 97 张
|
||||
r = calc_sheets_from_coin_balance(
|
||||
quote_per_unit=0.01,
|
||||
ct_mult=0.01,
|
||||
min_sz=1,
|
||||
coin_available=0.01,
|
||||
)
|
||||
self.assertTrue(r["ok"])
|
||||
self.assertEqual(r["sheets"], 97)
|
||||
|
||||
def test_spot_buy_buffer_normalize(self):
|
||||
from lib.options.options_margin_mode_lib import normalize_coin_spot_buy_buffer
|
||||
|
||||
self.assertAlmostEqual(normalize_coin_spot_buy_buffer(1.10), 1.10)
|
||||
self.assertAlmostEqual(normalize_coin_spot_buy_buffer(0.10), 1.10)
|
||||
self.assertAlmostEqual(normalize_coin_spot_buy_buffer(1.25), 1.25)
|
||||
|
||||
def test_plan_coin_open_by_budget(self):
|
||||
from lib.options.options_margin_mode_lib import plan_coin_open_by_budget
|
||||
|
||||
# ask 0.01, ct 0.1 → 单张权利金 0.001 ETH;×1.1=0.0011;×指数 2000 → 2.2 USDT/张
|
||||
r = plan_coin_open_by_budget(
|
||||
quote_per_unit=0.01,
|
||||
ct_mult=0.1,
|
||||
min_sz=1,
|
||||
budget_usdt=10.0,
|
||||
index_px=2000.0,
|
||||
ask_sz=100,
|
||||
spot_buy_buffer=1.10,
|
||||
)
|
||||
self.assertTrue(r["ok"], r.get("msg"))
|
||||
self.assertEqual(r["sheets"], 4) # floor(10/2.2)=4
|
||||
self.assertAlmostEqual(r["buy_usdt"], 4 * 0.01 * 0.1 * 1.10 * 2000, places=4)
|
||||
self.assertLess(r["buy_usdt"], 10.0)
|
||||
|
||||
one = plan_coin_open_by_budget(
|
||||
quote_per_unit=0.01,
|
||||
ct_mult=0.1,
|
||||
min_sz=1,
|
||||
budget_usdt=10.0,
|
||||
index_px=2000.0,
|
||||
ask_sz=100,
|
||||
spot_buy_buffer=1.10,
|
||||
target_sheets=1,
|
||||
)
|
||||
self.assertTrue(one["ok"], one.get("msg"))
|
||||
self.assertEqual(one["sheets"], 1)
|
||||
self.assertAlmostEqual(one["buy_usdt"], 0.01 * 0.1 * 1.10 * 2000, places=4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""期权净盈亏汇总与持仓卡口径一致."""
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
from lib.options.options_positions_lib import (
|
||||
display_pnl_from_option_row,
|
||||
net_pnl_from_display_row,
|
||||
sum_options_net_pnl_usdc,
|
||||
)
|
||||
|
||||
|
||||
class OptionsNetPnlSumTests(TestCase):
|
||||
def test_net_pnl_from_display_row(self):
|
||||
self.assertEqual(
|
||||
net_pnl_from_display_row({"close_preview": {"estimated_pnl": -2.8}, "premium_paid": 4.95}),
|
||||
-2.8,
|
||||
)
|
||||
self.assertIsNone(
|
||||
net_pnl_from_display_row({"close_preview": {"bid_invalid": True, "estimated_pnl": -1}})
|
||||
)
|
||||
self.assertEqual(
|
||||
net_pnl_from_display_row(
|
||||
{
|
||||
"close_preview": {"total_received": 2.15, "covered_sheets": 1},
|
||||
"premium_paid": 4.95,
|
||||
}
|
||||
),
|
||||
round(2.15 - 4.95, 4),
|
||||
)
|
||||
# bid 无效时 total_received=0 不得算出 −权利金
|
||||
self.assertIsNone(
|
||||
net_pnl_from_display_row(
|
||||
{
|
||||
"close_preview": {
|
||||
"bid_invalid": True,
|
||||
"total_received": 0.0,
|
||||
"covered_sheets": 0,
|
||||
},
|
||||
"premium_paid": 9.49,
|
||||
"upl": -4.2,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_display_pnl_falls_back_to_exchange_upl(self):
|
||||
self.assertEqual(
|
||||
display_pnl_from_option_row(
|
||||
{
|
||||
"close_preview": {"bid_invalid": True, "total_received": 0.0},
|
||||
"premium_paid": 9.49,
|
||||
"upl": -4.2,
|
||||
}
|
||||
),
|
||||
-4.2,
|
||||
)
|
||||
|
||||
@patch("lib.options.options_positions_lib.build_display_option_positions")
|
||||
def test_sum_options_net_pnl_usdc(self, mock_build):
|
||||
mock_build.return_value = [
|
||||
{"close_preview": {"estimated_pnl": -2.8}},
|
||||
{"close_preview": {"estimated_pnl": 1.0}},
|
||||
{"close_preview": {"bid_invalid": True, "estimated_pnl": 9}, "upl": -0.5},
|
||||
]
|
||||
cfg = {"fetch_option_positions": lambda ex: [{"instId": "X"}]}
|
||||
self.assertEqual(sum_options_net_pnl_usdc(cfg, object()), -2.3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from unittest import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""期权开平仓微信文案."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from lib.options.options_notify_lib import (
|
||||
build_options_close_message,
|
||||
build_options_open_message,
|
||||
)
|
||||
|
||||
|
||||
class TestOptionsNotify(unittest.TestCase):
|
||||
def test_open_close_messages_usdc(self) -> None:
|
||||
open_msg = build_options_open_message(
|
||||
account_label="OKX期权",
|
||||
inst_id="ETH-USD_UM-250725-3200-C",
|
||||
underlying="ETH",
|
||||
opt_type="C",
|
||||
sheets=2,
|
||||
premium_paid=8.5,
|
||||
open_quote=0.01,
|
||||
target_index=3400,
|
||||
signal_note="假突破",
|
||||
trade_id=12,
|
||||
premium_ccy="USDC",
|
||||
margin_mode="usdc",
|
||||
)
|
||||
self.assertIn("【OKX期权·开仓】", open_msg)
|
||||
self.assertIn("ETH-USD_UM-250725-3200-C", open_msg)
|
||||
self.assertIn("目标指数:3400", open_msg)
|
||||
self.assertIn("USDC", open_msg)
|
||||
|
||||
close_msg = build_options_close_message(
|
||||
account_label="OKX期权",
|
||||
inst_id="ETH-USD_UM-250725-3200-C",
|
||||
reason="手动平仓",
|
||||
underlying="ETH",
|
||||
opt_type="C",
|
||||
sheets=2,
|
||||
premium_paid=8.5,
|
||||
premium_received=12.0,
|
||||
realized_pnl=3.5,
|
||||
premium_ccy="USDC",
|
||||
)
|
||||
self.assertIn("【OKX期权·平仓】", close_msg)
|
||||
self.assertIn("手动平仓", close_msg)
|
||||
self.assertIn("3.5000", close_msg)
|
||||
self.assertIn("USDC", close_msg)
|
||||
|
||||
def test_open_close_messages_coin(self) -> None:
|
||||
open_msg = build_options_open_message(
|
||||
account_label="OKX期权",
|
||||
inst_id="ETH-USD-250725-3200-C",
|
||||
underlying="ETH",
|
||||
opt_type="C",
|
||||
sheets=1,
|
||||
premium_paid=0.001234,
|
||||
open_quote=0.01234,
|
||||
trade_id=99,
|
||||
premium_ccy="ETH",
|
||||
margin_mode="coin",
|
||||
)
|
||||
self.assertIn("本位:币本位", open_msg)
|
||||
self.assertIn("ETH", open_msg)
|
||||
self.assertNotIn("USDC", open_msg)
|
||||
|
||||
close_msg = build_options_close_message(
|
||||
account_label="OKX期权",
|
||||
inst_id="ETH-USD-250725-3200-C",
|
||||
reason="翻倍出场(1倍)",
|
||||
underlying="ETH",
|
||||
sheets=1,
|
||||
premium_paid=0.001234,
|
||||
premium_received=0.0025,
|
||||
realized_pnl=0.001266,
|
||||
premium_ccy="ETH",
|
||||
margin_mode="coin",
|
||||
)
|
||||
self.assertIn("本位:币本位", close_msg)
|
||||
self.assertIn("翻倍出场", close_msg)
|
||||
self.assertIn("ETH", close_msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""期权挂单超时撤单单测."""
|
||||
from unittest import TestCase
|
||||
|
||||
from lib.options.options_pending_lib import (
|
||||
cancel_stale_close_pending_orders,
|
||||
enrich_pending_orders,
|
||||
is_close_pending_order,
|
||||
order_age_seconds,
|
||||
)
|
||||
|
||||
|
||||
class OptionsPendingLibTests(TestCase):
|
||||
def test_order_age_and_close_detect(self):
|
||||
now = 1_700_000_600_000
|
||||
age = order_age_seconds({"c_time": now - 90_000}, now_ms=now)
|
||||
self.assertAlmostEqual(age, 90.0, places=3)
|
||||
self.assertTrue(is_close_pending_order({"side": "sell"}))
|
||||
self.assertTrue(is_close_pending_order({"side": "buy", "reduce_only": True}))
|
||||
self.assertFalse(is_close_pending_order({"side": "buy"}))
|
||||
|
||||
def test_enrich_expire(self):
|
||||
now = 1_700_000_600_000
|
||||
rows = enrich_pending_orders(
|
||||
[
|
||||
{"ord_id": "1", "inst_id": "A", "side": "sell", "c_time": now - 700_000},
|
||||
{"ord_id": "2", "inst_id": "B", "side": "buy", "c_time": now - 700_000},
|
||||
{"ord_id": "3", "inst_id": "C", "side": "sell", "c_time": now - 30_000},
|
||||
],
|
||||
ttl_seconds=600,
|
||||
now_ms=now,
|
||||
)
|
||||
by_id = {r["ord_id"]: r for r in rows}
|
||||
self.assertTrue(by_id["1"]["stale"])
|
||||
self.assertTrue(by_id["1"]["auto_cancel_enabled"])
|
||||
self.assertFalse(by_id["2"]["auto_cancel_enabled"])
|
||||
self.assertFalse(by_id["3"]["stale"])
|
||||
self.assertAlmostEqual(by_id["3"]["expire_in_sec"], 570.0, places=0)
|
||||
|
||||
def test_cancel_stale_only_close(self):
|
||||
now = 1_700_000_600_000
|
||||
pending = [
|
||||
{"ord_id": "s1", "inst_id": "A", "side": "sell", "c_time": now - 700_000},
|
||||
{"ord_id": "b1", "inst_id": "B", "side": "buy", "c_time": now - 700_000},
|
||||
{"ord_id": "s2", "inst_id": "C", "side": "sell", "c_time": now - 10_000},
|
||||
]
|
||||
cancelled = []
|
||||
|
||||
def fetch(_ex=None):
|
||||
return pending
|
||||
|
||||
def cancel(_ex=None, inst_id=None, ord_id=None):
|
||||
cancelled.append((inst_id, ord_id))
|
||||
return {"ok": True}
|
||||
|
||||
out = cancel_stale_close_pending_orders(
|
||||
fetch_pending=fetch,
|
||||
cancel_order=cancel,
|
||||
ttl_seconds=60,
|
||||
now_ms=now,
|
||||
ex=object(),
|
||||
)
|
||||
self.assertEqual(out["cancelled"], 1)
|
||||
self.assertEqual(cancelled, [("A", "s1")])
|
||||
@@ -0,0 +1,101 @@
|
||||
"""OKX 期权持仓笔数上限."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from lib.options.options_position_limit_lib import (
|
||||
count_live_option_positions,
|
||||
option_position_limit_block_msg,
|
||||
options_max_active_positions,
|
||||
)
|
||||
|
||||
|
||||
class OptionsPositionLimitTests(unittest.TestCase):
|
||||
def test_default_unlimited(self):
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("OKX_OPTIONS_MAX_ACTIVE_POSITIONS", None)
|
||||
self.assertEqual(options_max_active_positions(), 0)
|
||||
|
||||
def test_parse_max(self):
|
||||
with patch.dict(os.environ, {"OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "2"}):
|
||||
self.assertEqual(options_max_active_positions(), 2)
|
||||
|
||||
def test_count_live(self):
|
||||
rows = [{"instId": "A", "pos": "1"}, {"instId": "B", "pos": "0"}, {"instId": "C", "pos": "-2"}]
|
||||
self.assertEqual(count_live_option_positions(rows), 2)
|
||||
|
||||
def test_block_when_at_limit(self):
|
||||
rows = [{"instId": "ETH-C", "pos": "1"}, {"instId": "ETH-P", "pos": "2"}]
|
||||
msg = option_position_limit_block_msg(
|
||||
object(),
|
||||
opening_inst_id="ETH-NEW",
|
||||
max_active=2,
|
||||
fetch_positions=lambda _ex: rows,
|
||||
)
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertIn("上限", msg or "")
|
||||
|
||||
def test_allow_add_to_existing(self):
|
||||
rows = [{"instId": "ETH-C", "pos": "1"}, {"instId": "ETH-P", "pos": "2"}]
|
||||
msg = option_position_limit_block_msg(
|
||||
object(),
|
||||
opening_inst_id="ETH-C",
|
||||
max_active=2,
|
||||
fetch_positions=lambda _ex: rows,
|
||||
)
|
||||
self.assertIsNone(msg)
|
||||
|
||||
def test_allow_under_limit(self):
|
||||
rows = [{"instId": "ETH-C", "pos": "1"}]
|
||||
msg = option_position_limit_block_msg(
|
||||
object(),
|
||||
opening_inst_id="ETH-P",
|
||||
max_active=2,
|
||||
fetch_positions=lambda _ex: rows,
|
||||
)
|
||||
self.assertIsNone(msg)
|
||||
|
||||
def test_oo_needs_two_slots_when_max_one(self):
|
||||
msg = option_position_limit_block_msg(
|
||||
object(),
|
||||
opening_inst_ids=["ETH-C", "ETH-P"],
|
||||
max_active=1,
|
||||
fetch_positions=lambda _ex: [],
|
||||
)
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertIn("期期", msg or "")
|
||||
|
||||
def test_oo_ok_when_max_two_empty(self):
|
||||
msg = option_position_limit_block_msg(
|
||||
object(),
|
||||
opening_inst_ids=["ETH-C", "ETH-P"],
|
||||
max_active=2,
|
||||
fetch_positions=lambda _ex: [],
|
||||
)
|
||||
self.assertIsNone(msg)
|
||||
|
||||
def test_oo_block_when_one_slot_left(self):
|
||||
rows = [{"instId": "OTHER", "pos": "1"}]
|
||||
msg = option_position_limit_block_msg(
|
||||
object(),
|
||||
opening_inst_ids=["ETH-C", "ETH-P"],
|
||||
max_active=2,
|
||||
fetch_positions=lambda _ex: rows,
|
||||
)
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertIn("期期", msg or "")
|
||||
|
||||
def test_fail_closed_when_fetch_none(self):
|
||||
msg = option_position_limit_block_msg(
|
||||
object(),
|
||||
opening_inst_id="ETH-C",
|
||||
max_active=1,
|
||||
fetch_positions=lambda _ex: None,
|
||||
)
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertIn("无法获取", msg or "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,464 @@
|
||||
"""期权定价单测."""
|
||||
from lib.options.options_pricing_lib import (
|
||||
calc_order_size,
|
||||
premium_per_sheet,
|
||||
sheets_from_eth_amount,
|
||||
total_premium,
|
||||
)
|
||||
from lib.exchange.okx_options_lib import format_option_px, inst_family_from_inst_id, round_option_px
|
||||
|
||||
|
||||
def test_inst_family_from_inst_id():
|
||||
assert inst_family_from_inst_id("ETH-USD_UM-260707-1790-C") == "ETH-USD_UM"
|
||||
assert inst_family_from_inst_id("BTC-USD-260925-60000-C") == "BTC-USD"
|
||||
|
||||
|
||||
def test_round_option_px():
|
||||
assert round_option_px(14.9184, "0.2", "sell") == 14.8
|
||||
assert round_option_px(14.81, "0.2", "buy") == 15.0
|
||||
assert format_option_px(14.8, "0.2") == "14.8"
|
||||
# BTC 期权 tickSz=5: 整数末尾 0 必须保留 (1370 不能显成 137)
|
||||
assert format_option_px(1370, "5") == "1370"
|
||||
assert format_option_px(1160, 5) == "1160"
|
||||
assert format_option_px(1000, "5") == "1000"
|
||||
# 无 tick 时不得透出浮点毛刺
|
||||
assert format_option_px(482.4881990066513, None) == "482.4882"
|
||||
|
||||
|
||||
def test_premium_per_sheet():
|
||||
assert abs(premium_per_sheet(15.6, 0.01) - 0.156) < 1e-9
|
||||
|
||||
|
||||
def test_total_premium_half_eth():
|
||||
assert abs(total_premium(15.6, 0.5) - 7.8) < 1e-9
|
||||
|
||||
|
||||
def test_sheets_from_eth():
|
||||
assert sheets_from_eth_amount(0.5, 0.01) == 50
|
||||
|
||||
|
||||
def test_calc_order_size_budget():
|
||||
r = calc_order_size(
|
||||
quote_per_unit=15.6,
|
||||
ct_mult=0.01,
|
||||
min_sz=1,
|
||||
budget_usdc=10,
|
||||
budget_buffer=0.95,
|
||||
budget_cap=10,
|
||||
)
|
||||
assert r["ok"] is True
|
||||
assert r["sheets"] >= 1
|
||||
assert r["total_premium"] <= 10
|
||||
|
||||
|
||||
def test_calc_order_size_sheets():
|
||||
r = calc_order_size(
|
||||
quote_per_unit=15.6,
|
||||
ct_mult=0.01,
|
||||
min_sz=1,
|
||||
sheets=3,
|
||||
budget_cap=10,
|
||||
)
|
||||
assert r["ok"] is True
|
||||
assert r["sheets"] == 3
|
||||
assert abs(r["total_premium"] - 0.468) < 1e-9
|
||||
|
||||
|
||||
def test_option_moneyness():
|
||||
from lib.options.options_pricing_lib import option_moneyness, option_moneyness_label
|
||||
|
||||
assert option_moneyness(opt_type="C", strike=1700, index_px=1800) == "itm"
|
||||
assert option_moneyness(opt_type="C", strike=1900, index_px=1800) == "otm"
|
||||
assert option_moneyness_label("itm") == "实值"
|
||||
assert option_moneyness_label("otm") == "虚值"
|
||||
|
||||
|
||||
def test_equivalent_contract_leverage():
|
||||
from lib.options.options_pricing_lib import equivalent_contract_leverage
|
||||
|
||||
# index 1768, 0.2 ETH, premium 2.44 -> ~144.9x
|
||||
lev = equivalent_contract_leverage(index_px=1768, eth_amount=0.2, total_premium=2.44)
|
||||
assert lev == 144.9
|
||||
|
||||
|
||||
def test_straddle_pricing():
|
||||
from lib.options.options_pricing_lib import (
|
||||
format_straddle_band,
|
||||
straddle_ask_per_unit,
|
||||
straddle_breakeven_band,
|
||||
straddle_premium_total,
|
||||
)
|
||||
|
||||
assert straddle_ask_per_unit(0.148, 16.2) == 16.348
|
||||
assert straddle_premium_total(0.148, 16.2, 1.0) == 16.35
|
||||
lo, hi = straddle_breakeven_band(1800, 16.348)
|
||||
assert lo == 1783.65
|
||||
assert hi == 1816.35
|
||||
assert format_straddle_band(1800, 16.348) == "1784 ~ 1816"
|
||||
assert straddle_ask_per_unit(0.148, None) is None
|
||||
|
||||
|
||||
def test_estimate_expiry_value_and_profit_at_index():
|
||||
from lib.options.options_pricing_lib import (
|
||||
estimate_expiry_profit_at_index,
|
||||
estimate_expiry_value_at_index,
|
||||
)
|
||||
|
||||
value = estimate_expiry_value_at_index(
|
||||
opt_type="C", strike=1800, target_idx=2000, eth_amount=1.0
|
||||
)
|
||||
assert value == 200.0
|
||||
|
||||
profit = estimate_expiry_profit_at_index(
|
||||
opt_type="C",
|
||||
strike=1800,
|
||||
target_idx=2000,
|
||||
entry_px=0.148,
|
||||
eth_amount=1.0,
|
||||
total_premium=14.8,
|
||||
)
|
||||
assert profit == 185.2
|
||||
|
||||
# Call 1780, ask 12.2, 0.01 ETH, target 1793 -> value 0.13, profit 0.01
|
||||
v = estimate_expiry_value_at_index(
|
||||
opt_type="C", strike=1780, target_idx=1793, eth_amount=0.01
|
||||
)
|
||||
assert v == 0.13
|
||||
p = estimate_expiry_profit_at_index(
|
||||
opt_type="C",
|
||||
strike=1780,
|
||||
target_idx=1793,
|
||||
entry_px=12.2,
|
||||
eth_amount=0.01,
|
||||
total_premium=0.122,
|
||||
)
|
||||
assert p == 0.01
|
||||
# OTM call loses premium
|
||||
p2 = estimate_expiry_profit_at_index(
|
||||
opt_type="C",
|
||||
strike=1780,
|
||||
target_idx=1770,
|
||||
entry_px=12.2,
|
||||
eth_amount=0.01,
|
||||
total_premium=0.122,
|
||||
)
|
||||
assert p2 == -0.12
|
||||
|
||||
|
||||
def test_resolve_chain_quote_otm_no_quote():
|
||||
from lib.exchange.okx_options_lib import _resolve_chain_quote
|
||||
|
||||
q = _resolve_chain_quote(
|
||||
ticker={},
|
||||
meta={"tickSz": "0.2"},
|
||||
opt_type="C",
|
||||
strike=1800,
|
||||
index_px=1776,
|
||||
)
|
||||
assert q["ask"] is None
|
||||
assert q["bid"] is None
|
||||
assert q["ask_estimated"] is False
|
||||
|
||||
|
||||
def test_resolve_chain_quote_estimated_ask():
|
||||
from lib.exchange.okx_options_lib import _resolve_chain_quote
|
||||
|
||||
q = _resolve_chain_quote(
|
||||
ticker={"bidPx": "0.2", "bidSz": "3500"},
|
||||
meta={"tickSz": "0.2"},
|
||||
opt_type="C",
|
||||
strike=1650,
|
||||
index_px=1776,
|
||||
)
|
||||
assert q["ask_estimated"] is True
|
||||
assert q["ask"] is not None
|
||||
assert q["ask"] >= 120
|
||||
|
||||
|
||||
def test_format_quote_liquidity():
|
||||
from lib.options.options_pricing_lib import format_quote_liquidity
|
||||
|
||||
assert format_quote_liquidity(17.2, 150) == "17.2/150"
|
||||
assert format_quote_liquidity(817.6, 11) == "817.6/11"
|
||||
assert format_quote_liquidity(15.6, None) == "15.6"
|
||||
assert format_quote_liquidity(None, 10) is None
|
||||
|
||||
|
||||
def test_estimate_close_by_bids_full_depth():
|
||||
from lib.options.options_pricing_lib import estimate_close_by_bids
|
||||
|
||||
# 多档估算需显式 max_levels;默认只估买一
|
||||
out = estimate_close_by_bids(
|
||||
[{"px": 12.3, "sz": 2}, {"px": 12.1, "sz": 3}],
|
||||
4,
|
||||
ct_mult=0.01,
|
||||
premium_paid=0.4,
|
||||
max_levels=5,
|
||||
)
|
||||
assert out["covered_sheets"] == 4
|
||||
assert out["uncovered_sheets"] == 0
|
||||
assert out["total_received"] == 0.488
|
||||
assert out["avg_px"] == 12.2
|
||||
assert out["estimated_pnl"] == 0.088
|
||||
assert out["estimated_pnl_ratio_pct"] == 22.0
|
||||
assert [x["sheets"] for x in out["levels"]] == [2, 2]
|
||||
|
||||
bid1 = estimate_close_by_bids(
|
||||
[{"px": 12.3, "sz": 2}, {"px": 12.1, "sz": 3}],
|
||||
4,
|
||||
ct_mult=0.01,
|
||||
premium_paid=0.4,
|
||||
)
|
||||
assert bid1["covered_sheets"] == 2
|
||||
assert bid1["uncovered_sheets"] == 2
|
||||
assert [x["sheets"] for x in bid1["levels"]] == [2]
|
||||
|
||||
|
||||
def test_estimate_close_by_bids_partial_depth():
|
||||
from lib.options.options_pricing_lib import estimate_close_by_bids
|
||||
|
||||
out = estimate_close_by_bids([{"px": 10, "sz": 1}], 3, ct_mult=0.01, premium_paid=0.6)
|
||||
assert out["covered_sheets"] == 1
|
||||
assert out["uncovered_sheets"] == 2
|
||||
assert out["total_received"] == 0.1
|
||||
# 净盈亏 = 回收 − 全部权利金(不按覆盖比例摊薄)
|
||||
assert out["estimated_pnl"] == -0.5
|
||||
assert out["estimated_pnl_ratio_pct"] == round(-0.5 / 0.6 * 100, 2)
|
||||
|
||||
|
||||
def test_estimate_close_by_bids_empty():
|
||||
from lib.options.options_pricing_lib import estimate_close_by_bids
|
||||
|
||||
out = estimate_close_by_bids([], 2)
|
||||
assert out["covered_sheets"] == 0
|
||||
assert out["uncovered_sheets"] == 2
|
||||
assert out["avg_px"] is None
|
||||
|
||||
|
||||
def test_stub_bid_blocks_auto_close_estimate():
|
||||
from lib.options.options_pricing_lib import estimate_close_by_bids, is_stub_bid_px
|
||||
|
||||
stub, reason = is_stub_bid_px(0.2, mark_px=42.0)
|
||||
assert stub is True
|
||||
assert "残档" in reason or "无效" in reason or "远低于" in reason
|
||||
|
||||
out = estimate_close_by_bids(
|
||||
[{"px": 0.2, "sz": 3500}],
|
||||
66,
|
||||
ct_mult=0.01,
|
||||
premium_paid=9.37,
|
||||
mark_px=42.0,
|
||||
)
|
||||
assert out["auto_close_blocked"] is True
|
||||
assert out["bid_invalid"] is True
|
||||
assert out["estimated_pnl"] is None
|
||||
assert out["levels"] == []
|
||||
|
||||
ok, _ = is_stub_bid_px(30.0, mark_px=42.0)
|
||||
assert ok is False
|
||||
good = estimate_close_by_bids(
|
||||
[{"px": 30.0, "sz": 100}],
|
||||
10,
|
||||
ct_mult=0.01,
|
||||
premium_paid=1.0,
|
||||
mark_px=42.0,
|
||||
)
|
||||
assert good["auto_close_blocked"] is False
|
||||
assert good["covered_sheets"] == 10
|
||||
|
||||
|
||||
def test_intrinsic_px_coin_vs_usdc_units():
|
||||
from lib.options.options_pricing_lib import intrinsic_px_per_unit, is_stub_bid_px
|
||||
|
||||
# USDC / 默认:美元点差
|
||||
assert intrinsic_px_per_unit("C", 2250, 2274) == 24.0
|
||||
assert intrinsic_px_per_unit("C", 2250, 2274, margin_mode="usdc") == 24.0
|
||||
|
||||
# 币本位:与盘口同单位的币报价 (S−K)/S
|
||||
coin_iv = intrinsic_px_per_unit("C", 2250, 2274, quote_in_coin=True)
|
||||
assert coin_iv is not None
|
||||
assert abs(coin_iv - 24.0 / 2274.0) < 1e-12
|
||||
assert abs(
|
||||
intrinsic_px_per_unit("C", 2250, 2274, inst_id="ETH-USD-260822-2250-C") - 24.0 / 2274.0
|
||||
) < 1e-12
|
||||
# USD_UM 仍为点差
|
||||
assert intrinsic_px_per_unit("C", 2250, 2274, inst_id="ETH-USD_UM-260822-2250-C") == 24.0
|
||||
|
||||
# 复现线上误杀:把点差当内在价值会把正常买一判残档
|
||||
wrong_stub, _ = is_stub_bid_px(0.023, mark_px=0.0241, intrinsic_px=23.58)
|
||||
assert wrong_stub is True
|
||||
# 币报价内在价值后,买一贴近标记价应有效
|
||||
ok_stub, _ = is_stub_bid_px(0.023, mark_px=0.0241, intrinsic_px=coin_iv)
|
||||
assert ok_stub is False
|
||||
|
||||
put_iv = intrinsic_px_per_unit("P", 2300, 2274, quote_in_coin=True)
|
||||
assert put_iv is not None
|
||||
assert abs(put_iv - 26.0 / 2274.0) < 1e-12
|
||||
|
||||
|
||||
def test_expiry_breakeven_from_ask():
|
||||
from lib.options.options_pricing_lib import expiry_breakeven_from_ask
|
||||
|
||||
assert expiry_breakeven_from_ask(opt_type="C", strike=1760, ask_px=15.6) == 1775.6
|
||||
assert expiry_breakeven_from_ask(opt_type="P", strike=1760, ask_px=15.6) == 1744.4
|
||||
assert expiry_breakeven_from_ask(opt_type="C", strike=1760, ask_px=None, mark_px=14.2) == 1774.2
|
||||
|
||||
|
||||
def test_calc_order_size_too_small():
|
||||
r = calc_order_size(
|
||||
quote_per_unit=2000.0,
|
||||
ct_mult=0.01,
|
||||
min_sz=1,
|
||||
budget_usdc=10,
|
||||
budget_buffer=0.95,
|
||||
budget_cap=10,
|
||||
)
|
||||
assert r["ok"] is False
|
||||
|
||||
|
||||
def test_expiry_breakeven_from_api():
|
||||
from lib.options.options_pricing_lib import expiry_breakeven_px
|
||||
|
||||
assert expiry_breakeven_px(
|
||||
opt_type="C", strike=3500, avg_px=15.6, be_px_api=3516.2
|
||||
) == 3516.2
|
||||
|
||||
|
||||
def test_expiry_breakeven_call_put():
|
||||
from lib.options.options_pricing_lib import expiry_breakeven_px
|
||||
|
||||
assert expiry_breakeven_px(opt_type="C", strike=3500, avg_px=15.6) == 3515.6
|
||||
assert expiry_breakeven_px(opt_type="P", strike=3500, avg_px=15.6) == 3484.4
|
||||
|
||||
|
||||
def test_expiry_breakeven_coin_margin():
|
||||
from lib.options.options_pricing_lib import expiry_breakeven_from_ask, expiry_breakeven_px
|
||||
|
||||
# ETH-USD 币本位:卖一 0.0165 → 到期平衡 K/(1-p),非 K+p
|
||||
assert expiry_breakeven_px(
|
||||
opt_type="C", strike=2390, avg_px=0.0165, margin_mode="coin"
|
||||
) == round(2390 / (1 - 0.0165), 2)
|
||||
assert expiry_breakeven_px(
|
||||
opt_type="P", strike=2450, avg_px=0.0161, margin_mode="coin"
|
||||
) == round(2450 / (1 + 0.0161), 2)
|
||||
assert expiry_breakeven_from_ask(
|
||||
opt_type="C",
|
||||
strike=2425,
|
||||
ask_px=0.0187,
|
||||
inst_id="ETH-USD-260823-2425-C",
|
||||
) == round(2425 / (1 - 0.0187), 2)
|
||||
|
||||
|
||||
def test_strike_distance_to_be():
|
||||
from lib.options.options_pricing_lib import strike_distance_to_be
|
||||
|
||||
assert strike_distance_to_be(2390, 2430, opt_type="C") == 40.0
|
||||
assert strike_distance_to_be(2450, 2411, opt_type="P") == 39.0
|
||||
|
||||
|
||||
def test_straddle_breakeven_band_coin():
|
||||
from lib.options.options_pricing_lib import straddle_breakeven_band
|
||||
|
||||
lo, hi = straddle_breakeven_band(
|
||||
2425,
|
||||
quote_in_coin=True,
|
||||
call_ask=0.0187,
|
||||
put_ask=0.0253,
|
||||
)
|
||||
assert lo == round(2425 / (1 + 0.0253), 2)
|
||||
assert hi == round(2425 / (1 - 0.0187), 2)
|
||||
|
||||
|
||||
def test_close_breakeven_at_mark_equals_avg():
|
||||
from lib.options.options_pricing_lib import close_breakeven_idx
|
||||
|
||||
assert close_breakeven_idx(
|
||||
opt_type="C", idx_px=3480, mark_px=15.6, avg_px=15.6
|
||||
) == 3480.0
|
||||
assert close_breakeven_idx(
|
||||
opt_type="P", idx_px=3480, mark_px=15.6, avg_px=15.6
|
||||
) == 3480.0
|
||||
|
||||
|
||||
def test_close_breakeven_with_delta():
|
||||
from lib.options.options_pricing_lib import close_breakeven_idx
|
||||
|
||||
# mark below avg, delta 0.5 ETH on 0.5 ETH position -> slope 1
|
||||
be = close_breakeven_idx(
|
||||
opt_type="C",
|
||||
idx_px=3480,
|
||||
mark_px=14.6,
|
||||
avg_px=15.6,
|
||||
delta_pa=0.5,
|
||||
pos=50,
|
||||
ct_mult=0.01,
|
||||
)
|
||||
assert be == 3481.0
|
||||
|
||||
|
||||
def test_format_options_breakeven_line():
|
||||
from lib.options.options_pricing_lib import format_options_breakeven_line
|
||||
|
||||
s = format_options_breakeven_line(
|
||||
expiry_be_px=3515.6, close_be_px=3498.0, idx_px=3480.0
|
||||
)
|
||||
assert "到期平衡3516" in s
|
||||
assert "平掉回本3498" in s
|
||||
assert "指数3480" in s
|
||||
|
||||
|
||||
def test_format_position_row_premium_and_inst_parse():
|
||||
from lib.exchange.okx_options_lib import format_position_row
|
||||
|
||||
row = format_position_row(
|
||||
{
|
||||
"instId": "ETH-USD_UM-260709-1700-P",
|
||||
"pos": "20",
|
||||
"avgPx": "6.2",
|
||||
"markPx": "6.3241",
|
||||
"idxPx": "1746",
|
||||
"upl": "0.0248",
|
||||
"uplRatio": "0.02",
|
||||
}
|
||||
)
|
||||
assert row["opt_type"] == "P"
|
||||
assert row["strike"] == 1700.0
|
||||
assert row["premium_paid"] == 1.24
|
||||
assert row["exp_time_ms"] is not None
|
||||
assert row["exp_time_ms"] > 0
|
||||
|
||||
|
||||
def test_expiry_ms_from_inst_id():
|
||||
from lib.exchange.okx_options_lib import expiry_ms_from_inst_id, normalize_option_exp_ms
|
||||
|
||||
ms = expiry_ms_from_inst_id("ETH-USD_UM-260709-1700-P")
|
||||
assert ms is not None
|
||||
from datetime import datetime, timezone
|
||||
|
||||
dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
|
||||
assert dt.year == 2026 and dt.month == 7 and dt.day == 9 and dt.hour == 8
|
||||
assert normalize_option_exp_ms(None, "ETH-USD_UM-260709-1700-P") == ms
|
||||
|
||||
|
||||
def test_format_position_row_breakeven():
|
||||
from lib.exchange.okx_options_lib import format_position_row
|
||||
|
||||
row = format_position_row(
|
||||
{
|
||||
"instId": "ETH-USD_UM-260703-1800-C",
|
||||
"pos": "50",
|
||||
"avgPx": "15.6",
|
||||
"markPx": "16.2",
|
||||
"idxPx": "3480",
|
||||
"bePx": "3515.6",
|
||||
"optType": "C",
|
||||
"stk": "3500",
|
||||
"deltaPA": "0.45",
|
||||
"upl": "0.3",
|
||||
"uplRatio": "0.02",
|
||||
}
|
||||
)
|
||||
assert row["expiry_be_px"] == 3515.6
|
||||
assert row["idx_px"] == 3480.0
|
||||
assert row["close_be_px"] is not None
|
||||
assert row["dist_expiry_be"] == 15.6
|
||||
@@ -0,0 +1,65 @@
|
||||
"""单独期权翻倍出场命中条件."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_profit_exit_lib import (
|
||||
normalize_profit_exit_mult,
|
||||
profit_exit_by_inst,
|
||||
profit_exit_hit,
|
||||
required_recycle_usdc,
|
||||
set_profit_exit,
|
||||
)
|
||||
|
||||
|
||||
class TestOptionsProfitExit(unittest.TestCase):
|
||||
def test_hit_one_x_means_profit_equals_premium(self):
|
||||
# 1倍:盈利=权利金 ⇒ 回收≥2×权利金
|
||||
self.assertTrue(profit_exit_hit(premium_paid=10.0, recycle_usdc=20.0, mult=1.0))
|
||||
self.assertFalse(profit_exit_hit(premium_paid=10.0, recycle_usdc=19.9, mult=1.0))
|
||||
self.assertEqual(required_recycle_usdc(10.0, 1.0), 20.0)
|
||||
|
||||
def test_hit_two_x(self):
|
||||
self.assertTrue(profit_exit_hit(premium_paid=10.0, recycle_usdc=30.0, mult=2.0))
|
||||
self.assertFalse(profit_exit_hit(premium_paid=10.0, recycle_usdc=29.9, mult=2.0))
|
||||
|
||||
def test_normalize_mult(self):
|
||||
self.assertEqual(normalize_profit_exit_mult(None), 1.0)
|
||||
self.assertEqual(normalize_profit_exit_mult(0), 1.0)
|
||||
self.assertEqual(normalize_profit_exit_mult("1.5"), 1.5)
|
||||
|
||||
def test_set_and_clear(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db = Path(td) / "t.db"
|
||||
conn = sqlite3.connect(str(db))
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status)
|
||||
VALUES ('ETH-X', 'ETH', 'C', 1, 0.01, 10.0, 'open')
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
out = set_profit_exit(conn, inst_id="ETH-X", enabled=True, mult=1.5)
|
||||
self.assertTrue(out["ok"])
|
||||
conn.commit()
|
||||
m = profit_exit_by_inst(conn)
|
||||
self.assertTrue(m["ETH-X"]["profit_exit_enabled"])
|
||||
self.assertEqual(m["ETH-X"]["profit_exit_mult"], 1.5)
|
||||
self.assertEqual(m["ETH-X"]["required_recycle"], 25.0)
|
||||
out2 = set_profit_exit(conn, inst_id="ETH-X", enabled=False, mult=1.5)
|
||||
self.assertTrue(out2["ok"])
|
||||
conn.commit()
|
||||
m2 = profit_exit_by_inst(conn)
|
||||
self.assertNotIn("ETH-X", m2)
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,39 @@
|
||||
"""翻倍出场:买一价×全仓张数判定达标(不受买一深度截断)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from lib.options.options_profit_exit_lib import _estimate_recycle, profit_exit_hit
|
||||
|
||||
|
||||
class ProfitExitHitTests(unittest.TestCase):
|
||||
def test_hit_at_0_3_mult(self):
|
||||
self.assertTrue(profit_exit_hit(premium_paid=1.0, recycle_usdc=1.3, mult=0.3))
|
||||
self.assertFalse(profit_exit_hit(premium_paid=1.0, recycle_usdc=1.29, mult=0.3))
|
||||
|
||||
def test_estimate_uses_full_sheets_not_depth(self):
|
||||
pos = {"inst_id": "ETH-USD-260927-2700-P", "pos": 100, "ct_mult": 0.01}
|
||||
preview = {
|
||||
"bid_invalid": False,
|
||||
"levels": [{"px": 0.2, "sheets": 10}],
|
||||
"total_received": 0.02,
|
||||
}
|
||||
|
||||
def fake_attach(cfg, ex, row, premium_paid=None):
|
||||
row["close_preview"] = preview
|
||||
row["ct_mult"] = 0.01
|
||||
row["pos"] = 100
|
||||
return row
|
||||
|
||||
with patch(
|
||||
"lib.options.options_positions_lib.attach_close_preview",
|
||||
side_effect=fake_attach,
|
||||
):
|
||||
recv = _estimate_recycle({}, object(), pos, 1.0)
|
||||
self.assertAlmostEqual(float(recv), 0.2, places=6)
|
||||
self.assertTrue(profit_exit_hit(premium_paid=0.15, recycle_usdc=float(recv), mult=0.3))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,157 @@
|
||||
"""期权复盘自动 K 线:单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from lib.options.options_review_chart_async_lib import (
|
||||
OPTIONS_REVIEW_AUTO_CHART_TF,
|
||||
build_options_review_chart_filename,
|
||||
merge_auto_chart_into_images,
|
||||
schedule_options_review_exchange_chart,
|
||||
underlying_to_okx_perp_symbol,
|
||||
)
|
||||
from lib.options.options_review_db import init_options_review_tables
|
||||
from lib.options.options_review_lib import save_review_entry, upsert_option_history_row
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
c = sqlite3.connect(":memory:")
|
||||
c.row_factory = sqlite3.Row
|
||||
init_options_review_tables(c)
|
||||
return c
|
||||
|
||||
|
||||
class OptionsReviewChartAsyncTests(unittest.TestCase):
|
||||
def test_underlying_to_okx_perp_symbol(self):
|
||||
self.assertEqual(
|
||||
underlying_to_okx_perp_symbol(
|
||||
"ETH-USD",
|
||||
normalize_symbol_input_fn=lambda s: f"{s}/USDT",
|
||||
normalize_okx_symbol_fn=lambda s: f"{s.split('/')[0]}/USDT:USDT"
|
||||
if "/" in s
|
||||
else s,
|
||||
),
|
||||
"ETH/USDT:USDT",
|
||||
)
|
||||
|
||||
def test_merge_auto_chart_keeps_manual_slots(self):
|
||||
merged = merge_auto_chart_into_images(
|
||||
[
|
||||
{"tf": "auto", "file": "old.png"},
|
||||
{"tf": "15m", "file": "m15.png"},
|
||||
],
|
||||
"new_auto.png",
|
||||
)
|
||||
self.assertEqual(merged[0]["tf"], OPTIONS_REVIEW_AUTO_CHART_TF)
|
||||
self.assertEqual(merged[0]["file"], "new_auto.png")
|
||||
self.assertEqual(merged[1], {"tf": "15m", "file": "m15.png"})
|
||||
|
||||
def test_chart_filename(self):
|
||||
self.assertEqual(
|
||||
build_options_review_chart_filename(12), "options_journal_chart_12.png"
|
||||
)
|
||||
|
||||
@patch("lib.options.options_review_chart_async_lib.threading.Thread")
|
||||
def test_schedule_starts_daemon_thread(self, mock_thread_cls):
|
||||
thread = MagicMock()
|
||||
mock_thread_cls.return_value = thread
|
||||
schedule_options_review_exchange_chart(
|
||||
trade_id=7,
|
||||
exchange_symbol="ETH/USDT:USDT",
|
||||
title_prefix="t",
|
||||
journal_tfs=["15m", "1h"],
|
||||
journal_limit=100,
|
||||
marker_payload={},
|
||||
upload_folder="/tmp",
|
||||
generate_chart_fn=MagicMock(),
|
||||
get_db_fn=MagicMock(),
|
||||
now_str_fn=lambda: "2026-09-15 12:00:00",
|
||||
)
|
||||
mock_thread_cls.assert_called_once()
|
||||
self.assertTrue(mock_thread_cls.call_args.kwargs.get("daemon"))
|
||||
thread.start.assert_called_once()
|
||||
|
||||
def test_writeback_merges_auto_image(self):
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from lib.options.options_review_images_lib import parse_options_review_images_json
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = Path(td) / "t.db"
|
||||
|
||||
def get_db():
|
||||
c = sqlite3.connect(str(db_path))
|
||||
c.row_factory = sqlite3.Row
|
||||
return c
|
||||
|
||||
conn = get_db()
|
||||
init_options_review_tables(conn)
|
||||
upsert_option_history_row(
|
||||
conn,
|
||||
{
|
||||
"history_key": "ex:chart1",
|
||||
"inst_id": "ETH-USD-260328-2000-C",
|
||||
"underlying": "ETH",
|
||||
"opt_type": "C",
|
||||
"realized_pnl": 1.0,
|
||||
"created_at": "2026-09-01 10:00:00",
|
||||
"closed_at": "2026-09-01 12:00:00",
|
||||
},
|
||||
)
|
||||
trade_id = conn.execute(
|
||||
"SELECT id FROM options_review_trades WHERE history_key='ex:chart1'"
|
||||
).fetchone()["id"]
|
||||
save_review_entry(
|
||||
conn,
|
||||
trade_id,
|
||||
{
|
||||
"strategy_tag": "测试",
|
||||
"images": [{"tf": "15m", "file": "options_journal_x_15m.png"}],
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
chart_name = build_options_review_chart_filename(trade_id)
|
||||
generate = MagicMock(return_value=chart_name)
|
||||
|
||||
with patch(
|
||||
"lib.options.options_review_chart_async_lib.threading.Thread"
|
||||
) as mock_thread_cls:
|
||||
thread = MagicMock()
|
||||
mock_thread_cls.return_value = thread
|
||||
schedule_options_review_exchange_chart(
|
||||
trade_id=trade_id,
|
||||
exchange_symbol="ETH/USDT:USDT",
|
||||
title_prefix="ETH review",
|
||||
journal_tfs=["15m", "1h"],
|
||||
journal_limit=200,
|
||||
marker_payload={"entry_ts_ms": 1, "exit_ts_ms": 2},
|
||||
upload_folder=td,
|
||||
generate_chart_fn=generate,
|
||||
get_db_fn=get_db,
|
||||
now_str_fn=lambda: "2026-09-15 12:00:00",
|
||||
)
|
||||
target = mock_thread_cls.call_args.kwargs["target"]
|
||||
target()
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT images_json, image FROM options_review_entries WHERE trade_id=?",
|
||||
(trade_id,),
|
||||
).fetchone()
|
||||
self.assertEqual(row["image"], chart_name)
|
||||
images = parse_options_review_images_json(row["images_json"])
|
||||
self.assertEqual(images[0]["tf"], "auto")
|
||||
self.assertEqual(images[0]["file"], chart_name)
|
||||
self.assertTrue(any(x.get("tf") == "15m" for x in images))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,408 @@
|
||||
"""期权复盘(含对冲)单元测试:导入去重、双计防护、复盘不被覆盖、统计."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_leg, insert_plan
|
||||
from lib.options.options_review_db import SOURCE_OPTION, SOURCE_PERP_OPTIONS, init_options_review_tables
|
||||
from lib.options.options_review_images_lib import (
|
||||
build_options_review_slot_filename,
|
||||
is_valid_options_review_file,
|
||||
options_review_upload_dir,
|
||||
save_options_review_slot_file,
|
||||
)
|
||||
from lib.options.options_review_lib import (
|
||||
compute_review_stats,
|
||||
list_review_trades,
|
||||
save_review_entry,
|
||||
sync_hedge_plans_closed,
|
||||
sync_options_from_exchange,
|
||||
upsert_option_history_row,
|
||||
)
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
c = sqlite3.connect(":memory:")
|
||||
c.row_factory = sqlite3.Row
|
||||
init_options_review_tables(c)
|
||||
init_hedge_plan_tables(c)
|
||||
return c
|
||||
|
||||
|
||||
class _FakeFile:
|
||||
def __init__(self, name: str, data: bytes = b"img"):
|
||||
self.filename = name
|
||||
self._data = data
|
||||
|
||||
def save(self, path: str) -> None:
|
||||
Path(path).write_bytes(self._data)
|
||||
|
||||
|
||||
class OptionsReviewTests(unittest.TestCase):
|
||||
def test_option_upsert_idempotent(self):
|
||||
conn = _conn()
|
||||
row = {
|
||||
"history_key": "ex:pos1",
|
||||
"pos_id": "pos1",
|
||||
"inst_id": "ETH-USD-260328-2000-C",
|
||||
"underlying": "ETH",
|
||||
"opt_type": "C",
|
||||
"strike": 2000,
|
||||
"sheets": 10,
|
||||
"open_avg_px": 0.01,
|
||||
"close_avg_px": 0.02,
|
||||
"premium_paid": 1.0,
|
||||
"realized_pnl": 5.5,
|
||||
"created_at": "2026-03-01 10:00:00",
|
||||
"closed_at": "2026-03-01 12:00:00",
|
||||
"status_label": "已平",
|
||||
}
|
||||
self.assertEqual(upsert_option_history_row(conn, row), "inserted")
|
||||
row["realized_pnl"] = 6.0
|
||||
self.assertEqual(upsert_option_history_row(conn, row), "updated")
|
||||
n = conn.execute("SELECT COUNT(*) AS c FROM options_review_trades").fetchone()["c"]
|
||||
self.assertEqual(n, 1)
|
||||
pnl = conn.execute(
|
||||
"SELECT realized_pnl_total FROM options_review_trades WHERE history_key='ex:pos1'"
|
||||
).fetchone()["realized_pnl_total"]
|
||||
self.assertEqual(float(pnl), 6.0)
|
||||
|
||||
def test_entry_not_overwritten_by_resync(self):
|
||||
conn = _conn()
|
||||
upsert_option_history_row(
|
||||
conn,
|
||||
{
|
||||
"history_key": "ex:p2",
|
||||
"inst_id": "ETH-USD-260328-1800-P",
|
||||
"underlying": "ETH",
|
||||
"opt_type": "P",
|
||||
"realized_pnl": 1.0,
|
||||
"created_at": "2026-03-02 10:00:00",
|
||||
"closed_at": "2026-03-02 11:00:00",
|
||||
},
|
||||
)
|
||||
tid = conn.execute("SELECT id FROM options_review_trades").fetchone()["id"]
|
||||
save_review_entry(
|
||||
conn,
|
||||
tid,
|
||||
{"strategy_tag": "突破追涨", "note": "keep-me", "images": []},
|
||||
)
|
||||
upsert_option_history_row(
|
||||
conn,
|
||||
{
|
||||
"history_key": "ex:p2",
|
||||
"inst_id": "ETH-USD-260328-1800-P",
|
||||
"underlying": "ETH",
|
||||
"opt_type": "P",
|
||||
"realized_pnl": 2.0,
|
||||
"created_at": "2026-03-02 10:00:00",
|
||||
"closed_at": "2026-03-02 11:00:00",
|
||||
},
|
||||
)
|
||||
note = conn.execute(
|
||||
"SELECT note, strategy_tag FROM options_review_entries WHERE trade_id=?",
|
||||
(tid,),
|
||||
).fetchone()
|
||||
self.assertEqual(note["note"], "keep-me")
|
||||
self.assertEqual(note["strategy_tag"], "突破追涨")
|
||||
pnl = conn.execute(
|
||||
"SELECT realized_pnl_total FROM options_review_trades WHERE id=?", (tid,)
|
||||
).fetchone()["realized_pnl_total"]
|
||||
self.assertEqual(float(pnl), 2.0)
|
||||
|
||||
def test_hedge_import_and_double_count_guard(self):
|
||||
conn = _conn()
|
||||
upsert_option_history_row(
|
||||
conn,
|
||||
{
|
||||
"history_key": "ex:leg1",
|
||||
"inst_id": "ETH-USD-260328-2000-C",
|
||||
"underlying": "ETH",
|
||||
"opt_type": "C",
|
||||
"realized_pnl": -3.0,
|
||||
"created_at": "2026-03-03 09:00:00",
|
||||
"closed_at": "2026-03-03 18:00:00",
|
||||
},
|
||||
)
|
||||
plan_id = insert_plan(
|
||||
conn,
|
||||
{
|
||||
"plan_type": SOURCE_PERP_OPTIONS,
|
||||
"status": "closed",
|
||||
"underlying": "ETH",
|
||||
"direction": "long",
|
||||
"realized_pnl_perp": 20.0,
|
||||
"realized_pnl_options": -3.0,
|
||||
"realized_pnl_total": 17.0,
|
||||
"close_reason": "tp",
|
||||
"opened_at": "2026-03-03 09:00:00",
|
||||
"closed_at": "2026-03-03 18:00:00",
|
||||
"premium_total": 3.0,
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": plan_id,
|
||||
"leg_role": "perp",
|
||||
"symbol": "ETH-USDT-SWAP",
|
||||
"status": "closed",
|
||||
"realized_pnl": 20.0,
|
||||
},
|
||||
)
|
||||
insert_leg(
|
||||
conn,
|
||||
{
|
||||
"plan_id": plan_id,
|
||||
"leg_role": "option_hedge",
|
||||
"inst_id": "ETH-USD-260328-2000-C",
|
||||
"opt_type": "C",
|
||||
"status": "closed",
|
||||
"realized_pnl": -3.0,
|
||||
},
|
||||
)
|
||||
out = sync_hedge_plans_closed(conn)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(out["inserted"], 1)
|
||||
|
||||
listed = list_review_trades(conn, include_hedge_legs=False)
|
||||
types = {r["source_type"] for r in listed}
|
||||
self.assertIn(SOURCE_PERP_OPTIONS, types)
|
||||
self.assertNotIn(SOURCE_OPTION, types)
|
||||
|
||||
listed_all = list_review_trades(conn, include_hedge_legs=True)
|
||||
self.assertEqual(len(listed_all), 2)
|
||||
|
||||
stats = compute_review_stats(conn, include_hedge_legs=False)
|
||||
self.assertEqual(stats["kpi"]["total"], 1)
|
||||
self.assertEqual(stats["kpi"]["pnl_sum"], 17.0)
|
||||
|
||||
def test_sync_options_from_mock_exchange(self):
|
||||
conn = _conn()
|
||||
|
||||
def fetch(_ex, limit=500):
|
||||
return [
|
||||
{
|
||||
"instId": "ETH-USD-260328-2100-C",
|
||||
"posId": "mock1",
|
||||
"openAvgPx": "0.01",
|
||||
"closeAvgPx": "0.02",
|
||||
"closeTotalPos": "5",
|
||||
"realizedPnl": "1.23",
|
||||
"type": "2",
|
||||
"cTime": "1700000000000",
|
||||
"uTime": "1700003600000",
|
||||
"uly": "ETH-USD",
|
||||
}
|
||||
]
|
||||
|
||||
def fmt(raw, tick_sz=None, ct_mult=0.01):
|
||||
return {
|
||||
"history_key": f"ex:{raw['posId']}",
|
||||
"pos_id": raw["posId"],
|
||||
"inst_id": raw["instId"],
|
||||
"underlying": "ETH",
|
||||
"opt_type": "C",
|
||||
"sheets": 5,
|
||||
"open_avg_px": 0.01,
|
||||
"close_avg_px": 0.02,
|
||||
"premium_paid": 0.5,
|
||||
"realized_pnl": float(raw["realizedPnl"]),
|
||||
"created_at": "2026-01-01 00:00:00",
|
||||
"closed_at": "2026-01-01 01:00:00",
|
||||
"status_label": "已平",
|
||||
}
|
||||
|
||||
result = sync_options_from_exchange(
|
||||
conn, object(), limit=10, fetch_fn=fetch, format_fn=fmt
|
||||
)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["inserted"], 1)
|
||||
row = conn.execute(
|
||||
"SELECT realized_pnl_total FROM options_review_trades WHERE history_key='ex:mock1'"
|
||||
).fetchone()
|
||||
self.assertEqual(float(row["realized_pnl_total"]), 1.23)
|
||||
|
||||
def test_hide_trade_persists_across_local_sync(self):
|
||||
conn = _conn()
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_review_lib import (
|
||||
hide_review_trade,
|
||||
sync_options_from_local_trades,
|
||||
)
|
||||
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, sheets, eth_amount,
|
||||
open_quote, premium_paid, status, realized_pnl, created_at, closed_at)
|
||||
VALUES ('ETH-USD-1-C','ETH','C',2000,1,0.01,0.01,0.2,'closed',1.0,
|
||||
'2026-03-01 10:00:00','2026-03-01 11:00:00')
|
||||
"""
|
||||
)
|
||||
sync_options_from_local_trades(conn)
|
||||
tid = conn.execute("SELECT id FROM options_review_trades").fetchone()["id"]
|
||||
out = hide_review_trade(conn, tid)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT COUNT(*) AS c FROM options_review_trades").fetchone()["c"],
|
||||
0,
|
||||
)
|
||||
sync_options_from_local_trades(conn)
|
||||
self.assertEqual(
|
||||
conn.execute("SELECT COUNT(*) AS c FROM options_review_trades").fetchone()["c"],
|
||||
0,
|
||||
)
|
||||
|
||||
def test_local_options_trades_import(self):
|
||||
conn = _conn()
|
||||
from lib.options.options_db import init_options_tables
|
||||
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, sheets, eth_amount,
|
||||
open_quote, premium_paid, status, realized_pnl, created_at, closed_at)
|
||||
VALUES ('ETH-USD-260328-2000-C','ETH','C',2000,2,0.02,0.01,0.5,'closed',3.2,
|
||||
'2026-03-01 10:00:00','2026-03-01 12:00:00')
|
||||
"""
|
||||
)
|
||||
from lib.options.options_review_lib import sync_options_from_local_trades
|
||||
|
||||
out = sync_options_from_local_trades(conn)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(out["inserted"], 1)
|
||||
row = conn.execute(
|
||||
"SELECT history_key, realized_pnl_total, source_type FROM options_review_trades"
|
||||
).fetchone()
|
||||
self.assertTrue(str(row["history_key"]).startswith("local_opt:"))
|
||||
self.assertEqual(float(row["realized_pnl_total"]), 3.2)
|
||||
self.assertEqual(row["source_type"], SOURCE_OPTION)
|
||||
|
||||
def test_image_paths_resolve_legacy_journal_root(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
# 误存到 UPLOAD 根目录的 journal_*
|
||||
legacy = root / "journal_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_5m.png"
|
||||
legacy.write_bytes(b"img")
|
||||
# 正常 options_journal 子目录
|
||||
sub = root / "options_journal"
|
||||
sub.mkdir()
|
||||
modern = sub / "options_journal_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb_5m.png"
|
||||
modern.write_bytes(b"img2")
|
||||
from lib.options.options_review_images_lib import options_review_image_paths
|
||||
|
||||
class Row:
|
||||
images_json = (
|
||||
'[{"tf":"5m","file":"journal_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_5m.png"},'
|
||||
'{"tf":"5m","file":"options_journal_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb_5m.png"}]'
|
||||
)
|
||||
image = None
|
||||
|
||||
paths = options_review_image_paths(Row(), str(root))
|
||||
self.assertEqual(len(paths), 2)
|
||||
self.assertTrue(any(p.endswith(legacy.name) for p in paths))
|
||||
self.assertTrue(any(p.endswith(modern.name) for p in paths))
|
||||
|
||||
def test_image_namespace(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
folder = options_review_upload_dir(tmp)
|
||||
fname = build_options_review_slot_filename(
|
||||
"a" * 32, "5m", ".png", secure_filename_fn=lambda x: x
|
||||
)
|
||||
self.assertTrue(fname.startswith("options_journal_"))
|
||||
self.assertTrue(is_valid_options_review_file(fname, "a" * 32, "5m"))
|
||||
item = save_options_review_slot_file(
|
||||
_FakeFile("x.png"),
|
||||
"a" * 32,
|
||||
"5m",
|
||||
folder,
|
||||
secure_filename_fn=lambda x: x,
|
||||
)
|
||||
self.assertIsNotNone(item)
|
||||
self.assertTrue((Path(folder) / item["file"]).is_file())
|
||||
|
||||
def test_strategy_stats_only_tagged(self):
|
||||
conn = _conn()
|
||||
upsert_option_history_row(
|
||||
conn,
|
||||
{
|
||||
"history_key": "ex:a",
|
||||
"inst_id": "ETH-USD-1-C",
|
||||
"underlying": "ETH",
|
||||
"opt_type": "C",
|
||||
"realized_pnl": 10,
|
||||
"created_at": "2026-01-01 00:00:00",
|
||||
"closed_at": "2026-01-01 02:00:00",
|
||||
},
|
||||
)
|
||||
upsert_option_history_row(
|
||||
conn,
|
||||
{
|
||||
"history_key": "ex:b",
|
||||
"inst_id": "ETH-USD-2-P",
|
||||
"underlying": "ETH",
|
||||
"opt_type": "P",
|
||||
"realized_pnl": -4,
|
||||
"created_at": "2026-01-01 00:00:00",
|
||||
"closed_at": "2026-01-01 05:00:00",
|
||||
},
|
||||
)
|
||||
tid = conn.execute(
|
||||
"SELECT id FROM options_review_trades WHERE history_key='ex:a'"
|
||||
).fetchone()["id"]
|
||||
save_review_entry(conn, tid, {"strategy_tag": "假破", "images": []})
|
||||
stats = compute_review_stats(conn)
|
||||
self.assertEqual(len(stats["by_strategy"]), 1)
|
||||
self.assertEqual(stats["by_strategy"][0]["key"], "假破")
|
||||
self.assertEqual(stats["kpi"]["total"], 2)
|
||||
|
||||
def test_q_search_btcusdt_matches_btc_pending(self):
|
||||
from lib.options.options_review_lib import count_review_trades
|
||||
|
||||
conn = _conn()
|
||||
upsert_option_history_row(
|
||||
conn,
|
||||
{
|
||||
"history_key": "ex:btc1",
|
||||
"inst_id": "BTC-USD-260328-90000-C",
|
||||
"underlying": "BTC",
|
||||
"opt_type": "C",
|
||||
"realized_pnl": 1.2,
|
||||
"created_at": "2026-01-01 00:00:00",
|
||||
"closed_at": "2026-01-01 02:00:00",
|
||||
},
|
||||
)
|
||||
upsert_option_history_row(
|
||||
conn,
|
||||
{
|
||||
"history_key": "ex:eth1",
|
||||
"inst_id": "ETH-USD-260328-2000-C",
|
||||
"underlying": "ETH",
|
||||
"opt_type": "C",
|
||||
"realized_pnl": 2.0,
|
||||
"created_at": "2026-01-01 00:00:00",
|
||||
"closed_at": "2026-01-01 03:00:00",
|
||||
},
|
||||
)
|
||||
# 旧精确 strategy_tag 会把待复盘滤成空
|
||||
self.assertEqual(
|
||||
count_review_trades(conn, strategy_tag="BTCUSDT", reviewed="0"),
|
||||
0,
|
||||
)
|
||||
self.assertEqual(
|
||||
count_review_trades(conn, q="BTCUSDT", reviewed="0"),
|
||||
1,
|
||||
)
|
||||
rows = list_review_trades(conn, q="BTCUSDT", reviewed="0")
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["underlying"], "BTC")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""期权统计单测."""
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from unittest import TestCase
|
||||
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_stats_lib import compute_options_stats, compute_options_stats_from_history
|
||||
|
||||
|
||||
class OptionsStatsLibTests(TestCase):
|
||||
def _conn(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
return conn
|
||||
|
||||
def test_compute_options_stats_empty(self):
|
||||
conn = self._conn()
|
||||
out = compute_options_stats(lambda: conn)
|
||||
self.assertEqual(out["total_closed"], 0)
|
||||
self.assertEqual(out["open_count"], 0)
|
||||
self.assertIsNone(out["avg_hold_sec"])
|
||||
|
||||
def test_compute_options_stats_hold_times(self):
|
||||
conn = self._conn()
|
||||
now = datetime.now()
|
||||
win_open = (now - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
win_close = (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
loss_open = (now - timedelta(hours=4)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
loss_close = (now - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
open_at = (now - timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, sheets, eth_amount, status,
|
||||
realized_pnl, created_at, closed_at)
|
||||
VALUES ('A', 'ETH', 'C', 1800, 1, 0.01, 'closed', 1.2, ?, ?)
|
||||
""",
|
||||
(win_open, win_close),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, sheets, eth_amount, status,
|
||||
realized_pnl, created_at, closed_at)
|
||||
VALUES ('B', 'ETH', 'P', 1700, 1, 0.01, 'closed', -1.0, ?, ?)
|
||||
""",
|
||||
(loss_open, loss_close),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, sheets, eth_amount, status, created_at)
|
||||
VALUES ('C', 'BTC', 'C', 62000, 1, 0.01, 'open', ?)
|
||||
""",
|
||||
(open_at,),
|
||||
)
|
||||
conn.commit()
|
||||
out = compute_options_stats(lambda: conn)
|
||||
self.assertEqual(out["total_closed"], 2)
|
||||
self.assertEqual(out["win_count"], 1)
|
||||
self.assertEqual(out["loss_count"], 1)
|
||||
self.assertEqual(out["win_rate"], 50.0)
|
||||
self.assertAlmostEqual(out["avg_win"], 1.2, places=4)
|
||||
self.assertAlmostEqual(out["avg_loss"], 1.0, places=4)
|
||||
self.assertAlmostEqual(out["avg_win_hold_sec"], 3600.0, delta=5.0)
|
||||
self.assertAlmostEqual(out["avg_loss_hold_sec"], 3 * 3600.0, delta=5.0)
|
||||
self.assertEqual(out["open_count"], 1)
|
||||
self.assertGreater(out["avg_open_hold_sec"], 1700.0)
|
||||
|
||||
def test_compute_options_stats_from_history_exchange_rows(self):
|
||||
history = [
|
||||
{"status": "open", "created_at": "2026-07-11 08:08:38"},
|
||||
{"status": "closed", "realized_pnl": -3.99, "created_at": "2026-07-09 14:11:46", "closed_at": "2026-07-10 16:00:35"},
|
||||
{"status": "closed", "realized_pnl": 0.87, "created_at": "2026-07-09 14:11:46", "closed_at": "2026-07-10 09:55:34"},
|
||||
{"status": "closed", "realized_pnl": -1.33, "created_at": "2026-07-08 02:32:44", "closed_at": "2026-07-09 16:00:26"},
|
||||
]
|
||||
out = compute_options_stats_from_history(history)
|
||||
self.assertEqual(out["total_closed"], 3)
|
||||
self.assertEqual(out["win_count"], 1)
|
||||
self.assertEqual(out["loss_count"], 2)
|
||||
self.assertAlmostEqual(out["avg_win"], 0.87, places=4)
|
||||
self.assertAlmostEqual(out["avg_loss"], 2.66, places=2)
|
||||
self.assertAlmostEqual(out["profit_loss_ratio"], 0.33, places=2)
|
||||
self.assertEqual(out["open_count"], 1)
|
||||
self.assertAlmostEqual(out["net_realized_pnl"], round(0.87 - 3.99 - 1.33, 4), places=4)
|
||||
|
||||
def test_compute_options_stats_coin_to_usdt(self):
|
||||
history = [
|
||||
{
|
||||
"status": "closed",
|
||||
"realized_pnl": 0.00078,
|
||||
"premium_ccy": "ETH",
|
||||
"margin_mode": "coin",
|
||||
"inst_id": "ETH-USD-260822-2250-C",
|
||||
"idx_px": 2280,
|
||||
"created_at": "2026-08-20 08:00:00",
|
||||
"closed_at": "2026-08-20 13:32:00",
|
||||
}
|
||||
]
|
||||
out = compute_options_stats_from_history(history)
|
||||
self.assertEqual(out["pnl_unit"], "U")
|
||||
self.assertEqual(out["total_closed"], 1)
|
||||
self.assertAlmostEqual(out["net_realized_pnl"], 0.00078 * 2280, places=4)
|
||||
@@ -0,0 +1,310 @@
|
||||
"""期权平仓/到期状态同步单测."""
|
||||
import sqlite3
|
||||
|
||||
from lib.exchange.okx_options_lib import (
|
||||
format_option_history_row,
|
||||
format_usdc_amount,
|
||||
is_option_full_close_history,
|
||||
resolve_option_close_from_history,
|
||||
)
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_monitor_lib import sync_open_options_trades
|
||||
|
||||
|
||||
def test_format_usdc_amount():
|
||||
assert format_usdc_amount(4.896) == "4.90"
|
||||
assert format_usdc_amount(4.9) == "4.90"
|
||||
assert format_usdc_amount(4.0) == "4.00"
|
||||
|
||||
|
||||
def test_is_option_full_close_history():
|
||||
assert is_option_full_close_history({"type": "2"})
|
||||
assert is_option_full_close_history({"type": "3"})
|
||||
assert not is_option_full_close_history({"type": "1"})
|
||||
assert not is_option_full_close_history({"type": "5"})
|
||||
|
||||
|
||||
def test_format_option_history_row():
|
||||
raw = {
|
||||
"instId": "BTC-USD_UM-260710-62000-P",
|
||||
"openAvgPx": "380",
|
||||
"closeAvgPx": "0",
|
||||
"closeTotalPos": "1",
|
||||
"openMaxPos": "1",
|
||||
"realizedPnl": "-3.99",
|
||||
"pnlRatio": "-1.049",
|
||||
"type": "2",
|
||||
"cTime": "1784000000000",
|
||||
"uTime": "1784088035000",
|
||||
"posId": "pos-btc",
|
||||
}
|
||||
row = format_option_history_row(raw, tick_sz="0.1", ct_mult=0.01)
|
||||
assert row["inst_id"] == "BTC-USD_UM-260710-62000-P"
|
||||
assert row["sheets"] == 1
|
||||
assert row["realized_pnl"] == -3.99
|
||||
assert row["status_label"] == "已平"
|
||||
assert row["open_avg_px_fmt"] == "380"
|
||||
assert row["premium_paid_fmt"] == "3.80"
|
||||
assert row["history_key"] == "ex:pos-btc:1784088035000"
|
||||
|
||||
|
||||
def test_resolve_option_close_from_history_picks_latest():
|
||||
rows = [
|
||||
{"instId": "ETH-USD_UM-260709-1700-P", "uTime": "1000", "realizedPnl": "-1.0", "closeAvgPx": "0"},
|
||||
{"instId": "ETH-USD_UM-260709-1700-P", "uTime": "2000", "realizedPnl": "-1.24", "closeAvgPx": "0", "posId": "9"},
|
||||
]
|
||||
got = resolve_option_close_from_history(rows, open_ms=500)
|
||||
assert got is not None
|
||||
assert got["realized_pnl"] == -1.24
|
||||
assert got["pos_id"] == "9"
|
||||
|
||||
|
||||
def test_resolve_option_close_from_history_matches_open_and_sheets():
|
||||
rows = [
|
||||
{
|
||||
"instId": "ETH-USD_UM-260806-1875-C",
|
||||
"cTime": "1785932775047",
|
||||
"uTime": "1785933936733",
|
||||
"realizedPnl": "-3.036",
|
||||
"closeAvgPx": "12.4",
|
||||
"closeTotalPos": "57",
|
||||
"posId": "same-pos",
|
||||
},
|
||||
{
|
||||
"instId": "ETH-USD_UM-260806-1875-C",
|
||||
"cTime": "1785938392445",
|
||||
"uTime": "1785957764279",
|
||||
"realizedPnl": "16.937",
|
||||
"closeAvgPx": "41.0",
|
||||
"closeTotalPos": "66",
|
||||
"posId": "same-pos",
|
||||
},
|
||||
]
|
||||
# 本地时间相对交易所偏 8h 时,仍应按 cTime/张数对齐到正确一笔
|
||||
early = resolve_option_close_from_history(
|
||||
rows, open_ms=1785902775000, close_ms=1785903937000, sheets=57
|
||||
)
|
||||
late = resolve_option_close_from_history(
|
||||
rows, open_ms=1785908392000, close_ms=1785927764000, sheets=66
|
||||
)
|
||||
assert early is not None and early["realized_pnl"] == -3.036
|
||||
assert early["close_quote"] == 12.4
|
||||
assert late is not None and late["realized_pnl"] == 16.937
|
||||
assert late["close_quote"] == 41.0
|
||||
|
||||
|
||||
def test_sync_open_options_trades_marks_expired_closed():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, premium_paid, status)
|
||||
VALUES (?, 'ETH', 'P', 1700, '', 20, 0.2, 6.2, 1.24, 'open')
|
||||
""",
|
||||
("ETH-USD_UM-260709-1700-P",),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
n = sync_open_options_trades(
|
||||
conn,
|
||||
live_inst_ids=set(),
|
||||
fetch_history_fn=lambda _inst: [],
|
||||
)
|
||||
assert n == 1
|
||||
row = conn.execute("SELECT status, premium_received, realized_pnl, signal_note FROM options_trades").fetchone()
|
||||
assert row["status"] == "closed"
|
||||
assert row["premium_received"] == 0.0
|
||||
assert row["realized_pnl"] == -1.24
|
||||
assert "到期结算" in (row["signal_note"] or "")
|
||||
|
||||
|
||||
def test_sync_open_options_trades_skips_without_close_evidence():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, premium_paid, status, created_at)
|
||||
VALUES (?, 'BTC', 'P', 62000, '', 1, 0.01, 380.0, 3.8, 'open', '2026-08-05 08:00:00')
|
||||
""",
|
||||
("BTC-USD_UM-261231-62000-P",),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
n = sync_open_options_trades(
|
||||
conn,
|
||||
live_inst_ids=set(),
|
||||
fetch_history_fn=lambda _inst: [],
|
||||
)
|
||||
assert n == 0
|
||||
row = conn.execute("SELECT status FROM options_trades").fetchone()
|
||||
assert row["status"] == "open"
|
||||
|
||||
|
||||
def test_reconcile_live_open_trades_reopens_sync_artifact():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, premium_paid, status, closed_at)
|
||||
VALUES (?, 'BTC', 'P', 62000, '', 1, 0.01, 380.0, 3.8, 'closed', '2026-07-09 09:10:34')
|
||||
""",
|
||||
("BTC-USD_UM-260710-62000-P",),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
from lib.options.options_monitor_lib import reconcile_live_open_trades
|
||||
|
||||
n = reconcile_live_open_trades(conn, live_inst_ids={"BTC-USD_UM-260710-62000-P"})
|
||||
assert n == 1
|
||||
row = conn.execute("SELECT status, closed_at FROM options_trades").fetchone()
|
||||
assert row["status"] == "open"
|
||||
assert row["closed_at"] is None
|
||||
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, premium_paid, status, created_at)
|
||||
VALUES (?, 'ETH', 'P', 1700, '', 20, 0.2, 6.2, 1.24, 'open', '2026-07-08 02:32:44')
|
||||
""",
|
||||
("ETH-USD_UM-260709-1700-P",),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _hist(_inst):
|
||||
return [
|
||||
{
|
||||
"instId": "ETH-USD_UM-260709-1700-P",
|
||||
"uTime": "1784000000000",
|
||||
"realizedPnl": "-0.5",
|
||||
"closeAvgPx": "0.1",
|
||||
"posId": "pos-1",
|
||||
}
|
||||
]
|
||||
|
||||
n = sync_open_options_trades(
|
||||
conn,
|
||||
live_inst_ids=set(),
|
||||
fetch_history_fn=_hist,
|
||||
)
|
||||
assert n == 1
|
||||
row = conn.execute(
|
||||
"SELECT status, premium_received, realized_pnl, close_ord_id FROM options_trades"
|
||||
).fetchone()
|
||||
assert row["status"] == "closed"
|
||||
assert row["realized_pnl"] == -0.5
|
||||
assert row["premium_received"] == 0.74
|
||||
assert row["close_ord_id"] == "pos-1"
|
||||
|
||||
|
||||
def test_backfill_closed_options_realized_pnl_from_history():
|
||||
from lib.options.options_monitor_lib import backfill_closed_options_realized_pnl_from_history
|
||||
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, close_quote, premium_paid, premium_received, realized_pnl,
|
||||
status, created_at, closed_at, close_ord_id)
|
||||
VALUES (?, 'ETH', 'C', 1860, '', 43, 0.43, 22.4, 50.6, 9.632, 21.758, 12.126,
|
||||
'closed', '2026-07-20 08:02:14', '2026-07-21 01:38:43', 'ord-1')
|
||||
""",
|
||||
("ETH-USD_UM-260721-1860-C",),
|
||||
)
|
||||
conn.commit()
|
||||
hist = [
|
||||
{
|
||||
"instId": "ETH-USD_UM-260721-1860-C",
|
||||
"uTime": "1784564323000",
|
||||
"realizedPnl": "11.64",
|
||||
"closeAvgPx": "48.5",
|
||||
"closeTotalPos": "43",
|
||||
"posId": "pos-x",
|
||||
}
|
||||
]
|
||||
n = backfill_closed_options_realized_pnl_from_history(conn, hist)
|
||||
assert n == 1
|
||||
row = conn.execute(
|
||||
"SELECT realized_pnl, premium_received, close_quote FROM options_trades WHERE id=1"
|
||||
).fetchone()
|
||||
assert row["realized_pnl"] == 11.64
|
||||
assert abs(float(row["premium_received"]) - (9.632 + 11.64)) < 1e-6
|
||||
assert float(row["close_quote"]) == 48.5
|
||||
# idempotent
|
||||
assert backfill_closed_options_realized_pnl_from_history(conn, hist) == 0
|
||||
|
||||
|
||||
def test_backfill_does_not_overwrite_earlier_close_with_later_pnl():
|
||||
from lib.options.options_monitor_lib import backfill_closed_options_realized_pnl_from_history
|
||||
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, close_quote, premium_paid, realized_pnl, status, created_at, closed_at)
|
||||
VALUES (?, 'ETH', 'C', 1875, '', 57, 0.57, 16.6, 41.0, 9.462, 16.9368,
|
||||
'closed', '2026-08-05 12:26:15', '2026-08-05 12:45:37')
|
||||
""",
|
||||
("ETH-USD_UM-260806-1875-C",),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, close_quote, premium_paid, realized_pnl, status, created_at, closed_at)
|
||||
VALUES (?, 'ETH', 'C', 1875, '', 66, 0.66, 14.2, 41.0, 9.372, 16.9368,
|
||||
'closed', '2026-08-05 13:59:52', '2026-08-05 19:22:45')
|
||||
""",
|
||||
("ETH-USD_UM-260806-1875-C",),
|
||||
)
|
||||
conn.commit()
|
||||
hist = [
|
||||
{
|
||||
"instId": "ETH-USD_UM-260806-1875-C",
|
||||
"uTime": "1785933936733",
|
||||
"cTime": "1785932775047",
|
||||
"realizedPnl": "-3.03616314",
|
||||
"closeAvgPx": "12.4",
|
||||
"closeTotalPos": "57",
|
||||
"posId": "3806091806281486337",
|
||||
},
|
||||
{
|
||||
"instId": "ETH-USD_UM-260806-1875-C",
|
||||
"uTime": "1785957764279",
|
||||
"cTime": "1785938392445",
|
||||
"realizedPnl": "16.9368375",
|
||||
"closeAvgPx": "41.0",
|
||||
"closeTotalPos": "66",
|
||||
"posId": "3806091806281486337",
|
||||
},
|
||||
]
|
||||
n = backfill_closed_options_realized_pnl_from_history(conn, hist)
|
||||
assert n >= 1
|
||||
rows = {
|
||||
int(r["id"]): r
|
||||
for r in conn.execute(
|
||||
"SELECT id, realized_pnl, close_quote FROM options_trades ORDER BY id"
|
||||
).fetchall()
|
||||
}
|
||||
assert abs(float(rows[1]["realized_pnl"]) - (-3.0362)) < 1e-3
|
||||
assert float(rows[1]["close_quote"]) == 12.4
|
||||
assert abs(float(rows[2]["realized_pnl"]) - 16.9368) < 1e-3
|
||||
assert float(rows[2]["close_quote"]) == 41.0
|
||||
@@ -0,0 +1,169 @@
|
||||
"""期权目标位委托单元测试."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from lib.options.options_target_lib import (
|
||||
ensure_target_tables,
|
||||
list_active_targets,
|
||||
list_closing_targets,
|
||||
run_options_target_closes,
|
||||
target_hit,
|
||||
upsert_target_monitor,
|
||||
)
|
||||
|
||||
|
||||
class OptionsTargetLibTests(unittest.TestCase):
|
||||
def test_target_hit_call_put(self):
|
||||
self.assertTrue(target_hit(opt_type="C", index_px=2000, target_index=1950))
|
||||
self.assertFalse(target_hit(opt_type="C", index_px=1900, target_index=1950))
|
||||
self.assertTrue(target_hit(opt_type="P", index_px=1800, target_index=1850))
|
||||
self.assertFalse(target_hit(opt_type="P", index_px=1900, target_index=1850))
|
||||
|
||||
def test_upsert_and_trigger_close(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
ensure_target_tables(conn)
|
||||
out = upsert_target_monitor(
|
||||
conn,
|
||||
inst_id="ETH-USD_UM-260717-1900-C",
|
||||
target_index=1880,
|
||||
opt_type="C",
|
||||
sheets=1,
|
||||
)
|
||||
self.assertTrue(out["ok"])
|
||||
self.assertEqual(len(list_active_targets(conn)), 1)
|
||||
|
||||
closed = []
|
||||
|
||||
def close_fn(inst_id: str):
|
||||
closed.append(inst_id)
|
||||
return {
|
||||
"ok": True,
|
||||
"submitted_sheets": 1,
|
||||
"premium_received": 1.2,
|
||||
"close_ord_id": "oid1",
|
||||
"fully_closed": True,
|
||||
"remaining_sheets": 0,
|
||||
}
|
||||
|
||||
n = run_options_target_closes(
|
||||
conn,
|
||||
[{"inst_id": "ETH-USD_UM-260717-1900-C", "idx_px": 1885, "opt_type": "C"}],
|
||||
close_fn=close_fn,
|
||||
)
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(closed, ["ETH-USD_UM-260717-1900-C"])
|
||||
self.assertEqual(len(list_active_targets(conn)), 0)
|
||||
|
||||
def test_partial_fill_notifies_once_then_closing_retry_silent(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
ensure_target_tables(conn)
|
||||
upsert_target_monitor(
|
||||
conn,
|
||||
inst_id="ETH-USD_UM-260715-1870-P",
|
||||
target_index=1872,
|
||||
opt_type="P",
|
||||
sheets=1,
|
||||
)
|
||||
conn.commit()
|
||||
notices: list[str] = []
|
||||
calls = {"n": 0}
|
||||
|
||||
def close_fn(inst_id: str):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return {
|
||||
"ok": True,
|
||||
"submitted_sheets": 1,
|
||||
"premium_received": 0.032,
|
||||
"close_ord_id": "oid-a",
|
||||
"fully_closed": False,
|
||||
"remaining_sheets": 1,
|
||||
"stopped_reason": "order_not_filled",
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"submitted_sheets": 1,
|
||||
"premium_received": 0.032,
|
||||
"close_ord_id": "oid-b",
|
||||
"fully_closed": True,
|
||||
"remaining_sheets": 0,
|
||||
"already_flat": True,
|
||||
}
|
||||
|
||||
pos = [{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1867.5, "opt_type": "P"}]
|
||||
n1 = run_options_target_closes(
|
||||
conn,
|
||||
pos,
|
||||
close_fn=close_fn,
|
||||
send_wechat=notices.append,
|
||||
account_label="主账户·期权",
|
||||
)
|
||||
self.assertEqual(n1, 1)
|
||||
self.assertEqual(len(notices), 1)
|
||||
self.assertEqual(len(list_active_targets(conn)), 0)
|
||||
self.assertEqual(len(list_closing_targets(conn)), 1)
|
||||
|
||||
# 模拟后续 sync 异常也不会再推:closing 重试静默
|
||||
n2 = run_options_target_closes(
|
||||
conn,
|
||||
pos,
|
||||
close_fn=close_fn,
|
||||
send_wechat=notices.append,
|
||||
account_label="主账户·期权",
|
||||
)
|
||||
self.assertEqual(n2, 0)
|
||||
self.assertEqual(len(notices), 1)
|
||||
self.assertEqual(len(list_closing_targets(conn)), 0)
|
||||
|
||||
def test_commit_before_wechat_survives_later_rollback(self):
|
||||
"""状态在推送前已 commit,外层异常回滚不应让委托回到 active."""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
ensure_target_tables(conn)
|
||||
upsert_target_monitor(
|
||||
conn,
|
||||
inst_id="ETH-USD_UM-260715-1870-P",
|
||||
target_index=1872,
|
||||
opt_type="P",
|
||||
)
|
||||
conn.commit()
|
||||
notices: list[str] = []
|
||||
|
||||
def close_fn(inst_id: str):
|
||||
return {
|
||||
"ok": True,
|
||||
"submitted_sheets": 1,
|
||||
"premium_received": 0.03,
|
||||
"close_ord_id": "oid1",
|
||||
"fully_closed": True,
|
||||
"remaining_sheets": 0,
|
||||
}
|
||||
|
||||
run_options_target_closes(
|
||||
conn,
|
||||
[{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}],
|
||||
close_fn=close_fn,
|
||||
send_wechat=notices.append,
|
||||
)
|
||||
# 模拟 loop 后续 sync 抛错后 close 未再 commit —— 但 status 已提前 commit
|
||||
conn.rollback()
|
||||
self.assertEqual(len(notices), 1)
|
||||
self.assertEqual(len(list_active_targets(conn)), 0)
|
||||
|
||||
# 下一轮不应再次触发推送
|
||||
n2 = run_options_target_closes(
|
||||
conn,
|
||||
[{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}],
|
||||
close_fn=close_fn,
|
||||
send_wechat=notices.append,
|
||||
)
|
||||
self.assertEqual(n2, 0)
|
||||
self.assertEqual(len(notices), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""期权业务时区与 UTC 墙钟迁移单测."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_review_db import init_options_review_tables
|
||||
from lib.options.options_review_lib import get_sync_state, upsert_option_history_row
|
||||
from lib.options.options_time_lib import (
|
||||
migrate_options_utc_wall_clock_to_app,
|
||||
ms_to_options_app_str,
|
||||
options_wall_clock_to_ms,
|
||||
utc_wall_clock_to_app_str,
|
||||
)
|
||||
|
||||
|
||||
class OptionsTimeLibTests(unittest.TestCase):
|
||||
def test_ms_to_shanghai_wall_clock(self):
|
||||
# 2026-09-13 21:41:57 UTC → 2026-09-14 05:41:57 Asia/Shanghai
|
||||
ms = int(
|
||||
datetime(2026, 9, 13, 21, 41, 57, tzinfo=timezone.utc).timestamp() * 1000
|
||||
)
|
||||
self.assertEqual(ms_to_options_app_str(ms), "2026-09-14 05:41:57")
|
||||
|
||||
def test_utc_wall_to_app(self):
|
||||
self.assertEqual(
|
||||
utc_wall_clock_to_app_str("2026-09-13 21:41:57"),
|
||||
"2026-09-14 05:41:57",
|
||||
)
|
||||
|
||||
def test_wall_clock_to_ms_roundtrip(self):
|
||||
raw = "2026-09-14 05:41:57"
|
||||
ms = options_wall_clock_to_ms(raw)
|
||||
self.assertIsNotNone(ms)
|
||||
dt = datetime.fromtimestamp(ms / 1000.0, tz=ZoneInfo("Asia/Shanghai"))
|
||||
self.assertEqual(dt.strftime("%Y-%m-%d %H:%M:%S"), raw)
|
||||
|
||||
def test_migrate_once(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
init_options_review_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades(
|
||||
inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status,
|
||||
created_at, closed_at
|
||||
) VALUES ('ETH-USD-260917-2525-P', 'ETH', 'P', 1, 0.01, 1, 'closed',
|
||||
'2026-09-13 21:41:57', '2026-09-14 02:31:00')
|
||||
"""
|
||||
)
|
||||
upsert_option_history_row(
|
||||
conn,
|
||||
{
|
||||
"history_key": "local_opt:1",
|
||||
"inst_id": "ETH-USD-260917-2525-P",
|
||||
"underlying": "ETH",
|
||||
"opt_type": "P",
|
||||
"realized_pnl": 0.5,
|
||||
"created_at": "2026-09-13 21:41:57",
|
||||
"closed_at": "2026-09-14 02:31:00",
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
first = migrate_options_utc_wall_clock_to_app(conn)
|
||||
self.assertEqual(first["skipped"], 0)
|
||||
self.assertGreaterEqual(first["trades"], 1)
|
||||
self.assertGreaterEqual(first["review"], 1)
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT created_at, closed_at FROM options_trades WHERE id=1"
|
||||
).fetchone()
|
||||
self.assertEqual(row["created_at"], "2026-09-14 05:41:57")
|
||||
self.assertEqual(row["closed_at"], "2026-09-14 10:31:00")
|
||||
|
||||
rev = conn.execute(
|
||||
"SELECT opened_at, closed_at FROM options_review_trades WHERE history_key='local_opt:1'"
|
||||
).fetchone()
|
||||
self.assertEqual(rev["opened_at"], "2026-09-14 05:41:57")
|
||||
self.assertEqual(rev["closed_at"], "2026-09-14 10:31:00")
|
||||
self.assertEqual(get_sync_state(conn, "options_ts_utc_wall_to_app_done"), "1")
|
||||
|
||||
second = migrate_options_utc_wall_clock_to_app(conn)
|
||||
self.assertEqual(second["skipped"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user