Files
manifoldbt/python/tests/test_options.py
T
2026-08-23 13:31:37 +00:00

366 lines
12 KiB
Python

"""Tests for the option path from Python: contract terms in, settlement out.
The Rust side already proves the payoff arithmetic and the simulation loop.
What is under test here is the bridge: terms recorded at ingest must reach the
engine, and the one thing the user has to decide (which price series settles the
contract) must fail loudly when it is missing rather than be guessed.
"""
import os
import pytest
import manifoldbt as bt
pd = pytest.importorskip("pandas")
OPTION_ID = 2
UNDERLYING_ID = 1
STRIKE = 50_000.0
N_BARS = 40
# Expiry lands on bar 30 of a 40-bar daily series starting 2020-01-01.
EXPIRY_MS = 1_577_836_800_000 + 30 * 86_400_000
def _daily(prices):
ts = pd.date_range("2020-01-01", periods=len(prices), freq="1D", tz="UTC")
return pd.DataFrame(
{
"timestamp": ts,
"open": prices,
"high": prices,
"low": prices,
"close": prices,
"volume": [100.0] * len(prices),
}
)
def _store(tmp_path, underlying_price, premium, option_class="option"):
"""A two-symbol store: a perpetual and a call written against it.
``option_class`` exists so a test can write the same series as a plain
linear instrument, which is a different thing from an option missing its
terms.
"""
root = os.path.join(str(tmp_path), "data")
meta = os.path.join(str(tmp_path), "m.sqlite")
store = bt.import_dataframe(
_daily([underlying_price] * N_BARS),
symbol="BTC-PERPETUAL",
symbol_id=UNDERLYING_ID,
interval="1d",
data_root=root,
metadata_db=meta,
asset_class="crypto_perp",
)
store = bt.import_dataframe(
_daily([premium] * N_BARS),
symbol="BTC-CALL",
symbol_id=OPTION_ID,
interval="1d",
data_root=root,
metadata_db=meta,
asset_class=option_class,
)
return store, root, meta
def _write_terms(meta_db, settlement="cash_inverse"):
"""Record contract terms the way an option connector would."""
import sqlite3
conn = sqlite3.connect(meta_db)
conn.execute(
"UPDATE symbols SET option_underlying = ?, option_type = ?, option_strike = ?,"
" option_expiry = ?, option_contract_size = ?, option_settlement = ?"
" WHERE id = ?",
(
"BTC_USD index",
"call",
STRIKE,
pd.Timestamp(EXPIRY_MS, unit="ms", tz="UTC").isoformat().replace("+00:00", "Z"),
1.0,
settlement,
OPTION_ID,
),
)
conn.commit()
conn.close()
def _config(**kwargs):
from manifoldbt.helpers import time_range, Interval
start, end = time_range("2020-01-01", "2020-03-01")
base = dict(
universe=[UNDERLYING_ID, OPTION_ID],
time_range_start=start,
time_range_end=end,
bar_interval=Interval.days(1),
initial_capital=10.0,
currency="BTC",
execution=bt.ExecutionConfig(position_sizing_mode="Units"),
)
base.update(kwargs)
return bt.BacktestConfig(**base)
def _hold(**per_symbol):
"""Hold a fixed number of units of each named symbol id, every bar.
Legs are told apart by `col("symbol_id")` rather than by price level. A
price threshold is a trap: a premium crossing it flips the leg to zero and
the strategy closes its own position, which is exactly how an earlier
version of this file broke.
"""
from manifoldbt.indicators import col
size = bt.when(col("symbol_id") < 0.0, 0.0, 0.0) # a typed zero to fold onto
for symbol_id, units in per_symbol.items():
size = size + bt.when(col("symbol_id") == float(symbol_id), float(units), 0.0)
return (
bt.Strategy.create("hold")
.signal("leg", col("symbol_id"))
.size(size)
.describe("Fixed units per leg, held into expiry")
)
def _long_one_option():
return _hold(**{str(OPTION_ID): 1.0})
def test_contract_terms_round_trip_to_python(tmp_path):
_, _, meta = _store(tmp_path, 60_000.0, 0.05)
_write_terms(meta)
store = bt.DataStore(os.path.join(str(tmp_path), "data"), meta)
terms = store.option_contracts()
assert OPTION_ID in terms
assert terms[OPTION_ID]["option_type"] == "call"
assert terms[OPTION_ID]["strike"] == STRIKE
assert terms[OPTION_ID]["settlement"] == "cash_inverse"
assert UNDERLYING_ID not in terms, "a perpetual has no contract terms"
def test_an_option_without_a_declared_underlying_is_refused(tmp_path):
store, _, meta = _store(tmp_path, 60_000.0, 0.05)
_write_terms(meta)
# The public API re-classifies the failure, so catch what a user catches.
from manifoldbt.exceptions import DataError
with pytest.raises(DataError) as excinfo:
bt.run(_long_one_option(), _config(), store)
message = str(excinfo.value)
assert "option_underlyings" in message
assert "own last traded premium" in message
def test_a_call_expiring_in_the_money_settles_at_intrinsic(tmp_path):
# S = 60k against a 50k strike, inverse settlement: 10000/60000 BTC.
store, _, meta = _store(tmp_path, 60_000.0, 0.05)
_write_terms(meta)
result = bt.run(
_long_one_option(),
_config(option_underlyings={OPTION_ID: UNDERLYING_ID}),
store,
)
trades = result.trades.to_pandas()
settlements = trades[(trades.symbol_id == OPTION_ID) & (trades.exit_reason == 5)]
assert len(settlements) == 1, f"expected one settlement, got:\n{trades}"
assert settlements.iloc[0].fill_price == pytest.approx(10_000.0 / 60_000.0, abs=1e-12)
def test_a_call_expiring_out_of_the_money_settles_at_zero(tmp_path):
store, _, meta = _store(tmp_path, 40_000.0, 0.05)
_write_terms(meta)
result = bt.run(
_long_one_option(),
_config(option_underlyings={OPTION_ID: UNDERLYING_ID}),
store,
)
trades = result.trades.to_pandas()
settlements = trades[(trades.symbol_id == OPTION_ID) & (trades.exit_reason == 5)]
assert len(settlements) == 1
assert settlements.iloc[0].fill_price == 0.0
# The premium paid is the whole loss, and it is a loss.
assert float(result.equity_curve[-1]) < float(result.equity_curve[0])
def test_a_linear_universe_is_untouched_by_the_option_path(tmp_path):
# Two ordinary linear instruments: the option path must not touch them.
store, _, _ = _store(tmp_path, 40_000.0, 0.05, option_class="crypto_spot")
result = bt.run(_long_one_option(), _config(), store)
trades = result.trades.to_pandas()
assert (trades.exit_reason != 5).all(), "nothing may settle without contract terms"
def test_an_option_symbol_without_contract_terms_is_refused(tmp_path):
"""The Databento case before this branch: an option that never expires.
A symbol recorded as an option but carrying no strike or expiration would
otherwise price, trade and be held forever at its last quoted premium, with
nothing in the output looking wrong.
"""
from manifoldbt.exceptions import DataError
store, _, _ = _store(tmp_path, 60_000.0, 0.05) # asset_class="option", no terms
with pytest.raises(DataError) as excinfo:
bt.run(_long_one_option(), _config(), store)
message = str(excinfo.value)
assert "no contract terms" in message
assert "deribit, databento" in message
def test_a_multiplier_option_costs_and_settles_like_one_contract(tmp_path):
"""A listed-style option: 100 units of premium IS one exchange contract."""
# Premium 4.70, underlying 490, strike 470 -> one contract pays (490-470)*100.
store, _, meta = _store(tmp_path, 490.0, 4.70)
import sqlite3
conn = sqlite3.connect(meta)
conn.execute(
"UPDATE symbols SET option_underlying = ?, option_type = ?, option_strike = ?,"
" option_expiry = ?, option_contract_size = ?, option_settlement = ? WHERE id = ?",
(
"SPY",
"call",
470.0,
pd.Timestamp(EXPIRY_MS, unit="ms", tz="UTC").isoformat().replace("+00:00", "Z"),
100.0,
"cash_linear",
OPTION_ID,
),
)
conn.commit()
conn.close()
# 100 units of the option leg, nothing on the underlying.
hold_one_contract = _hold(**{str(OPTION_ID): 100.0})
config = _config(
initial_capital=100_000.0,
option_underlyings={OPTION_ID: UNDERLYING_ID},
)
result = bt.run(hold_one_contract, config, store)
trades = result.trades.to_pandas()
legs = trades[trades.symbol_id == OPTION_ID]
entry = legs[legs.exit_reason == 0].iloc[0]
assert entry.quantity * entry.fill_price == pytest.approx(470.0), "what one contract costs"
settlement = legs[legs.exit_reason == 5].iloc[0]
assert settlement.fill_price == pytest.approx(20.0), "intrinsic per share, not per contract"
assert settlement.quantity * settlement.fill_price == pytest.approx(
2_000.0
), "what one contract pays"
PUT_ID = 3
def _store_two_legs(tmp_path, underlying_price, call_premium, put_premium):
"""Underlying + a call + a put, all daily, all the same length."""
root = os.path.join(str(tmp_path), "data")
meta = os.path.join(str(tmp_path), "m.sqlite")
for symbol, symbol_id, price, klass in (
("BTC-PERPETUAL", UNDERLYING_ID, underlying_price, "crypto_perp"),
("BTC-CALL", OPTION_ID, call_premium, "option"),
("BTC-PUT", PUT_ID, put_premium, "option"),
):
store = bt.import_dataframe(
_daily([price] * N_BARS),
symbol=symbol,
symbol_id=symbol_id,
interval="1d",
data_root=root,
metadata_db=meta,
asset_class=klass,
)
return store, meta
def _write_leg_terms(meta_db, symbol_id, option_type, strike):
import sqlite3
conn = sqlite3.connect(meta_db)
conn.execute(
"UPDATE symbols SET option_underlying = ?, option_type = ?, option_strike = ?,"
" option_expiry = ?, option_contract_size = ?, option_settlement = ? WHERE id = ?",
(
"BTC_USD index",
option_type,
strike,
pd.Timestamp(EXPIRY_MS, unit="ms", tz="UTC").isoformat().replace("+00:00", "Z"),
1.0,
"cash_inverse",
symbol_id,
),
)
conn.commit()
conn.close()
def test_a_two_leg_structure_settles_each_leg_on_its_own_terms(tmp_path):
"""A risk reversal: long a call, short a put, both expiring together.
Each leg settles against the same underlying but on its own strike and
side, so one finishes in the money and the other worthless.
"""
# S = 60k at expiry: the 50k call is ITM, the 40k put is worthless.
store, meta = _store_two_legs(tmp_path, 60_000.0, 0.05, 0.03)
_write_leg_terms(meta, OPTION_ID, "call", 50_000.0)
_write_leg_terms(meta, PUT_ID, "put", 40_000.0)
config = _config(
universe=[UNDERLYING_ID, OPTION_ID, PUT_ID],
option_underlyings={OPTION_ID: UNDERLYING_ID, PUT_ID: UNDERLYING_ID},
option_margin_model="deribit",
execution=bt.ExecutionConfig(position_sizing_mode="Units", allow_short=True),
)
result = bt.run(
_hold(**{str(OPTION_ID): 1.0, str(PUT_ID): -1.0}),
config,
store,
)
trades = result.trades.to_pandas()
settlements = trades[trades.exit_reason == 5]
assert set(settlements.symbol_id) == {OPTION_ID, PUT_ID}, "both legs must settle"
call = settlements[settlements.symbol_id == OPTION_ID].iloc[0]
put = settlements[settlements.symbol_id == PUT_ID].iloc[0]
assert call.fill_price == pytest.approx(10_000.0 / 60_000.0, abs=1e-12)
assert put.fill_price == 0.0, "a 40k put is worthless with the underlying at 60k"
# Long the call, short the put: the short is bought back to close.
assert call.side == 2 and put.side == 1
def test_per_leg_sizing_leaves_the_other_leg_flat(tmp_path):
"""`col("symbol_id")` must target one leg without disturbing the others."""
store, meta = _store_two_legs(tmp_path, 60_000.0, 0.05, 0.03)
_write_leg_terms(meta, OPTION_ID, "call", 50_000.0)
_write_leg_terms(meta, PUT_ID, "put", 40_000.0)
config = _config(
universe=[UNDERLYING_ID, OPTION_ID, PUT_ID],
option_underlyings={OPTION_ID: UNDERLYING_ID, PUT_ID: UNDERLYING_ID},
)
result = bt.run(_hold(**{str(OPTION_ID): 1.0}), config, store)
trades = result.trades.to_pandas()
assert (trades.symbol_id == OPTION_ID).all(), (
f"only the call leg may trade, got:\n{trades}"
)