pnl

Profit-and-loss accounting — composable, Polars-native, and self-sufficient: it turns the positions you hold into the exact return and equity series the performance and risk metrics consume, so you never leave the toolkit, hand-roll P&L (mis-handling null / NaN / 0 / inf), or wonder whether an outside dependency’s output is compatible.

One question (what is my P&L?), answered by TWO flows; pick the one that matches the data you hold:

  • Return flow — you hold a weight (a signed fraction of capital) and the asset’s returns (in %); for strategy research, portfolios, and cross-asset work: returns_simple / returns_log -> returns_gross -> (subtract composable costs) -> returns_net -> equity_curve (the compounded capital curve).

  • Cash / position flow — you hold a quantity of units and a price (in currency); for instrument-level booking with contract multipliers, FX, and crypto: pnl_gross -> (subtract composable costs) -> pnl_net -> cumulative_pnl (the additive currency total).

Decision rule: think in weights + returns -> the returns_* flow; think in quantities + prices -> the pnl_* flow. The split is by unit (a fraction vs a currency amount), which is also the flow. Either flow ends in the series the metrics family reads directly: the return flow’s returns_net / equity_curve, the cash flow’s pnl_net / cumulative_pnl.

Every function is a free-standing pl.Expr factory: compose it in select / with_columns, eager or lazy, on a single series or a long panel via .over(...). To express a cost or convert PnL to your account currency, just compose with arithmetic (e.g. pnl_gross(...) * fx_rate). Source is organized into theme modules for maintainability; this package re-exports a flat public API.

pomata.pnl.cost_borrow(
quantity: Expr,
price: Expr,
rate: float,
) Expr[source]

Short-Borrow Cost, the per-bar fee for holding a short position.

The per-bar carrying cost of a short: the short notional (the absolute short size times the price) times a per-bar borrow rate. Only short positions pay it; a long or flat position has zero borrow cost:

\[c_t = \max(-q_t,\ 0) \cdot P_t \cdot \mathrm{rate}, \qquad q = \text{quantity}.\]

It is a holding cost (charged on the position held, not on a trade), so it is elementwise — no turnover, no lag. This is a currency / cash-flow cost component; build it per bar and subtract it (with any others) from the gross PnL via pnl_net().

Parameters:
  • quantity – Signed position size in units / shares / contracts held over the bar; only the short part (q < 0) is charged.

  • price – Instrument price series (e.g. pl.col("close")); must share a length and alignment with quantity.

  • rate – Per-bar borrow rate, as a fraction of the short notional (e.g. an annual rate divided by the bars per year). Must be a finite number >= 0.

Returns:

a non-negative cost on short bars (for a non-negative price; a negative price yields an economically meaningless negative value) and 0 on long or flat bars.

Return type:

The per-bar borrow cost for each row, the same length as the inputs

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If rate is not a finite number >= 0 (i.e. < 0, NaN, or ±inf).

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

Edge-case behavior:

  • Long / flat — a non-negative quantity has zero borrow cost (only the short part is charged).

  • Null — a null in the quantity or the price makes that row null (null takes precedence over NaN).

  • NaN — a NaN in either input (with no null) propagates, yielding NaN for that row.

  • Partitioning — the cost is elementwise (each row uses only its own pair), so .over(...) partitions identically and is optional here, unlike the turnover-based / cumulative functions.

See also

  • dividend(): The equity holding cashflow on the income side.

  • cost_funding(): The perpetual-swap holding cost.

  • pnl_net(): Subtracts the composed cost from the gross PnL.

References

Examples

>>> import polars as pl
>>> from pomata.pnl import cost_borrow
>>>
>>> frame = pl.DataFrame(
...     {
...         "quantity": [100.0, -50.0, -50.0, -20.0, -20.0],
...         "price": [10.0, 11.0, 12.0, 13.0, 14.0],
...     }
... )
>>> expr = cost_borrow(pl.col("quantity"), pl.col("price"), 0.0001).round(6)
>>> frame.select(expr.alias("cost"))["cost"].to_list()
[0.0, 0.055, 0.06, 0.026, 0.028]

On a multi-ticker panel, partition with .over — for this elementwise holding cost it is optional (the result is identical without it) and shown here only for consistency:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 3 + ["B"] * 3,
...         "quantity": [100.0, -50.0, -50.0, -20.0, -20.0, 30.0],
...         "price": [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],
...     }
... )
>>> expr = cost_borrow(pl.col("quantity"), pl.col("price"), 0.0001).over("ticker").round(6)
>>> frame.with_columns(expr.alias("c"))["c"].to_list()
[0.0, 0.055, 0.06, 0.026, 0.028, 0.0]

A null (which propagates) and a NaN make the missing-data handling visible:

>>> frame = pl.DataFrame(
...     {
...         "quantity": [-50.0, None, -50.0, float("nan"), -20.0],
...         "price": [10.0, 11.0, float("nan"), 12.0, 13.0],
...     }
... )
>>> expr = cost_borrow(pl.col("quantity"), pl.col("price"), 0.0001).round(6)
>>> frame.select(expr.alias("cost"))["cost"].to_list()
[0.05, None, nan, nan, 0.026]
pomata.pnl.cost_fixed(
quantity: Expr,
fee: float,
) Expr[source]

Fixed Transaction Cost, a flat charge per trade.

A flat fee in the account currency charged on every bar where the position changes (a trade occurs), and nothing on bars where it is held unchanged:

\[\begin{split}c_t = \begin{cases} \mathrm{fee} & \text{if } \lvert \Delta q_t \rvert > 0 \\ 0 & \text{otherwise} \end{cases}, \qquad q = \text{quantity}.\end{split}\]

A trade is detected from the turnover() of the quantity (with the pre-series quantity flat, so the entry trade counts). This is a currency / cash-flow cost component; build it per bar and subtract it (with any others) from the gross PnL via pnl_net().

Parameters:
  • quantity – Signed position size in units / shares / contracts held over the bar (e.g. 100, -2).

  • fee – Flat charge per trade, in the account currency. Must be a finite number >= 0.

Returns:

fee where the quantity changes (the first row counts as a trade from a flat start) and 0 where it is held.

Return type:

The per-bar fixed cost for each row, the same length as quantity

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If fee is not a finite number >= 0 (i.e. < 0, NaN, or ±inf).

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

Edge-case behavior:

  • Flat start — the pre-series quantity is taken as 0 (via turnover()), so the first row charges the fee (entering the initial position is a trade).

  • Null — a null quantity makes its own row null and the next row null (the turnover difference references the previous quantity); null takes precedence over NaN.

  • NaN — a NaN quantity propagates to its own row and the next, yielding NaN there.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the turnover never reaches across series boundaries, e.g. cost_fixed(pl.col("quantity"), 1.0).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.pnl import cost_fixed
>>>
>>> frame = pl.DataFrame({"quantity": [10.0, 10.0, -5.0, -5.0, 20.0]})
>>> frame.select(cost_fixed(pl.col("quantity"), 1.0).round(4).alias("cost"))["cost"].to_list()
[1.0, 0.0, 1.0, 0.0, 1.0]

On a multi-ticker panel, wrap the call in .over so each ticker starts flat:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 3 + ["B"] * 3,
...         "quantity": [10.0, 10.0, -5.0, 2.0, 2.0, 2.0],
...     }
... )
>>> frame.with_columns(cost_fixed(pl.col("quantity"), 1.0).over("ticker").round(4).alias("c"))["c"].to_list()
[1.0, 0.0, 1.0, 1.0, 0.0, 0.0]

A null (which voids its own row and the next) and a NaN make the missing-data handling visible:

>>> frame = pl.DataFrame({"quantity": [10.0, None, -5.0, float("nan"), 20.0]})
>>> frame.select(cost_fixed(pl.col("quantity"), 1.0).round(4).alias("cost"))["cost"].to_list()
[1.0, None, None, nan, nan]
pomata.pnl.cost_funding(
quantity: Expr,
price: Expr,
rate: Expr,
) Expr[source]

