Kogann commited on
Commit
8f99acd
Β·
verified Β·
1 Parent(s): 5c311f8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +130 -100
app.py CHANGED
@@ -9,8 +9,7 @@ Data sources, per the assignment constraints:
9
  * Embeddings -> stored in THIS Space repo, with a dataset-repo fallback
10
 
11
  Runs on ZeroGPU: the generation model is placed on CUDA at module level and the
12
- single GPU-dependent function is decorated with @spaces.GPU, so a GPU is
13
- allocated only for the seconds it is actually needed.
14
  """
15
 
16
  import os, json, re
@@ -31,17 +30,13 @@ DIV_COL = "dividend_yield_10_year_pct"
31
  # LOAD ARTIFACTS
32
  # ---------------------------------------------------------------------------
33
  print("Loading artifacts...")
34
-
35
- # The config records which model built the index, so the query encoder can never
36
- # drift out of sync with the vectors it searches.
37
  cfg = json.load(open(hf_hub_download(DATASET_REPO, "embedding_config.json",
38
  repo_type="dataset")))
39
  EMB_MODEL_ID = cfg["model_id"]
40
  QUERY_PREFIX = cfg.get("query_prefix") or ""
41
  EMB_FILE = f"stock_embeddings_{cfg['model_short']}.npy"
42
 
43
- # Embeddings: prefer the copy stored in this Space repo (no download at cold
44
- # start); fall back to the dataset repo if it is absent.
45
  if os.path.exists(EMB_FILE):
46
  embeddings = np.load(EMB_FILE)
47
  print(f"Embeddings loaded from Space repo: {EMB_FILE}")
@@ -49,7 +44,6 @@ else:
49
  embeddings = np.load(hf_hub_download(DATASET_REPO, EMB_FILE, repo_type="dataset"))
50
  print(f"Embeddings loaded from dataset repo: {EMB_FILE}")
51
 
52
- # Dataset and cached rationales: read directly from the HF dataset repo.
53
  df = pd.read_parquet(hf_hub_download(DATASET_REPO, "stock_metadata.parquet",
54
  repo_type="dataset"))
55
  QUICKSTART_CACHE = json.load(open(hf_hub_download(
@@ -61,26 +55,17 @@ assert len(embeddings) == len(df), "embeddings and metadata are misaligned"
61
  index = faiss.IndexFlatIP(embeddings.shape[1])
62
  index.add(embeddings.astype("float32"))
63
 
64
- # Embedding model pulled from the HF model repo. Kept on CPU: it runs outside
65
- # the @spaces.GPU function and encoding one short query takes milliseconds.
66
  encoder = SentenceTransformer(EMB_MODEL_ID, device="cpu")
67
  print(f"Ready: {index.ntotal:,} stocks | encoder {EMB_MODEL_ID}")
68
 
69
- # ---------------------------------------------------------------------------
70
- # GENERATION MODEL β€” placed on CUDA at MODULE level, as ZeroGPU requires.
71
- # A PyTorch CUDA emulation mode is active outside @spaces.GPU functions, so this
72
- # works at startup; a real GPU is allocated only inside the decorated function.
73
- # Lazy loading is explicitly discouraged β€” CUDA transfers are optimised for
74
- # placement done during startup.
75
- #
76
- # SmolLM2-1.7B was selected on measured evidence: across 5 runs per model it was
77
- # both the fastest and the only candidate with zero fabricated figures. Qwen-1.5B
78
- # produced 9 fabrications, including an annual dividend income given as $800 on
79
- # one run and $9,000 on another where the correct value was $1,050.
80
- # ---------------------------------------------------------------------------
81
  GEN_MODEL_ID = "HuggingFaceTB/SmolLM2-1.7B-Instruct"
82
  gen_tok = AutoTokenizer.from_pretrained(GEN_MODEL_ID)
83
- gen_tok.padding_side = "left" # required for batched generation
84
  if gen_tok.pad_token is None:
85
  gen_tok.pad_token = gen_tok.eos_token
86
  gen_model = AutoModelForCausalLM.from_pretrained(
@@ -89,7 +74,10 @@ gen_model.eval()
89
  print(f"Generation model ready: {GEN_MODEL_ID}")
90
 
91
  # ---------------------------------------------------------------------------
92
- # QUESTIONNAIRE -> QUERY (identical logic to the notebook)
 
 
 
93
  # ---------------------------------------------------------------------------
94
  RISK_TEXT = {
95
  "Low": "low volatility, defensive, conservative risk, capital preservation",
@@ -101,100 +89,130 @@ STYLE_TEXT = {
101
  "Growth": "pays no dividend, pure growth, high historical growth",
102
  }
103
  CAP_TEXT = {"Small": "small-cap company", "Medium": "mid-cap company",
104
- "Large": "large-cap company, mega-cap company", "Any": ""}
105
 
106
  # Risk bands apply to BETA rather than the model's own risk_level label, which
107
  # the EDA showed sometimes contradicts the beta in the same record.
108
  RISK_BANDS = {"Low": (-0.5, 0.85), "Medium": (0.85, 1.30), "High": (1.30, 3.00)}
109
 
110
- # The dataset covers four regions across seven exchanges, so country answers map
111
- # to the nearest available region. Switzerland specifically is not representable.
112
  COUNTRY_TO_REGION = {
113
  "USA": "US", "UK": "Europe", "Switzerland": "Europe", "Germany": "Europe",
114
  "France": "Europe", "Europe": "Europe", "Israel": "Israel",
115
- "Japan": "Asia", "China": "Asia", "Asia": "Asia", "Global": None,
116
  }
117
- SECTORS = ["Any"] + sorted(df["sector"].unique().tolist())
118
- MARKETS = ["Global", "USA", "Europe", "Switzerland", "UK", "Israel", "Asia", "Japan"]
119
-
120
- def parse_markets(target_market):
121
- if not target_market:
122
- return None
123
- items = ([m.strip() for m in target_market.split(",")]
124
- if isinstance(target_market, str) else list(target_market))
125
- if any(m in ("Global", "Any") for m in items):
 
 
 
 
 
 
 
126
  return None
127
  regions = {COUNTRY_TO_REGION.get(m) for m in items}
128
  regions.discard(None)
129
  return sorted(regions) or None
130
 
131
- def build_query(risk, sector, market, style, cap_pref):
132
- """Risk is stated twice: in a multi-clause query a single mention is diluted."""
133
- parts = [RISK_TEXT.get(risk, ""), STYLE_TEXT.get(style, "")]
134
- if CAP_TEXT.get(cap_pref):
135
- parts.append(CAP_TEXT[cap_pref])
136
- if sector and sector != "Any":
137
- parts.append(f"{sector} company")
138
- regions = parse_markets(market)
 
 
 
 
 
 
 
 
 
 
 
139
  if regions:
140
  parts.append("listed in " + " or ".join(regions))
141
- parts.append(RISK_TEXT.get(risk, ""))
 
142
  return ". ".join([p for p in parts if p])
143
 
144
  def embed_query(text):
145
  return encoder.encode([QUERY_PREFIX + text],
146
  normalize_embeddings=True).astype("float32")
147
 
148
- def recommend(risk_level, investment_amount, sector, target_market,
149
- stock_type, market_cap_pref, top_k=3):
 
 
 
 
 
 
 
 
 
 
150
  """
