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:
parent
b7255c4012
commit
02cbaecf62
64
cli/main.py
64
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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 == []
|
||||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue