Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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
|
| 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 |
-
#
|
| 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
|
| 65 |
-
#
|
| 66 |
encoder = SentenceTransformer(EMB_MODEL_ID, device="cpu")
|
| 67 |
print(f"Ready: {index.ntotal:,} stocks | encoder {EMB_MODEL_ID}")
|
| 68 |
|
| 69 |
-
#
|
| 70 |
-
#
|
| 71 |
-
#
|
| 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"
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 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",
|
| 116 |
}
|
| 117 |
-
SECTORS =
|
| 118 |
-
MARKETS = ["
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 132 |
-
"""
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
if regions:
|
| 140 |
parts.append("listed in " + " or ".join(regions))
|
| 141 |
-
|
|
|
|
| 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
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 157 |
-
regions = parse_markets(
|
| 158 |
-
|
|
|
|
| 159 |
|
| 160 |
-
scores, idx = index.search(embed_query(query), k=min(top_k *
|
| 161 |
res = df.iloc[idx[0]].copy()
|
| 162 |
res["similarity"] = scores[0].round(4)
|
| 163 |
|
| 164 |
-
res = res[(res
|
| 165 |
-
res = res[res["price_usd"] <=
|
| 166 |
-
if
|
| 167 |
-
res = res[res["sector"]
|
| 168 |
if regions:
|
| 169 |
res = res[res["market_region"].isin(regions)]
|
| 170 |
-
if
|
| 171 |
res = res[res[DIV_COL] > 0]
|
| 172 |
|
| 173 |
note = ""
|
| 174 |
if res.empty:
|
| 175 |
-
# Keep the
|
| 176 |
# instead, reporting what was traded. Substituting a different industry
|
| 177 |
# would ignore their stated intent.
|
| 178 |
pool = df.copy()
|
| 179 |
-
if
|
| 180 |
-
pool = pool[pool["sector"]
|
| 181 |
if regions:
|
| 182 |
pool = pool[pool["market_region"].isin(regions)]
|
| 183 |
-
pool = pool[pool["price_usd"] <=
|
| 184 |
-
if
|
| 185 |
pool = pool[pool[DIV_COL] > 0]
|
| 186 |
if not pool.empty:
|
| 187 |
-
|
| 188 |
-
|
|
|
|
| 189 |
else pool.iloc[(pool["beta"] - 1.075).abs().argsort()].head(top_k))
|
| 190 |
-
note = (f"β οΈ No
|
| 191 |
-
f"
|
| 192 |
-
f"(beta {pool['beta'].min():.2f}β
|
| 193 |
-
f"Sector, market and budget
|
|
|
|
| 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(
|
| 225 |
"""Qualitative descriptors only β the model never sees a raw figure, so it
|
| 226 |
-
cannot perform arithmetic on one.
|
| 227 |
-
|
| 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 |
-
|
|
|
|
| 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(
|
| 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(
|
| 260 |
tokenize=False, add_generation_prompt=True) for _, r in recs.iterrows()]
|
| 261 |
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
for i, ((_, r), s) in enumerate(zip(recs.iterrows(), sents), 1):
|
| 268 |
-
sh = int(
|
| 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(
|
| 284 |
-
"""
|
| 285 |
-
|
|
|
|
| 286 |
if recs.empty:
|
| 287 |
-
|
| 288 |
-
return
|
| 289 |
cols = [c for c in DISPLAY_COLS if c in recs.columns]
|
| 290 |
-
|
| 291 |
-
|
| 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.
|
| 334 |
-
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
| 336 |
with gr.Column():
|
| 337 |
-
q4 = gr.Dropdown(MARKETS, value="USA",
|
|
|
|
| 338 |
q5 = gr.Radio(["Dividend", "Growth"], value="Dividend",
|
| 339 |
label="5. Stock type preference")
|
| 340 |
-
q6 = gr.
|
| 341 |
-
|
| 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")
|