151
  FILTER-THEN-RANK. Explicit constraints are enforced in pandas; semantic
152
  similarity ranks the eligible candidates. Risk is a hard filter because
153
  ablation showed embedding similarity could not separate Low from Medium
154
  (beta 1.205 vs 1.200 against a dataset mean of 1.21).
155
  """
156
- query = build_query(risk_level, sector, target_market, stock_type, market_cap_pref)
157
- regions = parse_markets(target_market)
158
- lo, hi = RISK_BANDS[risk_level]
 
159
 
160
- scores, idx = index.search(embed_query(query), k=min(top_k * 200, index.ntotal))
161
  res = df.iloc[idx[0]].copy()
162
  res["similarity"] = scores[0].round(4)
163
 
164
- res = res[(res["beta"] >= lo) & (res["beta"] < hi)]
165
- res = res[res["price_usd"] <= investment_amount]
166
- if sector != "Any":
167
- res = res[res["sector"] == sector]
168
  if regions:
169
  res = res[res["market_region"].isin(regions)]
170
- if stock_type == "Dividend":
171
  res = res[res[DIV_COL] > 0]
172
 
173
  note = ""
174
  if res.empty:
175
- # Keep the SECTOR the user actively chose and widen the risk band
176
  # instead, reporting what was traded. Substituting a different industry
177
  # would ignore their stated intent.
178
  pool = df.copy()
179
- if sector != "Any":
180
- pool = pool[pool["sector"] == sector]
181
  if regions:
182
  pool = pool[pool["market_region"].isin(regions)]
183
- pool = pool[pool["price_usd"] <= investment_amount]
184
- if stock_type == "Dividend":
185
  pool = pool[pool[DIV_COL] > 0]
186
  if not pool.empty:
187
- pool = (pool.nsmallest(top_k, "beta") if risk_level == "Low"
188
- else pool.nlargest(top_k, "beta") if risk_level == "High"
 
189
  else pool.iloc[(pool["beta"] - 1.075).abs().argsort()].head(top_k))
190
- note = (f"⚠️ No **{sector}** stock falls in the **{risk_level}** risk band. "
191
- f"Showing the closest available {sector} stocks "
192
- f"(beta {pool['beta'].min():.2f}–{pool['beta'].max():.2f}). "
193
- f"Sector, market and budget constraints are still enforced.")
 
194
  res = pool
195
  else:
196
  note = ("⚠️ No stock matches these constraints. "
197
- "Try widening your budget or markets.")
198
  res = df.head(0)
199
  return res.head(top_k).reset_index(drop=True), note, query
200
 
@@ -221,16 +239,17 @@ def _tidy(s, max_words=30):
221
  s = s.rsplit(",", 1)[0]
222
  return s.rstrip(",;: ") + "."
223
 
224
- def _stock_prompt(risk, style, r):
225
  """Qualitative descriptors only β€” the model never sees a raw figure, so it
