GoshawkVortexAI commited on
Commit
793246b
·
verified ·
1 Parent(s): bac164e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +788 -456
app.py CHANGED
@@ -1,501 +1,833 @@
1
  """
2
- RCEA Lite v1.0 — yfinance Edition
3
- Regime-Conditioned Evidence Accumulation. Causal HMM. No supervised ML.
 
 
 
 
 
 
 
 
 
 
 
 
4
  """
5
 
6
- import math, time, warnings
7
- from datetime import datetime, timezone
8
- from typing import List, Optional
 
9
 
 
10
  import numpy as np
11
  import pandas as pd
 
12
  import yfinance as yf
13
- import gradio as gr
14
- from hmmlearn import hmm
15
-
16
- warnings.filterwarnings("ignore")
17
-
18
- # ---------- Config ----------
19
- COMMISSION, SLIPPAGE = 0.001, 0.0005
20
- COST = 2 * (COMMISSION + SLIPPAGE)
21
- REGIME_NAMES = ["TREND", "MEAN_REVERSION", "COMPRESSION"]
22
- HMM_K = 3
23
- TRAIN_FRAC, PURGE, EMBARGO = 0.60, 5, 3
24
- MIN_HMM, MIN_SIG = 150, 80
25
- FEAT_COLS = ["ret", "atr_norm", "ema_diff_norm", "efficiency", "rsi_dev", "dist_ma"]
26
-
27
- # 30 USDT paritesi (yfinance sembolleri)
28
- COINS = [
29
- "BTC-USD", "ETH-USD", "BNB-USD", "SOL-USD", "XRP-USD",
30
- "ADA-USD", "DOGE-USD", "AVAX-USD", "TRX-USD", "DOT-USD",
31
- "LINK-USD", "MATIC-USD", "LTC-USD", "BCH-USD", "UNI-USD",
32
- "ATOM-USD", "ETC-USD", "XLM-USD", "NEAR-USD", "APT-USD",
33
- "ARB-USD", "OP-USD", "FIL-USD", "INJ-USD", "SUI-USD",
34
- "SEI-USD", "TIA-USD", "AAVE-USD", "MKR-USD", "GRT-USD",
 
 
35
  ]
36
 
37
- # yfinance interval mapping + max period per interval
38
- YF_TF = {
39
- "15m": ("15m", "60d"),
40
- "1h": ("1h", "730d"),
41
- "4h": ("1h", "730d"), # 4h yok → 1h'den resample
42
- "1d": ("1d", "5y"),
43
- }
44
 
45
- TARGET_BARS = 1000
46
 
 
 
 
 
 
 
 
47
 
48
- # ---------- Helpers ----------
49
- def _sig(x):
50
- return 1.0 / (1.0 + math.exp(-x)) if x >= 0 else math.exp(x) / (1.0 + math.exp(x))
51
 
 
 
 
52
 
53
- def _ema(a, span):
54
- al = 2.0 / (span + 1.0)
55
- o = np.empty_like(a, dtype=float); o[0] = a[0]
56
- for i in range(1, len(a)): o[i] = al * a[i] + (1 - al) * o[i - 1]
57
- return o
58
 
 
 
 
59
 
60
- def _atr(h, l, c, p=14):
61
- n = len(c); tr = np.empty(n)
62
- tr[0] = h[0] - l[0]
63
- for i in range(1, n):
64
- tr[i] = max(h[i] - l[i], abs(h[i] - c[i-1]), abs(l[i] - c[i-1]))
65
- return _ema(tr, p)
 
 
 
 
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
- def _rsi(c, p=14):
69
- d = np.diff(c, prepend=c[0])
70
- g = np.where(d > 0, d, 0.0); ls = np.where(d < 0, -d, 0.0)
71
- ag, al = _ema(g, p), _ema(ls, p)
72
- rs = np.where(al > 1e-12, ag / al, 100.0)
73
- return 100.0 - 100.0 / (1.0 + rs)
74
 
 
 
 
75
 
76
- # ---------- yfinance Data Layer ----------
77
- def fetch_pairs(n=30):
78
- """Return fixed 30-coin USDT list (yfinance)."""
79
- out = COINS[:30]
80
- print(f"[RCEA] {len(out)} yfinance pairs resolved", flush=True)
81
- return out
82
 
 
 
 
83
 
84
- def fetch_klines(inst_id, timeframe, target_bars=TARGET_BARS):
85
  """
86
- Fetch OHLCV from Yahoo Finance via yfinance.
87
- For 4h, downloads 1h and resamples to 4H.
88
  Returns None if insufficient data.
 
 
 
 
89
  """
90
- if timeframe not in YF_TF:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  return None
92
- interval, period = YF_TF[timeframe]
93
 
94
- try:
95
- raw = yf.download(
96
- inst_id,
97
- interval=interval,
98
- period=period,
99
- auto_adjust=False,
100
- progress=False,
101
- threads=False,
102
- )
103
- except Exception as e:
104
- print(f"[RCEA] {inst_id} download failed: {e}", flush=True)
105
- return None
106
 
107
- if raw is None or raw.empty:
108
- print(f"[RCEA] {inst_id}: empty data", flush=True)
109
- return None
110
 
