diff --git a/mock_data/exchange_options/aevo_BTC.json b/mock_data/exchange_options/aevo_BTC.json new file mode 100644 index 0000000..9b9721f --- /dev/null +++ b/mock_data/exchange_options/aevo_BTC.json @@ -0,0 +1,41 @@ +{ + "markets": [ + {"instrument_name": "BTC-65000-C", "strike": 65000, "option_type": "call"}, + {"instrument_name": "BTC-65000-P", "strike": 65000, "option_type": "put"}, + {"instrument_name": "BTC-66000-C", "strike": 66000, "option_type": "call"}, + {"instrument_name": "BTC-66000-P", "strike": 66000, "option_type": "put"}, + {"instrument_name": "BTC-67000-C", "strike": 67000, "option_type": "call"}, + {"instrument_name": "BTC-67000-P", "strike": 67000, "option_type": "put"}, + {"instrument_name": "BTC-67500-C", "strike": 67500, "option_type": "call"}, + {"instrument_name": "BTC-67500-P", "strike": 67500, "option_type": "put"}, + {"instrument_name": "BTC-68000-C", "strike": 68000, "option_type": "call"}, + {"instrument_name": "BTC-68000-P", "strike": 68000, "option_type": "put"}, + {"instrument_name": "BTC-68500-C", "strike": 68500, "option_type": "call"}, + {"instrument_name": "BTC-68500-P", "strike": 68500, "option_type": "put"}, + {"instrument_name": "BTC-69000-C", "strike": 69000, "option_type": "call"}, + {"instrument_name": "BTC-69000-P", "strike": 69000, "option_type": "put"}, + {"instrument_name": "BTC-70000-C", "strike": 70000, "option_type": "call"}, + {"instrument_name": "BTC-70000-P", "strike": 70000, "option_type": "put"} + ], + "order_books": { + "BTC-65000-C": {"bids": [[2800.0, 1.5, 52.5]], "asks": [[2900.0, 1.2, 54.0]]}, + "BTC-65000-P": {"bids": [[0.85, 2.0, 51.5]], "asks": [[1.15, 1.8, 54.2]]}, + "BTC-66000-C": {"bids": [[1830.0, 1.0, 51.8]], "asks": [[1900.0, 0.8, 53.2]]}, + "BTC-66000-P": {"bids": [[16.00, 1.5, 51.0]], "asks": [[19.80, 1.2, 53.0]]}, + "BTC-67000-C": {"bids": [[960.0, 2.0, 51.0]], "asks": [[1010.0, 1.5, 52.5]]}, + "BTC-67000-P": {"bids": [[135.0, 1.0, 50.8]], "asks": [[148.0, 0.8, 52.2]]}, + "BTC-67500-C": {"bids": [[620.0, 1.5, 50.5]], "asks": [[655.0, 1.0, 52.0]]}, + "BTC-67500-P": {"bids": [[280.0, 1.2, 50.2]], "asks": [[305.0, 1.0, 51.8]]}, + "BTC-68000-C": {"bids": [[360.0, 1.0, 50.0]], "asks": [[385.0, 0.8, 51.5]]}, + "BTC-68000-P": {"bids": [[510.0, 1.5, 49.8]], "asks": [[542.0, 1.2, 51.2]]}, + "BTC-68500-C": {"bids": [[190.0, 0.8, 49.5]], "asks": [[205.0, 0.5, 51.0]]}, + "BTC-68500-P": {"bids": [[830.0, 1.0, 49.2]], "asks": [[870.0, 0.8, 50.8]]}, + "BTC-69000-C": {"bids": [[88.0, 1.0, 49.0]], "asks": [[98.0, 0.8, 50.5]]}, + "BTC-69000-P": {"bids": [[1210.0, 0.8, 48.8]], "asks": [[1280.0, 0.5, 50.2]]}, + "BTC-70000-C": {"bids": [[14.0, 0.5, 48.2]], "asks": [[16.80, 0.3, 49.8]]}, + "BTC-70000-P": {"bids": [[2120.0, 0.8, 48.0]], "asks": [[2220.0, 0.5, 49.5]]}, + + "BTC-65500-C": {"bids": [[2300.0, 1.0, 52.0]], "asks": [[2410.0, 0.8, 53.5]]}, + "BTC-65500-P": {"bids": [[3.80, 1.5, 51.5]], "asks": [[5.00, 1.0, 53.5]]} + } +} diff --git a/mock_data/exchange_options/aevo_ETH.json b/mock_data/exchange_options/aevo_ETH.json new file mode 100644 index 0000000..c2f4078 --- /dev/null +++ b/mock_data/exchange_options/aevo_ETH.json @@ -0,0 +1,34 @@ +{ + "markets": [ + {"instrument_name": "ETH-1950-C", "strike": 1950, "option_type": "call"}, + {"instrument_name": "ETH-1950-P", "strike": 1950, "option_type": "put"}, + {"instrument_name": "ETH-2000-C", "strike": 2000, "option_type": "call"}, + {"instrument_name": "ETH-2000-P", "strike": 2000, "option_type": "put"}, + {"instrument_name": "ETH-2025-C", "strike": 2025, "option_type": "call"}, + {"instrument_name": "ETH-2025-P", "strike": 2025, "option_type": "put"}, + {"instrument_name": "ETH-2050-C", "strike": 2050, "option_type": "call"}, + {"instrument_name": "ETH-2050-P", "strike": 2050, "option_type": "put"}, + {"instrument_name": "ETH-2075-C", "strike": 2075, "option_type": "call"}, + {"instrument_name": "ETH-2075-P", "strike": 2075, "option_type": "put"}, + {"instrument_name": "ETH-2100-C", "strike": 2100, "option_type": "call"}, + {"instrument_name": "ETH-2100-P", "strike": 2100, "option_type": "put"}, + {"instrument_name": "ETH-2150-C", "strike": 2150, "option_type": "call"}, + {"instrument_name": "ETH-2150-P", "strike": 2150, "option_type": "put"} + ], + "order_books": { + "ETH-1950-C": {"bids": [[101.00, 10.0, 61.5]], "asks": [[107.00, 8.0, 63.2]]}, + "ETH-1950-P": {"bids": [[0.25, 15.0, 61.0]], "asks": [[0.35, 12.0, 62.8]]}, + "ETH-2000-C": {"bids": [[55.50, 12.0, 60.8]], "asks": [[59.00, 10.0, 62.5]]}, + "ETH-2000-P": {"bids": [[2.90, 10.0, 60.5]], "asks": [[3.60, 8.0, 62.2]]}, + "ETH-2025-C": {"bids": [[35.50, 8.0, 60.2]], "asks": [[38.50, 6.0, 62.0]]}, + "ETH-2025-P": {"bids": [[7.50, 10.0, 60.0]], "asks": [[9.00, 8.0, 61.8]]}, + "ETH-2050-C": {"bids": [[19.80, 10.0, 59.8]], "asks": [[22.50, 8.0, 61.5]]}, + "ETH-2050-P": {"bids": [[16.00, 8.0, 59.5]], "asks": [[19.00, 6.0, 61.2]]}, + "ETH-2075-C": {"bids": [[9.80, 6.0, 59.2]], "asks": [[11.20, 5.0, 61.0]]}, + "ETH-2075-P": {"bids": [[30.00, 8.0, 59.0]], "asks": [[33.80, 6.0, 60.8]]}, + "ETH-2100-C": {"bids": [[4.00, 8.0, 58.8]], "asks": [[5.00, 6.0, 60.5]]}, + "ETH-2100-P": {"bids": [[49.00, 6.0, 58.5]], "asks": [[53.00, 5.0, 60.2]]}, + "ETH-2150-C": {"bids": [[0.45, 10.0, 58.2]], "asks": [[0.70, 8.0, 60.0]]}, + "ETH-2150-P": {"bids": [[94.50, 5.0, 58.0]], "asks": [[100.00, 4.0, 59.8]]} + } +} diff --git a/mock_data/exchange_options/aevo_SOL.json b/mock_data/exchange_options/aevo_SOL.json new file mode 100644 index 0000000..725a001 --- /dev/null +++ b/mock_data/exchange_options/aevo_SOL.json @@ -0,0 +1,34 @@ +{ + "markets": [ + {"instrument_name": "SOL-82-C", "strike": 82, "option_type": "call"}, + {"instrument_name": "SOL-82-P", "strike": 82, "option_type": "put"}, + {"instrument_name": "SOL-84-C", "strike": 84, "option_type": "call"}, + {"instrument_name": "SOL-84-P", "strike": 84, "option_type": "put"}, + {"instrument_name": "SOL-86-C", "strike": 86, "option_type": "call"}, + {"instrument_name": "SOL-86-P", "strike": 86, "option_type": "put"}, + {"instrument_name": "SOL-88-C", "strike": 88, "option_type": "call"}, + {"instrument_name": "SOL-88-P", "strike": 88, "option_type": "put"}, + {"instrument_name": "SOL-90-C", "strike": 90, "option_type": "call"}, + {"instrument_name": "SOL-90-P", "strike": 90, "option_type": "put"}, + {"instrument_name": "SOL-92-C", "strike": 92, "option_type": "call"}, + {"instrument_name": "SOL-92-P", "strike": 92, "option_type": "put"}, + {"instrument_name": "SOL-94-C", "strike": 94, "option_type": "call"}, + {"instrument_name": "SOL-94-P", "strike": 94, "option_type": "put"} + ], + "order_books": { + "SOL-82-C": {"bids": [[5.30, 50.0, 73.0]], "asks": [[5.50, 40.0, 74.5]]}, + "SOL-82-P": {"bids": [[0.0035, 80.0, 72.5]], "asks": [[0.0048, 60.0, 74.0]]}, + "SOL-84-C": {"bids": [[3.30, 40.0, 72.2]], "asks": [[3.52, 30.0, 73.8]]}, + "SOL-84-P": {"bids": [[0.045, 60.0, 71.8]], "asks": [[0.065, 50.0, 73.5]]}, + "SOL-86-C": {"bids": [[1.62, 30.0, 71.5]], "asks": [[1.78, 25.0, 73.2]]}, + "SOL-86-P": {"bids": [[0.32, 40.0, 71.0]], "asks": [[0.40, 30.0, 72.8]]}, + "SOL-88-C": {"bids": [[0.58, 25.0, 70.8]], "asks": [[0.65, 20.0, 72.5]]}, + "SOL-88-P": {"bids": [[1.18, 30.0, 70.5]], "asks": [[1.32, 25.0, 72.2]]}, + "SOL-90-C": {"bids": [[0.14, 20.0, 70.2]], "asks": [[0.18, 15.0, 72.0]]}, + "SOL-90-P": {"bids": [[2.70, 25.0, 70.0]], "asks": [[2.90, 20.0, 71.8]]}, + "SOL-92-C": {"bids": [[0.028, 15.0, 69.8]], "asks": [[0.040, 10.0, 71.5]]}, + "SOL-92-P": {"bids": [[4.55, 20.0, 69.5]], "asks": [[4.80, 15.0, 71.2]]}, + "SOL-94-C": {"bids": [[0.004, 10.0, 69.2]], "asks": [[0.006, 8.0, 71.0]]}, + "SOL-94-P": {"bids": [[6.45, 15.0, 69.0]], "asks": [[6.80, 12.0, 70.8]]} + } +} diff --git a/mock_data/exchange_options/deribit_BTC.json b/mock_data/exchange_options/deribit_BTC.json new file mode 100644 index 0000000..b6f1c3d --- /dev/null +++ b/mock_data/exchange_options/deribit_BTC.json @@ -0,0 +1,38 @@ +{ + "instruments": [ + {"instrument_name": "BTC-26FEB26-65000-C", "strike": 65000, "option_type": "call"}, + {"instrument_name": "BTC-26FEB26-65000-P", "strike": 65000, "option_type": "put"}, + {"instrument_name": "BTC-26FEB26-66000-C", "strike": 66000, "option_type": "call"}, + {"instrument_name": "BTC-26FEB26-66000-P", "strike": 66000, "option_type": "put"}, + {"instrument_name": "BTC-26FEB26-67000-C", "strike": 67000, "option_type": "call"}, + {"instrument_name": "BTC-26FEB26-67000-P", "strike": 67000, "option_type": "put"}, + {"instrument_name": "BTC-26FEB26-67500-C", "strike": 67500, "option_type": "call"}, + {"instrument_name": "BTC-26FEB26-67500-P", "strike": 67500, "option_type": "put"}, + {"instrument_name": "BTC-26FEB26-68000-C", "strike": 68000, "option_type": "call"}, + {"instrument_name": "BTC-26FEB26-68000-P", "strike": 68000, "option_type": "put"}, + {"instrument_name": "BTC-26FEB26-68500-C", "strike": 68500, "option_type": "call"}, + {"instrument_name": "BTC-26FEB26-68500-P", "strike": 68500, "option_type": "put"}, + {"instrument_name": "BTC-26FEB26-69000-C", "strike": 69000, "option_type": "call"}, + {"instrument_name": "BTC-26FEB26-69000-P", "strike": 69000, "option_type": "put"}, + {"instrument_name": "BTC-26FEB26-70000-C", "strike": 70000, "option_type": "call"}, + {"instrument_name": "BTC-26FEB26-70000-P", "strike": 70000, "option_type": "put"} + ], + "order_books": { + "BTC-26FEB26-65000-C": {"bid": 2780.0, "ask": 2920.0, "mark_price": 2847.68, "bid_iv": 52.1, "ask_iv": 54.3}, + "BTC-26FEB26-65000-P": {"bid": 0.90, "ask": 1.10, "mark_price": 0.99, "bid_iv": 51.8, "ask_iv": 53.9}, + "BTC-26FEB26-66000-C": {"bid": 1810.0, "ask": 1920.0, "mark_price": 1864.60, "bid_iv": 51.5, "ask_iv": 53.5}, + "BTC-26FEB26-66000-P": {"bid": 15.50, "ask": 20.30, "mark_price": 17.91, "bid_iv": 51.2, "ask_iv": 53.2}, + "BTC-26FEB26-67000-C": {"bid": 950.0, "ask": 1025.0, "mark_price": 987.04, "bid_iv": 50.8, "ask_iv": 52.8}, + "BTC-26FEB26-67000-P": {"bid": 130.0, "ask": 152.0, "mark_price": 140.36, "bid_iv": 50.5, "ask_iv": 52.5}, + "BTC-26FEB26-67500-C": {"bid": 610.0, "ask": 660.0, "mark_price": 638.43, "bid_iv": 50.2, "ask_iv": 52.2}, + "BTC-26FEB26-67500-P": {"bid": 275.0, "ask": 310.0, "mark_price": 291.75, "bid_iv": 50.0, "ask_iv": 52.0}, + "BTC-26FEB26-68000-C": {"bid": 355.0, "ask": 390.0, "mark_price": 373.27, "bid_iv": 49.8, "ask_iv": 51.8}, + "BTC-26FEB26-68000-P": {"bid": 505.0, "ask": 548.0, "mark_price": 526.59, "bid_iv": 49.5, "ask_iv": 51.5}, + "BTC-26FEB26-68500-C": {"bid": 185.0, "ask": 210.0, "mark_price": 197.11, "bid_iv": 49.2, "ask_iv": 51.2}, + "BTC-26FEB26-68500-P": {"bid": 820.0, "ask": 880.0, "mark_price": 850.42, "bid_iv": 49.0, "ask_iv": 51.0}, + "BTC-26FEB26-69000-C": {"bid": 85.0, "ask": 102.0, "mark_price": 93.43, "bid_iv": 48.8, "ask_iv": 50.8}, + "BTC-26FEB26-69000-P": {"bid": 1200.0, "ask": 1295.0, "mark_price": 1246.74, "bid_iv": 48.5, "ask_iv": 50.5}, + "BTC-26FEB26-70000-C": {"bid": 13.0, "ask": 17.50, "mark_price": 15.13, "bid_iv": 48.0, "ask_iv": 50.0}, + "BTC-26FEB26-70000-P": {"bid": 2100.0, "ask": 2240.0, "mark_price": 2168.44, "bid_iv": 47.8, "ask_iv": 49.8} + } +} diff --git a/mock_data/exchange_options/deribit_ETH.json b/mock_data/exchange_options/deribit_ETH.json new file mode 100644 index 0000000..2d02d8d --- /dev/null +++ b/mock_data/exchange_options/deribit_ETH.json @@ -0,0 +1,34 @@ +{ + "instruments": [ + {"instrument_name": "ETH-26FEB26-1950-C", "strike": 1950, "option_type": "call"}, + {"instrument_name": "ETH-26FEB26-1950-P", "strike": 1950, "option_type": "put"}, + {"instrument_name": "ETH-26FEB26-2000-C", "strike": 2000, "option_type": "call"}, + {"instrument_name": "ETH-26FEB26-2000-P", "strike": 2000, "option_type": "put"}, + {"instrument_name": "ETH-26FEB26-2025-C", "strike": 2025, "option_type": "call"}, + {"instrument_name": "ETH-26FEB26-2025-P", "strike": 2025, "option_type": "put"}, + {"instrument_name": "ETH-26FEB26-2050-C", "strike": 2050, "option_type": "call"}, + {"instrument_name": "ETH-26FEB26-2050-P", "strike": 2050, "option_type": "put"}, + {"instrument_name": "ETH-26FEB26-2075-C", "strike": 2075, "option_type": "call"}, + {"instrument_name": "ETH-26FEB26-2075-P", "strike": 2075, "option_type": "put"}, + {"instrument_name": "ETH-26FEB26-2100-C", "strike": 2100, "option_type": "call"}, + {"instrument_name": "ETH-26FEB26-2100-P", "strike": 2100, "option_type": "put"}, + {"instrument_name": "ETH-26FEB26-2150-C", "strike": 2150, "option_type": "call"}, + {"instrument_name": "ETH-26FEB26-2150-P", "strike": 2150, "option_type": "put"} + ], + "order_books": { + "ETH-26FEB26-1950-C": {"bid": 99.50, "ask": 108.50, "mark_price": 103.94, "bid_iv": 61.2, "ask_iv": 63.5}, + "ETH-26FEB26-1950-P": {"bid": 0.22, "ask": 0.38, "mark_price": 0.29, "bid_iv": 60.8, "ask_iv": 63.1}, + "ETH-26FEB26-2000-C": {"bid": 54.00, "ask": 60.00, "mark_price": 56.87, "bid_iv": 60.5, "ask_iv": 62.8}, + "ETH-26FEB26-2000-P": {"bid": 2.80, "ask": 3.70, "mark_price": 3.23, "bid_iv": 60.2, "ask_iv": 62.5}, + "ETH-26FEB26-2025-C": {"bid": 34.50, "ask": 39.50, "mark_price": 36.81, "bid_iv": 60.0, "ask_iv": 62.3}, + "ETH-26FEB26-2025-P": {"bid": 7.20, "ask": 9.20, "mark_price": 8.16, "bid_iv": 59.8, "ask_iv": 62.1}, + "ETH-26FEB26-2050-C": {"bid": 19.00, "ask": 23.00, "mark_price": 20.97, "bid_iv": 59.5, "ask_iv": 61.8}, + "ETH-26FEB26-2050-P": {"bid": 15.50, "ask": 19.50, "mark_price": 17.32, "bid_iv": 59.2, "ask_iv": 61.5}, + "ETH-26FEB26-2075-C": {"bid": 9.50, "ask": 11.50, "mark_price": 10.45, "bid_iv": 59.0, "ask_iv": 61.3}, + "ETH-26FEB26-2075-P": {"bid": 29.50, "ask": 34.50, "mark_price": 31.81, "bid_iv": 58.8, "ask_iv": 61.0}, + "ETH-26FEB26-2100-C": {"bid": 3.80, "ask": 5.20, "mark_price": 4.46, "bid_iv": 58.5, "ask_iv": 60.8}, + "ETH-26FEB26-2100-P": {"bid": 48.00, "ask": 54.00, "mark_price": 50.81, "bid_iv": 58.2, "ask_iv": 60.5}, + "ETH-26FEB26-2150-C": {"bid": 0.42, "ask": 0.75, "mark_price": 0.57, "bid_iv": 58.0, "ask_iv": 60.3}, + "ETH-26FEB26-2150-P": {"bid": 93.00, "ask": 101.00, "mark_price": 96.93, "bid_iv": 57.8, "ask_iv": 60.0} + } +} diff --git a/mock_data/exchange_options/deribit_SOL.json b/mock_data/exchange_options/deribit_SOL.json new file mode 100644 index 0000000..6481e37 --- /dev/null +++ b/mock_data/exchange_options/deribit_SOL.json @@ -0,0 +1,34 @@ +{ + "instruments": [ + {"instrument_name": "SOL-26FEB26-82-C", "strike": 82, "option_type": "call"}, + {"instrument_name": "SOL-26FEB26-82-P", "strike": 82, "option_type": "put"}, + {"instrument_name": "SOL-26FEB26-84-C", "strike": 84, "option_type": "call"}, + {"instrument_name": "SOL-26FEB26-84-P", "strike": 84, "option_type": "put"}, + {"instrument_name": "SOL-26FEB26-86-C", "strike": 86, "option_type": "call"}, + {"instrument_name": "SOL-26FEB26-86-P", "strike": 86, "option_type": "put"}, + {"instrument_name": "SOL-26FEB26-88-C", "strike": 88, "option_type": "call"}, + {"instrument_name": "SOL-26FEB26-88-P", "strike": 88, "option_type": "put"}, + {"instrument_name": "SOL-26FEB26-90-C", "strike": 90, "option_type": "call"}, + {"instrument_name": "SOL-26FEB26-90-P", "strike": 90, "option_type": "put"}, + {"instrument_name": "SOL-26FEB26-92-C", "strike": 92, "option_type": "call"}, + {"instrument_name": "SOL-26FEB26-92-P", "strike": 92, "option_type": "put"}, + {"instrument_name": "SOL-26FEB26-94-C", "strike": 94, "option_type": "call"}, + {"instrument_name": "SOL-26FEB26-94-P", "strike": 94, "option_type": "put"} + ], + "order_books": { + "SOL-26FEB26-82-C": {"bid": 5.20, "ask": 5.55, "mark_price": 5.34, "bid_iv": 72.5, "ask_iv": 75.0}, + "SOL-26FEB26-82-P": {"bid": 0.0030, "ask": 0.0050, "mark_price": 0.0036, "bid_iv": 72.0, "ask_iv": 74.5}, + "SOL-26FEB26-84-C": {"bid": 3.25, "ask": 3.55, "mark_price": 3.40, "bid_iv": 71.8, "ask_iv": 74.2}, + "SOL-26FEB26-84-P": {"bid": 0.040, "ask": 0.070, "mark_price": 0.055, "bid_iv": 71.5, "ask_iv": 73.8}, + "SOL-26FEB26-86-C": {"bid": 1.58, "ask": 1.82, "mark_price": 1.70, "bid_iv": 71.2, "ask_iv": 73.5}, + "SOL-26FEB26-86-P": {"bid": 0.30, "ask": 0.42, "mark_price": 0.36, "bid_iv": 70.8, "ask_iv": 73.1}, + "SOL-26FEB26-88-C": {"bid": 0.55, "ask": 0.68, "mark_price": 0.61, "bid_iv": 70.5, "ask_iv": 72.8}, + "SOL-26FEB26-88-P": {"bid": 1.15, "ask": 1.35, "mark_price": 1.27, "bid_iv": 70.2, "ask_iv": 72.5}, + "SOL-26FEB26-90-C": {"bid": 0.13, "ask": 0.19, "mark_price": 0.16, "bid_iv": 70.0, "ask_iv": 72.3}, + "SOL-26FEB26-90-P": {"bid": 2.65, "ask": 2.95, "mark_price": 2.82, "bid_iv": 69.8, "ask_iv": 72.0}, + "SOL-26FEB26-92-C": {"bid": 0.025, "ask": 0.042, "mark_price": 0.032, "bid_iv": 69.5, "ask_iv": 71.8}, + "SOL-26FEB26-92-P": {"bid": 4.50, "ask": 4.85, "mark_price": 4.69, "bid_iv": 69.2, "ask_iv": 71.5}, + "SOL-26FEB26-94-C": {"bid": 0.003, "ask": 0.007, "mark_price": 0.005, "bid_iv": 69.0, "ask_iv": 71.3}, + "SOL-26FEB26-94-P": {"bid": 6.40, "ask": 6.85, "mark_price": 6.66, "bid_iv": 68.8, "ask_iv": 71.0} + } +} diff --git a/tools/options-gps/README.md b/tools/options-gps/README.md index 14588ec..a6bd8f8 100644 --- a/tools/options-gps/README.md +++ b/tools/options-gps/README.md @@ -24,7 +24,7 @@ Turn a trader's view into one clear options decision. Inputs: **symbol**, **mark - **Vol (long vol bias):** Long straddle (buy ATM call + put), long strangle (buy OTM call + put). - **Vol (short vol bias):** Short straddle (sell ATM call + put, high risk only), short strangle (sell OTM call + put, medium/high risk), iron condor (defined-risk short vol). 5. **Payoff + Probability Engine:** Uses Synth percentile distribution (CDF-weighted) at horizon to compute probability of profit (PoP) and expected value (EV) for each strategy. PnL formulas cover all strategy types including straddles and strangles. -6. **Ranking Engine:** Scores with `fit_to_view + pop + expected_return - tail_penalty`; weighting shifts by risk (low → more PoP, high → more EV). For vol view, vol bias adjusts view fit: long_vol boosts long straddle/strangle scores, short_vol boosts iron condor/short straddle scores. Fusion bonus is skipped for vol view (direction-agnostic). Picks Best Match, Safer Alternative, Higher Upside. +6. **Ranking Engine:** Scores with `fit_to_view + pop + expected_return - tail_penalty`; weighting shifts by risk (low → more PoP, high → more EV). For vol view, vol bias adjusts view fit: long_vol boosts long straddle/strangle scores, short_vol boosts iron condor/short straddle scores. Fusion bonus is skipped for vol view (direction-agnostic). **Market Line Shopping** (crypto only): compares Synth fair value against Deribit/Aevo exchange prices; strategies where the market price is cheaper than fair get an additive score bonus (clamped ±0.15). Picks Best Match, Safer Alternative, Higher Upside. 7. **Guardrails:** Filters no-trade when fusion is countermove/unclear with directional view, volatility exceeds threshold (directional views), confidence is too low, or vol bias is neutral (vol view — no exploitable divergence between Synth and market IV). 8. **Risk Management:** Each strategy type has a specific risk plan (invalidation trigger, adjustment/reroute rule, review schedule). Short straddle/strangle are labeled "unlimited risk" with hard stops at 2x credit loss; they are risk-gated (high-only for short straddle, medium+ for short strangle). @@ -51,4 +51,4 @@ Prompts: symbol (default BTC), view (bullish/bearish/neutral/vol), risk (low/med From repo root: `python -m pytest tools/options-gps/tests/ -v`. No API key required (mock data). -Test coverage includes: forecast fusion, strategy generation (all views including vol), PnL calculations for all strategy types, CDF-weighted PoP/EV, ranking with vol bias, vol-specific guardrails, IV estimation, vol comparison, risk plans, hard filters, and end-to-end scripted tests. +Test coverage includes: forecast fusion, strategy generation (all views including vol), PnL calculations for all strategy types, CDF-weighted PoP/EV, ranking with vol bias, vol-specific guardrails, IV estimation, vol comparison, risk plans, hard filters, exchange data fetching/parsing, divergence computation, line shopping ranking integration, and end-to-end scripted tests. diff --git a/tools/options-gps/exchange.py b/tools/options-gps/exchange.py new file mode 100644 index 0000000..33b2f2e --- /dev/null +++ b/tools/options-gps/exchange.py @@ -0,0 +1,361 @@ +"""Market Line Shopping: fetch and compare option prices across exchanges. +Supports Deribit and Aevo for crypto assets (BTC, ETH, SOL). +Live mode fetches real-time quotes via public APIs (no auth needed). +Mock mode loads from JSON when mock_dir is provided.""" + +import json +import math +import os +from dataclasses import dataclass + +CRYPTO_ASSETS = {"BTC", "ETH", "SOL"} +EXCHANGES = ["deribit", "aevo"] + +DERIBIT_API = "https://www.deribit.com/api/v2/public" +AEVO_API = "https://api.aevo.xyz" +HTTP_TIMEOUT = 10 + + +@dataclass +class ExchangeQuote: + """Normalized option quote from any exchange.""" + exchange: str # "deribit" or "aevo" + asset: str + strike: float + option_type: str # "call" or "put" + bid: float + ask: float + mid: float # (bid + ask) / 2 + implied_vol: float | None # from exchange, if available + + +@dataclass +class EdgeMetrics: + """Statistical edge for a strike/type: Synth vs market consensus.""" + synth_fair: float + market_mean: float # mean of exchange mids + std_dev: float # population std across all pricing sources + z_score: float # (synth - market_mean) / std + divergence_pct: float # simple % divergence for display + n_sources: int # pricing sources count (synth + exchanges) + best_venue: str # exchange with best execution price + best_price: float # best execution price (lowest ask) + edge_label: str # "STRONG" |z|>=2, "MODERATE" |z|>=1, "WEAK" |z|>=0.5, "NONE" + + +# --- HTTP helper --- + +def _http_get_json(url: str, timeout: int = HTTP_TIMEOUT) -> dict | list: + """GET JSON from URL. Raises on failure.""" + import requests as _req + resp = _req.get(url, timeout=timeout, headers={"Accept": "application/json"}) + resp.raise_for_status() + return resp.json() + + +# --- Fetch functions --- + +def fetch_deribit(asset: str, mock_dir: str | None = None) -> list[ExchangeQuote]: + """Fetch option quotes from Deribit. Mock JSON when mock_dir is provided, + otherwise live from Deribit public API (no auth needed).""" + if asset not in CRYPTO_ASSETS: + return [] + if mock_dir is not None: + return _load_mock(asset, mock_dir, "deribit") + return _fetch_deribit_live(asset) + + +def fetch_aevo(asset: str, mock_dir: str | None = None) -> list[ExchangeQuote]: + """Fetch option quotes from Aevo. Mock JSON when mock_dir is provided, + otherwise live from Aevo public API (no auth needed).""" + if asset not in CRYPTO_ASSETS: + return [] + if mock_dir is not None: + return _load_mock(asset, mock_dir, "aevo") + return _fetch_aevo_live(asset) + + +def fetch_all_exchanges(asset: str, mock_dir: str | None = None) -> list[ExchangeQuote]: + """Fetch quotes from all supported exchanges and combine.""" + if asset not in CRYPTO_ASSETS: + return [] + return fetch_deribit(asset, mock_dir) + fetch_aevo(asset, mock_dir) + + +def _fetch_deribit_live(asset: str) -> list[ExchangeQuote]: + """Fetch all option book summaries from Deribit public API (single request). + Deribit prices are in base currency fraction — multiply by underlying_price for USD.""" + url = f"{DERIBIT_API}/get_book_summary_by_currency?currency={asset}&kind=option" + try: + data = _http_get_json(url) + except Exception: + return [] + quotes = [] + for item in data.get("result", []): + name = item.get("instrument_name", "") + parsed = _parse_instrument_key(name) + if parsed is None: + continue + strike, opt_type = parsed + underlying = float(item.get("underlying_price") or 0) + if underlying <= 0: + continue + bid_raw = item.get("bid_price") + ask_raw = item.get("ask_price") + if not bid_raw or not ask_raw: + continue + bid_f, ask_f = float(bid_raw), float(ask_raw) + if bid_f <= 0 or ask_f <= 0: + continue + bid = bid_f * underlying + ask = ask_f * underlying + mid = (bid + ask) / 2 + iv = float(item["mark_iv"]) if item.get("mark_iv") else None + quotes.append(ExchangeQuote("deribit", asset, strike, opt_type, bid, ask, mid, iv)) + return quotes + + +def _fetch_aevo_live(asset: str) -> list[ExchangeQuote]: + """Fetch option quotes from Aevo public API. + Discovers instruments via /markets, fetches orderbooks in parallel.""" + from concurrent.futures import ThreadPoolExecutor, as_completed + + try: + markets = _http_get_json(f"{AEVO_API}/markets?asset={asset}&instrument_type=OPTION") + except Exception: + try: + all_mkts = _http_get_json(f"{AEVO_API}/markets") + markets = [m for m in all_mkts if isinstance(m, dict) + and m.get("underlying_asset", "").upper() == asset + and m.get("instrument_type", "").upper() == "OPTION"] + except Exception: + return [] + if not isinstance(markets, list): + return [] + active = [m for m in markets if m.get("is_active", True)][:40] + if not active: + return [] + + def _fetch_one(mkt): + name = mkt.get("instrument_name", "") + if not name: + return None + parsed = _parse_instrument_key(name) + if parsed is None: + return None + strike, opt_type = parsed + try: + book = _http_get_json(f"{AEVO_API}/orderbook?instrument_name={name}", timeout=5) + except Exception: + return None + bids = book.get("bids", []) + asks = book.get("asks", []) + if not bids or not asks: + return None + try: + bid = float(bids[0][0]) + ask = float(asks[0][0]) + except (IndexError, ValueError, TypeError): + return None + if bid <= 0 or ask <= 0: + return None + mid = (bid + ask) / 2 + bid_iv = float(bids[0][2]) if len(bids[0]) > 2 else None + ask_iv = float(asks[0][2]) if len(asks[0]) > 2 else None + return ExchangeQuote("aevo", asset, strike, opt_type, bid, ask, mid, _avg_iv(bid_iv, ask_iv)) + + quotes = [] + with ThreadPoolExecutor(max_workers=10) as pool: + futures = [pool.submit(_fetch_one, m) for m in active] + for f in as_completed(futures): + try: + q = f.result() + if q: + quotes.append(q) + except Exception: + pass + return quotes + + +# --- Price comparison --- + +def best_market_price(quotes: list[ExchangeQuote], strike: float, option_type: str) -> ExchangeQuote | None: + """Find best (lowest ask) quote across exchanges for a given strike/type.""" + matches = [q for q in quotes if q.strike == strike and q.option_type == option_type] + if not matches: + return None + return min(matches, key=lambda q: q.ask) + + +def best_execution_price(quotes: list[ExchangeQuote], strike: float, + option_type: str, action: str = "BUY") -> ExchangeQuote | None: + """Action-aware best execution: lowest ask for BUY, highest bid for SELL.""" + matches = [q for q in quotes if q.strike == strike and q.option_type == option_type] + if not matches: + return None + if action == "SELL": + return max(matches, key=lambda q: q.bid) + return min(matches, key=lambda q: q.ask) + + +# --- Edge detection --- + +def compute_divergence(synth_fair: float, market_mid: float) -> float: + """Compute divergence percentage: (synth_fair - market_mid) / synth_fair * 100. + Positive = market cheaper than fair (favorable entry). + Returns 0.0 if synth_fair is zero.""" + if synth_fair == 0: + return 0.0 + return (synth_fair - market_mid) / synth_fair * 100 + + +def compute_edge(synth_fair: float, quotes: list[ExchangeQuote], + strike: float, option_type: str) -> EdgeMetrics | None: + """Statistical edge via z-score: how far Synth deviates from market consensus. + Uses population std dev across all pricing sources (Synth + exchange mids). + Positive z = Synth values higher than market (market underpriced). + Higher |z| = stronger conviction — alpha is in disagreement.""" + matches = [q for q in quotes if q.strike == strike and q.option_type == option_type] + if not matches: + return None + + exchange_mids = [q.mid for q in matches] + market_mean = sum(exchange_mids) / len(exchange_mids) + + all_prices = [synth_fair] + exchange_mids + n = len(all_prices) + mean_all = sum(all_prices) / n + variance = sum((p - mean_all) ** 2 for p in all_prices) / n + std = math.sqrt(variance) if variance > 0 else 0 + + # Floor: 0.1% of market mean to avoid near-zero division + noise = max(std, market_mean * 0.001) + + z_score = (synth_fair - market_mean) / noise + div_pct = compute_divergence(synth_fair, market_mean) + + best = min(matches, key=lambda q: q.ask) + + if abs(z_score) >= 2.0: + edge_label = "STRONG" + elif abs(z_score) >= 1.0: + edge_label = "MODERATE" + elif abs(z_score) >= 0.5: + edge_label = "WEAK" + else: + edge_label = "NONE" + + return EdgeMetrics( + synth_fair=synth_fair, + market_mean=market_mean, + std_dev=noise, + z_score=z_score, + divergence_pct=div_pct, + n_sources=n, + best_venue=best.exchange, + best_price=best.ask, + edge_label=edge_label, + ) + + +def leg_divergences(strategy, quotes: list[ExchangeQuote], synth_options: dict) -> dict: + """For each leg, compute edge metrics with action-aware execution routing. + Returns {leg_index: {"divergence_pct", "z_score", "edge_label", + "best_exchange", "best_price", "synth_fair", + "market_mean", "std_dev"}}""" + result = {} + call_opts = synth_options.get("call_options", {}) + put_opts = synth_options.get("put_options", {}) + for i, leg in enumerate(strategy.legs): + strike_key = str(int(leg.strike)) if leg.strike == int(leg.strike) else str(leg.strike) + synth_fair = (call_opts if leg.option_type.lower() == "call" else put_opts).get(strike_key) + if synth_fair is None: + continue + synth_fair = float(synth_fair) + edge = compute_edge(synth_fair, quotes, leg.strike, leg.option_type.lower()) + if edge is None: + continue + # Route to best execution: lowest ask for BUY, highest bid for SELL + exec_q = best_execution_price(quotes, leg.strike, leg.option_type.lower(), leg.action) + if exec_q is None: + continue + exec_price = exec_q.ask if leg.action == "BUY" else exec_q.bid + result[i] = { + "divergence_pct": edge.divergence_pct, + "z_score": edge.z_score, + "edge_label": edge.edge_label, + "best_exchange": exec_q.exchange, + "best_price": exec_price, + "synth_fair": synth_fair, + "market_mean": edge.market_mean, + "std_dev": edge.std_dev, + } + return result + + +def strategy_divergence(strategy, quotes: list[ExchangeQuote], synth_options: dict) -> float | None: + """Average z-score across all legs. None if no exchange data for any leg. + Positive = Synth values strategy higher than market (potential edge).""" + divs = leg_divergences(strategy, quotes, synth_options) + if not divs: + return None + return sum(d["z_score"] for d in divs.values()) / len(divs) + + +# --- Mock loaders --- + +def _parse_instrument_key(key: str) -> tuple[float, str] | None: + """Parse strike and option type from instrument key like 'BTC-26FEB26-67500-C' or 'BTC-67500-C'.""" + parts = key.split("-") + try: + strike = float(parts[-2]) + opt_type = "call" if parts[-1] == "C" else "put" + return strike, opt_type + except (IndexError, ValueError): + return None + + +def _avg_iv(bid_iv: float | None, ask_iv: float | None) -> float | None: + """Average bid/ask IV, falling back to whichever is available.""" + if bid_iv is not None and ask_iv is not None: + return (bid_iv + ask_iv) / 2 + return bid_iv if bid_iv is not None else ask_iv + + +def _load_mock(asset: str, mock_dir: str, exchange: str) -> list[ExchangeQuote]: + """Load mock exchange data. Handles both Deribit format (bid/ask fields) + and Aevo format (bids/asks arrays).""" + path = os.path.join(mock_dir, f"{exchange}_{asset}.json") + try: + with open(path) as f: + data = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return [] + quotes = [] + order_books = data.get("order_books", {}) + is_aevo = exchange == "aevo" + for key, book in order_books.items(): + parsed = _parse_instrument_key(key) + if parsed is None: + continue + strike, opt_type = parsed + if is_aevo: + bids = book.get("bids", []) + asks = book.get("asks", []) + if not bids or not asks: + continue + bid = float(bids[0][0]) + ask = float(asks[0][0]) + bid_iv = float(bids[0][2]) if len(bids[0]) > 2 else None + ask_iv = float(asks[0][2]) if len(asks[0]) > 2 else None + else: + bid = float(book.get("bid", 0)) + ask = float(book.get("ask", 0)) + bid_iv = float(book["bid_iv"]) if "bid_iv" in book else None + ask_iv = float(book["ask_iv"]) if "ask_iv" in book else None + quotes.append(ExchangeQuote( + exchange=exchange, asset=asset, strike=strike, + option_type=opt_type, bid=bid, ask=ask, mid=(bid + ask) / 2, + implied_vol=_avg_iv(bid_iv, ask_iv), + )) + return quotes diff --git a/tools/options-gps/main.py b/tools/options-gps/main.py index f52e390..6fd9936 100644 --- a/tools/options-gps/main.py +++ b/tools/options-gps/main.py @@ -32,6 +32,15 @@ PERCENTILE_LABELS, ) +from exchange import ( + fetch_all_exchanges, + strategy_divergence as _strat_div, + leg_divergences, + best_market_price, + compute_divergence, + compute_edge, +) + SUPPORTED_ASSETS = ["BTC", "ETH", "SOL", "XAU", "SPY", "NVDA", "TSLA", "AAPL", "GOOGL"] @@ -184,11 +193,113 @@ def screen_view_setup(preset_symbol: str | None = None, preset_view: str | None return symbol, view, risk +def _line_shopping_side(exchange_quotes, deribit_quotes, aevo_quotes, opts, strike, opt_type): + """Build data for one side (call or put) of a strike row. + Returns all original columns: fair, deribit_mid, aevo_mid, execute_venue, execute_price, edge, marker.""" + sk = str(int(strike)) if strike == int(strike) else str(strike) + fair = float(opts.get(sk, 0)) + if fair <= 0.01: + return None + best_deribit = best_market_price(deribit_quotes, strike, opt_type) + best_aevo = best_market_price(aevo_quotes, strike, opt_type) + edge = compute_edge(fair, exchange_quotes, strike, opt_type) + best = best_market_price(exchange_quotes, strike, opt_type) + return { + "fair": fair, + "deribit_mid": best_deribit.mid if best_deribit else None, + "aevo_mid": best_aevo.mid if best_aevo else None, + "exec_venue": best.exchange.upper()[:3] if best else None, + "exec_ask": best.ask if best else None, + "z_score": edge.z_score if edge else None, + } + + +def _fmt_price(val, width=7): + """Format a price value or --- if None.""" + if val is None: + return f"{'---':>{width}s}" + return f"{val:>{width},.0f}" + + +# Column widths for line shopping table (per side) +_W = {"synth": 7, "der": 6, "aev": 6, "exec": 9, "edge": 6} +# Side = synth + sp + der + sp + aev + 2sp + exec + sp + edge +_SIDE_W = _W["synth"] + 1 + _W["der"] + 1 + _W["aev"] + 2 + _W["exec"] + 1 + _W["edge"] + + +def _fmt_side(side): + """Format one side (call or put) of a strike row with all columns.""" + dash = lambda w: f"{'---':>{w}s}" + if side is None: + return f"{dash(_W['synth'])} {dash(_W['der'])} {dash(_W['aev'])} {dash(_W['exec'])} {dash(_W['edge'])}" + fair_s = _fmt_price(side["fair"], _W["synth"]) + der_s = _fmt_price(side["deribit_mid"], _W["der"]) + aev_s = _fmt_price(side["aevo_mid"], _W["aev"]) + if side["exec_venue"]: + venue = "DER" if side["exec_venue"].startswith("DER") else "AEV" + exec_s = f"{venue} {side['exec_ask']:>{_W['exec'] - 4},.0f}" + else: + exec_s = dash(_W["exec"]) + if side["z_score"] is not None: + raw = f"{side['z_score']:+.1f}\u03c3" + edge_s = f"{raw:>{_W['edge']}s}" + else: + edge_s = dash(_W["edge"]) + return f"{fair_s} {der_s} {aev_s} {exec_s} {edge_s}" + + +def _print_line_shopping_table(exchange_quotes: list, synth_options: dict, current_price: float): + """Display Market Line Shopping table with statistical edge detection. + Call and put shown side-by-side per strike with all columns: + Synth Fair, Deribit mid, Aevo mid, ★ Execute @ (venue + ask), Edge (z-score).""" + call_opts = synth_options.get("call_options", {}) + put_opts = synth_options.get("put_options", {}) + all_strikes = sorted(set(float(k) for k in list(call_opts.keys()) + list(put_opts.keys()))) + if not all_strikes: + return + # ATM ± 2 strikes + atm_idx = min(range(len(all_strikes)), key=lambda i: abs(all_strikes[i] - current_price)) + start = max(0, atm_idx - 2) + end = min(len(all_strikes), atm_idx + 3) + nearby = all_strikes[start:end] + deribit_quotes = [q for q in exchange_quotes if q.exchange == "deribit"] + aevo_quotes = [q for q in exchange_quotes if q.exchange == "aevo"] + rows = [] + for strike in nearby: + call_side = _line_shopping_side(exchange_quotes, deribit_quotes, aevo_quotes, call_opts, strike, "call") + put_side = _line_shopping_side(exchange_quotes, deribit_quotes, aevo_quotes, put_opts, strike, "put") + if not call_side and not put_side: + continue + rows.append((strike, call_side, put_side)) + if not rows: + return + print(f"{BAR}") + print(_section("MARKET LINE SHOPPING")) + side_hdr = (f"{'Synth':>{_W['synth']}s} {'DER':>{_W['der']}s} {'AEV':>{_W['aev']}s}" + f" {'* Exec':>{_W['exec']}s} {'Edge':>{_W['edge']}s}") + strike_col = 8 # width of strike number + atm_col = 3 # width of ATM marker + sep = " \u2502 " + print(f"{BAR} {'Strike':>{strike_col}s}{'':{atm_col}s} {'CALL':^{_SIDE_W}s}{sep}{'PUT':^{_SIDE_W}s}") + print(f"{BAR} {'':{strike_col}s}{'':{atm_col}s} {side_hdr}{sep}{side_hdr}") + w = strike_col + atm_col + 1 + _SIDE_W + len(sep) + _SIDE_W + print(f"{BAR} {SEP * w}") + atm_strike = nearby[min(range(len(nearby)), key=lambda i: abs(nearby[i] - current_price))] + for strike, call_side, put_side in rows: + atm = " \u25c0 " if strike == atm_strike else " " + c_str = _fmt_side(call_side) + p_str = _fmt_side(put_side) + print(f"{BAR} {strike:>{strike_col},.0f}{atm} {c_str}{sep}{p_str}") + print(f"{BAR} {SEP * w}") + print(f"{BAR} * Exec = best execution venue ask price (DER=Deribit, AEV=Aevo)") + + def screen_market_context(symbol: str, current_price: float, confidence: float, fusion_state: str, vol_future: float, vol_realized: float, volatility_high: bool, p1h_last: dict | None, p24h_last: dict | None, no_trade_reason: str | None, - implied_vol: float = 0.0, vol_bias: str | None = None): + implied_vol: float = 0.0, vol_bias: str | None = None, + exchange_quotes: list | None = None, synth_options: dict | None = None): """Screen 1b: Market context — shows current conditions before recommendations.""" print(_header(f"Market Context: {symbol}")) print(_kv("Price", f"${current_price:,.2f}")) @@ -204,6 +315,8 @@ def screen_market_context(symbol: str, current_price: float, confidence: float, bias_label = (vol_bias or "").replace("_", " ").upper() print(_kv("Implied Vol", f"{implied_vol:.1f}% (from ATM options)")) print(_kv("Synth vs IV", f"{iv_ratio:.2f}x \u2192 {bias_label}")) + if exchange_quotes and synth_options: + _print_line_shopping_table(exchange_quotes, synth_options, current_price) print(f"{BAR}") if p1h_last: p05 = float(p1h_last.get("0.05", 0)) @@ -277,15 +390,25 @@ def _comparison_table(cards: list[tuple[str, ScoredStrategy | None]], current_pr return lines -def _print_strategy_card(label: str, card: ScoredStrategy, icon: str, current_price: float = 0, asset: str = ""): +def _print_strategy_card(label: str, card: ScoredStrategy, icon: str, current_price: float = 0, asset: str = "", + leg_divs: dict | None = None): s = card.strategy ev_pct = (card.expected_value / current_price * 100) if current_price > 0 else 0.0 print(f"{BAR}") print(f"{BAR} {icon} {label}: {s.description}") print(_section("CONSTRUCTION")) if s.legs: - for leg in s.legs: + for i, leg in enumerate(s.legs): print(f"{BAR} {leg.action:<4s} {leg.quantity}x {asset} ${leg.strike:,.0f} {leg.option_type} @ ${leg.premium:,.2f}") + if leg_divs and i in leg_divs: + ld = leg_divs[i] + z = ld["z_score"] + venue = ld["best_exchange"].upper() + price = ld["best_price"] + action_verb = "Buy" if leg.action == "BUY" else "Sell" + price_type = "ask" if leg.action == "BUY" else "bid" + edge_marker = " \u25c6" if abs(z) >= 1.0 else "" + print(f"{BAR} \u2605 {action_verb} @ {venue} {price_type} ${price:,.2f} \u2014 edge {z:+.1f}\u03c3{edge_marker}") net_label = "Net Credit" if s.cost < 0 else "Net Debit" print(f"{BAR} {net_label}: ${abs(s.cost):,.2f} | Expiry: {s.expiry or 'N/A'}") print(_section("METRICS")) @@ -305,7 +428,8 @@ def _print_strategy_card(label: str, card: ScoredStrategy, icon: str, current_pr def screen_top_plays(best: ScoredStrategy | None, safer: ScoredStrategy | None, upside: ScoredStrategy | None, - no_trade_reason: str | None, confidence: float = 0.0, current_price: float = 0, asset: str = ""): + no_trade_reason: str | None, confidence: float = 0.0, current_price: float = 0, asset: str = "", + exchange_quotes: list | None = None, synth_options: dict | None = None): """Screen 2: Comparison table + detailed strategy cards.""" print(_header("Screen 2: Top Plays")) if no_trade_reason: @@ -331,7 +455,12 @@ def screen_top_plays(best: ScoredStrategy | None, safer: ScoredStrategy | None, for label, card, icon in cards: if card is None: continue - _print_strategy_card(label, card, icon, current_price, asset) + leg_divs = None + if exchange_quotes and synth_options: + leg_divs = leg_divergences(card.strategy, exchange_quotes, synth_options) + if not leg_divs: + leg_divs = None + _print_strategy_card(label, card, icon, current_price, asset, leg_divs=leg_divs) print(_footer()) @@ -542,12 +671,12 @@ def screen_if_wrong(best: ScoredStrategy | None, no_trade_reason: str | None, print(_footer()) -def _card_to_log(card: ScoredStrategy | None) -> dict | None: +def _card_to_log(card: ScoredStrategy | None, exchange_divergence: float | None = None) -> dict | None: """Serialize a strategy card for the decision log with full trade construction.""" if card is None: return None s = card.strategy - return { + result = { "description": s.description, "type": s.strategy_type, "legs": [ @@ -565,6 +694,15 @@ def _card_to_log(card: ScoredStrategy | None) -> dict | None: "tail_risk": round(card.tail_risk, 2), "loss_profile": card.loss_profile, } + if exchange_divergence is not None: + z = exchange_divergence + result["exchange_edge_zscore"] = round(z, 2) + result["exchange_edge_label"] = ( + "STRONG" if abs(z) >= 2.0 else + "MODERATE" if abs(z) >= 1.0 else + "WEAK" if abs(z) >= 0.5 else "NONE" + ) + return result def _parse_screen_arg(screen_arg: str) -> set[int]: @@ -627,7 +765,19 @@ def main(): no_trade_reason = should_no_trade(fusion_state, view, volatility_high, confidence, vol_bias=vol_bias) candidates = generate_strategies(options, view, risk, asset=symbol, expiry=expiry) outcome_prices, cdf_values = _outcome_prices_and_cdf(p24h_last) - scored = rank_strategies(candidates, fusion_state, view, outcome_prices, risk, current_price, confidence, vol_ratio, cdf_values=cdf_values, vol_bias=vol_bias) if candidates else [] + # Load exchange data for crypto assets + exchange_quotes = None + divergence_by_strategy = None + if symbol in ("BTC", "ETH", "SOL"): + mock_dir = os.path.join(os.path.dirname(__file__), "..", "..", "mock_data", "exchange_options") + exchange_quotes = fetch_all_exchanges(symbol, mock_dir=mock_dir if not os.environ.get("SYNTH_API_KEY") else None) + if exchange_quotes and candidates: + divergence_by_strategy = {} + for c in candidates: + div = _strat_div(c, exchange_quotes, options) + if div is not None: + divergence_by_strategy[id(c)] = div + scored = rank_strategies(candidates, fusion_state, view, outcome_prices, risk, current_price, confidence, vol_ratio, cdf_values=cdf_values, vol_bias=vol_bias, divergence_by_strategy=divergence_by_strategy) if candidates else [] best, safer, upside = select_three_cards(scored) shown_any = 1 in screens if shown_any: @@ -635,11 +785,13 @@ def main(): screen_market_context(symbol, current_price, confidence, fusion_state, vol_future, vol_realized, volatility_high, p1h_last, p24h_last, no_trade_reason, - implied_vol=implied_vol, vol_bias=vol_bias) + implied_vol=implied_vol, vol_bias=vol_bias, + exchange_quotes=exchange_quotes, synth_options=options) if 2 in screens: if shown_any: _pause("Screen 2: Top Plays", args.no_prompt) - screen_top_plays(best, safer, upside, no_trade_reason, confidence, current_price, asset=symbol) + screen_top_plays(best, safer, upside, no_trade_reason, confidence, current_price, asset=symbol, + exchange_quotes=exchange_quotes, synth_options=options) shown_any = True if 3 in screens: if shown_any: @@ -672,9 +824,10 @@ def main(): "no_trade_reason": no_trade_reason, "candidates_generated": len(candidates), "candidates_after_filters": len(scored), - "best_match": _card_to_log(best), - "safer_alt": _card_to_log(safer), - "higher_upside": _card_to_log(upside), + "exchange_data_available": bool(exchange_quotes), + "best_match": _card_to_log(best, divergence_by_strategy.get(id(best.strategy)) if divergence_by_strategy and best else None), + "safer_alt": _card_to_log(safer, divergence_by_strategy.get(id(safer.strategy)) if divergence_by_strategy and safer else None), + "higher_upside": _card_to_log(upside, divergence_by_strategy.get(id(upside.strategy)) if divergence_by_strategy and upside else None), } print(_header("Decision Log (JSON)")) for line in json.dumps(decision_log, indent=2, ensure_ascii=False).split("\n"): diff --git a/tools/options-gps/pipeline.py b/tools/options-gps/pipeline.py index 67e3efc..87265a0 100644 --- a/tools/options-gps/pipeline.py +++ b/tools/options-gps/pipeline.py @@ -697,13 +697,16 @@ def rank_strategies( volatility_ratio: float = 1.0, cdf_values: list[float] | None = None, vol_bias: VolBias | None = None, + divergence_by_strategy: dict | None = None, ) -> list[ScoredStrategy]: """Score and sort strategies. Returns list of ScoredStrategy sorted by score desc. volatility_ratio = forecast_vol / realized_vol (1.0 = normal). When elevated, defined-risk strategies get a bonus and naked/premium strategies get a penalty. cdf_values enables probability-weighted PoP/EV when provided. vol_bias controls scoring for vol view: long_vol favours straddles/strangles, - short_vol favours iron condors and short straddles/strangles.""" + short_vol favours iron condors and short straddles/strangles. + divergence_by_strategy maps id(candidate) -> z_score for exchange edge detection. + Higher |z| = stronger conviction (alpha is in disagreement, not consensus).""" vol_elevated = volatility_ratio > 1.15 scored: list[ScoredStrategy] = [] for c in candidates: @@ -745,6 +748,11 @@ def rank_strategies( score -= 0.10 if view != "vol": score *= confidence + if divergence_by_strategy is not None: + div = divergence_by_strategy.get(id(c)) + if div is not None: + div_bonus = max(-0.15, min(0.15, div * 0.06)) + score += div_bonus invalidation, reroute, review_time = _risk_plan(c) ev_pct = (ev / current_price * 100) if current_price > 0 else 0.0 vol_note = "" diff --git a/tools/options-gps/tests/conftest.py b/tools/options-gps/tests/conftest.py new file mode 100644 index 0000000..e1e90b1 --- /dev/null +++ b/tools/options-gps/tests/conftest.py @@ -0,0 +1,104 @@ +"""Shared fixtures for exchange/line-shopping tests. +Does NOT affect existing test_pipeline.py which uses module-level constants.""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest +from exchange import ExchangeQuote +from pipeline import StrategyCandidate, StrategyLeg, generate_strategies, run_forecast_fusion, forecast_confidence + +MOCK_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", "mock_data", "exchange_options") + +BTC_OPTION_DATA = { + "current_price": 67723, + "call_options": {"67000": 987, "67500": 640, "68000": 373}, + "put_options": {"67000": 140, "67500": 291, "68000": 526}, +} + +P24H = { + "0.05": 66000, "0.2": 67000, "0.35": 67400, + "0.5": 67800, "0.65": 68200, "0.8": 68800, "0.95": 70000, +} + + +@pytest.fixture +def mock_exchange_dir(): + return MOCK_DIR + + +@pytest.fixture +def btc_option_data(): + return { + "current_price": 67723.50, + "call_options": { + "65000": 2847.68, "66000": 1864.60, "67000": 987.04, + "67500": 638.43, "68000": 373.27, "68500": 197.11, + "69000": 93.43, "70000": 15.13, + }, + "put_options": { + "65000": 0.99, "66000": 17.91, "67000": 140.36, + "67500": 291.75, "68000": 526.59, "68500": 850.42, + "69000": 1246.74, "70000": 2168.44, + }, + } + + +@pytest.fixture +def sample_strategy(): + """A simple long call for testing divergence.""" + return StrategyCandidate( + strategy_type="long_call", direction="bullish", + description="Long 67500 Call", strikes=[67500], + cost=638.43, max_loss=638.43, + legs=[StrategyLeg(action="BUY", quantity=1, option_type="Call", strike=67500, premium=638.43)], + ) + + +@pytest.fixture +def multi_leg_strategy(): + """A call debit spread for testing multi-leg divergence.""" + return StrategyCandidate( + strategy_type="call_debit_spread", direction="bullish", + description="Call Debit Spread 67000/68000", strikes=[67000, 68000], + cost=987.04 - 373.27, max_loss=987.04 - 373.27, + legs=[ + StrategyLeg(action="BUY", quantity=1, option_type="Call", strike=67000, premium=987.04), + StrategyLeg(action="SELL", quantity=1, option_type="Call", strike=68000, premium=373.27), + ], + ) + + +@pytest.fixture +def sample_exchange_quotes(): + """Pre-built list of ExchangeQuote objects.""" + return [ + ExchangeQuote(exchange="deribit", asset="BTC", strike=67500, option_type="call", + bid=610.0, ask=660.0, mid=635.0, implied_vol=51.2), + ExchangeQuote(exchange="aevo", asset="BTC", strike=67500, option_type="call", + bid=620.0, ask=655.0, mid=637.5, implied_vol=51.25), + ExchangeQuote(exchange="deribit", asset="BTC", strike=67500, option_type="put", + bid=275.0, ask=310.0, mid=292.5, implied_vol=51.0), + ExchangeQuote(exchange="aevo", asset="BTC", strike=67500, option_type="put", + bid=280.0, ask=305.0, mid=292.5, implied_vol=51.0), + ExchangeQuote(exchange="deribit", asset="BTC", strike=67000, option_type="call", + bid=950.0, ask=1025.0, mid=987.5, implied_vol=51.8), + ExchangeQuote(exchange="aevo", asset="BTC", strike=67000, option_type="call", + bid=960.0, ask=1010.0, mid=985.0, implied_vol=51.75), + ExchangeQuote(exchange="deribit", asset="BTC", strike=68000, option_type="call", + bid=355.0, ask=390.0, mid=372.5, implied_vol=50.8), + ExchangeQuote(exchange="aevo", asset="BTC", strike=68000, option_type="call", + bid=360.0, ask=385.0, mid=372.5, implied_vol=50.75), + ] + + +@pytest.fixture +def ranking_context(): + """Shared setup for ranking integration tests: candidates, outcome_prices, fusion, confidence.""" + candidates = generate_strategies(BTC_OPTION_DATA, "bullish", "medium") + outcome_prices = [float(P24H[k]) for k in sorted(P24H.keys())] + fusion = run_forecast_fusion(None, P24H, 67723) + confidence = forecast_confidence(P24H, 67723) + return candidates, outcome_prices, fusion, confidence diff --git a/tools/options-gps/tests/test_exchange.py b/tools/options-gps/tests/test_exchange.py new file mode 100644 index 0000000..0bdc559 --- /dev/null +++ b/tools/options-gps/tests/test_exchange.py @@ -0,0 +1,239 @@ +"""Tests for exchange.py: Market Line Shopping — fetch, parse, edge detection, and ranking integration.""" + +import os +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from exchange import ( + ExchangeQuote, + EdgeMetrics, + fetch_deribit, + fetch_aevo, + fetch_all_exchanges, + best_market_price, + best_execution_price, + compute_divergence, + compute_edge, + leg_divergences, + strategy_divergence, +) +from pipeline import rank_strategies + + +class TestFetchParse: + def test_deribit_mock(self, mock_exchange_dir): + quotes = fetch_deribit("BTC", mock_dir=mock_exchange_dir) + assert len(quotes) > 0 + assert all(q.exchange == "deribit" for q in quotes) + + def test_aevo_mock(self, mock_exchange_dir): + quotes = fetch_aevo("BTC", mock_dir=mock_exchange_dir) + assert len(quotes) > 0 + assert all(q.exchange == "aevo" for q in quotes) + + def test_all_exchanges_mock(self, mock_exchange_dir): + quotes = fetch_all_exchanges("BTC", mock_dir=mock_exchange_dir) + assert {"deribit", "aevo"} == {q.exchange for q in quotes} + + def test_non_crypto_returns_empty(self, mock_exchange_dir): + for asset in ("XAU", "SPY", "NVDA", "TSLA", "AAPL", "GOOGL"): + assert fetch_all_exchanges(asset, mock_dir=mock_exchange_dir) == [] + + def test_mid_price(self, mock_exchange_dir): + for q in fetch_deribit("BTC", mock_dir=mock_exchange_dir): + assert abs(q.mid - (q.bid + q.ask) / 2) < 0.01 + + def test_fields_populated(self, mock_exchange_dir): + for q in fetch_deribit("BTC", mock_dir=mock_exchange_dir): + assert q.strike > 0 + assert q.option_type in ("call", "put") + + def test_empty_mock_graceful(self): + with tempfile.TemporaryDirectory() as tmpdir: + assert fetch_deribit("BTC", mock_dir=tmpdir) == [] + assert fetch_aevo("BTC", mock_dir=tmpdir) == [] + + def test_invalid_json_graceful(self): + with tempfile.TemporaryDirectory() as tmpdir: + for fname in ("deribit_BTC.json", "aevo_BTC.json"): + with open(os.path.join(tmpdir, fname), "w") as f: + f.write("not valid json {{{") + assert fetch_deribit("BTC", mock_dir=tmpdir) == [] + assert fetch_aevo("BTC", mock_dir=tmpdir) == [] + + +class TestBestMarketPrice: + def test_lowest_ask(self, sample_exchange_quotes): + best = best_market_price(sample_exchange_quotes, 67500, "call") + assert best is not None + assert best.exchange == "aevo" # ask=655 < 660 + assert best.ask == 655.0 + + def test_no_match(self, sample_exchange_quotes): + assert best_market_price(sample_exchange_quotes, 99999, "call") is None + + def test_single_exchange(self): + quotes = [ExchangeQuote("deribit", "BTC", 67500, "call", 610.0, 660.0, 635.0, 51.0)] + assert best_market_price(quotes, 67500, "call").exchange == "deribit" + + def test_prefers_lower_ask(self): + quotes = [ + ExchangeQuote("deribit", "BTC", 67500, "call", 610.0, 660.0, 635.0, 51.0), + ExchangeQuote("aevo", "BTC", 67500, "call", 630.0, 650.0, 640.0, 51.0), + ] + assert best_market_price(quotes, 67500, "call").exchange == "aevo" + + def test_execution_buy_lowest_ask(self, sample_exchange_quotes): + best = best_execution_price(sample_exchange_quotes, 67500, "call", "BUY") + assert best.exchange == "aevo" # ask=655 < 660 + assert best.ask == 655.0 + + def test_execution_sell_highest_bid(self, sample_exchange_quotes): + best = best_execution_price(sample_exchange_quotes, 67500, "call", "SELL") + assert best.exchange == "aevo" # bid=620 > 610 + assert best.bid == 620.0 + + +class TestEdgeDetection: + def test_compute_edge_basic(self, sample_exchange_quotes): + edge = compute_edge(638.43, sample_exchange_quotes, 67500, "call") + assert edge is not None + assert isinstance(edge, EdgeMetrics) + assert edge.n_sources == 3 # synth + 2 exchanges + + def test_compute_edge_z_score_positive(self, sample_exchange_quotes): + """Synth > market_mean → positive z-score (market underpriced).""" + edge = compute_edge(638.43, sample_exchange_quotes, 67500, "call") + # Synth 638.43 > market_mean ~636.25 → positive + assert edge.z_score > 0 + + def test_compute_edge_z_score_negative(self, sample_exchange_quotes): + """Synth < market_mean → negative z-score (market overpriced).""" + # 67500 put: synth ~291.75, market ~292.5 → negative + edge = compute_edge(291.75, sample_exchange_quotes, 67500, "put") + assert edge is not None + assert edge.z_score < 0 + + def test_compute_edge_no_match(self, sample_exchange_quotes): + assert compute_edge(100.0, sample_exchange_quotes, 99999, "call") is None + + def test_edge_label_strong(self): + """Large disagreement → STRONG edge.""" + quotes = [ + ExchangeQuote("deribit", "BTC", 67500, "call", 590.0, 620.0, 605.0, 51.0), + ExchangeQuote("aevo", "BTC", 67500, "call", 595.0, 615.0, 605.0, 51.0), + ] + edge = compute_edge(640.0, quotes, 67500, "call") # 640 vs 605 = big gap + assert edge.edge_label == "STRONG" + assert abs(edge.z_score) >= 2.0 + + def test_edge_label_none_when_close(self): + """Prices all agree → NONE edge.""" + quotes = [ + ExchangeQuote("deribit", "BTC", 67500, "call", 634.0, 636.0, 635.0, 51.0), + ExchangeQuote("aevo", "BTC", 67500, "call", 634.5, 636.5, 635.5, 51.0), + ] + edge = compute_edge(635.25, quotes, 67500, "call") # right at market mean + assert edge.edge_label == "NONE" + assert abs(edge.z_score) < 0.5 + + def test_edge_std_dev_floor(self): + """When all prices identical, noise floor prevents div-by-zero.""" + quotes = [ + ExchangeQuote("deribit", "BTC", 67500, "call", 635.0, 635.0, 635.0, 51.0), + ] + edge = compute_edge(635.0, quotes, 67500, "call") + assert edge is not None + assert edge.std_dev > 0 # floor applied + + +class TestDivergence: + def test_positive(self): + assert abs(compute_divergence(100.0, 95.0) - 5.0) < 0.01 + + def test_negative(self): + assert abs(compute_divergence(100.0, 105.0) - (-5.0)) < 0.01 + + def test_zero(self): + assert compute_divergence(100.0, 100.0) == 0.0 + + def test_zero_fair(self): + assert compute_divergence(0.0, 50.0) == 0.0 + + def test_leg_divergences_all_legs(self, multi_leg_strategy, sample_exchange_quotes, btc_option_data): + divs = leg_divergences(multi_leg_strategy, sample_exchange_quotes, btc_option_data) + assert len(divs) == 2 + expected_keys = {"divergence_pct", "z_score", "edge_label", "best_exchange", + "best_price", "synth_fair", "market_mean", "std_dev"} + for d in divs.values(): + assert set(d.keys()) == expected_keys + + def test_leg_divergences_has_z_score(self, multi_leg_strategy, sample_exchange_quotes, btc_option_data): + divs = leg_divergences(multi_leg_strategy, sample_exchange_quotes, btc_option_data) + for d in divs.values(): + assert isinstance(d["z_score"], float) + assert d["edge_label"] in ("STRONG", "MODERATE", "WEAK", "NONE") + + def test_leg_divergences_missing_exchange(self, sample_strategy, btc_option_data): + assert leg_divergences(sample_strategy, [], btc_option_data) == {} + + def test_strategy_divergence_average_zscore(self, multi_leg_strategy, sample_exchange_quotes, btc_option_data): + div = strategy_divergence(multi_leg_strategy, sample_exchange_quotes, btc_option_data) + leg_divs = leg_divergences(multi_leg_strategy, sample_exchange_quotes, btc_option_data) + expected = sum(d["z_score"] for d in leg_divs.values()) / len(leg_divs) + assert abs(div - expected) < 0.01 + + def test_strategy_divergence_none_when_no_data(self, sample_strategy): + assert strategy_divergence(sample_strategy, [], {"call_options": {}, "put_options": {}}) is None + + def test_leg_divergences_action_aware(self, multi_leg_strategy, sample_exchange_quotes, btc_option_data): + """BUY legs get lowest ask, SELL legs get highest bid.""" + divs = leg_divergences(multi_leg_strategy, sample_exchange_quotes, btc_option_data) + # Leg 0: BUY Call 67000 → best_price should be lowest ask + assert divs[0]["best_price"] == 1010.0 # Aevo ask < Deribit ask (1025) + # Leg 1: SELL Call 68000 → best_price should be highest bid + assert divs[1]["best_price"] == 360.0 # Aevo bid > Deribit bid (355) + + +class TestRankingIntegration: + def test_positive_divergence_boosts_score(self, ranking_context): + candidates, outcome_prices, fusion, confidence = ranking_context + scored_base = rank_strategies(candidates, fusion, "bullish", outcome_prices, "medium", 67723, confidence) + div_map = {id(c): 2.0 for c in candidates} + scored_div = rank_strategies(candidates, fusion, "bullish", outcome_prices, "medium", 67723, confidence, + divergence_by_strategy=div_map) + assert scored_div[0].score > scored_base[0].score + + def test_negative_divergence_reduces_score(self, ranking_context): + candidates, outcome_prices, fusion, confidence = ranking_context + scored_base = rank_strategies(candidates, fusion, "bullish", outcome_prices, "medium", 67723, confidence) + div_map = {id(c): -2.0 for c in candidates} + scored_div = rank_strategies(candidates, fusion, "bullish", outcome_prices, "medium", 67723, confidence, + divergence_by_strategy=div_map) + assert scored_div[0].score < scored_base[0].score + + def test_clamped_at_015(self, ranking_context): + candidates, outcome_prices, fusion, confidence = ranking_context + extreme = rank_strategies(candidates, fusion, "bullish", outcome_prices, "medium", 67723, confidence, + divergence_by_strategy={id(c): 50.0 for c in candidates}) + at_clamp = rank_strategies(candidates, fusion, "bullish", outcome_prices, "medium", 67723, confidence, + divergence_by_strategy={id(c): 3.0 for c in candidates}) + assert abs(extreme[0].score - at_clamp[0].score) < 0.01 + + def test_none_no_effect(self, ranking_context): + candidates, outcome_prices, fusion, confidence = ranking_context + a = rank_strategies(candidates, fusion, "bullish", outcome_prices, "medium", 67723, confidence) + b = rank_strategies(candidates, fusion, "bullish", outcome_prices, "medium", 67723, confidence, + divergence_by_strategy=None) + for sa, sb in zip(a, b): + assert abs(sa.score - sb.score) < 0.001 + + def test_empty_dict_no_effect(self, ranking_context): + candidates, outcome_prices, fusion, confidence = ranking_context + a = rank_strategies(candidates, fusion, "bullish", outcome_prices, "medium", 67723, confidence) + b = rank_strategies(candidates, fusion, "bullish", outcome_prices, "medium", 67723, confidence, + divergence_by_strategy={}) + for sa, sb in zip(a, b): + assert abs(sa.score - sb.score) < 0.001 diff --git a/tools/options-gps/tests/test_line_shopping_e2e.py b/tools/options-gps/tests/test_line_shopping_e2e.py new file mode 100644 index 0000000..b84d630 --- /dev/null +++ b/tools/options-gps/tests/test_line_shopping_e2e.py @@ -0,0 +1,162 @@ +"""End-to-end scripted test for Market Line Shopping (issue #32). +Runs the full pipeline with Synth mock + exchange mock data and verifies +exchange data flows through divergence scoring to ranking output.""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from pipeline import ( + generate_strategies, + rank_strategies, + select_three_cards, + forecast_confidence, + run_forecast_fusion, +) +from exchange import ( + fetch_all_exchanges, + strategy_divergence, + leg_divergences, +) + +MOCK_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", "mock_data", "exchange_options") + +OPTION_DATA = { + "current_price": 67723, + "call_options": { + "65000": 2847.68, "66000": 1864.60, "67000": 987.04, + "67500": 638.43, "68000": 373.27, "68500": 197.11, + "69000": 93.43, "70000": 15.13, + }, + "put_options": { + "65000": 0.99, "66000": 17.91, "67000": 140.36, + "67500": 291.75, "68000": 526.59, "68500": 850.42, + "69000": 1246.74, "70000": 2168.44, + }, +} + +P24H = { + "0.05": 66000, "0.2": 67000, "0.35": 67400, + "0.5": 67800, "0.65": 68200, "0.8": 68800, "0.95": 70000, +} + +CURRENT_PRICE = 67723.0 + + +def test_full_line_shopping_pipeline(): + """Load Synth mock + exchange mock -> compute divergence -> rank -> verify exchange data flows through.""" + # Step 1: Load exchange quotes from mock + quotes = fetch_all_exchanges("BTC", mock_dir=MOCK_DIR) + assert len(quotes) > 0, "Should load exchange quotes from mock" + exchanges = {q.exchange for q in quotes} + assert "deribit" in exchanges + assert "aevo" in exchanges + + # Step 2: Generate strategies from Synth mock options + candidates = generate_strategies(OPTION_DATA, "bullish", "medium", asset="BTC") + assert len(candidates) > 0 + + # Step 3: Compute divergence_by_strategy + divergence_by_strategy = {} + for c in candidates: + div = strategy_divergence(c, quotes, OPTION_DATA) + if div is not None: + divergence_by_strategy[id(c)] = div + + # At least some strategies should have exchange divergence data + assert len(divergence_by_strategy) > 0, "Some strategies should have exchange data" + + # Step 4: Rank with divergence + fusion = run_forecast_fusion(None, P24H, CURRENT_PRICE) + confidence = forecast_confidence(P24H, CURRENT_PRICE) + outcome_prices = [float(P24H[k]) for k in sorted(P24H.keys())] + + scored = rank_strategies( + candidates, fusion, "bullish", outcome_prices, "medium", CURRENT_PRICE, + confidence=confidence, divergence_by_strategy=divergence_by_strategy, + ) + assert len(scored) >= 2 + + # Step 5: Best strategy should exist and have valid score + best_card, safer, upside = select_three_cards(scored) + assert best_card is not None + assert best_card.score > 0 + + # Step 6: Verify ranking changes vs no-divergence baseline + scored_base = rank_strategies( + candidates, fusion, "bullish", outcome_prices, "medium", CURRENT_PRICE, + confidence=confidence, + ) + # Scores should differ when divergence is applied + has_difference = False + for s_div, s_base in zip(scored, scored_base): + if abs(s_div.score - s_base.score) > 0.001: + has_difference = True + break + assert has_difference, "Divergence should affect at least some scores" + + # Step 7: Verify leg divergences work for the best strategy + leg_divs = leg_divergences(best_card.strategy, quotes, OPTION_DATA) + # Should have at least one leg with exchange data + if best_card.strategy.legs: + # Only expect data if exchange has that strike + strikes_in_exchange = {q.strike for q in quotes} + strategy_strikes = {leg.strike for leg in best_card.strategy.legs} + if strikes_in_exchange & strategy_strikes: + assert len(leg_divs) > 0 + + +def test_non_crypto_skips_exchange(): + """XAU asset -> no exchange data -> ranking unchanged.""" + quotes = fetch_all_exchanges("XAU", mock_dir=MOCK_DIR) + assert quotes == [] + + xau_options = { + "current_price": 2000, + "call_options": {"1950": 60, "2000": 30, "2050": 10}, + "put_options": {"1950": 10, "2000": 30, "2050": 60}, + } + p24h = {"0.05": 1950, "0.2": 1970, "0.35": 1985, + "0.5": 2000, "0.65": 2015, "0.8": 2030, "0.95": 2050} + candidates = generate_strategies(xau_options, "bullish", "medium") + outcome_prices = [float(p24h[k]) for k in sorted(p24h.keys())] + fusion = run_forecast_fusion(None, p24h, 2000) + confidence = forecast_confidence(p24h, 2000) + + scored_a = rank_strategies(candidates, fusion, "bullish", outcome_prices, "medium", 2000, confidence) + scored_b = rank_strategies(candidates, fusion, "bullish", outcome_prices, "medium", 2000, confidence, + divergence_by_strategy=None) + assert len(scored_a) == len(scored_b) + for a, b in zip(scored_a, scored_b): + assert abs(a.score - b.score) < 0.001 + + +def test_exchange_failure_graceful(): + """Invalid mock dir -> empty quotes -> ranking proceeds normally.""" + quotes = fetch_all_exchanges("BTC", mock_dir="/nonexistent/path") + assert quotes == [] + + candidates = generate_strategies(OPTION_DATA, "bullish", "medium", asset="BTC") + outcome_prices = [float(P24H[k]) for k in sorted(P24H.keys())] + fusion = run_forecast_fusion(None, P24H, CURRENT_PRICE) + confidence = forecast_confidence(P24H, CURRENT_PRICE) + + # Should rank fine without exchange data + scored = rank_strategies( + candidates, fusion, "bullish", outcome_prices, "medium", CURRENT_PRICE, + confidence=confidence, divergence_by_strategy=None, + ) + assert len(scored) > 0 + best, safer, upside = select_three_cards(scored) + assert best is not None + + +if __name__ == "__main__": + test_full_line_shopping_pipeline() + print("PASS: test_full_line_shopping_pipeline") + test_non_crypto_skips_exchange() + print("PASS: test_non_crypto_skips_exchange") + test_exchange_failure_graceful() + print("PASS: test_exchange_failure_graceful") + print("\nAll line shopping E2E tests passed.")