Source code for trader.ranking.base
"""What a ranker takes, what it returns, and the ordering used without one.
A ranker never sees a strategy, a broker, an executor or a repository — the
same discipline `daemon/` follows. It takes value objects and returns an order.
That is what lets it be tested without a model and swapped without touching the
pipeline.
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Protocol, runtime_checkable
from trader.ranking.comparables import Comparables
__all__ = [
"SOURCE_CONFIDENCE_ORDERING_ENABLED",
"SOURCE_FALLBACK_CONFIDENCE",
"SOURCE_FALLBACK_SYMBOL",
"SOURCE_LLM",
"SOURCE_SINGLE",
"Candidate",
"Ranker",
"Ranking",
"fallback_ranking",
]
#: How an order was arrived at. Recorded on every decision row, so a log line
#: or a stored row never claims a ranking basis it did not have.
SOURCE_LLM = "llm"
SOURCE_FALLBACK_CONFIDENCE = "fallback:confidence"
SOURCE_FALLBACK_SYMBOL = "fallback:symbol"
SOURCE_SINGLE = "skipped:single-candidate"
#: `pipeline_config.confidence_ordering_enabled` forced this order, bypassing
#: whatever ranker was configured (issue #86). Distinct from
#: `SOURCE_FALLBACK_CONFIDENCE` on purpose: that source means "no ranker was
#: usable"; this one means "a ranker may well have been usable, an operator
#: chose not to use it" — collapsing the two would misreport why a cycle's
#: order was what it was.
SOURCE_CONFIDENCE_ORDERING_ENABLED = "confidence_ordering_enabled"
#: Sorts below any real confidence, including 0.0. A missing confidence must
#: not read as a high one — that would put the least-informed candidate first.
_ABSENT_CONFIDENCE = -1.0
[docs]
@dataclass(frozen=True, slots=True)
class Candidate:
"""One approved BUY waiting on capital — the ranker's whole input.
`confidence` is `float | None` rather than `float`: `LlmStrategy` puts one
in `Signal.indicators`, and the rule strategies put nothing there. `None`
means "this strategy does not report one", which is a different claim from
0.0 and must stay distinguishable.
"""
symbol: str
confidence: float | None
reason: str
last_close: Decimal
comparables: Comparables
[docs]
@dataclass(frozen=True, slots=True)
class Ranking:
"""An order over candidate symbols, and how it was arrived at."""
#: Best first. Always a permutation of exactly the candidate symbols.
order: tuple[str, ...]
source: str
#: Symbol -> the ranker's own words about it. Empty on every fallback path.
notes: Mapping[str, str] = field(default_factory=dict)
#: Provenance for `decisions.inputs_json`.
inputs: Mapping[str, object] = field(default_factory=dict)
[docs]
@runtime_checkable
class Ranker(Protocol):
"""Orders approved candidates. Never raises, never vetoes, never sizes."""
[docs]
def rank(self, candidates: Sequence[Candidate]) -> Ranking:
"""Return an order over `candidates`, best first.
The result must be a permutation of exactly the symbols passed in:
never shorter, never longer, never containing a symbol nobody proposed.
A ranker that could drop a candidate would be vetoing, which is
deliberately not its job — every candidate has already been approved by
its own per-symbol decision one call earlier.
"""
...
[docs]
def fallback_ranking(candidates: Sequence[Candidate]) -> Ranking:
"""Confidence descending, ties broken on symbol ascending.
Used whenever a model ranking is unavailable or unusable. Safe as a default
precisely because ranking only *orders*: every candidate here has already
passed its own decision and must still clear every guardrail, so a degraded
order cannot unlock risk — it only spends the headroom in a less informed
sequence. Refusing to trade at all on a ranker outage would make a dead
endpoint a single point of failure for trading, which is the shape the
discovery rules forbid.
Ties break on symbol so a cycle is reproducible.
"""
order = tuple(
c.symbol
for c in sorted(
candidates,
key=lambda c: (
-(c.confidence if c.confidence is not None else _ABSENT_CONFIDENCE),
c.symbol,
),
)
)
# With no confidence anywhere, every comparison tied and this is really
# alphabetical order. Saying "fallback:confidence" would claim a basis it
# did not have.
source = (
SOURCE_FALLBACK_CONFIDENCE
if any(c.confidence is not None for c in candidates)
else SOURCE_FALLBACK_SYMBOL
)
return Ranking(order=order, source=source)