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