-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdashboard.py
More file actions
3309 lines (2933 loc) · 157 KB
/
Copy pathdashboard.py
File metadata and controls
3309 lines (2933 loc) · 157 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# dashboard.py — M5: Product Dashboard
# ─────────────────────────────────────────────────────────────────────────────
# Run: streamlit run dashboard.py
#
# Two user flows:
# 💰 Fresh Investment — enter ₹ amount → get optimal allocation
# 🔄 Portfolio Rebalancer — enter current holdings → get rebalancing plan
#
# Powered by: FinBERT sentiment + Black-Litterman MVO + INR pricing
# ─────────────────────────────────────────────────────────────────────────────
import os, warnings
import numpy as np
import pandas as pd
import streamlit as st
import plotly.graph_objects as go
import plotly.express as px
from plotly.subplots import make_subplots
from financial_planner import (
FinancialProfile, FinancialAnalyzer, RiskProfiler,
AssetAllocator, FinancialPlanGenerator, RISK_QUESTIONS,
)
warnings.filterwarnings("ignore")
DATA_DIR = "data"
STOCKS = [
"TCS.NS", "INFY.NS", "WIPRO.NS", "HCLTECH.NS",
"HDFCBANK.NS", "ICICIBANK.NS", "SBIN.NS", "KOTAKBANK.NS",
"SUNPHARMA.NS", "DRREDDY.NS",
"HINDUNILVR.NS","ITC.NS",
"RELIANCE.NS", "ONGC.NS",
"LT.NS", "BHARTIARTL.NS",
]
STOCK_META = {
"TCS.NS": {"name": "Tata Consultancy Services", "sector": "Technology", "flag": "💻"},
"INFY.NS": {"name": "Infosys Ltd", "sector": "Technology", "flag": "🔷"},
"WIPRO.NS": {"name": "Wipro Ltd", "sector": "Technology", "flag": "🌐"},
"HCLTECH.NS": {"name": "HCL Technologies", "sector": "Technology", "flag": "⚙️"},
"HDFCBANK.NS": {"name": "HDFC Bank Ltd", "sector": "Finance", "flag": "🏦"},
"ICICIBANK.NS": {"name": "ICICI Bank Ltd", "sector": "Finance", "flag": "🏛️"},
"SBIN.NS": {"name": "State Bank of India", "sector": "Finance", "flag": "🇮🇳"},
"KOTAKBANK.NS": {"name": "Kotak Mahindra Bank", "sector": "Finance", "flag": "💼"},
"SUNPHARMA.NS": {"name": "Sun Pharmaceutical", "sector": "Healthcare", "flag": "💊"},
"DRREDDY.NS": {"name": "Dr. Reddy's Labs", "sector": "Healthcare", "flag": "🔬"},
"HINDUNILVR.NS": {"name": "Hindustan Unilever", "sector": "Consumer", "flag": "🛒"},
"ITC.NS": {"name": "ITC Ltd", "sector": "Consumer", "flag": "🏭"},
"RELIANCE.NS": {"name": "Reliance Industries", "sector": "Energy", "flag": "⛽"},
"ONGC.NS": {"name": "Oil & Natural Gas Corp", "sector": "Energy", "flag": "🛢️"},
"LT.NS": {"name": "Larsen & Toubro", "sector": "Infrastructure", "flag": "🏗️"},
"BHARTIARTL.NS": {"name": "Bharti Airtel", "sector": "Telecom", "flag": "📡"},
}
SECTOR_COLORS = {
"Technology": "#3b82f6",
"Finance": "#f59e0b",
"Healthcare": "#10b981",
"Consumer": "#8b5cf6",
"Energy": "#ef4444",
"Infrastructure": "#06b6d4",
"Telecom": "#f97316",
}
SENTIMENT_CONFIG = {
"bullish": {"color": "#16a34a", "icon": "▲▲", "badge": "🟢"},
"slightly_bullish": {"color": "#86efac", "icon": "▲", "badge": "🟡"},
"neutral": {"color": "#94a3b8", "icon": "─", "badge": "⚪"},
"slightly_bearish": {"color": "#fca5a5", "icon": "▼", "badge": "🟠"},
"bearish": {"color": "#dc2626", "icon": "▼▼", "badge": "🔴"},
}
ACTION_COLORS = {
"BUY": "#16a34a",
"SELL": "#dc2626",
"HOLD": "#94a3b8",
}
# ══════════════════════════════════════════════════════════════════════════════
# HELPERS
# ══════════════════════════════════════════════════════════════════════════════
def fmt_inr(amount: float, compact: bool = False) -> str:
"""Indian currency formatting."""
if compact:
if amount >= 1e7: return f"₹{amount/1e7:.1f}Cr"
if amount >= 1e5: return f"₹{amount/1e5:.1f}L"
if amount >= 1e3: return f"₹{amount/1e3:.0f}K"
return f"₹{amount:.0f}"
# Full formatting with Indian comma system
if amount >= 1e7: return f"₹{amount/1e7:.2f} Crore"
if amount >= 1e5: return f"₹{amount/1e5:.2f} Lakh"
return f"₹{amount:,.0f}"
def check_data_files() -> dict:
required = {
"prices.csv": f"{DATA_DIR}/prices.csv",
"returns.csv": f"{DATA_DIR}/returns.csv",
"sentiment_scores.csv": f"{DATA_DIR}/sentiment_scores.csv",
"market_caps.csv": f"{DATA_DIR}/market_caps.csv",
}
optional = {
"backtest_results.csv": f"{DATA_DIR}/backtest_results.csv",
"backtest_metrics.csv": f"{DATA_DIR}/backtest_metrics.csv",
"fundamentals.csv": f"{DATA_DIR}/fundamentals.csv",
"factor_scores.csv": f"{DATA_DIR}/factor_scores.csv",
}
return {
"required_ok": all(os.path.exists(p) for p in required.values()),
"required": {k: os.path.exists(v) for k, v in required.items()},
"optional": {k: os.path.exists(v) for k, v in optional.items()},
}
@st.cache_data(ttl=300)
def load_sentiment_df():
p = f"{DATA_DIR}/sentiment_scores.csv"
if not os.path.exists(p):
return None
return pd.read_csv(p, index_col="ticker")
@st.cache_data(ttl=300)
def load_factor_scores_df():
p = f"{DATA_DIR}/factor_scores.csv"
if not os.path.exists(p):
return None
return pd.read_csv(p, index_col="ticker")
@st.cache_data(ttl=300)
def load_backtest():
rp = f"{DATA_DIR}/backtest_results.csv"
mp = f"{DATA_DIR}/backtest_metrics.csv"
results = pd.read_csv(rp, index_col="date", parse_dates=True) if os.path.exists(rp) else None
metrics = pd.read_csv(mp) if os.path.exists(mp) else None
return results, metrics
@st.cache_data(ttl=300)
def load_enhanced_backtest():
"""Load enhanced backtester results (from backtester.py)."""
rp = f"{DATA_DIR}/backtest_enhanced_results.csv"
mp = f"{DATA_DIR}/backtest_enhanced_metrics.csv"
cp = f"{DATA_DIR}/backtest_costs.csv"
results = pd.read_csv(rp, index_col="date", parse_dates=True) if os.path.exists(rp) else None
metrics = pd.read_csv(mp) if os.path.exists(mp) else None
costs = pd.read_csv(cp, index_col="date", parse_dates=True) if os.path.exists(cp) else None
return results, metrics, costs
@st.cache_data(ttl=300)
def load_monte_carlo():
"""Load Monte Carlo simulation results if available."""
p = f"{DATA_DIR}/monte_carlo_results.csv"
if not os.path.exists(p):
return None
return pd.read_csv(p)
@st.cache_data(ttl=300)
def load_prices_inr():
"""Latest NSE stock prices (already in ₹ — no FX conversion needed)."""
p = f"{DATA_DIR}/prices.csv"
if not os.path.exists(p):
return None
prices = pd.read_csv(p, index_col=0, parse_dates=True)
latest = prices[[t for t in STOCKS if t in prices.columns]].iloc[-1]
return latest.round(2)
@st.cache_data(ttl=3600)
def load_macro_snapshot():
"""Run market regime + VIX overlay (cached 1 hour — regime changes slowly)."""
p = f"{DATA_DIR}/prices.csv"
if not os.path.exists(p):
return None
try:
from macro_overlay import get_macro_snapshot
prices = pd.read_csv(p, index_col=0, parse_dates=True)
return get_macro_snapshot(prices)
except Exception:
return None
def run_fresh_optimizer(investment_inr, risk_profile, analysis_method="llm"):
"""Run optimizer and cache result in session state."""
from optimizer import optimize_fresh_investment
return optimize_fresh_investment(investment_inr, risk_profile, analysis_method)
def run_rebalance_optimizer(current_holdings, additional_inr, risk_profile, analysis_method="llm"):
"""Run rebalancer and cache result in session state."""
from optimizer import optimize_rebalancing
return optimize_rebalancing(current_holdings, additional_inr, risk_profile, analysis_method)
def run_full_pipeline(analysis_method="llm"):
"""
Refresh all data needed for optimisation, based on the chosen analysis method.
Displays progress spinners for each step. Errors are shown as warnings — the
app never crashes, it just falls back to cached data.
"""
import data_collector as dc
# Step 1 — Always refresh prices
with st.spinner("📥 Step 1 — Downloading fresh market data…"):
try:
dc.download_prices()
st.toast("✅ Market data ready!", icon="📈")
except Exception as e:
st.warning(f"Using cached market data: {e}")
# Step 2 — LLM views (Groq/LLaMA) if needed
if analysis_method in ("llm", "combined"):
with st.spinner("🤖 Step 2 — LLaMA analysing stocks…"):
try:
import llm_views as lv
lv.run_llm_view_pipeline(lookback_days=10)
st.toast("✅ LLM views ready!", icon="🧠")
except Exception as e:
st.warning(f"LLM views failed: {e}")
# Step 3 — FinBERT news sentiment if needed
if analysis_method in ("sentiment", "combined"):
with st.spinner("📰 Step 3 — Reading news sentiment…"):
try:
from sentiment_engine import run_sentiment_pipeline, STOCKS as SE_STOCKS
run_sentiment_pipeline(SE_STOCKS)
st.toast("✅ Sentiment ready!", icon="📰")
except Exception as e:
st.warning(f"Sentiment failed: {e} — using LLM views only")
# Step 4 — Factor scoring
with st.spinner("📊 Scoring stocks on momentum & quality…"):
try:
import scorer as sc
sc.compute_factor_scores()
st.toast("✅ Factor scores ready!", icon="📊")
except Exception as e:
st.warning(f"Factor scores skipped: {e}")
# ══════════════════════════════════════════════════════════════════════════════
# CHART BUILDERS
# ══════════════════════════════════════════════════════════════════════════════
def chart_allocation_donut(allocation_df, sentiment_df, title="Portfolio Allocation"):
"""Donut chart coloured by sector."""
labels, values, colors, hovers = [], [], [], []
for _, row in allocation_df.iterrows():
t = row["ticker"]
meta = STOCK_META.get(t, {})
sent = sentiment_df.loc[t, "label"] if sentiment_df is not None and t in sentiment_df.index else "n/a"
s_cfg = SENTIMENT_CONFIG.get(sent, {})
labels.append(f"{t}")
values.append(row["invested_inr"])
colors.append(SECTOR_COLORS.get(meta.get("sector", ""), "#94a3b8"))
hovers.append(f"<b>{t}</b> — {meta.get('name','')}<br>"
f"Amount: ₹{row['invested_inr']:,.0f}<br>"
f"Weight: {row['target_weight']:.1%}<br>"
f"Sentiment: {s_cfg.get('badge','') } {sent}")
fig = go.Figure(go.Pie(
labels=labels, values=values,
marker_colors=colors,
hole=0.55,
hovertext=hovers, hoverinfo="text",
textinfo="label+percent",
textfont_size=13,
))
fig.update_layout(
title=dict(text=title, font_size=16),
showlegend=False,
height=400,
margin=dict(l=20, r=20, t=50, b=20),
)
return fig
def chart_allocation_bar(allocation_df, sentiment_df):
"""Horizontal bar chart with sentiment colour coding."""
df = allocation_df.copy()
df = df[df["invested_inr"] > 0].sort_values("target_weight")
colors = []
for t in df["ticker"]:
sent = sentiment_df.loc[t, "label"] if sentiment_df is not None and t in sentiment_df.index else "neutral"
colors.append(SENTIMENT_CONFIG.get(sent, {}).get("color", "#94a3b8"))
fig = go.Figure(go.Bar(
x = df["invested_inr"],
y = df["ticker"],
orientation = "h",
marker_color = colors,
text = [f"₹{v:,.0f} ({w:.1%})"
for v, w in zip(df["invested_inr"], df["target_weight"])],
textposition = "outside",
hovertemplate = "<b>%{y}</b><br>₹%{x:,.0f}<extra></extra>",
))
fig.update_layout(
xaxis_title = "Invested Amount (₹)",
plot_bgcolor = "white",
height = 400,
margin = dict(l=80, r=120, t=20, b=40),
xaxis = dict(showgrid=True, gridcolor="#e2e8f0"),
)
return fig
def chart_rebalance(rebalance_df):
"""Waterfall-style bar chart: green = BUY, red = SELL."""
df = rebalance_df[rebalance_df["action"] != "HOLD"].copy()
if df.empty:
return None
df["amount_signed"] = df.apply(
lambda r: r["trade_inr"] if r["action"] == "BUY" else -r["trade_inr"], axis=1
)
df = df.sort_values("amount_signed")
colors = [ACTION_COLORS.get(a, "#94a3b8") for a in df["action"]]
fig = go.Figure(go.Bar(
x = df["ticker"],
y = df["amount_signed"],
marker_color = colors,
text = [f"{'+'if r>0 else ''}₹{abs(r):,.0f}" for r in df["amount_signed"]],
textposition = "outside",
hovertemplate = "<b>%{x}</b><br>%{text}<extra></extra>",
))
fig.add_hline(y=0, line_color="#1e293b", line_width=1)
fig.update_layout(
title = "Rebalancing Trades Required",
yaxis_title = "Amount (₹) — positive = BUY | negative = SELL",
plot_bgcolor = "white",
height = 380,
margin = dict(l=60, r=60, t=50, b=40),
)
return fig
def chart_current_vs_target(rebalance_df, total_inr):
"""Grouped bar: current vs target allocation."""
df = rebalance_df.copy()
fig = go.Figure()
fig.add_trace(go.Bar(
name = "Current Holdings",
x = df["ticker"],
y = df["current_inr"],
marker_color = "#94a3b8",
text = [f"₹{v:,.0f}" for v in df["current_inr"]],
textposition = "outside",
))
fig.add_trace(go.Bar(
name = "Target Allocation",
x = df["ticker"],
y = df["target_inr"],
marker_color = "#3b82f6",
text = [f"₹{v:,.0f}" for v in df["target_inr"]],
textposition = "outside",
))
fig.update_layout(
barmode = "group",
title = "Current vs Optimised Target",
yaxis_title = "Value (₹)",
plot_bgcolor = "white",
height = 400,
legend = dict(orientation="h", yanchor="bottom", y=1.02, x=0),
)
return fig
def chart_cumulative_return(backtest_df):
cum_sent = (1 + backtest_df["ret_sentiment"]).cumprod() - 1
cum_base = (1 + backtest_df["ret_baseline"]).cumprod() - 1
cum_sp = (1 + backtest_df["ret_nifty50"].fillna(0)).cumprod() - 1
fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
row_heights=[0.7, 0.3],
subplot_titles=("Cumulative Return", "Drawdown"))
fig.add_trace(go.Scatter(x=backtest_df.index, y=cum_sent,
name="BL + Sentiment", line=dict(color="#3b82f6", width=2.5)), row=1, col=1)
fig.add_trace(go.Scatter(x=backtest_df.index, y=cum_base,
name="Pure Quant", line=dict(color="#f59e0b", width=1.8, dash="dash")), row=1, col=1)
fig.add_trace(go.Scatter(x=backtest_df.index, y=cum_sp,
name="Nifty 50", line=dict(color="#64748b", width=1.5, dash="dot")), row=1, col=1)
cum_port = (1 + backtest_df["ret_sentiment"]).cumprod()
drawdown = (cum_port - cum_port.cummax()) / cum_port.cummax()
fig.add_trace(go.Scatter(x=backtest_df.index, y=drawdown,
fill="tozeroy", line=dict(color="#dc2626"),
name="Drawdown", showlegend=False), row=2, col=1)
fig.update_layout(
height = 540,
plot_bgcolor = "white",
yaxis_tickformat = ".0%",
yaxis2_tickformat = ".0%",
legend = dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
)
return fig
def chart_sentiment_scores(sentiment_df):
df = sentiment_df.reset_index().sort_values("final_score", ascending=True)
colors = [SENTIMENT_CONFIG.get(l, {}).get("color", "#94a3b8")
for l in df["label"]]
fig = go.Figure(go.Bar(
x = df["final_score"],
y = df["ticker"],
orientation = "h",
marker_color = colors,
text = [f"{s:+.3f}" for s in df["final_score"]],
textposition = "outside",
hovertemplate = (
"<b>%{y}</b><br>Score: %{x:.3f}<br>"
"Headlines: %{customdata[0]}<br>"
"% Positive: %{customdata[1]:.0%}<extra></extra>"
),
customdata = df[["num_headlines", "pct_positive"]].values,
))
fig.add_vline(x=0.30, line_dash="dash", line_color="#16a34a",
annotation_text="Bullish", annotation_position="top")
fig.add_vline(x=-0.30, line_dash="dash", line_color="#dc2626",
annotation_text="Bearish", annotation_position="top")
fig.add_vline(x=0, line_color="#1e293b", line_width=1)
fig.update_layout(
xaxis_range = [-1.1, 1.3],
xaxis_title = "FinBERT Sentiment Score",
plot_bgcolor = "white",
height = 400,
margin = dict(l=60, r=60, t=20, b=40),
)
return fig
# ══════════════════════════════════════════════════════════════════════════════
# FACTOR SCORES CHARTS
# ══════════════════════════════════════════════════════════════════════════════
def chart_combined_factor_scores(factor_df: pd.DataFrame) -> go.Figure:
"""Horizontal bar chart of combined factor scores, coloured by sector."""
df = factor_df.reset_index().sort_values("combined_score", ascending=True)
colors = [SECTOR_COLORS.get(s, "#94a3b8") for s in df["sector"]]
selected_text = ["✅" if s else "" for s in df["selected"]]
fig = go.Figure(go.Bar(
x = df["combined_score"],
y = df["ticker"],
orientation = "h",
marker_color = colors,
text = [f"{v:.3f} {m}" for v, m in zip(df["combined_score"], selected_text)],
textposition = "outside",
hovertemplate = (
"<b>%{y}</b><br>"
"Combined: %{x:.3f}<br>"
"Momentum: %{customdata[0]:.3f}<br>"
"Quality: %{customdata[1]:.3f}<br>"
"Volatility: %{customdata[2]:.3f}<br>"
"6m Return: %{customdata[3]:.1%}<extra></extra>"
),
customdata = df[["momentum_score","quality_score","volatility_score","raw_momentum_6m"]].values,
))
fig.add_vline(x=df["combined_score"].quantile(0.0625), # 1/16 = bottom cut
line_dash="dash", line_color="#94a3b8", line_width=1)
fig.update_layout(
title = "Combined Factor Score (Momentum 40% + Quality 40% + Vol 20%)",
xaxis_range = [0, 1.15],
xaxis_title = "Combined Score (0→1)",
plot_bgcolor = "white",
height = 480,
margin = dict(l=80, r=120, t=50, b=40),
xaxis = dict(showgrid=True, gridcolor="#e2e8f0"),
)
return fig
def chart_individual_factor(factor_df: pd.DataFrame, col: str, title: str,
color: str = "#3b82f6") -> go.Figure:
"""Compact horizontal bar for one factor."""
df = factor_df.reset_index().sort_values(col, ascending=True)
fig = go.Figure(go.Bar(
x = df[col],
y = df["ticker"],
orientation = "h",
marker_color = color,
text = [f"{v:.3f}" for v in df[col]],
textposition = "outside",
hovertemplate = "<b>%{y}</b><br>" + title + ": %{x:.3f}<extra></extra>",
))
fig.update_layout(
title = title,
xaxis_range = [0, 1.2],
plot_bgcolor = "white",
height = 380,
margin = dict(l=80, r=60, t=40, b=30),
xaxis = dict(showgrid=True, gridcolor="#e2e8f0"),
)
return fig
def render_factor_scores_tab(factor_df: pd.DataFrame, key_prefix: str = "f"):
"""Render the full 📊 Factor Scores tab content."""
st.markdown("""
<div style="background:#f0f9ff; border:1px solid #bae6fd; border-radius:8px;
padding:0.7rem 1rem; margin-bottom:1rem; font-size:0.85rem; color:#0c4a6e;">
<b>How factor scoring works:</b> Stocks are ranked by combined factor score.
Top 15 enter the portfolio. LLM views then fine-tune the exact weights within this universe.
</div>
""", unsafe_allow_html=True)
# ── KPIs ─────────────────────────────────────────────────────────────────
selected = factor_df[factor_df["selected"] == True]
top_sector = factor_df[factor_df["selected"]]["sector"].value_counts().idxmax() \
if not selected.empty else "N/A"
k1, k2, k3, k4 = st.columns(4)
k1.metric("Stocks Scored", len(factor_df))
k2.metric("Selected for Portfolio", len(selected))
k3.metric("Top Sector", top_sector)
k4.metric("Avg Combined Score", f"{factor_df['combined_score'].mean():.3f}")
st.divider()
# ── Combined score bar chart ──────────────────────────────────────────────
st.plotly_chart(chart_combined_factor_scores(factor_df),
use_container_width=True, key=f"{key_prefix}_combined_bar")
# Sector legend
st.markdown("**Sector colour legend:** " + " | ".join(
f'<span style="color:{SECTOR_COLORS.get(s,"#94a3b8")}">■</span> **{s}**'
for s in sorted(SECTOR_COLORS)
), unsafe_allow_html=True)
st.divider()
# ── Three factor columns ──────────────────────────────────────────────────
st.markdown("#### Individual Factor Scores")
fc1, fc2, fc3 = st.columns(3)
with fc1:
st.plotly_chart(
chart_individual_factor(factor_df, "momentum_score",
"Momentum Score (6m return)", "#3b82f6"),
use_container_width=True, key=f"{key_prefix}_mom_bar",
)
st.caption("6-month price return (skip last month). "
"Higher rank = stronger trend.")
with fc2:
st.plotly_chart(
chart_individual_factor(factor_df, "quality_score",
"Quality Score (ROE / D:E / EPS)", "#10b981"),
use_container_width=True, key=f"{key_prefix}_qual_bar",
)
st.caption("Combines ROE, Debt/Equity, and EPS growth. "
"Higher = fundamentally stronger.")
with fc3:
st.plotly_chart(
chart_individual_factor(factor_df, "volatility_score",
"Low-Vol Score (inverted 60d vol)", "#f59e0b"),
use_container_width=True, key=f"{key_prefix}_vol_bar",
)
st.caption("60-day realised volatility, inverted. "
"Higher = lower risk / smoother returns.")
st.divider()
# ── Detailed table ────────────────────────────────────────────────────────
st.markdown("#### Full Factor Score Table")
table_rows = []
for ticker, row in factor_df.sort_values("combined_score", ascending=False).iterrows():
meta = STOCK_META.get(ticker, {})
table_rows.append({
"Ticker": ticker,
"Company": meta.get("name", ticker),
"Sector": row.get("sector", ""),
"Momentum": f"{row['momentum_score']:.3f}",
"Quality": f"{row['quality_score']:.3f}",
"Volatility": f"{row['volatility_score']:.3f}",
"Combined": f"{row['combined_score']:.3f}",
"6m Return": f"{row.get('raw_momentum_6m', 0):.1%}",
"60d Ann.Vol": f"{row.get('raw_vol_60d', 0):.1%}",
"Selected": "✅ Yes" if row["selected"] else "—",
})
st.dataframe(pd.DataFrame(table_rows), use_container_width=True, hide_index=True)
st.markdown("""
> **Interpretation:**
> - **Momentum score** ranks stocks by 6-month price momentum (rank 0→1)
> - **Quality score** ranks stocks by ROE, low debt, and earnings growth (rank 0→1)
> - **Low-Vol score** ranks stocks by **inverse** realised volatility (rank 0→1)
> - **Combined = 0.4×Momentum + 0.4×Quality + 0.2×Low-Vol**
> - Top 15 by combined score enter the portfolio. LLM views then fine-tune exact weights.
""")
# ══════════════════════════════════════════════════════════════════════════════
# RATIONALE GENERATOR
# ══════════════════════════════════════════════════════════════════════════════
def generate_rationale(ticker, weight, target_inr, sentiment_row,
mu_bl_val, mu_prior_val, prices_inr,
use_openai=False, openai_key="") -> str:
meta = STOCK_META.get(ticker, {})
name = meta.get("name", ticker)
score = float(sentiment_row.get("final_score", 0.0))
label = sentiment_row.get("label", "neutral")
n_news = int(sentiment_row.get("num_headlines", 0))
pct_pos = float(sentiment_row.get("pct_positive", 0.0))
price_inr = float(prices_inr.get(ticker, 0))
shares = round(target_inr / price_inr, 4) if price_inr > 0 else 0
stance = ("overweight" if weight >= 0.12 else
"underweight" if weight <= 0.04 else "market-weight")
alignment = ("aligned" if (score > 0 and mu_bl_val > mu_prior_val) or
(score < 0 and mu_bl_val < mu_prior_val) else "divergent")
if use_openai and openai_key:
try:
from openai import OpenAI
client = OpenAI(api_key=openai_key)
prompt = f"""You are a portfolio analyst for Indian retail investors.
Write a 3-sentence rationale for this allocation. Be specific and cite numbers.
Mention: the BL posterior return, the sentiment signal, and the weight rationale.
Stock : {name} ({ticker}) | Sector: {meta.get('sector','')}
Weight : {weight:.1%} ({stance}) | Target INR: ₹{target_inr:,.0f} | Approx. shares: {shares}
Sentiment: {label} ({score:+.3f}) | {n_news} headlines | {pct_pos:.0%} positive
BL Posterior Return: {mu_bl_val:.2%} | Market Prior: {mu_prior_val:.2%}
Price: ₹{price_inr:,.0f} per share
Write for an Indian retail investor. Do not use bullet points."""
resp = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": prompt}],
temperature=0.3, max_tokens=180,
)
return resp.choices[0].message.content.strip()
except Exception:
pass
# Template rationale
view_shift = mu_bl_val - mu_prior_val
shift_dir = "upward" if view_shift > 0 else "downward"
return (
f"**{name}** is assigned a **{stance} position of {weight:.1%}** "
f"(≈{shares:.4g} shares at ₹{price_inr:,.0f}/share = ₹{target_inr:,.0f}). "
f"FinBERT scored {n_news} recent headlines at **{score:+.3f}** ({label}), "
f"with {pct_pos:.0%} of articles carrying a positive signal — "
f"shifting the Black-Litterman posterior return {shift_dir} "
f"from the market prior of **{mu_prior_val:.2%}** to **{mu_bl_val:.2%}**. "
f"Sentiment and price signals are **{alignment}**, "
f"{'supporting the overweight thesis.' if stance == 'overweight' else 'suggesting caution at current valuations.' if stance == 'underweight' else 'warranting a neutral allocation.'}"
)
# ══════════════════════════════════════════════════════════════════════════════
# LEVEL 3: MACRO REGIME PANEL
# ══════════════════════════════════════════════════════════════════════════════
def chart_sector_heatmap(sector_sentiment: dict) -> go.Figure:
"""Single-row heatmap showing sector-level FinBERT sentiment."""
sectors = list(sector_sentiment.keys())
scores = [sector_sentiment[s] for s in sectors]
colors = [SECTOR_COLORS.get(s, "#94a3b8") for s in sectors]
fig = go.Figure(go.Bar(
x = sectors,
y = scores,
marker_color = [
"#16a34a" if v > 0.15 else
"#86efac" if v > 0.03 else
"#fca5a5" if v < -0.03 else
"#dc2626" if v < -0.15 else "#94a3b8"
for v in scores
],
text = [f"{v:+.3f}" for v in scores],
textposition = "outside",
hovertemplate = "<b>%{x}</b><br>Avg Sentiment: %{y:.3f}<extra></extra>",
))
fig.add_hline(y=0, line_color="#1e293b", line_width=1)
fig.update_layout(
yaxis_title = "Avg FinBERT Score",
plot_bgcolor = "white",
height = 260,
margin = dict(l=40, r=40, t=20, b=40),
yaxis = dict(range=[-0.7, 0.7], showgrid=True, gridcolor="#e2e8f0"),
)
return fig
def render_macro_panel(snap: dict, sector_sentiment: dict = None, key_prefix: str = "macro"):
"""
Friendly Market Conditions panel with traffic light, VIX mood meter, and plain-English narrative.
"""
r = snap["regime"]
v = snap["vix"]
# ── Traffic light ────────────────────────────────────────────────────────
_regime_key = r.get("regime", "neutral")
_tl_config = {
"bull": ("🟢", "Green light", "Markets are healthy. Good time to invest.",
"#16a34a", "#f0fdf4", "#bbf7d0"),
"neutral": ("🟡", "Yellow light", "Markets are mixed. Invest carefully.",
"#ca8a04", "#fefce8", "#fde68a"),
"bear": ("🔴", "Red light", "Markets are stressed. We're being cautious.",
"#dc2626", "#fef2f2", "#fecaca"),
}.get(_regime_key, ("🟡", "Yellow light", "Markets are mixed.", "#ca8a04", "#fefce8", "#fde68a"))
_tl_icon, _tl_title, _tl_desc, _tl_color, _tl_bg, _tl_border = _tl_config
# ── VIX mood ─────────────────────────────────────────────────────────────
_vix_val = v.get("vix", 20)
if _vix_val < 15:
_vix_face, _vix_mood, _vix_color = "😊", "Calm markets", "#16a34a"
elif _vix_val < 20:
_vix_face, _vix_mood, _vix_color = "😐", "Slightly nervous", "#ca8a04"
elif _vix_val < 30:
_vix_face, _vix_mood, _vix_color = "😰", "Fearful", "#ea580c"
else:
_vix_face, _vix_mood, _vix_color = "🚨", "Panic mode", "#dc2626"
_deployed_pct = int(snap.get("combined_scale", 1.0) * 100)
_cash_pct = int(snap.get("cash_buffer", 0.0) * 100)
st.markdown(f"""
<div style="display:flex; gap:1rem; flex-wrap:wrap; margin-bottom:1rem;">
<!-- Traffic light card -->
<div style="flex:1; min-width:220px; background:{_tl_bg}; border:2px solid {_tl_border};
border-radius:12px; padding:1rem 1.2rem;">
<div style="font-size:2.2rem; margin-bottom:0.3rem;">{_tl_icon}</div>
<div style="font-size:1rem; font-weight:800; color:{_tl_color};">{_tl_title}</div>
<div style="font-size:0.85rem; color:#475569; margin-top:0.2rem;">{_tl_desc}</div>
<div style="font-size:0.78rem; color:{_tl_color}; font-weight:600; margin-top:0.5rem;">
{r.get('emoji','')} {r.get('label','').title()} regime
</div>
</div>
<!-- VIX fear meter -->
<div style="flex:1; min-width:220px; background:white; border:1px solid #e2e8f0;
border-radius:12px; padding:1rem 1.2rem;">
<div style="font-size:0.78rem; color:#64748b; font-weight:600; text-transform:uppercase;
letter-spacing:0.03em; margin-bottom:0.4rem;">Market Fear Meter</div>
<div style="display:flex; align-items:center; gap:0.6rem;">
<span style="font-size:1.8rem;">{_vix_face}</span>
<div>
<div style="font-size:1.3rem; font-weight:800; color:{_vix_color};">{_vix_mood}</div>
<div style="font-size:0.78rem; color:#94a3b8;">VIX = {_vix_val:.1f}</div>
</div>
</div>
<div style="background:#f1f5f9; border-radius:6px; height:8px; margin-top:0.7rem; overflow:hidden;">
<div style="background:{_vix_color}; width:{min(int(_vix_val/50*100), 100)}%; height:100%; border-radius:6px;"></div>
</div>
<div style="display:flex; justify-content:space-between; margin-top:0.2rem;">
<span style="font-size:0.68rem; color:#94a3b8;">Calm</span>
<span style="font-size:0.68rem; color:#94a3b8;">Panic</span>
</div>
</div>
<!-- Deployment card -->
<div style="flex:1; min-width:220px; background:white; border:1px solid #e2e8f0;
border-radius:12px; padding:1rem 1.2rem;">
<div style="font-size:0.78rem; color:#64748b; font-weight:600; text-transform:uppercase;
letter-spacing:0.03em; margin-bottom:0.4rem;">Your money going in</div>
<div style="font-size:1.8rem; font-weight:800; color:#1e293b;">{_deployed_pct}%</div>
<div style="font-size:0.82rem; color:#64748b; margin-bottom:0.5rem;">invested in stocks</div>
<div style="background:#f1f5f9; border-radius:6px; height:8px; overflow:hidden;">
<div style="background:#3b82f6; width:{_deployed_pct}%; height:100%; border-radius:6px;"></div>
</div>
<div style="font-size:0.78rem; color:#94a3b8; margin-top:0.3rem;">
{_cash_pct}% kept as safety buffer
</div>
</div>
</div>
""", unsafe_allow_html=True)
# ── Narrative ────────────────────────────────────────────────────────────
st.markdown(f"""
<div style="background:{_tl_bg}; border-left:4px solid {_tl_color}; border-radius:0 8px 8px 0;
padding:0.8rem 1.1rem; margin-bottom:1rem; font-size:0.9rem; color:#1e293b;">
<strong>What this means for you:</strong> {snap.get("narrative", "")}
</div>
""", unsafe_allow_html=True)
# ── Technical signals (collapsed) ────────────────────────────────────────
if r.get("signals"):
sig = r["signals"]
with st.expander("📐 Technical signals (Nifty 50 moving averages)"):
sc1, sc2, sc3, sc4 = st.columns(4)
sc1.metric("Nifty 50", f"₹{sig.get('nifty50',0):,.0f}")
sc2.metric("50-day average", f"₹{sig.get('ma50',0):,.0f}")
sc3.metric("200-day average", f"₹{sig.get('ma200',0):,.0f}")
sc4.metric("20-day momentum", f"{sig.get('momentum_20d',0):.1%}")
# ══════════════════════════════════════════════════════════════════════════════
# ENHANCED BACKTEST CHARTS (from backtester.py)
# ══════════════════════════════════════════════════════════════════════════════
def chart_enhanced_cumulative(results_df: pd.DataFrame) -> go.Figure:
"""Cumulative returns for BL (net), BL (gross), Momentum Only, Equal Weight, Nifty 50."""
cum_bl_net = (1 + results_df["bl_net"]).cumprod() - 1
cum_bl_gross = (1 + results_df["bl_gross"]).cumprod() - 1
cum_eq = (1 + results_df["eq_net"]).cumprod() - 1
cum_nifty = (1 + results_df["nifty"]).cumprod() - 1
fig = make_subplots(rows=2, cols=1, shared_xaxes=True, row_heights=[0.7, 0.3],
subplot_titles=("Cumulative Return", "Monthly Period Costs (₹)"))
fig.add_trace(go.Scatter(x=results_df.index, y=cum_bl_net,
name="🤖 BL + AI Factor", line=dict(color="#534AB7", width=2.5)), row=1, col=1)
fig.add_trace(go.Scatter(x=results_df.index, y=cum_bl_gross,
name="BL+Factor (Before Costs)", line=dict(color="#93c5fd", width=1.5, dash="dot")), row=1, col=1)
if "mom_net" in results_df.columns:
cum_mom = (1 + results_df["mom_net"]).cumprod() - 1
fig.add_trace(go.Scatter(x=results_df.index, y=cum_mom,
name="📈 Pure Momentum", line=dict(color="#EF9F27", width=2)), row=1, col=1)
fig.add_trace(go.Scatter(x=results_df.index, y=cum_eq,
name="⚖️ Equal Weight", line=dict(color="#1D9E75", width=1.8, dash="dash")), row=1, col=1)
fig.add_trace(go.Scatter(x=results_df.index, y=cum_nifty,
name="📊 Nifty 50", line=dict(color="#888780", width=1.5, dash="dot")), row=1, col=1)
fig.add_trace(go.Bar(x=results_df.index, y=results_df["period_costs"],
name="Period Costs", marker_color="#fca5a5", showlegend=False), row=2, col=1)
n_stocks = int(results_df["n_stocks"].iloc[0]) if "n_stocks" in results_df.columns else 93
start_yr = results_df.index[0].year
end_yr = results_df.index[-1].year
fig.add_annotation(
x=0.01, y=0.97, xref="paper", yref="paper",
text=(f"All strategies: {n_stocks} NSE stocks<br>"
f"Jan {start_yr} – Mar {end_yr}<br>"
f"Real Zerodha costs included"),
align="left", showarrow=False,
bgcolor="rgba(255,255,255,0.85)", bordercolor="#cbd5e1",
borderwidth=1, font=dict(size=11, color="#475569"),
xanchor="left", yanchor="top",
)
fig.update_layout(
height=560, plot_bgcolor="white",
yaxis_tickformat=".0%", yaxis2_title="Cost (₹)",
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
)
return fig
def render_enhanced_backtest_tab(results_df, metrics_df, costs_df):
"""Render the full enhanced backtest tab content."""
# ── ₹1 Lakh story cards ──────────────────────────────────────────────────
m_bl = metrics_df[metrics_df["label"].str.contains("After Costs", na=False)]
m_eq = metrics_df[metrics_df["label"].str.contains("Equal", na=False)]
m_ni = metrics_df[metrics_df["label"].str.contains("Nifty", na=False)]
if not m_bl.empty:
# Estimate years from date range
_years = max(1, (results_df.index[-1] - results_df.index[0]).days / 365.25)
_start = 100_000
_bl_cum = float(m_bl.iloc[0]["cumulative_ret"]) if "cumulative_ret" in m_bl.columns else 0
_ni_cum = float(m_ni.iloc[0]["cumulative_ret"]) if (not m_ni.empty and "cumulative_ret" in m_ni.columns) else 0
_fd_cum = (1.065 ** _years) - 1 # approx 6.5% FD
_bl_end = round(_start * (1 + _bl_cum))
_ni_end = round(_start * (1 + _ni_cum))
_fd_end = round(_start * (1 + _fd_cum))
_yrs_lbl = f"{_years:.0f} years"
st.markdown(f"""
<div style="text-align:center; padding:0.5rem 0 1rem;">
<h4 style="color:#1e293b; margin:0;">What if you had invested ₹1 Lakh? 💭</h4>
<p style="color:#64748b; font-size:0.88rem; margin:0.3rem 0 0;">
Honest backtest over {_yrs_lbl} — zero look-ahead bias, real trading costs included ✅
</p>
</div>
""", unsafe_allow_html=True)
_sc1, _sc2, _sc3 = st.columns(3)
_sc1.markdown(f"""
<div class="finance-card" style="text-align:center; border-top:4px solid #94a3b8; opacity:0.9;">
<div style="font-size:1.5rem; margin-bottom:0.4rem;">🏦</div>
<div style="font-weight:700; color:#1e293b; margin-bottom:0.75rem;">Fixed Deposit</div>
<div style="color:#64748b; font-size:0.9rem;">₹1,00,000</div>
<div style="font-size:1.4rem; color:#64748b; margin:0.3rem 0;">↓</div>
<div style="font-size:1.6rem; font-weight:800; color:#64748b;">
{fmt_inr(_fd_end, compact=True)}
</div>
<div style="color:#94a3b8; font-size:0.82rem; margin-top:0.4rem;">
+{_fd_cum:.0%} in {_yrs_lbl}
</div>
</div>
""", unsafe_allow_html=True)
_sc2.markdown(f"""
<div class="finance-card" style="text-align:center; border-top:4px solid #64748b; opacity:0.9;">
<div style="font-size:1.5rem; margin-bottom:0.4rem;">📊</div>
<div style="font-weight:700; color:#1e293b; margin-bottom:0.75rem;">Nifty 50 Index</div>
<div style="color:#64748b; font-size:0.9rem;">₹1,00,000</div>
<div style="font-size:1.4rem; color:#64748b; margin:0.3rem 0;">↓</div>
<div style="font-size:1.6rem; font-weight:800; color:#1e293b;">
{fmt_inr(_ni_end, compact=True)}
</div>
<div style="color:#64748b; font-size:0.82rem; margin-top:0.4rem;">
+{_ni_cum:.0%} in {_yrs_lbl}
</div>
</div>
""", unsafe_allow_html=True)
_sc3.markdown(f"""
<div style="background:#1e3a8a;
border-radius:12px; padding:1.5rem; text-align:center; color:white;">
<div style="font-size:1.5rem; margin-bottom:0.4rem;">🤖</div>
<div style="font-weight:800; font-size:1.05rem; margin-bottom:0.75rem;">PortfolioAI</div>
<div style="opacity:0.8; font-size:0.9rem;">₹1,00,000</div>
<div style="font-size:1.4rem; opacity:0.8; margin:0.3rem 0;">↓</div>
<div style="font-size:1.9rem; font-weight:800;">
{fmt_inr(_bl_end, compact=True)}
</div>
<div style="opacity:0.85; font-size:0.88rem; margin-top:0.4rem;">
+{_bl_cum:.0%} in {_yrs_lbl}
</div>
</div>
""", unsafe_allow_html=True)
st.write("")
st.markdown("**Here's how each investment grew month by month:**")
st.markdown("""
<div style="background:#f0fdf4; border:1px solid #bbf7d0; border-radius:10px;
padding:0.65rem 1rem; margin-bottom:0.75rem;">
<div style="font-weight:700; color:#14532d; font-size:0.95rem; margin-bottom:0.5rem;">
✅ Honest Backtest — Zero Look-Ahead Bias
</div>
<div style="font-size:0.79rem; color:#166534; line-height:1.75;">
• <strong>Momentum signal:</strong> uses only prices that existed before each decision date<br>
• <strong>Volatility signal:</strong> uses only returns that existed before each decision date<br>
• <strong>Quality fundamentals:</strong> completely removed — no ROE/D:E/EPS in backtest<br>
• <strong>Stock eligibility:</strong> each stock must have ≥130 days of history at decision time<br>
• <strong>Transaction costs:</strong> real Zerodha rates (brokerage + STT + GST + stamp)<br>
• <em>Note: our 16-stock universe is fixed. A larger universe would improve results further.</em>
</div>
</div>
""", unsafe_allow_html=True)
st.plotly_chart(chart_enhanced_cumulative(results_df),
use_container_width=True, key="enhanced_bt_chart")
# ── Plain English KPIs ────────────────────────────────────────────────────
if not m_bl.empty and not m_ni.empty:
bl_cagr = float(m_bl.iloc[0]["ann_return"])
ni_cagr = float(m_ni.iloc[0]["ann_return"])
alpha = bl_cagr - ni_cagr
total_c = float(m_bl.iloc[0]["total_costs_inr"])
drag = float(m_bl.iloc[0]["cost_drag_ann"])
bl_dd = float(m_bl.iloc[0]["max_drawdown"]) if "max_drawdown" in m_bl.columns else 0
bl_shr = float(m_bl.iloc[0]["sharpe"]) if "sharpe" in m_bl.columns else 0
st.markdown("#### In plain English")
_pk1, _pk2, _pk3, _pk4 = st.columns(4)
_pk1.markdown(f"""
<div class="finance-card" style="text-align:center;">
<div style="color:#64748b; font-size:0.8rem;">Yearly growth rate</div>
<div style="font-size:1.6rem; font-weight:800; color:#2563eb; margin:0.3rem 0;">
{bl_cagr:.2%}
</div>
<div style="color:#94a3b8; font-size:0.75rem;">like a FD giving {bl_cagr:.1%}/yr!</div>
</div>
""", unsafe_allow_html=True)
alpha_color = "#16a34a" if alpha > 0 else "#dc2626"
alpha_label = "above" if alpha > 0 else "below"
_pk2.markdown(f"""
<div class="finance-card" style="text-align:center;">
<div style="color:#64748b; font-size:0.8rem;">Beat Nifty 50 by</div>
<div style="font-size:1.6rem; font-weight:800; color:{alpha_color}; margin:0.3rem 0;">
{alpha:+.2%}/yr
</div>
<div style="color:#94a3b8; font-size:0.75rem;">{alpha_label} index after costs</div>
</div>
""", unsafe_allow_html=True)
_pk3.markdown(f"""
<div class="finance-card" style="text-align:center;">
<div style="color:#64748b; font-size:0.8rem;">Worst loss from peak</div>
<div style="font-size:1.6rem; font-weight:800; color:#ea580c; margin:0.3rem 0;">
{bl_dd:.1%}
</div>