Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ htmlcov/
# Session memory (runtime data, not source code)
memory/
!src/qracer/memory/
!qracer/memory/
!tests/memory/

# IDE
Expand Down
11 changes: 11 additions & 0 deletions qracer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")

Expand All @@ -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)
Expand All @@ -969,6 +979,7 @@ def repl() -> None:
data_registry=data_registry,
sessions_dir=sessions_dir,
current_session=session_logger.path,
fact_store=fact_store,
)
)

Expand Down
23 changes: 21 additions & 2 deletions qracer/conversation/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -157,6 +165,7 @@ def update_registries(
synthesizer,
self._portfolio_config,
self._memory_searcher,
fact_store=self._fact_store,
)
self._config_version += 1

Expand Down Expand Up @@ -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)
59 changes: 59 additions & 0 deletions qracer/conversation/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
)
Expand Down Expand Up @@ -154,20 +164,36 @@ 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
self._analysis_loop = analysis_loop
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.
initial_results = await invoke_tools(
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(
Expand Down Expand Up @@ -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
31 changes: 31 additions & 0 deletions qracer/conversation/quickpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions qracer/memory/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
72 changes: 72 additions & 0 deletions qracer/memory/fact_models.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading