Skip to content

Local Development

Everything you need to know about developing Meridian plugins locally -- no infrastructure, no Docker, no accounts. The LocalClient gives you an in-memory trading simulator with the same API you will use in production.

Local Mode

Local Mode
Client LocalClient
Infrastructure None -- everything in-process
Use case Development, backtesting, unit tests
Data source CSV, Parquet, DataFrame, dicts
Order execution Simulated -- fills at close price
# Local mode -- no network, no sidecar
from meridian import LocalClient
client = LocalClient(starting_cash=100_000)

Connected mode (live data and execution) is planned for a future release.

LocalClient API Reference

Data Loading

load_bars(symbol, source)

Load price history for a symbol. The source argument is flexible:

# From a CSV file
client.load_bars("AAPL", "data/aapl_2023.csv")

# From a Parquet file (requires pandas)
client.load_bars("AAPL", "data/aapl_2023.parquet")

# From a list of dicts
client.load_bars("AAPL", [
    {"date": "2024-01-02", "open": 150.0, "high": 152.5, "low": 149.5, "close": 151.0, "volume": 1000000},
    {"date": "2024-01-03", "open": 151.0, "high": 153.0, "low": 150.0, "close": 152.5, "volume": 1100000},
])

# From a pandas DataFrame
import pandas as pd
df = pd.read_csv("data/aapl_2023.csv")
client.load_bars("AAPL", df)

Flexible Column Names

You do not need to rename your columns. The SDK recognizes common variants:

Standard Also accepted
date timestamp, time, dt, Date, Timestamp
open o, Open, OPEN
high h, High, HIGH
low l, Low, LOW
close c, Close, CLOSE, adj_close, Adj Close
volume vol, v, Volume, VOLUME

Only date and close are required. Missing open, high, low, or volume columns default to zero.

CSV Format

date,open,high,low,close,volume
2024-01-02,150.00,152.50,149.50,151.00,1000000
2024-01-03,151.00,153.00,150.00,152.50,1100000

Dates are parsed automatically. ISO 8601 (2024-01-02), US format (01/02/2024), and Unix timestamps are all supported.

Order Submission

submit_order(symbol, direction, quantity)

client.submit_order("AAPL", "BUY", 100)
client.submit_order("AAPL", "SELL", 50)

How simulation works:

  1. The order fills immediately at the current bar's close price
  2. For a BUY: cash is debited by price * quantity
  3. For a SELL: cash is credited by price * quantity
  4. A Fill object is appended to the fill log
  5. A balanced double-entry journal record is created

Rejection cases:

Condition Result
Insufficient cash for a BUY Order rejected, on_reject callback fired
Selling more shares than held Order rejected, on_reject callback fired
Symbol not loaded Order rejected, on_reject callback fired
Order during warmup period Order silently skipped (not rejected)

Position Tracking

positions(symbol=None)

Returns current positions computed cumulatively from fills.

# All positions
client.positions()
# {'AAPL': {'quantity': Decimal('100'), 'cost_basis': Decimal('15100.00')}}

# Single symbol
client.positions("AAPL")
# {'AAPL': {'quantity': Decimal('100'), 'cost_basis': Decimal('15100.00')}}

quantity is the net share count. cost_basis is the total cost of the current position (average cost times quantity).

Cash Tracking

cash(currency=None)

client.cash()
# Decimal('84900.00')

Starting balance is configurable at construction:

client = LocalClient(starting_cash=500_000)   # half a million
client = LocalClient(starting_cash=1_000_000)  # one million

Cash is debited on buys and credited on sells. The balance is checked before every buy order -- insufficient cash causes rejection.

Query Methods

fills(start=None, end=None)

Every executed order produces a Fill:

for fill in client.fills():
    print(f"{fill.timestamp} {fill.direction} {fill.quantity} {fill.symbol} @ ${fill.price}")

Each fill has: symbol, direction (BUY/SELL), quantity, price, timestamp, order_id, fill_id.

journal(start=None, end=None)

Every fill generates a balanced double-entry accounting record:

for entry in client.journal():
    print(f"{entry.timestamp} DR {entry.debit_account} / CR {entry.credit_account}  ${entry.amount}")

A BUY debits the asset account and credits cash. A SELL debits cash and credits the asset account. The journal always balances.

orders(status=None)

# All orders
client.orders()

# Filter by status
client.orders(status="FILLED")
client.orders(status="REJECTED")

history(symbol, start=None, end=None)

Query loaded bar data:

bars = client.history("AAPL")
bars = client.history("AAPL", start="2024-01-15", end="2024-02-15")

available()

List all symbols that have been loaded:

client.available()
# ['AAPL', 'MSFT', 'SPY']

resolve(symbol)

Look up metadata for a loaded symbol:

client.resolve("AAPL")
# {'symbol': 'AAPL', 'bar_count': 252, 'start': '2024-01-02', 'end': '2024-12-31'}

SignalEngine

The SignalEngine base class drives your strategy through historical data one bar at a time.

from meridian import LocalClient, SignalEngine
from decimal import Decimal

client = LocalClient(starting_cash=100_000)
client.load_bars("AAPL", "data/aapl_2023.csv")
client.load_bars("MSFT", "data/msft_2023.csv")


class MyStrategy(SignalEngine):
    def on_data(self, window):
        """Called once per bar date. This is where your logic lives."""
        bars = window.bars("AAPL", lookback=20)
        if len(bars) < 20:
            return

        latest = window.latest("AAPL")
        sma = sum(b.close for b in bars) / len(bars)

        pos = self.client.positions("AAPL")
        holding = pos.get("AAPL", {}).get("quantity", Decimal(0))

        if latest.close > sma and holding == 0:
            self.submit_order("AAPL", "BUY", 100)
        elif latest.close < sma and holding > 0:
            self.submit_order("AAPL", "SELL", int(holding))

    def on_fill(self, fill):
        """Called after every successful fill."""
        print(f"Filled: {fill.direction} {fill.quantity} {fill.symbol} @ ${fill.price}")

    def on_reject(self, order, reason):
        """Called when an order is rejected."""
        print(f"Rejected: {order.symbol} {order.direction} -- {reason}")


MyStrategy(client).run(warmup=20)

Warmup

The warmup parameter tells the engine how many bars to replay before orders are allowed:

MyStrategy(client).run(warmup=50)

During warmup, window.is_warmup is True and any calls to submit_order are silently skipped. This gives indicators time to stabilize -- a 50-day moving average needs 50 bars of history before producing meaningful values.

DataWindow

The window argument passed to on_data provides:

Method Returns
window.bars(symbol, lookback=N) List of the last N bars for the symbol
window.latest(symbol) The current bar for the symbol
window.is_warmup True during the warmup period
window.date The current bar date

Each bar is an object with date, open, high, low, close, and volume attributes. Prices are Decimal values for exact arithmetic.

Event Handlers

Handler When it fires
on_data(window) Once per bar date (required)
on_fill(fill) After every successful order fill
on_reject(order, reason) When an order is rejected

All handlers are optional except on_data.

Next Steps