Wire catalyst score into the live watcher and arming rank

Completes the Phase 2 loop: the catalyst score now actually reaches the runtime
decision, not just the pure functions. Gated behind its own flag so enabling the
Phase 1 advisory/dashboard never silently changes trade scoring.

- OllamaConfig.catalyst_score_enabled (NEWS_CATALYST_SCORE_ENABLED, default off).
- Watcher accepts an optional catalyst_score_provider (symbol -> 0..1 | None) and
  passes it into evaluate_setup; a None provider reproduces today's behavior. The
  provider is injected so runtime/ stays free of research/ imports.
- run_live_paper: one TTL-cached catalyst provider shared by the watcher and the
  execution service; the watcher gets the score provider only when the blend is
  enabled. step_arm gains a gated graded ranking multiplier (bullish catalyst
  boosts by 1+gain*score; a confirmed dilutive offering is sunk below the armed
  cut), falling back to the existing binary news boost when off.
- Add the enrich task to the run_pass pipeline order so a single --once pass also
  runs enrichment (previously only the continuous loop did).
- .env.example documents the new flags; watcher tests cover the blend + the
  no-op None-provider case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5HTMkaadGDnAPtXe2hWwg
This commit is contained in:
Claude committed 2026-06-19 18:45:18 +00:00
1 parent e3cb2b8e5f
commit 767bd75a8d
5 files changed
+138 -24

No files matched your search

