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>
130 lines
6.0 KiB
Python
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
|