System Architecture / Derivatives Research Infrastructure

Pluto: Architecture of an Event-Driven Indian Derivatives Simulation Engine

A professional walkthrough of a modular research stack for NSEI Derivatives market: data normalization, contract discovery, event sequencing, execution accounting, simulation orchestration, portfolio aggregation, and analytics.

Core EngineEvent-driven EMS
StorageDuckDB + Redis
ComputeMultiprocessing + Cython
OutputTradebook-first analytics

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.

Pluto's central design principle: strategies should express intent, while the engine owns data access, event ordering, execution state, and accounting.

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.

Storage Layer DuckDB tick store Redis state cache Data Interface expiry lookup tick normalization Contract Discovery moneyness / premium strike selection Strategy Hooks on_event() on_bar_complete() Event Engine time sequencing new-day resets session invariants Execution Ledger fills / positions MTM / margin tradebook source of truth Analytics daily PnL drawdown / Sharpe basket reports Research loop: config → event simulation → tradebook → metrics → basket construction → parameter search
Pluto architecture: data access and symbol logic are separated from event processing, execution state, and analytics.

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.

engine/ems_db.py — exchange and product map
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.

Data access pattern — exact tick, next tick, fallback tick
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)
01

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.

Strategy Intent "give me 20 premium CE" Expiry Resolver nearest / monthly / idx Option Chain Query timestamp + type + exchange Resolved Symbol NIFTY24042522500CE Contract selection is a reusable primitive: premium, moneyness, expiry, strike grid, and OI checks live outside strategy code.
Contract discovery converts research intent into a concrete tradable symbol at a specific timestamp.
find_symbol_by_moneyness() — reusable symbol selection primitive
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.

EventInterface.process_event() — event-driven state transition
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.

Strategy hook interface
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.

Professional rule: analytics must be downstream of the tradebook, not downstream of a signal vector.
place_trade() — fill creation and position accounting
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.

Position invariant — reconstructed position must equal live state
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
02

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.

Day-Level Parallel Simulation Date Range start_date → end_date Worker Day 1 Worker Day 2 Worker Day N Consolidation trades + metadata Tradebook sorted by time State isolation by date reduces hidden carryover and makes bad sessions easier to debug.
Intraday simulation is parallelized across dates, then consolidated into one chronological tradebook.
backtest/sim_new.py — multiprocessing simulation driver
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.

metrics/metrics.py — tradebook to daily PnL
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.

Basket concept — combine tradebooks under a portfolio view
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)
Important distinction: a strategy can be attractive in isolation and still be poor at the portfolio level if it clusters losses with the rest of the book or consumes capital inefficiently.

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.

Cython payoff acceleration concept
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.

Typed config schemas:  validate every JSON file before simulation using Pydantic or dataclasses.
Experiment registry:  persist config hash, git commit, data version, slippage model, brokerage model, run timestamp, and output paths.
Storage adapters:  hide DuckDB, Redis, CSV, and live data behind explicit interfaces.
Deterministic replay:  allow one bad trading day or one bad event stream to be replayed without rerunning the entire period.
Realistic cost model:  include brokerage, STT, exchange fees, spread-based slippage, premium buckets, and time-of-day liquidity effects.
Walk-forward evaluation:  rank parameters by stability, not only by in-sample returns.
Suggested config schema for reproducible research
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.