Files
fxhnt/tests/integration/test_analyst_fleet.py
jgrusewski 0689884f8b fix(factory): constrain LlmAnalyst to backtestable kinds (no hallucinated kinds)
Real Ollama test showed qwen2.5:3b inventing invalid strategy kinds (FUTURES_CONTRACTS, MARKET_RETURN, ...)
because the prompt never listed the engine's available() kinds. Now the analyst is given the allowed kinds,
the prompt forbids inventing others, and proposals with an unknown/empty kind are dropped — so the fleet only
emits backtestable hypotheses. The gauntlet still filters the rest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:44:16 +02:00

130 lines
6.0 KiB
Python

"""Analyst fleet: runs specialists, dedups proposals vs SeenCandidate, count every distinct
hypothesis in the global trial total (the honest N the gauntlet's DSR bar scales with). A bad
analyst is skipped, not fatal."""
from __future__ import annotations
from fxhnt.application.factory.analyst_fleet import AnalystFleet
from fxhnt.application.factory.analysts import FakeAnalyst, LlmAnalyst, Proposal
class FakeStore:
def __init__(self): self.seen = set(); self.trials = 0
def is_seen(self, k): return k in self.seen
def mark_seen(self, k): self.seen.add(k)
def add_trials(self, n): self.trials += n
class BoomAnalyst:
name = "boom"
def propose(self, universe): raise RuntimeError("boom")
class _FakeChatResponse:
def __init__(self, content: str) -> None:
self.content = content
class _FakeChatModel:
def __init__(self, content: str) -> None:
self._content = content
def invoke(self, prompt: str) -> _FakeChatResponse:
return _FakeChatResponse(self._content)
# ── Core dedup-only contract (M1: gather is read-only, no counting) ──────────────
def test_fleet_dedups_within_batch() -> None:
"""gather() deduplicates proposals within a single batch — same key appears only once."""
a = FakeAnalyst("momentum", [Proposal("ES", "trend", {"window": 100.0}),
Proposal("ES", "trend", {"window": 100.0})]) # dup
store = FakeStore()
fleet = AnalystFleet([a], store)
props = fleet.gather(universe=["ES", "NQ"])
assert len(props) == 1 # dup removed
# M1: gather does NOT call add_trials or mark_seen — counting authority is the hunt
assert store.trials == 0
assert len(store.seen) == 0
def test_bad_analyst_is_skipped() -> None:
good = FakeAnalyst("carry", [Proposal("GC", "carry", {})])
store = FakeStore()
props = AnalystFleet([BoomAnalyst(), good], store).gather(universe=["GC"])
assert len(props) == 1 # boom skipped, good kept
assert store.trials == 0 # no counting in gather
def test_cross_analyst_dedup() -> None:
"""Two different analysts proposing the same (market, kind, params) → only ONE fresh entry.
gather does NOT call add_trials (M1 — single counting authority is the hunt)."""
shared = Proposal("ES", "trend", {"window": 100.0})
a1 = FakeAnalyst("specialist-A", [shared])
a2 = FakeAnalyst("specialist-B", [shared])
store = FakeStore()
fleet = AnalystFleet([a1, a2], store)
fresh = fleet.gather(universe=["ES"])
assert len(fresh) == 1
assert store.trials == 0 # gather never counts
assert len(store.seen) == 0 # gather never marks seen
def test_cross_cycle_dedup_filters_already_seen() -> None:
"""A proposal whose key is already in the store (pre-seeded as seen from a prior cycle)
must NOT be returned. gather leaves trials == 0 and does not add to store.seen."""
p = Proposal("ES", "trend", {"window": 100.0})
store = FakeStore()
store.seen.add(p.key()) # pre-seed: store already knows this key from a prior cycle
fleet = AnalystFleet([FakeAnalyst("mom", [p])], store)
fresh = fleet.gather(universe=["ES"])
assert fresh == []
assert store.trials == 0 # add_trials must NOT have been called
assert len(store.seen) == 1 # store unchanged (only the pre-seeded key)
def test_fresh_proposals_not_marked_seen_by_gather() -> None:
"""gather returns fresh proposals without marking them seen — the hunt does that when it
evaluates them. If gather marked them, the hunt would see them as already tested and skip."""
p = Proposal("ES", "trend", {"window": 200.0})
store = FakeStore()
fleet = AnalystFleet([FakeAnalyst("trend", [p])], store)
fresh = fleet.gather(universe=["ES"])
assert len(fresh) == 1
assert p.key() not in store.seen # not pre-emptively marked — the hunt owns mark_seen
# ── Canonical key format (M1: shared namespace via candidate_key) ────────────────────
def test_proposal_key_uses_candidate_key_format() -> None:
"""Proposal.key() must use the canonical candidate_key format (market:kind:k=v)
so it matches the key the FleetOrchestrator uses for dedup."""
from fxhnt.domain.factory import candidate_key
p = Proposal("ES", "trend", {"window": 100.0})
assert p.key() == candidate_key("ES", "trend", {"window": 100.0})
# ── LLM analyst parsing ────────────────────────────────────────────────────────
def test_llm_constrains_kind_and_drops_nonfinite() -> None:
"""LlmAnalyst only emits proposals whose kind is in the allowed (backtestable) set; it drops empty kinds,
hallucinated/unknown kinds, and non-finite param values."""
llm_output = "\n".join([
"ES||window=100", # empty kind → dropped
"ES|FUTURES_CONTRACTS|x=1", # hallucinated kind not in allowed set → dropped
"ES|momentum|window=50", # 'momentum' not a backtestable kind → dropped
"ES|trend|window=inf", # valid kind, non-finite param dropped → params == {}
"ES|trend|window=50", # fully valid
"ES|MEAN_REVERSION|z=2", # case-insensitive: normalizes to 'mean_reversion' (allowed)
])
chat = _FakeChatModel(llm_output)
analyst = LlmAnalyst("trend", chat, kinds=["trend", "mean_reversion"])
proposals = analyst.propose(["ES"])
kinds = [p.kind for p in proposals]
assert "" not in kinds and "futures_contracts" not in kinds and "momentum" not in kinds
assert kinds.count("trend") == 2 and "mean_reversion" in kinds # only allowed kinds, normalized
inf_trend = next(p for p in proposals if p.kind == "trend" and not p.params)
assert "window" not in inf_trend.params # the inf param was dropped