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
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
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
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
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:
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.
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.
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:
# 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.