The real problem is not the signal. It is the system.
Most amateur backtests optimize an entry condition. A professional derivatives research system optimizes the infrastructure around that condition.
Index options research has a much wider failure surface than equity backtesting. The engine has to handle expiry calendars, strike grids, moneyness rules, option-chain availability, tick gaps, session boundaries, margin, slippage, position state, and portfolio aggregation. If those mechanics are weak, the signal quality is almost irrelevant because the backtest is measuring the wrong object.
That separation is what turns Pluto from a notebook-style backtest into a reusable research platform. The useful output is not only a PnL curve. It is a structured tradebook that can be audited, aggregated, stressed, optimized, and replayed.
Architecture map
Pluto is organized as a layered event-driven system. Each layer has a narrow responsibility and passes a cleaner object downstream.
Input
Config files, underlyings, sessions, expiry rules, and market data.
Engine
Event loop, state machine, order accounting, MTM, and margin routines.
Output
Tradebooks, metadata, metrics, optimization reports, and basket-level views.
1. Data interface: normalize the market before researching it
The data interface is the lowest-level abstraction used by strategies and the event engine. It hides storage details behind functions like get_tick(), get_expiry_code(), and find_symbol_by_premium().
This matters because options backtesting is mostly a data-access problem disguised as a signal problem. A strategy should not directly construct DuckDB table names, parse expiries, or recover missing ticks.
EXCHANGE_MAPPING = {
"BSE": ["SENSEX", "BANKEX"],
"NSE": ["NIFTY", "BANKNIFTY", "FINNIFTY", "MIDCPNIFTY"],
"CBOE": ["SPXW"],
"NASDAQ": ["NDXP"],
"MCX": ["GOLDM"],
}
strike_diff_dict = {
"BANKNIFTY": 100,
"NIFTY": 50,
"FINNIFTY": 50,
"MIDCPNIFTY": 25,
"SENSEX": 100,
"BANKEX": 100,
"SPXW": 5,
"NDXP": 10,
"GOLDM": 1000,
}
The design allows the same research engine to support multiple venues and underlyings. Product-specific mechanics are pushed into dictionaries and interface methods instead of being scattered through strategy code.
def get_tick(self, timestamp, symbol):
timestamp_str = timestamp.strftime("%Y-%m-%d %H:%M:%S")
instrument = "Index" if not any(char.isdigit() for char in symbol) else "Options"
underlying = next((u for u in indexes if symbol.startswith(u)), None)
exchange = self.get_exchange(underlying)
if instrument == "Options":
expiry = self.parse_date_from_symbol(symbol).strftime("%Y%m%d")
strike = self.parse_strike_from_symbol(symbol)
opt_type = "call" if "CE" in symbol else "put"
table = f"{exchange}_Options_Expiry_{underlying}_{expiry}"
query = f"""
SELECT *
FROM {table}
WHERE ts = '{timestamp_str}'
AND strike = {strike}
AND option_type = '{opt_type}'
LIMIT 1
"""
else:
table = f"{exchange}_Index_{underlying}"
query = f"""
SELECT *
FROM {table}
WHERE ts = '{timestamp_str}'
LIMIT 1
"""
result = self.conn.execute(query).fetchdf()
if not result.empty:
return result.iloc[0]
return self.get_last_available_tick(symbol)
What matters
The strategy receives tradable market objects. It does not care whether data came from DuckDB, Redis, CSV, or a live adapter. That separation is the first sign of a serious research system.
2. Contract discovery: convert intent into symbols
In options, a strategy rarely wants a fixed symbol. It wants an instrument selected by rule: nearest expiry, ATM, OTM count, target premium, target delta, or liquidity filter.
Pluto centralizes this inside the data interface. That is the correct design because symbol selection is infrastructure, not strategy logic.
def find_symbol_by_moneyness(self, timestamp, underlying, expiry_idx, opt_type, otm_count):
strike_diff = self.get_strike_diff(underlying)
shifter = 1 if opt_type == "CE" else -1
spot = self.get_tick(timestamp, f"{underlying}SPOT")
spot_price = spot["c"] if spot is not None else 0
atm_strike = round(spot_price / strike_diff) * strike_diff
selected_strike = int(atm_strike + otm_count * shifter * strike_diff)
expiry_code = self.get_expiry_code(timestamp, underlying, expiry_idx)
symbol = f"{underlying}{expiry_code}{selected_strike}{opt_type}"
return symbol
3. Event engine: the state machine that keeps the simulation honest
Pluto's core abstraction is the event interface. Instead of vectorizing a signal across a dataframe, the engine processes events in chronological order and gives strategies controlled lifecycle hooks.
This makes the framework closer to real execution. The strategy receives a timestamp, updates its state, checks open positions, and decides whether to act. The engine guards sequencing and session invariants.
def process_event(self, event):
self.event = event
self.now = event["ts"]
self.check_if_new_day()
if self.last_event is not None:
if self.last_event["ts"] >= self.event["ts"]:
print("STALE TIMESTAMP !!!")
return
self.on_event()
if self.event["bar_complete"]:
self.on_bar_complete()
if self.event["ts"].time() >= self.stop_time:
assert len(self.positions) == 0, (
f"Trades open after stop time @ {self.now}"
)
self.last_event = self.event
Chronology is explicit
Stale timestamps are rejected, so events cannot be consumed out of order without being noticed.
Session boundaries are enforced
Positions are checked at stop time, preventing silent carry of intraday risk.
State resets are centralized
New-day detection is handled once inside the engine instead of being duplicated across strategies.
Strategies remain thin
Strategies implement hooks; they do not own the entire backtesting loop.
def on_start(self):
pass
def on_stop(self):
pass
def on_new_day(self):
pass
def on_event(self):
pass
def on_bar_complete(self):
pass
This is the right degree of abstraction. The engine handles lifecycle, while the strategy only defines behavior at lifecycle boundaries.
4. Execution accounting: the tradebook is the source of truth
The execution layer converts decisions into fills, positions, turnover, and realized cashflows. This is where many backtests become unreliable. If a framework cannot reconstruct exposure from trades, the PnL is not auditable.
def place_trade(self, timestamp, action, qty, symbol, price=None, note=""):
trade = {}
trade["uid"] = self.uid
trade["ts"] = timestamp
trade["dte"] = self.get_dte(timestamp, symbol)
trade["action"] = action
action_int = 1 if action == "BUY" else -1
trade["qty"] = int(qty * self.weight)
trade["qty_dir"] = int(qty * action_int * self.weight)
trade["symbol"] = symbol
if price is None:
price_data = self.get_tick(timestamp, symbol)
price = float(price_data["c"])
trade["price_time"] = price_data["ts"]
if np.isnan(price) or price <= 0:
return False, price
self.positions[symbol] = self.positions.get(symbol, 0) + trade["qty_dir"]
if self.positions[symbol] == 0:
self.positions.pop(symbol)
trade["price"] = float(price)
trade["value"] = trade["price"] * trade["qty_dir"] * -1
trade["turnover"] = abs(trade["value"])
trade["note"] = note
self.trades.append(trade)
return True, price
The framework also includes consistency checks that reconstruct live positions from the trade list and compare them against the engine's position dictionary.
def get_active_trades(self):
active_trades = []
pos = {}
for trade in self.trades:
symbol = trade["symbol"]
pos[symbol] = pos.get(symbol, 0) + trade["qty_dir"]
if pos[symbol] != 0:
active_trades.append(trade)
if pos[symbol] == 0:
pos.pop(symbol)
assert pos == self.positions, f"POS MISMATCH: {pos} != {self.positions}"
return active_trades
What matters
Tradebook-first design makes the system auditable. If every PnL number comes from fills, the same output can support execution analysis, cost modeling, exposure checks, and portfolio aggregation.
5. Simulation orchestration: parallelize at the trading-day boundary
Pluto runs historical simulations by splitting work across trading days. This is a sensible boundary for intraday options systems because daily sessions are mostly independent, and state leakage across sessions is a major source of false results.
def sim_for_strat(strat_class, strat_uid, start_date, end_date, sim_uid, max_threads=24):
dates = _get_simulation_dates(start_date, end_date)
with multiprocessing.Pool(processes=max_threads) as pool:
results = list(tqdm(
pool.starmap(
sim_for_date,
zip(repeat(strat_class), repeat(strat_uid), dates),
chunksize=max(1, len(dates) // (max_threads * 4))
),
total=len(dates),
desc=f"Simulating {sim_uid}",
unit="days"
))
all_trades, all_meta = _consolidate_results(results)
tb = (
pd.DataFrame(all_trades)
.sort_values("ts")
.reset_index(drop=True)
if all_trades else pd.DataFrame()
)
return tb, pd.DataFrame(all_meta)
This architecture gives two practical advantages: speed and failure localization. A bad date can be logged, inspected, and re-run without invalidating the structure of the entire research stack.
6. Analytics layer: convert fills into risk
The analytics layer starts from the tradebook. It applies cost assumptions, groups values into daily PnL, then computes drawdown, Sharpe, Calmar, CAGR, payoff ratio, and weekday/monthly breakdowns.
def compute_daily_pnl(tradebook: pd.DataFrame) -> pd.Series:
daily_pnl = tradebook.groupby(tradebook["date"])["value"].sum()
daily_pnl.index = pd.to_datetime(daily_pnl.index)
return daily_pnl
def sharpe_ratio(daily_pnl: pd.Series, risk_free_rate=0.0) -> float:
excess_returns = daily_pnl - risk_free_rate / 252
return excess_returns.mean() / (excess_returns.std() + 1e-9) * np.sqrt(252)
def max_drawdown(daily_pnl: pd.Series) -> float:
equity = daily_pnl.cumsum()
peak = equity.cummax()
drawdown = equity - peak
return drawdown.min()
For options strategies, this layer should be treated as a risk engine, not a cosmetic report generator. Short-volatility systems especially need drawdown duration, worst-day loss, payoff asymmetry, and month/weekday decomposition.
Path risk
Drawdown, drawdown duration, worst single-day PnL.
Return quality
Sharpe, Calmar, CAGR, win/loss day distribution.
Behavioral decomposition
Monthly PnL, weekday PnL, expiry-cycle behavior.
7. Basket layer: move from strategy PnL to book construction
Single-strategy research is only the first step. Pluto includes basket tooling because a serious derivatives book is a collection of strategy sleeves, each with different payoff shapes, margin usage, holding periods, and tail behavior.
The basket layer loads multiple tradebooks, applies weights, combines PnL streams, and enables portfolio-level review.
def load_tradebooks(configs, multiplier, label):
dfs = []
for config in configs:
uid = config["uid"]
df = pd.read_csv(f"{TRADEBOOK_DIR}/{label}/{uid}.csv")
df["value"] *= multiplier
df["uid"] = uid
df["strategy_type"] = label
dfs.append(df)
return pd.concat(dfs)
8. Performance layer: optimize the bottlenecks that matter
Pluto uses a pragmatic performance stack:
DuckDB
Local analytical store for historical index and options-chain queries.
Redis
Fast intermediate cache/state layer in older interface paths and simulation workflows.
Multiprocessing
Parallelizes simulation by trading day, which is the right boundary for intraday research.
Cython
Accelerates repeated low-level payoff and margin calculations.
The key is not to prematurely optimize strategy code. The high-impact bottlenecks are usually tick retrieval, option-chain filtering, payoff grids, margin loops, and repeated tradebook aggregation.
from cython_modules.payoff import single_strike_payoff_cal
for symbol in active_position_records:
payoff = single_strike_payoff_cal(
strike=symbol["strike"],
option_type=symbol["symbol"][-2:],
price=symbol["price"],
var_range=var_range,
qty=symbol["qty_dir"],
)
This is the correct optimization target: repeated deterministic arithmetic inside risk and margin calculations, not the high-level strategy hook.
What matters for production-grade hardening
The architecture already has the right skeleton. The next improvements should focus on reproducibility, cleaner interfaces, and stricter research hygiene.
from pydantic import BaseModel, Field
class BacktestConfig(BaseModel):
strategy: str
underlying: str
start_time: str
stop_time: str
timeframe: int = Field(gt=0)
weight: float = Field(default=1.0, gt=0)
class ExperimentManifest(BaseModel):
strategy_uid: str
config_hash: str
git_commit: str
data_version: str
start_date: str
end_date: str
slippage_model: str
brokerage_model: str
Final view
Pluto is best described as an event-driven derivatives research engine. Its value is not one strategy. Its value is the repeatable infrastructure around strategy research: data normalization, contract discovery, event sequencing, execution accounting, day-level parallel simulation, tradebook persistence, analytics, basket construction, and optimization.
A signal can be written in a few lines. A reliable options research platform is the system that makes those few lines measurable, comparable, and auditable.