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
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ finnhub = ["finnhub-python>=2.4.0"]
fred = ["fredapi>=0.5.0"]
all-llm = ["openai>=1.0.0", "google-generativeai>=0.8.0"]
web = ["fastapi>=0.104.0", "uvicorn>=0.24.0"]
pdf = ["fpdf2>=2.7.0"]

[dependency-groups]
dev = [
Expand All @@ -29,6 +30,7 @@ dev = [
"pytest-asyncio>=0.24.0",
"pytest-cov>=5.0.0",
"httpx>=0.27.0",
"fpdf2>=2.7.0",
]

[project.scripts]
Expand Down
15 changes: 14 additions & 1 deletion qracer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
║ qracer — conversational alpha engine ║
╚══════════════════════════════════════════╝
Type your query, or 'quit' to exit.
Commands: save, save json, backtest, help
Commands: save, save json, save pdf, backtest, help
"""


Expand Down Expand Up @@ -350,6 +350,7 @@ def _build_registries() -> tuple[LLMRegistry, DataRegistry, list[str]]:
Available commands:
save Save last analysis as Markdown
save json Save last analysis as JSON
save pdf Save last analysis as PDF (requires qracer[pdf] extra)
backtest Backtest the last trade thesis against historical data
watchlist Show watchlist with current prices
watch TICKER Add ticker to watchlist
Expand Down Expand Up @@ -500,6 +501,18 @@ async def _repl_loop(
click.echo("No analysis to save. Run a query first.\n")
continue

if cmd in ("save pdf", "/save pdf"):
try:
path = engine.save_last_report(fmt="pdf") # type: ignore[attr-defined]
except ImportError as exc:
click.echo(f"{exc}\n")
continue
if path:
click.echo(f"Saved to {path}\n")
else:
click.echo("No analysis to save. Run a query first.\n")
continue

# Watchlist commands
if cmd in ("watchlist", "wl", "/watchlist"):
_show_watchlist(watchlist) # type: ignore[arg-type]
Expand Down
4 changes: 3 additions & 1 deletion qracer/conversation/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ def save_last_report(self, fmt: str = "md") -> Path | None:
"""Save the last analysis result as a report file.

Args:
fmt: ``"md"`` for Markdown, ``"json"`` for JSON.
fmt: ``"md"`` for Markdown, ``"json"`` for JSON, ``"pdf"`` for PDF.

Returns:
Path to the saved file, or None if no report exporter is
Expand All @@ -180,6 +180,8 @@ def save_last_report(self, fmt: str = "md") -> Path | None:
resp = self._last_response
if fmt == "json":
return self._report_exporter.save_json(resp.intent, resp.analysis, resp.text)
if fmt == "pdf":
return self._report_exporter.save_pdf(resp.intent, resp.analysis, resp.text)
return self._report_exporter.save_markdown(resp.intent, resp.analysis, resp.text)

def _log_turn(self, role: str, content: str, **kwargs: object) -> None:
Expand Down
100 changes: 98 additions & 2 deletions qracer/conversation/report_exporter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""ReportExporter — saves analysis results to Markdown and JSON files.
"""ReportExporter — saves analysis results to Markdown, JSON, and PDF files.

Reports are stored in ``~/.qracer/reports/`` with filenames based on
the primary ticker and date.
Expand All @@ -18,7 +18,7 @@


class ReportExporter:
"""Exports analysis results to Markdown and/or JSON files.
"""Exports analysis results to Markdown, JSON, and/or PDF files.

Usage::

Expand Down Expand Up @@ -155,3 +155,99 @@ def save_json(
path.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
logger.info("Report saved: %s", path)
return path

def save_pdf(
self,
intent: Intent,
analysis: AnalysisResult,
response_text: str,
) -> Path:
"""Save the analysis as a formatted PDF report.

Requires the optional ``fpdf2`` dependency — install it with
``pip install qracer[pdf]`` (or ``uv sync --extra pdf``).

Returns the path to the saved file.
"""
try:
from fpdf import FPDF
except ImportError as exc: # pragma: no cover - import guard
raise ImportError(
"PDF export requires the optional 'fpdf2' dependency. "
"Install it with: pip install 'qracer[pdf]'"
) from exc

ticker = intent.tickers[0] if intent.tickers else "general"
today = datetime.now().strftime("%Y-%m-%d")

pdf = FPDF()
pdf.add_page()
pdf.set_auto_page_break(auto=True, margin=15)

# Header
pdf.set_font("Helvetica", "B", 16)
pdf.cell(0, 10, f"qracer Analysis: {ticker}", new_x="LMARGIN", new_y="NEXT", align="C")
pdf.set_font("Helvetica", "", 10)
pdf.cell(
0,
6,
f"Generated: {today} | Confidence: {analysis.confidence:.2f}",
new_x="LMARGIN",
new_y="NEXT",
align="C",
)
pdf.ln(6)

# Query metadata
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "Query", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 11)
pdf.multi_cell(0, 6, intent.raw_query)
pdf.ln(2)

# Response body
pdf.set_font("Helvetica", "B", 12)
pdf.cell(0, 8, "Response", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 11)
pdf.multi_cell(0, 6, response_text)

# Trade thesis section (if present)
if analysis.trade_thesis is not None:
t = analysis.trade_thesis
pdf.ln(4)
pdf.set_font("Helvetica", "B", 13)
pdf.cell(0, 10, "Trade Thesis", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 11)
thesis_lines = [
f"Ticker: {t.ticker}",
f"Entry Zone: ${t.entry_zone[0]:.2f} - ${t.entry_zone[1]:.2f}",
f"Target: ${t.target_price:.2f} | Stop: ${t.stop_loss:.2f}",
f"Risk/Reward: {t.risk_reward_ratio:.2f}x",
f"Conviction: {t.conviction}/10",
f"Catalyst: {t.catalyst}",
]
if t.catalyst_date:
thesis_lines.append(f"Catalyst Date: {t.catalyst_date}")
thesis_lines.extend(["", t.summary])
pdf.multi_cell(0, 6, "\n".join(thesis_lines))

# Data sources (successful tools only, matching Markdown output)
tools_used = [r for r in analysis.results if r.success]
if tools_used:
pdf.ln(4)
pdf.set_font("Helvetica", "B", 13)
pdf.cell(0, 10, "Data Sources", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 10)
for r in tools_used:
pdf.cell(
0,
5,
f" - [{r.tool}] {r.source}",
new_x="LMARGIN",
new_y="NEXT",
)

path = self._build_filename(intent, ".pdf")
pdf.output(str(path))
logger.info("Report saved: %s", path)
return path
65 changes: 65 additions & 0 deletions tests/conversation/test_report_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,38 @@
from __future__ import annotations

import json
import re
import zlib

import pytest

from qracer.conversation.analysis_loop import AnalysisResult
from qracer.conversation.intent import Intent, IntentType
from qracer.conversation.report_exporter import ReportExporter
from qracer.models import ToolResult, TradeThesis


def _extract_pdf_text(pdf_bytes: bytes) -> bytes:
"""Decompress FlateDecode content streams from a PDF for text assertions.

fpdf2 zlib-compresses page streams, so raw PDF bytes don't contain the
literal strings. This walks each FlateDecode stream and returns the
concatenated decompressed content, which holds the text operators like
``(Trade Thesis) Tj``.
"""
pattern = re.compile(
rb"/Filter /FlateDecode.*?stream\n(.*?)\nendstream",
re.DOTALL,
)
chunks: list[bytes] = []
for match in pattern.finditer(pdf_bytes):
try:
chunks.append(zlib.decompress(match.group(1)))
except zlib.error:
continue
return b"\n".join(chunks)


def _intent(tickers: list[str] | None = None) -> Intent:
return Intent(
intent_type=IntentType.DEEP_DIVE,
Expand Down Expand Up @@ -104,3 +129,43 @@ def test_no_thesis_key_when_none(self, tmp_path) -> None:
path = exporter.save_json(_intent(), _analysis(with_thesis=False), "Response")
data = json.loads(path.read_text())
assert "trade_thesis" not in data


class TestReportExporterPdf:
def test_save_basic(self, tmp_path) -> None:
pytest.importorskip("fpdf")
exporter = ReportExporter(tmp_path)
path = exporter.save_pdf(_intent(), _analysis(), "Analysis text here.")
assert path.exists()
assert path.suffix == ".pdf"
# Minimum PDF signature check.
assert path.read_bytes().startswith(b"%PDF-")

def test_save_with_thesis(self, tmp_path) -> None:
pytest.importorskip("fpdf")
exporter = ReportExporter(tmp_path)
path = exporter.save_pdf(_intent(), _analysis(with_thesis=True), "Response")
text = _extract_pdf_text(path.read_bytes())
assert b"Trade Thesis" in text
assert b"AAPL" in text
assert b"Q2 2026" in text
assert b"8/10" in text

def test_save_data_sources_only_successful(self, tmp_path) -> None:
pytest.importorskip("fpdf")
exporter = ReportExporter(tmp_path)
path = exporter.save_pdf(_intent(), _analysis(), "Response")
text = _extract_pdf_text(path.read_bytes())
assert b"Data Sources" in text
assert b"price_event" in text
assert b"news" in text
# Failed tool (macro) should be excluded from the data sources list.
assert b"macro" not in text

def test_general_ticker_fallback(self, tmp_path) -> None:
pytest.importorskip("fpdf")
exporter = ReportExporter(tmp_path)
intent = Intent(intent_type=IntentType.MACRO_QUERY, tickers=[], raw_query="inflation?")
path = exporter.save_pdf(intent, _analysis(), "Response")
assert "general" in path.name
assert path.suffix == ".pdf"
Loading
Loading