Funding Cost, the per-bar perpetual-swap funding payment seen as a cost to the holder.

A perpetual swap has no expiry, so a periodic funding payment tethers it to the spot price: each funding bar the holder pays (or receives) the position notional times the funding rate. Taken as a cost to the holder, it is the signed product of the position, the price, and the per-bar funding rate:

\[c_t = q_t \cdot P_t \cdot f_t, \qquad q = \text{quantity},\ f = \text{rate}.\]

The rate is signed and per-bar: a positive rate debits a long (a positive cost) and credits a short (a negative cost — a rebate), and a negative rate flips both. It is a holding cost (charged on the position held, not on a trade), so it is elementwise — no turnover, no lag. This is a currency / cash-flow cost component; build it per bar and subtract it (with any others) from the gross PnL via pnl_net().

Parameters:
  • quantity – Signed position size in units / shares / contracts held over the bar (e.g. 100, -2).

  • price – Instrument price series (e.g. pl.col("close")); must share a length and alignment with quantity.

  • rate – Per-bar funding rate as a signed fraction of notional, supplied as a series so it can be 0 on the bars between funding events (e.g. 0.0001 = 1 bp); a positive rate charges longs and rebates shorts.

Returns:

positive where the holder pays and negative (a rebate) where the holder receives.

Return type:

The per-bar funding cost for each row, the same length as the inputs

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

Edge-case behavior:

  • Sign — the cost follows sign(quantity) * sign(rate): a long pays a positive rate and is rebated by a negative one; a short is the mirror image.

  • Off-funding bars — pass rate = 0 on bars with no funding event; the cost is then 0 there.

  • Null — a null in any input makes that row null (null takes precedence over NaN).

  • NaN — a NaN in any input (with no null) propagates, yielding NaN for that row.

  • Partitioning — the cost is elementwise (each row uses only its own triple), so .over(...) partitions identically and is optional here, unlike the turnover-based / cumulative functions.

See also

  • cost_borrow(): The short-borrow holding cost on the equity side.

  • cost_notional(): The maker/taker fee on each perpetual-swap trade.

  • pnl_net(): Subtracts the composed cost from the gross PnL.

References

Examples

>>> import polars as pl
>>> from pomata.pnl import cost_funding
>>>
>>> frame = pl.DataFrame(
...     {
...         "quantity": [10.0, 10.0, -5.0, -5.0, 20.0],
...         "price": [100.0, 102.0, 101.0, 104.0, 103.0],
...         "rate": [0.0001, 0.0001, 0.0001, -0.0001, 0.0001],
...     }
... )
>>> expr = cost_funding(pl.col("quantity"), pl.col("price"), pl.col("rate")).round(6)
>>> frame.select(expr.alias("cost"))["cost"].to_list()
[0.1, 0.102, -0.0505, 0.052, 0.206]

On a multi-ticker panel, partition with .over — for this elementwise holding cost it is optional (the result is identical without it) and shown here only for consistency:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 3 + ["B"] * 3,
...         "quantity": [10.0, 10.0, -5.0, 2.0, 2.0, -3.0],
...         "price": [100.0, 102.0, 101.0, 50.0, 51.0, 49.0],
...         "rate": [0.0001, 0.0001, 0.0001, 0.0001, -0.0001, 0.0001],
...     }
... )
>>> expr = cost_funding(pl.col("quantity"), pl.col("price"), pl.col("rate")).over("ticker").round(6)
>>> frame.with_columns(expr.alias("c"))["c"].to_list()
[0.1, 0.102, -0.0505, 0.01, -0.0102, -0.0147]

A null (which propagates) and a NaN make the missing-data handling visible:

>>> frame = pl.DataFrame(
...     {
...         "quantity": [10.0, None, -5.0, float("nan"), 20.0],
...         "price": [100.0, 102.0, 101.0, 104.0, 103.0],
...         "rate": [0.0001, 0.0001, 0.0001, 0.0001, 0.0001],
...     }
... )
>>> expr = cost_funding(pl.col("quantity"), pl.col("price"), pl.col("rate")).round(6)
>>> frame.select(expr.alias("cost"))["cost"].to_list()
[0.1, None, -0.0505, nan, 0.206]
pomata.pnl.cost_notional(
quantity: Expr,
price: Expr,
rate: float,
) Expr[source]

Notional Transaction Cost, a fee as a fraction of the traded notional.

The traded notional (the units traded times the price) times a flat rate — the bps-of-notional commission charged on each trade, the maker/taker fee shape of crypto and FX venues:

\[c_t = \lvert \Delta q_t \rvert \cdot P_t \cdot \mathrm{rate}, \qquad q = \text{quantity}.\]

The units traded come from the turnover() of the quantity (with the pre-series quantity flat). This is a currency / cash-flow cost component; build it per bar and subtract it (with any others) from the gross PnL via pnl_net().

Parameters:
  • quantity – Signed position size in units / shares / contracts held over the bar (e.g. 100, -2).

  • price – Instrument price series (e.g. pl.col("close")); must share a length and alignment with quantity.

  • rate – Proportional cost rate, the fee as a fraction of traded notional (e.g. 0.001 = 10 bps). Must be a finite number >= 0.

Returns:

The per-bar notional cost for each row, the same length as the inputs. The first row charges on |quantity_0| * price_0 (the entry trade from a flat start).

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If rate is not a finite number >= 0 (i.e. < 0, NaN, or ±inf).

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

Edge-case behavior:

  • Flat start — the pre-series quantity is taken as 0 (via turnover()), so the first row charges on the entry trade.

  • Null — a null in the quantity (or its predecessor, via turnover) or the price makes that row null (null takes precedence over NaN).

  • NaN — a NaN in either input propagates, yielding NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the turnover never reaches across series boundaries, e.g. cost_notional(pl.col("quantity"), pl.col("price"), 0.001).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.pnl import cost_notional
>>>
>>> frame = pl.DataFrame(
...     {
...         "quantity": [10.0, 10.0, -5.0, -5.0, 20.0],
...         "price": [100.0, 102.0, 101.0, 104.0, 103.0],
...     }
... )
>>> expr = cost_notional(pl.col("quantity"), pl.col("price"), 0.001).round(4)
>>> frame.select(expr.alias("cost"))["cost"].to_list()
[1.0, 0.0, 1.515, 0.0, 2.575]

On a multi-ticker panel, wrap the call in .over so each ticker starts flat:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 3 + ["B"] * 3,
...         "quantity": [10.0, 10.0, -5.0, 2.0, 2.0, 2.0],
...         "price": [100.0, 102.0, 101.0, 50.0, 51.0, 49.0],
...     }
... )
>>> frame.with_columns(
...     cost_notional(pl.col("quantity"), pl.col("price"), 0.001).over("ticker").round(4).alias("c")
... )["c"].to_list()
[1.0, 0.0, 1.515, 0.1, 0.0, 0.0]

A null (which voids the rows that reference it) and a NaN make the missing-data handling visible:

>>> frame = pl.DataFrame(
...     {
...         "quantity": [10.0, None, -5.0, float("nan"), 20.0],
...         "price": [100.0, 102.0, 101.0, 104.0, float("nan")],
...     }
... )
>>> expr = cost_notional(pl.col("quantity"), pl.col("price"), 0.001).round(4)
>>> frame.select(expr.alias("cost"))["cost"].to_list()
[1.0, None, None, nan, nan]
pomata.pnl.cost_per_share(
quantity: Expr,
fee: float,
) Expr[source]

Per-Share Transaction Cost, a commission per unit traded.

The number of units traded times a flat per-unit fee — the per-share commission of equity and futures brokers:

\[c_t = \lvert \Delta q_t \rvert \cdot \mathrm{fee}, \qquad q = \text{quantity}.\]

