diff --git a/cli/main.py b/cli/main.py index 17a53c04..7e5ff3fb 100644 --- a/cli/main.py +++ b/cli/main.py @@ -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.""" diff --git a/tests/portfolio/test_candidate_prioritizer_memory.py b/tests/portfolio/test_candidate_prioritizer_memory.py new file mode 100644 index 00000000..4b669d4f --- /dev/null +++ b/tests/portfolio/test_candidate_prioritizer_memory.py @@ -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 diff --git a/tests/portfolio/test_lesson_store.py b/tests/portfolio/test_lesson_store.py new file mode 100644 index 00000000..2dddac34 --- /dev/null +++ b/tests/portfolio/test_lesson_store.py @@ -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 diff --git a/tests/portfolio/test_selection_reflector.py b/tests/portfolio/test_selection_reflector.py new file mode 100644 index 00000000..eeb901a4 --- /dev/null +++ b/tests/portfolio/test_selection_reflector.py @@ -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 == [] diff --git a/tradingagents/graph/portfolio_setup.py b/tradingagents/graph/portfolio_setup.py index 70003004..eac006af 100644 --- a/tradingagents/graph/portfolio_setup.py +++ b/tradingagents/graph/portfolio_setup.py @@ -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 = [] diff --git a/tradingagents/portfolio/candidate_prioritizer.py b/tradingagents/portfolio/candidate_prioritizer.py index ac9bd879..dec5bea5 100644 --- a/tradingagents/portfolio/candidate_prioritizer.py +++ b/tradingagents/portfolio/candidate_prioritizer.py @@ -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) diff --git a/tradingagents/portfolio/lesson_store.py b/tradingagents/portfolio/lesson_store.py new file mode 100644 index 00000000..24b6d2f1 --- /dev/null +++ b/tradingagents/portfolio/lesson_store.py @@ -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() diff --git a/tradingagents/portfolio/memory_loader.py b/tradingagents/portfolio/memory_loader.py new file mode 100644 index 00000000..0638018a --- /dev/null +++ b/tradingagents/portfolio/memory_loader.py @@ -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 diff --git a/tradingagents/portfolio/selection_reflector.py b/tradingagents/portfolio/selection_reflector.py new file mode 100644 index 00000000..e7a43d66 --- /dev/null +++ b/tradingagents/portfolio/selection_reflector.py @@ -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