feat: Add macro scanner feedback loop and lessons memory (#124)

* feat: add macro scanner feedback loop and lessons memory

- Implements `LessonStore` to persist JSON screening lessons
- Adds `selection_reflector.py` to fetch performance and news, and generate LLM lessons
- Adds `memory_loader.py` to filter negative lessons into `FinancialSituationMemory`
- Integrates a rejection gate in `candidate_prioritizer.py` based on past negative lessons
- Adds `reflect` command to CLI

Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com>

* feat: update macro scanner feedback loop for dual-lesson output (Trend DNA)

- Update `selection_reflector.py` to calculate exact terminal returns, `mfe_pct`, `mae_pct`, and `days_to_peak`.
- Update LLM prompt to generate distinct `screening_advice` and `exit_advice`.
- Update `lesson_store` tests to reflect new schema.
- Update `memory_loader.py` to use `screening_advice` for negative selection filtering.
- Update `micro_summary_agent.py` to inject `exit_advice` into PM context for current holdings.
- Update `cli/main.py` default horizons to `30,90` and print dual-advice columns.

Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com>

* fix: resolve bugs in macro scanner feedback loop

- Address Key mismatch (stock_return_pct vs terminal_return_pct)
- Fix missing persistence of mfe_pct and mae_pct
- Use create_report_store() instead of raw ReportStore() in load_scan_candidates
- Clean up unused imports in fetch_news_summary
- Ensure default horizons match code in cli description
- Create isolated `_local_safe_pct` to remove cross-module dependency

Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com>

* fix: address PR 124 feedback on macro scanner memory feedback loop

- Use `l.get('screening_advice')` gracefully in `memory_loader` to prevent KeyErrors.
- Properly instantiate `selection_memory` inside the graph in `portfolio_setup.py` and pass it to the prioriziation rejection gate.

Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com>
This commit is contained in:
ahmet guzererler 2026-03-26 23:44:44 +01:00 committed by GitHub
parent b7255c4012
commit 02cbaecf62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 683 additions and 1 deletions

View File

@ -1382,6 +1382,61 @@ def run_analysis():
display_complete_report(final_state)
def run_reflect(date: str | None = None, horizons_str: str = "30,90"):
"""Core reflect logic. Callable from tests."""
from tradingagents.portfolio.lesson_store import LessonStore
from tradingagents.portfolio.selection_reflector import reflect_on_scan
reflect_date = date or datetime.datetime.now().strftime("%Y-%m-%d")
horizons = [int(h.strip()) for h in horizons_str.split(",")]
# Build quick_think LLM using DEFAULT_CONFIG
from tradingagents.graph.scanner_graph import ScannerGraph
scanner = ScannerGraph(config=DEFAULT_CONFIG.copy())
llm = scanner._create_llm("quick_think")
all_lessons = []
for horizon in horizons:
scan_date = (datetime.datetime.strptime(reflect_date, "%Y-%m-%d")
- datetime.timedelta(days=horizon)).strftime("%Y-%m-%d")
console.print(f"[cyan]Reflecting on {scan_date} ({horizon}d ago)...[/cyan]")
lessons = reflect_on_scan(scan_date, reflect_date, llm, horizon)
all_lessons.extend(lessons)
store = LessonStore()
added = store.append(all_lessons)
console.print(f"[green]Added {added} new lessons. Total: {len(store.load_all())}[/green]")
# Print Rich table summary
if all_lessons:
table = Table(title="Reflected Lessons", box=box.ROUNDED)
table.add_column("Ticker", style="cyan bold")
table.add_column("Scan Date")
table.add_column("Horizon")
table.add_column("Alpha", style="magenta")
table.add_column("MFE / MAE")
table.add_column("Sentiment")
table.add_column("Screening Advice")
table.add_column("Exit Advice")
for l in all_lessons:
color = "green" if l.get("sentiment") == "positive" else ("red" if l.get("sentiment") == "negative" else "yellow")
alpha_str = f"{l.get('terminal_return_pct', 0.0) - l.get('spy_return_pct', 0.0):+.1f}%"
mfe_mae_str = f"+{l.get('mfe_pct', 0.0):.1f}% / {l.get('mae_pct', 0.0):.1f}%"
table.add_row(
l.get("ticker", ""),
l.get("scan_date", ""),
str(l.get("horizon_days", "")),
alpha_str,
mfe_mae_str,
f"[{color}]{l.get('sentiment', '')}[/{color}]",
l.get("screening_advice", ""),
l.get("exit_advice", "")
)
console.print(table)
def run_scan(date: Optional[str] = None):
"""Run the 3-phase LLM scanner pipeline via ScannerGraph."""
console.print(
@ -1729,6 +1784,15 @@ def scan(
run_scan(date=date)
@app.command()
def reflect(
date: Optional[str] = typer.Option(None, "--date", "-d", help="Reference date YYYY-MM-DD"),
horizons: str = typer.Option("30,90", "--horizons", help="Comma-separated lookback days"),
):
"""Reflect on past scan picks: compute returns, fetch news, generate screening lessons."""
run_reflect(date=date, horizons_str=horizons)
@app.command()
def pipeline():
"""Full pipeline: macro scan JSON → filter → per-ticker deep dive."""

View File

@ -0,0 +1,95 @@
import pytest
from tradingagents.portfolio.candidate_prioritizer import prioritize_candidates
from tradingagents.agents.utils.memory import FinancialSituationMemory
from tradingagents.portfolio.models import Portfolio, Holding
@pytest.fixture
def empty_portfolio():
return Portfolio(
portfolio_id="test_port",
name="test",
initial_cash=100000.0,
cash=100000.0,
total_value=100000.0,
created_at="2025-01-01"
)
@pytest.fixture
def test_candidate():
return {
"ticker": "AAPL",
"sector": "technology",
"thesis_angle": "growth",
"rationale": "High earnings potential",
"conviction": "high"
}
def test_no_memory_backward_compat(empty_portfolio, test_candidate):
enriched = prioritize_candidates(
[test_candidate], empty_portfolio, [], {"max_sector_pct": 0.35}, selection_memory=None
)
assert len(enriched) == 1
assert enriched[0]["priority_score"] > 0
assert "skip_reason" not in enriched[0]
def test_negative_match_zeroes_score(empty_portfolio, test_candidate):
memory = FinancialSituationMemory("test_mem")
# Matches the candidate description well
memory.add_situations([
("AAPL technology growth High earnings potential high", "Avoid tech growth stocks in this macro"),
("MSFT another", "Another lesson") # Add second situation to ensure BM25 idf is positive or normalized properly, or mock it
])
# Mocking get_memories directly because BM25 scores can be tricky to predict with tiny corpora
original_get = memory.get_memories
memory.get_memories = lambda *args, **kwargs: [{"similarity_score": 0.9, "recommendation": "Avoid tech growth stocks in this macro"}]
enriched = prioritize_candidates(
[test_candidate], empty_portfolio, [], {"max_sector_pct": 0.35}, selection_memory=memory
)
assert len(enriched) == 1
assert enriched[0]["priority_score"] == 0.0
assert "Memory lesson" in enriched[0]["skip_reason"]
assert "Avoid tech growth stocks" in enriched[0]["skip_reason"]
memory.get_memories = original_get
def test_positive_lessons_not_loaded(empty_portfolio, test_candidate):
memory = FinancialSituationMemory("test_mem")
enriched = prioritize_candidates(
[test_candidate], empty_portfolio, [], {"max_sector_pct": 0.35}, selection_memory=memory
)
assert enriched[0]["priority_score"] > 0
def test_score_threshold_boundary(empty_portfolio, test_candidate):
# If the score is exactly 0.5 (or less), it should not trigger rejection
memory = FinancialSituationMemory("test_mem")
memory.add_situations([("completely unrelated stuff that barely matches maybe one token aapl", "lesson text")])
# Manually overwrite the get_memories to return a score exactly 0.5
original_get = memory.get_memories
memory.get_memories = lambda *args, **kwargs: [{"similarity_score": 0.5, "recommendation": "lesson text"}]
enriched = prioritize_candidates(
[test_candidate], empty_portfolio, [], {"max_sector_pct": 0.35}, selection_memory=memory
)
assert len(enriched) == 1
assert enriched[0]["priority_score"] > 0
assert "skip_reason" not in enriched[0]
def test_skip_reason_contains_advice(empty_portfolio, test_candidate):
memory = FinancialSituationMemory("test_mem")
advice_text = "Specific unique advice string 12345"
memory.add_situations([("AAPL technology growth High earnings potential high", advice_text)])
original_get = memory.get_memories
memory.get_memories = lambda *args, **kwargs: [{"similarity_score": 0.9, "recommendation": advice_text}]
enriched = prioritize_candidates(
[test_candidate], empty_portfolio, [], {"max_sector_pct": 0.35}, selection_memory=memory
)
assert enriched[0]["priority_score"] == 0.0
assert advice_text in enriched[0]["skip_reason"]
memory.get_memories = original_get

View File

@ -0,0 +1,48 @@
import json
import pytest
from pathlib import Path
from tradingagents.portfolio.lesson_store import LessonStore
@pytest.fixture
def tmp_store(tmp_path):
store = LessonStore(tmp_path / "test_lessons.json")
yield store
store.clear()
def test_append_to_empty(tmp_store):
lessons = [
{"ticker": "NVDA", "scan_date": "2025-12-27", "horizon_days": 30, "sentiment": "negative"},
{"ticker": "AAPL", "scan_date": "2025-12-27", "horizon_days": 30, "sentiment": "positive"},
]
added = tmp_store.append(lessons)
assert added == 2
loaded = tmp_store.load_all()
assert len(loaded) == 2
assert loaded[0]["ticker"] == "NVDA"
def test_deduplication(tmp_store):
lesson1 = {"ticker": "NVDA", "scan_date": "2025-12-27", "horizon_days": 30, "sentiment": "negative"}
lesson2 = {"ticker": "NVDA", "scan_date": "2025-12-27", "horizon_days": 30, "sentiment": "positive"} # same dedup key
added1 = tmp_store.append([lesson1])
assert added1 == 1
added2 = tmp_store.append([lesson2])
assert added2 == 0
loaded = tmp_store.load_all()
assert len(loaded) == 1
assert loaded[0]["sentiment"] == "negative"
def test_load_missing_file(tmp_store):
assert tmp_store.load_all() == []
def test_atomic_write(tmp_store):
tmp_store.append([{"ticker": "NVDA", "scan_date": "2025-12-27", "horizon_days": 30}])
assert tmp_store.path.exists()
assert not tmp_store.path.with_suffix('.tmp').exists()
def test_append_to_existing(tmp_store):
tmp_store.append([{"ticker": "NVDA", "scan_date": "2025-12-27", "horizon_days": 30}])
added = tmp_store.append([{"ticker": "AAPL", "scan_date": "2025-12-27", "horizon_days": 30}])
assert added == 1
assert len(tmp_store.load_all()) == 2

View File

@ -0,0 +1,106 @@
import pytest
import pandas as pd
from unittest.mock import MagicMock
from langchain_core.messages import AIMessage
from tradingagents.portfolio.selection_reflector import (
fetch_price_trend, fetch_news_summary, generate_lesson, reflect_on_scan
)
@pytest.fixture
def mock_yf_download(monkeypatch):
def _mock_download(tickers, start, end, **kwargs):
dates = pd.date_range(start, periods=5)
# Ticker goes up then down
ticker_closes = [100.0, 110.0, 105.0, 90.0, 85.0]
# SPY goes up steadily
spy_closes = [400.0, 402.0, 405.0, 407.0, 410.0]
df = pd.DataFrame({
"AAPL": ticker_closes,
"SPY": spy_closes
}, index=dates)
return df
monkeypatch.setattr("yfinance.download", _mock_download)
return _mock_download
def test_fetch_price_data_normal(mock_yf_download):
terminal_return, spy_return, mfe_pct, mae_pct, days_to_peak, top_move_dates = fetch_price_trend("AAPL", "2025-01-01", "2025-01-05")
assert terminal_return == pytest.approx(-15.0) # (85 - 100) / 100 * 100
assert spy_return == pytest.approx(2.5) # (410 - 400) / 400 * 100
assert mfe_pct == pytest.approx(10.0) # (110 - 100) / 100 * 100
assert mae_pct == pytest.approx(-15.0) # (85 - 100) / 100 * 100
assert days_to_peak == 1 # Peak is at index 1 (2025-01-02)
assert len(top_move_dates) == 3
def test_fetch_price_data_single_day(monkeypatch):
monkeypatch.setattr("yfinance.download", lambda *args, **kwargs: pd.DataFrame({"AAPL": [100.0], "SPY": [400.0]}))
terminal_return, spy_return, mfe_pct, mae_pct, days_to_peak, top_move_dates = fetch_price_trend("AAPL", "2025-01-01", "2025-01-01")
assert terminal_return is None
assert spy_return is None
assert mfe_pct is None
assert mae_pct is None
assert days_to_peak is None
assert top_move_dates == []
def test_fetch_news_summary_weighted(monkeypatch):
def mock_get_company_news(ticker, start, end):
if start == "2025-01-01":
return "- Start news 1\n- Start news 2\n- Start news 3"
elif start == "2025-01-02":
return "- Top move 1\n- Top move 1b"
elif start == "2025-01-04":
return "- Top move 2"
return ""
monkeypatch.setattr("tradingagents.portfolio.selection_reflector.get_company_news", mock_get_company_news)
summary = fetch_news_summary("AAPL", "2025-01-01", "2025-01-05", ["2025-01-02", "2025-01-04"])
assert "- Start news 1" in summary
assert "- Start news 2" in summary
assert "- Start news 3" not in summary # Only taking 2 start news
assert "- Top move 1" in summary
assert "- Top move 1b" not in summary # Only taking 1 from each top move date
assert "- Top move 2" in summary
def test_generate_lesson_valid():
llm = MagicMock()
llm.invoke.return_value = AIMessage(content='```json\n{"situation": "test sit", "screening_advice": "test screen", "exit_advice": "test exit", "sentiment": "negative"}\n```')
cand = {"ticker": "AAPL", "sector": "Tech", "thesis_angle": "growth", "rationale": "good", "conviction": "high"}
lesson = generate_lesson(llm, cand, -10.0, 2.0, 5.0, -12.0, 5, "news", 30)
assert lesson is not None
assert lesson["situation"] == "test sit"
assert lesson["screening_advice"] == "test screen"
assert lesson["exit_advice"] == "test exit"
assert lesson["sentiment"] == "negative"
def test_generate_lesson_mfe_mae_in_prompt():
llm = MagicMock()
llm.invoke.return_value = AIMessage(content='{"situation": "a", "screening_advice": "b", "exit_advice": "c", "sentiment": "neutral"}')
cand = {"ticker": "AAPL"}
generate_lesson(llm, cand, -10.0, 2.0, 5.1, -12.2, 5, "news", 30)
call_args = llm.invoke.call_args[0][0]
prompt_text = call_args[0].content
assert "MFE): +5.1%" in prompt_text
assert "MAE): -12.2%" in prompt_text
assert "Day 5" in prompt_text
def test_generate_lesson_bad_json():
llm = MagicMock()
llm.invoke.return_value = AIMessage(content='Not a JSON')
lesson = generate_lesson(llm, {}, -10.0, 2.0, 5.0, -12.0, 5, "news", 30)
assert lesson is None
def test_reflect_on_scan_no_file(monkeypatch):
llm = MagicMock()
monkeypatch.setattr("tradingagents.portfolio.selection_reflector.load_scan_candidates", lambda date: [])
lessons = reflect_on_scan("2025-01-01", "2025-01-31", llm, 30)
assert lessons == []

View File

@ -169,7 +169,15 @@ class PortfolioGraphSetup:
h.enrich(prices[h.ticker], total_value)
portfolio.enrich(holdings)
ranked = prioritize_candidates(candidates, portfolio, holdings, config)
from tradingagents.portfolio.memory_loader import build_selection_memory
try:
selection_memory = build_selection_memory()
except Exception as exc:
logger.warning("prioritize_candidates_node: could not load selection_memory: %s", exc)
selection_memory = None
ranked = prioritize_candidates(candidates, portfolio, holdings, config, selection_memory=selection_memory)
except Exception as exc:
logger.error("prioritize_candidates_node: %s", exc)
ranked = []

View File

@ -14,6 +14,7 @@ from tradingagents.portfolio.risk_evaluator import sector_concentration
if TYPE_CHECKING:
from tradingagents.portfolio.models import Holding, Portfolio
from tradingagents.agents.utils.memory import FinancialSituationMemory
# ---------------------------------------------------------------------------
@ -94,12 +95,20 @@ def score_candidate(
return conviction_weight * thesis_score * diversification_factor * held_penalty
def _build_candidate_description(candidate: dict) -> str:
"""Concatenate ticker, sector, thesis_angle, rationale, conviction for BM25 query."""
parts = [candidate.get(k, "") for k in
("ticker", "sector", "thesis_angle", "rationale", "conviction")]
return " ".join(p for p in parts if p)
def prioritize_candidates(
candidates: list[dict[str, Any]],
portfolio: "Portfolio",
holdings: list["Holding"],
config: dict[str, Any],
top_n: int | None = None,
selection_memory: "FinancialSituationMemory | None" = None,
) -> list[dict[str, Any]]:
"""Score and rank candidates by priority_score descending.
@ -137,6 +146,19 @@ def prioritize_candidates(
f"Sector '{sector}' is at or above max exposure limit "
f"({config.get('max_sector_pct', 0.35):.0%})"
)
# Memory rejection gate
if selection_memory is not None and ps > 0.0:
desc = _build_candidate_description(candidate)
matches = selection_memory.get_memories(desc, n_matches=1)
if matches and matches[0]["similarity_score"] > 0.5:
ps = 0.0
item["priority_score"] = 0.0
item["skip_reason"] = (
f"Memory lesson (score={matches[0]['similarity_score']:.2f}): "
f"{matches[0]['recommendation'][:120]}"
)
enriched.append(item)
enriched.sort(key=lambda c: c["priority_score"], reverse=True)

View File

@ -0,0 +1,66 @@
import json
import os
from pathlib import Path
from tradingagents.report_paths import REPORTS_ROOT
class LessonStore:
"""Append-only JSON store for screening lessons.
Deduplicates on (ticker, scan_date, horizon_days).
Atomic writes: write to .tmp, then os.replace().
"""
DEFAULT_PATH = REPORTS_ROOT / "memory" / "selection_lessons.json"
def __init__(self, path: Path | str | None = None):
if path is None:
self.path = self.DEFAULT_PATH
else:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
def load_all(self) -> list[dict]:
"""Returns all lessons, or [] if file is missing."""
if not self.path.exists():
return []
try:
with open(self.path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError:
return []
def append(self, lessons: list[dict]) -> int:
"""Appends lessons, skipping duplicates. Returns count added."""
if not lessons:
return 0
existing_lessons = self.load_all()
existing_keys = {
(l.get("ticker"), l.get("scan_date"), l.get("horizon_days"))
for l in existing_lessons
}
to_add = []
for l in lessons:
key = (l.get("ticker"), l.get("scan_date"), l.get("horizon_days"))
if key not in existing_keys:
to_add.append(l)
existing_keys.add(key)
if not to_add:
return 0
new_lessons = existing_lessons + to_add
tmp_path = self.path.with_suffix('.tmp')
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(new_lessons, f, indent=2)
os.replace(tmp_path, self.path)
return len(to_add)
def clear(self) -> None:
"""Clears the store (for test isolation)."""
if self.path.exists():
self.path.unlink()

View File

@ -0,0 +1,23 @@
from pathlib import Path
from tradingagents.portfolio.lesson_store import LessonStore
from tradingagents.agents.utils.memory import FinancialSituationMemory
def load_into_memory(lesson_store: LessonStore,
memory: FinancialSituationMemory) -> int:
"""Populate memory with ONLY negative-sentiment lessons. Returns count loaded."""
lessons = lesson_store.load_all()
pairs = [
(l.get("situation", ""), l.get("screening_advice", l.get("advice", "")))
for l in lessons
if l.get("sentiment") == "negative"
]
if pairs:
memory.add_situations(pairs)
return len(pairs)
def build_selection_memory(path: Path | None = None) -> FinancialSituationMemory:
"""Convenience: LessonStore + FinancialSituationMemory + load. Used by CLI."""
store = LessonStore(path)
memory = FinancialSituationMemory("selection_memory")
load_into_memory(store, memory)
return memory

View File

@ -0,0 +1,250 @@
import json
import logging
from datetime import datetime, timedelta
import yfinance as yf
import pandas as pd
from langchain_core.messages import HumanMessage
from tradingagents.agents.utils.json_utils import extract_json
from tradingagents.report_paths import get_market_dir
from tradingagents.portfolio.report_store import ReportStore
from tradingagents.dataflows.finnhub import get_company_news
logger = logging.getLogger(__name__)
def load_scan_candidates(scan_date: str) -> list[dict]:
"""Read macro_scan_summary.md for scan_date, extract stocks_to_investigate.
Falls back to report_store.load_scan() if .md absent."""
from tradingagents.portfolio.store_factory import create_report_store
try:
scan_dir = get_market_dir(scan_date)
summary_path = scan_dir / "macro_scan_summary.md"
if summary_path.exists():
content = summary_path.read_text(encoding="utf-8")
data = extract_json(content)
if isinstance(data, dict) and "stocks_to_investigate" in data:
return data["stocks_to_investigate"]
except Exception as e:
logger.warning(f"Error reading macro_scan_summary.md for {scan_date}: {e}")
try:
store = create_report_store()
scan_data = store.load_scan(scan_date)
if scan_data and isinstance(scan_data, dict) and "stocks_to_investigate" in scan_data:
return scan_data["stocks_to_investigate"]
except Exception as e:
logger.warning(f"Error loading scan from ReportStore for {scan_date}: {e}")
return []
def fetch_price_trend(ticker: str, start_date: str, end_date: str) -> tuple[float | None, float | None, float | None, float | None, int | None, list[str]]:
"""Download [ticker, SPY] via yf.download().
Returns (terminal_return, spy_return, mfe_pct, mae_pct, days_to_peak, top_move_dates).
- mfe_pct: Maximum Favorable Excursion (peak return vs entry)
- mae_pct: Maximum Adverse Excursion (worst drawdown from entry)
- days_to_peak: Integer number of days from start_date to Highest_High
- top_move_dates: up to 3 dates with largest single-day absolute price moves
Returns (None, None, None, None, None, []) if < 2 trading days or download fails.
"""
def _local_safe_pct(closes: pd.Series, days_back: int) -> float | None:
if len(closes) < days_back + 1:
return None
base = closes.iloc[-(days_back + 1)]
current = closes.iloc[-1]
if base == 0:
return None
return (current - base) / base * 100
try:
hist = yf.download(
[ticker, "SPY"],
start=start_date,
end=end_date,
auto_adjust=True,
progress=False
)
if hist.empty or len(hist) < 2:
return None, None, None, None, None, []
if isinstance(hist.columns, pd.MultiIndex):
closes = hist["Close"]
else:
closes = hist
if ticker not in closes.columns or "SPY" not in closes.columns:
return None, None, None, None, None, []
stock_closes = closes[ticker].dropna()
spy_closes = closes["SPY"].dropna()
if len(stock_closes) < 2 or len(spy_closes) < 2:
return None, None, None, None, None, []
# Terminal returns
terminal_return = _local_safe_pct(stock_closes, len(stock_closes) - 1)
spy_return = _local_safe_pct(spy_closes, len(spy_closes) - 1)
# MFE / MAE
entry_price = stock_closes.iloc[0]
if entry_price == 0:
return None, None, None, None, None, []
peak_price = stock_closes.max()
worst_price = stock_closes.min()
mfe_pct = (peak_price - entry_price) / entry_price * 100
mae_pct = (worst_price - entry_price) / entry_price * 100
# Days to peak
start_datetime = stock_closes.index[0]
peak_datetime = stock_closes.idxmax()
days_to_peak = (peak_datetime - start_datetime).days
# Top move dates
stock_returns = stock_closes.pct_change().dropna()
abs_returns = stock_returns.abs()
top_moves = abs_returns.nlargest(3)
top_move_dates = [d.strftime("%Y-%m-%d") for d in top_moves.index]
return terminal_return, spy_return, mfe_pct, mae_pct, days_to_peak, top_move_dates
except Exception as e:
logger.warning(f"Error fetching price data for {ticker}: {e}")
return None, None, None, None, None, []
def fetch_news_summary(ticker: str, start_date: str, end_date: str, top_move_dates: list[str], n: int = 5) -> str:
"""Fetch n headlines, weighted toward largest-move dates.
Strategy: 2 headlines from window start (initial catalyst context),
3 headlines from dates nearest top_move_dates (outcome context).
Returns bullet-list string."""
headlines = []
# 1. Fetch 2 headlines from the start of the window
try:
start_news_md = get_company_news(ticker, start_date, start_date)
# Assuming get_company_news returns markdown list, extract lines starting with -
if start_news_md:
start_lines = [line for line in start_news_md.split('\n') if line.strip().startswith('-')]
headlines.extend(start_lines[:2])
except Exception as e:
logger.warning(f"Failed to fetch start news for {ticker}: {e}")
# 2. Fetch 3 headlines from top move dates
try:
top_news_lines = []
for date_str in top_move_dates:
news_md = get_company_news(ticker, date_str, date_str)
if news_md:
lines = [line for line in news_md.split('\n') if line.strip().startswith('-')]
if lines:
top_news_lines.append(lines[0]) # Get best headline for each top move date
headlines.extend(top_news_lines[:3])
except Exception as e:
logger.warning(f"Failed to fetch top move news for {ticker}: {e}")
if not headlines:
return "No specific headlines found."
return "\n".join(headlines)
def generate_lesson(llm, candidate: dict, terminal_return: float | None,
spy_return: float | None, mfe_pct: float | None,
mae_pct: float | None, days_to_peak: int | None,
news_summary: str, horizon_days: int) -> dict | None:
"""Invoke quick_think LLM, parse JSON via extract_json(), return lesson dict.
Returns None on parse failure (logs warning)."""
if terminal_return is None or spy_return is None:
return None
prompt = f"""STOCK SELECTION REVIEW (0 TO {horizon_days} DAYS)
======================
Ticker: {candidate.get('ticker')} | Sector: {candidate.get('sector')}
Original thesis: {candidate.get('thesis_angle')} {candidate.get('rationale')}
THE TREND STORY
------------------
Terminal Return: {terminal_return:+.1f}% (SPY Benchmark: {spy_return:+.1f}%)
Optimal Sell Moment (MFE): {mfe_pct:+.1f}% (Reached on Day {days_to_peak})
Deepest Drawdown (MAE): {mae_pct:+.1f}%
TOP HEADLINES
-------------
{news_summary}
Return ONLY a JSON object with the following keys:
- "situation": 1-2 sentences describing the stock pattern/thesis type.
- "screening_advice": 1 sentence on whether the scanner should buy this setup again.
- "exit_advice": 1 sentence defining the optimal exit strategy based on the MFE/MAE (e.g., "Take profits at +20% or tighten trailing stops after 20 days").
- "sentiment": "positive" | "negative" | "neutral"
"""
try:
response = llm.invoke([HumanMessage(content=prompt)])
data = extract_json(response.content if hasattr(response, 'content') else response)
if not data or not isinstance(data, dict):
logger.warning(f"LLM response parse failure: {response}")
return None
# Verify required keys
if not all(k in data for k in ["situation", "screening_advice", "exit_advice", "sentiment"]):
logger.warning(f"LLM response missing required keys: {data}")
return None
return data
except Exception as e:
logger.warning(f"Error generating lesson: {e}")
return None
def reflect_on_scan(scan_date: str, reflect_date: str, llm, horizon_days: int) -> list[dict]:
"""Top-level: load candidates, fetch data, generate lessons, return list."""
candidates = load_scan_candidates(scan_date)
lessons = []
for cand in candidates:
ticker = cand.get("ticker")
if not ticker:
continue
terminal_return, spy_return, mfe_pct, mae_pct, days_to_peak, top_move_dates = fetch_price_trend(ticker, scan_date, reflect_date)
if terminal_return is None:
continue
news_summary = fetch_news_summary(ticker, scan_date, reflect_date, top_move_dates)
lesson_data = generate_lesson(
llm=llm,
candidate=cand,
terminal_return=terminal_return,
spy_return=spy_return,
mfe_pct=mfe_pct,
mae_pct=mae_pct,
days_to_peak=days_to_peak,
news_summary=news_summary,
horizon_days=horizon_days
)
if lesson_data:
alpha = terminal_return - spy_return
lesson = {
"ticker": ticker,
"scan_date": scan_date,
"reflect_date": reflect_date,
"horizon_days": horizon_days,
"terminal_return_pct": round(terminal_return, 2),
"spy_return_pct": round(spy_return, 2),
"alpha_pct": round(alpha, 2),
"mfe_pct": round(mfe_pct, 2) if mfe_pct is not None else None,
"mae_pct": round(mae_pct, 2) if mae_pct is not None else None,
"days_to_peak": days_to_peak,
"news_summary": news_summary,
"situation": lesson_data["situation"],
"screening_advice": lesson_data.get("screening_advice", lesson_data.get("advice", "")),
"exit_advice": lesson_data.get("exit_advice", ""),
"sentiment": lesson_data["sentiment"],
"created_at": datetime.now().isoformat()
}
lessons.append(lesson)
return lessons