226
- cannot perform arithmetic on one. This is a structural mitigation: given raw
227
- numbers, the benchmark model fabricated 9 figures across 5 runs."""
228
  vol = "low" if r["beta"] < 0.85 else ("moderate" if r["beta"] <= 1.30 else "high")
229
  inc = ("no" if r[DIV_COL] == 0 else "low" if r[DIV_COL] < 1.5
230
  else "moderate" if r[DIV_COL] < 3.0 else "high")
231
  grw = ("slow" if r["cagr_10yr_pct"] < 5 else
232
  "steady" if r["cagr_10yr_pct"] < 12 else "fast")
233
- return (f"Investor: {risk.lower()} risk tolerance, prefers {style.lower()} stocks.\n"
 
234
  f"This stock has {vol} volatility, {inc} dividend income, and {grw} "
235
  f"historical growth.\n"
236
  f"In one sentence of at most 20 words, explain what that means for this "
@@ -238,9 +257,7 @@ def _stock_prompt(risk, style, r):
238
 
239
  @spaces.GPU(duration=30)
240
  def _generate_sentences(prompts):
241
- """The only function needing a real GPU, so the only one decorated.
242
- duration=30 is generous for three short generations; shorter declared
243
- durations receive higher queue priority."""
244
  enc = gen_tok(prompts, return_tensors="pt", padding=True).to("cuda")
245
  with torch.no_grad():
246
  out = gen_model.generate(**enc, max_new_tokens=110, do_sample=True,
@@ -250,22 +267,32 @@ def _generate_sentences(prompts):
250
  return gen_tok.batch_decode(out[:, enc["input_ids"].shape[1]:],
251
  skip_special_tokens=True)
252
 
253
- def generate_rationale(risk, amount, sector, market, style, recs):
254
  """One sentence per stock, generated in a single batched pass. All factual
