"""Analyst report generation. Turns an agent's picks + research trace into a professional Markdown research note (with any charts the agent generated via run_analysis embedded), a combined daily report, and PDF exports. Stored under state/reports//. """ from __future__ import annotations import re import shutil from pathlib import Path from loguru import logger from langchain_core.messages import HumanMessage, SystemMessage from agents.schemas import AgentOutput from config.settings import get_settings from llm.client import build_llm _CHART_RE = re.compile(r"([^\s,]+\.png)") REPORT_SYSTEM = ( "You are a senior sell-side equity research analyst. Write a clear, professional research " "note in GitHub-flavored Markdown. Be specific and data-driven; cite the tickers, prices, and " "numbers from the research. Do not invent data you weren't given. Output ONLY Markdown." ) def _reports_day_dir(trade_date: str) -> Path: d = get_settings().reports_dir / trade_date d.mkdir(parents=True, exist_ok=True) return d def _collect_charts(out: AgentOutput, dest_dir: Path) -> list[str]: """Find chart PNGs the agent made via run_analysis (logged in reasoning_trace), copy them next to the report, and return the local filenames to embed.""" names: list[str] = [] seen: set[str] = set() for line in out.reasoning_trace: if "Artifacts written" not in line: continue for m in _CHART_RE.findall(line): src = Path(m) if src.suffix.lower() == ".png" and src.exists() and str(src) not in seen: seen.add(str(src)) fname = f"{out.persona_id}_{len(names) + 1}.png" try: shutil.copyfile(src, dest_dir / fname) names.append(fname) except Exception: pass return names def write_agent_report(out: AgentOutput, trade_date: str) -> Path: """Generate + save one agent's Markdown research note. Returns the .md path.""" day_dir = _reports_day_dir(trade_date) charts = _collect_charts(out, day_dir) picks_json = "\n".join( f"- {p.ticker} [{p.instrument.value}/{p.direction.value}] conv={p.conviction} " f"weight={p.requested_weight_pct} stop={p.stop_loss_pct} take={p.take_profit_pct} :: {p.thesis}" for p in out.picks ) or "(no picks)" findings = "\n".join(out.reasoning_trace[-60:]) chart_note = ( "Embed these charts where relevant using Markdown image syntax " f"(e.g. ![]({charts[0]})): {', '.join(charts)}" if charts else "No charts were generated." ) human = ( f"Trade date: {trade_date}. Analyst persona: {out.agent_name}.\n" f"Status: {out.status.value}. Market summary: {out.market_summary or '(none)'}\n\n" f"FINAL PICKS:\n{picks_json}\n\n" f"RESEARCH TRACE (tool calls + findings):\n{findings}\n\n" f"{chart_note}\n\n" "Write the note with these sections:\n" "# — Research Note ()\n" "## Market Overview (what regime/backdrop framed today's work)\n" "## Picks — one `###` per pick: direction+conviction, thesis, supporting data/valuation, " "catalysts, key risks, and the entry/stop/take plan.\n" "## Portfolio Notes (how these fit existing holdings)\n" "## Summary Table (Markdown table: Ticker | Dir | Conv | Weight | Stop | Take | One-liner)" ) try: resp = build_llm(max_tokens=4096).invoke( [SystemMessage(content=REPORT_SYSTEM), HumanMessage(content=human)] ) md = resp.content if isinstance(resp.content, str) else str(resp.content) if not md.strip(): raise ValueError("empty report content") except Exception as e: logger.warning("report gen failed for {}: {}", out.persona_id, e) md = f"# {out.agent_name} — Research Note ({trade_date})\n\n(Report generation failed: {e})\n\n{picks_json}" md_path = day_dir / f"{out.persona_id}.md" md_path.write_text(md) logger.info("wrote report {}", md_path) return md_path def write_daily_report(trade_date: str, outputs: list[AgentOutput], exec_summary: str = "") -> Path: """Combine the agents into one daily report (links their notes + a portfolio-wide summary).""" day_dir = _reports_day_dir(trade_date) lines = [f"# Daily Research — {trade_date}\n"] if exec_summary: lines += ["## Execution Results\n", exec_summary, ""] lines.append("## Agents\n") for out in outputs: tickers = ", ".join( f"{p.ticker}({p.direction.value},{p.conviction:.2f})" for p in out.picks ) or "no picks" lines.append(f"### {out.agent_name} — *{out.status.value}*") lines.append(f"- Picks: {tickers}") lines.append(f"- Full note: [`{out.persona_id}.md`]({out.persona_id}.md)\n") path = day_dir / "daily.md" path.write_text("\n".join(lines)) logger.info("wrote daily report {}", path) return path def to_pdf(md_path: Path) -> Path | None: """Render a Markdown report to PDF (embeds relative-path charts). None on failure.""" try: import markdown as md_lib from xhtml2pdf import pisa except Exception as e: logger.warning("PDF deps missing: {}", e) return None md_path = Path(md_path) base = md_path.parent html_body = md_lib.markdown(md_path.read_text(), extensions=["tables", "fenced_code"]) html = ( "{html_body}" ) def _link_cb(uri: str, rel: str) -> str: p = base / uri return str(p) if p.exists() else uri pdf_path = md_path.with_suffix(".pdf") try: with open(pdf_path, "wb") as f: pisa.CreatePDF(html, dest=f, link_callback=_link_cb) return pdf_path except Exception as e: logger.warning("PDF render failed for {}: {}", md_path, e) return None