fix(ui): fix signal cards rendering as raw HTML in Streamlit

Two bugs were causing the signal card HTML to display as literal text:

1. CommonMark HTML block termination: Streamlit's markdown parser
   (CommonMark-compliant) terminates a <div> block at the first blank
   line. Empty optional fields (name_html, desc_html, risk_badge_html)
   left whitespace-only lines in the f-string template, ending the HTML
   block and causing the parser to render subsequent tags as text.
   Fixed by building HTML from a parts list and only appending optional
   elements when non-empty — no blank lines can appear in output.

2. Unescaped HTML chars in LLM-generated text: reason fields from the
   ranker contained raw > and & characters (e.g. '>5% move',
   '50 & 200 SMA') that corrupted the HTML structure. Fixed by running
   all LLM-generated fields through html.escape() before interpolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Youssef Aitousarrah 2026-02-20 11:33:15 -08:00
parent 07851feeda
commit 6a9a190af5
1 changed files with 52 additions and 40 deletions

View File

@ -629,46 +629,58 @@ def signal_card(
safe_risk_title = safe_risk.title() if safe_risk else "" safe_risk_title = safe_risk.title() if safe_risk else ""
return f""" # Build HTML piece-by-piece to avoid blank/whitespace-only lines.
<div class="signal-card {strat_css}"> # CommonMark terminates a <div> HTML block at the FIRST blank line, so any
<div class="signal-header"> # empty interpolation (name_html, desc_html, risk_badge_html = "") would leave
<div style="display:flex;align-items:center;gap:0.75rem;"> # a whitespace-only line that makes Streamlit's markdown parser stop treating
<span class="signal-ticker">{ticker}</span> # subsequent content as HTML and render it as literal text instead.
<span class="signal-rank">#{rank}</span> parts = [
{name_html} f'<div class="signal-card {strat_css}">',
</div> '<div class="signal-header">',
</div> '<div style="display:flex;align-items:center;gap:0.75rem;">',
{desc_html} f'<span class="signal-ticker">{ticker}</span>',
<div class="signal-badges"> f'<span class="signal-rank">#{rank}</span>',
<span class="badge {strat_badge}">{safe_strategy}</span> ]
<span class="badge {score_badge}">Score {score}</span> if name_html:
<span class="badge badge-muted">Conf {confidence}/10</span> parts.append(name_html)
{risk_badge_html} parts += ["</div>", "</div>"]
</div> if desc_html:
<div class="signal-metrics"> parts.append(desc_html)
<div class="signal-metric"> parts += [
<div class="signal-metric-label">Entry</div> '<div class="signal-badges">',
<div class="signal-metric-value">{entry_str}</div> f'<span class="badge {strat_badge}">{safe_strategy}</span>',
</div> f'<span class="badge {score_badge}">Score {score}</span>',
<div class="signal-metric"> f'<span class="badge badge-muted">Conf {confidence}/10</span>',
<div class="signal-metric-label">Score</div> ]
<div class="signal-metric-value">{score}/100</div> if risk_badge_html:
</div> parts.append(risk_badge_html)
<div class="signal-metric"> parts += [
<div class="signal-metric-label">Confidence</div> "</div>",
<div class="signal-metric-value">{confidence}/10</div> '<div class="signal-metrics">',
</div> '<div class="signal-metric">',
<div class="signal-metric"> '<div class="signal-metric-label">Entry</div>',
<div class="signal-metric-label">Risk</div> f'<div class="signal-metric-value">{entry_str}</div>',
<div class="signal-metric-value" style="font-size:0.8rem;">{safe_risk_title}</div> "</div>",
</div> '<div class="signal-metric">',
</div> '<div class="signal-metric-label">Score</div>',
<div class="signal-thesis">{safe_reason}</div> f'<div class="signal-metric-value">{score}/100</div>',
<div class="conf-bar"> "</div>",
<div class="conf-fill" style="width:{conf_pct}%;background:{bar_color};"></div> '<div class="signal-metric">',
</div> '<div class="signal-metric-label">Confidence</div>',
</div> f'<div class="signal-metric-value">{confidence}/10</div>',
""" "</div>",
'<div class="signal-metric">',
'<div class="signal-metric-label">Risk</div>',
f'<div class="signal-metric-value" style="font-size:0.8rem;">{safe_risk_title}</div>',
"</div>",
"</div>",
f'<div class="signal-thesis">{safe_reason}</div>',
'<div class="conf-bar">',
f'<div class="conf-fill" style="width:{conf_pct}%;background:{bar_color};"></div>',
"</div>",
"</div>",
]
return "\n".join(parts)
def pnl_color(value: float) -> str: def pnl_color(value: float) -> str: