PnL¶
pomata.pnl turns the positions you hold into per-bar profit and loss, in two flows — returns (a signed weight of
capital times an asset return) and cash (a quantity of units times a price change) — with transaction costs you
compose, never bake in. Pick the flow that matches your data; either one ends in exactly the return or equity series the
metrics family consumes.
What you get¶
Eighteen primitives, split by the unit you hold — which is also the flow.
Returns flow¶
A signed weight and an asset return, from the price transform through to the compounded capital curve:
returns_simple() · returns_log() · returns_gross() ·
returns_net() · equity_curve() · turnover() ·
cost_proportional() · cost_slippage()
Cash flow¶
A quantity of units and a price, for instrument-level booking with contract multipliers, dividends, and funding:
pnl_gross() · pnl_gross_inverse() · pnl_net() ·
cumulative_pnl() · dividend() · cost_fixed() ·
cost_notional() · cost_per_share() · cost_borrow() ·
cost_funding()
Common pains, solved¶
Gross vs net: the costs that actually bite¶
A backtest that forgets transaction costs is a fiction — the alpha you keep is the gross return minus what the broker takes. Compose the cost and net it; on every bar you traded, net sits strictly below gross.
>>> import polars as pl
>>> from pomata.pnl import returns_gross, returns_net, cost_proportional
>>>
>>> frame = pl.DataFrame(
... {
... "weight": [1.0, 1.0, -1.0, -1.0, 0.5, 0.5],
... "asset_returns": [0.02, -0.01, 0.03, -0.02, 0.01, 0.04],
... }
... )
>>> gross = returns_gross(pl.col("weight"), pl.col("asset_returns"))
>>> net = returns_net(gross, cost_proportional(pl.col("weight"), rate=0.001))
>>> frame.with_columns(gross=gross.round(4), net=net.round(4))
shape: (6, 4)
┌────────┬───────────────┬───────┬────────┐
│ weight ┆ asset_returns ┆ gross ┆ net │
│ --- ┆ --- ┆ --- ┆ --- │
│ f64 ┆ f64 ┆ f64 ┆ f64 │
╞════════╪═══════════════╪═══════╪════════╡
│ 1.0 ┆ 0.02 ┆ 0.02 ┆ 0.019 │
│ 1.0 ┆ -0.01 ┆ -0.01 ┆ -0.01 │
│ -1.0 ┆ 0.03 ┆ -0.03 ┆ -0.032 │
│ -1.0 ┆ -0.02 ┆ 0.02 ┆ 0.02 │
│ 0.5 ┆ 0.01 ┆ 0.005 ┆ 0.0035 │
│ 0.5 ┆ 0.04 ┆ 0.02 ┆ 0.02 │
└────────┴───────────────┴───────┴────────┘
The drag lands only where the weight moved (rows 0, 2, 4 — the entry, the flip, the resize); a held bar pays nothing, so its net equals its gross.
Signal → position → P&L, with no look-ahead¶
A signal computed at a bar’s close cannot trade that same bar — only the next one. One .shift(1) on the weight is the
whole story: the position lags the signal by exactly one bar, so the P&L can never peek at the return that produced the
signal.
>>> from pomata.pnl import returns_simple
>>>
>>> frame = pl.DataFrame({"close": [100.0, 102.0, 101.0, 104.0, 103.0, 106.0, 108.0]})
>>> asset_returns = returns_simple(pl.col("close"))
>>> signal = (asset_returns > 0).cast(pl.Float64) # decide at close t
>>> weight = signal.shift(1) # act at t+1
>>> frame.with_columns(signal=signal, weight=weight, gross=returns_gross(weight, asset_returns).round(4))
shape: (7, 4)
┌───────┬────────┬────────┬─────────┐
│ close ┆ signal ┆ weight ┆ gross │
│ --- ┆ --- ┆ --- ┆ --- │
│ f64 ┆ f64 ┆ f64 ┆ f64 │
╞═══════╪════════╪════════╪═════════╡
│ 100.0 ┆ null ┆ null ┆ null │
│ 102.0 ┆ 1.0 ┆ null ┆ null │
│ 101.0 ┆ 0.0 ┆ 1.0 ┆ -0.0098 │
│ 104.0 ┆ 1.0 ┆ 0.0 ┆ 0.0 │
│ 103.0 ┆ 0.0 ┆ 1.0 ┆ -0.0096 │
│ 106.0 ┆ 1.0 ┆ 0.0 ┆ 0.0 │
│ 108.0 ┆ 1.0 ┆ 1.0 ┆ 0.0189 │
└───────┴────────┴────────┴─────────┘
The signal and the weight are the same series, offset by one row: the up-bar at index 1 only earns its position at
index 2, where it eats the next bar’s -0.0098. Nothing is shifted for you, so an already-aligned weight is never
double-lagged.
Equity curve vs cumulative P&L¶
Two ways to total a return series, and confusing them quietly corrupts every downstream metric.
equity_curve() compounds — a multiplicative growth factor for capital that reinvests its P&L;
cumulative_pnl() sums — an additive currency total for a fixed notional. Same inputs, different
cumulation.
>>> from pomata.pnl import equity_curve, cumulative_pnl
>>>
>>> frame = pl.DataFrame({"returns": [0.1, 0.1, 0.1, -0.1, 0.1]})
>>> frame.with_columns(equity=equity_curve(pl.col("returns")).round(4), cum_pnl=cumulative_pnl(pl.col("returns")).round(4))
shape: (5, 3)
┌─────────┬────────┬─────────┐
│ returns ┆ equity ┆ cum_pnl │
│ --- ┆ --- ┆ --- │
│ f64 ┆ f64 ┆ f64 │
╞═════════╪════════╪═════════╡
│ 0.1 ┆ 1.1 ┆ 0.1 │
│ 0.1 ┆ 1.21 ┆ 0.2 │
│ 0.1 ┆ 1.331 ┆ 0.3 │
│ -0.1 ┆ 1.1979 ┆ 0.2 │
│ 0.1 ┆ 1.3177 ┆ 0.3 │
└─────────┴────────┴─────────┘
Three +10% bars compound to 1.331 but sum to 0.3; the -10% bar then takes the curve to 1.1979 (a tenth of
the grown capital) while the sum drops by a flat 0.1. Feed the equity curve to a drawdown, the cumulative total to a
fixed-notional ledger — never the reverse.
Per-asset P&L on a panel, via .over¶
A long panel of many tickers is one DataFrame — but a naked return reaches across the ticker boundary and books a
phantom move where one symbol’s last close meets the next’s first bar. Wrap the call in .over(...) and each ticker
restarts its own warm-up.
>>> frame = pl.DataFrame(
... {
... "ticker": ["A", "A", "A", "B", "B", "B"],
... "close": [100.0, 110.0, 121.0, 50.0, 55.0, 60.5],
... }
... )
>>> frame.with_columns(
... leaky=returns_simple(pl.col("close")).round(4),
... clean=returns_simple(pl.col("close")).over("ticker").round(4),
... )
shape: (6, 4)
┌────────┬───────┬─────────┬───────┐
│ ticker ┆ close ┆ leaky ┆ clean │
│ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ f64 ┆ f64 │
╞════════╪═══════╪═════════╪═══════╡
│ A ┆ 100.0 ┆ null ┆ null │
│ A ┆ 110.0 ┆ 0.1 ┆ 0.1 │
│ A ┆ 121.0 ┆ 0.1 ┆ 0.1 │
│ B ┆ 50.0 ┆ -0.5868 ┆ null │
│ B ┆ 55.0 ┆ 0.1 ┆ 0.1 │
│ B ┆ 60.5 ┆ 0.1 ┆ 0.1 │
└────────┴───────┴─────────┴───────┘
Without .over, ticker A’s 121 close bleeds into ticker B’s 50 open and fabricates a -58.68% return at the
boundary. With it, B begins at null — its own warm-up — exactly as a fresh series should.