153 lines
6.2 KiB
Python
153 lines
6.2 KiB
Python
"""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/<trade_date>/.
|
|
"""
|
|
|
|
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. ): {', '.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"
|
|
"# <Analyst name> — Research Note (<date>)\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><head><style>body{font-family:Helvetica,Arial,sans-serif;font-size:11px;} "
|
|
"h1{font-size:20px;} h2{font-size:15px;border-bottom:1px solid #ccc;} h3{font-size:13px;} "
|
|
"table{border-collapse:collapse;} td,th{border:1px solid #999;padding:3px;} "
|
|
f"img{{max-width:520px;}}</style></head><body>{html_body}</body></html>"
|
|
)
|
|
|
|
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
|