111
- # yfinance multiindex flatten (tek sembolde bile MultiIndex dönebilir)
112
- if isinstance(raw.columns, pd.MultiIndex):
113
- raw.columns = raw.columns.get_level_values(0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
- raw = raw.rename(columns=str.lower)
116
- needed = ["open", "high", "low", "close", "volume"]
117
- if not all(c in raw.columns for c in needed):
118
- print(f"[RCEA] {inst_id}: missing columns {raw.columns.tolist()}", flush=True)
119
- return None
120
 
121
- df = raw[needed].copy()
122
- df = df.dropna(subset=["open", "high", "low", "close"])
123
- df = df[df["close"] > 0]
124
 
125
- # 4h resample
126
- if timeframe == "4h":
127
- df = df.resample("4H").agg({
128
- "open": "first", "high": "max", "low": "min",
129
- "close": "last", "volume": "sum",
130
- }).dropna()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
- if len(df) < MIN_HMM:
133
- print(f"[RCEA] {inst_id}: only {len(df)} bars (< {MIN_HMM})", flush=True)
134
- return None
 
135
 
136
- # Son target_bars
137
- df = df.tail(target_bars).reset_index(drop=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  return df
139
 
140
 
141
- # ---------- Features ----------
142
- def features(df):
143
- c = df["close"].values.astype(float)
144
- h = df["high"].values.astype(float)
145
- lo = df["low"].values.astype(float)
146
- n = len(c)
147
- o = pd.DataFrame(index=df.index)
148
- o["close"] = c
149
- r = np.zeros(n); r[1:] = np.diff(c) / c[:-1]; o["ret"] = r
150
- a14 = _atr(h, lo, c, 14); o["atr"] = a14
151
- o["atr_norm"] = a14 / np.where(c > 0, c, 1.0)
152
- ef, es = _ema(c, 12), _ema(c, 26)
153
- o["ema_fast"], o["ema_slow"] = ef, es
154
- o["ema_diff_norm"] = (ef - es) / np.where(c > 0, c, 1.0)
155
- lb, eff = 10, np.zeros(n)
156
- for i in range(lb, n):
157
- net = c[i] - c[i - lb]; path = np.sum(np.abs(np.diff(c[i - lb:i + 1])))
158
- eff[i] = net / path if path > 1e-12 else 0.0
159
- o["efficiency"] = eff
160
- mom = np.zeros(n); mom[10:] = (c[10:] - c[:-10]) / np.where(c[:-10] > 0, c[:-10], 1.0)
161
- o["momentum"] = mom
162
- am = np.zeros(n); am[1:] = np.diff(c) / np.where(a14[1:] > 1e-12, a14[1:], 1.0)
163
- o["atr_move"] = am
164
- rs = _rsi(c, 14); o["rsi"] = rs; o["rsi_dev"] = (rs - 50.0) / 50.0
165
- rm = pd.Series(c).rolling(20, min_periods=20).mean().values
166
- dm = np.where(rm > 0, (c - rm) / rm, 0.0); o["dist_ma"] = np.nan_to_num(dm)
167
- ae = np.where(a14 > 1e-12, (c - rm) / a14, 0.0); o["atr_extension"] = np.nan_to_num(ae)
168
- a5, a20 = _atr(h, lo, c, 5), _atr(h, lo, c, 20)
169
- o["vol_ratio"] = np.where(a20 > 1e-12, a5 / a20, 1.0)
170
- ph, pl_ = np.roll(h, 1), np.roll(lo, 1)
171
- ph[0], pl_[0] = h[0], lo[0]
172
- o["prev_high"], o["prev_low"] = ph, pl_
173
- o["breakout"] = np.where(c > ph, 1.0, np.where(c < pl_, -1.0, 0.0))
174
- return o.replace([np.inf, -np.inf], np.nan).fillna(0.0)
175
-
176
-
177
- # ---------- HMM ----------
178
- def fit_hmm(f):
179
- ntr = min(max(MIN_HMM, int(len(f) * TRAIN_FRAC)), len(f) - 20)
180
- if ntr < MIN_HMM: return None
181
- X = f.iloc[:ntr][FEAT_COLS].values
182
- mu, sd = X.mean(0), X.std(0); sd[sd < 1e-12] = 1.0
183
- m = hmm.GaussianHMM(n_components=HMM_K, covariance_type="diag",
184
- n_iter=100, random_state=42, tol=1e-4)
185
- try: m.fit((X - mu) / sd)
186
- except Exception: return None
187
- m._mu, m._sd = mu, sd
188
- return m
189
-
190
-
191
- def forward_filter(m, f):
192
- n = len(f)
193
- post = np.ones((n, HMM_K)) / HMM_K
194
- ntr = min(max(MIN_HMM, int(n * TRAIN_FRAC)), n - 20)
195
- if ntr < 10: return post
196
- X = (f[FEAT_COLS].values - m._mu) / m._sd
197
- ls, lt = np.log(m.startprob_ + 1e-300), np.log(m.transmat_ + 1e-300)
198
- le = np.zeros((n, HMM_K))
199
- for k in range(HMM_K):
200
- cov = m.covars_[k]; var = np.diag(cov) if cov.ndim > 1 else cov
201
- var = np.maximum(var, 1e-6); d = X - m.means_[k]
202
- le[:, k] = -0.5 * np.sum(d * d / var, 1) - 0.5 * np.sum(np.log(2 * np.pi * var))
203
- a = np.zeros((n, HMM_K)); a[0] = ls + le[0]
204
- for t in range(1, n):
205
- for k in range(HMM_K):
206
- terms = a[t - 1] + lt[:, k]; mx = terms.max()
207
- a[t, k] = le[t, k] + mx + np.log(np.exp(terms - mx).sum())
208
- for t in range(n):
209
- e = np.exp(a[t] - a[t].max()); post[t] = e / e.sum()
210
- post[:ntr] = 1.0 / HMM_K
211
- return post
212
-
213
-
214
- # ---------- Evidence ----------
215
- def evidence(f, rp, lam):
216
- n = len(f)
217
- c = f["close"].values; ef = f["ema_fast"].values; es = f["ema_slow"].values
218
- eff = f["efficiency"].values; mom = f["momentum"].values; am = f["atr_move"].values
219
- rd = f["rsi_dev"].values; dm = f["dist_ma"].values; ae = f["atr_extension"].values
220
- vr = f["vol_ratio"].values; bo = f["breakout"].values
221
- et, em, ec = np.zeros(n), np.zeros(n), np.zeros(n)
222
- for i in range(n):
223
- s = 0.0
224
- if c[i] > ef[i] > es[i]: s += 0.4
225
- elif c[i] < ef[i] < es[i]: s -= 0.4
226
- s += 0.3 * eff[i] + 0.3 * np.tanh(mom[i] * 10) + 0.2 * np.tanh(am[i])
227
- et[i] = np.tanh(s * 2.0)
228
- s = -0.4 * np.tanh(rd[i] * 3) - 0.4 * np.tanh(dm[i] * 20) - 0.3 * np.tanh(ae[i] * 2)
229
- em[i] = np.tanh(s * 2.0)
230
- contr = max(0.0, 1.0 - vr[i])
231
- ec[i] = np.tanh(bo[i] * (0.5 + 0.5 * contr) * 2.0)
232
- raw = rp[:, 0] * et + rp[:, 1] * em + rp[:, 2] * ec
233
- acc = np.zeros(n); acc[0] = raw[0]
234
- for i in range(1, n): acc[i] = lam * acc[i - 1] + (1 - lam) * raw[i]
235
- pl = np.array([_sig(4.0 * x) for x in acc])
236
- return acc, pl, 1.0 - pl
237
-
238
-
239
- # ---------- Signal + Backtest ----------
240
- def latest_signal(f, rp, acc, pl, ps, thr, mconf):
241
- i = len(f) - 1
242
- if i < MIN_SIG: return None
243
- p = rp[i]; dom = int(np.argmax(p)); conf = float(p[dom])
244
- if conf < mconf: return None
245
- e, l, s = float(acc[i]), float(pl[i]), float(ps[i])
246
- sig = "LONG" if e > thr and l > 0.5 else "SHORT" if e < -thr and s > 0.5 else "NO TRADE"
247
- return {"signal": sig, "evidence": e, "P_long": l, "P_short": s,
248
- "regime_probs": {REGIME_NAMES[j]: float(p[j]) for j in range(HMM_K)},
249
- "dominant_regime": REGIME_NAMES[dom], "regime_conf": conf,
250
- "close": float(f["close"].values[i])}
251
-
252
-
253
- def backtest(sym, f, rp, acc, pl, ps, thr, mconf, maxhold):
254
- n = len(f); c = f["close"].values; h = f["high"].values; lo = f["low"].values
255
- atr = f["atr"].values
256
- trades = []; in_pos = False; ei = eprice = d = sp = tp = 0
257
- reg = ""; rconf = 0.0
258
-
259
- train_end = int(n * TRAIN_FRAC)
260
- start = train_end + PURGE + EMBARGO
261
- start = max(start, 30)
262
- start = min(start, n - 2)
263
-
264
- if start < n:
265
- pass_thr = int((np.abs(acc[start:]) > thr).sum())
266
- pass_conf = int((rp[start:].max(axis=1) >= mconf).sum())
267
- both = int(((np.abs(acc[start:]) > thr) &
268
- (rp[start:].max(axis=1) >= mconf)).sum())
269
- print(f"[BT] {sym}: n={n} start={start} window={n-start} "
270
- f"thr_pass={pass_thr} conf_pass={pass_conf} both={both}",
271
- flush=True)
272
-
273
- for i in range(start, n):
274
- if not in_pos:
275
- p = rp[i]; dom = int(np.argmax(p)); conf = float(p[dom])
276
- if conf < mconf: continue
277
- ed = 1 if acc[i] > thr and pl[i] > 0.5 else -1 if acc[i] < -thr and ps[i] > 0.5 else 0
278
- if ed == 0 or atr[i] < 1e-12: continue
279
- eprice = c[i] * (1.0 + SLIPPAGE * ed); d = ed; ei = i; in_pos = True
280
- if d == 1: sp, tp = eprice - 1.5 * atr[i], eprice + 3.0 * atr[i]
281
- else: sp, tp = eprice + 1.5 * atr[i], eprice - 3.0 * atr[i]
282
- reg, rconf = REGIME_NAMES[dom], conf
283
  else:
284
- bh = i - ei
285
- hs = lo[i] <= sp if d == 1 else h[i] >= sp
286
- ht = h[i] >= tp if d == 1 else lo[i] <= tp
287
- xp = xr = None
288
- if hs and ht: xp, xr = sp, "STOP (both, conservative)"
289
- elif hs: xp, xr = sp, "STOP"
290
- elif ht: xp, xr = tp, "TARGET"
291
- elif bh >= maxhold: xp, xr = c[i], "TIME"
292
- if xp is not None:
293
- xa = xp * (1.0 - SLIPPAGE * d)
294
- gr = (xa - eprice) / eprice if d == 1 else (eprice - xa) / eprice
295
- nr = gr - COST
296
- trades.append({"symbol": sym, "direction": "LONG" if d == 1 else "SHORT",
297
- "entry_bar": ei, "exit_bar": i, "bars_held": bh,
298
- "entry_price": eprice, "exit_price": xa,
299
- "stop_price": sp, "target_price": tp,
300
- "exit_reason": xr, "gross_return": gr, "net_return": nr,
301
- "regime": reg, "regime_conf": rconf, "win": nr > 0})
302
- in_pos = False
303
- return trades
304
-
305
-
306
- # ---------- Metrics ----------
307
- def metrics(trades):
308
- if not trades:
309
- return dict(total_trades=0, win_rate=0.0, profit_factor=0.0, net_expectancy=0.0,
310
- avg_win=0.0, avg_loss=0.0, max_drawdown=0.0, net_compounded_return=0.0,
311
- long_trades=0, short_trades=0, long_win_rate=0.0, short_win_rate=0.0)
312
- r = np.array([t["net_return"] for t in trades])
313
- w, l = r[r > 0], r[r <= 0]
314
- gp = w.sum() if len(w) else 0.0; gl = abs(l.sum()) if len(l) else 0.0
315
- eq = np.cumprod(1 + r); peak = np.maximum.accumulate(eq)
316
- dd = (eq - peak) / peak
317
- longs = [t for t in trades if t["direction"] == "LONG"]
318
- shorts = [t for t in trades if t["direction"] == "SHORT"]
319
- lw = sum(1 for t in longs if t["win"]); sw = sum(1 for t in shorts if t["win"])
320
- return dict(total_trades=len(r),
321
- win_rate=len(w) / len(r), profit_factor=gp / gl if gl > 1e-12 else 999.0,
322
- net_expectancy=float(r.mean()), avg_win=float(w.mean()) if len(w) else 0.0,
323
- avg_loss=float(l.mean()) if len(l) else 0.0,
324
- max_drawdown=float(dd.min()) if len(dd) else 0.0,
325
- net_compounded_return=float(eq[-1] - 1) if len(eq) else 0.0,
326
- long_trades=len(longs), short_trades=len(shorts),
327
- long_win_rate=lw / len(longs) if longs else 0.0,
328
- short_win_rate=sw / len(shorts) if shorts else 0.0)
329
-
330
-
331
- def regime_table(trades):
332
- if not trades: return pd.DataFrame(columns=["Regime", "Trades", "WinRate", "PF", "NetExp"])
333
- rows = []
334
- for reg in REGIME_NAMES:
335
- rt = [t for t in trades if t["regime"] == reg]
336
- if not rt:
337
- rows.append({"Regime": reg, "Trades": 0, "WinRate": 0.0, "PF": 0.0, "NetExp": 0.0}); continue
338
- n = np.array([t["net_return"] for t in rt])
339
- w, l = n[n > 0], n[n <= 0]
340
- gp = w.sum() if len(w) else 0.0; gl = abs(l.sum()) if len(l) else 0.0
341
- rows.append({"Regime": reg, "Trades": len(rt),
342
- "WinRate": round(len(w) / len(rt), 4),
343
- "PF": round(gp / gl, 4) if gl > 1e-12 else 999.0,
344
- "NetExp": round(float(n.mean()), 6)})
345
- return pd.DataFrame(rows)
346
 
 
 
 
347
 
348
- def coin_table(trades):
349
- if not trades: return pd.DataFrame(columns=["Symbol", "Trades", "WinRate", "NetExp", "TotalNet"])
350
  rows = []
351
- for sym in sorted({t["symbol"] for t in trades}):
352
- st = [t for t in trades if t["symbol"] == sym]
353
- n = np.array([t["net_return"] for t in st])
354
- w = n[n > 0]
355
- rows.append({"Symbol": sym, "Trades": len(st),
356
- "WinRate": round(len(w) / len(st), 4),
357
- "NetExp": round(float(n.mean()), 6),
358
- "TotalNet": round(float(n.sum()), 6)})
359
- return pd.DataFrame(rows).sort_values("TotalNet", ascending=False)
360
-
361
-
362
- # ---------- Pipeline ----------
363
- def run(timeframe, n_coins, thr, lam, mconf, maxhold, progress=gr.Progress()):
364
- def err(msg, detail=""):
365
- return msg, detail, pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame()
366
-
367
- n_coins = 30 # sabit — yfinance 30 parite
368
- thr = max(0.0, min(1.0, float(thr)))
369
- lam = max(0.0, min(0.99, float(lam)))
370
- mconf = max(0.0, min(1.0, float(mconf)))
371
- maxhold = max(5, min(200, int(maxhold)))
372
-
373
- if timeframe not in YF_TF:
374
- return err("ERROR: Invalid timeframe")
375
-
376
- progress(0.02, desc="Resolving yfinance pairs...")
377
- try: pairs = fetch_pairs(n_coins)
378
- except Exception as e: return err(f"ERROR fetching pairs: {e}")
379
- if not pairs:
380
- return err("ERROR: No pairs resolved.")
381
-
382
- signals, trades, analyzed, failed = [], [], 0, []
383
- for i, sym in enumerate(pairs):
384
- progress((i + 1) / len(pairs) * 0.9, desc=f"{sym} ({i+1}/{len(pairs)})")
385
- time.sleep(0.25) # yfinance rate-limit nezaketi
386
- df = fetch_klines(sym, timeframe)
387
- if df is None:
388
- failed.append(sym); continue
389
- try:
390
- f = features(df)
391
- m = fit_hmm(f)
392
- if m is None:
393
- print(f"[RCEA] {sym}: HMM fit failed (n={len(f)})", flush=True)
394
- failed.append(sym); continue
395
- rp = forward_filter(m, f)
396
- acc, pl, ps = evidence(f, rp, lam)
397
- s = latest_signal(f, rp, acc, pl, ps, thr, mconf)
398
- if s: s["symbol"] = sym; signals.append(s)
399
- sym_trades = backtest(sym, f, rp, acc, pl, ps, thr, mconf, maxhold)
400
- trades.extend(sym_trades)
401
- print(f"[RCEA] {sym}: candles={len(f)} signal={s['signal'] if s else 'NONE'} "
402
- f"trades={len(sym_trades)}", flush=True)
403
- analyzed += 1
404
- except Exception as e:
405
- print(f"[RCEA] {sym}: EXC {e}", flush=True); failed.append(sym)
406
- progress(1.0, desc="Done")
407
-
408
- scan = ["# RCEA Lite — yfinance Market Scan",
409
- f"**Timeframe:** {timeframe} | **yfinance interval:** {YF_TF[timeframe][0]}",
410
- f"**Coins:** {len(pairs)} (fixed list)",
411
- f"**Analyzed:** {analyzed}",
412
- f"**Failed:** {len(failed)}",
413
- f"**Total trades:** {len(trades)}",
414
- f"**Threshold:** {thr} | **λ:** {lam} | **MinConf:** {mconf} | **MaxHold:** {maxhold}",
415
- f"**UTC:** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')}"]
416
- if len(trades) < 30:
417
- scan.append(f"\n⚠️ Only {len(trades)} trades. Try threshold=0.04, "
418
- f"λ=0.65, MinConf=0.25.")
419
- scan_report = "\n".join(scan)
420
-
421
- if signals:
422
- sig_df = pd.DataFrame([{
423
- "Symbol": s["symbol"], "Signal": s["signal"],
424
- "Evidence": round(s["evidence"], 4),
425
- "P(Long)": round(s["P_long"], 4), "P(Short)": round(s["P_short"], 4),
426
- "Regime": s["dominant_regime"], "Regime Conf": round(s["regime_conf"], 4),
427
- "P(Trend)": round(s["regime_probs"]["TREND"], 4),
428
- "P(MeanRev)": round(s["regime_probs"]["MEAN_REVERSION"], 4),
429
- "P(Compress)": round(s["regime_probs"]["COMPRESSION"], 4),
430
- "Close": s["close"]} for s in signals])
431
- sig_df["_p"] = sig_df["Signal"].map({"LONG": 0, "SHORT": 0, "NO TRADE": 1})
432
- sig_df = sig_df.sort_values(["_p", "Evidence"], ascending=[True, False]).drop(columns="_p")
433
- else:
434
- sig_df = pd.DataFrame(columns=["Symbol", "Signal", "Evidence", "P(Long)", "P(Short)",
435
- "Regime", "Regime Conf", "P(Trend)", "P(MeanRev)",
436
- "P(Compress)", "Close"])
437
-
438
- if trades:
439
- tr_df = pd.DataFrame([{
440
- "Symbol": t["symbol"], "Direction": t["direction"],
441
- "Entry Bar": t["entry_bar"], "Exit Bar": t["exit_bar"],
442
- "Bars": t["bars_held"],
443
- "Entry": round(t["entry_price"], 6), "Exit": round(t["exit_price"], 6),
444
- "Stop": round(t["stop_price"], 6), "Target": round(t["target_price"], 6),
445
- "Reason": t["exit_reason"],
446
- "Gross": round(t["gross_return"], 6), "Net": round(t["net_return"], 6),
447
- "Regime": t["regime"], "Regime Conf": round(t["regime_conf"], 4),
448
- "Win": t["win"]} for t in trades]).sort_values(["Symbol", "Entry Bar"])
449
- else:
450
- tr_df = pd.DataFrame(columns=["Symbol", "Direction", "Entry Bar", "Exit Bar", "Bars",
451
- "Entry", "Exit", "Stop", "Target", "Reason",
452
- "Gross", "Net", "Regime", "Regime Conf", "Win"])
453
-
454
- m = metrics(trades)
455
- m_str = "\n".join([
456
- "# Performance Metrics (net of costs)",
457
- f"**Total trades:** {m['total_trades']}",
458
- f"**Win rate:** {m['win_rate']:.2%}",
459
- f"**Profit factor:** {m['profit_factor']:.3f}",
460
- f"**Net expectancy:** {m['net_expectancy']:.6f}",
461
- f"**Avg win / loss:** {m['avg_win']:.6f} / {m['avg_loss']:.6f}",
462
- f"**Max DD:** {m['max_drawdown']:.2%}",
463
- f"**Net compounded:** {m['net_compounded_return']:.2%}",
464
- f"**Long:** {m['long_trades']} ({m['long_win_rate']:.2%}) | "
465
- f"**Short:** {m['short_trades']} ({m['short_win_rate']:.2%})",
466
- ])
467
- if m["total_trades"] < 30:
468
- m_str += "\n\n⚠️ Insufficient trades for reliable statistics."
469
-
470
- return scan_report, m_str, sig_df, tr_df, regime_table(trades), coin_table(trades)
471
-
472
-
473
- # ---------- UI ----------
474
- with gr.Blocks(title="RCEA Lite v1.0 — yfinance") as demo:
475
- gr.Markdown("# RCEA Lite v1.0 — yfinance Edition\n"
476
- "HMM regime detection (causal forward filtering) · No supervised ML · Data: Yahoo Finance")
477
- with gr.Row():
478
- with gr.Column(scale=1):
479
- tf = gr.Dropdown(["15m", "1h", "4h", "1d"], value="1h", label="Timeframe")
480
- nc = gr.Number(value=30, label="Coins (fixed 30)", interactive=False)
481
- thr_i = gr.Slider(0.0, 1.0, value=0.06, step=0.01, label="Evidence threshold")
482
- lam_i = gr.Slider(0.0, 0.99, value=0.70, step=0.01, label="Memory λ")
483
- mc_i = gr.Slider(0.0, 1.0, value=0.30, step=0.01, label="Min regime confidence")
484
- mh_i = gr.Slider(10, 200, value=72, step=1, label="Max holding (bars)")
485
- btn = gr.Button("🚀 Run Scan", variant="primary")
486
- with gr.Column(scale=2):
487
- scan_out = gr.Markdown(label="Scan Report")
488
- met_out = gr.Markdown(label="Metrics")
489
- with gr.Tabs():
490
- with gr.Tab("📡 Signals"): sig_tbl = gr.Dataframe(interactive=False, wrap=True)
491
- with gr.Tab("📜 Trades"): tr_tbl = gr.Dataframe(interactive=False, wrap=True)
492
- with gr.Tab("📊 Regime"): reg_tbl = gr.Dataframe(interactive=False)
493
- with gr.Tab("🪙 Coins"): coin_tbl = gr.Dataframe(interactive=False)
494
- btn.click(run, inputs=[tf, nc, thr_i, lam_i, mc_i, mh_i],
495
- outputs=[scan_out, met_out, sig_tbl, tr_tbl, reg_tbl, coin_tbl])
496
- gr.Markdown("---\n**Data source:** Yahoo Finance (yfinance). "
497
- "Net of 0.10% commission + 0.05% slippage per side.")
498
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
 
500
  if __name__ == "__main__":
 
501
  demo.launch(server_name="0.0.0.0", server_port=7860)
 
1
  """
2
+ BIST Value Intelligence — Conservative Value Screener
3
+ =====================================================
4
+ A transparent, data-honest value screening application for Borsa Istanbul.
5
+
6
+ DESIGN PHILOSOPHY:
7
+ - Never fabricate missing data. Missing data = None + confidence penalty.
8
+ - Clearly distinguish between computed metrics and unavailable fields.
9
+ - Use only yfinance as data source (no KAP API key required).
10
+ - All assumptions are explicit in code.
11
+
12
+ LIMITATIONS (READ BEFORE USE):
13
+ - yfinance fundamentals are NOT point-in-time. Do not use for backtesting.
14
+ - Coverage for non-BIST-100 names is incomplete. Many fields will be None.
15
+ - No DCF, Monte Carlo, or ML—insufficient reliable historical data.
16
  """
17
 
18
+ import logging
19
+ import time
20
+ from datetime import datetime, timedelta
21
+ from typing import Any, Dict, List, Optional, Tuple
22
 
23
+ import gradio as gr
24
  import numpy as np
25
  import pandas as pd
26
+ import plotly.graph_objects as go
27
  import yfinance as yf
28
+
29
+ # ============================================================================
30
+ # CONFIGURATION
31
+ # ============================================================================
32
+
33
+ logging.basicConfig(
34
+ level=logging.INFO,
35
+ format="%(asctime)s | %(levelname)s | %(message)s",
36
+ )
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # BIST universe: core liquid names. Extend cautiously—non-BIST-100
40
+ # fundamentals coverage in yfinance is poor and produces misleading scores.
41
+ BIST_UNIVERSE = [
42
+ "AEFES.IS", "AKBNK.IS", "AKSA.IS", "AKSEN.IS", "ALARK.IS",
43
+ "ARCLK.IS", "ASELS.IS", "ASTOR.IS", "BIMAS.IS", "BRSAN.IS",
44
+ "CCOLA.IS", "CIMSA.IS", "DOAS.IS", "DOHOL.IS", "ECILC.IS",
45
+ "EKGYO.IS", "ENJSA.IS", "ENKAI.IS", "EREGL.IS", "FROTO.IS",
46
+ "GARAN.IS", "GUBRF.IS", "HEKTS.IS", "ISCTR.IS", "KCHOL.IS",
47
+ "KRDMD.IS", "MGROS.IS", "ODAS.IS", "PETKM.IS", "PGSUS.IS",
48
+ "SAHOL.IS", "SASA.IS", "SISE.IS", "SKBNK.IS", "TCELL.IS",
49
+ "THYAO.IS", "TKFEN.IS", "TOASO.IS", "TSKB.IS", "TTKOM.IS",
50
+ "TUPRS.IS", "ULKER.IS", "VAKBN.IS", "VESBE.IS", "YKBNK.IS",
51
+ "ZOREN.IS",
52
  ]
53
 
54
+ # Cache configuration
55
+ CACHE_TTL_HOURS = 6
56
+ _cache: Dict[str, Tuple[datetime, Any]] = {}
 
 
 
 
57
 
 
58
 
59
+ def _cache_get(key: str) -> Optional[Any]:
60
+ """Retrieve from cache if TTL valid."""
61
+ if key in _cache:
62
+ ts, data = _cache[key]
63
+ if datetime.now() - ts < timedelta(hours=CACHE_TTL_HOURS):
64
+ return data
65
+ return None
66
 
 
 
 
67
 
68
+ def _cache_set(key: str, data: Any) -> None:
69
+ """Store in cache."""
70
+ _cache[key] = (datetime.now(), data)
71
 
 
 
 
 
 
72
 
73
+ # ============================================================================
74
+ # DATA ACQUISITION
75
+ # ============================================================================
76
 
77
+ def fetch_ticker_data(symbol: str, force_refresh: bool = False) -> Optional[Dict[str, Any]]:
78
+ """
79
+ Fetch all available data for a single ticker.
80
+ Returns None if the ticker cannot be retrieved at all.
81
+ All individual fields may be None if unavailable.
82
+ """
83
+ cache_key = f"ticker_{symbol}"
84
+ if not force_refresh:
85
+ cached = _cache_get(cache_key)
86
+ if cached is not None:
87
+ return cached
88
 
89
+ try:
90
+ ticker = yf.Ticker(symbol)
91
+ info = ticker.info or {}
92
+
93
+ # Core price data
94
+ price = info.get("currentPrice") or info.get("regularMarketPrice")
95
+ if price is None:
96
+ # Fallback: try history
97
+ hist = ticker.history(period="5d")
98
+ if not hist.empty:
99
+ price = float(hist["Close"].iloc[-1])
100
+
101
+ # Market cap
102
+ market_cap = info.get("marketCap")
103
+
104
+ # Valuation multiples
105
+ trailing_pe = info.get("trailingPE")
106
+ forward_pe = info.get("forwardPE")
107
+ price_to_book = info.get("priceToBook")
108
+ price_to_sales = info.get("priceToSalesTrailing12Months")
109
+ ev_to_ebitda = info.get("enterpriseToEbitda")
110
+ enterprise_value = info.get("enterpriseValue")
111
+ earnings_yield = (1.0 / trailing_pe) if trailing_pe and trailing_pe > 0 else None
112
+
113
+ # Profitability
114
+ roe = info.get("returnOnEquity")
115
+ roa = info.get("returnOnAssets")
116
+ gross_margin = info.get("grossMargins")
117
+ operating_margin = info.get("operatingMargins")
118
+ profit_margin = info.get("profitMargins")
119
+
120
+ # Balance sheet
121
+ debt_to_equity = info.get("debtToEquity")
122
+ current_ratio = info.get("currentRatio")
123
+ total_debt = info.get("totalDebt")
124
+ total_cash = info.get("totalCash")
125
+
126
+ # Cash flow
127
+ free_cashflow = info.get("freeCashflow")
128
+ operating_cashflow = info.get("operatingCashflow")
129
+ fcf_yield = None
130
+ if free_cashflow is not None and market_cap and market_cap > 0:
131
+ fcf_yield = free_cashflow / market_cap
132
+
133
+ # Per-share data for Graham
134
+ trailing_eps = info.get("trailingEps")
135
+ book_value_per_share = info.get("bookValue")
136
+
137
+ # Sector
138
+ sector = info.get("sector", "Unknown")
139
+ industry = info.get("industry", "Unknown")
140
+
141
+ # Dividend
142
+ dividend_yield = info.get("dividendYield")
143
+
144
+ result = {
145
+ "symbol": symbol.replace(".IS", ""),
146
+ "price": price,
147
+ "market_cap": market_cap,
148
+ "trailing_pe": trailing_pe,
149
+ "forward_pe": forward_pe,
150
+ "price_to_book": price_to_book,
151
+ "price_to_sales": price_to_sales,
152
+ "ev_to_ebitda": ev_to_ebitda,
153
+ "enterprise_value": enterprise_value,
154
+ "earnings_yield": earnings_yield,
155
+ "roe": roe,
156
+ "roa": roa,
157
+ "gross_margin": gross_margin,
158
+ "operating_margin": operating_margin,
159
+ "profit_margin": profit_margin,
160
+ "debt_to_equity": debt_to_equity,
161
+ "current_ratio": current_ratio,
162
+ "total_debt": total_debt,
163
+ "total_cash": total_cash,
164
+ "free_cashflow": free_cashflow,
165
+ "operating_cashflow": operating_cashflow,
166
+ "fcf_yield": fcf_yield,
167
+ "trailing_eps": trailing_eps,
168
+ "book_value_per_share": book_value_per_share,
169
+ "sector": sector,
170
+ "industry": industry,
171
+ "dividend_yield": dividend_yield,
172
+ "data_timestamp": datetime.now().isoformat(),
173
+ }
174
+
175
+ # Graham Number: sqrt(22.5 * EPS * BVPS)
176
+ if (trailing_eps is not None and trailing_eps > 0
177
+ and book_value_per_share is not None and book_value_per_share > 0):
178
+ result["graham_number"] = float(np.sqrt(22.5 * trailing_eps * book_value_per_share))
179
+ else:
180
+ result["graham_number"] = None
181
 
182
+ _cache_set(cache_key, result)
183
+ return result
 
 
 
 
184
 
185
+ except Exception as e:
186
+ logger.warning(f"Failed to fetch {symbol}: {e}")
187
+ return None
188
 
 
 
 
 
 
 
189
 
190
+ # ============================================================================
191
+ # SCORING ENGINE
192
+ # ============================================================================
193
 
194
+ def compute_piotroski_score(data: Dict[str, Any]) -> Optional[int]:
195
  """
196
+ Compute Piotroski F-Score using available data.
 
197
  Returns None if insufficient data.
198
+
199
+ Note: True Piotroski requires year-over-year changes in 9 metrics.
200
+ Without historical statements, we compute a reduced version using
201
+ only current-period available fields. This is explicitly a partial score.
202
  """
203
+ score = 0
204
+ components_available = 0
205
+
206
+ # Profitability signals (4 components if data exists)
207
+ if data.get("roa") is not None:
208
+ components_available += 1
209
+ if data["roa"] > 0:
210
+ score += 1
211
+
212
+ if data.get("operating_cashflow") is not None:
213
+ components_available += 1
214
+ if data["operating_cashflow"] > 0:
215
+ score += 1
216
+
217
+ if data.get("roa") is not None and data.get("operating_cashflow") is not None:
218
+ components_available += 1
219
+ # CFO > ROA (approximation: CFO/assets > net income/assets)
220
+ if data["operating_cashflow"] > 0 and data["roa"] > 0:
221
+ score += 1
222
+
223
+ # Leverage/liquidity (2 components)
224
+ if data.get("debt_to_equity") is not None:
225
+ components_available += 1
226
+ # Lower leverage is better; use 100 as threshold (yfinance reports as percentage)
227
+ if data["debt_to_equity"] < 100:
228
+ score += 1
229
+
230
+ if data.get("current_ratio") is not None:
231
+ components_available += 1
232
+ if data["current_ratio"] > 1.0:
233
+ score += 1
234
+
235
+ # Require at least 4 components to return a score
236
+ if components_available < 4:
237
  return None
 
238
 
239
+ # Scale to 9-point maximum based on available components
240
+ # This is NOT a true Piotroski score and should be labeled accordingly
241
+ max_possible = components_available
242
+ scaled = round((score / max_possible) * 9) if max_possible > 0 else 0
243
+ return min(9, max(0, scaled))
 
 
 
 
 
 
 
244
 
 
 
 
245
 
246
+ def compute_valuation_score(data: Dict[str, Any], sector_medians: Dict[str, float]) -> Tuple[Optional[float], int]:
247
+ """
248
+ Compute valuation score (0-100). Returns (score, confidence_0_to_100).
249
+ Confidence reflects data completeness.
250
+ """
251
+ scores = []
252
+ weights = []
253
+ missing = 0
254
+ total = 0
255
+
256
+ # Earnings Yield (higher = better value)
257
+ total += 1
258
+ ey = data.get("earnings_yield")
259
+ if ey is not None and ey > 0:
260
+ # Normalize: 0% = 0, 15%+ = 100
261
+ s = min(100, ey * 100 / 0.15 * 100)
262
+ scores.append(s)
263
+ weights.append(0.25)
264
+ else:
265
+ missing += 1
266
+
267
+ # P/B vs sector (lower = better)
268
+ total += 1
269
+ pb = data.get("price_to_book")
270
+ sector_pb = sector_medians.get(data.get("sector", ""), {}).get("pb")
271
+ if pb is not None and pb > 0 and sector_pb and sector_pb > 0:
272
+ ratio = pb / sector_pb
273
+ s = max(0, min(100, (1.5 - ratio) / 1.5 * 100))
274
+ scores.append(s)
275
+ weights.append(0.25)
276
+ else:
277
+ missing += 1
278
+
279
+ # EV/EBITDA vs sector
280
+ total += 1
281
+ evebitda = data.get("ev_to_ebitda")
282
+ sector_ev = sector_medians.get(data.get("sector", ""), {}).get("ev_ebitda")
283
+ if evebitda is not None and evebitda > 0 and sector_ev and sector_ev > 0:
284
+ ratio = evebitda / sector_ev
285
+ s = max(0, min(100, (1.5 - ratio) / 1.5 * 100))
286
+ scores.append(s)
287
+ weights.append(0.25)
288
+ else:
289
+ missing += 1
290
+
291
+ # FCF Yield
292
+ total += 1
293
+ fcfy = data.get("fcf_yield")
294
+ if fcfy is not None:
295
+ s = min(100, max(0, fcfy * 100 / 0.08 * 100))
296
+ scores.append(s)
297
+ weights.append(0.25)
298
+ else:
299
+ missing += 1
300
+
301
+ if not scores:
302
+ return None, 0
303
+
304
+ # Weighted average
305
+ total_weight = sum(weights)
306
+ if total_weight == 0:
307
+ return None, 0
308
+ final_score = sum(s * w for s, w in zip(scores, weights)) / total_weight
309
+
310
+ # Confidence: penalize missing data
311
+ confidence = max(0, 100 - (missing / total) * 60)
312
+ return final_score, int(confidence)
313
+
314
+
315
+ def compute_quality_score(data: Dict[str, Any]) -> Tuple[Optional[float], int]:
316
+ """Compute quality score (0-100) with confidence."""
317
+ scores = []
318
+ weights = []
319
+ missing = 0
320
+ total = 0
321
+
322
+ # ROE (higher = better)
323
+ total += 1
324
+ roe = data.get("roe")
325
+ if roe is not None:
326
+ s = min(100, max(0, roe * 100 / 0.25 * 100))
327
+ scores.append(s)
328
+ weights.append(0.3)
329
+ else:
330
+ missing += 1
331
+
332
+ # Operating margin
333
+ total += 1
334
+ om = data.get("operating_margin")
335
+ if om is not None:
336
+ s = min(100, max(0, om * 100 / 0.20 * 100))
337
+ scores.append(s)
338
+ weights.append(0.25)
339
+ else:
340
+ missing += 1
341
+
342
+ # Debt/Equity (lower = better)
343
+ total += 1
344
+ de = data.get("debt_to_equity")
345
+ if de is not None and de >= 0:
346
+ s = max(0, min(100, (150 - de) / 150 * 100))
347
+ scores.append(s)
348
+ weights.append(0.25)
349
+ else:
350
+ missing += 1
351
+
352
+ # Current ratio
353
+ total += 1
354
+ cr = data.get("current_ratio")
355
+ if cr is not None:
356
+ s = min(100, max(0, (cr - 0.5) / 2.0 * 100))
357
+ scores.append(s)
358
+ weights.append(0.20)
359
+ else:
360
+ missing += 1
361
+
362
+ if not scores:
363
+ return None, 0
364
 
365
+ total_weight = sum(weights)
366
+ final_score = sum(s * w for s, w in zip(scores, weights)) / total_weight
367
+ confidence = max(0, 100 - (missing / total) * 60)
368
+ return final_score, int(confidence)
 
369
 
 
 
 
370
 
371
+ def compute_composite_score(
372
+ data: Dict[str, Any],
373
+ sector_medians: Dict[str, float],
374
+ ) -> Dict[str, Any]:
375
+ """
376
+ Compute composite score with explicit component breakdown.
377
+ Missing data reduces confidence; it does NOT boost scores.
378
+ """
379
+ val_score, val_conf = compute_valuation_score(data, sector_medians)
380
+ qual_score, qual_conf = compute_quality_score(data)
381
+
382
+ piotroski = compute_piotroski_score(data)
383
+
384
+ # Weighted composite: 50% valuation, 50% quality (simplified from spec
385
+ # because growth data is unavailable)
386
+ components = []
387
+ weights = []
388
+
389
+ if val_score is not None:
390
+ components.append(val_score * 0.50)
391
+ weights.append(0.50)
392
+ if qual_score is not None:
393
+ components.append(qual_score * 0.50)
394
+ weights.append(0.50)
395
+
396
+ if not components:
397
+ composite = None
398
+ overall_conf = 0
399
+ else:
400
+ total_w = sum(weights)
401
+ composite = sum(c * w for c, w in zip(components, weights)) / total_w
402
+ # Overall confidence: weighted average of component confidences
403
+ overall_conf = int((val_conf * 0.5 + qual_conf * 0.5)) if (val_score is not None and qual_score is not None) else max(val_conf, qual_conf)
404
+
405
+ # Margin of Safety vs Graham Number
406
+ mos = None
407
+ graham = data.get("graham_number")
408
+ price = data.get("price")
409
+ if graham and price and price > 0:
410
+ mos = (graham - price) / graham
411
+
412
+ return {
413
+ "composite_score": composite,
414
+ "valuation_score": val_score,
415
+ "quality_score": qual_score,
416
+ "piotroski": piotroski,
417
+ "confidence": overall_conf,
418
+ "margin_of_safety": mos,
419
+ }
420
+
421
+
422
+ # ============================================================================
423
+ # FORECASTING (Conservative, assumption-explicit)
424
+ # ============================================================================
425
+
426
+ def compute_12m_forecast(data: Dict[str, Any], score_result: Dict[str, Any]) -> Dict[str, Any]:
427
+ """
428
+ Scenario-based 12-month price target.
429
+
430
+ METHODOLOGY (explicit):
431
+ - Bear: Price reverts to 70% of current P/E multiple (multiple compression)
432
+ - Base: Price grows with earnings yield (conservative proxy for earnings growth)
433
+ - Bull: Price re-rates to 120% of current P/E multiple
434
+
435
+ These are NOT statistically calibrated forecasts. They are
436
+ transparent heuristic scenarios. Do not treat them as predictions.
437
+ """
438
+ price = data.get("price")
439
+ pe = data.get("trailing_pe")
440
+ earnings_yield = data.get("earnings_yield")
441
+
442
+ if price is None or price <= 0:
443
+ return {"bear": None, "base": None, "bull": None, "expected": None,
444
+ "expected_return": None, "note": "Insufficient price data"}
445
+
446
+ # Base case: earnings power drives price
447
+ # If earnings yield = 10%, implied annual return ≈ 10% (simplistic)
448
+ base_growth = 0.08 # Conservative 8% nominal (below Turkish historical inflation)
449
+ if earnings_yield and earnings_yield > 0:
450
+ base_growth = min(0.20, max(0.03, earnings_yield * 0.8))
451
+
452
+ bear_return = -0.20 # 20% downside in bear
453
+ bull_return = base_growth * 1.8
454
+
455
+ bear = price * (1 + bear_return)
456
+ base = price * (1 + base_growth)
457
+ bull = price * (1 + bull_return)
458
+
459
+ # Probability-weighted (20/55/25 from spec)
460
+ expected = bear * 0.20 + base * 0.55 + bull * 0.25
461
+ expected_return = (expected - price) / price
462
+
463
+ # Add dividend yield if available
464
+ div_yield = data.get("dividend_yield") or 0
465
+ total_return = expected_return + div_yield
466
+
467
+ return {
468
+ "bear": round(bear, 2),
469
+ "base": round(base, 2),
470
+ "bull": round(bull, 2),
471
+ "expected": round(expected, 2),
472
+ "expected_return": round(expected_return, 4),
473
+ "total_expected_return": round(total_return, 4),
474
+ "dividend_yield": div_yield,
475
+ "base_assumption": f"{base_growth*100:.1f}% base growth",
476
+ "note": "Heuristic scenario, not calibrated forecast",
477
+ }
478
+
479
+
480
+ # ============================================================================
481
+ # SECTOR MEDIANS (computed from universe)
482
+ # ============================================================================
483
+
484
+ def compute_sector_medians(all_data: List[Dict[str, Any]]) -> Dict[str, Dict[str, float]]:
485
+ """Compute median multiples per sector from available data."""
486
+ sectors: Dict[str, Dict[str, List[float]]] = {}
487
+
488
+ for d in all_data:
489
+ sector = d.get("sector", "Unknown")
490
+ if sector not in sectors:
491
+ sectors[sector] = {"pb": [], "ev_ebitda": [], "pe": []}
492
+
493
+ if d.get("price_to_book") and d["price_to_book"] > 0:
494
+ sectors[sector]["pb"].append(d["price_to_book"])
495
+ if d.get("ev_to_ebitda") and d["ev_to_ebitda"] > 0:
496
+ sectors[sector]["ev_ebitda"].append(d["ev_to_ebitda"])
497
+ if d.get("trailing_pe") and d["trailing_pe"] > 0:
498
+ sectors[sector]["pe"].append(d["trailing_pe"])
499
+
500
+ medians = {}
501
+ for sector, metrics in sectors.items():
502
+ medians[sector] = {}
503
+ for key, values in metrics.items():
504
+ if values:
505
+ medians[sector][key] = float(np.median(values))
506
+ return medians
507
+
508
+
509
+ # ============================================================================
510
+ # SCANNER
511
+ # ============================================================================
512
+
513
+ def run_scanner(force_refresh: bool = False) -> pd.DataFrame:
514
+ """Run full universe scan. Returns DataFrame for display."""
515
+ all_data = []
516
+ failed = []
517
+
518
+ for i, sym in enumerate(BIST_UNIVERSE):
519
+ logger.info(f"Scanning {sym} ({i+1}/{len(BIST_UNIVERSE)})")
520
+ data = fetch_ticker_data(sym, force_refresh)
521
+ if data is not None and data.get("price") is not None:
522
+ all_data.append(data)
523
+ else:
524
+ failed.append(sym)
525
 
526
+ if not all_data:
527
+ return pd.DataFrame()
528
+
529
+ sector_medians = compute_sector_medians(all_data)
530
 
531
+ rows = []
532
+ for d in all_data:
533
+ score_result = compute_composite_score(d, sector_medians)
534
+ forecast = compute_12m_forecast(d, score_result)
535
+
536
+ rows.append({
537
+ "Symbol": d["symbol"],
538
+ "Price": d["price"],
539
+ "Sector": d["sector"],
540
+ "Composite Score": round(score_result["composite_score"], 1) if score_result["composite_score"] else None,
541
+ "Valuation Score": round(score_result["valuation_score"], 1) if score_result["valuation_score"] else None,
542
+ "Quality Score": round(score_result["quality_score"], 1) if score_result["quality_score"] else None,
543
+ "Piotroski (partial)": score_result["piotroski"],
544
+ "P/E": round(d["trailing_pe"], 2) if d["trailing_pe"] else None,
545
+ "P/B": round(d["price_to_book"], 2) if d["price_to_book"] else None,
546
+ "ROE": round(d["roe"] * 100, 1) if d["roe"] else None,
547
+ "FCF Yield": round(d["fcf_yield"] * 100, 1) if d["fcf_yield"] else None,
548
+ "Graham Number": round(d["graham_number"], 2) if d["graham_number"] else None,
549
+ "Margin of Safety": round(score_result["margin_of_safety"] * 100, 1) if score_result["margin_of_safety"] else None,
550
+ "12M Base Target": forecast["base"],
551
+ "Expected Return": round(forecast["total_expected_return"] * 100, 1) if forecast["total_expected_return"] else None,
552
+ "Confidence": score_result["confidence"],
553
+ })
554
+
555
+ df = pd.DataFrame(rows)
556
+ # Sort by composite score, nulls last
557
+ df = df.sort_values("Composite Score", ascending=False, na_position="last")
558
  return df
559
 
560
 
561
+ def get_company_detail(symbol_input: str) -> Tuple[str, Optional[go.Figure]]:
562
+ """Return detailed markdown report and price chart for a company."""
563
+ symbol = symbol_input.strip().upper()
564
+ if not symbol.endswith(".IS"):
565
+ symbol = symbol + ".IS"
566
+
567
+ data = fetch_ticker_data(symbol)
568
+ if data is None or data.get("price") is None:
569
+ return f"## {symbol}\n\nNo data available. Check ticker validity.", None
570
+
571
+ # Recompute score (need sector medians from current cache)
572
+ all_cached = []
573
+ for key in _cache:
574
+ if key.startswith("ticker_"):
575
+ _, val = _cache[key]
576
+ if val is not None:
577
+ all_cached.append(val)
578
+ sector_medians = compute_sector_medians(all_cached) if all_cached else {}
579
+
580
+ score = compute_composite_score(data, sector_medians)
581
+ forecast = compute_12m_forecast(data, score)
582
+
583
+ # Build markdown report
584
+ lines = [
585
+ f"## {data['symbol']} — Company Detail",
586
+ "",
587
+ f"**Sector**: {data['sector']} | **Industry**: {data['industry']}",
588
+ f"**Data timestamp**: {data['data_timestamp']}",
589
+ "",
590
+ "### Valuation",
591
+ "",
592
+ f"| Metric | Value |",
593
+ f"|---|---|",
594
+ f"| Price | {data['price']} |",
595
+ f"| Market Cap | {data['market_cap']:,} |" if data['market_cap'] else "| Market Cap | N/A |",
596
+ f"| Trailing P/E | {data['trailing_pe']:.1f} |" if data['trailing_pe'] else "| Trailing P/E | N/A |",
597
+ f"| P/B | {data['price_to_book']:.2f} |" if data['price_to_book'] else "| P/B | N/A |",
598
+ f"| EV/EBITDA | {data['ev_to_ebitda']:.1f} |" if data['ev_to_ebitda'] else "| EV/EBITDA | N/A |",
599
+ f"| FCF Yield | {data['fcf_yield']*100:.1f}% |" if data['fcf_yield'] else "| FCF Yield | N/A |",
600
+ f"| Graham Number | {data['graham_number']:.2f} |" if data['graham_number'] else "| Graham Number | N/A |",
601
+ f"| Margin of Safety | {score['margin_of_safety']*100:.1f}% |" if score['margin_of_safety'] else "| Margin of Safety | N/A |",
602
+ "",
603
+ "### Quality",
604
+ "",
605
+ f"| Metric | Value |",
606
+ f"|---|---|",
607
+ f"| ROE | {data['roe']*100:.1f}% |" if data['roe'] else "| ROE | N/A |",
608
+ f"| ROA | {data['roa']*100:.1f}% |" if data['roa'] else "| ROA | N/A |",
609
+ f"| Operating Margin | {data['operating_margin']*100:.1f}% |" if data['operating_margin'] else "| Operating Margin | N/A |",
610
+ f"| Debt/Equity | {data['debt_to_equity']:.1f} |" if data['debt_to_equity'] is not None else "| Debt/Equity | N/A |",
611
+ f"| Current Ratio | {data['current_ratio']:.2f} |" if data['current_ratio'] else "| Current Ratio | N/A |",
612
+ f"| Piotroski (partial) | {score['piotroski']}/9 |" if score['piotroski'] is not None else "| Piotroski (partial) | N/A |",
613
+ "",
614
+ "### Scores",
615
+ "",
616
+ f"- **Composite**: {score['composite_score']:.1f}/100 (confidence: {score['confidence']}%)" if score['composite_score'] else "- **Composite**: N/A",
617
+ f"- **Valuation**: {score['valuation_score']:.1f}/100" if score['valuation_score'] else "- **Valuation**: N/A",
618
+ f"- **Quality**: {score['quality_score']:.1f}/100" if score['quality_score'] else "- **Quality**: N/A",
619
+ "",
620
+ "### 12-Month Scenarios (heuristic, not calibrated)",
621
+ "",
622
+ f"| Scenario | Target | Return |",
623
+ f"|---|---|---|",
624
+ f"| Bear | {forecast['bear']} | {(forecast['bear']/data['price']-1)*100:.1f}% |" if forecast['bear'] else "| Bear | N/A | N/A |",
625
+ f"| Base | {forecast['base']} | {(forecast['base']/data['price']-1)*100:.1f}% |" if forecast['base'] else "| Base | N/A | N/A |",
626
+ f"| Bull | {forecast['bull']} | {(forecast['bull']/data['price']-1)*100:.1f}% |" if forecast['bull'] else "| Bull | N/A | N/A |",
627
+ f"| **Probability-weighted** | **{forecast['expected']}** | **{forecast['total_expected_return']*100:.1f}%** |" if forecast['expected'] else "| Expected | N/A | N/A |",
628
+ "",
629
+ f"*{forecast['note']}*",
630
+ "",
631
+ "---",
632
+ "*Data source: Yahoo Finance via yfinance. Fundamentals are not point-in-time.*",
633
+ ]
634
+
635
+ markdown = "\n".join(lines)
636
+
637
+ # Price chart
638
+ try:
639
+ ticker = yf.Ticker(symbol)
640
+ hist = ticker.history(period="1y")
641
+ if not hist.empty:
642
+ fig = go.Figure()
643
+ fig.add_trace(go.Scatter(
644
+ x=hist.index, y=hist["Close"],
645
+ mode="lines", name="Close",
646
+ line=dict(color="#1f77b4", width=2),
647
+ ))
648
+ fig.update_layout(
649
+ title=f"{symbol} — 1 Year Price",
650
+ xaxis_title="Date", yaxis_title="Price (TRY)",
651
+ template="plotly_white", height=400,
652
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
  else:
654
+ fig = None
655
+ except Exception:
656
+ fig = None
657
+
658
+ return markdown, fig
659
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
660
 
661
+ # ============================================================================
662
+ # DATA QUALITY MONITOR
663
+ # ============================================================================
664
 
665
+ def data_quality_report() -> pd.DataFrame:
666
+ """Report data completeness across the universe."""
667
  rows = []
668
+ for sym in BIST_UNIVERSE:
669
+ data = fetch_ticker_data(sym)
670
+ if data is None:
671
+ rows.append({"Symbol": sym.replace(".IS", ""), "Status": "FETCH FAILED",
672
+ "Fields Available": 0, "Critical Missing": "ALL"})
673
+ continue
674
+
675
+ critical_fields = ["price", "trailing_pe", "price_to_book", "roe", "fcf_yield"]
676
+ available = sum(1 for f in critical_fields if data.get(f) is not None)
677
+ missing = [f for f in critical_fields if data.get(f) is None]
678
+
679
+ rows.append({
680
+ "Symbol": data["symbol"],
681
+ "Status": "OK" if available >= 3 else "INCOMPLETE",
682
+ "Fields Available": f"{available}/{len(critical_fields)}",
683
+ "Critical Missing": ", ".join(missing) if missing else "—",
684
+ "Sector": data.get("sector", "Unknown"),
685
+ })
686
+
687
+ return pd.DataFrame(rows)
688
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689
 
690
+ # ============================================================================
691
+ # GRADIO UI
692
+ # ============================================================================
693
+
694
+ def build_ui():
695
+ with gr.Blocks(title="BIST Value Intelligence", theme=gr.themes.Soft()) as demo:
696
+ gr.Markdown("""
697
+ # BIST Value Intelligence
698
+ ### Conservative Value Screening for Borsa Istanbul
699
+
700
+ **⚠️ Important limitations:**
701
+ - Data from Yahoo Finance is **not point-in-time**. Do not use for backtesting.
702
+ - Coverage for non-BIST-100 stocks is incomplete. Missing data is shown as `N/A`, never imputed.
703
+ - Scores are **relative rankings within this universe**, not absolute quality measures.
704
+ - The 12-month scenarios are **transparent heuristics**, not statistically calibrated forecasts.
705
+ - This tool is for research and education. Not investment advice.
706
+ """)
707
+
708
+ with gr.Tabs():
709
+ # Tab 1: Scanner
710
+ with gr.Tab("📊 Market Scanner"):
711
+ with gr.Row():
712
+ scan_btn = gr.Button("Run Scanner", variant="primary", scale=1)
713
+ refresh_cb = gr.Checkbox(label="Force refresh (bypass cache)", value=False, scale=1)
714
+
715
+ status_text = gr.Textbox(label="Status", interactive=False, visible=False)
716
+ scanner_table = gr.Dataframe(
717
+ label="BIST Universe Scan Results",
718
+ interactive=False,
719
+ wrap=True,
720
+ )
721
+
722
+ def do_scan(force):
723
+ status_text = gr.update(visible=True, value="Scanning universe...")
724
+ df = run_scanner(force_refresh=force)
725
+ if df.empty:
726
+ return gr.update(value=None), gr.update(visible=True, value="No data retrieved. Check network.")
727
+ return df, gr.update(visible=True, value=f"Scanned {len(df)} stocks. Sorted by composite score.")
728
+
729
+ scan_btn.click(do_scan, inputs=[refresh_cb], outputs=[scanner_table, status_text])
730
+
731
+ # Tab 2: Company Detail
732
+ with gr.Tab("🔍 Company Analysis"):
733
+ with gr.Row():
734
+ ticker_input = gr.Textbox(label="Ticker (e.g., THYAO, GARAN)", value="THYAO")
735
+ detail_btn = gr.Button("Analyze", variant="primary")
736
+
737
+ detail_md = gr.Markdown()
738
+ price_chart = gr.Plot()
739
+
740
+ detail_btn.click(
741
+ get_company_detail,
742
+ inputs=[ticker_input],
743
+ outputs=[detail_md, price_chart],
744
+ )
745
+
746
+ # Tab 3: Data Quality
747
+ with gr.Tab("📋 Data Quality Monitor"):
748
+ gr.Markdown("""
749
+ Shows which fundamental fields are available for each ticker.
750
+ **Critical fields**: price, trailing_pe, price_to_book, roe, fcf_yield.
751
+
752
+ If fewer than 3 of 5 are available, the stock's score confidence is severely reduced.
753
+ """)
754
+ quality_btn = gr.Button("Check Data Quality", variant="primary")
755
+ quality_table = gr.Dataframe(label="Field Availability", interactive=False)
756
+
757
+ def do_quality():
758
+ return data_quality_report()
759
+
760
+ quality_btn.click(do_quality, outputs=[quality_table])
761
+
762
+ # Tab 4: Methodology
763
+ with gr.Tab("📐 Methodology"):
764
+ gr.Markdown("""
765
+ ### Scoring Methodology
766
+
767
+ **Valuation Score (50% weight in composite)**
768
+ - Earnings yield (25%): normalized to 15% = 100
769
+ - P/B vs sector median (25%): lower is better
770
+ - EV/EBITDA vs sector median (25%): lower is better
771
+ - FCF yield (25%): normalized to 8% = 100
772
+
773
+ **Quality Score (50% weight in composite)**
774
+ - ROE (30%): normalized to 25% = 100
775
+ - Operating margin (25%): normalized to 20% = 100
776
+ - Debt/Equity (25%): lower is better, 150 = 0
777
+ - Current ratio (20%): 2.0 = 100, 0.5 = 0
778
+
779
+ **Piotroski F-Score (partial)**
780
+ - Computed from available current-period fields only
781
+ - Does NOT include year-over-year changes (historical statements unavailable)
782
+ - Scaled to 9-point maximum based on available components
783
+ - **Labeled "partial" throughout the UI**
784
+
785
+ **Graham Number**
786
+ - `sqrt(22.5 × EPS × Book Value per Share)`
787
+ - Requires positive EPS and positive BVPS
788
+ - Returns `N/A` otherwise
789
+
790
+ **12-Month Scenarios**
791
+ - Bear: 20% downside (multiple compression)
792
+ - Base: Earnings-yield-proportional growth, capped at 20%, floored at 3%
793
+ - Bull: Base × 1.8
794
+ - Probability-weighted: 20% bear, 55% base, 25% bull
795
+
796
+ **Confidence Score**
797
+ - Reflects data completeness
798
+ - Missing critical fields reduce confidence
799
+ - High confidence with missing data is impossible by design
800
+
801
+ ### What This Tool Does NOT Do
802
+
803
+ - ❌ DCF valuation (requires growth assumptions we cannot validate)
804
+ - ❌ Monte Carlo simulation (requires calibrated return distributions)
805
+ - ❌ Machine learning (requires point-in-time data for training)
806
+ - ❌ Historical backtesting (survivorship and look-ahead bias unavoidable with current data)
807
+ - ❌ Fabricate missing data
808
+
809
+ ### Why the Limitations Exist
810
+
811
+ Yahoo Finance fundamentals for BIST stocks are:
812
+ 1. **Not point-in-time**: the API returns current snapshots, not historical filing data
813
+ 2. **Incomplete**: many small/mid-cap stocks lack ROE, FCF, and debt figures
814
+ 3. **Inconsistent**: field availability changes without notice
815
+
816
+ KAP (the official Turkish disclosure platform) is the authoritative source, but programmatic access requires an MKK API key and IP whitelisting. The `kap-mcp` library exists for this purpose [citation:2][citation:12] but cannot be used without institutional credentials.
817
+
818
+ For a production-grade system, you need either:
819
+ - KAP API access (via MKK)
820
+ - A paid data vendor (Bloomberg, Refinitiv, Matriks, İş Yatırım)
821
+ - BilancoVeri's API (free but needs validation) [citation:11]
822
+ """)
823
+
824
+ return demo
825
+
826
+
827
+ # ============================================================================
828
+ # MAIN
829
+ # ============================================================================
830
 
831
  if __name__ == "__main__":
832
+ demo = build_ui()
833
  demo.launch(server_name="0.0.0.0", server_port=7860)