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’sreturns(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
quantityof units and aprice(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,
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 withquantity.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
0on 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
rateis 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
nullin the quantity or the price makes that rownull(nulltakes precedence overNaN).NaN — a
NaNin either input (with nonull) propagates, yieldingNaNfor 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 aNaNmake 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,
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 viapnl_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:
feewhere the quantity changes (the first row counts as a trade from a flat start) and0where 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
feeis 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(viaturnover()), so the first row charges thefee(entering the initial position is a trade).Null — a
nullquantity makes its own rownulland the next rownull(the turnover difference references the previous quantity);nulltakes precedence overNaN.NaN — a
NaNquantity propagates to its own row and the next, yieldingNaNthere.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
cost_per_share(): A per-unit-traded commission.cost_notional(): A proportional (bps-of-notional) commission.pnl_net(): Subtracts the composed cost from the gross PnL.
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
.overso 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 aNaNmake 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,
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 withquantity.rate – Per-bar funding rate as a signed fraction of notional, supplied as a series so it can be
0on 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 = 0on bars with no funding event; the cost is then0there.Null — a
nullin any input makes that rownull(nulltakes precedence overNaN).NaN — a
NaNin any input (with nonull) propagates, yieldingNaNfor 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 aNaNmake 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,
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 viapnl_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 withquantity.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
rateis 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(viaturnover()), so the first row charges on the entry trade.Null — a
nullin the quantity (or its predecessor, via turnover) or the price makes that rownull(nulltakes precedence overNaN).NaN — a
NaNin either input propagates, yieldingNaN.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
cost_per_share(): A per-unit-traded commission.cost_fixed(): A flat charge per trade.pnl_net(): Subtracts the composed cost from the gross PnL.
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
.overso 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 aNaNmake 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]
- quantity: Expr,
- fee: float,
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 viapnl_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
feeis 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(viaturnover()), so the first row charges on|quantity_0|(entering the initial position is a trade).Null — a
nullquantity makes its own rownulland the next rownull(the turnover difference references the previous quantity);nulltakes precedence overNaN.NaN — a
NaNquantity propagates to its own row and the next, yieldingNaNthere.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
cost_fixed(): A flat charge per trade.cost_notional(): A proportional (bps-of-notional) commission.pnl_net(): Subtracts the composed cost from the gross PnL.
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
.overso 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 aNaNmake 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,
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 iscost_slippage().- Parameters:
weight – Signed weight, the fraction of capital held (e.g.
1.0fully long,-0.5half short);|weight| > 1is 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, perturnover()).- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
rateis 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(viaturnover()), so the first row is|weight_0| * rate: establishing the initial weight carries its cost.Null — a
nullweight makes its own rownulland the next rownull(the turnover difference references the previous weight), then resumes;nulltakes precedence overNaN.NaN — a
NaNweight propagates to its own row and the next, yieldingNaNthere.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
.overso 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 aNaNmake 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,
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 viareturns_net().half_spreadis 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.0fully long,-0.5half short);|weight| > 1is 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, perturnover()).- Raises:
TypeError – If any input is not a
pl.Expr.ValueError – If
half_spreadis 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(viaturnover()), so the first row is|weight_0| * half_spread: establishing the initial weight crosses the spread.Null — a
nullweight makes its own rownulland the next rownull(the turnover difference references the previous weight), then resumes;nulltakes precedence overNaN.NaN — a
NaNweight propagates to its own row and the next, yieldingNaNthere.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
cost_proportional(): The proportional broker fee, 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_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
.overso 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 aNaNmake 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,
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: useequity_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
nullreturn contributes nothing and emitsnullat that row, while the running sum carries across it unchanged (the cumulation skips the gap rather than breaking on it).NaN — a
NaNreturn propagates into the running sum and every later row staysNaN(it is a real value that contaminates the total, unlike anullgap).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
.overso 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 aNaN(which contaminates every later row) inreturnsmake 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,
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
nullin either input makes that rownull(nulltakes precedence overNaN).NaN — a
NaNin either input (with nonull) propagates, yieldingNaNfor 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
.overpartitions 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
nullthen aNaNinquantity(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,
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 additivecumulative_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 of1(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
nullreturn emitsnullat 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-upnull(e.g. the first row ofreturns_simple()) therefore staysnulland the curve begins at the first defined return.NaN — a
NaNreturn propagates into the running product and every later row staysNaN.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
.overso 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
nullstaysnull(the curve begins at the first defined return) and a laterNaNthen 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,
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.Exprdoes 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 withquantity.multiplier – Contract multiplier / point value (e.g.
50for an E-mini S&P future);1.0for 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
multiplieris 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
quantityat rowtis the position held over the price change into rowt. 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
nullinquantity,price, or the previouspricemakes that rownull(nulltakes precedence overNaN).NaN — a
NaNin either input (with nonull) propagates, yieldingNaNfor 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
.overso 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 anulland aNaNinquantitythat 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,
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.
1USD 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 linearpnl_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 / Pis 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 vectorizedpl.Exprdoes 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 withquantity.multiplier – Contract notional in the quote currency — the quote value of one contract (e.g.
1USD for an inverse BTC/USD perpetual,100on some venues);1.0for 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 priceprice.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
multiplieris 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
quantityat rowtis the position held over the price change into rowt. 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_tinfinite, so the bar is-inf(a long) or+inf(a short); a zero previous price makes1 / 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
nullinquantity,price, or the previouspricemakes that rownull(nulltakes precedence overNaN).NaN — a
NaNin either input (with nonull) propagates, yieldingNaNfor 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
.overso 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 anulland aNaNinquantitythat 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,
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
nullin either input makes that rownull(nulltakes precedence overNaN).NaN — a
NaNin either input (with nonull) propagates, yieldingNaNfor 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
pnl_gross(): The gross position PnL this nets costs from.cost_per_share(): A usual source ofcost(sum several cost components with+).cumulative_pnl(): Cumulates these net PnL into a running currency total.
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
.overpartitions 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
nullthen aNaNinpnl_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,
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
weightwithasset_returnsat the same index, so the caller is responsible for alignment.- Parameters:
weight – Signed weight, the fraction of capital held (e.g.
1.0fully long,-0.5half short);|weight| > 1is 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
weightandasset_returns(so a warm-upnullis inherited only from the inputs, e.g. the first row ofreturns_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
weightat rowtis the weight held overasset_returnsat rowt. 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
nullin either input makes that rownull(the product propagatesnull, which takes precedence overNaN).NaN — a
NaNin either input (with nonullat that row) propagates, yieldingNaNfor 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 ofasset_returns.turnover(): The traded fraction of the sameweight, the basis for transaction costs.equity_curve(): Compounds these per-bar returns into a capital curve.
References
Meucci, A. (2010). “Quant Nugget 2: Linear vs. Compounded Returns.”
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
.overpartitions 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
nullthen aNaNinasset_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,
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 isnull(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
nullat the current row or at the previous row yieldsnullat that position.NaN — a
NaNat the current row or at the previous row (and nonull) yieldsNaN. Because the return is a fixed-lag transform of two endpoints rather than a recurrence, anullorNaNcontaminates 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 = 0over a positiveP_{t-1}) yields-inf, a negative relative (the prices straddle zero) yieldsNaN, and a zero previous price yields+infonly for a positive current price, while0/0and a negative current price over zero yieldNaN; 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
Meucci, A. (2010). “Quant Nugget 2: Linear vs. Compounded Returns.”
https://en.wikipedia.org/wiki/Rate_of_return#Logarithmic_or_continuously_compounded_return
https://www.investopedia.com/terms/c/continuouscompounding.asp
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
.overso 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 aNaN(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,
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_grossminuscostat 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
nullin either input makes that rownull(the subtraction propagatesnull, which takes precedence overNaN).NaN — a
NaNin either input (with nonullat that row) propagates, yieldingNaNfor 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
returns_gross(): The gross return this nets costs from.cost_proportional(): The usual source ofcost(a proportional, bps-of-notional fee).equity_curve(): Compounds these net returns into a capital curve.
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
.overpartitions 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
nullthen aNaNinreturns_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,
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 isnull(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
nullat the current row or at the previous row yieldsnullat that position.NaN — a
NaNat the current row or at the previous row (and nonull) yieldsNaN. Because the return is a fixed-lag transform of two endpoints rather than a recurrence, anullorNaNcontaminates only the positions that reference it and never latches onto the rest of the series.Division by zero — when the previous price is
0the relative divides by zero following IEEE-754: a zero change (0 / 0) isNaNand 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
Meucci, A. (2010). “Quant Nugget 2: Linear vs. Compounded Returns.”
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
.overso 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 aNaN(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,
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.0fully long,-0.5half short);|weight| > 1is 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), notnull.- 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 thannull; establishing the initial weight from cash is a real trade and carries its cost.Null — a
nullweight makes its own rownulland also the next rownull(the difference references the previous weight), then turnover resumes;nulltakes precedence overNaN.NaN — a
NaNweight propagates to its own row and the next, yieldingNaNthere.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
cost_proportional(): The proportional transaction cost this turnover scales.cost_slippage(): A per-trade slippage cost also driven by the traded fraction.returns_gross(): The gross return of the sameweight.
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
.overso 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 aNaN(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]