The units traded come from the turnover() of the quantity (with the pre-series quantity flat). This is a currency / cash-flow cost component; build it per bar and subtract it (with any others) from the gross PnL via pnl_net().

Parameters:
  • quantity – Signed position size in units / shares / contracts held over the bar (e.g. 100, -2).

  • fee – Commission per unit traded, in the account currency (e.g. 0.01 = one cent per share). Must be a finite number >= 0.

Returns:

The per-bar per-share cost for each row, the same length as quantity. The first row charges on |quantity_0| (the entry trade from a flat start).

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If fee is not a finite number >= 0 (i.e. < 0, NaN, or ±inf).

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

Edge-case behavior:

  • Flat start — the pre-series quantity is taken as 0 (via turnover()), so the first row charges on |quantity_0| (entering the initial position is a trade).

  • Null — a null quantity makes its own row null and the next row null (the turnover difference references the previous quantity); null takes precedence over NaN.

  • NaN — a NaN quantity propagates to its own row and the next, yielding NaN there.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the turnover never reaches across series boundaries, e.g. cost_per_share(pl.col("quantity"), 0.01).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.pnl import cost_per_share
>>>
>>> frame = pl.DataFrame({"quantity": [10.0, 10.0, -5.0, -5.0, 20.0]})
>>> frame.select(cost_per_share(pl.col("quantity"), 0.01).round(4).alias("cost"))["cost"].to_list()
[0.1, 0.0, 0.15, 0.0, 0.25]

On a multi-ticker panel, wrap the call in .over so each ticker starts flat:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 3 + ["B"] * 3,
...         "quantity": [10.0, 10.0, -5.0, 2.0, 2.0, 2.0],
...     }
... )
>>> frame.with_columns(cost_per_share(pl.col("quantity"), 0.01).over("ticker").round(4).alias("c"))[
...     "c"
... ].to_list()
[0.1, 0.0, 0.15, 0.02, 0.0, 0.0]

A null (which voids its own row and the next) and a NaN make the missing-data handling visible:

>>> frame = pl.DataFrame({"quantity": [10.0, None, -5.0, float("nan"), 20.0]})
>>> frame.select(cost_per_share(pl.col("quantity"), 0.01).round(4).alias("cost"))["cost"].to_list()
[0.1, None, None, nan, nan]
pomata.pnl.cost_proportional(
weight: Expr,
rate: float,
) Expr[source]

Proportional Transaction Cost, a fee charged as a fraction of the traded notional.

The per-bar broker commission: the fraction of capital traded that bar (the turnover()) times a flat rate, the classic bps-of-notional fee:

\[c_t = \mathrm{turnover}_t \cdot \mathrm{rate}.\]

It is one orthogonal cost component: build it per bar and subtract it (with any others) from the gross return via returns_net(). A fixed bid-ask half-spread has the same shape against turnover; that distinct cost axis is cost_slippage().

Parameters:
  • weight – Signed weight, the fraction of capital held (e.g. 1.0 fully long, -0.5 half short); |weight| > 1 is leverage.

  • rate – Proportional cost rate, the fee as a fraction of traded notional (e.g. 0.001 = 10 bps). Must be a finite number >= 0.

Returns:

The per-bar proportional cost for each row, the same length as weight. The first row is |weight_0| * rate (the cost of the entry trade from a flat start, per turnover()).

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If rate is not a finite number >= 0 (i.e. < 0, NaN, or ±inf).

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

Edge-case behavior:

  • Flat start — the weight before the series is taken as 0 (via turnover()), so the first row is |weight_0| * rate: establishing the initial weight carries its cost.

  • Null — a null weight makes its own row null and the next row null (the turnover difference references the previous weight), then resumes; null takes precedence over NaN.

  • NaN — a NaN weight propagates to its own row and the next, yielding NaN there.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the turnover never reaches across series boundaries, e.g. cost_proportional(pl.col("weight"), 0.001).over("ticker").

See also

  • cost_slippage(): The fixed half-spread cost, the other MVP cost axis; sum the two for both.

  • turnover(): The traded fraction this scales.

  • returns_net(): Subtracts the composed cost from the gross return.

References

Examples

>>> import polars as pl
>>> from pomata.pnl import cost_proportional
>>>
>>> frame = pl.DataFrame({"weight": [0.5, 1.0, -0.5, -0.5, 0.0]})
>>> expr = cost_proportional(pl.col("weight"), 0.001).round(4)
>>> frame.select(expr.alias("cost"))["cost"].to_list()
[0.0005, 0.0005, 0.0015, 0.0, 0.0005]

On a multi-ticker panel, wrap the call in .over so each ticker starts flat and never reaches across the boundary:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 3 + ["B"] * 3,
...         "weight": [0.5, 1.0, -0.5, 1.0, 1.0, 0.0],
...     }
... )
>>> frame.with_columns(cost_proportional(pl.col("weight"), 0.001).over("ticker").round(4).alias("c"))[
...     "c"
... ].to_list()
[0.0005, 0.0005, 0.0015, 0.001, 0.0, 0.001]

A null (which voids its own row and the next) and a NaN make the missing-data handling visible:

>>> frame = pl.DataFrame({"weight": [0.5, None, -0.5, float("nan"), 0.0]})
>>> frame.select(cost_proportional(pl.col("weight"), 0.001).round(4).alias("cost"))["cost"].to_list()
[0.0005, None, None, nan, nan]
pomata.pnl.cost_slippage(
weight: Expr,
half_spread: float,
) Expr[source]

Slippage Cost, the fixed bid-ask half-spread crossed on each trade.

The per-bar market cost of crossing the spread: the fraction of capital traded that bar (the turnover()) times a fixed half-spread, the cost paid per side relative to the mid price:

\[c_t = \mathrm{turnover}_t \cdot \mathrm{half\_spread}.\]

It is one orthogonal cost component, distinct from the broker fee (cost_proportional()): build it per bar and subtract it (with any others) from the gross return via returns_net(). half_spread is the per-side cost, half the full bid-ask spread, taken directly (no hidden division).

Parameters:
  • weight – Signed weight, the fraction of capital held (e.g. 1.0 fully long, -0.5 half short); |weight| > 1 is leverage.

  • half_spread – Fixed bid-ask half-spread crossed per trade, as a fraction (half the full spread; e.g. 0.002). Must be a finite number >= 0.

Returns:

The per-bar slippage cost for each row, the same length as weight. The first row is |weight_0| * half_spread (the cost of the entry trade from a flat start, per turnover()).

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If half_spread is not a finite number >= 0 (i.e. < 0, NaN, or ±inf).

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

Edge-case behavior:

  • Flat start — the weight before the series is taken as 0 (via turnover()), so the first row is |weight_0| * half_spread: establishing the initial weight crosses the spread.

  • Null — a null weight makes its own row null and the next row null (the turnover difference references the previous weight), then resumes; null takes precedence over NaN.

  • NaN — a NaN weight propagates to its own row and the next, yielding NaN there.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the turnover never reaches across series boundaries, e.g. cost_slippage(pl.col("weight"), 0.002).over("ticker").

See also

References

Examples

>>> import polars as pl
>>> from pomata.pnl import cost_slippage
>>>
>>> frame = pl.DataFrame({"weight": [0.5, 1.0, -0.5, -0.5, 0.0]})
>>> expr = cost_slippage(pl.col("weight"), 0.002).round(4)
>>> frame.select(expr.alias("cost"))["cost"].to_list()
[0.001, 0.001, 0.003, 0.0, 0.001]

On a multi-ticker panel, wrap the call in .over so each ticker starts flat and never reaches across the boundary:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 3 + ["B"] * 3,
...         "weight": [0.5, 1.0, -0.5, 1.0, 1.0, 0.0],
...     }
... )
>>> frame.with_columns(cost_slippage(pl.col("weight"), 0.002).over("ticker").round(4).alias("c"))["c"].to_list()
[0.001, 0.001, 0.003, 0.002, 0.0, 0.002]

