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,
)