File size: 10,489 Bytes
e6a3151
f773bc9
 
e6a3151
 
 
 
f773bc9
e6a3151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f773bc9
e6a3151
 
 
 
f773bc9
e6a3151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f773bc9
e6a3151
 
 
 
f773bc9
e6a3151
 
 
 
 
 
 
 
f773bc9
e6a3151
 
 
 
f773bc9
e6a3151
f773bc9
e6a3151
 
 
 
f773bc9
 
e6a3151
f773bc9
e6a3151
 
 
 
f773bc9
e6a3151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f773bc9
 
e6a3151
f773bc9
 
e6a3151
f773bc9
 
 
 
 
e6a3151
f773bc9
aa9ef27
 
 
f773bc9
e6a3151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f773bc9
 
 
2750cce
f773bc9
 
 
2750cce
 
f773bc9
 
 
e6a3151
f773bc9
 
 
 
e6a3151
 
f773bc9
 
 
 
 
 
aa9ef27
 
 
f773bc9
 
e6a3151
 
f773bc9
e6a3151
 
f773bc9
 
 
e6a3151
 
c789300
 
e6a3151
 
 
aa9ef27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e176953
aa9ef27
 
 
 
 
 
 
 
 
 
 
 
 
 
816b15c
 
 
 
 
 
 
 
 
 
 
e176953
 
 
 
 
816b15c
 
e176953
 
 
816b15c
e176953
 
 
 
 
 
 
 
816b15c
aa9ef27
e176953
 
aa9ef27
 
 
816b15c
aa9ef27
 
e176953
aa9ef27
 
e176953
aa9ef27
 
 
 
 
 
e6a3151
 
f773bc9
e6a3151
 
 
 
f773bc9
e6a3151
 
f773bc9
 
e6a3151
f773bc9
e6a3151
 
f773bc9
e6a3151
f773bc9
 
 
 
e6a3151
 
 
 
f773bc9
e6a3151
 
f773bc9
 
 
 
e6a3151
 
be4d472
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
"""
QuantScale AI - Streamlit Frontend (Main App)
Directly imports QuantScaleSystem - no HTTP dependency needed.
"""
import re
import pandas as pd
import streamlit as st
from core.schema import OptimizationRequest

# --- Page Config ---
st.set_page_config(
    page_title="QuantScale AI",
    page_icon="πŸ“ˆ",
    layout="wide",
    initial_sidebar_state="collapsed"
)

st.markdown("""
<style>
    .stApp { background-color: #0f1117; }
    .main-header {
        background: linear-gradient(90deg, #60a5fa, #34d399);
        -webkit-background-clip: text;
        background-clip: text;
        -webkit-text-fill-color: transparent;
        font-size: 2.5rem;
        font-weight: 700;
        text-align: center;
        padding-top: 1rem;
    }
    .sub-header {
        color: #94a3b8;
        font-size: 1.1rem;
        text-align: center;
        margin-bottom: 2rem;
    }
    div[data-testid="metric-container"] {
        background-color: #1e212b;
        border: 1px solid #2d3748;
        border-radius: 12px;
        padding: 1rem;
    }
    .section-title {
        color: #94a3b8;
        font-size: 0.8rem;
        text-transform: uppercase;
        letter-spacing: 0.08em;
        font-weight: 600;
        margin-bottom: 0.5rem;
        margin-top: 1.5rem;
    }
    .narrative-box {
        background-color: #1e212b;
        border-left: 4px solid #10b981;
        padding: 1.5rem;
        border-radius: 0 12px 12px 0;
        line-height: 1.8;
        color: #e2e8f0;
        font-size: 0.95rem;
    }
</style>
""", unsafe_allow_html=True)

# --- Parsers ---
def parse_investment_amount(text: str) -> float:
    text = text.replace(",", "")
    match = re.search(r'\$?([\d.]+)\s*([kKmM]?)', text)
    if match:
        amount = float(match.group(1))
        suffix = match.group(2).lower()
        if suffix == 'k': amount *= 1_000
        elif suffix == 'm': amount *= 1_000_000
        return amount
    return 100_000.0