A null (which voids its own row and the next) and a NaN make the missing-data handling visible:

>>> frame = pl.DataFrame({"weight": [0.5, None, -0.5, float("nan"), 0.0]})
>>> frame.select(cost_slippage(pl.col("weight"), 0.002).round(4).alias("cost"))["cost"].to_list()
[0.001, None, None, nan, nan]
pomata.pnl.cumulative_pnl(
returns: Expr,
) Expr[source]

Cumulative P&L, the additive running total of a per-bar P&L (or return) series.

The plain cumulative sum of the per-bar values to date:

\[\mathrm{cumPnL}_t = \sum_{i \le t} x_i.\]

P&L in currency is additive — you sum dollars, you do not compound them — so for the cash / position flow this running sum is your total P&L to date (pair it with pnl_net()). For the return flow, where capital is reinvested, the cumulation is compounded: use equity_curve() instead, which is what “cumulative return” conventionally means. The additive name lives here, on the currency P&L, where additive is the standard; the per-bar inputs are unchanged either way, only the cumulation differs.

Parameters:

returns – Input per-bar values to cumulate — the strategy’s net P&L (e.g. from pnl_net()) for a currency total, or a net-return series for an additive (fixed-notional) return total.

Returns:

The running sum for each row, the same length as returns.

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

Edge-case behavior:

  • Null — a null return contributes nothing and emits null at that row, while the running sum carries across it unchanged (the cumulation skips the gap rather than breaking on it).

  • NaN — a NaN return propagates into the running sum and every later row stays NaN (it is a real value that contaminates the total, unlike a null gap).

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the running sum restarts per series and never carries across boundaries, e.g. cumulative_pnl(pl.col("returns")).over("ticker").

See also

  • equity_curve(): The compounded (reinvested) return-flow cumulation, a product of one-plus-returns.

  • pnl_net(): The per-bar net P&L this typically cumulates in the cash flow.

  • returns_net(): The per-bar net return it cumulates for an additive, fixed-notional total.

References

Examples

Basic usage on a per-bar P&L series:

>>> import polars as pl
>>> from pomata.pnl import cumulative_pnl
>>>
>>> frame = pl.DataFrame({"returns": [0.1, -0.05, 0.2, 0.1, -0.15, 0.05, 0.3, -0.1]})
>>> frame.select(cumulative_pnl(pl.col("returns")).round(4).alias("cumulative_pnl"))["cumulative_pnl"].to_list()
[0.1, 0.05, 0.25, 0.35, 0.2, 0.25, 0.55, 0.45]

On a multi-ticker panel, wrap the call in .over so each ticker accumulates independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "returns": [0.1, 0.2, -0.05, 0.1, 0.0, 0.1, 0.1, -0.2],
...     }
... )
>>> frame.with_columns(cumulative_pnl(pl.col("returns")).over("ticker").round(4).alias("c"))["c"].to_list()
[0.1, 0.3, 0.25, 0.35, 0.0, 0.1, 0.2, 0.0]

A null (skipped, the running total carries across it) then a NaN (which contaminates every later row) in returns make the missing-data handling visible:

>>> frame = pl.DataFrame({"returns": [0.1, None, 0.2, float("nan"), 0.1]})
>>> frame.select(cumulative_pnl(pl.col("returns")).round(4).alias("cumulative_pnl"))["cumulative_pnl"].to_list()
[0.1, None, 0.3, nan, nan]
pomata.pnl.dividend(
quantity: Expr,
dividend_per_share: Expr,
) Expr[source]

Dividend Cashflow, the per-bar dividend income (or expense) of a held quantity.

The quantity held times the dividend paid per share that bar — the cash a position receives when the instrument distributes a dividend (a long receives, a short pays):

\[d_t = q_t \cdot \mathrm{dps}_t, \qquad q = \text{quantity},\ \mathrm{dps} = \text{dividend per share}.\]

The dividend per share is a per-bar series, zero on ordinary bars and the cash amount on ex-dividend bars. This is a holding cashflow on the income side (not a cost): add it to the gross PnL (e.g. pnl_gross(...) + dividend(...)) before subtracting costs.

Parameters:
  • quantity – Signed position size in units / shares / contracts held over the bar; a long (positive) receives the dividend, a short (negative) pays it.

  • dividend_per_share – Dividend paid per share for the bar (e.g. pl.col("dividend")); zero on ordinary bars.

Returns:

The dividend cashflow for each row, the same length as the inputs.

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

Edge-case behavior:

  • Null — a null in either input makes that row null (null takes precedence over NaN).

  • NaN — a NaN in either input (with no null) propagates, yielding NaN for that row.

  • Partitioning — the product is elementwise (each row uses only its own pair), so .over(...) partitions identically and is optional here, unlike the lagged / cumulative functions.

See also

  • pnl_gross(): The gross position PnL this dividend income is added to.

  • cost_borrow(): The equity holding cashflow on the cost side (short-borrow).

  • cost_funding(): The perpetual-swap funding leg, another per-bar holding cashflow.

References

Examples

Basic usage on a held quantity and a per-share dividend:

>>> import polars as pl
>>> from pomata.pnl import dividend
>>>
>>> frame = pl.DataFrame(
...     {
...         "quantity": [100.0, 100.0, 100.0, 0.0, -50.0, -50.0, 200.0, 200.0],
...         "dividend_per_share": [0.0, 0.0, 0.5, 0.0, 0.5, 0.5, 0.0, 0.0],
...     }
... )
>>> expr = dividend(pl.col("quantity"), pl.col("dividend_per_share")).round(4)
>>> frame.select(expr.alias("dividend"))["dividend"].to_list()
[0.0, 0.0, 50.0, 0.0, -25.0, -25.0, 0.0, 0.0]

The product is elementwise, so .over partitions identically and is shown only for consistency:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "quantity": [100.0, 100.0, 100.0, 0.0, 50.0, 50.0, -50.0, -50.0],
...         "dividend_per_share": [0.0, 0.0, 0.5, 0.0, 0.0, 0.3, 0.3, 0.3],
...     }
... )
>>> expr = dividend(pl.col("quantity"), pl.col("dividend_per_share")).over("ticker").round(4)
>>> frame.with_columns(expr.alias("d"))["d"].to_list()
[0.0, 0.0, 50.0, 0.0, 0.0, 15.0, -15.0, -15.0]

A null then a NaN in quantity (both propagate through the product) make the missing-data handling visible:

>>> frame = pl.DataFrame(
...     {
...         "quantity": [100.0, None, 100.0, float("nan"), -50.0],
...         "dividend_per_share": [0.5, 0.5, 0.5, 0.5, 0.5],
...     }
... )
>>> expr = dividend(pl.col("quantity"), pl.col("dividend_per_share")).round(4)
>>> frame.select(expr.alias("dividend"))["dividend"].to_list()
[50.0, None, 50.0, nan, -25.0]
pomata.pnl.equity_curve(
returns: Expr,
) Expr[source]

Equity Curve, the compounded growth of one unit of capital over a return series.

The cumulative product of the gross returns (one plus each per-bar return) — the value of one unit of capital that reinvests its P&L each bar, so every return compounds on the grown capital:

\[\mathrm{equity}_t = \prod_{i \le t} (1 + r_i).\]

This is the standard equity curve and the multiplicative twin of cumulative_pnl(): use this when the P&L is reinvested (the total-return convention) and the additive cumulative_pnl() when the notional is held fixed. It is also the natural input to a drawdown, so the metrics family consumes it directly.

Parameters:

returns – Input per-bar returns to compound, typically the strategy’s gross or net returns (e.g. from returns_gross()).

Returns:

The compounded equity for each row, the same length as returns, expressed as a growth factor relative to a starting capital of 1 (multiply by the starting capital for a currency curve).

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