255
  content β€” tickers and figures β€” is printed from the DataFrame, so it cannot
256
  be hallucinated."""
257
  prompts = [gen_tok.apply_chat_template(
258
  [{"role": "system", "content": STOCK_SYSTEM_PROMPT},
259
- {"role": "user", "content": _stock_prompt(risk, style, r)}],
260
  tokenize=False, add_generation_prompt=True) for _, r in recs.iterrows()]
261
 
262
- sents = _generate_sentences(prompts)
263
-
264
- lines = [f"Based on your {risk.lower()}-risk profile and ${amount:,.0f} budget, "
265
- f"here are {len(recs)} {sector} stocks from {market} matching your "
266
- f"preferences.", ""]
 
 
 
 
 
 
 
 
 
 
267
  for i, ((_, r), s) in enumerate(zip(recs.iterrows(), sents), 1):
268
- sh = int(amount // r["price_usd"])
269
  lines.append(f"{i}. **{r['ticker']}** β€” beta {r['beta']:.2f}, "
270
  f"{r[DIV_COL]:.2f}% yield, {r['cagr_10yr_pct']:.1f}% growth, "
271
  f"${r['price_usd']:,.2f} per share ({sh} shares affordable). "
@@ -280,17 +307,15 @@ def generate_rationale(risk, amount, sector, market, style, recs):
280
  DISPLAY_COLS = ["ticker", "sector", "market_region", "cluster_name", "risk_level",
281
  "beta", DIV_COL, "cagr_10yr_pct", "price_usd", "similarity"]
282
 
283
- def run_custom(risk, amount, sector, market, style, cap):
284
- """Yields twice: the table appears immediately, the explanation follows."""
285
- recs, note, query = recommend(risk, amount, sector, market, style, cap)
 
286
  if recs.empty:
287
- yield pd.DataFrame(), note, ""
288
- return
289
  cols = [c for c in DISPLAY_COLS if c in recs.columns]
290
- yield recs[cols], (note + "\n\n⏳ Generating your personalised explanation…").strip(), \
291
- f"*Semantic query:* `{query}`"
292
- text = generate_rationale(risk, amount, sector, market, style, recs)
293
- yield recs[cols], text, f"*Semantic query:* `{query}`"
294
 
295
  def run_quickstart(name):
296
  """Served from the pre-generated cache β€” instant, uses no GPU quota."""
@@ -327,18 +352,23 @@ with gr.Blocks(title="StockMatch AI", theme=gr.themes.Soft()) as demo:
327
  b2 = gr.Button("πŸ‘” Balanced Professional", variant="secondary")
328
  b3 = gr.Button("πŸš€ Young Growth Seeker", variant="secondary")
329
 
330
- gr.Markdown("### πŸ“‹ Or build your own profile")
 
331
  with gr.Row():
332
  with gr.Column():
333
- q1 = gr.Radio(["Low", "Medium", "High"], value="Low", label="1. Risk level")
334
- q2 = gr.Number(value=25000, label="2. Investment amount (USD)")
335
- q3 = gr.Dropdown(SECTORS, value="Utilities", label="3. Sector")
 
 
 
336
  with gr.Column():
337
- q4 = gr.Dropdown(MARKETS, value="USA", label="4. Target market")
 
338
  q5 = gr.Radio(["Dividend", "Growth"], value="Dividend",
339
  label="5. Stock type preference")
340
- q6 = gr.Radio(["Small", "Medium", "Large", "Any"], value="Large",
341
- label="6. Company size preference")
342
  go = gr.Button("πŸ” Find my stocks", variant="primary", size="lg")
343
 
344
  gr.Markdown("### Results")
 
9
  * Embeddings -> stored in THIS Space repo, with a dataset-repo fallback
10
 
11
  Runs on ZeroGPU: the generation model is placed on CUDA at module level and the
12
+ single GPU-dependent function is decorated with @spaces.GPU.
 
13
  """
