Source code for trader.ranking.comparables

"""Two deterministic figures per candidate, derived from stored bars.

The ranker is shown each candidate's own prose reason from its per-symbol
decision. A model comparing several pieces of its own fluent prose will tend to
pick the most fluent, not the best — so the prompt also carries numbers that are
commensurable across candidates and can contradict a well-written reason.

Both figures come from bars this app already fetched. Nothing here does I/O, so
`decisions.inputs_json` stays reproducible and a later provider comparison stays
a script rather than a project.
"""

from collections.abc import Sequence
from dataclasses import dataclass
from decimal import Decimal

from trader.domain import Bar

__all__ = ["Comparables", "build_comparables"]

_HUNDRED = Decimal(100)
_PERCENT = Decimal("0.01")


[docs] @dataclass(frozen=True, slots=True) class Comparables: """Where a candidate sits in its own recent range, and which way it moved.""" bars_considered: int #: Percent change from the window's first close to its last. window_change_pct: Decimal #: Where the last close sits between the window low and high, 0-100. #: #: `None` when the window is flat (high == low). NOT 0 and NOT 50: a flat #: window is real for an illiquid or halted name, and both numbers would be #: false claims about where price sits — one saying "at the low", the other #: "mid-range" — which the ranker would believe. range_position_pct: Decimal | None
[docs] def as_dict(self) -> dict[str, object]: """JSON-safe, for `decisions.inputs_json` and for the prompt. Prices and percentages are strings rather than floats for the same reason `build_bar_summary` does it: a float both loses precision and misrepresents what the ranker was shown. """ return { "bars_considered": self.bars_considered, "window_change_pct": str(self.window_change_pct), "range_position_pct": ( None if self.range_position_pct is None else str(self.range_position_pct) ), }
[docs] def build_comparables(bars: Sequence[Bar], lookback: int) -> Comparables: """The two figures, over the last `lookback` bars. `lookback` is the candidate's own strategy's `warmup_bars()`. For `LlmStrategy` that is its `_lookback_bars`, so these figures describe exactly the window the model was shown per symbol. The pool being ranked is always single-strategy, so the window is uniform across candidates — which is the only property that matters for comparing them. """ window = list(bars[-lookback:]) if lookback > 0 else list(bars) if not window: return Comparables( bars_considered=0, window_change_pct=Decimal(0).quantize(_PERCENT), range_position_pct=None, ) first_close = window[0].close last_close = window[-1].close change = ( ((last_close - first_close) / first_close * _HUNDRED).quantize(_PERCENT) if first_close else Decimal(0).quantize(_PERCENT) ) high = max(b.high for b in window) low = min(b.low for b in window) span = high - low position = ((last_close - low) / span * _HUNDRED).quantize(_PERCENT) if span else None return Comparables( bars_considered=len(window), window_change_pct=change, range_position_pct=position, )