Edge-case behavior:

  • Null — a null return emits null at that row while the running product carries across it unchanged (a missing bar contributes a neutral factor of one rather than breaking the curve); a leading warm-up null (e.g. the first row of returns_simple()) therefore stays null and the curve begins at the first defined return.

  • NaN — a NaN return propagates into the running product and every later row stays NaN.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the product restarts per series and never carries across boundaries, e.g. equity_curve(pl.col("returns")).over("ticker").

See also

  • cumulative_pnl(): The additive (fixed-notional) twin, a cumulative sum of returns.

  • returns_gross(): The per-bar strategy returns this typically compounds.

  • drawdown(): The metric that consumes this equity curve, its decline from the running peak.

References

Examples

Basic usage on a per-bar return series:

>>> import polars as pl
>>> from pomata.pnl import equity_curve
>>>
>>> frame = pl.DataFrame({"returns": [0.1, -0.05, 0.2, 0.1, -0.15, 0.05, 0.3, -0.1]})
>>> frame.select(equity_curve(pl.col("returns")).round(4).alias("equity"))["equity"].to_list()
[1.1, 1.045, 1.254, 1.3794, 1.1725, 1.2311, 1.6004, 1.4404]

On a multi-ticker panel, wrap the call in .over so each ticker compounds independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "returns": [0.1, 0.2, -0.05, 0.1, 0.0, 0.1, 0.1, -0.2],
...     }
... )
>>> frame.with_columns(equity_curve(pl.col("returns")).over("ticker").round(4).alias("e"))["e"].to_list()
[1.1, 1.32, 1.254, 1.3794, 1.0, 1.1, 1.21, 0.968]

A leading null stays null (the curve begins at the first defined return) and a later NaN then contaminates every row after it:

>>> frame = pl.DataFrame({"returns": [None, 0.1, 0.2, float("nan"), 0.1]})
>>> frame.select(equity_curve(pl.col("returns")).round(4).alias("equity"))["equity"].to_list()
[None, 1.1, 1.32, nan, nan]
pomata.pnl.pnl_gross(
quantity: Expr,
price: Expr,
*,
multiplier: float = 1.0,
) Expr[source]

Gross Position PnL, the per-bar mark-to-market profit and loss of a held quantity.

The signed quantity held over a bar times the bar’s price change times the contract multiplier — the strategy’s gross P&L for that bar in the price’s currency, before transaction costs. This is the cash / position flow’s counterpart to returns_gross(): use it when you hold a quantity of an instrument at a price (so the instrument’s multiplier, and later dividends / funding / FX, can be booked honestly), rather than a weight and a return.

\[\mathrm{pnl}^{\mathrm{gross}}_t = q_t \cdot (P_t - P_{t-1}) \cdot m, \qquad q = \text{quantity},\ m = \text{multiplier}.\]

Summed over time it is the total mark-to-market PnL (realized plus unrealized combined); pomata does not split realized from unrealized (that needs cost-basis lot accounting, which a vectorized pl.Expr does not carry).

Parameters:
  • quantity – Signed position size in units / shares / contracts held over the bar (e.g. 100, -2).

  • price – Instrument price series (e.g. pl.col("close")); must share a length and alignment with quantity.

  • multiplier – Contract multiplier / point value (e.g. 50 for an E-mini S&P future); 1.0 for cash equity and spot. Must be a finite number > 0.

Returns:

the previous price price.shift(1) is undefined for the first row, so no price change can be measured there.

Return type:

The gross PnL for each row, the same length as the inputs. The first value is null (warm-up)

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If multiplier is not a finite number > 0 (i.e. <= 0, NaN, or ±inf).

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

No lookahead (alignment is the caller’s): the PnL assumes quantity at row t is the position held over the price change into row t. To stay lookahead-free, that quantity must depend only on information available before that price; if it is decided on the same bar’s close, lag it by one bar (pnl_gross(quantity.shift(1), price)). Nothing is shifted for you, so a quantity you have already aligned is never double-shifted.

Edge-case behavior:

  • Null — a null in quantity, price, or the previous price makes that row null (null takes precedence over NaN).

  • NaN — a NaN in either input (with no null) propagates, yielding NaN for that row.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the one-bar price change never reaches across series boundaries, e.g. pnl_gross(pl.col("quantity"), pl.col("price")).over("ticker").

See also

  • returns_gross(): The return-flow counterpart (weight times asset return).

  • pnl_net(): Subtracts the composed cost from this gross PnL.

  • pnl_gross_inverse(): The coin-margined (inverse-contract) version, nonlinear in price.

References

Examples

Basic usage on a held quantity and a price series:

>>> import polars as pl
>>> from pomata.pnl import pnl_gross
>>>
>>> frame = pl.DataFrame(
...     {
...         "quantity": [10.0, 10.0, -5.0, -5.0, 20.0, 20.0, -10.0, -10.0],
...         "price": [100.0, 102.0, 101.0, 104.0, 103.0, 105.0, 104.0, 106.0],
...     }
... )
>>> frame.select(pnl_gross(pl.col("quantity"), pl.col("price")).round(4).alias("pnl"))["pnl"].to_list()
[None, 20.0, 5.0, -15.0, -20.0, 40.0, 10.0, -20.0]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "quantity": [10.0, 10.0, -5.0, -5.0, 2.0, 2.0, 2.0, 2.0],
...         "price": [100.0, 102.0, 101.0, 104.0, 50.0, 51.0, 49.0, 52.0],
...     }
... )
>>> frame.with_columns(pnl_gross(pl.col("quantity"), pl.col("price")).over("ticker").round(4).alias("p"))[
...     "p"
... ].to_list()
[None, 20.0, 5.0, -15.0, None, 2.0, -4.0, 6.0]

A leading warm-up null (row 0, no prior price), then a null and a NaN in quantity that void only their own rows:

>>> frame = pl.DataFrame(
...     {
...         "quantity": [10.0, None, -5.0, float("nan"), 20.0],
...         "price": [100.0, 102.0, 101.0, 104.0, 103.0],
...     }
... )
>>> frame.select(pnl_gross(pl.col("quantity"), pl.col("price")).round(4).alias("pnl"))["pnl"].to_list()
[None, None, 5.0, nan, -20.0]
pomata.pnl.pnl_gross_inverse(
quantity: Expr,
price: Expr,
*,
multiplier: float = 1.0,
) Expr[source]

Gross Inverse-Contract PnL (coin-margined), the per-bar mark-to-market profit and loss settled in the base coin.

An inverse (coin-margined) perpetual or futures contract carries a fixed notional in the quote currency (e.g. 1 USD per contract) but settles its profit and loss in the base coin (e.g. BTC). Its value per contract is therefore the reciprocal of the price, so the PnL is the signed quantity times the contract notional times the one-bar change in that reciprocal — nonlinear in the price, the one case the linear pnl_gross() cannot express:

\[\mathrm{pnl}^{\mathrm{gross}}_t = q_t \cdot m \cdot \left( \frac{1}{P_{t-1}} - \frac{1}{P_t} \right), \qquad q = \text{quantity},\ m = \text{multiplier}.\]

A long gains as the price rises (the reciprocal falls), exactly as for a linear contract, but the coin-denominated payoff is concave in the price for a long (convex for a short), since the contract’s coin value 1 / P is convex. Summed over time it is the total mark-to-market PnL (realized plus unrealized combined); pomata does not split realized from unrealized (that needs cost-basis lot accounting, which a vectorized pl.Expr does not carry).

Parameters:
  • quantity – Signed position size in units / shares / contracts held over the bar (e.g. 100, -2).

  • price – Instrument price series, the quote per base unit (e.g. USD per BTC, pl.col("close")); must be strictly positive (see the Domain note) and share a length and alignment with quantity.

  • multiplier – Contract notional in the quote currency — the quote value of one contract (e.g. 1 USD for an inverse BTC/USD perpetual, 100 on some venues); 1.0 for a one-unit contract. Must be a finite number > 0.

Returns:

The gross PnL for each row, in the base coin, the same length as the inputs. The first value is null (warm-up): the previous price price.shift(1) is undefined for the first row, so no price change can be measured there.

