rl-trading-agent / ai_analysis.py
nirmanpatel's picture
Update ai_analysis.py
84aabd2 verified
Raw
History Blame Contribute Delete
29.1 kB
import json
import os
import re
from dotenv import load_dotenv
# Load environment variables from .venv file
load_dotenv(".venv")
# ── Gemini via REST (no SDK) ───────────────────────────────
try:
import requests as _requests
GEMINI_AVAILABLE = True
except ImportError:
_requests = None
GEMINI_AVAILABLE = False
GEMINI_MODEL = "gemini-2.5-flash"
GEMINI_URL = (
"https://generativelanguage.googleapis.com/v1beta/models/"
"{model}:generateContent"
)
# Injected into the prompt so the model can give specific, grounded explanations rather than generic RL platitudes.
TICKER_CONTEXT = {
"NVDA": (
"NVDA experienced an extreme bull run in 2023–2024 driven by "
"AI infrastructure demand. Its price appreciation exceeded 200% "
"during this period — a regime completely unlike the 2015–2022 "
"training window where NVDA was a volatile but range-bound "
"semiconductor stock."
),
"GOOGL": (
"GOOGL underwent a post-pandemic valuation reset followed by a "
"strong recovery in 2023–2024. The test period coincided with "
"renewed AI optimism around Google DeepMind and Gemini, which "
"created momentum patterns different from the training window."
),
"AAPL": (
"AAPL exhibited a selective, patient strategy with very few trades "
"and long average hold durations (~145 days). The test period "
"included Apple's Vision Pro launch and continued services growth, "
"creating a steady uptrend that rewarded holding."
),
"MSFT": (
"MSFT was the strongest performer. The test period coincided with "
"Azure AI growth and the OpenAI partnership driving consistent "
"institutional buying. The agent learned a moderate-frequency "
"trading strategy that captured most of this uptrend."
),
"AMZN": (
"AMZN recovered strongly in 2023–2024 after a severe 2022 drawdown. "
"The agent learned an aggressive strategy with high trade frequency "
"and 60% position sizing, effectively catching the recovery wave."
),
}
# ── Training period context ────────────────────────────────────
TRAINING_CONTEXT = (
"The PPO agents were trained on data from 2015 to mid 2026 using "
"a custom Gymnasium environment with a 30-day lookback window, "
"MultiDiscrete action space (direction × size), and a Differential "
"Sharpe Ratio reward function with idle and invalid action penalties. "
"Hyperparameters were tuned via Optuna TPE with 30 trials per ticker, "
"optimising validation Sharpe Ratio."
)
def build_analysis_prompt(
ticker: str,
kpis_rl: dict,
kpis_bnh: dict,
kpis_sma: dict,
actions: list,
trades: int,
period: str,
) -> str:
# Action breakdown percentages
total = len(actions) if actions else 1
hold_pct = actions.count(0) / total * 100 if actions else 0
buy_pct = actions.count(1) / total * 100 if actions else 0
sell_pct = actions.count(2) / total * 100 if actions else 0
ticker_ctx = TICKER_CONTEXT.get(ticker, "No specific context available.")
prompt = f"""
You are a quantitative finance analyst reviewing the performance of a
Reinforcement Learning trading agent (PPO algorithm) on {ticker} stock.
=== TRAINING CONTEXT ===
{TRAINING_CONTEXT}
=== TICKER-SPECIFIC CONTEXT ===
{ticker_ctx}
=== BACKTEST RESULTS ({period} live data) ===
RL Agent (PPO):
Final Value : {kpis_rl.get("Final Value", "N/A")}
Total Return : {kpis_rl.get("Total Return", "N/A")}
Ann. Return : {kpis_rl.get("Ann. Return", "N/A")}
Ann. Volatility : {kpis_rl.get("Ann. Volatility", "N/A")}
Sharpe Ratio : {kpis_rl.get("Sharpe Ratio", "N/A")}
Sortino Ratio : {kpis_rl.get("Sortino Ratio", "N/A")}
Max Drawdown : {kpis_rl.get("Max Drawdown", "N/A")}
Calmar Ratio : {kpis_rl.get("Calmar Ratio", "N/A")}
Total Trades : {trades}
Action Breakdown : Hold {hold_pct:.1f}% | Buy {buy_pct:.1f}% | Sell {sell_pct:.1f}%
Buy & Hold:
Total Return : {kpis_bnh.get("Total Return", "N/A")}
Sharpe Ratio : {kpis_bnh.get("Sharpe Ratio", "N/A")}
Max Drawdown : {kpis_bnh.get("Max Drawdown", "N/A")}
SMA Crossover:
Total Return : {kpis_sma.get("Total Return", "N/A")}
Sharpe Ratio : {kpis_sma.get("Sharpe Ratio", "N/A")}
Max Drawdown : {kpis_sma.get("Max Drawdown", "N/A")}
=== YOUR TASK ===
Provide a structured analysis in EXACTLY this JSON format.
Return ONLY the JSON object, no markdown, no preamble:
{{
"summary": "2-3 sentence executive summary of overall performance",
"strengths": [
"Specific strength 1 with numbers from the results",
"Specific strength 2",
"Specific strength 3"
],
"weaknesses": [
"Specific weakness 1 — diagnose the root cause",
"Specific weakness 2",
"Specific weakness 3"
],
"failure_diagnosis": "1-2 paragraphs specifically explaining WHY the agent performed the way it did on {ticker}. Reference the training period, regime change, reward function, action space, or hyperparameters as appropriate. Be precise and technical.",
"improvements": [
"Concrete improvement 1 with implementation detail",
"Concrete improvement 2",
"Concrete improvement 3",
"Concrete improvement 4"
],
"verdict": "Final verdict in this exact form: start with a letter grade (A/B/C/D/F) based on actual performance, then state the alpha vs Buy & Hold and the Sharpe with real numbers, then a clear deployment call. Grade A = clearly beat B&H with Sharpe >= 1; B = small positive edge; C = roughly matched B&H; D = underperformed; F = never traded. Be specific and non-cliché — no 'shows promise but needs tuning'."
}}
"""
return prompt.strip()
def get_ai_analysis(
ticker: str,
kpis_rl: dict,
kpis_bnh: dict,
kpis_sma: dict,
actions: list,
trades: int,
period: str,
) -> dict:
if _requests is None:
return _fallback_analysis(ticker, kpis_rl, kpis_bnh, trades)
try:
api_key = os.getenv("GEMINI_API_KEY")
if not api_key:
return _fallback_analysis(
ticker,
kpis_rl,
kpis_bnh,
trades,
note="GEMINI_API_KEY not found in environment.",
)
prompt = build_analysis_prompt(
ticker, kpis_rl, kpis_bnh, kpis_sma, actions, trades, period
)
# ── Gemini REST call (no SDK, no websockets dependency) ───────
url = GEMINI_URL.format(model=GEMINI_MODEL)
resp = _requests.post(
url,
headers={"Content-Type": "application/json"},
params={"key": api_key},
json={"contents": [{"parts": [{"text": prompt}]}]},
timeout=30,
)
# Handle HTTP errors WITHOUT raise_for_status: its message embeds the
# full request URL, which includes ?key=... and would leak the API key.
if resp.status_code != 200:
msg = {
429: "rate limit reached — try again shortly",
503: "service temporarily unavailable — try again shortly",
500: "service error — try again shortly",
403: "request rejected (check API key configuration)",
400: "bad request",
}.get(resp.status_code, f"HTTP {resp.status_code}")
return _fallback_analysis(
ticker, kpis_rl, kpis_bnh, trades,
note=f"AI analysis unavailable: {msg}.",
)
data = resp.json()
# Extract text from the first candidate's first part.
raw_text = ""
try:
raw_text = data["candidates"][0]["content"]["parts"][0]["text"]
except (KeyError, IndexError, TypeError):
raw_text = ""
if not raw_text:
raise ValueError(
"Gemini returned an empty response. This usually happens due "
"to safety filters or an invalid API key."
)
raw_text = raw_text.strip()
# Strip markdown code fences if present
raw_text = re.sub(r"^```(?:json)?", "", raw_text).strip()
raw_text = re.sub(r"```$", "", raw_text).strip()
analysis = json.loads(raw_text)
# Ensure a grade exists for the medallion, derived from real KPIs,
# so the AI path renders the same performance-graded verdict UI.
if "grade" not in analysis:
analysis["grade"] = _compute_grade(kpis_rl, kpis_bnh, trades)
analysis["score"] = _compute_score(kpis_rl, kpis_bnh, trades)
return analysis
except json.JSONDecodeError:
# Response was not valid JSON — extract what we can
return _fallback_analysis(
ticker, kpis_rl, kpis_bnh, trades, note="AI response could not be parsed."
)
except Exception as e:
# Defensive: scrub anything that looks like an API key or the request
# URL from the exception text before showing it, so a key can never
# leak into the UI even from an unexpected error path.
safe = re.sub(r"key=[\w\-]+", "key=***", str(e))
safe = re.sub(r"https?://\S+", "[endpoint]", safe)
# Keep it short and generic
safe = safe[:120]
return _fallback_analysis(
ticker, kpis_rl, kpis_bnh, trades,
note=f"AI analysis unavailable: {safe}",
)
def _compute_score(kpis_rl: dict, kpis_bnh: dict, trades: int) -> int:
def parse_pct(v):
try:
return float(str(v).replace("%", "").replace("+", "").strip())
except Exception:
return 0.0
rl_ret = parse_pct(kpis_rl.get("Total Return", "0%"))
bnh_ret = parse_pct(kpis_bnh.get("Total Return", "0%"))
rl_sharpe = parse_pct(kpis_rl.get("Sharpe Ratio", "0"))
alpha = rl_ret - bnh_ret
if trades == 0:
return 0
# Alpha component: 0 at -20%, 50 at +20% (capped)
alpha_score = max(0.0, min(50.0, (alpha + 20.0) / 40.0 * 50.0))
# Sharpe component: 0 at <=0, 40 at >=2.0 (capped)
sharpe_score = max(0.0, min(40.0, rl_sharpe / 2.0 * 40.0))
# Participation: up to 10 for a sane (non-degenerate) trade count
part_score = 10.0 if 2 <= trades <= 80 else (5.0 if trades > 0 else 0.0)
return int(round(alpha_score + sharpe_score + part_score))
def _compute_grade(kpis_rl: dict, kpis_bnh: dict, trades: int) -> str:
"""Grade the agent A–F from real KPIs. Single source of truth for both
the AI and fallback paths."""
def parse_pct(v):
try:
return float(str(v).replace("%", "").replace("+", "").strip())
except Exception:
return 0.0
rl_ret = parse_pct(kpis_rl.get("Total Return", "0%"))
bnh_ret = parse_pct(kpis_bnh.get("Total Return", "0%"))
rl_sharpe = parse_pct(kpis_rl.get("Sharpe Ratio", "0"))
alpha = rl_ret - bnh_ret
if trades == 0:
return "F"
if alpha >= 5.0 and rl_sharpe >= 1.0:
return "A"
if alpha >= 0.0 and rl_sharpe >= 0.5:
return "B"
if alpha >= -5.0:
return "C"
return "D"
def _fallback_analysis(
ticker: str,
kpis_rl: dict,
kpis_bnh: dict,
trades: int,
note: str = "",
) -> dict:
def parse_pct(v):
try:
return float(str(v).replace("%", "").replace("+", "").strip())
except Exception:
return 0.0
rl_ret = parse_pct(kpis_rl.get("Total Return", "0%"))
bnh_ret = parse_pct(kpis_bnh.get("Total Return", "0%"))
rl_sharpe = parse_pct(kpis_rl.get("Sharpe Ratio", "0"))
rl_dd = parse_pct(kpis_rl.get("Max Drawdown", "0%"))
outperform = rl_ret > bnh_ret
ticker_ctx = TICKER_CONTEXT.get(ticker, "")
summary = (
f"The PPO agent on {ticker} achieved a total return of "
f"{kpis_rl.get('Total Return', 'N/A')} vs Buy & Hold's "
f"{kpis_bnh.get('Total Return', 'N/A')} over the evaluation period. "
f"{'The agent outperformed the passive benchmark.' if outperform else 'The agent underperformed the passive benchmark.'}"
)
strengths = []
# A non-participating agent (zero trades) has no genuine strengths — its
# flat 0% drawdown and untouched capital are artifacts of inaction, not
# skill. Only credit strengths when the agent actually traded.
if trades > 0:
if trades > 20:
strengths.append(
f"Active participation: {trades} trades show the agent engaged with the market rather than ignoring it."
)
if rl_sharpe > 0.5:
strengths.append(
f"Positive Sharpe Ratio of {kpis_rl.get('Sharpe Ratio')} indicates the agent generated risk-adjusted returns above the risk-free rate."
)
if outperform:
strengths.append(
f"Beat Buy & Hold by {rl_ret - bnh_ret:.1f}% — the policy added value over passively holding the asset."
)
if rl_dd > -25:
strengths.append(
f"Controlled drawdown of {kpis_rl.get('Max Drawdown')} suggests the reward function's drawdown penalty was effective."
)
if not strengths:
if trades == 0:
strengths.append(
"None — the agent never entered the market, so there is no behaviour to credit. The flat capital and zero drawdown reflect inaction, not risk management."
)
else:
strengths.append(
"The agent traded but produced no clear edge; no individual metric stands out as a genuine strength."
)
weaknesses = []
if rl_ret < bnh_ret:
weaknesses.append(
f"Underperformed Buy & Hold by {bnh_ret - rl_ret:.1f}% — the added complexity did not translate to returns."
)
if rl_sharpe < 0:
weaknesses.append(
"Negative Sharpe Ratio means the strategy's volatility was not compensated by returns."
)
if trades == 0:
weaknesses.append(
"Zero executed trades indicates a degenerate policy — the agent learned to always attempt invalid actions."
)
weaknesses.append(
"Limited out-of-sample generalisation due to the 2015–2022 training window not capturing recent market regimes."
)
failure_diagnosis = (
f"The agent's performance on {ticker} reflects a distribution shift between "
f"the training period (2015–2022) and the test period. {ticker_ctx} "
f"The PPO agent optimises for a Differential Sharpe Ratio reward which "
f"penalises volatility — in a strongly trending market this causes the agent "
f"to exit positions too early, missing sustained directional moves that a "
f"simple Buy & Hold captures fully."
)
improvements = [
"Retrain with data through 2026 to include recent market regimes in the training distribution.",
"Increase the entropy coefficient (ent_coef) to 0.05+ to prevent premature policy collapse.",
"Add a regime detection module (HMM or volatility clustering) to switch strategies dynamically.",
"Implement online learning or periodic retraining to adapt to distribution shift in production.",
]
# ── Performance-graded verdict (driven by actual numbers) ─────────
# Grade on three axes: alpha vs Buy & Hold, risk-adjusted return (Sharpe),
# and whether the agent actually traded coherently. No clichés — the
# verdict states the grade, the numbers behind it, and the deployment call.
alpha = rl_ret - bnh_ret
if trades == 0:
grade, headline, deploy = (
"F",
f"The agent never traded on {ticker} — it sat in cash for the entire window and returned {rl_ret:+.1f}% against Buy & Hold's {bnh_ret:+.1f}%.",
"Not deployable. A non-participating policy has nothing to deploy.",
)
elif alpha >= 5.0 and rl_sharpe >= 1.0:
grade, headline, deploy = (
"A",
f"The agent beat Buy & Hold by {alpha:+.1f}% on {ticker} ({rl_ret:+.1f}% vs {bnh_ret:+.1f}%) at a Sharpe of {rl_sharpe:.2f}, across {trades} trades.",
"Promising enough to paper-trade forward on unseen data before any live capital.",
)
elif alpha >= 0.0 and rl_sharpe >= 0.5:
grade, headline, deploy = (
"B",
f"The agent edged Buy & Hold by {alpha:+.1f}% on {ticker} ({rl_ret:+.1f}% vs {bnh_ret:+.1f}%) at a Sharpe of {rl_sharpe:.2f}, but the margin is thin.",
"Not ready for live capital — the edge is too small to survive real costs and slippage. Validate across more regimes first.",
)
elif alpha >= -5.0:
grade, headline, deploy = (
"C",
f"The agent tracked Buy & Hold within {abs(alpha):.1f}% on {ticker} ({rl_ret:+.1f}% vs {bnh_ret:+.1f}%) — it is effectively replicating the benchmark, not beating it.",
"Not worth deploying over simply holding the asset, which is cheaper and simpler.",
)
else:
grade, headline, deploy = (
"D",
f"The agent underperformed Buy & Hold by {abs(alpha):.1f}% on {ticker} ({rl_ret:+.1f}% vs {bnh_ret:+.1f}%) at a Sharpe of {rl_sharpe:.2f}.",
"Do not deploy. The added complexity actively cost return versus just holding.",
)
verdict = f"{headline} {deploy}"
result = {
"summary": summary,
"strengths": strengths,
"weaknesses": weaknesses,
"failure_diagnosis": failure_diagnosis,
"improvements": improvements,
"verdict": verdict,
"grade": grade,
"score": _compute_score(kpis_rl, kpis_bnh, trades),
}
if note:
result["note"] = note
return result
def format_analysis_as_html(analysis: dict, ticker: str, theme: dict) -> str:
# ── Claude identity palette (warm, editorial) ─────────────────────
ink = "#1F1E1C" # near-black warm ink
muted = "#6E6B65" # secondary text
clay = "#C96442" # the one bold accent — Claude's signature rust
ivory = "#FAF9F5" # page surface
cream = "#F0EEE6" # card surface
line = "#E3DFD3" # hairline warm border
on_clay = "#FFFFFF"
grn = "#3D8C5F" # desaturated to sit in the warm palette
amb = "#C7901F"
red = "#BC4633"
# Map legacy names used below
primary, secondary, tertiary = ink, muted, clay
neutral, surface, on_primary = ivory, cream, on_clay
SERIF = "'IBM Plex Sans',sans-serif"
SANS = "'IBM Plex Sans',sans-serif"
MONO = "'IBM Plex Mono',monospace"
# Performance score 0–100 → accent color + label
score = int(analysis.get("score", 0))
if score >= 75:
grade_color, grade_label = grn, "Strong"
elif score >= 55:
grade_color, grade_label = grn, "Marginal edge"
elif score >= 40:
grade_color, grade_label = amb, "Matches benchmark"
elif score > 0:
grade_color, grade_label = red, "Underperforms"
else:
grade_color, grade_label = red, "No participation"
def md(t: str) -> str:
if not isinstance(t, str):
return t
t = re.sub(r"\*\*(.*?)\*\*", r"<b>\1</b>", t)
t = re.sub(r"\*(.*?)\*", r"<i>\1</i>", t)
t = re.sub(
r"`(.*?)`",
f"<code style=\"background:rgba(201,100,66,0.08);padding:2px 6px;border-radius:4px;font-family:{MONO};font-size:0.88em;color:{clay}\">\\1</code>",
t,
)
return t
def icon(name: str) -> str:
return {
"cpu": "◆", "calendar": "", "message-square": "",
"trending-up": "↑", "alert-triangle": "△", "bug": "◈",
"flowchart": "", "target": "", "chevron-right": "→",
"check": "✓", "x": "✕",
}.get(name, "")
def pattern(kind: str, color: str) -> str:
"""Light geometric SVG pattern, absolutely positioned, very low opacity.
Sits behind card content as a subtle texture."""
pid = f"p{kind}{abs(hash(kind+color))%9999}"
defs = {
"grid": f'<pattern id="{pid}" width="14" height="14" patternUnits="userSpaceOnUse"><path d="M14 0H0V14" fill="none" stroke="{color}" stroke-width="0.5"/></pattern>',
"dots": f'<pattern id="{pid}" width="12" height="12" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r="1" fill="{color}"/></pattern>',
"diag": f'<pattern id="{pid}" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M0 10L10 0" stroke="{color}" stroke-width="0.5"/></pattern>',
"tri": f'<pattern id="{pid}" width="16" height="16" patternUnits="userSpaceOnUse"><path d="M8 2L14 13H2Z" fill="none" stroke="{color}" stroke-width="0.5"/></pattern>',
}.get(kind, "")
return (
f'<svg width="100%" height="100%" style="position:absolute;inset:0;'
f'opacity:0.06;pointer-events:none" aria-hidden="true">'
f'<defs>{defs}</defs><rect width="100%" height="100%" fill="url(#{pid})"/></svg>'
)
def eyebrow(text: str, color: str) -> str:
return (
f"<p style=\"font-family:{MONO};font-size:10px;color:{color};"
f"text-transform:uppercase;letter-spacing:0.14em;margin:0 0 12px;"
f"font-weight:500;position:relative\">{text}</p>"
)
def card(
title: str, content: str, icon_name: str, color: str, subtitle: str = ""
) -> str:
return f"""<div style="
position:relative;overflow:hidden;
background:{cream};
border:1px solid {line};
border-radius:0;
padding:20px 22px;
margin-bottom:16px;
">
{pattern("grid", color)}
<div style="position:relative">
{eyebrow(title, color)}
{content}
{"<p style=\"font-family:" + MONO + ";font-size:10px;color:" + muted + ';margin-top:10px">' + subtitle + "</p>" if subtitle else ""}
</div>
</div>"""
def get_icon_name(color: str) -> str:
if color == grn:
return "check"
elif color == amb:
return "alert-triangle"
else:
return "x"
def bullet_list(items: list, color: str) -> str:
icon_char = icon(get_icon_name(color))
lis = "".join(
f"<li style=\"margin-bottom:10px;color:{ink};padding-left:22px;position:relative;"
f"font-family:{SANS};font-size:13.5px;line-height:1.6\">"
f'<span style="position:absolute;left:0;top:1px;color:{color};font-weight:700">{icon_char}</span>'
f"{md(item)}</li>"
for item in items
)
return f'<ul style="padding-left:0;margin:0;list-style:none">{lis}</ul>'
def flow_section(title: str, items: list) -> str:
nodes = ""
for i, item in enumerate(items):
nodes += f"""<div style="display:flex;align-items:flex-start;gap:14px;margin-bottom:14px;position:relative">
<div style="background:{clay};color:{on_clay};width:26px;height:26px;border-radius:0;display:flex;align-items:center;justify-content:center;font-family:{MONO};font-size:11px;font-weight:600;flex-shrink:0">{i + 1}</div>
<div style="flex:1">
<p style="font-family:{SANS};font-size:13.5px;color:{ink};margin:2px 0 0;line-height:1.55">{md(item)}</p>
</div>
</div>"""
return f"""<div style="position:relative;overflow:hidden;background:{cream};border:1px solid {line};border-radius:0;padding:20px 22px;margin-bottom:16px">
{pattern("diag", clay)}
<div style="position:relative">
{eyebrow("How to improve it", clay)}
{nodes}
</div>
</div>"""
note_html = ""
if "note" in analysis:
note_html = f"<p style=\"font-family:{MONO};font-size:11px;color:{muted};font-style:italic;margin-top:14px\">{analysis['note']}</p>"
strengths_html = bullet_list(analysis.get("strengths", []), grn)
weaknesses_html = bullet_list(analysis.get("weaknesses", []), amb)
improvements_flow = flow_section("How to improve it", analysis.get("improvements", []))
grade_medallion = f"""
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
width:88px;height:88px;flex-shrink:0;border-radius:0;
background:{grade_color}14;border:2px solid {grade_color}">
<span style="font-family:{MONO};font-size:34px;font-weight:600;color:{grade_color};line-height:1">{score}</span>
<span style="font-family:{MONO};font-size:9px;color:{grade_color};letter-spacing:0.1em;margin-top:2px">/ 100</span>
</div>"""
html = f"""<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap">
</head>
<body style="font-family:{SANS};background:{ivory};color:{ink};margin:0;padding:0">
<div style="
background:{ivory};
border:1px solid {line};
border-radius:0;
padding:28px 30px;
margin-top:12px;
max-width:920px;
">
<!-- Header -->
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin-bottom:26px;padding-bottom:18px;border-bottom:1px solid {line}">
<div>
<p style="font-family:{MONO};font-size:10px;color:{clay};text-transform:uppercase;letter-spacing:0.16em;margin:0 0 6px;font-weight:500">Agent performance review</p>
<h1 style="font-family:{SANS};font-size:24px;font-weight:600;color:{ink};margin:0;letter-spacing:-0.01em">{ticker} <span style="color:{muted};font-weight:400">·</span> PPO trading agent</h1>
</div>
<span style="font-family:{MONO};font-size:10px;color:{muted};letter-spacing:0.05em;white-space:nowrap">Reviewed by Claude</span>
</div>
<!-- Verdict — signature: performance score -->
<div style="position:relative;overflow:hidden;background:{cream};border:1px solid {line};border-left:3px solid {grade_color};border-radius:0;padding:22px 24px;margin-bottom:20px;display:flex;align-items:center;gap:22px">
{pattern("dots", grade_color)}
<div style="position:relative;display:flex;align-items:center;gap:22px;width:100%">
{grade_medallion}
<div style="flex:1">
<p style="font-family:{MONO};font-size:10px;color:{grade_color};text-transform:uppercase;letter-spacing:0.14em;margin:0 0 6px;font-weight:600">Performance score · {grade_label}</p>
<p style="font-family:{SANS};font-size:15px;font-weight:400;color:{ink};margin:0;line-height:1.55">{md(analysis.get("verdict", ""))}</p>
</div>
</div>
</div>
<!-- Summary -->
<div style="position:relative;overflow:hidden;background:{cream};border:1px solid {line};border-radius:0;padding:20px 22px;margin-bottom:16px">
{pattern("grid", clay)}
<div style="position:relative">
{eyebrow("What happened", clay)}
<p style="font-family:{SANS};font-size:14px;color:{ink};margin:0;line-height:1.62">{md(analysis.get("summary", ""))}</p>
</div>
</div>
<!-- Strengths & Weaknesses -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">
<div style="position:relative;overflow:hidden;background:{cream};border:1px solid {line};border-radius:0;padding:20px 22px">
{pattern("dots", grn)}
<div style="position:relative">
{eyebrow("What worked", grn)}
{strengths_html}
</div>
</div>
<div style="position:relative;overflow:hidden;background:{cream};border:1px solid {line};border-radius:0;padding:20px 22px">
{pattern("tri", amb)}
<div style="position:relative">
{eyebrow("What held it back", amb)}
{weaknesses_html}
</div>
</div>
</div>
<!-- Diagnosis -->
<div style="position:relative;overflow:hidden;background:{cream};border:1px solid {line};border-radius:0;padding:20px 22px;margin-bottom:16px">
{pattern("diag", red)}
<div style="position:relative">
{eyebrow("Why it behaved this way", red)}
<p style="font-family:{SANS};font-size:13.5px;color:{ink};margin:0;line-height:1.65">{md(analysis.get("failure_diagnosis", ""))}</p>
</div>
</div>
<!-- Improvements -->
{improvements_flow}
{note_html}
<p style="font-family:{MONO};font-size:10px;color:{muted};margin:18px 0 0;text-align:center;letter-spacing:0.04em">
This is research analysis, not financial advice. Validate on unseen data before risking capital.
</p>
</div>
</body>
</html>
"""
return html