AJAY KASU commited on
Commit
f773bc9
Β·
1 Parent(s): e6a3151

Feat: Switch to Streamlit as main app (Option A)

Browse files
Files changed (2) hide show
  1. Dockerfile +3 -3
  2. streamlit_app.py +71 -94
Dockerfile CHANGED
@@ -7,8 +7,8 @@ RUN pip install --no-cache-dir -r requirements.txt
7
 
8
  COPY . .
9
 
10
- # Expose API Port
11
  EXPOSE 7860
12
 
13
- # Run FastAPI
14
- CMD ["uvicorn", "api.app:app", "--host", "0.0.0.0", "--port", "7860"]
 
7
 
8
  COPY . .
9
 
10
+ # Expose Streamlit Port
11
  EXPOSE 7860
12
 
13
+ # Run Streamlit
14
+ CMD ["streamlit", "run", "streamlit_app.py", "--server.port=7860", "--server.address=0.0.0.0", "--server.headless=true"]
streamlit_app.py CHANGED
@@ -1,12 +1,11 @@
1
  """
2
- QuantScale AI - Streamlit Frontend
3
- Calls the existing FastAPI `/optimize` endpoint and displays the
4
- full portfolio allocation with Investment ($) and Allocation (%).
5
  """
6
  import re
7
- import requests
8
  import pandas as pd
9
  import streamlit as st
 
10
 
11
  # --- Page Config ---