Raises:
  • TypeError – If any input is not a pl.Expr.

  • ValueError – If multiplier is not a finite number > 0 (i.e. <= 0, NaN, or ±inf).

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

No lookahead (alignment is the caller’s): the PnL assumes quantity at row t is the position held over the price change into row t. To stay lookahead-free, that quantity must depend only on information available before that price; if it is decided on the same bar’s close, lag it by one bar (pnl_gross_inverse(quantity.shift(1), price)). Nothing is shifted for you, so a quantity you have already aligned is never double-shifted.

Domain — the payoff is defined on strictly positive prices. Following IEEE-754 division, a zero current price makes 1 / P_t infinite, so the bar is -inf (a long) or +inf (a short); a zero previous price makes 1 / P_{t-1} infinite, so the bar takes the opposite sign; and a negative price yields a finite but economically meaningless value (the reciprocal flips sign). These are the documented and intended boundary values rather than an error.

Edge-case behavior:

  • Null — a null in quantity, price, or the previous price makes that row null (null takes precedence over NaN).

  • NaN — a NaN in either input (with no null) propagates, yielding NaN for that row.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the one-bar price change never reaches across series boundaries, e.g. pnl_gross_inverse(pl.col("quantity"), pl.col("price")).over("ticker").

See also

  • pnl_gross(): The linear (quote-margined) counterpart; use it when the contract settles in the quote currency rather than the base coin.

  • pnl_net(): Subtracts the composed cost from this gross PnL.

  • cost_funding(): The perpetual-swap funding leg, the companion holding cost.

References

Examples

Basic usage on an inverse (coin-margined) contract:

>>> import polars as pl
>>> from pomata.pnl import pnl_gross_inverse
>>>
>>> frame = pl.DataFrame(
...     {
...         "quantity": [1.0, 1.0, -2.0, -2.0, 3.0, 3.0, -1.0, -1.0],
...         "price": [100.0, 110.0, 105.0, 120.0, 115.0, 118.0, 112.0, 120.0],
...     }
... )
>>> expr = pnl_gross_inverse(pl.col("quantity"), pl.col("price")).round(6)
>>> frame.select(expr.alias("pnl"))["pnl"].to_list()
[None, 0.000909, 0.000866, -0.002381, -0.001087, 0.000663, 0.000454, -0.000595]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "quantity": [1.0, 1.0, -2.0, -2.0, 2.0, 2.0, 2.0, 2.0],
...         "price": [100.0, 110.0, 105.0, 120.0, 50.0, 55.0, 52.0, 58.0],
...     }
... )
>>> frame.with_columns(
...     pnl_gross_inverse(pl.col("quantity"), pl.col("price")).over("ticker").round(6).alias("p")
... )["p"].to_list()
[None, 0.000909, 0.000866, -0.002381, None, 0.003636, -0.002098, 0.003979]

A leading warm-up null (row 0, no prior price), then a null and a NaN in quantity that void only their own rows:

>>> frame = pl.DataFrame(
...     {
...         "quantity": [1.0, None, -2.0, float("nan"), 3.0],
...         "price": [100.0, 110.0, 105.0, 120.0, 115.0],
...     }
... )
>>> expr = pnl_gross_inverse(pl.col("quantity"), pl.col("price")).round(6)
>>> frame.select(expr.alias("pnl"))["pnl"].to_list()
[None, None, 0.000866, nan, -0.001087]
pomata.pnl.pnl_net(
pnl_gross: Expr,
cost: Expr,
) Expr[source]

Net Position PnL, the gross position PnL after transaction costs.

The gross per-bar position PnL minus the per-bar transaction cost, both in the account currency — the cash flow’s net P&L, the counterpart of returns_net():

\[\mathrm{pnl}^{\mathrm{net}}_t = \mathrm{pnl}^{\mathrm{gross}}_t - c_t.\]

A pure elementwise subtraction with no built-in cost model: the caller composes the cost from the cost components (summing several with +) and passes it, e.g. pnl_net(pnl_gross(quantity, price), cost_per_share(quantity, fee) + cost_notional(quantity, price, rate)).

Parameters:
  • pnl_gross – Gross per-bar position PnL, typically from pnl_gross().

  • cost – Per-bar transaction cost in the same currency, typically from cost_per_share() (sum several with +).

Returns:

The net PnL for each row, the same length as the inputs.

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

Edge-case behavior:

  • Null — a null in either input makes that row null (null takes precedence over NaN).

  • NaN — a NaN in either input (with no null) propagates, yielding NaN for that row.

  • Partitioning — the subtraction is elementwise (each row uses only its own pair), so .over(...) partitions identically and is optional here, unlike the lagged / cumulative functions.

See also

References

Examples

Basic usage on a gross P&L and a cost series:

>>> import polars as pl
>>> from pomata.pnl import pnl_net
>>>
>>> frame = pl.DataFrame(
...     {
...         "pnl_gross": [20.0, 5.0, -15.0, -20.0, 8.0, 12.0, -3.0, 10.0],
...         "cost": [2.0, 0.0, 3.0, 0.0, 1.0, 2.0, 0.0, 1.0],
...     }
... )
>>> frame.select(pnl_net(pl.col("pnl_gross"), pl.col("cost")).round(4).alias("pnl_net"))["pnl_net"].to_list()
[18.0, 5.0, -18.0, -20.0, 7.0, 10.0, -3.0, 9.0]

The subtraction is elementwise, so .over partitions identically and is shown only for consistency:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "pnl_gross": [20.0, 5.0, -15.0, -20.0, 8.0, 12.0, -3.0, 10.0],
...         "cost": [2.0, 0.0, 3.0, 0.0, 1.0, 2.0, 0.0, 1.0],
...     }
... )
>>> frame.with_columns(pnl_net(pl.col("pnl_gross"), pl.col("cost")).over("ticker").round(4).alias("n"))[
...     "n"
... ].to_list()
[18.0, 5.0, -18.0, -20.0, 7.0, 10.0, -3.0, 9.0]

A null then a NaN in pnl_gross (both propagate through the subtraction) make the missing-data handling visible:

>>> frame = pl.DataFrame(
...     {
...         "pnl_gross": [20.0, None, -15.0, float("nan"), 8.0],
...         "cost": [2.0, 3.0, 3.0, 0.0, 1.0],
...     }
... )
>>> frame.select(pnl_net(pl.col("pnl_gross"), pl.col("cost")).round(4).alias("pnl_net"))["pnl_net"].to_list()
[18.0, None, -18.0, nan, 7.0]
pomata.pnl.returns_gross(
weight: Expr,
asset_returns: Expr,
) Expr[source]

Gross Strategy Returns, the per-bar return of a weight before costs.

The signed weight times the asset’s per-bar return — the strategy’s gross return for that bar, before any transaction costs:

\[r^{\mathrm{gross}}_t = w_t \cdot r_t, \qquad w = \text{weight}.\]

Because simple returns aggregate across assets (a portfolio’s return is the weighted sum of its constituents’), this per-leg product is the building block of a multi-asset gross return: sum it over the legs of a panel. It is a pure elementwise multiply with no built-in lag: each row pairs weight with asset_returns at the same index, so the caller is responsible for alignment.

Parameters:
  • weight – Signed weight, the fraction of capital held (e.g. 1.0 fully long, -0.5 half short); |weight| > 1 is leverage.

  • asset_returns – Per-bar asset returns, typically from returns_simple() (e.g. returns_simple(pl.col("close"))).

Returns:

The gross strategy return for each row, the same length as the inputs. There is no window and no warm-up of its own: every row is the product of its own weight and asset_returns (so a warm-up null is inherited only from the inputs, e.g. the first row of returns_simple()).

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

No lookahead (alignment is the caller’s): the product assumes weight at row t is the weight held over asset_returns at row t. To stay lookahead-free, that weight must depend only on information available before that return; if your weight is decided on the same bar that closes the return, lag it by one bar – returns_gross(weight.shift(1), asset_returns) – so the weight reflects only the prior close. Nothing is shifted for you, so a weight you have already aligned is never double-shifted.