def parse_strategy(text: str):
    lower = text.lower()
    strategy, top_n = None, None
    if "smallest" in lower:
        strategy = "smallest_market_cap"
    elif "largest" in lower:
        strategy = "largest_market_cap"
    if strategy:
        match = re.search(r'(\d+)\s*(?:smallest|largest|companies|stocks)', lower)
        top_n = int(match.group(1)) if match else 50
    return strategy, top_n


def build_portfolio_df(allocations: dict, investment: float) -> pd.DataFrame:
    rows = []
    for ticker, weight in sorted(allocations.items(), key=lambda x: x[1], reverse=True):
        rows.append({
            "Ticker": ticker,
            "Allocation (%)": f"{weight * 100:.2f}%",
            "Investment ($)": f"${weight * investment:,.2f}"
        })
    return pd.DataFrame(rows)


# --- Lazy-load system to avoid import overhead on every rerender ---
@st.cache_resource(show_spinner="Loading QuantScale Engine...")
def get_system():
    from main import QuantScaleSystem
    return QuantScaleSystem()


import plotly.graph_objects as go
import plotly.express as px

# --- UI ---
st.markdown('<div class="main-header">QuantScale AI</div>', unsafe_allow_html=True)
st.markdown('<div class="sub-header">Direct Indexing & Attribution Engine</div>', unsafe_allow_html=True)

user_input = st.text_area(
    "",
    placeholder="Describe your goal, e.g., 'Optimize my $10,000 portfolio but exclude the Energy sector.'",
    height=100,
    label_visibility="collapsed"
)
run_btn = st.button("πŸš€ Generate Portfolio Strategy", use_container_width=True, type="primary")

