Source code for trader.reporting.returns
"""Percentage and rate-of-return math shared by the CLI and the LLM prompt.
`position_return_pct` is the exact function `_position_lines`
(`trader/llm/prompt.py`) calls to describe a held position in the decision
prompt — not a parallel reimplementation of the same formula. That is what
guarantees an operator reading `trader positions` and the model reading the
prompt see the same number for "up 7%" on the same position: the same
function ran once, not two formulas that happen to agree today and can drift
apart tomorrow.
Every percentage here is `Decimal`, rounded with an explicit `quantize` —
never left to string formatting to round implicitly — and every function
returns `None` exactly when its inputs cannot support a number (a zero cost
basis, a hold too short to rate), never a fabricated `0`. Money-adjacent
zeros that were never measured are exactly the defect this project has been
bitten by before; callers choose their own display fallback (a dash, or the
prompt's own `0.00%` for a case that must never abort a response) rather than
this module inventing one.
"""
from datetime import datetime
from decimal import Decimal
from trader.domain import Position
__all__ = [
"MIN_HOLDING_DAYS_FOR_RATE",
"PERCENT_QUANTUM",
"avg_daily_return_pct",
"cost_basis",
"days_held",
"position_return_pct",
"return_pct",
]
#: Two decimal places on every displayed percentage in this module.
PERCENT_QUANTUM = Decimal("0.01")
#: `avg_daily_return_pct` returns `None` below this many whole days held.
#:
#: A same-day or next-day round trip is exactly the case where "return
#: divided by days held" looks authoritative and means nothing: the entire
#: move happened inside a single session, so dividing it by "days" invents a
#: rate the position never actually ran at. The threshold is a judgement
#: call, not a derived value — 1 whole day is the smallest one for which "per
#: day" describes more than the single day the position opened on.
MIN_HOLDING_DAYS_FOR_RATE = 1
[docs]
def cost_basis(entry_price: Decimal, quantity: Decimal) -> Decimal:
"""What was paid to open a position or round trip: price times shares."""
return entry_price * quantity
[docs]
def return_pct(pl: Decimal, basis: Decimal) -> Decimal | None:
"""`pl` as a percentage of `basis`. `None` when `basis` is zero.
A zero cost basis is a real, if rare, broker state — a corporate action
can hand out shares at `avg_entry_price == 0` — and the return on nothing
paid is undefined, not zero. A caller that needs a number to display
(the LLM prompt, which must never raise) picks its own fallback; this
function does not invent one.
"""
if not basis:
return None
return (pl / basis * 100).quantize(PERCENT_QUANTUM)
[docs]
def position_return_pct(position: Position) -> Decimal | None:
"""Unrealized return on cost basis for an open position.
Cost basis (`avg_entry_price * quantity`), not market value, as the
denominator — the number a person means by "up 7%" is return on what was
paid, and it stays stable while the market value moves. See the module
docstring for why this is the one function both the CLI and the prompt
call.
"""
return return_pct(
position.unrealized_pl,
cost_basis(position.avg_entry_price, position.quantity),
)
[docs]
def days_held(opened_at: datetime, as_of: datetime) -> int:
"""Whole days between `opened_at` and `as_of`, floored, never negative.
`timedelta.days` already floors towards zero for a positive delta; the
`max(..., 0)` guards only a reversed pair or clock skew, not the
ordinary case.
"""
return max((as_of - opened_at).days, 0)
[docs]
def avg_daily_return_pct(total_return_pct: Decimal, days: int) -> Decimal | None:
"""Simple (not compounded) average daily return: `total_return_pct / days`.
Simple division over compounding (`(1 + r) ** (1 / n) - 1`) for two
reasons. Compounding is undefined for a total return at or below -100%
(`1 + r <= 0`), which a genuinely bad round trip can reach; simple
division has no such domain restriction. And simple division is the
figure an operator can verify by eye against the `RETURN %` column next
to it — multiplying the two back together reproduces the total, which
is not true of a compounded rate. Callers must label the column (e.g.
`AVG %/DAY (SIMPLE)`) so it is never misread as annualized or compounded.
`None` below `MIN_HOLDING_DAYS_FOR_RATE` — never a rate computed from a
same-day hold, which would divide by zero or invent false precision from
a single session.
"""
if days < MIN_HOLDING_DAYS_FOR_RATE:
return None
return (total_return_pct / days).quantize(PERCENT_QUANTUM)