Repository navigation
Expand file tree
/
Copy pathrender.py
More file actions
2435 lines (2138 loc) · 98.7 KB
/
Copy pathrender.py
File metadata and controls
2435 lines (2138 loc) · 98.7 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
"""
render.py — Page renderers for the Quant-AI multipage app (v10.5.3, R2).
Intent: st.Page needs FILE-based pages so AppTest.switch_page can drive them, so
the four page renderers live here and the thin scripts under quant/pages/ call
them. dashboard.py is the navigation entry only.
State/View: render functions reflect the artifacts read via the five helpers in
quant.reporting.artifacts. All user-facing strings come from quant.ui.copy.
Dependencies: streamlit, pandas, plotly, quant.*, quant.ui.copy, quant.ui.runner.
"""
from __future__ import annotations
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
import logging
import os
import threading
from datetime import date as _date
from datetime import datetime as _dt
import pandas as pd
import streamlit as st
from quant import __version__, paths
from quant.analytics.buffett import buffett_filter
from quant.config import RISK_PROFILE_DESCRIPTIONS, RISK_PROFILES, STALE_DATA_DAYS
from quant.data.database import read_only_connection
from quant.data.news import load_news
from quant.execution.routing import holding_routes_to_savings_plan
from quant.execution.taxonomy import (
INVERSE_STRUCTURE,
LEVERAGED_STRUCTURE,
get_structure,
resolve_broker,
)
from quant.portfolio.account import AccountState, load_account, save_account
from quant.portfolio.cash_rate import current_cash_apy, current_rate
from quant.portfolio.editor import save_portfolio, validate_positions
from quant.reporting.artifacts import (
latest_ok_review_ts,
latest_review,
read_actions,
read_history,
read_regime,
read_scores,
read_update_state,
read_value_series,
)
from quant.ui import copy as C
from quant.ui import palette as P
from quant.ui import runner
from quant.ui.cards import explore_card_fields
from quant.ui.search import discovery_candidates, label_for, load_index, search
logger = logging.getLogger(__name__)
_EDIT_COLS = ["Symbol", "Avg_Entry_Price", "Current_Value_EUR", "Broker_PnL_EUR"]
_COLUMN_CONFIG = {
# v10.7.3 (Part 2.2): the broker statement shows the company name (read-only).
"Name": st.column_config.TextColumn("Name"),
"Symbol": st.column_config.TextColumn(C.COLUMN_HEADERS["Symbol"]),
"Avg_Entry_Price": st.column_config.NumberColumn(
C.COLUMN_HEADERS["Avg_Entry_Price"], format="%.2f"),
"Current_Value_EUR": st.column_config.NumberColumn(
C.COLUMN_HEADERS["Current_Value_EUR"], format="%.2f"),
"Broker_PnL_EUR": st.column_config.NumberColumn(
C.COLUMN_HEADERS["Broker_PnL_EUR"], format="%.2f"),
}
# ── Read-only helpers (spec 4.1: short-lived, always closed) ──────────────────
def q(sql: str, params: list | None = None) -> pd.DataFrame:
"""Run a read-only query. Returns an empty frame on any failure."""
try:
with read_only_connection() as conn:
return conn.execute(sql, params or []).df()
except Exception: # noqa: BLE001
return pd.DataFrame()
def load_portfolio() -> pd.DataFrame:
"""Load the broker-synced portfolio (empty frame on failure)."""
try:
from quant.portfolio.portfolio import load_portfolio as _lp
return _lp(paths.DATA_PORTFOLIO)
except Exception: # noqa: BLE001
return pd.DataFrame()
def _display_name(symbol: str) -> str:
"""Company name for a symbol (v10.7.3, Part 2): registry -> cache -> symbol."""
try:
from quant.data.names import display_name
return display_name(symbol)
except Exception: # noqa: BLE001
return str(symbol)
def _fundamentals_for(symbol: str) -> dict:
"""Cached fundamentals for a symbol mapped to the Buffett keys (no network).
v10.7.6 (Part 1): the UI must never trigger a live fundamentals fetch, so
this reads the local cache table only. ROIC is not cached, so that check is
honestly reported as not met when the data is absent.
"""
df = q("SELECT pe, roe, debt_to_equity FROM fundamentals WHERE symbol = ?", [symbol])
if df is None or df.empty:
return {}
r = df.iloc[0]
return {
"PE": r.get("pe"),
"ROE": r.get("roe"),
"DebtToEquity": r.get("debt_to_equity"),
}
def _buffett_for(symbol: str) -> dict:
"""Buffett quality result for a symbol from cached data (v10.7.6, Part 1)."""
close = q("SELECT Close FROM market_history WHERE Symbol = ? ORDER BY Date", [symbol])
series = close["Close"] if close is not None and not close.empty else None
return buffett_filter(_fundamentals_for(symbol), series)
def latest_bar_date() -> str:
"""Return the latest market bar date as a string (empty when absent)."""
df = q("SELECT MAX(Date) AS d FROM market_history")
if df.empty or df["d"].iloc[0] is None:
return ""
return str(df["d"].iloc[0])
def _markets_closed_line(today: _date | None = None) -> str:
"""R8: markets-closed freshness line.
Intent: when today is non-trading (weekend) and the latest bar is the
previous trading session (Friday), the Today header states the close date
instead of implying stale data. Invariants: empty string when the bar is
missing, unparseable, or today is a trading day.
"""
bar = latest_bar_date()
if not bar:
return ""
try:
bd = _dt.fromisoformat(str(bar)).date()
except ValueError:
return ""
today = today or _date.today()
if today.weekday() >= 5 and bd < today and bd.weekday() == 4:
return C.MARKETS_CLOSED.format(date=C.fmt_weekday_date(bd))
return ""
def _any_savings_plan_holding(portfolio) -> bool:
"""R8: True when at least one holding routes to a savings plan."""
if portfolio is None or portfolio.empty:
return False
for sym in portfolio["Symbol"].astype(str):
broker = resolve_broker(sym) or {}
if holding_routes_to_savings_plan(broker.get("instrument_class", ""),
get_structure(sym)):
return True
return False
# ── Shared renderers ──────────────────────────────────────────────────────────
def render_action_cards(holdings: list[dict]) -> None:
"""Render action cards from the ONE advice pipeline (v10.7.1)."""
cards = [h for h in holdings if h.get("action") and not h.get("blocked")]
if not cards:
st.info(C.EMPTY_NOTHING_TO_DO)
return
for a in cards:
kind = a.get("kind")
if not kind:
# Legacy callers pass only the action word; map it.
word = a.get("action", "")
kind = "buy" if word == "BUY MORE" else ("sell_part" if word == "TRIM" else "keep")
target = (a.get("target_weight") or "").rstrip("%") or "?"
pct = abs(float(str(a.get("drift", "0")).rstrip("%") or 0))
name = a.get("name") or a["symbol"]
amount = a.get("amount_eur")
if kind == "buy" and amount:
st.write(C.ACTION_ADD.format(
amount=f"{amount:.0f}", symbol=a["symbol"],
name=name, pct=f"{pct:.0f}", target=target))
elif kind == "sell_part" and amount:
st.write(C.ACTION_SELL.format(
amount=f"{amount:.0f}", symbol=a["symbol"],
pct=f"{pct:.0f}", target=target))
elif kind in ("change_savings_plan", "to_cash"):
st.write(a.get("reason") or C.ADVICE_KEEP)
# ── v10.6.2: Three-tier dashboard, emergency liquidity, tax-loss ─────────────
def render_empty_state(tier: str) -> None:
"""Render a helpful empty state for a tier (v10.6.3)."""
if tier == "FORTRESS":
st.info(C.EMPTY_FORTRESS)
elif tier == "ALPHA":
st.info(C.EMPTY_ALPHA)
elif tier == "SPECULATIVE":
st.info(C.EMPTY_SPECULATIVE)
else:
st.info(C.EMPTY_TIER)
def _add_asset_to_tier(symbol: str, tier: str) -> None:
"""Append a symbol to data/tiers.csv with the given tier (v10.6.3)."""
from quant.portfolio.tier_manager import load_tiers, save_tiers
symbol = str(symbol).strip().upper()
if not symbol:
return
tiers_df = load_tiers()
if not tiers_df.empty and symbol in set(tiers_df["symbol"].astype(str)):
return
row = pd.DataFrame([{
"symbol": symbol, "tier": tier,
"last_updated": _date.today().isoformat(),
"notes": "Added via onboarding",
}])
save_tiers(pd.concat([tiers_df, row], ignore_index=True))
def render_onboarding_wizard() -> None:
"""Three-step onboarding wizard for first-time users (v10.6.3)."""
if st.session_state.get("onboarding_completed"):
return
portfolio = load_portfolio()
if portfolio is not None and not portfolio.empty:
return
st.header(C.ONBOARD_TITLE)
st.write(C.ONBOARD_INTRO)
step = st.session_state.get("onboarding_step", 1)
if step == 1:
st.subheader(C.ONBOARD_STEP1)
sym = st.text_input(C.ONBOARD_SYMBOL_LABEL, value="URTH", key="onboard_fortress")
if st.button(C.ONBOARD_ADD_FORTRESS, key="onboard_add_fortress"):
_add_asset_to_tier(sym, "FORTRESS")
st.session_state["onboarding_step"] = 2
st.rerun()
elif step == 2:
st.subheader(C.ONBOARD_STEP2)
sym = st.text_input(C.ONBOARD_SYMBOL_LABEL, value="NVDA", key="onboard_alpha")
if st.button(C.ONBOARD_ADD_ALPHA, key="onboard_add_alpha"):
_add_asset_to_tier(sym, "ALPHA")
st.session_state["onboarding_step"] = 3
st.rerun()
elif step == 3:
st.subheader(C.ONBOARD_STEP3)
st.number_input(C.ONBOARD_SPARPLAN_LABEL, min_value=25, max_value=1000,
value=150, key="onboard_sparplan")
if st.button(C.ONBOARD_COMPLETE, key="onboard_complete"):
st.session_state["onboarding_completed"] = True
st.success(C.ONBOARD_DONE)
st.rerun()
if st.button(C.ONBOARD_SKIP, key="onboard_skip"):
st.session_state["onboarding_completed"] = True
st.rerun()
def render_tier_dashboard(portfolio: pd.DataFrame | None) -> None:
"""Render the three-tier dashboard (Fortress / Alpha / Speculative tabs)."""
st.subheader(C.SEC_TIERS)
tiers = ["FORTRESS", "ALPHA", "SPECULATIVE"]
labels = [C.TIER_FORTRESS, C.TIER_ALPHA, C.TIER_SPECULATIVE]
helps = [C.HELP_TIER_FORTRESS, C.HELP_TIER_ALPHA, C.HELP_TIER_SPECULATIVE]
for tab, tier, help_text in zip(st.tabs(labels), tiers, helps):
with tab:
st.caption(help_text)
if portfolio is None or portfolio.empty or "Tier" not in portfolio.columns:
render_empty_state(tier)
continue
sub = portfolio[portfolio["Tier"] == tier]
if sub.empty:
render_empty_state(tier)
continue
cols = [c for c in ["Symbol", "Current_Value_EUR", "Broker_PnL_EUR"]
if c in sub.columns]
st.dataframe(sub[cols], width="stretch", hide_index=True)
def render_emergency_liquidity(portfolio: pd.DataFrame | None) -> None:
"""Emergency liquidity calculator: amount input -> tier-aware sell order."""
st.subheader(C.SEC_EMERGENCY)
amount = st.number_input(C.EMERGENCY_PROMPT, min_value=0.0, value=0.0, step=100.0)
if amount <= 0:
# v10.7.3 (Part 1.11): a zero amount is a hint, not an empty block.
st.caption(C.EMERGENCY_HINT)
return
if portfolio is None or portfolio.empty or "Tier" not in portfolio.columns:
st.info(C.EMERGENCY_NONE)
return
from quant.portfolio.risk import emergency_sell_plan
plan = emergency_sell_plan(float(amount), portfolio)
if plan["fortress_warning"]:
st.warning(plan["fortress_warning"])
if not plan["recommendations"]:
st.info(C.EMERGENCY_NONE)
return
st.write(C.EMERGENCY_ORDER)
for h in plan["recommendations"]:
pnl = float(h.get("pnl_eur", 0) or 0)
tax = pnl * 0.26375
if pnl < 0:
note = "loss, tax-loss harvest"
elif pnl > 0:
note = "profit, taxable"
else:
note = "no gain or loss"
st.write(C.EMERGENCY_LINE.format(
symbol=_display_name(h["symbol"]), value=f"{h['value_eur']:.0f}",
tax=f"{tax:.2f}", note=note))
def render_tax_loss_alerts(portfolio: pd.DataFrame | None) -> None:
"""Highlight positions with an unrealized loss (tax-loss candidates)."""
st.subheader(C.SEC_TAX_LOSS)
if portfolio is None or portfolio.empty or "Broker_PnL_EUR" not in portfolio.columns:
st.info(C.TAX_LOSS_NONE)
return
pnl = pd.to_numeric(portfolio["Broker_PnL_EUR"], errors="coerce")
losers = portfolio[pnl < 0]
if losers.empty:
st.info(C.TAX_LOSS_NONE)
return
st.write(C.TAX_LOSS_HEADER)
for _, r in losers.iterrows():
st.write(C.TAX_LOSS_LINE.format(
name=_display_name(str(r.get("Symbol"))),
pnl=f"{float(r.get('Broker_PnL_EUR', 0)):.2f}"))
def render_autobalance_section() -> None:
"""Render the tier auto-balance section in the Portfolio page (v10.6.4)."""
from quant.portfolio.autobalance import (
analyze_tier_allocations,
apply_rebalance_suggestions,
suggest_rebalance,
)
from quant.portfolio.portfolio import load_portfolio
from quant.portfolio.tier_manager import load_tiers, save_tiers
portfolio_df = load_portfolio()
tiers_df = load_tiers()
analysis = analyze_tier_allocations(portfolio_df, tiers_df)
st.subheader(C.SEC_AUTOBALANCE)
for tier, alloc in analysis["allocations"].items():
limit_str = f"{alloc['limit']:.0%}" if alloc["limit"] else "no limit"
line = C.AUTOBALANCE_LINE.format(
tier=tier, value=f"{alloc['value_eur']:.0f}",
pct=f"{alloc['pct']:.1%}", limit=limit_str)
if alloc["violated"]:
st.error(line)
else:
st.write(line)
if not analysis["violations"]:
st.success(C.AUTOBALANCE_OK)
return
st.warning(C.AUTOBALANCE_VIOLATION.format(tiers=", ".join(analysis["violations"])))
suggestions = suggest_rebalance(portfolio_df, tiers_df)
if not suggestions:
st.info(C.AUTOBALANCE_NONE)
return
st.write(C.AUTOBALANCE_SUGGESTIONS.format(n=len(suggestions)))
approved: list[str] = []
for i, s in enumerate(suggestions, 1):
with st.expander(C.AUTOBALANCE_SUGGESTION_TITLE.format(
i=i, symbol=s["symbol"], source=s["current_tier"],
target=s["suggested_tier"])):
st.write(C.AUTOBALANCE_MOVE.format(
source=s["current_tier"], target=s["suggested_tier"]))
st.write(C.AUTOBALANCE_VALUE.format(value=f"{s['value_eur']:.2f}"))
st.write(s["reason"])
if st.checkbox(C.AUTOBALANCE_APPROVE.format(i=i), key=f"approve_{i}"):
approved.append(s["symbol"])
if approved:
if st.button(C.BTN_APPLY_AUTOBALANCE, key="apply_autobalance"):
updated = apply_rebalance_suggestions(tiers_df, suggestions, approved)
save_tiers(updated)
st.success(C.AUTOBALANCE_APPLIED.format(n=len(approved)))
st.rerun()
st.caption(C.AUTOBALANCE_MANUAL)
def render_trade_limit_warning() -> None:
"""Show a warning when the weekly Alpha trade limit is reached (v10.6.3)."""
from datetime import date as _d
from quant.config import MAX_ALPHA_TRADES_PER_WEEK
from quant.portfolio.behavioral_guardrails import track_weekly_trades
trades = track_weekly_trades(_d.today().isoformat())
remaining = max(0, MAX_ALPHA_TRADES_PER_WEEK - trades)
if trades >= MAX_ALPHA_TRADES_PER_WEEK:
st.error(C.TRADE_LIMIT_REACHED.format(n=trades, max=MAX_ALPHA_TRADES_PER_WEEK))
elif remaining == 1:
st.warning(C.TRADE_LIMIT_ONE_LEFT)
def render_tier_assignment_alerts() -> None:
"""Show alerts for unclassified assets with an auto-assign action (v10.6.3)."""
from quant.portfolio.portfolio import load_portfolio
from quant.portfolio.tier_manager import (
auto_assign_tiers,
detect_unclassified_assets,
load_tiers,
save_tiers,
)
portfolio_df = load_portfolio()
tiers_df = load_tiers()
unclassified = detect_unclassified_assets(portfolio_df, tiers_df)
if not unclassified:
return
st.warning(C.TIER_UNCLASSIFIED_WARNING.format(n=len(unclassified)))
for rec in unclassified:
st.info(C.TIER_UNCLASSIFIED_LINE.format(
symbol=rec["symbol"], tier=rec["recommended_tier"], reason=rec["reason"]))
if st.button(C.BTN_AUTO_ASSIGN_TIERS, key="auto_assign_tiers"):
updated = auto_assign_tiers(unclassified, tiers_df)
save_tiers(updated)
st.success(C.TIER_AUTO_ASSIGNED)
st.rerun()
# ── Page: Today (P4) ──────────────────────────────────────────────────────────
def _resolve_alert(alert_id: int, status: str, reason: str | None) -> None:
"""Resolve an alert from the UI (write connection, short-lived)."""
try:
from quant.data.database import connect_with_retry
from quant.engine import alerts as alerts_mod
conn = connect_with_retry()
try:
alerts_mod.resolve_alert(conn, alert_id, status, reason)
finally:
conn.close()
st.success(C.ALERT_RESOLVED.format(status=status))
except Exception: # noqa: BLE001
st.warning("Could not resolve the action. Try again.")
def render_alerts_banner() -> None:
"""Red block listing open alerts until resolved (v10.7.0, Section 4.4)."""
try:
from quant.data.database import read_only_connection
from quant.engine import alerts as alerts_mod
with read_only_connection() as conn:
open_now = alerts_mod.open_alerts(conn)
except Exception: # noqa: BLE001
return
if not open_now:
return
st.error(C.ALERT_BANNER_TITLE)
for alert in open_now:
st.write(alert.get("message", ""))
cols = st.columns(2)
if cols[0].button(C.BTN_ALERT_DONE, key=f"alert_done_{alert['id']}"):
_resolve_alert(alert["id"], "done", None)
if cols[1].button(C.BTN_ALERT_DECLINED, key=f"alert_decl_{alert['id']}"):
_resolve_alert(alert["id"], "declined", "declined in app")
def _render_overview_money(portfolio, account) -> None:
"""Block A: the two money plaques (v10.7.0, Section 10.1)."""
st.subheader(C.SEC_YOUR_MONEY)
invested = 0.0
pnl = 0.0
estimated = False
est_as_of = None
if portfolio is not None and not portfolio.empty:
# v10.7.3 (Part 3.1): the plaque reads the revalued estimate when
# holdings_meta is newer than the broker CSV.
disp = _holding_display_values(portfolio)
if disp:
invested = sum(v["value"] for v in disp.values())
estimated = any(v["estimated"] for v in disp.values())
est_as_of = next((v["as_of"] for v in disp.values()
if v["estimated"] and v["as_of"]), None)
elif "Current_Value_EUR" in portfolio.columns:
invested = float(portfolio["Current_Value_EUR"].sum())
if "Broker_PnL_EUR" in portfolio.columns:
pnl = float(portfolio["Broker_PnL_EUR"].sum())
cost = invested - pnl
pct = (pnl / cost * 100) if cost > 0 else 0.0
cash = account.cash_eur if account.cash_is_set else 0.0
cols = st.columns(2)
# v10.7.3 (Part 1.12): big plaques show whole EUR; cents live in the caption.
cols[0].metric("Invested", C.fmt_eur_whole(invested))
cols[0].caption(C.INVESTED_LINE.format(
amount=f"{invested:.0f}", pnl=f"{pnl:+.2f}", pct=f"{pct:+.1f}",
date=C.fmt_date(est_as_of or _date.today())))
if estimated:
cols[0].caption(C.ESTIMATED_LABEL.format(
date=C.fmt_date(est_as_of or _date.today())))
cols[1].metric("Operational cash", C.fmt_eur_whole(cash))
cols[1].caption(C.OPERATIONAL_CASH_LINE.format(
amount=f"{cash:.0f}",
date=C.fmt_date(account.cash_updated or _date.today()),
apy=f"{current_cash_apy()*100:g}"))
# v10.7.3 (Part 4.4): the income line.
dividends, cash_yield = _income_12m(cash)
if dividends > 0:
st.caption(C.INCOME_LINE.format(
dividends=f"{dividends:.0f}", cash_yield=f"{cash_yield:.0f}",
apy=f"{current_cash_apy()*100:g}"))
else:
st.caption(C.INCOME_NONE)
# v10.7.3 (Part 8.2): link the provenance section.
st.caption(C.WHERE_NUMBERS)
def _render_overview_steps(holdings) -> None:
"""Block B: your steps this week + the Not this week block."""
from quant.data.database import read_only_connection
from quant.engine import alerts as alerts_mod
from quant.engine import plans
st.subheader(C.SEC_STEPS)
try:
with read_only_connection() as conn:
open_now = alerts_mod.open_alerts(conn)
plan = plans.load_plan(conn, _month_key())
except Exception: # noqa: BLE001
open_now, plan = [], None
# v10.7.1: the "Not this week" block renders the advice pipeline's rejected
# notes (the system showing its work), not a second computation.
_advice_failed = False
try:
from quant.engine.advice import build_advice
_advice, rejected = build_advice(
_monthly_holdings(), open_alerts=open_now, plans=plan)
except Exception as e: # noqa: BLE001
# v10.8.0 (3.2): a failed computation is a visible failure, never an
# empty success state.
logger.warning("Advice failed: %s", e)
_advice, rejected = [], []
_advice_failed = True
# v10.8.0 (Phase 1, redesign 3.2): ONE decision list, three groups. The
# Overview renders the same list every other surface reads.
from quant.engine.decisions import build_decision_list, group_order
if _advice_failed:
st.warning(C.COULD_NOT_CHECK.format(reason="the advice step failed"))
return
decisions = build_decision_list(_advice, holdings, rejected)
if not decisions:
st.write(C.NOTHING_TO_DO_WEEK)
return
for group in group_order():
items = [d for d in decisions if d["group"] == group]
if not items:
continue
st.markdown(f"**{group}**")
for d in items:
line = d["reason"] or d["label"]
if d.get("amount_eur"):
st.write(f"- {line} ({C.fmt_eur(d['amount_eur'])})")
else:
st.write(f"- {line}")
def _render_market_expander(has_review: bool) -> None:
"""Block E: the market expander (regime + the honest news-pillar line).
The regime line renders only when a review exists (S0 has no regime); the
news-pillar line renders whenever the pillar is absent, independent of the
review.
"""
with st.expander(C.SEC_MARKET):
if has_review:
reg = read_regime()
if reg.get("state") == "estimated":
# v10.7.3 (Part 1.2): the regime line lives ONLY here.
st.write(C.MARKET_REGIME_LINE.format(
label=reg.get("label"), confidence=reg.get("confidence")))
elif reg.get("state") == "failed":
st.write(C.MARKET_TREND_FAILED)
else:
st.write(C.MARKET_TREND_INSUFFICIENT)
# v10.7.2 (Part 2.3): the exact absent line when the news pillar is off.
try:
from quant.engine import news_pillar
if news_pillar.is_absent():
st.write(C.NEWS_PILLAR_ABSENT)
except Exception: # noqa: BLE001
pass
def page_today() -> None:
"""Render the Overview page."""
st.title(C.PAGE_TODAY)
render_alerts_banner()
portfolio = load_portfolio()
history = read_history()
# H3.7 (L1): the header/tables/regime read the most recent SUCCESSFUL
# review; the S4 card reads the latest review attempt of any status.
attempt = latest_review()
review = latest_review(ok_only=True)
has_review = bool(review)
if attempt.get("review_status") == "failed":
# S4: the review ran and failed (single source: the run artifact).
if not history.empty:
last_ts = history.iloc[-1]["review_ts"]
st.write(C.HEADER_REVIEW.format(date=C.fmt_date(latest_bar_date()),
prepared=C.fmt_review_ts(last_ts)))
st.warning(C.LAST_REVIEW_FAILED)
elif not has_review:
st.info(C.GUIDE_NO_REVIEW)
else:
prepared = C.fmt_review_ts(review.get("review_ts"))
# H3.8 (M1): BOTH header fields come from the SAME review artifact; a
# legacy artifact with no close date renders only the prepared line
# (never a borrowed live close date).
_bar = review.get("latest_bar")
bar = C.fmt_date(_bar) if (_bar and str(_bar).strip().lower()
not in ("unknown", "nan", "none")) else ""
if bar:
st.write(C.HEADER_REVIEW.format(date=bar, prepared=prepared))
else:
st.write(C.HEADER_REVIEW_PREPARED.format(prepared=prepared))
# v10.7.3 (Part 1.2): the market line moved into the Market expander only.
# R8: markets-closed freshness line (header-level, independent of review).
_closed = _markets_closed_line()
if _closed:
st.write(_closed)
holdings = read_actions()
# v10.7.0 Overview blocks A-C (Section 10.1).
_account = load_account()
_render_overview_money(portfolio, _account)
_render_overview_steps(holdings)
# v10.8.0 (Phase 2): the savings-plan block is deleted from Overview. The
# plan lives once, on the Monthly decision page; the sidebar links there.
_render_market_expander(has_review)
# 2. Portfolio value chart (spec 3.1).
st.subheader(C.SEC_PORTFOLIO_VALUE)
_render_value_chart(history, holdings)
# v10.8.0 (Phase 2): the "Where your money is" donut is deleted. The split
# lives once, on My holdings, as plain lines (no duplicate composition view).
account = load_account()
# 4. Holdings table. Status comes from the audit actions so the table and the
# cards can never disagree (A3). S0 -> every row "Not reviewed yet".
table_rows = []
if holdings:
for h in holdings:
table_rows.append({
"Holding": label_for(h.get("name") or h["symbol"], h["symbol"]),
"Value": C.fmt_eur(h["value_eur"]),
"Share vs target": f"{h['current_weight']} / {h['target_weight']}",
"Status": h["status"],
})
elif not portfolio.empty:
for sym in portfolio["Symbol"].astype(str):
table_rows.append({"Holding": sym, "Value": "",
"Share vs target": "", "Status": C.STATUS_NOT_REVIEWED})
if table_rows:
st.dataframe(pd.DataFrame(table_rows), width="stretch", hide_index=True)
# 5. What to do today (S0/S1: nothing, S2: cards).
if has_review:
st.subheader(C.SEC_WHAT_TO_DO)
render_action_cards(holdings)
_render_suppression_footnotes(holdings)
# R8: savings-plan countdown under the actions block, only when a
# holding actually routes to a savings plan.
if account.savings_plan_day and _any_savings_plan_holding(portfolio):
st.write(C.savings_plan_line(_date.today(), account.savings_plan_day))
# 6. Needs attention first (sentence only; Repair lives in Settings Health).
blockers = [h for h in holdings if h["blocked"]]
if blockers:
st.subheader(C.SEC_NEEDS_ATTENTION)
for b in blockers:
st.warning(C.NEEDS_ATTENTION_ISIN.format(symbol=b["symbol"]))
# ── Page: My holdings (v10.7.1, Section 10.2) ─────────────────────────────────
def _latest_close(symbol: str) -> float | None:
"""Latest close for a symbol in EUR (v10.8.0, 2.2); None when absent."""
from quant.data.currency import price_in_eur
return price_in_eur(symbol)
def _income_12m(cash: float) -> tuple[float, float]:
"""Dividends from flows (last 12 months) and the cash yield (Part 4.4)."""
dividends = 0.0
try:
from datetime import timedelta
from quant.data.database import read_only_connection
from quant.engine import flows
start = _date.today() - timedelta(days=365)
with read_only_connection() as conn:
for f in flows.load_flows(conn, start=start):
if str(f.get("type")) == "dividend":
dividends += float(f.get("amount_eur", 0) or 0)
except Exception: # noqa: BLE001
pass
return dividends, float(cash) * current_cash_apy()
def _last_sync_date():
"""The newest holdings_meta sync_date, or None (v10.7.3, Part 3.2)."""
try:
from quant.data.database import read_only_connection
with read_only_connection() as conn:
row = conn.execute("SELECT MAX(sync_date) FROM holdings_meta").fetchone()
return row[0] if row else None
except Exception: # noqa: BLE001
return None
def _holdings_meta_row(symbol: str):
"""(shares, sync_date, invested_at_sync) for a symbol, or None."""
try:
from quant.data.database import read_only_connection
with read_only_connection() as conn:
row = conn.execute(
"SELECT shares, sync_date, invested_at_sync FROM holdings_meta "
"WHERE symbol = ?", [symbol]).fetchone()
return row
except Exception: # noqa: BLE001
return None
def _estimate_is_fresher() -> bool:
"""True when holdings_meta is newer than the broker CSV (v10.7.3, Part 3.1).
A pending-sync marker (actuals or a quick event recorded since the last CSV
export) always means the estimate is fresher. Otherwise the newest
holdings_meta sync_date is compared to the CSV file's modification date.
"""
try:
from quant.engine import plans
if plans.is_pending_sync():
return True
except Exception: # noqa: BLE001
pass
try:
import os
from quant.data.database import read_only_connection
csv_mtime = os.path.getmtime(paths.DATA_PORTFOLIO)
with read_only_connection() as conn:
row = conn.execute("SELECT MAX(sync_date) FROM holdings_meta").fetchone()
if not row or row[0] is None:
return False
last = row[0]
if isinstance(last, str):
last = _date.fromisoformat(last[:10])
return last.toordinal() > _date.fromtimestamp(csv_mtime).toordinal()
except Exception: # noqa: BLE001
return False
def _holding_display_values(portfolio) -> dict:
"""Per-symbol display value/entry from the ONE position source (v10.8.0, 2.1).
Returns {symbol: {value, entry, estimated, as_of}}.
"""
from quant.engine.positions import positions_now
out: dict = {}
for p in positions_now():
out[p["symbol"]] = {
"value": p["value_eur"], "entry": p["entry_eur"],
"estimated": p["estimated"], "as_of": p["as_of"],
}
return out
def _render_asset_chart(symbol: str) -> None:
"""Per-asset line chart: normalized to 100, 1M/3M/1Y/Max, no fill."""
import plotly.graph_objects as go
df = q("SELECT Date AS d, Close FROM market_history WHERE Symbol = ? "
"ORDER BY Date ASC", [symbol])
if df is None or df.empty:
st.caption(C.CHART_NO_HISTORY.format(name=symbol))
return
df["d"] = pd.to_datetime(df["d"], errors="coerce")
df = df.dropna(subset=["d"]).sort_values("d")
rng = st.segmented_control(C.LABEL_RANGE, list(_RANGE_DAYS),
default="Max", key=f"asset_range_{symbol}") or "Max"
df = _filter_range(df, rng)
if len(df) < 2:
st.caption(C.CHART_NO_HISTORY.format(name=symbol))
return
base = float(df["Close"].iloc[0]) or 1.0
series = df["Close"] / base * 100.0
fig = go.Figure(go.Scatter(x=df["d"], y=series, mode="lines",
line=dict(width=2, color=P.ACCENT), fill=None))
fig.add_hline(y=100.0, line_dash="dot", line_color=P.BASELINE)
lo, hi = float(series.min()), float(series.max())
pad = (hi - lo) * 0.1 or 1.0
fig.update_layout(height=240, margin=dict(l=8, r=8, t=8, b=8),
yaxis=dict(range=[lo - pad, hi + pad]), showlegend=False)
st.plotly_chart(fig, width="stretch")
def _verdict_word(a) -> str:
"""The Verdict cell from a build_advice record (v10.7.3, Part 4.1).
The legacy status mapping is removed from the render path: a FORTRESS row
can only show "Keep, do nothing" or the savings-plan top-up sentence.
"""
if not a:
return C.STATUS_NOT_REVIEWED
kind = a.get("kind")
if kind == "keep":
return C.ADVICE_KEEP
if kind == "change_savings_plan":
return a.get("why") or C.ADVICE_TOP_UP
if kind == "sell_part":
amt = a.get("eur")
return (C.STATUS_SELL_PART_AMOUNT.format(amount=f"{amt:.0f}")
if amt else C.ADVICE_SELL_PART)
if kind == "buy":
amt = a.get("eur")
return f"Buy (about {amt:.0f} EUR)" if amt else C.ADVICE_BUY
if kind == "to_cash":
return C.ADVICE_TO_CASH
return C.ADVICE_KEEP
def _estimated_only_positions(portfolio) -> dict:
"""holdings_meta rows not in the broker CSV (v10.7.3, Part 3.4).
A buy recorded for a symbol not yet held creates an estimated position that
appears in the verdicts table with the estimated label.
"""
out: dict = {}
try:
from quant.data.database import read_only_connection
with read_only_connection() as conn:
rows = conn.execute(
"SELECT symbol, shares, sync_date FROM holdings_meta").fetchall()
except Exception: # noqa: BLE001
return out
csv_syms = set(portfolio["Symbol"].astype(str)) if (
portfolio is not None and not portfolio.empty) else set()
for sym, shares, sync_date in rows:
s = str(sym)
if s in csv_syms:
continue
price = _latest_close(s)
if price is None:
continue
out[s] = {"value": float(shares) * float(price), "as_of": sync_date}
return out
def _score_cell(value) -> str:
"""A score cell as a string, so the column is Arrow-compatible.
Mixing floats and "" in one column makes Streamlit's Arrow conversion fail
("Could not convert '' ... to double"). Formatting every cell as a string
keeps the column homogeneous.
"""
if value is None or value == "":
return ""
try:
return f"{float(value):.0f}"
except (TypeError, ValueError):
return str(value)
def _render_holdings_table(portfolio, holdings) -> None:
"""Block 2: the holdings table (no per-share columns, B7).
v10.7.3 (Part 1.6): the page already has ONE "My holdings" heading; this
table must not repeat it. v10.7.3 (Part 3.1): the Value column reads the
revalued estimate when holdings_meta is newer than the broker CSV.
"""
# v10.7.3 (Part 4.1): the Verdict column renders ONLY from build_advice.
from quant.engine.advice import build_advice
from quant.portfolio.tier_manager import load_tiers_safe, tier_map
try:
tiers_df, _ = load_tiers_safe()
tmap = tier_map(tiers_df)
except Exception: # noqa: BLE001
tmap = {}
advice, _rejected = build_advice(holdings=_monthly_holdings(), tiers=tmap)
verdict_by_sym = {a["symbol"]: a for a in advice if a.get("symbol")}
disp = _holding_display_values(portfolio)
rows = []
estimated_any = False
if portfolio is not None and not portfolio.empty:
for _, r in portfolio.iterrows():
sym = str(r["Symbol"])
scores = read_scores(sym)
d = disp.get(sym, {})
if d.get("estimated"):
estimated_any = True
# v10.7.6 (Part 1): the Buffett score is shown only for equities with
# cached fundamentals; ETFs and commodities have no company data.
has_fund = bool(_fundamentals_for(sym))
buffett = _buffett_for(sym) if has_fund else None
rows.append({
"Name": _display_name(sym),
"Value (EUR)": C.fmt_eur(d.get("value", r.get("Current_Value_EUR"))),
"Profit (EUR)": C.fmt_eur(r.get("Broker_PnL_EUR")),
"Structure": _score_cell(scores.get("structural_grade")),
"Tactics": _score_cell(scores.get("tactical_grade")),
"Buffett": f"{buffett['score']:.0f}" if buffett else "",
"Verdict": _verdict_word(verdict_by_sym.get(sym)),
})
# v10.7.3 (Part 3.4): a buy for an unheld symbol appears as an estimated
# position with a pending-sync marker.
for sym, d in _estimated_only_positions(portfolio).items():
rows.append({
"Name": _display_name(sym),
"Value (EUR)": C.fmt_eur(d["value"]),
"Profit (EUR)": "",
"Structure": "", "Tactics": "", "Buffett": "",
"Verdict": C.ESTIMATED_PENDING,
})
estimated_any = True
if rows:
st.dataframe(pd.DataFrame(rows), width="stretch", hide_index=True)
# v10.7.3 (Part 1.7): one-line scores caption under the verdicts table.
st.caption(C.SCORES_CAPTION)
if estimated_any:
st.caption(C.ESTIMATED_LABEL.format(date=C.fmt_date(_date.today())))
def _render_holding_expanders(portfolio, holdings) -> None:
"""Block 3: one expander per holding with the per-asset detail."""
if portfolio is None or portfolio.empty:
return
by_sym = {h["symbol"]: h for h in holdings}
disp = _holding_display_values(portfolio)
for _, r in portfolio.iterrows():
sym = str(r["Symbol"])
# v10.7.3 (Part 1.8 / 2.2): expanders are labeled with company names.
name = _display_name(sym)
with st.expander(name):
_render_asset_chart(sym)
d = disp.get(sym, {})
# v10.7.3 (Part 3.2): entry price is estimated and live.
entry = float(d.get("entry", r.get("Avg_Entry_Price", 0) or 0))
current = _latest_close(sym)
st.write(C.ENTRY_PRICE_LINE.format(
entry=f"{entry:.2f}",
current=f"{current:.2f}" if current is not None else "not available yet",
estimated=C.ESTIMATED_LABEL.format(
date=C.fmt_date(d.get("as_of") or _date.today()))))
try:
from quant.data.database import read_only_connection
with read_only_connection() as conn:
row = conn.execute(
"SELECT shares, sync_date FROM holdings_meta WHERE symbol = ?",
[sym]).fetchone()
if row:
st.write(C.SHARES_LINE.format(
shares=f"{float(row[0]):.3f}", date=C.fmt_date(row[1])))
except Exception: # noqa: BLE001
pass
h = by_sym.get(sym, {})
st.write(C.TIER_LINE.format(tier=h.get("tier_word") or C.tier_word("ALPHA")))
st.write(C.WHY_VERDICT_LINE.format(
why=h.get("reason") or "within its target band."))
# v10.7.6 (Part 1): the Buffett quality lens for equities only.
if _fundamentals_for(sym):
buffett = _buffett_for(sym)