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.