+8 -1
View File
@@ -51,7 +51,14 @@ Alpha_Vantage_Api_Key=
# NEWS_ENRICH_INTERVAL_SECONDS=120
# NEWS_ENRICH_LOOKBACK_HOURS=12
# NEWS_ENRICH_BATCH_LIMIT=50 # max headlines classified per pass (GPU budget)
# Phase 2 (automation — ships OFF): block confirmed dilutive offerings before sizing.
# Phase 2 (automation — each ships OFF; separate from the advisory above so the
# dashboard never silently changes scoring):
# score blend — fold the catalyst into the setup quality score AND graded arming
# rank (bullish catalyst boosts, confirmed dilution sinks below the armed cut).
# NEWS_CATALYST_SCORE_ENABLED=0
# NEWS_RANK_BOOST_GAIN=2.0 # arming-rank multiplier = 1 + gain*score
# NEWS_DILUTIVE_PENALTY=0.1 # arming-rank multiplier for a confirmed dilutive name
# dilution veto — block confirmed dilutive offerings before sizing.
# NEWS_DILUTION_VETO_ENABLED=0
# NEWS_DILUTION_VETO_CONVICTION=0.6
+4
View File
@@ -166,6 +166,9 @@ class OllamaConfig(BaseModel):
# Phase 2 dilution veto (ships OFF).
dilution_veto_enabled: bool = False
dilution_veto_min_conviction: float = 0.6
# Phase 2 score blend (ships OFF; separate from enrichment so turning on the
# advisory/dashboard does NOT silently change trade scoring).
catalyst_score_enabled: bool = False
@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> "OllamaConfig":
@@ -205,6 +208,7 @@ class OllamaConfig(BaseModel):
enrichment_batch_limit=i("NEWS_ENRICH_BATCH_LIMIT", 50),
dilution_veto_enabled=b("NEWS_DILUTION_VETO_ENABLED", False),
dilution_veto_min_conviction=f("NEWS_DILUTION_VETO_CONVICTION", 0.6),
catalyst_score_enabled=b("NEWS_CATALYST_SCORE_ENABLED", False),
)
+61 -22
View File
@@ -196,6 +196,33 @@ def build_runtime(args: argparse.Namespace) -> dict:
f"(tuned {str(_learned.get('as_of', ''))[:10]} over {_learned.get('sessions')} "
f"sessions; backtest pnl {_learned.get('pnl')}, entry@{_learned.get('entry_min')}min)")
ollama_cfg = OllamaConfig.from_env()
# Catalyst advisory provider, TTL-cached so a pass doesn't re-query per symbol;
# returns None on any fault so neither the (gated) dilution veto nor the score
# blend ever block/skew on a DB blip. Shared by the watcher AND the execution
# service (one cache). catalyst_score_provider maps the advisory -> a 0..1 score
# OUTSIDE the pure evaluator, and is only handed to the watcher when the score
# blend is explicitly enabled (so Phase 1 advisory never changes scoring).
_catalyst_cache: dict = {"map": {}, "at": 0.0}
_CATALYST_TTL = 30.0
def catalyst_provider(symbol: str):
import time as _time
from research.ingestion.news_enrichment import catalyst_map
now = _time.monotonic()
if (now - _catalyst_cache["at"]) >= _CATALYST_TTL:
try:
_catalyst_cache["map"] = catalyst_map(research_con)
except Exception: # noqa: BLE001
_catalyst_cache["map"] = {}
_catalyst_cache["at"] = now
return _catalyst_cache["map"].get((symbol or "").upper())
def catalyst_score_provider(symbol: str):
from research.ingestion.news_enrichment import catalyst_score
return catalyst_score(catalyst_provider(symbol))
watcher = Watcher(
store,
ResearchWatchlistProvider(
@@ -213,6 +240,9 @@ def build_runtime(args: argparse.Namespace) -> dict:
"WATCHER_READY_SCORE", str(_learned.get("ready_score_pct", 60.0)))),
min_quality=float(os.environ.get("WATCHER_MIN_QUALITY", "0.30")),
),
catalyst_score_provider=(
catalyst_score_provider if ollama_cfg.catalyst_score_enabled else None
),
)
exec_settings = ExecutionSettings.from_env()
@@ -248,24 +278,6 @@ def build_runtime(args: argparse.Namespace) -> dict:
_price_cache[symbol] = (price, now)
return price
# Catalyst advisory provider for the (gated) dilution veto. TTL-cached so a
# guard pass doesn't re-query per symbol; returns None on any fault so the
# veto simply doesn't fire rather than blocking entries on a DB blip.
_catalyst_cache: dict = {"map": {}, "at": 0.0}
_CATALYST_TTL = 30.0
def catalyst_provider(symbol: str):
import time as _time
from research.ingestion.news_enrichment import catalyst_map
now = _time.monotonic()
if (now - _catalyst_cache["at"]) >= _CATALYST_TTL:
try:
_catalyst_cache["map"] = catalyst_map(research_con)
except Exception: # noqa: BLE001
_catalyst_cache["map"] = {}
_catalyst_cache["at"] = now
return _catalyst_cache["map"].get((symbol or "").upper())
execution = (
TradingExecutionService(
store,
@@ -306,6 +318,8 @@ def build_runtime(args: argparse.Namespace) -> dict:
"sync": sync,
"book": book,
"latest_price": latest_price,
"catalyst_provider": catalyst_provider,
"ollama_cfg": ollama_cfg,
"session_id": session_id,
"event_db": event_db,
}
@@ -314,6 +328,7 @@ def build_runtime(args: argparse.Namespace) -> dict:
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
rt = build_runtime(args)
ollama_cfg = rt["ollama_cfg"]
symbols: list[str] = rt["symbols"]
client = rt["client"]
@@ -580,6 +595,32 @@ def main(argv: list[str] | None = None) -> int:
news_map = recent_news_map(
rt["research_con"], lookback_hours=int(os.environ.get("NEWS_LOOKBACK_HOURS", "8")))
news_boost = float(os.environ.get("NEWS_RANK_BOOST", "2.0"))
# GATED graded ranking from the LLM catalyst read. When the score blend is
# off, ranking is the existing binary `news_boost if catalyst else 1.0`.
# When on, a bullish catalyst boosts by `1 + gain*score` and a confirmed
# dilutive offering is sunk below the armed cut (multiplied by a small
# penalty) so the book stops arming the gap-and-fade traps.
cat_map: dict = {}
if ollama_cfg.catalyst_score_enabled:
from research.ingestion.news_enrichment import catalyst_map as _cmap
try:
cat_map = _cmap(rt["research_con"])
except Exception: # noqa: BLE001
cat_map = {}
news_rank_gain = float(os.environ.get("NEWS_RANK_BOOST_GAIN", "2.0"))
dilutive_penalty = float(os.environ.get("NEWS_DILUTIVE_PENALTY", "0.1"))
def _cat_mult(sym: str, has_headline: bool) -> float:
if not ollama_cfg.catalyst_score_enabled:
return news_boost if has_headline else 1.0
from research.ingestion.news_enrichment import catalyst_score as _cscore
adv = cat_map.get((sym or "").upper())
if not adv:
return 1.0
if adv.get("is_dilutive") and float(adv.get("conviction") or 0.0) >= (
ollama_cfg.dilution_veto_min_conviction):
return dilutive_penalty
return 1.0 + news_rank_gain * (_cscore(adv) or 0.0)
# optional MANUAL per-symbol priority nudge (SYMBOL_WEIGHTS=ABCD:1.2,XYZ:1.1)
# — a small boost to names you want watched; keep modest so it doesn't
# override the gap/volume logic.
@@ -631,7 +672,7 @@ def main(argv: list[str] | None = None) -> int:
# a fresh catalyst is WHY a small-cap runs — boost it up the rank;
# times an optional manual per-symbol weight (default 1.0)
"_score": (g.gap_pct * max(g.relative_volume, 0.1)
* (news_boost if catalyst else 1.0)
* _cat_mult(g.symbol, bool(catalyst))
* weights.get(g.symbol, 1.0)),
"complete": complete,
})
@@ -792,8 +833,6 @@ def main(argv: list[str] | None = None) -> int:
# path. Classifies recently-ingested headlines (catalyst type / sentiment /
# dilution) into news_catalyst_cache for the dashboard. Gated by both the
# enrichment flag AND ollama.enabled; degrades to a no-op if Ollama is down.
ollama_cfg = OllamaConfig.from_env()
def step_enrich():
if not (ollama_cfg.enabled and ollama_cfg.enrichment_enabled):
return "skipped (disabled)"
@@ -830,7 +869,7 @@ def main(argv: list[str] | None = None) -> int:
for task in scheduler.tasks:
task.last_run = -10**9
results = {}
for name in ("discover", "ingest", "watch", "arm", "sync", "execute", "exits", "eod"):
for name in ("discover", "ingest", "enrich", "watch", "arm", "sync", "execute", "exits", "eod"):
for task in scheduler.tasks:
if task.name == name and task.enabled:
results.update(scheduler_run_one(task))
+15 -1
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, date
from typing import Protocol
from typing import Callable, Protocol
import pandas as pd
@@ -80,10 +80,14 @@ class Watcher:
store: EventStore,
provider: WatchlistProvider,
config: WatcherConfig | None = None,
catalyst_score_provider: Callable[[str], float | None] | None = None,
):
self.store = store
self.provider = provider
self.config = config or WatcherConfig()
# optional symbol -> 0..1 catalyst score (or None). Injected so the pure
# evaluator stays DB-free; None provider == today's behavior exactly.
self.catalyst_score_provider = catalyst_score_provider
if not self.config.session_id:
self.config.session_id = f"session-{date.today().isoformat()}"
# symbol -> current state for this session
@@ -181,6 +185,15 @@ class Watcher:
except Exception: # noqa: BLE001 - fall back to now()
eval_time = None
# optional LLM catalyst score (None unless the blend is enabled
# AND a provider is injected) — never raises into the loop.
cat_score = None
if self.catalyst_score_provider is not None:
try:
cat_score = self.catalyst_score_provider(symbol)
except Exception: # noqa: BLE001
cat_score = None
evaluation = evaluate_setup(
bars,
previous_close=candidate.previous_close,
@@ -188,6 +201,7 @@ class Watcher:
ready_score_pct=self.config.ready_score_pct,
evaluation_time=eval_time,
min_bars=self.config.min_bars,
catalyst_score=cat_score,
)
result.evaluated += 1
+50
View File
@@ -74,6 +74,56 @@ def test_tick_emits_canonical_events(store, provider):
assert states # discovered -> ... -> ready transitions recorded
def _ready_quality(store, symbol="GOOD"):
import json
events = store.query_events(event_type="signal_ready", symbol=symbol)
assert events, "expected a signal_ready event"
payload = json.loads(events[-1]["payload_json"])
data = payload.get("signal_data") or payload.get("payload", {}).get("signal_data")
return data["quality_score"]
def test_catalyst_score_provider_lifts_quality(store, provider):
# baseline: no provider -> today's exact quality score
base = make_watcher(store, provider)
base.tick(SESSION_DATE)
baseline_q = _ready_quality(store)
# a strong bullish catalyst for GOOD raises its blended quality score
store2 = EventStore(":memory:")
try:
watcher = Watcher(
store2, provider,
WatcherConfig(session_id="t2", mode=EventMode.PAPER, min_quality=0.0),
catalyst_score_provider=lambda sym: 1.0 if sym == "GOOD" else None,
)
watcher.tick(SESSION_DATE)
boosted_q = _ready_quality(store2)
finally:
store2.close()
assert boosted_q > baseline_q
def test_none_catalyst_provider_is_unchanged(store, provider):
# an explicit provider that returns None must reproduce the baseline exactly
base = make_watcher(store, provider)
base.tick(SESSION_DATE)
baseline_q = _ready_quality(store)
store2 = EventStore(":memory:")
try:
watcher = Watcher(
store2, provider,
WatcherConfig(session_id="t3", mode=EventMode.PAPER, min_quality=0.0),
catalyst_score_provider=lambda sym: None,
)
watcher.tick(SESSION_DATE)
assert _ready_quality(store2) == baseline_q
finally:
store2.close()
def test_signal_ready_debounced_across_ticks(store, provider):
watcher = make_watcher(store, provider)
watcher.tick(SESSION_DATE)