Ready for first real test
This commit is contained in:
1 parent
acddf9135f
commit
b91c81ebdf
46 files changed
+34903
-26
No files matched your search
@@ -0,0 +1,152 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user