12
  st.set_page_config(
@@ -16,20 +15,18 @@ st.set_page_config(
16
  initial_sidebar_state="collapsed"
17
  )
18
 
19
- # Custom dark-mode CSS
20
  st.markdown("""
21
  <style>
22
- /* Overall dark background */
23
  .stApp { background-color: #0f1117; }
24
-
25
- /* Header */
26
  .main-header {
27
  background: linear-gradient(90deg, #60a5fa, #34d399);
28
  -webkit-background-clip: text;
 
29
  -webkit-text-fill-color: transparent;
30
  font-size: 2.5rem;
31
  font-weight: 700;
32
  text-align: center;
 
33
  }
34
  .sub-header {
35
  color: #94a3b8;
@@ -37,26 +34,21 @@ st.markdown("""
37
  text-align: center;
38
  margin-bottom: 2rem;
39
  }
40
-
41
- /* Metric Cards */
42
  div[data-testid="metric-container"] {
43
  background-color: #1e212b;
44
  border: 1px solid #2d3748;
45
  border-radius: 12px;
46
  padding: 1rem;
47
  }
48
-
49
- /* Section headers */
50
  .section-title {
51
  color: #94a3b8;
52
- font-size: 0.85rem;
53
  text-transform: uppercase;
54
  letter-spacing: 0.08em;
55
  font-weight: 600;
56
  margin-bottom: 0.5rem;
 
57
  }
58
-
59
- /* Narrative / Commentary Box */
60
  .narrative-box {
61
  background-color: #1e212b;
62
  border-left: 4px solid #10b981;
@@ -64,21 +56,12 @@ st.markdown("""
64
  border-radius: 0 12px 12px 0;
65
  line-height: 1.8;
66
  color: #e2e8f0;
67
- }
68
-
69
- /* Dataframe styling override */
70
- .stDataFrame thead tr th {
71
- background-color: #1e212b !important;
72
- color: #94a3b8 !important;
73
- font-size: 0.8rem !important;
74
- text-transform: uppercase;
75
- letter-spacing: 0.05em;
76
  }
77
  </style>
78
  """, unsafe_allow_html=True)
79
 
80
  # --- Constants ---
81
- API_BASE_URL = "http://localhost:8000" # Change to HF Space URL in prod
82
  SECTOR_KEYWORDS = {
83
  "Energy": ["energy", "oil", "gas"],
84
  "Technology": ["technology", "tech", "software", "it"],
@@ -93,32 +76,27 @@ SECTOR_KEYWORDS = {
93
  INCLUDE_KEYWORDS = ["keep", "include", "with", "stay", "portfolio", "only"]
94
 
95
 
 
96
  def parse_investment_amount(text: str) -> float:
97
- """Extract dollar amount from natural language. Returns 100_000 as default."""
98
- text = text.replace(",", "") # Remove commas: $10,000 -> $10000
99
- # Match patterns like $10000, $10K, 10K, 10000, 50k
100
  match = re.search(r'\$?([\d.]+)\s*([kKmM]?)', text)
101
  if match:
102
  amount = float(match.group(1))
103
  suffix = match.group(2).lower()
104
- if suffix == 'k':
105
- amount *= 1_000
106
- elif suffix == 'm':
107
- amount *= 1_000_000
108
  return amount
109
- return 100_000.0 # Default
110
 
111
 
112
  def parse_excluded_sectors(text: str) -> list:
113
- """Extract sectors to exclude from natural language, respecting 'keep' intent."""
114
  lower = text.lower()
115
  excluded = []
116
  for sector, keywords in SECTOR_KEYWORDS.items():
117
  if any(k in lower for k in keywords):
118
- import re as _re
119
- inc_pattern = _re.compile(
120
  rf'({"|".join(INCLUDE_KEYWORDS)})\s+(the\s+)?({"|".join([sector.lower()] + keywords)})',
121
- _re.IGNORECASE
122
  )
123
  if not inc_pattern.search(lower):
124
  excluded.append(sector)
@@ -126,10 +104,8 @@ def parse_excluded_sectors(text: str) -> list:
126
 
127
 
128
  def parse_strategy(text: str):
129
- """Detect strategy keywords and Top N."""
130
  lower = text.lower()
131
- strategy = None
132
- top_n = None
133
  if "smallest" in lower:
134
  strategy = "smallest_market_cap"
135
  elif "largest" in lower:
@@ -141,25 +117,27 @@ def parse_strategy(text: str):
141
 
142
 
143
  def build_portfolio_df(allocations: dict, investment: float) -> pd.DataFrame:
144
- """Convert raw allocation dict to a formatted DataFrame."""
145
  rows = []
146
  for ticker, weight in sorted(allocations.items(), key=lambda x: x[1], reverse=True):
147
  rows.append({
148
  "Ticker": ticker,
149
- "Allocation (%)": weight,
150
- "Investment ($)": weight * investment
151
  })
152
- df = pd.DataFrame(rows)
153
- df["Allocation (%)"] = df["Allocation (%)"].apply(lambda x: f"{x * 100:.2f}%")
154
- df["Investment ($)"] = df["Investment ($)"].apply(lambda x: f"${x:,.2f}")
155
- return df
156
 
 
 
 
 
 
157
 
158
- # --- UI Layout ---
 
159
  st.markdown('<div class="main-header">QuantScale AI</div>', unsafe_allow_html=True)
160
  st.markdown('<div class="sub-header">Direct Indexing & Attribution Engine</div>', unsafe_allow_html=True)
161
 
162
- # Input
163
  user_input = st.text_area(
164
  "",
165
  placeholder="Describe your goal, e.g., 'Optimize my $10,000 portfolio but exclude the Energy sector.'",
@@ -168,85 +146,84 @@ user_input = st.text_area(
168
  )
169
  run_btn = st.button("πŸš€ Generate Portfolio Strategy", use_container_width=True, type="primary")
170
 
171
- # --- Main Logic ---
172
  if run_btn and user_input:
173
  investment_amount = parse_investment_amount(user_input)
174
  excluded_sectors = parse_excluded_sectors(user_input)
175
  strategy, top_n = parse_strategy(user_input)
176
 
177
- payload = {
178
- "client_id": "StreamlitUser",
179
- "initial_investment": investment_amount,
180
- "excluded_sectors": excluded_sectors,
181
- "excluded_tickers": [],
182
- "strategy": strategy,
183
- "top_n": top_n,
184
- "benchmark": "^GSPC"
185
- }
186
-
187
- with st.spinner("Running Convex Optimization & AI Analysis..."):
188
  try:
189
- resp = requests.post(f"{API_BASE_URL}/optimize", json=payload, timeout=120)
190
- resp.raise_for_status()
191
- data = resp.json()
192
- except requests.exceptions.RequestException as e:
193
- st.error(f"❌ API Error: {e}")
194
  st.stop()
195
 
196
- # --- Metrics Row ---
 
 
 
 
 
 
 
197
  col1, col2, col3 = st.columns(3)
198
  with col1:
199
- st.metric(
200
- "πŸ’Ό Invested Amount",
201
- f"${investment_amount:,.0f}"
202
- )
203
  with col2:
204
- te = data.get("tracking_error", 0.0)
205
  st.metric(
206
- "πŸ“Š Projected Tracking Error",
207
- f"{te * 100:.4f}%",
208
- help="How closely this portfolio tracks the S&P 500. Lower = more index-like."
209
  )
210
  with col3:
211
- excl_text = ", ".join(excluded_sectors) if excluded_sectors else "None"
212
- st.metric("🚫 Excluded Sectors", excl_text if len(excl_text) < 25 else f"{len(excluded_sectors)} Sectors")
213
 
214
  st.divider()
215
 
216
  # --- AI Commentary ---
217
  st.markdown('<p class="section-title">AI Performance Attribution</p>', unsafe_allow_html=True)
218
- narrative = data.get("attribution_narrative", "No commentary generated.")
219
- st.markdown(f'<div class="narrative-box">{narrative}</div>', unsafe_allow_html=True)
220
 
221
  st.divider()
222
 
223
  # --- Full Portfolio Table ---
224
- allocations = data.get("allocations", {})
225
  if allocations:
226
  df = build_portfolio_df(allocations, investment_amount)
227
- total_holdings = len(df)
228
-
229
  st.markdown(
230
- f'<p class="section-title">Full Portfolio Allocation (100%) β€” {total_holdings} Holdings</p>',
231
  unsafe_allow_html=True
232
  )
233
-
234
- # Summary stats row above table
235
  c1, c2, c3 = st.columns(3)
236
- c1.metric("Total Holdings", total_holdings)
237
- c2.metric("Largest Position", df["Ticker"].iloc[0] if len(df) else "β€”")
238
- c3.metric("Smallest Position", df["Ticker"].iloc[-1] if len(df) else "β€”")
239
-
240
  st.dataframe(
241
  df,
242
  use_container_width=True,
243
  hide_index=True,
244
- height=min(400, 36 * total_holdings + 36), # Dynamic auto-height, max 400px scrollable
245
  column_config={
246
  "Ticker": st.column_config.TextColumn("Ticker", width="small"),
247
- "Allocation (%)": st.column_config.TextColumn("Allocation (%)", width="medium"),
248
- "Investment ($)": st.column_config.TextColumn(f"Investment ($) of ${investment_amount:,.0f}", width="medium"),
 
 
249
  }
250
  )
251
- else:
252
- st.warning("No allocation data returned from the optimizer.")
 
1
  """
2
+ QuantScale AI - Streamlit Frontend (Main App)
3
+ Directly imports QuantScaleSystem - no HTTP dependency needed.
 
4
  """
5
  import re
 
6
  import pandas as pd
7
  import streamlit as st
8
+ from core.schema import OptimizationRequest
9
 
10
  # --- Page Config ---
11
  st.set_page_config(
 
15
  initial_sidebar_state="collapsed"
16
  )
17
 
 
18
  st.markdown("""
19
  <style>
 
20
  .stApp { background-color: #0f1117; }
 
 
21
  .main-header {
22
  background: linear-gradient(90deg, #60a5fa, #34d399);
23
  -webkit-background-clip: text;
24
+ background-clip: text;
25
  -webkit-text-fill-color: transparent;
26
  font-size: 2.5rem;
27
  font-weight: 700;
28
  text-align: center;
29
+ padding-top: 1rem;
30
  }
31
  .sub-header {
32
  color: #94a3b8;
 
34
  text-align: center;
35
  margin-bottom: 2rem;
36
  }
 
 
37
  div[data-testid="metric-container"] {
38
  background-color: #1e212b;
39
  border: 1px solid #2d3748;
40
  border-radius: 12px;
41
  padding: 1rem;
42
  }
 
 
43
  .section-title {
44
  color: #94a3b8;
45
+ font-size: 0.8rem;
46
  text-transform: uppercase;
47
  letter-spacing: 0.08em;
48
  font-weight: 600;
49
  margin-bottom: 0.5rem;
50
+ margin-top: 1.5rem;
51
  }
 
 
52
  .narrative-box {
53
  background-color: #1e212b;
54
  border-left: 4px solid #10b981;
 
56
  border-radius: 0 12px 12px 0;
57
  line-height: 1.8;
58
  color: #e2e8f0;
59
+ font-size: 0.95rem;
 
 
 
 
 
 
 
 
60
  }
61
  </style>
62
  """, unsafe_allow_html=True)
63
 
64
  # --- Constants ---
 
65
  SECTOR_KEYWORDS = {
66
  "Energy": ["energy", "oil", "gas"],
67
  "Technology": ["technology", "tech", "software", "it"],
 
76
  INCLUDE_KEYWORDS = ["keep", "include", "with", "stay", "portfolio", "only"]
77
 
78
 
79
+ # --- Parsers ---
80
  def parse_investment_amount(text: str) -> float:
81
+ text = text.replace(",", "")
 
 
82
  match = re.search(r'\$?([\d.]+)\s*([kKmM]?)', text)
83
  if match:
84
  amount = float(match.group(1))
85
  suffix = match.group(2).lower()
86
+ if suffix == 'k': amount *= 1_000
87
+ elif suffix == 'm': amount *= 1_000_000
 
 
88
  return amount
89
+ return 100_000.0
90
 
91
 
92
  def parse_excluded_sectors(text: str) -> list:
 
93
  lower = text.lower()
94
  excluded = []
95
  for sector, keywords in SECTOR_KEYWORDS.items():
96
  if any(k in lower for k in keywords):
97
+ inc_pattern = re.compile(
 
98
  rf'({"|".join(INCLUDE_KEYWORDS)})\s+(the\s+)?({"|".join([sector.lower()] + keywords)})',
99
+ re.IGNORECASE
100
  )
101
  if not inc_pattern.search(lower):
102
  excluded.append(sector)
 
104
 
105
 
106
  def parse_strategy(text: str):
 
107
  lower = text.lower()
108
+ strategy, top_n = None, None
 
109
  if "smallest" in lower:
110
  strategy = "smallest_market_cap"
111
  elif "largest" in lower:
 
117
 
118
 
119
  def build_portfolio_df(allocations: dict, investment: float) -> pd.DataFrame:
 
120
  rows = []
121
  for ticker, weight in sorted(allocations.items(), key=lambda x: x[1], reverse=True):
122
  rows.append({
123
  "Ticker": ticker,
124
+ "Allocation (%)": f"{weight * 100:.2f}%",
125
+ "Investment ($)": f"${weight * investment:,.2f}"
126
  })
127
+ return pd.DataFrame(rows)
128
+
 
 
129
 
130
+ # --- Lazy-load system to avoid import overhead on every rerender ---
131
+ @st.cache_resource(show_spinner="Loading QuantScale Engine...")
132
+ def get_system():
133
+ from main import QuantScaleSystem
134
+ return QuantScaleSystem()
135
 
136
+
137
+ # --- UI ---
138
  st.markdown('<div class="main-header">QuantScale AI</div>', unsafe_allow_html=True)
139
  st.markdown('<div class="sub-header">Direct Indexing & Attribution Engine</div>', unsafe_allow_html=True)
140
 
 
141
  user_input = st.text_area(
142
  "",
143
  placeholder="Describe your goal, e.g., 'Optimize my $10,000 portfolio but exclude the Energy sector.'",
 
146
  )
147
  run_btn = st.button("πŸš€ Generate Portfolio Strategy", use_container_width=True, type="primary")
148
 
 
149
  if run_btn and user_input:
150
  investment_amount = parse_investment_amount(user_input)
151
  excluded_sectors = parse_excluded_sectors(user_input)
152
  strategy, top_n = parse_strategy(user_input)
153
 
154
+ request = OptimizationRequest(
155
+ client_id="StreamlitUser",
156
+ initial_investment=investment_amount,
157
+ excluded_sectors=excluded_sectors,
158
+ excluded_tickers=[],
159
+ strategy=strategy,
160
+ top_n=top_n,
161
+ benchmark="^GSPC"
162
+ )
163
+
164
+ with st.spinner("βš™οΈ Running Convex Optimization & AI Analysis..."):
165
  try:
166
+ system = get_system()
167
+ result = system.run_pipeline(request)
168
+ except Exception as e:
169
+ st.error(f"❌ Optimization error: {e}")
 
170
  st.stop()
171
 
172
+ if not result:
173
+ st.error("Pipeline returned no result. Check your input.")
174
+ st.stop()
175
+
176
+ opt = result["optimization"]
177
+ commentary = result["commentary"]
178
+
179
+ # --- Metrics ---
180
  col1, col2, col3 = st.columns(3)
181
  with col1:
182
+ st.metric("πŸ’Ό Invested", f"${investment_amount:,.0f}")
 
 
 
183
  with col2:
 
184
  st.metric(
185
+ "πŸ“Š Tracking Error",
186
+ f"{opt.tracking_error * 100:.4f}%",
187
+ help="How closely the portfolio tracks the S&P 500"
188
  )
189
  with col3:
190
+ excl_display = ", ".join(excluded_sectors) if excluded_sectors else "None"
191
+ st.metric("🚫 Excluded", excl_display if len(excl_display) <= 30 else f"{len(excluded_sectors)} Sectors")
192
 
193
  st.divider()
194
 
195
  # --- AI Commentary ---
196
  st.markdown('<p class="section-title">AI Performance Attribution</p>', unsafe_allow_html=True)
197
+ st.markdown(f'<div class="narrative-box">{commentary}</div>', unsafe_allow_html=True)
 
198
 
199
  st.divider()
200
 
201
  # --- Full Portfolio Table ---
202
+ allocations = opt.weights
203
  if allocations:
204
  df = build_portfolio_df(allocations, investment_amount)
205
+ total = len(df)
206
+
207
  st.markdown(
208
+ f'<p class="section-title">Full Portfolio Allocation (100%) β€” {total} Holdings</p>',
209
  unsafe_allow_html=True
210
  )
211
+
 
212
  c1, c2, c3 = st.columns(3)
213
+ c1.metric("Total Holdings", total)
214
+ c2.metric("Largest Position", df["Ticker"].iloc[0])
215
+ c3.metric("Smallest Position", df["Ticker"].iloc[-1])
216
+
217
  st.dataframe(
218
  df,
219
  use_container_width=True,
220
  hide_index=True,
221
+ height=min(500, 36 * total + 40),
222
  column_config={
223
  "Ticker": st.column_config.TextColumn("Ticker", width="small"),
224
+ "Allocation (%)": st.column_config.TextColumn("Allocation (%)", width="small"),
225
+ "Investment ($)": st.column_config.TextColumn(
226
+ f"Investment (of ${investment_amount:,.0f})", width="medium"
227
+ ),
228
  }
229
  )