State Management

PositionLedger, SignalStore, PortfolioState

The single source of truth

PositionLedger is the authoritative store for positions, fills, P&L, and per-strategy attribution. Every other layer reads from it.

PositionLedger

Apply a fill

python
from horizon.state.ledger import PositionLedger

lg = PositionLedger()

# Open a long
lg.apply_fill(
    market_id="AAPL",
    side="buy",
    quantity=10,
    price=100.0,
    fee=1.0,
    strategy_id="momentum",
)

# Add to it
lg.apply_fill("AAPL", "buy", 10, 110.0, fee=1.0)
# avg_cost = 105.0

# Reduce. realizes P&L on the closed portion
lg.apply_fill("AAPL", "sell", 5, 120.0, fee=0.5)
# realized P&L = (120 - 105) × 5 = 75

Query state

python
pos = lg.position("AAPL")
# LedgerPosition(market_id='AAPL', quantity=15, avg_cost=105.0, ...)

print(pos.unrealized_pnl)       # mark-to-market P&L
print(pos.notional_usd)         # |qty| × last_price × multiplier

print(lg.realized_pnl())        # cumulative realized across all markets
print(lg.unrealized_pnl())      # total MTM on open positions
print(lg.gross_notional())      # sum of |notionals|
print(lg.net_notional())        # signed sum

# Per-strategy attribution
print(lg.strategy_realized("momentum"))     # realized P&L for that strategy
print(lg.strategy_trade_count("momentum"))  # number of fills
print(lg.strategy_hit_rate("momentum"))     # wins / (wins + losses)

Mark-to-market

python
lg.mark("AAPL", 112.0)
# Updates pos.last_price = 112.0
# pos.unrealized_pnl recomputed

lg.mark_many({"AAPL": 112.0, "MSFT": 450.0, "NVDA": 900.0})

The backtest loop calls mark_many(...) on every tick with the new prices, so unrealized P&L stays accurate.

Flipping a position

python
lg = PositionLedger()
lg.apply_fill("AAPL", "buy", 10, 100.0)
# quantity = +10

lg.apply_fill("AAPL", "sell", 15, 110.0)
# Closes the 10 longs (realizes +100 P&L)
# Opens a 5-short at 110
pos = lg.position("AAPL")
# quantity = -5, avg_cost = 110.0

The ledger correctly handles flip-through-zero scenarios, which is why the EquityExecutor splits flip targets into two separate orders (close + open).

Validation

The ledger rejects bad fills at construction:

python
lg.apply_fill("A", "buy", -5, 100.0)    # ✗ negative quantity
lg.apply_fill("A", "buy", 10, 0.0)      # ✗ non-positive price
lg.apply_fill("A", "hold", 10, 100.0)   # ✗ invalid side
lg.apply_fill("A", "buy", 10, float("nan"))  # ✗ NaN price

Each of these raises ValueError. Fails loudly at the boundary.

SignalStore

Strategies emit signals each tick; the store holds them with a TTL.

python
from horizon.state.signal_store import SignalStore

store = SignalStore()

# Add a signal
store.add(signal, now=datetime.utcnow())

# Query
active = store.active(now)                     # all non-expired
for_market = store.by_market("AAPL", now)      # just one market
for_strat = store.by_strategy("momentum", now) # just one strategy

# Purge expired
n_purged = store.purge_expired(now)

# Clear
store.remove_for_market("AAPL")
store.clear()

TTL semantics

  • Default TTL = signal’s horizon (1-day horizon → 1-day TTL)
  • Override via Signal.ttl
  • Same-strategy replace: re-adding for the same (strategy_id, market_id) replaces the previous
  • Different strategies: kept separately so the optimizer sees multiple opinions

Why not just re-emit every tick?

Some strategies are expensive (ML inference, cross-asset correlation). TTL lets them emit less often and keeps the signal alive between evaluations. Other strategies (fast mean reversion) re-emit every tick and rely on the replace semantics.

Both patterns work. The choice is the strategy author’s.

PortfolioState

What strategies see via ctx.portfolio when they opt into the third evaluate() argument.

python
from horizon import Strategy, Signal
from horizon.asset_classes import Equity

class SelfAwareMR(Strategy):
    asset_classes = [Equity]
    features = {"z": Zscore(20)}

    def evaluate(self, f, universe, ctx):
        portfolio = ctx.portfolio

        # Gate on drawdown
        if portfolio.drawdown_pct > 0.05:
            return []

        # Adaptive sizing based on recent equity
        size_pct = 0.02 if portfolio.drawdown_pct < 0.02 else 0.01

        return [
            Signal.from_score(
                m, score=-f.z[m.id], edge_per_stdev=20,
                preferred_notional_usd=portfolio.equity * size_pct,
            )
            for m in universe
            if abs(f.z[m.id]) > 2
        ]

Fields available on PortfolioState

  • Capital: equity, cash, cash_by_venue, buying_power, gross_notional, net_notional, gross_leverage
  • P&L: realized_pnl_total, realized_pnl_today, unrealized_pnl
  • Drawdown: drawdown_pct, drawdown_usd, peak_equity, peak_time
  • Trade stats: hit_rate_30d, avg_trade_pnl_30d, n_trades_30d
  • Positions: positions, position_by_market, n_positions, has_position(id)
  • Per-strategy attribution: strategy_pnl, my_pnl

Signature detection

The engine detects whether a strategy wants ctx by introspecting its evaluate signature. Strategies that don’t need it omit the third argument:

python
# Without ctx
def evaluate(self, f, universe):
    ...

# With ctx. detected automatically
def evaluate(self, f, universe, ctx):
    ...

No flag, no manifest, no configuration. Just the function signature.

Conservation invariant

The ledger enforces:

equity = initial_cash + realized_pnl + unrealized_pnl

The backtest loop and metrics collector both use this formula. Because the ledger is the only source, there’s no risk of the venue and the metrics disagreeing.

Tested explicitly in tests/test_behavioral_audit.py::TestConservation.

Next