Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,501 +1,833 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
-
import
|
| 7 |
-
|
| 8 |
-
from
|
|
|
|
| 9 |
|
|
|
|
| 10 |
import numpy as np
|
| 11 |
import pandas as pd
|
|
|
|
| 12 |
import yfinance as yf
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
"
|
| 30 |
-
"
|
| 31 |
-
"
|
| 32 |
-
"
|
| 33 |
-
"
|
| 34 |
-
"
|
|
|
|
|
|
|
| 35 |
]
|
| 36 |
|
| 37 |
-
#
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
"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
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
| 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
|
| 85 |
"""
|
| 86 |
-
|
| 87 |
-
For 4h, downloads 1h and resamples to 4H.
|
| 88 |
Returns None if insufficient data.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
"""
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
return None
|
| 92 |
-
interval, period = YF_TF[timeframe]
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 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 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 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 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
-
if
|
| 133 |
-
|
| 134 |
-
|
|
|
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
return df
|
| 139 |
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
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 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 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
|
| 349 |
-
|
| 350 |
rows = []
|
| 351 |
-
for sym in
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 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)
|