feat: wire gatekeeper into scanner graph

This commit is contained in:
Ahmet Guzererler 2026-03-28 09:33:32 +01:00
parent 7aa76d0061
commit 733a11bd0a
15 changed files with 185 additions and 32 deletions

View File

@ -29,7 +29,7 @@ All storage, event, checkpoint, and phase re-run logic is now documented in ADR
- Added live-tested `yfinance` gatekeeper universe query for US-listed liquid profitable mid-cap+ names
- Added live-tested Finviz gap-subset path using the bounded gatekeeper-plus-gap filter
- Narrowed Finviz usage to the gap/event layer instead of the full market-universe layer
- Next step is graph wiring so downstream candidate selection is hard-filtered by the gatekeeper universe
- Added graph wiring: dedicated gatekeeper scanner node, gatekeeper-aware drift context, and deterministic ranking that excludes names outside the gatekeeper universe
# In Progress

View File

@ -23,8 +23,8 @@ from tradingagents.api_usage import (
class TestVendorEstimate:
def test_total(self):
ve = VendorEstimate(yfinance=10, alpha_vantage=5, finnhub=2)
assert ve.total == 17
ve = VendorEstimate(yfinance=10, alpha_vantage=5, finnhub=2, finviz=1)
assert ve.total == 18
def test_default_zeros(self):
ve = VendorEstimate()
@ -157,6 +157,10 @@ class TestEstimateScan:
est = estimate_scan()
assert est.vendor_calls.yfinance > 0
def test_scan_uses_finviz_for_gap_subset(self):
est = estimate_scan()
assert est.vendor_calls.finviz >= 1
def test_finnhub_for_calendars(self):
"""Global bounded scanners should add Finnhub earnings-calendar usage."""
est = estimate_scan()
@ -220,6 +224,15 @@ class TestFormatEstimate:
est = estimate_analyze()
text = format_estimate(est)
assert "yfinance" in text
def test_includes_finviz_when_present(self):
est = UsageEstimate(
command="scan",
description="scan",
vendor_calls=VendorEstimate(finviz=1),
)
text = format_estimate(est)
assert "Finviz" in text
assert "Total:" in text
def test_default_format_includes_av_assessment(self):
@ -264,12 +277,14 @@ class TestFormatVendorBreakdown:
"yfinance": {"ok": 8, "fail": 1},
"alpha_vantage": {"ok": 3, "fail": 0},
"finnhub": {"ok": 2, "fail": 0},
"finviz": {"ok": 1, "fail": 0},
}
}
text = format_vendor_breakdown(summary)
assert "yfinance:8ok/1fail" in text
assert "AV:3ok/0fail" in text
assert "Finnhub:2ok/0fail" in text
assert "Finviz:1ok/0fail" in text
# ──────────────────────────────────────────────────────────────────────────────

View File