if run_btn and user_input:
    investment_amount = parse_investment_amount(user_input)
    strategy, top_n = parse_strategy(user_input)

    request = OptimizationRequest(
        client_id="StreamlitUser",
        initial_investment=investment_amount,
        excluded_sectors=[], # Let the LLM derive this
        excluded_tickers=[],
        strategy=strategy,
        top_n=top_n,
        benchmark="^GSPC",
        user_prompt=user_input
    )

    with st.spinner("βš™οΈ Running Convex Optimization & AI Analysis..."):
        try:
            system = get_system()
            result = system.run_pipeline(request)
        except Exception as e:
            st.error(f"❌ Optimization error: {e}")
            st.stop()

    if not result:
        st.error("Pipeline returned no result. Check your input.")
        st.stop()

    opt = result["optimization"]
    commentary = result["commentary"]
    market_data = result["market_data"]
    benchmark_weights = result["benchmark_weights"]
    sector_map = result["sector_map"]

    # --- Metrics ---
    col1, col2, col3 = st.columns(3)
    with col1:
        st.metric("πŸ’Ό Invested", f"${investment_amount:,.0f}")
    with col2:
        st.metric(
            "πŸ“Š Tracking Error",
            f"{opt.tracking_error * 100:.4f}%",
            help="How closely the portfolio tracks the S&P 500"
        )
    with col3:
        excl_display = ", ".join(request.excluded_sectors) if request.excluded_sectors else "None"
        st.metric("🚫 Excluded", excl_display if len(excl_display) <= 30 else f"{len(request.excluded_sectors)} Sectors")

    st.divider()

    # --- Advanced EDA Section ---
    st.markdown('<p class="section-title">Market Context & Portfolio EDA</p>', unsafe_allow_html=True)
    
    eda_col1, eda_col2 = st.columns([2, 1])
    
    with eda_col1:
        # 1. Performance Comparison (Trailing 30 Days)
        last_30_returns = market_data.iloc[-21:]
        
        # Portfolio vs Benchmark Cumulative Returns
        bench_daily = (last_30_returns * benchmark_weights).sum(axis=1)
        port_daily = (last_30_returns * pd.Series(opt.weights)).sum(axis=1)
        
        cum_bench = (1 + bench_daily).cumprod() * 100
        cum_port = (1 + port_daily).cumprod() * 100
        
        fig_perf = go.Figure()
        fig_perf.add_trace(go.Scatter(x=cum_bench.index, y=cum_bench, name="Benchmark (S&P 500 Proxy)", line=dict(color="#94a3b8", width=2, dash='dot')))
        fig_perf.add_trace(go.Scatter(x=cum_port.index, y=cum_port, name="Optimized Portfolio", line=dict(color="#60a5fa", width=3)))
        
        fig_perf.update_layout(
            title="Growth of $100 (Trailing 30 Days)",
            template="plotly_dark",
            paper_bgcolor='rgba(0,0,0,0)',
            plot_bgcolor='rgba(0,0,0,0)',
            margin=dict(l=20, r=20, t=40, b=20),
            legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1)
        )
        st.plotly_chart(fig_perf, use_container_width=True)

    with eda_col2:
        # 2. Sector Weight Comparison (Bar)
        # Aggregating sector weights
        port_sector_weights = {}
        bench_sector_weights = {}
        
        for ticker, weight in opt.weights.items():
            s = sector_map.get(ticker, "Unknown")
            port_sector_weights[s] = port_sector_weights.get(s, 0) + weight
            
        for ticker, weight in benchmark_weights.items():
            s = sector_map.get(ticker, "Unknown")
            bench_sector_weights[s] = bench_sector_weights.get(s, 0) + weight
            
        all_sectors = sorted(list(set(list(port_sector_weights.keys()) + list(bench_sector_weights.keys()))))
        
        # 3. Portfolio Composition Pie Chart (Holding Level)
        # Using top 15 holdings for better readability in the pie
        sorted_weights = sorted(opt.weights.items(), key=lambda x: x[1], reverse=True)
        top_holdings = sorted_weights[:15]
        other_weight = sum(w for t, w in sorted_weights[15:])
        
        labels = [t for t, w in top_holdings]
        values = [w for t, w in top_holdings]
        if other_weight > 0:
            labels.append("Others")
            values.append(other_weight)
        
        fig_pie = go.Figure(data=[go.Pie(
            labels=labels, 
            values=values, 
            hole=.4,
            textinfo='label',
            marker=dict(colors=px.colors.qualitative.Prism)
        )])
        
        fig_pie.update_layout(
            title="Top Holdings Allocation",
            template="plotly_dark",
            paper_bgcolor='rgba(0,0,0,0)',
            plot_bgcolor='rgba(0,0,0,0)',
            margin=dict(l=10, r=10, t=40, b=10),
            showlegend=False
        )
        st.plotly_chart(fig_pie, use_container_width=True)

        # Bar chart for sector comparison
        fig_sector = go.Figure(data=[
            go.Bar(name='Bench', x=all_sectors, y=[bench_sector_weights.get(s, 0)*100 for s in all_sectors], marker_color="#94a3b8"),
            go.Bar(name='Port', x=all_sectors, y=[port_sector_weights.get(s, 0)*100 for s in all_sectors], marker_color="#34d399")
        ])
        
        fig_sector.update_layout(
            title="Sector Exposure Match (%)",
            template="plotly_dark",
            barmode='group',
            height=250,
            paper_bgcolor='rgba(0,0,0,0)',
            plot_bgcolor='rgba(0,0,0,0)',
            margin=dict(l=10, r=10, t=30, b=10),
            showlegend=False
        )
        st.plotly_chart(fig_sector, use_container_width=True)

    st.divider()

    # --- AI Commentary ---
    st.markdown('<p class="section-title">AI Performance Attribution</p>', unsafe_allow_html=True)
    st.markdown(f'<div class="narrative-box">{commentary}</div>', unsafe_allow_html=True)

    st.divider()

    # --- Full Portfolio Table ---
    allocations = opt.weights
    if allocations:
        df = build_portfolio_df(allocations, investment_amount)
        total = len(df)

        st.markdown(
            f'<p class="section-title">Full Portfolio Allocation (100%) β€” {total} Holdings</p>',
            unsafe_allow_html=True
        )

        c1, c2, c3 = st.columns(3)
        c1.metric("Total Holdings", total)
        c2.metric("Largest Position", df["Ticker"].iloc[0])
        c3.metric("Smallest Position", df["Ticker"].iloc[-1])

        st.dataframe(
            df,
            use_container_width=True,
            hide_index=True,
            height=min(500, 36 * total + 40),
            column_config={
                "Ticker": st.column_config.TextColumn("Ticker", width="small"),
                "Allocation (%)": st.column_config.TextColumn("Allocation (%)", width="small"),
                "Investment ($)": st.column_config.TextColumn(
                    f"Investment (of ${investment_amount:,.0f})", width="medium"
                ),
            }
        )
# Metadata: Update trigger for build system