From 553d7f08f7f48ad39981d351ec7573cd72f0f593 Mon Sep 17 00:00:00 2001 From: Kihwan Kim Date: Tue, 14 Apr 2026 22:04:40 +0900 Subject: [PATCH] feat: structured fact store for cross-session memory persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a FactStore layer that persists TradeThesis objects to DuckDB after each analysis query, enabling deterministic ticker-based lookup across sessions instead of relying solely on free-text search. Write path: engine._persist_facts() extracts the thesis from AnalysisResult and upserts it into fact_store.duckdb with automatic supersession handling (new thesis on same ticker marks old as superseded). Read path: StandardHandler injects open theses as "prior_thesis" ToolResult evidence before the analysis loop. QuickPathHandler appends a thesis reminder to price checks. Session-start briefing shows open theses with upcoming catalysts. Zero additional LLM calls — all data is already structured in the existing TradeThesis dataclass. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + qracer/cli.py | 11 ++ qracer/conversation/engine.py | 23 ++- qracer/conversation/handlers.py | 59 +++++++ qracer/conversation/quickpath.py | 31 ++++ qracer/memory/__init__.py | 7 + qracer/memory/fact_models.py | 72 +++++++++ qracer/memory/fact_store.py | 256 ++++++++++++++++++++++++++++++ tests/conversation/test_engine.py | 99 ++++++++++++ tests/memory/test_fact_store.py | 185 +++++++++++++++++++++ 10 files changed, 742 insertions(+), 2 deletions(-) create mode 100644 qracer/memory/fact_models.py create mode 100644 qracer/memory/fact_store.py create mode 100644 tests/memory/test_fact_store.py diff --git a/.gitignore b/.gitignore index dd1738c..6d73991 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ htmlcov/ # Session memory (runtime data, not source code) memory/ !src/qracer/memory/ +!qracer/memory/ !tests/memory/ # IDE diff --git a/qracer/cli.py b/qracer/cli.py index 3285056..98ae256 100644 --- a/qracer/cli.py +++ b/qracer/cli.py @@ -383,6 +383,7 @@ async def _repl_loop( data_registry: object | None = None, sessions_dir: Path | None = None, current_session: Path | None = None, + fact_store: object | None = None, ) -> None: """Run the interactive read-eval-print loop.""" from qracer.alert_monitor import AlertMonitor @@ -411,6 +412,7 @@ async def _repl_loop( executor.store, sessions_dir, current_session=current_session, + fact_store=fact_store, # type: ignore[arg-type] ) except Exception: logger.debug("Session briefing generation failed", exc_info=True) @@ -933,6 +935,13 @@ def repl() -> None: if loaded_contexts: click.echo(f" ✓ Loaded {loaded_contexts} past session summaries from {summaries_dir}") + from qracer.memory.fact_store import FactStore + + fact_store = FactStore(_user_dir() / "fact_store.duckdb") + open_theses = fact_store.get_open_theses() + if open_theses: + click.echo(f" ✓ Loaded {len(open_theses)} open theses from fact store") + reports_dir = _user_dir() / "reports" watchlist = Watchlist(_user_dir() / "watchlist.json") @@ -956,6 +965,7 @@ def repl() -> None: language=app_cfg.language, memory_searcher=memory_searcher, summaries_dir=summaries_dir, + fact_store=fact_store, ) task_executor = TaskExecutor(task_store, data_registry, llm_registry, engine=engine) @@ -969,6 +979,7 @@ def repl() -> None: data_registry=data_registry, sessions_dir=sessions_dir, current_session=session_logger.path, + fact_store=fact_store, ) ) diff --git a/qracer/conversation/engine.py b/qracer/conversation/engine.py index 46dc32c..ba5d6ff 100644 --- a/qracer/conversation/engine.py +++ b/qracer/conversation/engine.py @@ -35,6 +35,7 @@ from qracer.conversation.synthesizer import ComparisonSynthesizer, ResponseSynthesizer from qracer.data.registry import DataRegistry from qracer.llm.registry import LLMRegistry +from qracer.memory.fact_store import FactStore from qracer.memory.memory_searcher import MemorySearcher from qracer.memory.session_compactor import SessionCompactor from qracer.memory.session_logger import SessionLogger, TurnRecord @@ -71,6 +72,7 @@ def __init__( memory_searcher: MemorySearcher | None = None, language: str = "en", summaries_dir: Path | None = None, + fact_store: FactStore | None = None, ) -> None: self._llm = llm_registry self._data = data_registry @@ -79,6 +81,7 @@ def __init__( self._memory_searcher = memory_searcher self._language = language self._summaries_dir = summaries_dir + self._fact_store = fact_store analysis_loop = AnalysisLoop( llm_registry, @@ -94,7 +97,7 @@ def __init__( data_registry, self._portfolio_config, language=language ) self._quickpath_handler = QuickPathHandler( - data_registry, memory_searcher, language=language + data_registry, memory_searcher, language=language, fact_store=fact_store ) self._comparison_handler = ComparisonHandler( data_registry, comparison_synthesizer, memory_searcher @@ -106,10 +109,12 @@ def __init__( synthesizer, self._portfolio_config, memory_searcher, + fact_store=fact_store, ) self._history: list[dict] = [] self._session_logger = session_logger + self._session_id = session_logger.path.stem if session_logger else "unknown" self._compactor = SessionCompactor(llm_registry) if session_logger else None self._report_exporter = ReportExporter(report_dir) if report_dir else None self._context: ConversationContext = ConversationContext() @@ -145,7 +150,10 @@ def update_registries( data_registry, self._portfolio_config, language=lang ) self._quickpath_handler = QuickPathHandler( - data_registry, self._memory_searcher, language=lang + data_registry, + self._memory_searcher, + language=lang, + fact_store=self._fact_store, ) self._comparison_handler = ComparisonHandler( data_registry, comparison_synthesizer, self._memory_searcher @@ -157,6 +165,7 @@ def update_registries( synthesizer, self._portfolio_config, self._memory_searcher, + fact_store=self._fact_store, ) self._config_version += 1 @@ -314,4 +323,14 @@ async def query(self, user_input: str) -> EngineResponse: response = EngineResponse(text=result.text, intent=intent, analysis=result.analysis) self._last_response = response + self._persist_facts(result.analysis) return response + + def _persist_facts(self, analysis: AnalysisResult) -> None: + """Extract and persist structured facts from analysis results.""" + if self._fact_store is None or analysis.trade_thesis is None: + return + try: + self._fact_store.save_thesis(analysis.trade_thesis, self._session_id) + except Exception: + logger.warning("Failed to persist thesis to fact store", exc_info=True) diff --git a/qracer/conversation/handlers.py b/qracer/conversation/handlers.py index f3819e7..14ea406 100644 --- a/qracer/conversation/handlers.py +++ b/qracer/conversation/handlers.py @@ -19,6 +19,8 @@ from qracer.data.providers import PriceProvider from qracer.data.registry import DataRegistry from qracer.llm.registry import LLMRegistry +from qracer.memory.fact_models import PersistedThesis +from qracer.memory.fact_store import FactStore from qracer.memory.memory_searcher import MemorySearcher from qracer.models import ToolResult, TradeThesis from qracer.risk.calculator import RiskCalculator @@ -92,16 +94,24 @@ def __init__( memory_searcher: MemorySearcher | None = None, *, language: str = "en", + fact_store: FactStore | None = None, ) -> None: self._data = data_registry self._memory_searcher = memory_searcher self._language = language + self._fact_store = fact_store async def handle(self, intent: Intent) -> HandlerResult: results = await invoke_tools( intent.tools, intent, self._data, memory_searcher=self._memory_searcher ) text = format_quickpath(intent, results, language=self._language) + + if self._fact_store is not None and intent.tickers: + open_theses = self._fact_store.get_open_theses(intent.tickers) + if open_theses: + text += "\n\n" + _format_thesis_reminder(open_theses[0]) + return HandlerResult( text=text, analysis=AnalysisResult(results=results, confidence=1.0, iterations=0) ) @@ -154,6 +164,8 @@ def __init__( synthesizer: ResponseSynthesizer, portfolio_config: PortfolioConfig, memory_searcher: MemorySearcher | None = None, + *, + fact_store: FactStore | None = None, ) -> None: self._data = data_registry self._llm = llm_registry @@ -161,6 +173,7 @@ def __init__( self._synthesizer = synthesizer self._portfolio_config = portfolio_config self._memory_searcher = memory_searcher + self._fact_store = fact_store async def handle(self, intent: Intent) -> HandlerResult: # Invoke initial pipeline tools. @@ -168,6 +181,19 @@ async def handle(self, intent: Intent) -> HandlerResult: intent.tools, intent, self._data, memory_searcher=self._memory_searcher ) + # Inject open theses from fact store as prior evidence. + if self._fact_store is not None and intent.tickers: + open_theses = self._fact_store.get_open_theses(intent.tickers) + if open_theses: + initial_results.append( + ToolResult( + tool="prior_thesis", + success=True, + data={"theses": [_thesis_to_dict(t) for t in open_theses]}, + source="FactStore", + ) + ) + # Run analysis loop. analysis = await self._analysis_loop.run(intent, initial_results) logger.info( @@ -234,3 +260,36 @@ def _format_rebalance_suggestions(suggestions: list[RebalanceAction]) -> str: else: lines.append(f" ADD {s.ticker} — {s.reason}") return "\n".join(lines) + + +def _thesis_to_dict(t: PersistedThesis) -> dict: + """Convert a PersistedThesis to a dict for ToolResult.data.""" + return { + "ticker": t.ticker, + "entry_zone": [t.entry_zone_low, t.entry_zone_high], + "target_price": t.target_price, + "stop_loss": t.stop_loss, + "risk_reward_ratio": t.risk_reward_ratio, + "catalyst": t.catalyst, + "catalyst_date": t.catalyst_date, + "conviction": t.conviction, + "status": t.status.value, + "session_id": t.session_id, + } + + +def _format_thesis_reminder(t: PersistedThesis) -> str: + """One-line reminder of an active thesis for QuickPath output.""" + entry_mid = (t.entry_zone_low + t.entry_zone_high) / 2 + direction = "LONG" if t.target_price > entry_mid else "SHORT" + line = ( + f" Active thesis: {direction} {t.ticker} " + f"(conviction {t.conviction}/10, " + f"target ${t.target_price:.2f}, stop ${t.stop_loss:.2f}" + ) + if t.catalyst: + line += f", catalyst: {t.catalyst}" + if t.catalyst_date: + line += f" ({t.catalyst_date})" + line += ")" + return line diff --git a/qracer/conversation/quickpath.py b/qracer/conversation/quickpath.py index 806d768..82b60b2 100644 --- a/qracer/conversation/quickpath.py +++ b/qracer/conversation/quickpath.py @@ -14,6 +14,7 @@ from qracer.conversation.intent import Intent, IntentType from qracer.data.providers import PriceProvider from qracer.data.registry import DataRegistry +from qracer.memory.fact_store import FactStore from qracer.models import ToolResult from qracer.risk.models import PortfolioSnapshot from qracer.tasks import TaskStore @@ -214,6 +215,7 @@ async def generate_briefing( task_store: TaskStore, sessions_dir: Path, current_session: Path | None = None, + fact_store: FactStore | None = None, ) -> str | None: """Generate a session-start briefing. @@ -266,6 +268,15 @@ async def generate_briefing( lines.append("") has_content = True + # Open theses from fact store + if fact_store is not None: + thesis_lines = _briefing_thesis_lines(fact_store) + if thesis_lines: + lines.append(f"Open Theses ({len(thesis_lines)}):") + lines.extend(thesis_lines) + lines.append("") + has_content = True + if not has_content: return None @@ -275,6 +286,26 @@ async def generate_briefing( return "\n".join(lines) +def _briefing_thesis_lines(fact_store: FactStore) -> list[str]: + """Format open theses for the session-start briefing.""" + upcoming = fact_store.get_upcoming_catalysts(days_ahead=14) + if not upcoming: + upcoming = fact_store.get_open_theses()[:5] + lines: list[str] = [] + for t in upcoming: + entry_mid = (t.entry_zone_low + t.entry_zone_high) / 2 + direction = "LONG" if t.target_price > entry_mid else "SHORT" + line = ( + f" {direction} {t.ticker}: conviction {t.conviction}/10, target ${t.target_price:.2f}" + ) + if t.catalyst: + line += f", catalyst: {t.catalyst}" + if t.catalyst_date: + line += f" ({t.catalyst_date})" + lines.append(line) + return lines + + def _find_last_session( sessions_dir: Path, current_session: Path | None = None, diff --git a/qracer/memory/__init__.py b/qracer/memory/__init__.py index 7a7fbf9..64b222e 100644 --- a/qracer/memory/__init__.py +++ b/qracer/memory/__init__.py @@ -1,12 +1,19 @@ +from qracer.memory.fact_models import Finding, PersistedThesis, SessionDigest, ThesisStatus +from qracer.memory.fact_store import FactStore from qracer.memory.memory_searcher import MemorySearcher, SearchResult from qracer.memory.session_compactor import CompactionResult, SessionCompactor from qracer.memory.session_logger import SessionLogger, TurnRecord __all__ = [ "CompactionResult", + "FactStore", + "Finding", "MemorySearcher", + "PersistedThesis", "SearchResult", "SessionCompactor", + "SessionDigest", "SessionLogger", + "ThesisStatus", "TurnRecord", ] diff --git a/qracer/memory/fact_models.py b/qracer/memory/fact_models.py new file mode 100644 index 0000000..f982883 --- /dev/null +++ b/qracer/memory/fact_models.py @@ -0,0 +1,72 @@ +"""Structured fact models for cross-session memory persistence. + +These models represent structured knowledge extracted from analysis sessions +(trade theses, findings, session digests) that survive across REPL sessions. +They map to DuckDB tables managed by :class:`FactStore`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum + + +class ThesisStatus(str, Enum): + OPEN = "open" + CLOSED = "closed" + SUPERSEDED = "superseded" + INVALIDATED = "invalidated" + + +@dataclass +class PersistedThesis: + """A trade thesis persisted to the fact store. + + Maps to the runtime ``TradeThesis`` from ``models/base.py`` with added + lifecycle metadata (id, status, session_id, timestamps, superseded_by). + """ + + id: int + ticker: str + entry_zone_low: float + entry_zone_high: float + target_price: float + stop_loss: float + risk_reward_ratio: float + catalyst: str + catalyst_date: str | None + conviction: int # 1-10 + summary: str + status: ThesisStatus + session_id: str + created_at: datetime + updated_at: datetime + superseded_by: int | None = None + + +@dataclass +class Finding: + """A discrete analytical insight extracted from analysis.""" + + id: int + entity: str # ticker or macro indicator name + statement: str + confidence: float # 0.0-1.0 + source_tool: str + session_id: str + event_date: str | None = None + created_at: datetime = field(default_factory=datetime.now) + + +@dataclass +class SessionDigest: + """Lightweight structured summary of a session.""" + + session_id: str + tickers_discussed: list[str] + intent_types_used: list[str] + thesis_ids: list[int] + key_conclusions: str + turn_count: int + created_at: datetime = field(default_factory=datetime.now) diff --git a/qracer/memory/fact_store.py b/qracer/memory/fact_store.py new file mode 100644 index 0000000..1ab52e1 --- /dev/null +++ b/qracer/memory/fact_store.py @@ -0,0 +1,256 @@ +"""FactStore — structured fact persistence for cross-session memory. + +Manages trade theses (Phase 1), findings, and session digests in DuckDB. +Follows the same connection pattern as ``storage/repositories.py``. + +The store uses its own DuckDB file (``fact_store.duckdb``), separate from +``memory_index.duckdb``, so the existing :class:`MemorySearcher` is +completely unaffected. +""" + +from __future__ import annotations + +import logging +import re +from datetime import datetime, timedelta +from pathlib import Path + +import duckdb + +from qracer.memory.fact_models import PersistedThesis, ThesisStatus +from qracer.models.base import TradeThesis + +logger = logging.getLogger(__name__) + +_SCHEMA_SQL = """\ +CREATE SEQUENCE IF NOT EXISTS thesis_id_seq START 1; + +CREATE TABLE IF NOT EXISTS theses ( + id INTEGER PRIMARY KEY DEFAULT nextval('thesis_id_seq'), + ticker VARCHAR NOT NULL, + entry_zone_low DOUBLE NOT NULL, + entry_zone_high DOUBLE NOT NULL, + target_price DOUBLE NOT NULL, + stop_loss DOUBLE NOT NULL, + risk_reward_ratio DOUBLE NOT NULL, + catalyst VARCHAR NOT NULL, + catalyst_date VARCHAR, + conviction INTEGER NOT NULL, + summary VARCHAR NOT NULL, + status VARCHAR NOT NULL DEFAULT 'open', + session_id VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + superseded_by INTEGER +); +""" + + +def _parse_catalyst_date(raw: str | None) -> datetime | None: + """Best-effort parse of catalyst_date strings into a datetime. + + Handles ISO dates (``2026-05-01``), quarter notation (``Q2 2026``), + and year-month (``2026-05``). Returns ``None`` for unparseable values. + """ + if not raw: + return None + raw = raw.strip() + # ISO date: 2026-05-01 + try: + return datetime.fromisoformat(raw) + except ValueError: + pass + # Quarter: Q1/Q2/Q3/Q4 2026 + m = re.match(r"Q([1-4])\s*(\d{4})", raw, re.IGNORECASE) + if m: + quarter, year = int(m.group(1)), int(m.group(2)) + month = (quarter - 1) * 3 + 1 + return datetime(year, month, 1) + # Year-month: 2026-05 + m = re.match(r"(\d{4})-(\d{2})$", raw) + if m: + return datetime(int(m.group(1)), int(m.group(2)), 1) + return None + + +def _row_to_thesis(row: tuple) -> PersistedThesis: + """Convert a DuckDB row tuple to a PersistedThesis.""" + return PersistedThesis( + id=row[0], + ticker=row[1], + entry_zone_low=row[2], + entry_zone_high=row[3], + target_price=row[4], + stop_loss=row[5], + risk_reward_ratio=row[6], + catalyst=row[7], + catalyst_date=row[8], + conviction=row[9], + summary=row[10], + status=ThesisStatus(row[11]), + session_id=row[12], + created_at=row[13], + updated_at=row[14], + superseded_by=row[15], + ) + + +_SELECT_COLUMNS = ( + "id, ticker, entry_zone_low, entry_zone_high, target_price, stop_loss, " + "risk_reward_ratio, catalyst, catalyst_date, conviction, summary, " + "status, session_id, created_at, updated_at, superseded_by" +) + + +class FactStore: + """Structured fact storage for cross-session memory. + + Usage:: + + store = FactStore(Path("~/.qracer/fact_store.duckdb")) + thesis_id = store.save_thesis(trade_thesis, session_id="abc123") + open_theses = store.get_open_theses(["AAPL"]) + store.close() + """ + + def __init__(self, path: str | Path | None = None) -> None: + db_path = str(path) if path else ":memory:" + self._conn = duckdb.connect(db_path) + self._conn.execute(_SCHEMA_SQL) + + @property + def connection(self) -> duckdb.DuckDBPyConnection: + return self._conn + + # ------------------------------------------------------------------ + # Thesis CRUD + # ------------------------------------------------------------------ + + def save_thesis(self, thesis: TradeThesis, session_id: str) -> int: + """Persist a TradeThesis with automatic supersession handling. + + If an open thesis exists for the same ticker, it is marked as + ``superseded`` and linked to the new thesis via ``superseded_by``. + + Returns the new thesis id. + """ + now = datetime.now() + + # Insert the new thesis first to get its id. + self._conn.execute( + """ + INSERT INTO theses ( + ticker, entry_zone_low, entry_zone_high, target_price, + stop_loss, risk_reward_ratio, catalyst, catalyst_date, + conviction, summary, status, session_id, + created_at, updated_at, superseded_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + thesis.ticker, + thesis.entry_zone[0], + thesis.entry_zone[1], + thesis.target_price, + thesis.stop_loss, + thesis.risk_reward_ratio, + thesis.catalyst, + thesis.catalyst_date, + thesis.conviction, + thesis.summary, + ThesisStatus.OPEN.value, + session_id, + now, + now, + None, # superseded_by + ], + ) + + new_id: int = self._conn.execute("SELECT currval('thesis_id_seq')").fetchone()[0] # type: ignore[index] + + # Supersede any prior open theses on the same ticker. + self._conn.execute( + """ + UPDATE theses + SET status = ?, superseded_by = ?, updated_at = ? + WHERE ticker = ? AND status = ? AND id != ? + """, + [ + ThesisStatus.SUPERSEDED.value, + new_id, + now, + thesis.ticker, + ThesisStatus.OPEN.value, + new_id, + ], + ) + + return new_id + + def get_open_theses(self, tickers: list[str] | None = None) -> list[PersistedThesis]: + """Get all open theses, optionally filtered by ticker list.""" + if tickers: + placeholders = ", ".join("?" for _ in tickers) + rows = self._conn.execute( + f"SELECT {_SELECT_COLUMNS} FROM theses " + f"WHERE status = 'open' AND ticker IN ({placeholders}) " + "ORDER BY created_at DESC", + tickers, + ).fetchall() + else: + rows = self._conn.execute( + f"SELECT {_SELECT_COLUMNS} FROM theses " + "WHERE status = 'open' ORDER BY created_at DESC", + ).fetchall() + return [_row_to_thesis(r) for r in rows] + + def get_upcoming_catalysts(self, days_ahead: int = 14) -> list[PersistedThesis]: + """Get open theses with catalyst_date within *days_ahead* of today. + + Best-effort date parsing: ISO dates, quarter notation (``Q2 2026``), + and year-month (``2026-05``) are supported. Unparseable dates are + excluded. + """ + all_open = self.get_open_theses() + cutoff = datetime.now() + timedelta(days=days_ahead) + now = datetime.now() + result: list[PersistedThesis] = [] + for t in all_open: + dt = _parse_catalyst_date(t.catalyst_date) + if dt is not None and now <= dt <= cutoff: + result.append(t) + return result + + def get_thesis_history(self, ticker: str, limit: int = 10) -> list[PersistedThesis]: + """Get all theses for a ticker (all statuses), most recent first.""" + rows = self._conn.execute( + f"SELECT {_SELECT_COLUMNS} FROM theses " + "WHERE ticker = ? ORDER BY created_at DESC LIMIT ?", + [ticker, limit], + ).fetchall() + return [_row_to_thesis(r) for r in rows] + + def update_thesis_status( + self, + thesis_id: int, + status: ThesisStatus, + *, + superseded_by: int | None = None, + ) -> None: + """Update a thesis status (close, invalidate, supersede).""" + self._conn.execute( + "UPDATE theses SET status = ?, superseded_by = ?, updated_at = ? WHERE id = ?", + [status.value, superseded_by, datetime.now(), thesis_id], + ) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def close(self) -> None: + self._conn.close() + + def __enter__(self) -> FactStore: + return self + + def __exit__(self, *_: object) -> None: + self.close() diff --git a/tests/conversation/test_engine.py b/tests/conversation/test_engine.py index 547832b..1e55129 100644 --- a/tests/conversation/test_engine.py +++ b/tests/conversation/test_engine.py @@ -836,3 +836,102 @@ async def test_maybe_compact_falls_back_to_in_memory_without_dir(self, tmp_path) engine._compactor.compact.assert_awaited_once() engine._compactor.compact_and_save.assert_not_called() + + +# --------------------------------------------------------------------------- +# Structured fact persistence (FactStore integration) +# --------------------------------------------------------------------------- + + +class TestFactExtraction: + async def test_thesis_persisted_after_query(self) -> None: + """When the standard handler produces a TradeThesis, it should be + persisted to the fact store automatically.""" + from qracer.memory.fact_store import FactStore + from qracer.models.base import TradeThesis + + intent_resp = json.dumps({"intent": "event_analysis", "tickers": ["AAPL"]}) + llm = _mock_llm_registry({Role.RESEARCHER: intent_resp, Role.STRATEGIST: "Response"}) + + fact_store = FactStore() # in-memory + engine = ConversationEngine(llm, DataRegistry(), fact_store=fact_store) + + thesis = TradeThesis( + ticker="AAPL", + entry_zone=(170.0, 175.0), + target_price=200.0, + stop_loss=160.0, + risk_reward_ratio=2.4, + catalyst="AI revenue", + catalyst_date="Q2 2026", + conviction=8, + summary="Long AAPL on AI", + ) + + # Patch handlers to return an analysis with a trade thesis. + analysis = AnalysisResult( + results=[_ok_result("price_event")], + confidence=0.9, + iterations=1, + trade_thesis=thesis, + ) + with patch.object(engine._standard_handler, "handle", new=AsyncMock()) as mock_handle: + from qracer.conversation.handlers import HandlerResult + + mock_handle.return_value = HandlerResult(text="Response", analysis=analysis) + await engine.query("Analyze AAPL") + + open_theses = fact_store.get_open_theses(["AAPL"]) + assert len(open_theses) == 1 + assert open_theses[0].ticker == "AAPL" + assert open_theses[0].conviction == 8 + assert open_theses[0].target_price == 200.0 + + fact_store.close() + + async def test_supersession_on_second_analysis(self) -> None: + """A second thesis for the same ticker should supersede the first.""" + from qracer.memory.fact_store import FactStore + from qracer.models.base import TradeThesis + + fact_store = FactStore() + intent_resp = json.dumps({"intent": "event_analysis", "tickers": ["AAPL"]}) + llm = _mock_llm_registry({Role.RESEARCHER: intent_resp, Role.STRATEGIST: "R"}) + engine = ConversationEngine(llm, DataRegistry(), fact_store=fact_store) + + for target in (200.0, 220.0): + thesis = TradeThesis( + ticker="AAPL", + entry_zone=(170.0, 175.0), + target_price=target, + stop_loss=160.0, + risk_reward_ratio=2.0, + catalyst="AI", + catalyst_date=None, + conviction=8, + summary="thesis", + ) + analysis = AnalysisResult(results=[], confidence=0.9, iterations=1, trade_thesis=thesis) + with patch.object(engine._standard_handler, "handle", new=AsyncMock()) as mh: + from qracer.conversation.handlers import HandlerResult + + mh.return_value = HandlerResult(text="R", analysis=analysis) + await engine.query("AAPL") + + open_theses = fact_store.get_open_theses(["AAPL"]) + assert len(open_theses) == 1 + assert open_theses[0].target_price == 220.0 + + fact_store.close() + + async def test_no_crash_without_fact_store(self) -> None: + """Engine without a fact_store should work normally.""" + intent_resp = json.dumps({"intent": "price_check", "tickers": ["AAPL"]}) + llm = _mock_llm_registry({Role.RESEARCHER: intent_resp}) + engine = ConversationEngine(llm, DataRegistry()) + + with patch("qracer.conversation.handlers.invoke_tools") as mock_invoke: + mock_invoke.return_value = [_ok_result("price_event")] + response = await engine.query("AAPL price") + + assert response.text # Should produce a response without crashing diff --git a/tests/memory/test_fact_store.py b/tests/memory/test_fact_store.py new file mode 100644 index 0000000..ad2312b --- /dev/null +++ b/tests/memory/test_fact_store.py @@ -0,0 +1,185 @@ +"""Tests for FactStore — structured fact persistence.""" + +from __future__ import annotations + +from collections.abc import Iterator +from datetime import datetime, timedelta + +import pytest + +from qracer.memory.fact_models import ThesisStatus +from qracer.memory.fact_store import FactStore, _parse_catalyst_date +from qracer.models.base import TradeThesis + + +def _make_thesis( + ticker: str = "AAPL", + entry_zone: tuple[float, float] = (170.0, 175.0), + target_price: float = 200.0, + stop_loss: float = 160.0, + conviction: int = 8, + catalyst: str = "Q2 earnings beat", + catalyst_date: str | None = None, +) -> TradeThesis: + return TradeThesis( + ticker=ticker, + entry_zone=entry_zone, + target_price=target_price, + stop_loss=stop_loss, + risk_reward_ratio=(target_price - 172.5) / (172.5 - stop_loss), + catalyst=catalyst, + catalyst_date=catalyst_date, + conviction=conviction, + summary=f"Long {ticker} thesis", + ) + + +@pytest.fixture +def fact_store() -> Iterator[FactStore]: + store = FactStore() # in-memory DuckDB + yield store + store.close() + + +# --------------------------------------------------------------------------- +# Thesis CRUD +# --------------------------------------------------------------------------- + + +class TestThesisCRUD: + def test_save_and_get_open_thesis(self, fact_store: FactStore) -> None: + thesis = _make_thesis() + thesis_id = fact_store.save_thesis(thesis, session_id="sess_001") + + assert thesis_id >= 1 + open_theses = fact_store.get_open_theses(["AAPL"]) + assert len(open_theses) == 1 + t = open_theses[0] + assert t.ticker == "AAPL" + assert t.entry_zone_low == 170.0 + assert t.entry_zone_high == 175.0 + assert t.target_price == 200.0 + assert t.stop_loss == 160.0 + assert t.conviction == 8 + assert t.status == ThesisStatus.OPEN + assert t.session_id == "sess_001" + + def test_supersession(self, fact_store: FactStore) -> None: + """Saving a new thesis for the same ticker supersedes the old one.""" + old_id = fact_store.save_thesis(_make_thesis(), session_id="sess_001") + new_id = fact_store.save_thesis( + _make_thesis(target_price=220.0, conviction=9), + session_id="sess_002", + ) + + # Only the new thesis is open. + open_theses = fact_store.get_open_theses(["AAPL"]) + assert len(open_theses) == 1 + assert open_theses[0].id == new_id + assert open_theses[0].target_price == 220.0 + + # Old thesis is superseded. + history = fact_store.get_thesis_history("AAPL") + old = [t for t in history if t.id == old_id][0] + assert old.status == ThesisStatus.SUPERSEDED + assert old.superseded_by == new_id + + def test_get_open_theses_filters_by_ticker(self, fact_store: FactStore) -> None: + fact_store.save_thesis(_make_thesis("AAPL"), session_id="s1") + fact_store.save_thesis(_make_thesis("MSFT"), session_id="s1") + fact_store.save_thesis(_make_thesis("TSLA"), session_id="s1") + + result = fact_store.get_open_theses(["AAPL", "TSLA"]) + tickers = {t.ticker for t in result} + assert tickers == {"AAPL", "TSLA"} + + def test_get_open_theses_all(self, fact_store: FactStore) -> None: + """get_open_theses() with no filter returns all open theses.""" + fact_store.save_thesis(_make_thesis("AAPL"), session_id="s1") + fact_store.save_thesis(_make_thesis("MSFT"), session_id="s1") + + result = fact_store.get_open_theses() + assert len(result) == 2 + + def test_get_open_theses_excludes_closed(self, fact_store: FactStore) -> None: + tid = fact_store.save_thesis(_make_thesis(), session_id="s1") + fact_store.update_thesis_status(tid, ThesisStatus.CLOSED) + + assert fact_store.get_open_theses(["AAPL"]) == [] + + def test_update_thesis_status(self, fact_store: FactStore) -> None: + tid = fact_store.save_thesis(_make_thesis(), session_id="s1") + fact_store.update_thesis_status(tid, ThesisStatus.INVALIDATED) + + history = fact_store.get_thesis_history("AAPL") + assert history[0].status == ThesisStatus.INVALIDATED + + def test_get_thesis_history_ordered_desc(self, fact_store: FactStore) -> None: + fact_store.save_thesis(_make_thesis(conviction=5), session_id="s1") + fact_store.save_thesis(_make_thesis(conviction=9), session_id="s2") + + history = fact_store.get_thesis_history("AAPL") + assert len(history) == 2 + # Most recent first (conviction 9 was inserted second). + assert history[0].conviction == 9 + assert history[1].conviction == 5 + + def test_different_tickers_not_superseded(self, fact_store: FactStore) -> None: + """Theses for different tickers are independent.""" + fact_store.save_thesis(_make_thesis("AAPL"), session_id="s1") + fact_store.save_thesis(_make_thesis("MSFT"), session_id="s1") + + aapl = fact_store.get_open_theses(["AAPL"]) + msft = fact_store.get_open_theses(["MSFT"]) + assert len(aapl) == 1 + assert len(msft) == 1 + + +# --------------------------------------------------------------------------- +# Catalyst date parsing +# --------------------------------------------------------------------------- + + +class TestCatalystDateParsing: + def test_iso_date(self) -> None: + assert _parse_catalyst_date("2026-05-01") == datetime(2026, 5, 1) + + def test_quarter_notation(self) -> None: + assert _parse_catalyst_date("Q2 2026") == datetime(2026, 4, 1) + assert _parse_catalyst_date("Q1 2026") == datetime(2026, 1, 1) + assert _parse_catalyst_date("Q3 2026") == datetime(2026, 7, 1) + assert _parse_catalyst_date("Q4 2026") == datetime(2026, 10, 1) + + def test_year_month(self) -> None: + assert _parse_catalyst_date("2026-05") == datetime(2026, 5, 1) + + def test_none_input(self) -> None: + assert _parse_catalyst_date(None) is None + + def test_unparseable(self) -> None: + assert _parse_catalyst_date("sometime next year") is None + + def test_get_upcoming_catalysts(self, fact_store: FactStore) -> None: + tomorrow = (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%d") + far_future = (datetime.now() + timedelta(days=365)).strftime("%Y-%m-%d") + + fact_store.save_thesis(_make_thesis("NEAR", catalyst_date=tomorrow), session_id="s1") + fact_store.save_thesis(_make_thesis("FAR", catalyst_date=far_future), session_id="s1") + fact_store.save_thesis(_make_thesis("NONE", catalyst_date=None), session_id="s1") + + upcoming = fact_store.get_upcoming_catalysts(days_ahead=14) + assert len(upcoming) == 1 + assert upcoming[0].ticker == "NEAR" + + +# --------------------------------------------------------------------------- +# Context manager +# --------------------------------------------------------------------------- + + +class TestContextManager: + def test_context_manager_protocol(self) -> None: + with FactStore() as store: + store.save_thesis(_make_thesis(), session_id="s1") + assert len(store.get_open_theses()) == 1 + # Connection closed after __exit__, no assertion needed — just no crash.