Edge-case behavior:

  • Null — a null in either input makes that row null (the product propagates null, which takes precedence over NaN).

  • NaN — a NaN in either input (with no null at that row) propagates, yielding NaN for that row.

  • Partitioning — the product is elementwise (each row uses only its own pair), so it is already correct on a multi-series panel: .over(...) partitions identically and is therefore optional here, unlike the lagged / cumulative functions where it is required to stop state spanning series boundaries.

See also

  • returns_simple(): The usual source of asset_returns.

  • turnover(): The traded fraction of the same weight, the basis for transaction costs.

  • equity_curve(): Compounds these per-bar returns into a capital curve.

References

Examples

Basic usage on a weight and an asset-return series:

>>> import polars as pl
>>> from pomata.pnl import returns_gross
>>>
>>> frame = pl.DataFrame(
...     {
...         "weight": [1.0, 0.5, -1.0, -1.0, 0.5, 1.0, -0.5, 0.5],
...         "asset_returns": [0.02, -0.01, 0.03, -0.02, 0.04, 0.01, -0.03, 0.02],
...     }
... )
>>> expr = returns_gross(pl.col("weight"), pl.col("asset_returns")).round(4)
>>> frame.select(expr.alias("returns_gross"))["returns_gross"].to_list()
[0.02, -0.005, -0.03, 0.02, 0.02, 0.01, 0.015, 0.01]

The product is elementwise, so .over partitions identically and is shown only for consistency:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "weight": [1.0, -1.0, 0.5, 0.5, 0.5, 0.5, -1.0, 1.0],
...         "asset_returns": [0.02, 0.03, -0.01, 0.04, -0.02, 0.01, 0.03, -0.01],
...     }
... )
>>> expr = returns_gross(pl.col("weight"), pl.col("asset_returns")).over("ticker").round(4)
>>> frame.with_columns(expr.alias("g"))["g"].to_list()
[0.02, -0.03, -0.005, 0.02, -0.01, 0.005, -0.03, -0.01]

A null then a NaN in asset_returns (both propagate through the product) make the missing-data handling visible:

>>> frame = pl.DataFrame(
...     {
...         "weight": [1.0, 0.5, -1.0, -1.0, 0.5],
...         "asset_returns": [0.02, None, 0.03, float("nan"), 0.04],
...     }
... )
>>> expr = returns_gross(pl.col("weight"), pl.col("asset_returns")).round(4)
>>> frame.select(expr.alias("returns_gross"))["returns_gross"].to_list()
[0.02, None, -0.03, nan, 0.02]
pomata.pnl.returns_log(
expr: Expr,
) Expr[source]

Logarithmic Returns, also known as continuously-compounded or log returns.

The natural logarithm of the gross return — the price relative to the previous observation — measuring the instantaneous growth rate that, compounded continuously, takes the series from one bar to the next:

\[r_t = \ln\!\left(\frac{P_t}{P_{t-1}}\right) = \ln P_t - \ln P_{t-1}.\]

Log returns are the representation that aggregates across time: the multi-period log return is the plain sum of the single-period log returns, \(\sum_t r_t = \ln(P_T / P_0)\), since the logarithm turns the product of gross returns into a sum. They are defined on a strictly positive price series and are the natural input to time-series models and any horizon-aggregation by addition; for combining holdings into a portfolio at one point in time use returns_simple(), which aggregates across assets instead — the two are not interchangeable.

Parameters:

expr – Input series, typically a price column (e.g. pl.col("close")).

Returns:

the lagged term expr.shift(1) is undefined for the first row, so no return can be measured there.

Return type:

The log return for each row, the same length as expr. The first value is null (warm-up)

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

Edge-case behavior:

  • Null — a null at the current row or at the previous row yields null at that position.

  • NaN — a NaN at the current row or at the previous row (and no null) yields NaN. Because the return is a fixed-lag transform of two endpoints rather than a recurrence, a null or NaN contaminates only the positions that reference it and never latches onto the rest of the series.

  • Domain — log returns are defined on strictly positive prices. Following the IEEE-754 logarithm, a zero price relative (P_t = 0 over a positive P_{t-1}) yields -inf, a negative relative (the prices straddle zero) yields NaN, and a zero previous price yields +inf only for a positive current price, while 0/0 and a negative current price over zero yield NaN; these are the documented and intended boundary values rather than an error.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the lag never reaches across series boundaries, e.g. returns_log(pl.col("close")).over("ticker").

See also

  • returns_simple(): The arithmetic sibling, which aggregates across assets rather than across time.

  • cumulative_pnl(): The additive running total; log returns sum to the total log return over a horizon.

  • equity_curve(): Compounds the gross returns into the growth path of one unit of capital.

References

Examples

>>> import polars as pl
>>> from pomata.pnl import returns_log
>>>
>>> frame = pl.DataFrame({"close": [100.0, 102.0, 101.0, 105.0, 104.0, 107.0, 110.0, 108.0, 112.0]})
>>> frame.select(returns_log(pl.col("close")).round(4).alias("returns_log"))["returns_log"].to_list()
[None, 0.0198, -0.0099, 0.0388, -0.0096, 0.0284, 0.0277, -0.0183, 0.0364]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "close": [100.0, 105.0, 102.0, 108.0, 50.0, 52.0, 51.0, 55.0],
...     }
... )
>>> frame.with_columns(returns_log(pl.col("close")).over("ticker").round(4).alias("r"))["r"].to_list()
[None, 0.0488, -0.029, 0.0572, None, 0.0392, -0.0194, 0.0755]

A null (whose lag voids the next bar too) and a NaN (which propagates) touch only the positions that reference them before the series recovers, making the missing-data handling visible:

>>> frame = pl.DataFrame({"close": [100.0, 105.0, None, 108.0, 110.0, float("nan"), 113.0, 115.0]})
>>> frame.select(returns_log(pl.col("close")).round(4).alias("returns_log"))["returns_log"].to_list()
[None, 0.0488, None, None, 0.0183, nan, nan, 0.0175]
pomata.pnl.returns_net(
returns_gross: Expr,
cost: Expr,
) Expr[source]

Net Strategy Returns, the gross return after transaction costs.

The gross per-bar strategy return minus the per-bar transaction cost — the strategy’s net return, which is the series the performance and risk metrics consume:

\[r^{\mathrm{net}}_t = r^{\mathrm{gross}}_t - c_t.\]

It is a pure elementwise subtraction with no built-in cost model: the caller composes the cost from the cost components (summing several with +) and passes it, e.g. returns_net(returns_gross(weight, asset_returns), cost_proportional(weight, rate)).

Parameters:
  • returns_gross – Gross per-bar strategy returns, typically from returns_gross().

  • cost – Per-bar transaction cost as a return drag, typically from cost_proportional() (sum several with +).

Returns:

The net strategy return for each row, the same length as the inputs. There is no window and no warm-up of its own: every row is returns_gross minus cost at that row.

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

Edge-case behavior:

  • Null — a null in either input makes that row null (the subtraction propagates null, which takes precedence over NaN).

  • NaN — a NaN in either input (with no null at that row) propagates, yielding NaN for that row.

  • Partitioning — the subtraction is elementwise (each row uses only its own pair), so it is already correct on a multi-series panel: .over(...) partitions identically and is therefore optional here, unlike the lagged / cumulative functions where it is required.

See also

References

Examples

Basic usage on a gross return and a cost series:

>>> import polars as pl
>>> from pomata.pnl import returns_net
>>>
>>> frame = pl.DataFrame(
...     {
...         "returns_gross": [0.05, -0.02, 0.03, 0.01, 0.0, 0.04, -0.01, 0.02],
...         "cost": [0.0005, 0.0015, 0.0005, 0.0, 0.0005, 0.001, 0.0, 0.0005],
...     }
... )
>>> expr = returns_net(pl.col("returns_gross"), pl.col("cost")).round(4)
>>> frame.select(expr.alias("returns_net"))["returns_net"].to_list()
[0.0495, -0.0215, 0.0295, 0.01, -0.0005, 0.039, -0.01, 0.0195]

The subtraction is elementwise, so .over partitions identically and is shown only for consistency:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "returns_gross": [0.05, -0.02, 0.03, 0.01, 0.0, 0.04, -0.01, 0.02],
...         "cost": [0.0005, 0.0015, 0.0005, 0.0, 0.0005, 0.001, 0.0, 0.0005],
...     }
... )
>>> expr = returns_net(pl.col("returns_gross"), pl.col("cost")).over("ticker").round(4)
>>> frame.with_columns(expr.alias("n"))["n"].to_list()
[0.0495, -0.0215, 0.0295, 0.01, -0.0005, 0.039, -0.01, 0.0195]

A null then a NaN in returns_gross (both propagate through the subtraction) make the missing-data handling visible:

>>> frame = pl.DataFrame(
...     {
...         "returns_gross": [0.05, None, 0.03, float("nan"), 0.0],
...         "cost": [0.0005, 0.0015, 0.0005, 0.0, 0.0005],
...     }
... )
>>> expr = returns_net(pl.col("returns_gross"), pl.col("cost")).round(4)
>>> frame.select(expr.alias("returns_net"))["returns_net"].to_list()
[0.0495, None, 0.0295, nan, -0.0005]
pomata.pnl.returns_simple(
expr: Expr,
) Expr[source]

Simple Returns, also known as arithmetic or linear returns.

The fractional change of the series relative to its previous observation — the gross return minus one — the everyday “percent return” of a holding over one bar:

\[r_t = \frac{P_t}{P_{t-1}} - 1 = \frac{P_t - P_{t-1}}{P_{t-1}}.\]

Simple returns are the representation that aggregates across assets: a portfolio’s single-period return is the weighted sum of its constituents’ simple returns, \(r_p = \sum_i w_i\, r_i\), which makes them the correct input for combining holdings and for portfolio construction at one point in time. For aggregating one holding’s return across many periods by addition use returns_log() instead — the two are not interchangeable.

Parameters:

expr – Input series, typically a price column (e.g. pl.col("close")).

Returns:

the lagged term expr.shift(1) is undefined for the first row, so no return can be measured there.

Return type:

The simple return for each row, the same length as expr. The first value is null (warm-up)

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

Edge-case behavior:

  • Null — a null at the current row or at the previous row yields null at that position.

  • NaN — a NaN at the current row or at the previous row (and no null) yields NaN. Because the return is a fixed-lag transform of two endpoints rather than a recurrence, a null or NaN contaminates only the positions that reference it and never latches onto the rest of the series.

  • Division by zero — when the previous price is 0 the relative divides by zero following IEEE-754: a zero change (0 / 0) is NaN and a non-zero change over zero is +/-inf (the sign tracks the change). This is the documented and intended behavior rather than an error.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the lag never reaches across series boundaries, e.g. returns_simple(pl.col("close")).over("ticker").

See also

  • returns_log(): The logarithmic sibling, which aggregates across time rather than across assets.

  • equity_curve(): Compounds the simple returns into the growth path of one unit of capital.

  • cumulative_pnl(): The additive running total of a per-bar P&L or return series.

References

Examples

>>> import polars as pl
>>> from pomata.pnl import returns_simple
>>>
>>> frame = pl.DataFrame({"close": [100.0, 102.0, 101.0, 105.0, 104.0, 107.0, 110.0, 108.0, 112.0]})
>>> frame.select(returns_simple(pl.col("close")).round(4).alias("returns_simple"))["returns_simple"].to_list()
[None, 0.02, -0.0098, 0.0396, -0.0095, 0.0288, 0.028, -0.0182, 0.037]

On a multi-ticker panel, wrap the call in .over so each ticker warms up independently:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "close": [100.0, 105.0, 102.0, 108.0, 50.0, 52.0, 51.0, 55.0],
...     }
... )
>>> frame.with_columns(returns_simple(pl.col("close")).over("ticker").round(4).alias("r"))["r"].to_list()
[None, 0.05, -0.0286, 0.0588, None, 0.04, -0.0192, 0.0784]

A null (whose lag voids the next bar too) and a NaN (which propagates) touch only the positions that reference them before the series recovers, making the missing-data handling visible:

>>> frame = pl.DataFrame({"close": [100.0, 105.0, None, 108.0, 110.0, float("nan"), 113.0, 115.0]})
>>> frame.select(returns_simple(pl.col("close")).round(4).alias("returns_simple"))["returns_simple"].to_list()
[None, 0.05, None, None, 0.0185, nan, nan, 0.0177]
pomata.pnl.turnover(
weight: Expr,
) Expr[source]

Turnover, the traded fraction of capital between consecutive bars.

The absolute change in the weight from one bar to the next — how much was bought or sold to move from the previous weight to the current one, as a fraction of capital:

\[\mathrm{turnover}_t = \lvert w_t - w_{t-1} \rvert, \qquad w = \text{weight}.\]

The pre-series weight is taken as flat (0), so the first bar is \(\lvert w_0 \rvert\): entering the initial weight from cash is itself a trade. Turnover is the basis for proportional transaction costs (a cost per unit traded), and is a dimensionless churn measure in its own right.

Parameters:

weight – Signed weight, the fraction of capital held (e.g. 1.0 fully long, -0.5 half short); |weight| > 1 is leverage.

Returns:

The traded fraction for each row, the same length as weight. The first row is |weight_0| (the trade from a flat start), not null.

Raises:

TypeError – If any input is not a pl.Expr.

Note

Correctness – the result is checked against an independent reference oracle on every input, and every edge case (missing data, boundaries, and warm-up where applicable) is given a defined behavior.

Edge-case behavior:

  • Flat start — the weight before the series is taken as 0, so the first row is |weight_0| rather than null; establishing the initial weight from cash is a real trade and carries its cost.

  • Null — a null weight makes its own row null and also the next row null (the difference references the previous weight), then turnover resumes; null takes precedence over NaN.

  • NaN — a NaN weight propagates to its own row and the next, yielding NaN there.

  • Partitioning — wrap the call in .over(...) for a multi-series panel so the one-bar difference never reaches across series boundaries (and each series gets its own flat start), e.g. turnover(pl.col("weight")).over("ticker").

See also

References

Examples

Basic usage on a weight series:

>>> import polars as pl
>>> from pomata.pnl import turnover
>>>
>>> frame = pl.DataFrame({"weight": [0.5, 1.0, -0.5, -0.5, 0.0, 1.0, 1.0, -1.0]})
>>> frame.select(turnover(pl.col("weight")).round(4).alias("turnover"))["turnover"].to_list()
[0.5, 0.5, 1.5, 0.0, 0.5, 1.0, 0.0, 2.0]

On a multi-ticker panel, wrap the call in .over so each ticker starts flat and never differences across the boundary:

>>> frame = pl.DataFrame(
...     {
...         "ticker": ["A"] * 4 + ["B"] * 4,
...         "weight": [0.5, 1.0, -0.5, -0.5, 1.0, 1.0, 0.0, 0.5],
...     }
... )
>>> frame.with_columns(turnover(pl.col("weight")).over("ticker").round(4).alias("t"))["t"].to_list()
[0.5, 0.5, 1.5, 0.0, 1.0, 0.0, 1.0, 0.5]

A null (which voids its own row and the next, since the difference references the previous weight) then a NaN (likewise) make the missing-data handling visible:

>>> frame = pl.DataFrame({"weight": [0.5, None, -0.5, float("nan"), 0.0]})
>>> frame.select(turnover(pl.col("weight")).round(4).alias("turnover"))["turnover"].to_list()
[0.5, None, None, nan, nan]