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 `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 - 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 - 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 # In Progress

View File

@ -23,8 +23,8 @@ from tradingagents.api_usage import (
class TestVendorEstimate: class TestVendorEstimate:
def test_total(self): def test_total(self):
ve = VendorEstimate(yfinance=10, alpha_vantage=5, finnhub=2) ve = VendorEstimate(yfinance=10, alpha_vantage=5, finnhub=2, finviz=1)
assert ve.total == 17 assert ve.total == 18
def test_default_zeros(self): def test_default_zeros(self):
ve = VendorEstimate() ve = VendorEstimate()
@ -157,6 +157,10 @@ class TestEstimateScan:
est = estimate_scan() est = estimate_scan()
assert est.vendor_calls.yfinance > 0 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): def test_finnhub_for_calendars(self):
"""Global bounded scanners should add Finnhub earnings-calendar usage.""" """Global bounded scanners should add Finnhub earnings-calendar usage."""
est = estimate_scan() est = estimate_scan()
@ -220,6 +224,15 @@ class TestFormatEstimate:
est = estimate_analyze() est = estimate_analyze()
text = format_estimate(est) text = format_estimate(est)
assert "yfinance" in text 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 assert "Total:" in text
def test_default_format_includes_av_assessment(self): def test_default_format_includes_av_assessment(self):
@ -264,12 +277,14 @@ class TestFormatVendorBreakdown:
"yfinance": {"ok": 8, "fail": 1}, "yfinance": {"ok": 8, "fail": 1},
"alpha_vantage": {"ok": 3, "fail": 0}, "alpha_vantage": {"ok": 3, "fail": 0},
"finnhub": {"ok": 2, "fail": 0}, "finnhub": {"ok": 2, "fail": 0},
"finviz": {"ok": 1, "fail": 0},
} }
} }
text = format_vendor_breakdown(summary) text = format_vendor_breakdown(summary)
assert "yfinance:8ok/1fail" in text assert "yfinance:8ok/1fail" in text
assert "AV:3ok/0fail" in text assert "AV:3ok/0fail" in text
assert "Finnhub:2ok/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 ( from tradingagents.agents.scanners.factor_alignment_scanner import (
create_factor_alignment_scanner, create_factor_alignment_scanner,
) )
from tradingagents.agents.scanners.gatekeeper_scanner import (
create_gatekeeper_scanner,
)
class MockRunnable(Runnable): class MockRunnable(Runnable):
@ -38,11 +41,42 @@ def _base_state():
return { return {
"messages": [HumanMessage(content="Run the market scan.")], "messages": [HumanMessage(content="Run the market scan.")],
"scan_date": "2026-03-27", "scan_date": "2026-03-27",
"gatekeeper_universe_report": "| Symbol |\n| NVDA |\n| AAPL |",
"sector_performance_report": "| Sector | 1-Month % |\n| Technology | +5.0% |", "sector_performance_report": "| Sector | 1-Month % |\n| Technology | +5.0% |",
"market_movers_report": "| Symbol | Change % |\n| NVDA | +4.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(): def test_factor_alignment_scanner_end_to_end():
llm = MockLLM( llm = MockLLM(
[ [

View File

@ -26,6 +26,7 @@ def test_extract_rankable_tickers_filters_noise():
def test_build_candidate_rankings_rewards_overlap(): def test_build_candidate_rankings_rewards_overlap():
state = { state = {
"gatekeeper_universe_report": "NVDA AAPL MSFT",
"market_movers_report": "NVDA AAPL", "market_movers_report": "NVDA AAPL",
"smart_money_report": "NVDA", "smart_money_report": "NVDA",
"factor_alignment_report": "NVDA MSFT", "factor_alignment_report": "NVDA MSFT",
@ -36,3 +37,17 @@ def test_build_candidate_rankings_rewards_overlap():
assert ranked[0]["ticker"] == "NVDA" assert ranked[0]["ticker"] == "NVDA"
assert ranked[0]["score"] > ranked[1]["score"] 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 from tradingagents.graph.scanner_setup import ScannerGraphSetup
mock_agents = { mock_agents = {
"gatekeeper_scanner": MagicMock(),
"geopolitical_scanner": MagicMock(), "geopolitical_scanner": MagicMock(),
"market_movers_scanner": MagicMock(), "market_movers_scanner": MagicMock(),
"sector_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 .geopolitical_scanner import create_geopolitical_scanner
from .market_movers_scanner import create_market_movers_scanner from .market_movers_scanner import create_market_movers_scanner
from .sector_scanner import create_sector_scanner from .sector_scanner import create_sector_scanner

View File

@ -15,11 +15,14 @@ def create_drift_scanner(llm):
scan_date = state["scan_date"] scan_date = state["scan_date"]
tools = [get_gap_candidates, get_topic_news, get_earnings_calendar] tools = [get_gap_candidates, get_topic_news, get_earnings_calendar]
gatekeeper_context = state.get("gatekeeper_universe_report", "")
market_context = state.get("market_movers_report", "") market_context = state.get("market_movers_report", "")
sector_context = state.get("sector_performance_report", "") sector_context = state.get("sector_performance_report", "")
context_chunks = [] context_chunks = []
if gatekeeper_context:
context_chunks.append(f"Gatekeeper universe:\n{gatekeeper_context}")
if market_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: if sector_context:
context_chunks.append(f"Sector rotation context:\n{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 "" 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 = ( system_message = (
"You are a drift-window scanner focused on 1-3 month continuation setups. " "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 " "Stay global and bounded: the gatekeeper universe defines the only admissible stock set, and the Finviz "
"look like the start of a sustained drift rather than one-day noise.\n\n" "gap scan provides the event subset within that universe.\n\n"
"You MUST perform these bounded searches:\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" "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" f"3. Call get_earnings_calendar from {start_date.isoformat()} to {end_date.isoformat()}.\n\n"
"Then write a concise report covering:\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" "(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." "(4) the key evidence for continuation risk versus reversal risk."
f"{context_section}" 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: ### Geopolitical Report:
{state.get("geopolitical_report", "Not available")} {state.get("geopolitical_report", "Not available")}
### Gatekeeper Universe Report:
{state.get("gatekeeper_universe_report", "Not available")}
### Market Movers Report: ### Market Movers Report:
{state.get("market_movers_report", "Not available")} {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]]: 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 = [ weighted_sources = [
("market_movers_report", 2, "market_movers"), ("market_movers_report", 2, "market_movers"),
("smart_money_report", 2, "smart_money"), ("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: for state_key, weight, label in weighted_sources:
tickers = _extract_rankable_tickers(state.get(state_key, "")) tickers = _extract_rankable_tickers(state.get(state_key, ""))
for ticker in tickers: for ticker in tickers:
if allowed_tickers and ticker not in allowed_tickers:
continue
scores[ticker] += weight scores[ticker] += weight
sources[ticker].append(label) 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) ranking_section = "\n\n### Deterministic Candidate Ranking:\n" + "\n".join(ranking_lines)
all_reports_context = f"""## All Scanner and Research Reports all_reports_context = f"""## All Scanner and Research Reports
### Gatekeeper Universe Report:
{state.get("gatekeeper_universe_report", "Not available")}
### Geopolitical Report: ### Geopolitical Report:
{state.get("geopolitical_report", "Not available")} {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 = ( system_message = (
"You are a macro strategist synthesizing all scanner and research reports into a final investment thesis. " "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. " "smart money institutional screener results, and industry deep dive analysis. "
"A deterministic candidate-ranking snapshot is also provided when available. Treat higher-ranked " "A deterministic candidate-ranking snapshot is also provided when available. Treat higher-ranked "
"candidates as preferred because they appeared across more independent scanner streams. " "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" "## 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. " "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 " "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 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 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): def market_movers_scanner_node(state):
scan_date = state["scan_date"] scan_date = state["scan_date"]
tools = [get_market_movers, get_market_indices] tools = [get_market_indices]
system_message = ( system_message = (
"You are a market analyst scanning for unusual activity and momentum signals. " "You are a market regime analyst scanning for broad index and risk-appetite conditions. "
"Use get_market_movers to fetch today's top gainers, losers, and most active stocks. "
"Use get_market_indices to check major index performance. " "Use get_market_indices to check major index performance. "
"Analyze the results and write a report covering: " "Analyze the results and write a report covering: "
"(1) Unusual movers and potential catalysts, " "(1) Index trends and breadth, "
"(2) Volume anomalies, " "(2) Risk-on versus risk-off tone, "
"(3) Index trends and breadth, " "(3) Small-cap versus large-cap participation, "
"(4) Sector concentration in movers. " "(4) Whether the broader tape is supportive for gap continuation trades. "
"Include a summary table of the most significant moves." "Do not use this report to nominate stocks; the gatekeeper universe controls admissible names."
) )
prompt = ChatPromptTemplate.from_messages( prompt = ChatPromptTemplate.from_messages(

View File

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

View File

@ -56,10 +56,11 @@ class VendorEstimate:
yfinance: int = 0 yfinance: int = 0
alpha_vantage: int = 0 alpha_vantage: int = 0
finnhub: int = 0 finnhub: int = 0
finviz: int = 0
@property @property
def total(self) -> int: def total(self) -> int:
return self.yfinance + self.alpha_vantage + self.finnhub return self.yfinance + self.alpha_vantage + self.finnhub + self.finviz
@dataclass @dataclass
@ -94,6 +95,9 @@ def _resolve_vendor(config: dict, method: str) -> str:
get_category_for_method, get_category_for_method,
) )
if method == "get_gap_candidates":
return "finviz"
# Tool-level override first # Tool-level override first
tool_vendors = config.get("tool_vendors", {}) tool_vendors = config.get("tool_vendors", {})
if method in 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 est.vendor_calls.alpha_vantage += count
elif vendor == "finnhub": elif vendor == "finnhub":
est.vendor_calls.finnhub += count est.vendor_calls.finnhub += count
elif vendor == "finviz":
est.vendor_calls.finviz += count
if vendor not in breakdown: if vendor not in breakdown:
breakdown[vendor] = {} breakdown[vendor] = {}
breakdown[vendor][method] = breakdown[vendor].get(method, 0) + count 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") _add("get_topic_news")
est.notes.append(f"Phase 1A (Geopolitical): ~{topic_news_calls} topic news calls") est.notes.append(f"Phase 1A (Geopolitical): ~{topic_news_calls} topic news calls")
# Phase 1B: Market Movers Scanner — 3 market_movers + 1 indices # Phase 1B: Gatekeeper universe — 1 bounded yfinance query
_add("get_market_movers", 3) _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") _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") _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_topic_news", 2)
_add("get_earnings_calendar") _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_gap_candidates")
_add("get_topic_news") _add("get_topic_news")
_add("get_earnings_calendar") _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 # Phase 2: Industry Deep Dive — ~3 industry perf + ~3 topic news
industry_calls = 3 industry_calls = 3
@ -295,11 +304,13 @@ def estimate_pipeline(
est.vendor_calls.yfinance += scan_est.vendor_calls.yfinance est.vendor_calls.yfinance += scan_est.vendor_calls.yfinance
est.vendor_calls.alpha_vantage += scan_est.vendor_calls.alpha_vantage est.vendor_calls.alpha_vantage += scan_est.vendor_calls.alpha_vantage
est.vendor_calls.finnhub += scan_est.vendor_calls.finnhub est.vendor_calls.finnhub += scan_est.vendor_calls.finnhub
est.vendor_calls.finviz += scan_est.vendor_calls.finviz
# Analyze phase × num_tickers # Analyze phase × num_tickers
est.vendor_calls.yfinance += analyze_est.vendor_calls.yfinance * 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.alpha_vantage += analyze_est.vendor_calls.alpha_vantage * num_tickers
est.vendor_calls.finnhub += analyze_est.vendor_calls.finnhub * 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 # Merge breakdowns
merged: dict[str, dict[str, int]] = {} 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)") lines.append(f" Alpha Vantage: {vc.alpha_vantage:>3} calls (free tier: {AV_FREE_DAILY_LIMIT}/day)")
if vc.finnhub: if vc.finnhub:
lines.append(f" Finnhub: {vc.finnhub:>3} calls (free tier: 60/min)") 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") lines.append(f" Total: {vc.total:>4} vendor API calls")
# Alpha Vantage assessment # Alpha Vantage assessment
@ -382,7 +395,7 @@ def format_vendor_breakdown(summary: dict) -> str:
return "" return ""
parts: list[str] = [] parts: list[str] = []
for vendor in ("yfinance", "alpha_vantage", "finnhub"): for vendor in ("yfinance", "alpha_vantage", "finnhub", "finviz"):
counts = vendors_used.get(vendor) counts = vendors_used.get(vendor)
if counts: if counts:
ok = counts.get("ok", 0) ok = counts.get("ok", 0)
@ -391,6 +404,7 @@ def format_vendor_breakdown(summary: dict) -> str:
"yfinance": "yfinance", "yfinance": "yfinance",
"alpha_vantage": "AV", "alpha_vantage": "AV",
"finnhub": "Finnhub", "finnhub": "Finnhub",
"finviz": "Finviz",
}.get(vendor, vendor) }.get(vendor, vendor)
parts.append(f"{label}:{ok}ok/{fail}fail") 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.default_config import DEFAULT_CONFIG
from tradingagents.llm_clients import create_llm_client from tradingagents.llm_clients import create_llm_client
from tradingagents.agents.scanners import ( from tradingagents.agents.scanners import (
create_gatekeeper_scanner,
create_geopolitical_scanner, create_geopolitical_scanner,
create_market_movers_scanner, create_market_movers_scanner,
create_sector_scanner, create_sector_scanner,
@ -20,7 +21,7 @@ from .scanner_setup import ScannerGraphSetup
class ScannerGraph: class ScannerGraph:
"""Orchestrates the macro scanner pipeline. """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 1b (bounded global follow-ons): factor_alignment_scanner, smart_money_scanner
Phase 1c (after market + sector): drift_scanner Phase 1c (after market + sector): drift_scanner
Phase 2: industry_deep_dive (fan-in from all Phase 1 nodes) 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)) scan_horizon_days = int(self.config.get("scan_horizon_days", 30))
agents = { agents = {
"gatekeeper_scanner": create_gatekeeper_scanner(quick_llm),
"geopolitical_scanner": create_geopolitical_scanner(quick_llm), "geopolitical_scanner": create_geopolitical_scanner(quick_llm),
"market_movers_scanner": create_market_movers_scanner(quick_llm), "market_movers_scanner": create_market_movers_scanner(quick_llm),
"sector_scanner": create_sector_scanner(quick_llm), "sector_scanner": create_sector_scanner(quick_llm),
@ -155,6 +157,7 @@ class ScannerGraph:
initial_state: dict[str, Any] = { initial_state: dict[str, Any] = {
"scan_date": scan_date, "scan_date": scan_date,
"messages": [], "messages": [],
"gatekeeper_universe_report": "",
"geopolitical_report": "", "geopolitical_report": "",
"market_movers_report": "", "market_movers_report": "",
"sector_performance_report": "", "sector_performance_report": "",

View File

@ -9,7 +9,7 @@ class ScannerGraphSetup:
"""Sets up the scanner graph with LLM agent nodes. """Sets up the scanner graph with LLM agent nodes.
Phase 1a (parallel from START): 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): Phase 1b (sequential after sector_scanner):
factor_alignment_scanner, smart_money_scanner bounded global follow-ons factor_alignment_scanner, smart_money_scanner bounded global follow-ons
that use sector rotation context that use sector rotation context
@ -24,6 +24,7 @@ class ScannerGraphSetup:
Args: Args:
agents: Dict mapping node names to agent node functions: agents: Dict mapping node names to agent node functions:
- geopolitical_scanner - geopolitical_scanner
- gatekeeper_scanner
- market_movers_scanner - market_movers_scanner
- sector_scanner - sector_scanner
- factor_alignment_scanner - factor_alignment_scanner
@ -46,6 +47,7 @@ class ScannerGraphSetup:
workflow.add_node(name, node_fn) workflow.add_node(name, node_fn)
# Phase 1a: parallel fan-out from START # Phase 1a: parallel fan-out from START
workflow.add_edge(START, "gatekeeper_scanner")
workflow.add_edge(START, "geopolitical_scanner") workflow.add_edge(START, "geopolitical_scanner")
workflow.add_edge(START, "market_movers_scanner") workflow.add_edge(START, "market_movers_scanner")
workflow.add_edge(START, "sector_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", "smart_money_scanner")
workflow.add_edge("sector_scanner", "drift_scanner") workflow.add_edge("sector_scanner", "drift_scanner")
workflow.add_edge("market_movers_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 # 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("geopolitical_scanner", "industry_deep_dive")
workflow.add_edge("market_movers_scanner", "industry_deep_dive") workflow.add_edge("market_movers_scanner", "industry_deep_dive")
workflow.add_edge("factor_alignment_scanner", "industry_deep_dive") workflow.add_edge("factor_alignment_scanner", "industry_deep_dive")