14
 
15
  import os, json, re
 
30
  # LOAD ARTIFACTS
31
  # ---------------------------------------------------------------------------
32
  print("Loading artifacts...")
 
 
 
33
  cfg = json.load(open(hf_hub_download(DATASET_REPO, "embedding_config.json",
34
  repo_type="dataset")))
35
  EMB_MODEL_ID = cfg["model_id"]
36
  QUERY_PREFIX = cfg.get("query_prefix") or ""
37
  EMB_FILE = f"stock_embeddings_{cfg['model_short']}.npy"
38
 
39
+ # Prefer the copy stored in this Space repo; fall back to the dataset repo.
 
40
  if os.path.exists(EMB_FILE):
41
  embeddings = np.load(EMB_FILE)
42
  print(f"Embeddings loaded from Space repo: {EMB_FILE}")
 
44
  embeddings = np.load(hf_hub_download(DATASET_REPO, EMB_FILE, repo_type="dataset"))
45
  print(f"Embeddings loaded from dataset repo: {EMB_FILE}")
46
 
 
47
  df = pd.read_parquet(hf_hub_download(DATASET_REPO, "stock_metadata.parquet",
48
  repo_type="dataset"))
49
  QUICKSTART_CACHE = json.load(open(hf_hub_download(
 
55
  index = faiss.IndexFlatIP(embeddings.shape[1])
56
  index.add(embeddings.astype("float32"))
57
 
58
+ # Embedding model from the HF model repo. Kept on CPU: it runs outside the
59
+ # @spaces.GPU function and encoding one short query takes milliseconds.
60
  encoder = SentenceTransformer(EMB_MODEL_ID, device="cpu")
61
  print(f"Ready: {index.ntotal:,} stocks | encoder {EMB_MODEL_ID}")
62
 
63
+ # ZeroGPU requires models on CUDA at MODULE level. A CUDA emulation mode is
64
+ # active outside @spaces.GPU functions, so this works at startup; a real GPU is
65
+ # allocated only inside the decorated function.
 
 
 
 
 
 
 
 
 
66
  GEN_MODEL_ID = "HuggingFaceTB/SmolLM2-1.7B-Instruct"
67
  gen_tok = AutoTokenizer.from_pretrained(GEN_MODEL_ID)
68
+ gen_tok.padding_side = "left"
69
  if gen_tok.pad_token is None:
70
  gen_tok.pad_token = gen_tok.eos_token
71
  gen_model = AutoModelForCausalLM.from_pretrained(
 
74
  print(f"Generation model ready: {GEN_MODEL_ID}")
75
 
76
  # ---------------------------------------------------------------------------
77
+ # QUESTIONNAIRE -> QUERY
78
+ # Questions 1, 3, 4 and 6 accept MULTIPLE selections. Each selected option
79
+ # contributes its phrase to the semantic query, and the hard filters use set
80
+ # membership (isin) or a union of ranges rather than a single equality test.
81
  # ---------------------------------------------------------------------------
82
  RISK_TEXT = {
83
  "Low": "low volatility, defensive, conservative risk, capital preservation",
 
89
  "Growth": "pays no dividend, pure growth, high historical growth",
90
  }
91
  CAP_TEXT = {"Small": "small-cap company", "Medium": "mid-cap company",
92
+ "Large": "large-cap company, mega-cap company"}
93
 
94
  # Risk bands apply to BETA rather than the model's own risk_level label, which
95
  # the EDA showed sometimes contradicts the beta in the same record.
96
  RISK_BANDS = {"Low": (-0.5, 0.85), "Medium": (0.85, 1.30), "High": (1.30, 3.00)}
97
 
 
 
98
  COUNTRY_TO_REGION = {
99
  "USA": "US", "UK": "Europe", "Switzerland": "Europe", "Germany": "Europe",
100
  "France": "Europe", "Europe": "Europe", "Israel": "Israel",
101
+ "Japan": "Asia", "China": "Asia", "Asia": "Asia",
102
  }
103
+ SECTORS = sorted(df["sector"].unique().tolist())
104
+ MARKETS = ["USA", "Europe", "Switzerland", "UK", "Israel", "Asia", "Japan"]
105
+ AMOUNTS = ["$1,000", "$5,000", "$10,000", "$25,000", "$50,000",
106
+ "$100,000", "$250,000", "$500,000"]
107
+
108
+ def _as_list(x):
109
+ """Gradio multi-select returns a list; tolerate a bare string or None."""
110
+ if x is None:
111
+ return []
112
+ return list(x) if isinstance(x, (list, tuple)) else [x]
113
+
114
+ def parse_markets(markets):
115
+ """Map selected countries to the four regions the dataset covers.
116
+ Empty selection means unrestricted."""
117
+ items = _as_list(markets)
118
+ if not items:
119
  return None
120
  regions = {COUNTRY_TO_REGION.get(m) for m in items}
121
  regions.discard(None)
122
  return sorted(regions) or None
123
 
124
+ def parse_amount(a):
125
+ """The dropdown shows formatted currency; strip it back to a number."""
126
+ if isinstance(a, str):
127
+ return float(re.sub(r"[^\d.]", "", a) or 0)
128
+ return float(a or 0)
129
+
130
+ def build_query(risks, sectors, markets, style, caps):
131
+ """Every selected option contributes its phrase. The risk phrases are
132
+ repeated at the end because in a multi-clause query a single mention gets
133
+ diluted by the other attributes."""
134
+ risk_phrase = ". ".join(RISK_TEXT[r] for r in _as_list(risks) if r in RISK_TEXT)
135
+ parts = [risk_phrase, STYLE_TEXT.get(style, "")]
136
+ cap_phrase = ", ".join(CAP_TEXT[c] for c in _as_list(caps) if c in CAP_TEXT)
137
+ if cap_phrase:
138
+ parts.append(cap_phrase)
139
+ sec = _as_list(sectors)
140
+ if sec:
141
+ parts.append(" or ".join(f"{s} company" for s in sec))
142
+ regions = parse_markets(markets)
143
  if regions:
144
  parts.append("listed in " + " or ".join(regions))
145
+ if risk_phrase:
146
+ parts.append(risk_phrase)
147
  return ". ".join([p for p in parts if p])
148
 
149
  def embed_query(text):
150
  return encoder.encode([QUERY_PREFIX + text],
151
  normalize_embeddings=True).astype("float32")
152
 
153
+ def _beta_mask(frame, risks):
154
+ """Union of the selected risk bands. No selection means no restriction."""
155
+ lv = [r for r in _as_list(risks) if r in RISK_BANDS]
156
+ if not lv:
157
+ return pd.Series(True, index=frame.index)
158
+ m = pd.Series(False, index=frame.index)
159
+ for r in lv:
160
+ lo, hi = RISK_BANDS[r]
161
+ m |= (frame["beta"] >= lo) & (frame["beta"] < hi)
162
+ return m
163
+
164
+ def recommend(risks, amount, sectors, markets, style, caps, top_k=3):
165
  """
166
  FILTER-THEN-RANK. Explicit constraints are enforced in pandas; semantic
167
  similarity ranks the eligible candidates. Risk is a hard filter because
168
  ablation showed embedding similarity could not separate Low from Medium
169
  (beta 1.205 vs 1.200 against a dataset mean of 1.21).
170
  """
171
+ query = build_query(risks, sectors, markets, style, caps)
172
+ regions = parse_markets(markets)
173
+ sec = _as_list(sectors)
174
+ amount = parse_amount(amount)
175
 
176
+ scores, idx = index.search(embed_query(query), k=min(top_k * 300, index.ntotal))
177
  res = df.iloc[idx[0]].copy()
178
  res["similarity"] = scores[0].round(4)
179
 
180
+ res = res[_beta_mask(res, risks)]
181
+ res = res[res["price_usd"] <= amount]
182
+ if sec:
183
+ res = res[res["sector"].isin(sec)]
184
  if regions:
185
  res = res[res["market_region"].isin(regions)]
186
+ if style == "Dividend":
187
  res = res[res[DIV_COL] > 0]
188
 
189
  note = ""
190
  if res.empty:
191
+ # Keep the SECTORS the user actively chose and widen the risk band
192
  # instead, reporting what was traded. Substituting a different industry
193
  # would ignore their stated intent.
194
  pool = df.copy()
195
+ if sec:
196
+ pool = pool[pool["sector"].isin(sec)]
197
  if regions:
198
  pool = pool[pool["market_region"].isin(regions)]
199
+ pool = pool[pool["price_usd"] <= amount]
200
+ if style == "Dividend":
201
  pool = pool[pool[DIV_COL] > 0]
202
  if not pool.empty:
203
+ lv = _as_list(risks)
204
+ pool = (pool.nsmallest(top_k, "beta") if lv == ["Low"]
205
+ else pool.nlargest(top_k, "beta") if lv == ["High"]
206
  else pool.iloc[(pool["beta"] - 1.075).abs().argsort()].head(top_k))
207
+ note = (f"⚠️ No stock in {', '.join(sec) or 'the selected sectors'} falls "
208
+ f"in the {', '.join(lv) or 'selected'} risk band. Showing the "
209
+ f"closest available (beta {pool['beta'].min():.2f}–"
210
+ f"{pool['beta'].max():.2f}). Sector, market and budget "
211
+ f"constraints are still enforced.")
212
  res = pool
213
  else:
214
  note = ("⚠️ No stock matches these constraints. "
215
+ "Try widening your budget, sectors or markets.")
216
  res = df.head(0)
217
  return res.head(top_k).reset_index(drop=True), note, query
218
 
 
239
  s = s.rsplit(",", 1)[0]
240
  return s.rstrip(",;: ") + "."
241
 
242
+ def _stock_prompt(risks, style, r):
243
  """Qualitative descriptors only β€” the model never sees a raw figure, so it
244
+ cannot perform arithmetic on one. Given raw numbers during benchmarking, a
245
+ model fabricated 9 figures across 5 runs."""
246
  vol = "low" if r["beta"] < 0.85 else ("moderate" if r["beta"] <= 1.30 else "high")
247
  inc = ("no" if r[DIV_COL] == 0 else "low" if r[DIV_COL] < 1.5
248
  else "moderate" if r[DIV_COL] < 3.0 else "high")
249
  grw = ("slow" if r["cagr_10yr_pct"] < 5 else
250
  "steady" if r["cagr_10yr_pct"] < 12 else "fast")
251
+ rl = "/".join(_as_list(risks)).lower() or "flexible"
252
+ return (f"Investor: {rl} risk tolerance, prefers {style.lower()} stocks.\n"
253
  f"This stock has {vol} volatility, {inc} dividend income, and {grw} "
254
  f"historical growth.\n"
255
  f"In one sentence of at most 20 words, explain what that means for this "
 
257
 
258
  @spaces.GPU(duration=30)
259
  def _generate_sentences(prompts):
260
+ """The only function needing a real GPU, so the only one decorated."""
 
 
261
  enc = gen_tok(prompts, return_tensors="pt", padding=True).to("cuda")
262
  with torch.no_grad():
263
  out = gen_model.generate(**enc, max_new_tokens=110, do_sample=True,
 
267
  return gen_tok.batch_decode(out[:, enc["input_ids"].shape[1]:],
268
  skip_special_tokens=True)
269
 
270
+ def generate_rationale(risks, amount, sectors, markets, style, recs):
271
  """One sentence per stock, generated in a single batched pass. All factual
272
  content β€” tickers and figures β€” is printed from the DataFrame, so it cannot
273
  be hallucinated."""
274
  prompts = [gen_tok.apply_chat_template(
275
  [{"role": "system", "content": STOCK_SYSTEM_PROMPT},
276
+ {"role": "user", "content": _stock_prompt(risks, style, r)}],
277
  tokenize=False, add_generation_prompt=True) for _, r in recs.iterrows()]
278
 
279
+ try:
280
+ sents = _generate_sentences(prompts)
281
+ except Exception as e:
282
+ # A GPU failure must not take the whole recommendation down; the table
283
+ # above is still valid and useful on its own.
284
+ return (f"_Recommendations above are complete. The written explanation "
285
+ f"could not be generated: {type(e).__name__}._")
286
+
287
+ amt = parse_amount(amount)
288
+ rl = "/".join(_as_list(risks)).lower() or "flexible"
289
+ sec = ", ".join(_as_list(sectors)) or "all sectors"
290
+ mkt = ", ".join(_as_list(markets)) or "all markets"
291
+
292
+ lines = [f"Based on your {rl}-risk profile and ${amt:,.0f} budget, here are "
293
+ f"{len(recs)} stocks in {sec} from {mkt} matching your preferences.", ""]
294
  for i, ((_, r), s) in enumerate(zip(recs.iterrows(), sents), 1):
295
+ sh = int(amt // r["price_usd"]) if r["price_usd"] else 0
296
  lines.append(f"{i}. **{r['ticker']}** β€” beta {r['beta']:.2f}, "
297
  f"{r[DIV_COL]:.2f}% yield, {r['cagr_10yr_pct']:.1f}% growth, "
298
  f"${r['price_usd']:,.2f} per share ({sh} shares affordable). "
 
307
  DISPLAY_COLS = ["ticker", "sector", "market_region", "cluster_name", "risk_level",
308
  "beta", DIV_COL, "cagr_10yr_pct", "price_usd", "similarity"]
309
 
310
+ def run_custom(risks, amount, sectors, markets, style, caps):
311
+ """Plain function, not a generator: @spaces.GPU does not compose well with
312
+ Gradio generator callbacks, and on GPU the whole round trip is ~2 seconds."""
313
+ recs, note, query = recommend(risks, amount, sectors, markets, style, caps)
314
  if recs.empty:
315
+ return pd.DataFrame(), note, ""
 
316
  cols = [c for c in DISPLAY_COLS if c in recs.columns]
317
+ text = generate_rationale(risks, amount, sectors, markets, style, recs)
318
+ return recs[cols], (note + "\n\n" + text).strip(), f"*Semantic query:* `{query}`"
 
 
319
 
320
  def run_quickstart(name):
321
  """Served from the pre-generated cache β€” instant, uses no GPU quota."""
 
352
  b2 = gr.Button("πŸ‘” Balanced Professional", variant="secondary")
353
  b3 = gr.Button("πŸš€ Young Growth Seeker", variant="secondary")
354
 
355
+ gr.Markdown("### πŸ“‹ Or build your own profile β€” questions 1, 3, 4 and 6 accept "
356
+ "multiple answers")
357
  with gr.Row():
358
  with gr.Column():
359
+ q1 = gr.CheckboxGroup(["Low", "Medium", "High"], value=["Low"],
360
+ label="1. Risk level (select one or more)")
361
+ q2 = gr.Dropdown(AMOUNTS, value="$25,000",
362
+ label="2. Investment amount (USD)")
363
+ q3 = gr.Dropdown(SECTORS, value=["Utilities"], multiselect=True,
364
+ label="3. Sector (select one or more, or none for all)")
365
  with gr.Column():
366
+ q4 = gr.Dropdown(MARKETS, value=["USA"], multiselect=True,
367
+ label="4. Target market (select one or more, or none for all)")
368
  q5 = gr.Radio(["Dividend", "Growth"], value="Dividend",
369
  label="5. Stock type preference")
370
+ q6 = gr.CheckboxGroup(["Small", "Medium", "Large"], value=["Large"],
371
+ label="6. Company size (select one or more)")
372
  go = gr.Button("πŸ” Find my stocks", variant="primary", size="lg")
373
 
374
  gr.Markdown("### Results")