@ -8,6 +8,9 @@ from tradingagents.agents.scanners.drift_scanner import create_drift_scanner
from tradingagents.agents.scanners.factor_alignment_scanner import (
create_factor_alignment_scanner,
)
from tradingagents.agents.scanners.gatekeeper_scanner import (
create_gatekeeper_scanner,
)
class MockRunnable(Runnable):
@ -38,11 +41,42 @@ def _base_state():
return {
"messages": [HumanMessage(content="Run the market scan.")],
"scan_date": "2026-03-27",
"gatekeeper_universe_report": "| Symbol |\n| NVDA |\n| AAPL |",
"sector_performance_report": "| Sector | 1-Month % |\n| Technology | +5.0% |",
"market_movers_report": "| Symbol | Change % |\n| NVDA | +4.0% |",
}
def test_gatekeeper_scanner_end_to_end():
llm = MockLLM(
[
AIMessage(
content="",
tool_calls=[
{"name": "get_gatekeeper_universe", "args": {}, "id": "tc1"},
],
),
AIMessage(content="Gatekeeper report with liquid profitable names."),
]
)
gatekeeper_tool = SimpleNamespace(
name="get_gatekeeper_universe",
invoke=lambda args: "gatekeeper universe table",
)
with patch(
"tradingagents.agents.scanners.gatekeeper_scanner.get_gatekeeper_universe",
gatekeeper_tool,
):
node = create_gatekeeper_scanner(llm)
result = node(_base_state())
assert "Gatekeeper report" in result["gatekeeper_universe_report"]
assert result["sender"] == "gatekeeper_scanner"
assert [tool.name for tool in llm.tools_bound] == ["get_gatekeeper_universe"]
def test_factor_alignment_scanner_end_to_end():
llm = MockLLM(
[

View File

@ -26,6 +26,7 @@ def test_extract_rankable_tickers_filters_noise():
def test_build_candidate_rankings_rewards_overlap():
state = {
"gatekeeper_universe_report": "NVDA AAPL MSFT",
"market_movers_report": "NVDA AAPL",
"smart_money_report": "NVDA",
"factor_alignment_report": "NVDA MSFT",
@ -36,3 +37,17 @@ def test_build_candidate_rankings_rewards_overlap():
assert ranked[0]["ticker"] == "NVDA"
assert ranked[0]["score"] > ranked[1]["score"]
def test_build_candidate_rankings_excludes_names_outside_gatekeeper():
state = {
"gatekeeper_universe_report": "NVDA AAPL",
"market_movers_report": "NVDA TSLA",
"drift_opportunities_report": "TSLA",
}
ranked = _build_candidate_rankings(state)
tickers = {row["ticker"] for row in ranked}
assert "NVDA" in tickers
assert "TSLA" not in tickers

View File

@ -41,6 +41,7 @@ def test_scanner_setup_compiles_graph():
from tradingagents.graph.scanner_setup import ScannerGraphSetup
mock_agents = {
"gatekeeper_scanner": MagicMock(),
"geopolitical_scanner": MagicMock(),
"market_movers_scanner": MagicMock(),
"sector_scanner": MagicMock(),

View File

@ -1,3 +1,4 @@
from .gatekeeper_scanner import create_gatekeeper_scanner
from .geopolitical_scanner import create_geopolitical_scanner
from .market_movers_scanner import create_market_movers_scanner
from .sector_scanner import create_sector_scanner

View File

@ -15,11 +15,14 @@ def create_drift_scanner(llm):
scan_date = state["scan_date"]
tools = [get_gap_candidates, get_topic_news, get_earnings_calendar]
gatekeeper_context = state.get("gatekeeper_universe_report", "")
market_context = state.get("market_movers_report", "")
sector_context = state.get("sector_performance_report", "")
context_chunks = []
if gatekeeper_context:
context_chunks.append(f"Gatekeeper universe:\n{gatekeeper_context}")
if market_context:
context_chunks.append(f"Market movers context:\n{market_context}")
context_chunks.append(f"Market regime context:\n{market_context}")
if sector_context:
context_chunks.append(f"Sector rotation context:\n{sector_context}")
context_section = f"\n\n{'\n\n'.join(context_chunks)}" if context_chunks else ""
@ -32,16 +35,16 @@ def create_drift_scanner(llm):
system_message = (
"You are a drift-window scanner focused on 1-3 month continuation setups. "
"Stay global and bounded: use the existing market movers context, then confirm whether those moves "
"look like the start of a sustained drift rather than one-day noise.\n\n"
"Stay global and bounded: the gatekeeper universe defines the only admissible stock set, and the Finviz "
"gap scan provides the event subset within that universe.\n\n"
"You MUST perform these bounded searches:\n"
"1. Call get_gap_candidates to retrieve real market-data gap candidates.\n"
"1. Call get_gap_candidates to retrieve Finviz gap candidates from the gatekeeper universe.\n"
"2. Call get_topic_news for earnings beats, raised guidance, and positive post-event follow-through.\n"
f"3. Call get_earnings_calendar from {start_date.isoformat()} to {end_date.isoformat()}.\n\n"
"Then write a concise report covering:\n"
"(1) which current movers look most likely to sustain a 1-3 month drift,\n"
"(1) which gatekeeper names look most likely to sustain a 1-3 month drift,\n"
"(2) which sectors show the cleanest drift setup rather than short-covering noise,\n"
"(3) 5-8 candidate tickers surfaced globally from the mover context plus catalyst confirmation,\n"
"(3) 5-8 candidate tickers surfaced from the gap subset plus catalyst confirmation,\n"
"(4) the key evidence for continuation risk versus reversal risk."
f"{context_section}"
)

View File

@ -0,0 +1,53 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from tradingagents.agents.utils.scanner_tools import get_gatekeeper_universe
from tradingagents.agents.utils.tool_runner import run_tool_loop
def create_gatekeeper_scanner(llm):
def gatekeeper_scanner_node(state):
scan_date = state["scan_date"]
tools = [get_gatekeeper_universe]
system_message = (
"You are the gatekeeper scanner for the market-wide search graph. "
"Your job is to define the only stock universe that downstream agents are allowed to consider.\n\n"
"You MUST call get_gatekeeper_universe before writing your report.\n"
"Then write a concise report covering:\n"
"(1) the size and quality of the eligible universe,\n"
"(2) which sectors dominate the gatekeeper set,\n"
"(3) 10-15 representative liquid names worth monitoring,\n"
"(4) any obvious universe concentration risks.\n\n"
"Do not introduce stocks outside the gatekeeper universe."
)
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"You are a helpful AI assistant, collaborating with other assistants."
" Use the provided tools to progress towards answering the question."
" If you are unable to fully answer, that's OK; another assistant with different tools"
" will help where you left off. Execute what you can to make progress."
" You have access to the following tools: {tool_names}.\n{system_message}"
" For your reference, the current date is {current_date}.",
),
MessagesPlaceholder(variable_name="messages"),
]
)
prompt = prompt.partial(system_message=system_message)
prompt = prompt.partial(tool_names=", ".join([tool.name for tool in tools]))
prompt = prompt.partial(current_date=scan_date)
chain = prompt | llm.bind_tools(tools)
result = run_tool_loop(chain, state["messages"], tools)
return {
"messages": [result],
"gatekeeper_universe_report": result.content or "",
"sender": "gatekeeper_scanner",
}
return gatekeeper_scanner_node

View File

@ -136,6 +136,9 @@ def create_industry_deep_dive(llm):
### Geopolitical Report:
{state.get("geopolitical_report", "Not available")}
### Gatekeeper Universe Report:
{state.get("gatekeeper_universe_report", "Not available")}
### Market Movers Report:
{state.get("market_movers_report", "Not available")}

View File

@ -42,6 +42,7 @@ def _extract_rankable_tickers(text: str) -> set[str]:
def _build_candidate_rankings(state: dict, limit: int = 15) -> list[dict[str, object]]:
allowed_tickers = _extract_rankable_tickers(state.get("gatekeeper_universe_report", ""))
weighted_sources = [
("market_movers_report", 2, "market_movers"),
("smart_money_report", 2, "smart_money"),
@ -56,6 +57,8 @@ def _build_candidate_rankings(state: dict, limit: int = 15) -> list[dict[str, ob
for state_key, weight, label in weighted_sources:
tickers = _extract_rankable_tickers(state.get(state_key, ""))
for ticker in tickers:
if allowed_tickers and ticker not in allowed_tickers:
continue
scores[ticker] += weight
sources[ticker].append(label)
@ -92,6 +95,9 @@ def create_macro_synthesis(llm, max_scan_tickers: int = 10, scan_horizon_days: i
ranking_section = "\n\n### Deterministic Candidate Ranking:\n" + "\n".join(ranking_lines)
all_reports_context = f"""## All Scanner and Research Reports
### Gatekeeper Universe Report:
{state.get("gatekeeper_universe_report", "Not available")}
### Geopolitical Report:
{state.get("geopolitical_report", "Not available")}
@ -117,10 +123,11 @@ def create_macro_synthesis(llm, max_scan_tickers: int = 10, scan_horizon_days: i
system_message = (
"You are a macro strategist synthesizing all scanner and research reports into a final investment thesis. "
"You have received: geopolitical analysis, market movers analysis, sector performance analysis, "
"You have received: gatekeeper universe analysis, geopolitical analysis, market regime analysis, sector performance analysis, "
"smart money institutional screener results, and industry deep dive analysis. "
"A deterministic candidate-ranking snapshot is also provided when available. Treat higher-ranked "
"candidates as preferred because they appeared across more independent scanner streams. "
"Do not recommend stocks outside the gatekeeper universe. "
"## THE GOLDEN OVERLAP (apply when Smart Money Report is available and not 'Not available'):\n"
"Cross-reference the Smart Money tickers with your macro regime thesis. "
"If a Smart Money ticker fits your top-down macro narrative (e.g., an Energy stock with heavy insider "

View File

@ -1,5 +1,5 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from tradingagents.agents.utils.scanner_tools import get_market_movers, get_market_indices
from tradingagents.agents.utils.scanner_tools import get_market_indices
from tradingagents.agents.utils.tool_runner import run_tool_loop
@ -7,18 +7,17 @@ def create_market_movers_scanner(llm):
def market_movers_scanner_node(state):
scan_date = state["scan_date"]
tools = [get_market_movers, get_market_indices]
tools = [get_market_indices]
system_message = (
"You are a market analyst scanning for unusual activity and momentum signals. "
"Use get_market_movers to fetch today's top gainers, losers, and most active stocks. "
"You are a market regime analyst scanning for broad index and risk-appetite conditions. "
"Use get_market_indices to check major index performance. "
"Analyze the results and write a report covering: "
"(1) Unusual movers and potential catalysts, "
"(2) Volume anomalies, "
"(3) Index trends and breadth, "
"(4) Sector concentration in movers. "
"Include a summary table of the most significant moves."
"(1) Index trends and breadth, "
"(2) Risk-on versus risk-off tone, "
"(3) Small-cap versus large-cap participation, "
"(4) Whether the broader tape is supportive for gap continuation trades. "
"Do not use this report to nominate stocks; the gatekeeper universe controls admissible names."
)
prompt = ChatPromptTemplate.from_messages(

View File

@ -27,6 +27,7 @@ class ScannerState(MessagesState):
scan_date: str
# Phase 1: Parallel scanner outputs — each written by exactly one node
gatekeeper_universe_report: Annotated[str, _last_value]
geopolitical_report: Annotated[str, _last_value]
market_movers_report: Annotated[str, _last_value]
sector_performance_report: Annotated[str, _last_value]

View File

@ -56,10 +56,11 @@ class VendorEstimate:
yfinance: int = 0
alpha_vantage: int = 0
finnhub: int = 0
finviz: int = 0
@property
def total(self) -> int:
return self.yfinance + self.alpha_vantage + self.finnhub
return self.yfinance + self.alpha_vantage + self.finnhub + self.finviz
@dataclass
@ -94,6 +95,9 @@ def _resolve_vendor(config: dict, method: str) -> str:
get_category_for_method,
)
if method == "get_gap_candidates":
return "finviz"
# Tool-level override first
tool_vendors = config.get("tool_vendors", {})
if method in tool_vendors:
@ -220,6 +224,8 @@ def estimate_scan(config: dict | None = None) -> UsageEstimate:
est.vendor_calls.alpha_vantage += count
elif vendor == "finnhub":
est.vendor_calls.finnhub += count
elif vendor == "finviz":
est.vendor_calls.finviz += count
if vendor not in breakdown:
breakdown[vendor] = {}
breakdown[vendor][method] = breakdown[vendor].get(method, 0) + count
@ -230,25 +236,28 @@ def estimate_scan(config: dict | None = None) -> UsageEstimate:
_add("get_topic_news")
est.notes.append(f"Phase 1A (Geopolitical): ~{topic_news_calls} topic news calls")
# Phase 1B: Market Movers Scanner — 3 market_movers + 1 indices
_add("get_market_movers", 3)
# Phase 1B: Gatekeeper universe — 1 bounded yfinance query
_add("get_gatekeeper_universe")
est.notes.append("Phase 1B (Gatekeeper): 1 bounded yfinance universe query")
# Phase 1C: Market regime scanner — 1 indices call
_add("get_market_indices")
est.notes.append("Phase 1B (Market Movers): 3 screener calls + 1 indices call")
est.notes.append("Phase 1C (Market Regime): 1 indices call")
# Phase 1C: Sector Scanner — 1 sector performance
# Phase 1D: Sector Scanner — 1 sector performance
_add("get_sector_performance")
est.notes.append("Phase 1C (Sector): 1 sector performance call")
est.notes.append("Phase 1D (Sector): 1 sector performance call")
# Phase 1D: Factor Alignment — bounded global revision/sentiment checks
# Phase 1E: Factor Alignment — bounded global revision/sentiment checks
_add("get_topic_news", 2)
_add("get_earnings_calendar")
est.notes.append("Phase 1D (Factor Alignment): ~2 topic news + 1 earnings calendar")
est.notes.append("Phase 1E (Factor Alignment): ~2 topic news + 1 earnings calendar")
# Phase 1E: Drift Scanner — bounded global continuation checks
# Phase 1F: Drift Scanner — bounded gap subset + continuation checks
_add("get_gap_candidates")
_add("get_topic_news")
_add("get_earnings_calendar")
est.notes.append("Phase 1E (Drift): 1 live gap scan + ~1 topic news + 1 earnings calendar")
est.notes.append("Phase 1F (Drift): 1 Finviz gap scan + ~1 topic news + 1 earnings calendar")
# Phase 2: Industry Deep Dive — ~3 industry perf + ~3 topic news
industry_calls = 3
@ -295,11 +304,13 @@ def estimate_pipeline(
est.vendor_calls.yfinance += scan_est.vendor_calls.yfinance
est.vendor_calls.alpha_vantage += scan_est.vendor_calls.alpha_vantage
est.vendor_calls.finnhub += scan_est.vendor_calls.finnhub
est.vendor_calls.finviz += scan_est.vendor_calls.finviz
# Analyze phase × num_tickers
est.vendor_calls.yfinance += analyze_est.vendor_calls.yfinance * num_tickers
est.vendor_calls.alpha_vantage += analyze_est.vendor_calls.alpha_vantage * num_tickers
est.vendor_calls.finnhub += analyze_est.vendor_calls.finnhub * num_tickers
est.vendor_calls.finviz += analyze_est.vendor_calls.finviz * num_tickers
# Merge breakdowns
merged: dict[str, dict[str, int]] = {}
@ -342,6 +353,8 @@ def format_estimate(est: UsageEstimate) -> str:
lines.append(f" Alpha Vantage: {vc.alpha_vantage:>3} calls (free tier: {AV_FREE_DAILY_LIMIT}/day)")
if vc.finnhub:
lines.append(f" Finnhub: {vc.finnhub:>3} calls (free tier: 60/min)")
if vc.finviz:
lines.append(f" Finviz: {vc.finviz:>3} calls (HTML scrape, bounded use)")
lines.append(f" Total: {vc.total:>4} vendor API calls")
# Alpha Vantage assessment
@ -382,7 +395,7 @@ def format_vendor_breakdown(summary: dict) -> str:
return ""
parts: list[str] = []
for vendor in ("yfinance", "alpha_vantage", "finnhub"):
for vendor in ("yfinance", "alpha_vantage", "finnhub", "finviz"):
counts = vendors_used.get(vendor)
if counts:
ok = counts.get("ok", 0)
@ -391,6 +404,7 @@ def format_vendor_breakdown(summary: dict) -> str:
"yfinance": "yfinance",
"alpha_vantage": "AV",
"finnhub": "Finnhub",
"finviz": "Finviz",
}.get(vendor, vendor)
parts.append(f"{label}:{ok}ok/{fail}fail")

View File

@ -5,6 +5,7 @@ from typing import Any, List, Optional
from tradingagents.default_config import DEFAULT_CONFIG
from tradingagents.llm_clients import create_llm_client
from tradingagents.agents.scanners import (
create_gatekeeper_scanner,
create_geopolitical_scanner,
create_market_movers_scanner,
create_sector_scanner,
@ -20,7 +21,7 @@ from .scanner_setup import ScannerGraphSetup
class ScannerGraph:
"""Orchestrates the macro scanner pipeline.
Phase 1a (parallel): geopolitical_scanner, market_movers_scanner, sector_scanner
Phase 1a (parallel): gatekeeper_scanner, geopolitical_scanner, market_movers_scanner, sector_scanner
Phase 1b (bounded global follow-ons): factor_alignment_scanner, smart_money_scanner
Phase 1c (after market + sector): drift_scanner
Phase 2: industry_deep_dive (fan-in from all Phase 1 nodes)
@ -52,6 +53,7 @@ class ScannerGraph:
scan_horizon_days = int(self.config.get("scan_horizon_days", 30))
agents = {
"gatekeeper_scanner": create_gatekeeper_scanner(quick_llm),
"geopolitical_scanner": create_geopolitical_scanner(quick_llm),
"market_movers_scanner": create_market_movers_scanner(quick_llm),
"sector_scanner": create_sector_scanner(quick_llm),
@ -155,6 +157,7 @@ class ScannerGraph:
initial_state: dict[str, Any] = {
"scan_date": scan_date,
"messages": [],
"gatekeeper_universe_report": "",
"geopolitical_report": "",
"market_movers_report": "",
"sector_performance_report": "",

View File

@ -9,7 +9,7 @@ class ScannerGraphSetup:
"""Sets up the scanner graph with LLM agent nodes.
Phase 1a (parallel from START):
geopolitical_scanner, market_movers_scanner, sector_scanner
gatekeeper_scanner, geopolitical_scanner, market_movers_scanner, sector_scanner
Phase 1b (sequential after sector_scanner):
factor_alignment_scanner, smart_money_scanner bounded global follow-ons
that use sector rotation context
@ -24,6 +24,7 @@ class ScannerGraphSetup:
Args:
agents: Dict mapping node names to agent node functions:
- geopolitical_scanner
- gatekeeper_scanner
- market_movers_scanner
- sector_scanner
- factor_alignment_scanner
@ -46,6 +47,7 @@ class ScannerGraphSetup:
workflow.add_node(name, node_fn)
# Phase 1a: parallel fan-out from START
workflow.add_edge(START, "gatekeeper_scanner")
workflow.add_edge(START, "geopolitical_scanner")
workflow.add_edge(START, "market_movers_scanner")
workflow.add_edge(START, "sector_scanner")
@ -55,8 +57,10 @@ class ScannerGraphSetup:
workflow.add_edge("sector_scanner", "smart_money_scanner")
workflow.add_edge("sector_scanner", "drift_scanner")
workflow.add_edge("market_movers_scanner", "drift_scanner")
workflow.add_edge("gatekeeper_scanner", "drift_scanner")
# Fan-in: all Phase 1 nodes must complete before Phase 2
workflow.add_edge("gatekeeper_scanner", "industry_deep_dive")
workflow.add_edge("geopolitical_scanner", "industry_deep_dive")
workflow.add_edge("market_movers_scanner", "industry_deep_dive")
workflow.add_edge("factor_alignment_scanner", "industry_deep_dive")