Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,63 +1,68 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
=========================================================================
|
| 4 |
-
Data integrity > model complexity.
|
| 5 |
-
|
| 6 |
-
Veri
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import logging
|
|
|
|
| 11 |
import tempfile
|
| 12 |
import time
|
| 13 |
-
from datetime import datetime, timedelta
|
| 14 |
from typing import Any, Dict, List, Optional, Tuple
|
| 15 |
|
| 16 |
import gradio as gr
|
| 17 |
import numpy as np
|
| 18 |
import pandas as pd
|
| 19 |
import plotly.graph_objects as go
|
| 20 |
-
import
|
| 21 |
|
| 22 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
|
| 23 |
-
logger = logging.getLogger("
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
"UNH", "JNJ", "LLY", "PFE", "MRK", "ABBV", "TMO", "ABT", "DHR", "BMY",
|
| 34 |
-
"AMGN", "GILD", "MDT", "CVS", "ISRG",
|
| 35 |
-
# Bankalar
|
| 36 |
-
"JPM", "BAC", "WFC", "C", "GS", "MS", "USB", "PNC", "TFC", "SCHW", "BK",
|
| 37 |
-
# Sigorta
|
| 38 |
-
"BRK-B", "PGR", "CB", "MMC", "AIG", "MET", "PRU", "ALL", "TRV", "AFL",
|
| 39 |
-
# Sanayi
|
| 40 |
-
"CAT", "DE", "HON", "GE", "UPS", "UNP", "RTX", "LMT", "BA", "MMM",
|
| 41 |
-
"FDX", "EMR", "ETN", "ITW", "WM",
|
| 42 |
-
# Enerji
|
| 43 |
-
"XOM", "CVX", "COP", "SLB", "EOG", "OXY", "PSX", "MPC", "KMI",
|
| 44 |
-
# Kamu hizmetleri
|
| 45 |
-
"NEE", "DUK", "SO", "D", "AEP",
|
| 46 |
-
# Malzeme
|
| 47 |
-
"LIN", "SHW", "APD", "NEM", "FCX", "DOW", "NUE",
|
| 48 |
-
# REIT
|
| 49 |
-
"PLD", "AMT", "EQIX", "O", "SPG", "PSA", "WELL", "DLR", "CCI",
|
| 50 |
-
# İletişim / medya
|
| 51 |
-
"T", "VZ", "CMCSA", "DIS", "NFLX", "TMUS",
|
| 52 |
-
]
|
| 53 |
-
US_UNIVERSE = list(dict.fromkeys(US_UNIVERSE)) # tekrarları temizle
|
| 54 |
-
assert len(US_UNIVERSE) >= 100, f"Evren {len(US_UNIVERSE)} hisse; en az 100 olmalı"
|
| 55 |
-
|
| 56 |
-
REQUEST_DELAY_SEC = 0.3
|
| 57 |
-
CACHE_TTL_HOURS = 6
|
| 58 |
MIN_PEERS = 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
_cache: Dict[str, Tuple[datetime, Any]] = {}
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
def _cget(k):
|
|
@@ -78,7 +83,7 @@ def _cset(k, v):
|
|
| 78 |
|
| 79 |
def num(x) -> Optional[float]:
|
| 80 |
try:
|
| 81 |
-
if x is None:
|
| 82 |
return None
|
| 83 |
v = float(x)
|
| 84 |
return None if (np.isnan(v) or np.isinf(v)) else v
|
|
@@ -93,13 +98,6 @@ def div(a, b) -> Optional[float]:
|
|
| 93 |
return a / b
|
| 94 |
|
| 95 |
|
| 96 |
-
def growth(cur, prev) -> Optional[float]:
|
| 97 |
-
cur, prev = num(cur), num(prev)
|
| 98 |
-
if cur is None or prev is None or prev <= 0:
|
| 99 |
-
return None
|
| 100 |
-
return cur / prev - 1
|
| 101 |
-
|
| 102 |
-
|
| 103 |
def real_ret(nominal, infl) -> Optional[float]:
|
| 104 |
return None if nominal is None else (1 + nominal) / (1 + infl) - 1
|
| 105 |
|
|
@@ -129,205 +127,316 @@ def fm(x, d=2):
|
|
| 129 |
return "N/A" if x is None else f"{x:,.{d}f}"
|
| 130 |
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
def fp(x, d=1):
|
| 133 |
return "N/A" if x is None else f"{x * 100:.{d}f}%"
|
| 134 |
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
def norm_sym(t: str) -> str:
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
|
| 141 |
def mk_params(infl, rw, rtg, rg, pt, cv) -> Dict[str, float]:
|
| 142 |
return dict(infl=infl / 100, rw=rw / 100, rtg=rtg / 100, rg=rg / 100, pt=pt / 100, conv=cv / 100)
|
| 143 |
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
# ============================================================================
|
| 146 |
-
# DATA ACQUISITION
|
| 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 |
-
def
|
| 175 |
-
if
|
| 176 |
-
return
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
return num(r.iloc[i])
|
| 183 |
-
return None
|
| 184 |
|
| 185 |
|
| 186 |
-
def
|
| 187 |
-
|
| 188 |
-
if "bank" in ind:
|
| 189 |
-
return "Bank"
|
| 190 |
-
if "insurance" in ind:
|
| 191 |
-
return "Insurance"
|
| 192 |
-
if "reit" in ind or "real estate" in ind:
|
| 193 |
-
return "REIT"
|
| 194 |
-
return "Industrial"
|
| 195 |
|
| 196 |
|
| 197 |
-
def
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
if not force:
|
| 200 |
c = _cget(key)
|
| 201 |
if c is not None:
|
| 202 |
return c
|
| 203 |
try:
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
dv = None
|
| 211 |
-
price = num(info.get("currentPrice")) or num(info.get("regularMarketPrice"))
|
| 212 |
-
if price is None:
|
| 213 |
-
h = t.history(period="5d")
|
| 214 |
-
price = num(h["Close"].iloc[-1]) if not h.empty else None
|
| 215 |
except Exception as e:
|
| 216 |
-
logger.warning("%s fetch failed: %s",
|
| 217 |
return None
|
| 218 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
return None
|
| 220 |
|
| 221 |
flags: List[str] = []
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
age = (datetime.now() - sd).days if sd else None
|
| 250 |
-
if age is not None and age > 550:
|
| 251 |
-
flags.append(f"stale statements ({age} days)")
|
| 252 |
-
if sd is None:
|
| 253 |
-
flags.append("no annual statements")
|
| 254 |
-
fc, cur = info.get("financialCurrency"), info.get("currency")
|
| 255 |
-
unit_ok = (fc is None) or (cur is None) or (fc == cur)
|
| 256 |
-
if not unit_ok:
|
| 257 |
-
flags.append(f"currency mismatch (fin {fc} vs price {cur})")
|
| 258 |
-
if fc is None:
|
| 259 |
-
flags.append("financial currency unknown")
|
| 260 |
-
|
| 261 |
-
shares = num(info.get("sharesOutstanding")) or s["shares"][0]
|
| 262 |
-
mcap = num(info.get("marketCap")) or (price * shares if shares else None)
|
| 263 |
-
if shares and mcap and abs(price * shares / mcap - 1) > 0.35:
|
| 264 |
-
flags.append("shares x price differs >35% from market cap")
|
| 265 |
-
eq0, ta0 = s["eq"][0], s["ta"][0]
|
| 266 |
-
if eq0 is not None and ta0 is not None and eq0 > ta0 * 1.01:
|
| 267 |
-
flags.append("equity > total assets (suspicious)")
|
| 268 |
-
if s["rev"][0] and s["ni"][0] is not None and abs(s["ni"][0]) > 1.0 * s["rev"][0] and s["rev"][0] > 0:
|
| 269 |
-
flags.append("|net income| > revenue (suspicious)")
|
| 270 |
-
|
| 271 |
-
bvps = num(info.get("bookValue")) or div(eq0, shares)
|
| 272 |
-
eps = num(info.get("trailingEps")) or div(s["ni"][0], shares)
|
| 273 |
-
if eps is not None and shares and s["ni"][0] not in (None, 0) and abs(eps * shares / s["ni"][0] - 1) > 0.5:
|
| 274 |
-
flags.append("EPS x shares differs >50% from net income")
|
| 275 |
-
|
| 276 |
-
# dividends: 0 only if history fetched and empty; None if unavailable
|
| 277 |
-
dy = None
|
| 278 |
-
try:
|
| 279 |
-
if dv is not None:
|
| 280 |
-
if len(dv) > 0:
|
| 281 |
-
cutoff = pd.Timestamp.now(tz=dv.index.tz) - pd.Timedelta(days=365)
|
| 282 |
-
dy = float(dv[dv.index >= cutoff].sum()) / price
|
| 283 |
-
else:
|
| 284 |
-
dy = 0.0
|
| 285 |
-
except Exception:
|
| 286 |
-
dy = None
|
| 287 |
-
if dy is None:
|
| 288 |
-
y = num(info.get("dividendYield"))
|
| 289 |
-
if y is not None:
|
| 290 |
-
dy = y / 100 if y > 1 else y
|
| 291 |
-
|
| 292 |
-
tax_assumed = False
|
| 293 |
-
tr = div(s["tax"][0], s["pretax"][0]) if (s["pretax"][0] or 0) > 0 else None
|
| 294 |
-
if tr is None:
|
| 295 |
-
tr, tax_assumed = 0.25, True
|
| 296 |
-
tr = float(np.clip(tr, 0, 0.35))
|
| 297 |
-
|
| 298 |
-
net_debt = (s["debt"][0] - s["cash"][0]) if (s["debt"][0] is not None and s["cash"][0] is not None) else None
|
| 299 |
-
fcf0, int0 = s["fcf"][0], s["interest"][0]
|
| 300 |
-
fcff = (fcf0 + int0 * (1 - tr)) if (fcf0 is not None and int0 is not None) else None # unlevered -> net debt bridge
|
| 301 |
-
oe, oe_parts = None, None
|
| 302 |
-
ni0, da0, cx0, wc0 = s["ni"][0], s["da"][0], s["capex"][0], s["dwc"][0]
|
| 303 |
-
if None not in (ni0, da0, cx0, wc0):
|
| 304 |
-
maint = min(cx0, da0) # [D] estimate: maintenance capex ~ min(total capex, D&A)
|
| 305 |
-
oe = ni0 + da0 - maint + wc0
|
| 306 |
-
oe_parts = dict(ni=ni0, da=da0, capex_total=cx0, maint_est=maint, dwc=wc0)
|
| 307 |
-
ev = (mcap + net_debt) if (mcap and net_debt is not None) else num(info.get("enterpriseValue"))
|
| 308 |
-
ebitda0 = s["ebitda"][0]
|
| 309 |
-
industry = info.get("industry") or "Unknown"
|
| 310 |
-
seg = segment_of(industry, symbol)
|
| 311 |
-
roes = [div(s["ni"][i], s["eq"][i]) for i in range(3) if (s["eq"][i] or 0) > 0]
|
| 312 |
-
roes = [r for r in roes if r is not None]
|
| 313 |
-
ir = num(info.get("returnOnEquity"))
|
| 314 |
-
if ir is not None:
|
| 315 |
-
roes.append(ir)
|
| 316 |
-
sust_roe = float(np.clip(np.mean(roes), -1, 1.5)) if roes else None
|
| 317 |
-
|
| 318 |
d = {
|
| 319 |
-
"sym":
|
| 320 |
-
"
|
| 321 |
-
"
|
| 322 |
-
"
|
| 323 |
-
"
|
| 324 |
-
"
|
| 325 |
-
"
|
| 326 |
-
|
| 327 |
-
"ps": div(mcap, s["rev"][0]) if (s["rev"][0] or 0) > 0 else None,
|
| 328 |
-
"ev_ebitda": div(ev, ebitda0) if (ebitda0 or 0) > 0 and ev else None,
|
| 329 |
-
"ev_fcf": div(ev, fcf0) if (fcf0 or 0) > 0 and ev else None,
|
| 330 |
-
"fcf_yield": div(fcf0, mcap), "earn_yield": div(eps, price), "fetched": datetime.now().isoformat(),
|
| 331 |
}
|
| 332 |
_cset(key, d)
|
| 333 |
return d
|
|
@@ -337,14 +446,14 @@ def cached_ds() -> List[Dict[str, Any]]:
|
|
| 337 |
return [v for k, (ts, v) in _cache.items() if k.startswith("t_") and v is not None]
|
| 338 |
|
| 339 |
|
| 340 |
-
MULTS = ["
|
| 341 |
-
POS_ONLY = {"
|
| 342 |
|
| 343 |
|
| 344 |
-
def build_medians(ds) -> Dict[
|
| 345 |
-
groups: Dict[
|
| 346 |
for d in ds:
|
| 347 |
-
groups.setdefault(
|
| 348 |
med = {}
|
| 349 |
for k, g in groups.items():
|
| 350 |
m = {}
|
|
@@ -356,23 +465,19 @@ def build_medians(ds) -> Dict[Tuple[str, str], Dict[str, Tuple[Optional[float],
|
|
| 356 |
|
| 357 |
|
| 358 |
# ============================================================================
|
| 359 |
-
# VALUATION ENGINE
|
| 360 |
# ============================================================================
|
| 361 |
|
| 362 |
-
def
|
| 363 |
-
"""
|
| 364 |
-
|
|
|
|
| 365 |
return None
|
| 366 |
-
|
| 367 |
for t in range(1, years + 1):
|
| 368 |
g = g0 + (tg - g0) * (t - 1) / (years - 1)
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
pv = sum(fl / (1 + wacc) ** (i + 1) for i, fl in enumerate(flows))
|
| 372 |
-
pvtv = flows[-1] * (1 + tg) / (wacc - tg) / (1 + wacc) ** years
|
| 373 |
-
ev = pv + pvtv
|
| 374 |
-
eq = ev - net_debt
|
| 375 |
-
return {"ev": ev, "equity": eq, "per_share": eq / shares, "tv_share": pvtv / ev if ev else 0.0}
|
| 376 |
|
| 377 |
|
| 378 |
def assumptions(P, rg=None, rw=None):
|
|
@@ -382,75 +487,66 @@ def assumptions(P, rg=None, rw=None):
|
|
| 382 |
return (1 + rg) * (1 + i * P["pt"]) - 1, (1 + rw) * (1 + i) - 1, (1 + P["rtg"]) * (1 + i) - 1 # g0, wacc, tg (nominal)
|
| 383 |
|
| 384 |
|
| 385 |
-
def
|
| 386 |
-
if not
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
return None, "net debt unavailable"
|
| 396 |
if wacc <= tg:
|
| 397 |
-
return None, "discount rate <= terminal
|
| 398 |
-
r =
|
| 399 |
-
if r is None or r["
|
| 400 |
-
return None, "
|
| 401 |
-
return r["
|
| 402 |
|
| 403 |
|
| 404 |
def relative_value(d, med) -> Tuple[Optional[float], str]:
|
| 405 |
-
m = med.get(
|
| 406 |
-
if not m
|
| 407 |
-
return None, "no peer group
|
| 408 |
-
|
| 409 |
-
if m["
|
| 410 |
-
vals.append(m["
|
| 411 |
-
if
|
| 412 |
-
vals.append(m["
|
| 413 |
-
if
|
| 414 |
-
vals.append(
|
| 415 |
vals = [v for v in vals if v > 0]
|
| 416 |
if not vals:
|
| 417 |
return None, f"<{MIN_PEERS} valid peers"
|
| 418 |
-
return float(np.mean(vals)), f"{len(vals)}
|
| 419 |
|
| 420 |
|
| 421 |
def intrinsic(d, P, med, rg=None, rw=None):
|
| 422 |
g0, wacc, tg = assumptions(P, rg, rw)
|
| 423 |
m: Dict[str, Tuple[Optional[float], str]] = {}
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
m["DCF"] = (None, "not applied to banks/insurers")
|
| 432 |
-
elif seg == "REIT":
|
| 433 |
-
m["NAV proxy (book)"] = ((bv, "book value; appraised NAV not in Yahoo") if (d["unit_ok"] and bv and bv > 0) else (None, "book value unavailable"))
|
| 434 |
-
else:
|
| 435 |
-
m["DCF"] = dcf_try(d["fcff"], g0, wacc, tg, d["shares"], d["net_debt"], d["unit_ok"])
|
| 436 |
-
m["Owner Earnings"] = dcf_try(d["oe"], g0, wacc, tg, d["shares"], 0.0, d["unit_ok"]) # equity CF: no debt bridge
|
| 437 |
m["Relative"] = relative_value(d, med)
|
| 438 |
vals = [v for v, _ in m.values() if v is not None and v > 0]
|
| 439 |
return m, (float(np.median(vals)) if vals else None)
|
| 440 |
|
| 441 |
|
| 442 |
def reverse_dcf(d, P):
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
if not d["unit_ok"] or base is None or base <= 0 or not shares or nd is None:
|
| 447 |
-
return None, "essential inputs missing or FCFF <= 0"
|
| 448 |
_, wacc0, tg = assumptions(P)
|
| 449 |
|
| 450 |
def solve(wacc):
|
| 451 |
if wacc <= tg:
|
| 452 |
return None
|
| 453 |
-
f = lambda g:
|
| 454 |
lo, hi = -0.9, 3.0
|
| 455 |
if not (f(lo) <= d["price"] <= f(hi)):
|
| 456 |
return None
|
|
@@ -464,7 +560,7 @@ def reverse_dcf(d, P):
|
|
| 464 |
rw = P["rw"] + dw
|
| 465 |
w = (1 + rw) * (1 + P["infl"]) - 1
|
| 466 |
g = solve(w)
|
| 467 |
-
rows.append({"Real discount rate": fp(rw), "Nominal
|
| 468 |
"Implied (real)": fp(real_ret(g, P["infl"]))})
|
| 469 |
return rows, "ok"
|
| 470 |
|
|
@@ -482,10 +578,8 @@ def scenarios(d, P, med, probs):
|
|
| 482 |
return out, None
|
| 483 |
pw = sum(o["Prob"] * o["Target"] for o in out)
|
| 484 |
nom = pw / d["price"] - 1
|
| 485 |
-
|
| 486 |
-
tot = nom
|
| 487 |
-
return out, dict(pw=pw, nominal=nom, total=tot, real=real_ret(tot, P["infl"]), real_price=real_ret(nom, P["infl"]),
|
| 488 |
-
dy_known=d["dy"] is not None)
|
| 489 |
|
| 490 |
|
| 491 |
def validate_probs(bear, base):
|
|
@@ -496,100 +590,57 @@ def validate_probs(bear, base):
|
|
| 496 |
|
| 497 |
|
| 498 |
# ============================================================================
|
| 499 |
-
# QUALITY,
|
| 500 |
# ============================================================================
|
| 501 |
|
| 502 |
-
def cv(series):
|
| 503 |
-
v = [x for x in series if x is not None]
|
| 504 |
-
if len(v) < 3 or np.mean(v) <= 0:
|
| 505 |
-
return None
|
| 506 |
-
return float(np.std(v) / np.mean(v))
|
| 507 |
-
|
| 508 |
-
|
| 509 |
def quality(d):
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
q["
|
| 513 |
-
q["
|
| 514 |
-
ic = None
|
| 515 |
-
if None not in (eq0, s["debt"][0], s["cash"][0]):
|
| 516 |
-
ic = eq0 + s["debt"][0] - s["cash"][0]
|
| 517 |
-
q["roic"] = div(s["opinc"][0] * (1 - d["tax_rate"]), ic) if (s["opinc"][0] is not None and ic and ic > 0) else None
|
| 518 |
-
q["gm"] = div(s["gp"][0], rev0)
|
| 519 |
-
q["opm"] = div(s["opinc"][0], rev0)
|
| 520 |
-
q["npm"] = div(ni0, rev0)
|
| 521 |
-
q["de"] = div(s["debt"][0], eq0) * 100 if (eq0 or 0) > 0 and s["debt"][0] is not None else None
|
| 522 |
-
e0 = s["ebitda"][0]
|
| 523 |
-
q["nd_ebitda"] = div(d["net_debt"], e0) if (e0 or 0) > 0 and d["net_debt"] is not None else None
|
| 524 |
-
q["cr"] = div(s["ca"][0], s["cl"][0])
|
| 525 |
-
q["icov"] = div(s["opinc"][0], s["interest"][0]) if (s["interest"][0] or 0) > 0 else None
|
| 526 |
-
q["fcf_conv"] = div(s["fcf"][0], ni0) if (ni0 or 0) > 0 else None
|
| 527 |
-
q["fcf_cv"] = cv(s["fcf"])
|
| 528 |
-
q["ni_cv"] = cv(s["ni"])
|
| 529 |
-
q["rev_g"] = growth(rev0, s["rev"][1])
|
| 530 |
-
q["ni_g"] = growth(ni0, s["ni"][1])
|
| 531 |
-
q["eq_ta"] = div(eq0, ta0)
|
| 532 |
return q
|
| 533 |
|
| 534 |
|
| 535 |
-
def
|
| 536 |
-
|
| 537 |
-
|
| 538 |
def cmp(a, b, op):
|
| 539 |
return None if a is None or b is None else op(a, b)
|
| 540 |
|
| 541 |
-
|
| 542 |
-
cr = [div(s["ca"][i], s["cl"][i]) for i in (0, 1)]
|
| 543 |
-
lev = [div(s["ltd"][i] if s["ltd"][i] is not None else s["debt"][i], s["ta"][i]) for i in (0, 1)]
|
| 544 |
-
gm = [div(s["gp"][i], s["rev"][i]) for i in (0, 1)]
|
| 545 |
-
at = [div(s["rev"][i], s["ta"][i]) for i in (0, 1)]
|
| 546 |
comps = [
|
| 547 |
-
("1
|
| 548 |
-
("2
|
| 549 |
-
("3
|
| 550 |
-
("4
|
| 551 |
-
("5
|
| 552 |
-
("6
|
| 553 |
-
("7
|
| 554 |
-
("8
|
| 555 |
-
("9
|
| 556 |
]
|
| 557 |
n = sum(1 for _, v in comps if v is not None)
|
| 558 |
return sum(1 for _, v in comps if v), n, comps
|
| 559 |
|
| 560 |
|
| 561 |
def risk_engine(d, q, cov):
|
| 562 |
-
s, bank = d["s"], d["seg"] in ("Bank", "Insurance")
|
| 563 |
-
ni0, ni1 = s["ni"][0], s["ni"][1]
|
| 564 |
fl = []
|
| 565 |
|
| 566 |
def add(name, w, cond, detail):
|
| 567 |
fl.append((name, w, None if cond is None else ("TRIGGERED" if cond else "OK"), detail))
|
| 568 |
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
add("
|
| 576 |
-
add("
|
| 577 |
-
add("
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
add("
|
| 582 |
-
md = None
|
| 583 |
-
if not bank and q["opm"] is not None and s["opinc"][1] is not None and (s["rev"][1] or 0) > 0:
|
| 584 |
-
md = q["opm"] < s["opinc"][1] / s["rev"][1] - 0.02
|
| 585 |
-
add("Margin deterioration", 8, md, "operating margin down >2 pts")
|
| 586 |
-
dv = None if bank or ni0 is None or s["cfo"][0] is None or ni0 <= 0 else s["cfo"][0] < 0.5 * ni0
|
| 587 |
-
add("CFO vs NI divergence", 10, dv, "CFO < 50% of positive NI")
|
| 588 |
-
dil = cmp_g = growth(s["shares"][0], s["shares"][1])
|
| 589 |
-
add("Dilution", 8, None if dil is None else dil > 0.03, "share count +3% y/y")
|
| 590 |
-
ev = anyc(None if d["pe"] is None and d["eps"] is None else (d["eps"] is not None and d["eps"] <= 0) or (d["pe"] is not None and d["pe"] > 40),
|
| 591 |
-
None if d["pb"] is None else d["pb"] > 6)
|
| 592 |
-
add("Extreme valuation", 10, ev, "P/E>40, loss-making, or P/B>6")
|
| 593 |
add("Data reliability", 10, cov < 0.7 or bool(d["flags"]), f"coverage<70% or integrity flags ({len(d['flags'])})")
|
| 594 |
ev_w = sum(w for _, w, st, _ in fl if st is not None)
|
| 595 |
score = None if ev_w == 0 else 100 * sum(w for _, w, st, _ in fl if st == "TRIGGERED") / ev_w
|
|
@@ -600,70 +651,49 @@ def risk_engine(d, q, cov):
|
|
| 600 |
# SCORING & ANALYSIS
|
| 601 |
# ============================================================================
|
| 602 |
|
| 603 |
-
WEIGHTS = {"
|
| 604 |
-
|
| 605 |
|
| 606 |
|
| 607 |
def coverage(d):
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
if seg not in ("Bank", "Insurance"):
|
| 611 |
-
f += [s["rev"][0], s["cfo"][0], s["debt"][0], s["cash"][0], d["fcff"], s["ebitda"][0], d["oe"]]
|
| 612 |
return sum(x is not None for x in f) / len(f)
|
| 613 |
|
| 614 |
|
| 615 |
def analyze(d, P, med, probs=(0.25, 0.5, 0.25)):
|
| 616 |
A: Dict[str, Any] = {"d": d}
|
| 617 |
-
infl
|
| 618 |
q = quality(d)
|
| 619 |
A["q"] = q
|
| 620 |
A["methods"], A["iv"] = intrinsic(d, P, med)
|
| 621 |
A["mos"] = (A["iv"] - d["price"]) / A["iv"] if A["iv"] else None
|
| 622 |
rv, A["rel_note"] = relative_value(d, med)
|
| 623 |
A["rel_mos"] = (rv - d["price"]) / rv if rv else None
|
| 624 |
-
A["pio"] =
|
| 625 |
A["cov"] = coverage(d)
|
| 626 |
A["risk"], A["flags"] = risk_engine(d, q, A["cov"])
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
vi += [(pe_s, 2), (pb_s, 3), (lin(d["dy"], 0, 0.08), 1)]
|
| 635 |
-
elif seg == "REIT":
|
| 636 |
-
vi += [(lin(d["pb"], 1.5, 0.4), 3), (pe_s, 1)]
|
| 637 |
-
else:
|
| 638 |
-
vi += [(pe_s, 1), (pb_s, 1), (lin(d["ev_ebitda"], 15, 3), 1), (lin(d["fcf_yield"], -0.02, 0.12), 1),
|
| 639 |
-
(lin(d["earn_yield"], 0, 0.25), 1)]
|
| 640 |
-
if bank:
|
| 641 |
-
qi = [(lin(rroe, -0.1, 0.2), 4), (lin(q["roa"], 0, 0.03), 2), (lin(q["ni_cv"], 2, 0.3), 2),
|
| 642 |
-
(lin(d["dy"], 0, 0.08), 1), (lin(rni, -0.2, 0.3), 1)]
|
| 643 |
-
else:
|
| 644 |
-
qi = [(lin(rroe, -0.1, 0.2), 3), (lin(rroic, -0.05, 0.15), 3), (lin(q["opm"], 0.02, 0.25), 2),
|
| 645 |
-
(lin(q["fcf_conv"], 0, 1.2), 2), (lin(q["fcf_cv"], 2, 0.3), 1), (lin(rrev, -0.1, 0.2), 1), (lin(rni, -0.2, 0.3), 1)]
|
| 646 |
sc, n, _ = A["pio"]
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
si = [(lin(q["eq_ta"], 0.04, 0.12), 4), (lin(q["roa"], 0, 0.02), 1)]
|
| 650 |
-
else:
|
| 651 |
-
nde = 0.0 if (e0 is not None and e0 <= 0 and (d["net_debt"] or 0) > 0) else lin(q["nd_ebitda"], 5, 0)
|
| 652 |
-
if q["nd_ebitda"] is None and d["net_debt"] is not None and d["net_debt"] <= 0 and (e0 or 0) > 0:
|
| 653 |
-
nde = 100.0 # net cash
|
| 654 |
-
si = [(nde, 3), (lin(q["cr"], 0.8, 2.0), 2), (lin(q["icov"], 1, 8), 2), (lin(q["de"], 250, 30), 2),
|
| 655 |
-
(lin(sc / n, 0.3, 0.8) if n >= 5 else None, 2)]
|
| 656 |
A["val"], A["val_cov"] = wavg(vi)
|
| 657 |
A["qual"], A["qual_cov"] = wavg(qi)
|
| 658 |
A["str"], A["str_cov"] = wavg(si)
|
| 659 |
-
w = WEIGHTS[seg]
|
| 660 |
comps = [A["val"], A["qual"], A["str"], None if A["risk"] is None else 100 - A["risk"]]
|
| 661 |
if A["val"] is None or A["qual"] is None:
|
| 662 |
A["composite"] = None
|
| 663 |
else:
|
| 664 |
av = [(c, wt) for c, wt in zip(comps, w) if c is not None]
|
| 665 |
A["composite"] = sum(c * wt for c, wt in av) / sum(wt for _, wt in av) * min(1.0, A["cov"] / 0.7)
|
| 666 |
-
sev = (
|
| 667 |
lvl = 2 if A["cov"] >= 0.85 else 1 if A["cov"] >= 0.65 else 0
|
| 668 |
lvl = max(lvl - (1 if sev else 0), 0)
|
| 669 |
A["conf"] = ["Low", "Medium", "High"][lvl]
|
|
@@ -680,20 +710,20 @@ def get_A(ticker, P, probs=(0.25, 0.5, 0.25)):
|
|
| 680 |
sym = norm_sym(ticker)
|
| 681 |
d = fetch(sym)
|
| 682 |
if d is None:
|
| 683 |
-
return None, f"⚠️ {sym}
|
| 684 |
return analyze(d, P, build_medians(cached_ds()), probs), None
|
| 685 |
|
| 686 |
|
| 687 |
-
def price_fig(
|
| 688 |
try:
|
| 689 |
-
h =
|
| 690 |
-
if h.empty:
|
| 691 |
return None
|
| 692 |
-
f = go.Figure(go.Scatter(x=h
|
| 693 |
for nm, v in (extra or {}).items():
|
| 694 |
if v:
|
| 695 |
f.add_hline(y=v, line_dash="dot", annotation_text=nm)
|
| 696 |
-
f.update_layout(title=f"{sym} — 1Y", template="plotly_white", height=380, margin=dict(l=10, r=10, t=40, b=10))
|
| 697 |
return f
|
| 698 |
except Exception:
|
| 699 |
return None
|
|
@@ -704,25 +734,29 @@ def scan_row(A):
|
|
| 704 |
sc, n, _ = A["pio"]
|
| 705 |
e = A["exp"]
|
| 706 |
return {
|
| 707 |
-
"
|
| 708 |
"Composite": None if A["composite"] is None else round(A["composite"], 1),
|
| 709 |
-
"
|
| 710 |
"MoS %": None if A["mos"] is None else round(A["mos"] * 100, 1),
|
| 711 |
-
"
|
| 712 |
-
"
|
| 713 |
-
"
|
| 714 |
-
"
|
| 715 |
-
"
|
| 716 |
"Risk": None if A["risk"] is None else round(A["risk"], 0),
|
| 717 |
-
"Exp Return % (
|
| 718 |
"Real Exp Return %": None if not e or e["real"] is None else round(e["real"] * 100, 1),
|
|
|
|
|
|
|
| 719 |
"Coverage %": round(A["cov"] * 100), "Confidence": A["conf"],
|
| 720 |
}
|
| 721 |
|
| 722 |
|
| 723 |
-
def run_scan(force, extra, P, progress):
|
| 724 |
-
|
| 725 |
-
syms =
|
|
|
|
|
|
|
| 726 |
ds, failed = [], []
|
| 727 |
for i, s in enumerate(syms):
|
| 728 |
progress((i + 1) / len(syms), desc=f"Fetching {s}")
|
|
@@ -735,7 +769,7 @@ def run_scan(force, extra, P, progress):
|
|
| 735 |
rows = [scan_row(analyze(d, P, med)) for d in ds]
|
| 736 |
df = pd.DataFrame(rows)
|
| 737 |
LAST["df"], LAST["failed"] = df, failed
|
| 738 |
-
return df, failed
|
| 739 |
|
| 740 |
|
| 741 |
def apply_filters(seg, minc, minm, sortby, desc):
|
|
@@ -749,26 +783,29 @@ def apply_filters(seg, minc, minm, sortby, desc):
|
|
| 749 |
if minm > -100:
|
| 750 |
x = x[x["MoS %"].fillna(-1e9) >= minm]
|
| 751 |
x = x.sort_values(sortby, ascending=not desc, na_position="last")
|
| 752 |
-
f = tempfile.NamedTemporaryFile(delete=False, suffix=".csv", prefix="
|
| 753 |
x.to_csv(f.name, index=False)
|
| 754 |
return x, f.name
|
| 755 |
|
| 756 |
|
| 757 |
def md_report(A, P):
|
| 758 |
d = A["d"]
|
| 759 |
-
L = [f"## {d['sym']} —
|
| 760 |
-
f"**{d['seg']}** · {d['sector']}
|
| 761 |
-
f"{d['
|
| 762 |
f"**Composite {fm(A['composite'], 0)}** | Valuation {fm(A['val'], 0)} | Quality {fm(A['qual'], 0)} | "
|
| 763 |
f"Strength {fm(A['str'], 0)} | Risk {fm(A['risk'], 0)} (düşük=iyi) | Coverage {fp(A['cov'], 0)} | "
|
| 764 |
f"Confidence **{A['conf']}**",
|
| 765 |
-
f"**
|
| 766 |
-
|
|
|
|
|
|
|
| 767 |
for k, (v, n) in A["methods"].items():
|
| 768 |
-
L.append(f"| {k} | {
|
| 769 |
if d["flags"]:
|
| 770 |
L.append("\n**⚠️ Veri bayrakları:** " + "; ".join(d["flags"]))
|
| 771 |
-
L.append("\n*
|
|
|
|
| 772 |
return "\n".join(L)
|
| 773 |
|
| 774 |
|
|
@@ -791,35 +828,45 @@ def self_test() -> str:
|
|
| 791 |
def chk(name, cond):
|
| 792 |
res.append(("✅ " if cond else "❌ ") + name)
|
| 793 |
|
| 794 |
-
r =
|
| 795 |
-
chk("
|
| 796 |
-
chk("
|
| 797 |
-
chk("
|
| 798 |
-
chk("
|
| 799 |
-
chk("DCF refuses missing input", dcf_value(100, 0.1, 0.2, 0.05, None, 0) is None)
|
| 800 |
chk("Real return 50% nom / 25% infl = 20%", abs(real_ret(0.5, 0.25) - 0.2) < 1e-12)
|
| 801 |
chk("div by zero → None", div(1, 0) is None and div(None, 2) is None)
|
| 802 |
P = dict(infl=0.30, rw=0.10, rtg=0.01, rg=0.03, pt=0.5, conv=0.5)
|
| 803 |
g0, w, tg = assumptions(P)
|
| 804 |
-
chk("
|
| 805 |
chk("Probabilities 25/50 → 25/50/25 sum=1", validate_probs(25, 50) is not None and abs(sum(validate_probs(25, 50)) - 1) < 1e-9)
|
| 806 |
chk("Probabilities 70/50 rejected", validate_probs(70, 50) is None)
|
| 807 |
-
# reverse
|
| 808 |
-
d = {"
|
| 809 |
P2 = dict(infl=0.0, rw=0.10, rtg=0.0, rg=0.0, pt=0.0, conv=0.5)
|
| 810 |
rows, _ = reverse_dcf(d, P2)
|
| 811 |
-
chk("Reverse
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
sc, n, _ =
|
| 815 |
-
chk("
|
| 816 |
# peer rule
|
| 817 |
-
ds = [{"seg": "
|
| 818 |
-
chk("
|
| 819 |
-
ds += [{"seg": "
|
| 820 |
-
chk("
|
| 821 |
-
chk("
|
| 822 |
-
and
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 823 |
return "\n".join(res)
|
| 824 |
|
| 825 |
|
|
@@ -827,19 +874,24 @@ def self_test() -> str:
|
|
| 827 |
# GRADIO UI
|
| 828 |
# ============================================================================
|
| 829 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 830 |
def build_ui():
|
| 831 |
-
with gr.Blocks(title="
|
| 832 |
-
gr.Markdown("#
|
|
|
|
| 833 |
"Eksik veri `N/A` gösterilir, asla sıfırla doldurulmaz. 12 aylık senaryolar heuristiktir, istatistiksel tahmin değildir. "
|
| 834 |
"Yatırım tavsiyesi değildir.**")
|
| 835 |
with gr.Accordion("⚙️ Varsayımlar [A] (tüm sekmeler için)", open=False):
|
| 836 |
with gr.Row():
|
| 837 |
s_infl = gr.Slider(0, 20, 3, step=0.5, label="Enflasyon (%)")
|
| 838 |
-
s_rw = gr.Slider(1,
|
| 839 |
-
s_rtg = gr.Slider(0,
|
| 840 |
with gr.Row():
|
| 841 |
-
s_rg = gr.Slider(-
|
| 842 |
-
s_pt = gr.Slider(0, 100, 50, step=5, label="Enflasyonun nominal
|
| 843 |
s_cv = gr.Slider(0, 100, 50, step=5, label="12 ay içinde değer-fiyat farkının kapanma oranı (%)")
|
| 844 |
PI = [s_infl, s_rw, s_rtg, s_rg, s_pt, s_cv]
|
| 845 |
|
|
@@ -848,15 +900,16 @@ def build_ui():
|
|
| 848 |
with gr.Row():
|
| 849 |
scan_btn = gr.Button("Run Scanner", variant="primary", scale=2)
|
| 850 |
force = gr.Checkbox(label="Force refresh", value=False)
|
| 851 |
-
|
|
|
|
|
|
|
|
|
|
| 852 |
with gr.Row():
|
| 853 |
-
f_seg = gr.Dropdown(["All"
|
| 854 |
f_c = gr.Slider(0, 100, 0, step=5, label="Min composite")
|
| 855 |
f_m = gr.Slider(-100, 100, -100, step=5, label="Min MoS % (-100 = kapalı)")
|
| 856 |
with gr.Row():
|
| 857 |
-
f_sort = gr.Dropdown(
|
| 858 |
-
"Exp Return % (nom, incl div)", "Real Exp Return %", "Coverage %"],
|
| 859 |
-
value="Composite", label="Sırala")
|
| 860 |
f_desc = gr.Checkbox(label="Azalan", value=True)
|
| 861 |
f_btn = gr.Button("Filtreyi uygula")
|
| 862 |
status = gr.Markdown()
|
|
@@ -864,14 +917,15 @@ def build_ui():
|
|
| 864 |
csv = gr.File(label="CSV indir")
|
| 865 |
FI = [f_seg, f_c, f_m, f_sort, f_desc]
|
| 866 |
|
| 867 |
-
def do_scan(frc, ext, *a, progress=gr.Progress()):
|
| 868 |
P = mk_params(*a[:6])
|
| 869 |
seg, c, m, so, de = a[6:]
|
| 870 |
-
|
|
|
|
| 871 |
if df.empty:
|
| 872 |
-
return None, None, "⚠️ Veri çekilemedi."
|
| 873 |
x, path = apply_filters(seg, c, m, so, de)
|
| 874 |
-
msg = f"✅ {len(df)}
|
| 875 |
if failed:
|
| 876 |
msg += f" · ⚠️ Çekilemeyen ({len(failed)}): " + ", ".join(failed)
|
| 877 |
return x, path, msg
|
|
@@ -880,12 +934,12 @@ def build_ui():
|
|
| 880 |
x, path = apply_filters(*a)
|
| 881 |
return x, path
|
| 882 |
|
| 883 |
-
scan_btn.click(do_scan, [force, extra] + PI + FI, [table, csv, status])
|
| 884 |
f_btn.click(do_filter, FI, [table, csv])
|
| 885 |
|
| 886 |
-
with gr.Tab("🔍
|
| 887 |
with gr.Row():
|
| 888 |
-
t1 = gr.Textbox(value="
|
| 889 |
b1 = gr.Button("Analyze", variant="primary")
|
| 890 |
md1 = gr.Markdown()
|
| 891 |
with gr.Row():
|
|
@@ -897,13 +951,14 @@ def build_ui():
|
|
| 897 |
A, err = get_A(t, P)
|
| 898 |
if err:
|
| 899 |
return err, None, None
|
| 900 |
-
|
|
|
|
| 901 |
|
| 902 |
b1.click(company, [t1] + PI, [md1, p1, r1])
|
| 903 |
|
| 904 |
-
with gr.Tab("🧮
|
| 905 |
with gr.Row():
|
| 906 |
-
t2 = gr.Textbox(value="
|
| 907 |
b2 = gr.Button("Calculate", variant="primary")
|
| 908 |
md2 = gr.Markdown()
|
| 909 |
p2 = gr.Plot()
|
|
@@ -916,29 +971,26 @@ def build_ui():
|
|
| 916 |
d = A["d"]
|
| 917 |
g0, w, tg = assumptions(P)
|
| 918 |
L = [f"### {d['sym']} — {d['seg']}",
|
| 919 |
-
f"**Varsayımlar [A]:** yıl-1 nominal
|
| 920 |
-
f"nominal iskonto {fp(w)} · nominal terminal {fp(tg)}",
|
| 921 |
-
f"**
|
| 922 |
-
f"
|
| 923 |
-
|
| 924 |
-
o = d["oe_parts"]
|
| 925 |
-
L.append(f"**Owner Earnings [D]:** NI {fm(o['ni'], 0)} + D&A {fm(o['da'], 0)} − bakım capex (tahmin=min(capex, D&A)) "
|
| 926 |
-
f"{fm(o['maint_est'], 0)} + ΔİS sermayesi {fm(o['dwc'], 0)} = **{fm(d['oe'], 0)}** (toplam capex {fm(o['capex_total'], 0)})")
|
| 927 |
L.append("\n| Yöntem | Değer | Not |\n|---|---|---|")
|
| 928 |
for k, (v, n) in A["methods"].items():
|
| 929 |
-
L.append(f"| {k} | {
|
| 930 |
-
L.append(f"\n**
|
| 931 |
names = [k for k, (v, _) in A["methods"].items() if v]
|
| 932 |
vals = [A["methods"][k][0] for k in names]
|
| 933 |
-
f = go.Figure(go.Bar(x=["Price"] + names + ["
|
| 934 |
f.update_layout(template="plotly_white", height=340, margin=dict(l=10, r=10, t=20, b=10))
|
| 935 |
return "\n".join(L), f
|
| 936 |
|
| 937 |
b2.click(calc, [t2] + PI, [md2, p2])
|
| 938 |
|
| 939 |
-
with gr.Tab("↩️ Reverse
|
| 940 |
with gr.Row():
|
| 941 |
-
t3 = gr.Textbox(value="
|
| 942 |
b3 = gr.Button("Solve", variant="primary")
|
| 943 |
md3 = gr.Markdown()
|
| 944 |
tb3 = gr.Dataframe(interactive=False)
|
|
@@ -953,18 +1005,17 @@ def build_ui():
|
|
| 953 |
if rows is None:
|
| 954 |
return f"**{d['sym']}**: ⚠️ {note}", None
|
| 955 |
g0, _, _ = assumptions(P)
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
"Not: Zorunlu bir 'doğru' yoktur; implied büyüme makul görünmüyorsa piyasa beklentisi ya aşırı iyimser ya da kötümserdir.")
|
| 961 |
return md, pd.DataFrame(rows)
|
| 962 |
|
| 963 |
b3.click(rev, [t3] + PI, [md3, tb3])
|
| 964 |
|
| 965 |
-
with gr.Tab("💎
|
| 966 |
with gr.Row():
|
| 967 |
-
t4 = gr.Textbox(value="
|
| 968 |
b4 = gr.Button("Show", variant="primary")
|
| 969 |
md4 = gr.Markdown()
|
| 970 |
tb4 = gr.Dataframe(interactive=False)
|
|
@@ -977,25 +1028,29 @@ def build_ui():
|
|
| 977 |
return err, None, None
|
| 978 |
q, d = A["q"], A["d"]
|
| 979 |
pc = lambda x: fp(x)
|
| 980 |
-
rows = [("
|
| 981 |
-
("
|
| 982 |
-
("
|
| 983 |
-
("
|
| 984 |
-
("
|
| 985 |
-
("
|
| 986 |
-
("
|
|
|
|
|
|
|
|
|
|
|
|
|
| 987 |
sc, n, comps = A["pio"]
|
| 988 |
cp = [(c, "N/A" if v is None else ("1" if v else "0")) for c, v in comps]
|
| 989 |
-
md = (f"**{d['sym']}** —
|
| 990 |
("" if n == 9 else " ⚠️ eksik bileşen var; skor tam değildir.") +
|
| 991 |
-
("
|
| 992 |
-
return md, pd.DataFrame(rows, columns=["Metric", "Value"]), pd.DataFrame(cp, columns=["
|
| 993 |
|
| 994 |
b4.click(qual, [t4] + PI, [md4, tb4, tb4b])
|
| 995 |
|
| 996 |
with gr.Tab("🛡️ Risk Analysis"):
|
| 997 |
with gr.Row():
|
| 998 |
-
t5 = gr.Textbox(value="
|
| 999 |
b5 = gr.Button("Assess", variant="primary")
|
| 1000 |
md5 = gr.Markdown()
|
| 1001 |
tb5 = gr.Dataframe(interactive=False)
|
|
@@ -1011,9 +1066,9 @@ def build_ui():
|
|
| 1011 |
|
| 1012 |
b5.click(risk_tab, [t5] + PI, [md5, tb5])
|
| 1013 |
|
| 1014 |
-
with gr.Tab("🏭
|
| 1015 |
with gr.Row():
|
| 1016 |
-
t6 = gr.Textbox(value="
|
| 1017 |
b6 = gr.Button("Compare", variant="primary")
|
| 1018 |
md6 = gr.Markdown()
|
| 1019 |
tb6 = gr.Dataframe(interactive=False)
|
|
@@ -1023,23 +1078,24 @@ def build_ui():
|
|
| 1023 |
P = mk_params(*a)
|
| 1024 |
ds = cached_ds()
|
| 1025 |
if not ds:
|
| 1026 |
-
return "Önce Market Scanner'ı çalıştırın (medyanlar önbellekteki
|
| 1027 |
med = build_medians(ds)
|
| 1028 |
rows = []
|
| 1029 |
-
for
|
| 1030 |
-
r = {"Segment": seg, "
|
| 1031 |
-
for f in
|
| 1032 |
v, n = m[f]
|
| 1033 |
r[f] = None if v is None else round(v, 3)
|
| 1034 |
rows.append(r)
|
| 1035 |
-
fig, md = None, f"
|
|
|
|
| 1036 |
d = fetch(norm_sym(t)) if t.strip() else None
|
| 1037 |
if d:
|
| 1038 |
-
m = med.get(
|
| 1039 |
-
keys = ["
|
| 1040 |
cv_ = [d[k] for k in keys]
|
| 1041 |
mv = [m[k][0] if m else None for k in keys]
|
| 1042 |
-
fig = go.Figure([go.Bar(name=d["sym"], x=keys, y=cv_), go.Bar(name="
|
| 1043 |
fig.update_layout(barmode="group", template="plotly_white", height=340, margin=dict(l=10, r=10, t=20, b=10))
|
| 1044 |
md = f"**{d['sym']}** — {d['seg']} / {d['sector']}. " + md
|
| 1045 |
return md, pd.DataFrame(rows), fig
|
|
@@ -1048,7 +1104,7 @@ def build_ui():
|
|
| 1048 |
|
| 1049 |
with gr.Tab("🎯 12-Month Scenarios"):
|
| 1050 |
with gr.Row():
|
| 1051 |
-
t7 = gr.Textbox(value="
|
| 1052 |
pb = gr.Slider(0, 100, 25, step=5, label="Bear olasılığı (%)")
|
| 1053 |
pm = gr.Slider(0, 100, 50, step=5, label="Base olasılığı (%) — Bull = kalan")
|
| 1054 |
b7 = gr.Button("Run", variant="primary")
|
|
@@ -1066,16 +1122,14 @@ def build_ui():
|
|
| 1066 |
return err, None, None
|
| 1067 |
d, e = A["d"], A["exp"]
|
| 1068 |
rows = [{"Scenario": o["Scenario"], "Real growth": o["Real growth"], "Real disc. rate": o["Real disc. rate"],
|
| 1069 |
-
"Multiple factor": o["Multiple factor"], "
|
| 1070 |
-
"12M target":
|
| 1071 |
-
"Probability": f"{o['Prob'] * 100:.0f}%"} for o in A["scen"]]
|
| 1072 |
if e is None:
|
| 1073 |
-
return f"**{d['sym']}**: ⚠️ Senaryo üretilemedi (
|
| 1074 |
-
md = (f"**{d['sym']}** · Olasılık-ağırlıklı hedef **{
|
| 1075 |
-
f"
|
| 1076 |
-
f" · Reel (temettü-dahil) {fp(e['real'])} · Reel fiyat getirisi {fp(e['real_price'])}\n\n"
|
| 1077 |
f"*Heuristik: hedef = fiyat + k×(senaryo değeri − fiyat), k={P['conv'] * 100:.0f}%, çarpan faktörü uygulanır. "
|
| 1078 |
-
"İstatistiksel olarak doğrulanmış tahmin değildir.
|
| 1079 |
f = go.Figure(go.Bar(x=["Price", "Bear", "Base", "Bull", "Prob-weighted"],
|
| 1080 |
y=[d["price"]] + [o["Target"] for o in A["scen"]] + [e["pw"]]))
|
| 1081 |
f.update_layout(template="plotly_white", height=340, margin=dict(l=10, r=10, t=20, b=10))
|
|
@@ -1089,19 +1143,21 @@ def build_ui():
|
|
| 1089 |
tb8 = gr.Dataframe(interactive=False, wrap=True)
|
| 1090 |
|
| 1091 |
def dq(progress=gr.Progress()):
|
|
|
|
| 1092 |
rows, failed = [], []
|
| 1093 |
-
for i, s in enumerate(
|
| 1094 |
-
progress((i + 1) / len(
|
| 1095 |
was = _cget(f"t_{s}") is not None
|
| 1096 |
d = fetch(s)
|
| 1097 |
if d is None:
|
| 1098 |
failed.append(s)
|
| 1099 |
continue
|
| 1100 |
c = coverage(d)
|
| 1101 |
-
rows.append({"
|
| 1102 |
-
"
|
| 1103 |
-
"Age (
|
| 1104 |
-
"
|
|
|
|
| 1105 |
if not was:
|
| 1106 |
time.sleep(REQUEST_DELAY_SEC * 0.5)
|
| 1107 |
df = pd.DataFrame(rows)
|
|
@@ -1120,33 +1176,40 @@ def build_ui():
|
|
| 1120 |
|
| 1121 |
|
| 1122 |
METHODOLOGY = """
|
| 1123 |
-
###
|
| 1124 |
-
- **
|
| 1125 |
-
- **
|
| 1126 |
-
- **
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
- **
|
| 1130 |
-
- **
|
| 1131 |
-
- **
|
| 1132 |
-
- **
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1133 |
|
| 1134 |
### Skorlama (0-100, açıklanabilir)
|
| 1135 |
Composite = ağırlıklı ortalama (mevcut bileşenler üzerinden yeniden normalize) × min(1, coverage/0.7).
|
| 1136 |
| Segment | Valuation | Quality | Strength | Risk (ters) |
|
| 1137 |
|---|---|---|---|---|
|
| 1138 |
-
|
|
| 1139 |
-
|
|
| 1140 |
-
|
|
| 1141 |
-
| REIT | 45 | 20 | 20 | 15 |
|
| 1142 |
|
| 1143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1144 |
|
| 1145 |
### Bilinen veri sınırlamaları
|
| 1146 |
-
-
|
| 1147 |
-
-
|
| 1148 |
-
-
|
| 1149 |
-
-
|
| 1150 |
- Sonuçlar araştırma/eğitim amaçlıdır, yatırım tavsiyesi değildir.
|
| 1151 |
"""
|
| 1152 |
|
|
|
|
| 1 |
"""
|
| 2 |
+
OKX Value Intelligence v1 — Compact Fair-Value & Risk Platform (OKX Spot, USDT pairs)
|
| 3 |
+
======================================================================================
|
| 4 |
+
Data integrity > model complexity. Fair value > momentum. Margin of safety > prediction.
|
| 5 |
+
|
| 6 |
+
Veri kaynağı: OKX public REST API (anahtar gerekmez) — spot ticker, günlük mumlar, perp funding / open interest.
|
| 7 |
+
Veri etiketleri: [R] OKX'ten alınan [D] türetilen [A] kullanıcı varsayımı
|
| 8 |
+
UYARI: Kripto varlıkların bilançosu/nakit akışı yoktur. "Fair value" fiyat-hacim verisinden türetilen
|
| 9 |
+
istatistiksel referanstır (içsel değer değildir). Backtest/yatırım tavsiyesi değildir.
|
| 10 |
"""
|
| 11 |
|
| 12 |
import logging
|
| 13 |
+
import math
|
| 14 |
import tempfile
|
| 15 |
import time
|
| 16 |
+
from datetime import datetime, timedelta, timezone
|
| 17 |
from typing import Any, Dict, List, Optional, Tuple
|
| 18 |
|
| 19 |
import gradio as gr
|
| 20 |
import numpy as np
|
| 21 |
import pandas as pd
|
| 22 |
import plotly.graph_objects as go
|
| 23 |
+
import requests
|
| 24 |
|
| 25 |
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
|
| 26 |
+
logger = logging.getLogger("okx_v1")
|
| 27 |
+
|
| 28 |
+
# ============================================================================
|
| 29 |
+
# CONFIG & UNIVERSE
|
| 30 |
+
# ============================================================================
|
| 31 |
+
|
| 32 |
+
OKX_BASE = "https://www.okx.com"
|
| 33 |
+
REQUEST_DELAY_SEC = 0.12 # OKX public limit: 20 req / 2 s per endpoint
|
| 34 |
+
CACHE_TTL_HOURS = 1
|
| 35 |
+
LOOKBACK_DAYS = 500 # günlük mum derinliği (300 + 2x100 sayfa)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
MIN_PEERS = 5
|
| 37 |
+
DEFAULT_UNIVERSE_N = 100
|
| 38 |
+
MIN_VOL_USDT = 1_000_000 # 24s hacim alt sınırı (evren filtresi)
|
| 39 |
+
|
| 40 |
+
STABLES = {"USDC", "DAI", "TUSD", "FDUSD", "USDE", "PYUSD", "USDG", "USDD", "EURC", "BUSD", "USTC", "USDP", "EURT", "XAUT"}
|
| 41 |
+
|
| 42 |
+
# Kategori (segment) haritası — listede olmayan varlıklar "Other"
|
| 43 |
+
CATEGORY_MAP = {
|
| 44 |
+
"Majors": ["BTC", "ETH", "SOL", "XRP", "BNB", "ADA", "TRX", "LTC", "BCH", "AVAX", "DOT", "TON"],
|
| 45 |
+
"Alt L1": ["ATOM", "NEAR", "APT", "SUI", "SEI", "ALGO", "ICP", "FIL", "HBAR", "XLM", "ETC", "EOS", "XTZ", "VET",
|
| 46 |
+
"THETA", "TIA", "KAS", "MINA", "FLOW", "S", "FTM", "KAVA", "CFX", "EGLD"],
|
| 47 |
+
"DeFi": ["UNI", "AAVE", "MKR", "LDO", "CRV", "SNX", "COMP", "DYDX", "INJ", "JUP", "ENA", "ONDO", "PENDLE", "1INCH",
|
| 48 |
+
"SUSHI", "CAKE", "RUNE", "GMX", "ETHFI", "EIGEN"],
|
| 49 |
+
"Infra/L2": ["ARB", "OP", "POL", "MATIC", "LINK", "GRT", "STX", "IMX", "STRK", "ZK", "MNT", "PYTH", "API3", "W", "BLUR", "ZRO"],
|
| 50 |
+
"Meme": ["DOGE", "SHIB", "PEPE", "WIF", "BONK", "FLOKI", "TRUMP", "PNUT", "MEME", "TURBO", "NEIRO", "BOME", "MEW", "ORDI"],
|
| 51 |
+
"AI/Gaming": ["FET", "RENDER", "RNDR", "WLD", "TAO", "AGIX", "OCEAN", "AR", "SAND", "MANA", "AXS", "GALA", "APE", "ENS", "AIXBT", "VIRTUAL"],
|
| 52 |
+
}
|
| 53 |
+
CATEGORY = {b: cat for cat, bases in CATEGORY_MAP.items() for b in bases}
|
| 54 |
+
SEGMENTS = list(CATEGORY_MAP.keys()) + ["Other"]
|
| 55 |
+
|
| 56 |
+
FALLBACK_BASES = ["BTC", "ETH", "SOL", "XRP", "DOGE", "ADA", "AVAX", "LINK", "DOT", "LTC", "BCH", "TRX", "UNI", "ATOM",
|
| 57 |
+
"NEAR", "APT", "ARB", "OP", "SUI", "FIL", "ICP", "ETC", "XLM", "HBAR", "AAVE", "MKR", "LDO", "INJ",
|
| 58 |
+
"SHIB", "PEPE", "WIF", "BONK", "FET", "RENDER", "GRT", "IMX", "SAND", "MANA", "TIA", "SEI"]
|
| 59 |
+
|
| 60 |
_cache: Dict[str, Tuple[datetime, Any]] = {}
|
| 61 |
+
_TICK: Dict[str, Any] = {"ts": 0.0, "data": {}}
|
| 62 |
+
SETTINGS: Dict[str, Any] = {"perp": True}
|
| 63 |
+
LAST: Dict[str, Any] = {"df": None, "failed": [], "universe": []}
|
| 64 |
+
_session = requests.Session()
|
| 65 |
+
_session.headers.update({"User-Agent": "okx-value-intelligence/1.0"})
|
| 66 |
|
| 67 |
|
| 68 |
def _cget(k):
|
|
|
|
| 83 |
|
| 84 |
def num(x) -> Optional[float]:
|
| 85 |
try:
|
| 86 |
+
if x is None or x == "":
|
| 87 |
return None
|
| 88 |
v = float(x)
|
| 89 |
return None if (np.isnan(v) or np.isinf(v)) else v
|
|
|
|
| 98 |
return a / b
|
| 99 |
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
def real_ret(nominal, infl) -> Optional[float]:
|
| 102 |
return None if nominal is None else (1 + nominal) / (1 + infl) - 1
|
| 103 |
|
|
|
|
| 127 |
return "N/A" if x is None else f"{x:,.{d}f}"
|
| 128 |
|
| 129 |
|
| 130 |
+
def fpx(x):
|
| 131 |
+
"""Fiyat biçimi: BTC (100k) ile PEPE (0.00001) aynı yerde okunabilsin."""
|
| 132 |
+
if x is None:
|
| 133 |
+
return "N/A"
|
| 134 |
+
a = abs(x)
|
| 135 |
+
if a >= 1000:
|
| 136 |
+
return f"{x:,.0f}"
|
| 137 |
+
if a >= 1:
|
| 138 |
+
return f"{x:,.2f}"
|
| 139 |
+
if a >= 0.01:
|
| 140 |
+
return f"{x:.4f}"
|
| 141 |
+
return f"{x:.4g}"
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def rsig(x, n=5):
|
| 145 |
+
return None if x is None else float(f"{x:.{n}g}")
|
| 146 |
+
|
| 147 |
+
|
| 148 |
def fp(x, d=1):
|
| 149 |
return "N/A" if x is None else f"{x * 100:.{d}f}%"
|
| 150 |
|
| 151 |
|
| 152 |
+
def pct(x, d=1):
|
| 153 |
+
return None if x is None else round(x * 100, d)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
def norm_sym(t: str) -> str:
|
| 157 |
+
"""'btc', 'eth/usdt', 'SOLUSDT', 'BTC-USDT-SWAP' -> OKX spot instId (BTC-USDT)."""
|
| 158 |
+
s = (t or "").strip().upper().replace("/", "-").replace("_", "-").replace(" ", "")
|
| 159 |
+
if not s:
|
| 160 |
+
return ""
|
| 161 |
+
if s.endswith("-SWAP"):
|
| 162 |
+
s = s[:-5]
|
| 163 |
+
if "-" in s:
|
| 164 |
+
parts = s.split("-")
|
| 165 |
+
return f"{parts[0]}-{parts[1]}" if len(parts) >= 2 and parts[1] else f"{parts[0]}-USDT"
|
| 166 |
+
for q in ("USDT", "USDC"):
|
| 167 |
+
if s.endswith(q) and len(s) > len(q):
|
| 168 |
+
return f"{s[:-len(q)]}-{q}"
|
| 169 |
+
return f"{s}-USDT"
|
| 170 |
|
| 171 |
|
| 172 |
def mk_params(infl, rw, rtg, rg, pt, cv) -> Dict[str, float]:
|
| 173 |
return dict(infl=infl / 100, rw=rw / 100, rtg=rtg / 100, rg=rg / 100, pt=pt / 100, conv=cv / 100)
|
| 174 |
|
| 175 |
|
| 176 |
+
def tier_of(v: Optional[float]) -> str:
|
| 177 |
+
if v is None:
|
| 178 |
+
return "Tier-4 (<5M)"
|
| 179 |
+
if v >= 100e6:
|
| 180 |
+
return "Tier-1 (≥100M)"
|
| 181 |
+
if v >= 20e6:
|
| 182 |
+
return "Tier-2 (20-100M)"
|
| 183 |
+
if v >= 5e6:
|
| 184 |
+
return "Tier-3 (5-20M)"
|
| 185 |
+
return "Tier-4 (<5M)"
|
| 186 |
+
|
| 187 |
+
|
| 188 |
# ============================================================================
|
| 189 |
+
# OKX DATA ACQUISITION
|
| 190 |
# ============================================================================
|
| 191 |
|
| 192 |
+
def okx_get(path: str, params: Optional[Dict[str, str]] = None, retries: int = 3) -> Optional[List[Any]]:
|
| 193 |
+
"""OKX v5 public GET. Returns list (maybe empty) on success, None on failure."""
|
| 194 |
+
url = OKX_BASE + path
|
| 195 |
+
for k in range(retries):
|
| 196 |
+
try:
|
| 197 |
+
r = _session.get(url, params=params, timeout=15)
|
| 198 |
+
if r.status_code == 429:
|
| 199 |
+
time.sleep(1.0 * (k + 1))
|
| 200 |
+
continue
|
| 201 |
+
r.raise_for_status()
|
| 202 |
+
j = r.json()
|
| 203 |
+
code = str(j.get("code"))
|
| 204 |
+
if code == "0":
|
| 205 |
+
return j.get("data") or []
|
| 206 |
+
if code == "50011": # rate limit
|
| 207 |
+
time.sleep(1.0 * (k + 1))
|
| 208 |
+
continue
|
| 209 |
+
logger.debug("OKX %s %s -> code %s %s", path, params, code, j.get("msg"))
|
| 210 |
+
return None
|
| 211 |
+
except Exception as e:
|
| 212 |
+
logger.warning("OKX %s failed (%s/%s): %s", path, k + 1, retries, e)
|
| 213 |
+
time.sleep(0.5 * (k + 1))
|
| 214 |
+
return None
|
| 215 |
|
| 216 |
|
| 217 |
+
def get_tickers(force: bool = False) -> Dict[str, Dict[str, Any]]:
|
| 218 |
+
if not force and _TICK["data"] and time.time() - _TICK["ts"] < 120:
|
| 219 |
+
return _TICK["data"]
|
| 220 |
+
rows = okx_get("/api/v5/market/tickers", {"instType": "SPOT"})
|
| 221 |
+
if rows:
|
| 222 |
+
_TICK["data"] = {r["instId"]: r for r in rows if r.get("instId")}
|
| 223 |
+
_TICK["ts"] = time.time()
|
| 224 |
+
return _TICK["data"]
|
|
|
|
|
|
|
| 225 |
|
| 226 |
|
| 227 |
+
def is_excluded(base: str) -> bool:
|
| 228 |
+
return base in STABLES or base.endswith(("3L", "3S", "5L", "5S"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
|
| 231 |
+
def build_universe(n: int = DEFAULT_UNIVERSE_N) -> Tuple[List[str], str]:
|
| 232 |
+
"""Top-N OKX spot USDT pairs by 24h quote volume (stablecoin / leveraged tokens excluded)."""
|
| 233 |
+
tk = get_tickers(force=True)
|
| 234 |
+
rows = []
|
| 235 |
+
for inst, r in tk.items():
|
| 236 |
+
if not inst.endswith("-USDT"):
|
| 237 |
+
continue
|
| 238 |
+
if is_excluded(inst[:-5]):
|
| 239 |
+
continue
|
| 240 |
+
v = num(r.get("volCcy24h")) # SPOT: quote currency (USDT) cinsinden 24s hacim
|
| 241 |
+
if v is None or v < MIN_VOL_USDT:
|
| 242 |
+
continue
|
| 243 |
+
rows.append((v, inst))
|
| 244 |
+
rows.sort(reverse=True)
|
| 245 |
+
if rows:
|
| 246 |
+
return [i for _, i in rows[:n]], "OKX tickers (24s hacme göre)"
|
| 247 |
+
return [f"{b}-USDT" for b in FALLBACK_BASES][:n], "⚠️ ticker listesi alınamadı → gömülü yedek liste"
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def fetch_candles(inst: str, n: int = LOOKBACK_DAYS) -> Optional[pd.DataFrame]:
|
| 251 |
+
rows = okx_get("/api/v5/market/candles", {"instId": inst, "bar": "1D", "limit": "300"})
|
| 252 |
+
if not rows:
|
| 253 |
+
return None
|
| 254 |
+
allr = list(rows)
|
| 255 |
+
oldest = int(rows[-1][0])
|
| 256 |
+
while len(allr) < n:
|
| 257 |
+
time.sleep(REQUEST_DELAY_SEC)
|
| 258 |
+
more = okx_get("/api/v5/market/history-candles", {"instId": inst, "bar": "1D", "after": str(oldest), "limit": "100"})
|
| 259 |
+
if not more:
|
| 260 |
+
break
|
| 261 |
+
allr.extend(more)
|
| 262 |
+
new_oldest = int(more[-1][0])
|
| 263 |
+
if new_oldest >= oldest:
|
| 264 |
+
break
|
| 265 |
+
oldest = new_oldest
|
| 266 |
+
if len(more) < 100:
|
| 267 |
+
break
|
| 268 |
+
cols = ["ts", "open", "high", "low", "close", "vol", "volccy", "volq", "confirm"]
|
| 269 |
+
recs = []
|
| 270 |
+
for r in allr:
|
| 271 |
+
r = list(r) + [None] * (9 - len(r))
|
| 272 |
+
recs.append(r[:9])
|
| 273 |
+
df = pd.DataFrame(recs, columns=cols)
|
| 274 |
+
for c in cols:
|
| 275 |
+
df[c] = pd.to_numeric(df[c], errors="coerce")
|
| 276 |
+
df["confirm"] = df["confirm"].fillna(1)
|
| 277 |
+
df = df.dropna(subset=["ts", "close"]).drop_duplicates("ts").sort_values("ts").reset_index(drop=True)
|
| 278 |
+
df = df[df["close"] > 0].reset_index(drop=True)
|
| 279 |
+
df["volq"] = df["volq"].where(df["volq"].notna(), df["close"] * df["vol"])
|
| 280 |
+
df["date"] = pd.to_datetime(df["ts"], unit="ms", utc=True)
|
| 281 |
+
return df.tail(n).reset_index(drop=True)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def fetch_perp(inst: str) -> Tuple[Optional[float], Optional[float]]:
|
| 285 |
+
"""Perp funding rate (8h) ve open interest (USD). Perp yoksa (None, None)."""
|
| 286 |
+
parts = inst.split("-")
|
| 287 |
+
swap = f"{parts[0]}-{parts[1]}-SWAP" if len(parts) >= 2 else None
|
| 288 |
+
if not swap:
|
| 289 |
+
return None, None
|
| 290 |
+
funding, oi_usd = None, None
|
| 291 |
+
fr = okx_get("/api/v5/public/funding-rate", {"instId": swap})
|
| 292 |
+
if fr:
|
| 293 |
+
funding = num(fr[0].get("fundingRate"))
|
| 294 |
+
if funding is not None:
|
| 295 |
+
time.sleep(REQUEST_DELAY_SEC)
|
| 296 |
+
oi = okx_get("/api/v5/public/open-interest", {"instType": "SWAP", "instId": swap})
|
| 297 |
+
if oi:
|
| 298 |
+
oi_usd = num(oi[0].get("oiUsd"))
|
| 299 |
+
return funding, oi_usd
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def _tail(a, k, min_frac=0.6):
|
| 303 |
+
return a[-k:] if len(a) > 0 and len(a) >= int(np.ceil(k * min_frac)) else None
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def metrics_from_candles(conf: pd.DataFrame, price: float, bid: Optional[float] = None, ask: Optional[float] = None) -> Dict[str, Any]:
|
| 307 |
+
"""Pure function: confirmed daily candles + live price -> derived [D] metrics."""
|
| 308 |
+
c = conf["close"].to_numpy(float)
|
| 309 |
+
h = conf["high"].to_numpy(float)
|
| 310 |
+
vb = conf["vol"].to_numpy(float)
|
| 311 |
+
vq = conf["volq"].to_numpy(float)
|
| 312 |
+
n = len(c)
|
| 313 |
+
m: Dict[str, Any] = {"hist_days": n}
|
| 314 |
+
lr = np.diff(np.log(c)) if n >= 2 else np.array([])
|
| 315 |
+
rets = c[1:] / c[:-1] - 1 if n >= 2 else np.array([])
|
| 316 |
+
|
| 317 |
+
def ret_k(k):
|
| 318 |
+
return price / c[-k] - 1 if n >= k else None
|
| 319 |
+
|
| 320 |
+
m["ret30"], m["ret90"], m["ret365"] = ret_k(30), ret_k(90), ret_k(365)
|
| 321 |
+
|
| 322 |
+
def vol_k(k):
|
| 323 |
+
t = _tail(lr, k)
|
| 324 |
+
return float(np.std(t, ddof=1) * np.sqrt(365)) if t is not None and len(t) >= 15 else None
|
| 325 |
+
|
| 326 |
+
m["vol30"], m["vol90"], m["vol365"] = vol_k(30), vol_k(90), vol_k(365)
|
| 327 |
+
m["sharpe"] = m["sortino"] = None
|
| 328 |
+
t = _tail(lr, 365)
|
| 329 |
+
if t is not None and len(t) >= 100:
|
| 330 |
+
sd = float(np.std(t, ddof=1))
|
| 331 |
+
if sd > 1e-12:
|
| 332 |
+
m["sharpe"] = float(np.mean(t) / sd * np.sqrt(365))
|
| 333 |
+
dd = float(np.sqrt(np.mean(np.minimum(t, 0) ** 2)))
|
| 334 |
+
if dd > 1e-12:
|
| 335 |
+
m["sortino"] = float(np.mean(t) / dd * np.sqrt(365))
|
| 336 |
+
w = c[-365:]
|
| 337 |
+
m["maxdd"] = None
|
| 338 |
+
if len(w) >= 60:
|
| 339 |
+
cs = np.append(w, price)
|
| 340 |
+
m["maxdd"] = float(np.max(1 - cs / np.maximum.accumulate(cs)))
|
| 341 |
+
m["hi365"], m["dd_high"] = None, None
|
| 342 |
+
if n >= 30:
|
| 343 |
+
hi = float(max(np.nanmax(h[-365:]), price))
|
| 344 |
+
m["hi365"], m["dd_high"] = hi, 1 - price / hi
|
| 345 |
+
m["sma50"] = float(np.mean(c[-50:])) if n >= 50 else None
|
| 346 |
+
m["sma200"] = float(np.mean(c[-200:])) if n >= 200 else None
|
| 347 |
+
m["vwap365"] = None
|
| 348 |
+
if n >= 120:
|
| 349 |
+
sb, sq = np.nansum(vb[-365:]), np.nansum(vq[-365:])
|
| 350 |
+
if sb > 0 and sq > 0:
|
| 351 |
+
m["vwap365"] = float(sq / sb)
|
| 352 |
+
m["trend_fair"] = m["trend_g"] = m["trend_z"] = m["trend_sigma"] = None
|
| 353 |
+
if n >= 120:
|
| 354 |
+
mm = min(n, 500)
|
| 355 |
+
y, x = np.log(c[-mm:]), np.arange(mm, dtype=float)
|
| 356 |
+
slope, icpt = np.polyfit(x, y, 1)
|
| 357 |
+
sigma = float(np.std(y - (icpt + slope * x), ddof=2))
|
| 358 |
+
fit_now = icpt + slope * mm
|
| 359 |
+
m["trend_fair"] = float(np.exp(fit_now))
|
| 360 |
+
m["trend_g"] = float(np.exp(slope * 365) - 1)
|
| 361 |
+
m["trend_sigma"] = sigma
|
| 362 |
+
if sigma > 1e-9:
|
| 363 |
+
m["trend_z"] = float((np.log(price) - fit_now) / sigma)
|
| 364 |
+
q30, q90 = _tail(vq, 30), _tail(vq, 90)
|
| 365 |
+
m["vq30"] = float(np.nanmean(q30)) if q30 is not None else None
|
| 366 |
+
m["vq90"] = float(np.nanmean(q90)) if q90 is not None else None
|
| 367 |
+
m["vol_ratio"] = div(m["vq30"], m["vq90"]) if (m["vq90"] or 0) > 0 else None
|
| 368 |
+
m["vcv"] = float(np.nanstd(q90) / np.nanmean(q90)) if q90 is not None and np.nanmean(q90) > 0 else None
|
| 369 |
+
r90 = _tail(rets, 90)
|
| 370 |
+
m["worst90"] = float(np.min(r90)) if r90 is not None else None
|
| 371 |
+
m["var95"] = float(-np.percentile(r90, 5)) if r90 is not None else None
|
| 372 |
+
m["spread_bps"] = None
|
| 373 |
+
if bid and ask and bid > 0 and ask > 0 and ask >= bid:
|
| 374 |
+
m["spread_bps"] = (ask - bid) / ((ask + bid) / 2) * 1e4
|
| 375 |
+
return m
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def fetch(inst: str, force: bool = False) -> Optional[Dict[str, Any]]:
|
| 379 |
+
key = f"t_{inst}"
|
| 380 |
if not force:
|
| 381 |
c = _cget(key)
|
| 382 |
if c is not None:
|
| 383 |
return c
|
| 384 |
try:
|
| 385 |
+
df = fetch_candles(inst)
|
| 386 |
+
tk = get_tickers().get(inst)
|
| 387 |
+
if tk is None:
|
| 388 |
+
rows = okx_get("/api/v5/market/ticker", {"instId": inst})
|
| 389 |
+
tk = rows[0] if rows else None
|
| 390 |
+
funding, oi_usd = fetch_perp(inst) if SETTINGS.get("perp", True) else (None, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
except Exception as e:
|
| 392 |
+
logger.warning("%s fetch failed: %s", inst, e)
|
| 393 |
return None
|
| 394 |
+
if df is None or df.empty:
|
| 395 |
+
return None
|
| 396 |
+
conf = df[df["confirm"] >= 1].reset_index(drop=True)
|
| 397 |
+
price = num(tk.get("last")) if tk else None
|
| 398 |
+
if price is None:
|
| 399 |
+
price = num(df["close"].iloc[-1])
|
| 400 |
+
if price is None or price <= 0 or len(conf) < 10:
|
| 401 |
return None
|
| 402 |
|
| 403 |
flags: List[str] = []
|
| 404 |
+
notes: List[str] = []
|
| 405 |
+
mt = metrics_from_candles(conf, price, num(tk.get("bidPx")) if tk else None, num(tk.get("askPx")) if tk else None)
|
| 406 |
+
n = mt["hist_days"]
|
| 407 |
+
if tk is None:
|
| 408 |
+
flags.append("ticker missing (price from last candle)")
|
| 409 |
+
if n < 200:
|
| 410 |
+
flags.append(f"short history ({n} days; SMA200 unavailable)")
|
| 411 |
+
last_ts = int(conf["ts"].iloc[-1])
|
| 412 |
+
last_dt = pd.to_datetime(last_ts, unit="ms", utc=True)
|
| 413 |
+
age_h = (datetime.now(timezone.utc) - (last_dt + pd.Timedelta(days=1))).total_seconds() / 3600
|
| 414 |
+
if age_h > 48:
|
| 415 |
+
flags.append(f"stale candles ({age_h:.0f} h)")
|
| 416 |
+
first_ts = int(conf["ts"].iloc[0])
|
| 417 |
+
gaps = int(round((last_ts - first_ts) / 86_400_000)) + 1 - n
|
| 418 |
+
if gaps > 3:
|
| 419 |
+
flags.append(f"{gaps} missing daily candles")
|
| 420 |
+
zero_days = int((conf["volq"].to_numpy(float)[-90:] == 0).sum())
|
| 421 |
+
if zero_days > 3:
|
| 422 |
+
flags.append(f"{zero_days} zero-volume days (last 90)")
|
| 423 |
+
if abs(price / float(conf["close"].iloc[-1]) - 1) > 0.30:
|
| 424 |
+
flags.append("live price deviates >30% from last close")
|
| 425 |
+
if funding is None:
|
| 426 |
+
notes.append("no perp (funding/OI) data")
|
| 427 |
+
|
| 428 |
+
base = inst.split("-")[0]
|
| 429 |
+
open24 = num(tk.get("open24h")) if tk else None
|
| 430 |
+
vol24 = num(tk.get("volCcy24h")) if tk else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
d = {
|
| 432 |
+
"sym": inst, "name": base, "base": base, "price": price, "seg": CATEGORY.get(base, "Other"),
|
| 433 |
+
"sector": tier_of(mt["vq30"] if mt["vq30"] is not None else vol24), "vol24": vol24,
|
| 434 |
+
"chg24": (price / open24 - 1) if open24 else None, "funding": funding, "oi_usd": oi_usd,
|
| 435 |
+
"flags": flags, "notes": notes, "unit_ok": True, "last_dt": last_dt, "age_h": age_h,
|
| 436 |
+
"p_vwap": div(price, mt["vwap365"]), "p_sma200": div(price, mt["sma200"]),
|
| 437 |
+
"p_trend": div(price, mt["trend_fair"]), "p_high": div(price, mt["hi365"]),
|
| 438 |
+
"hist": conf[["date", "close"]].tail(365).reset_index(drop=True), "fetched": datetime.now().isoformat(),
|
| 439 |
+
**mt,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
}
|
| 441 |
_cset(key, d)
|
| 442 |
return d
|
|
|
|
| 446 |
return [v for k, (ts, v) in _cache.items() if k.startswith("t_") and v is not None]
|
| 447 |
|
| 448 |
|
| 449 |
+
MULTS = ["p_vwap", "p_sma200", "p_trend", "p_high", "ret90", "vol90", "sharpe"]
|
| 450 |
+
POS_ONLY = {"p_vwap", "p_sma200", "p_trend", "p_high"}
|
| 451 |
|
| 452 |
|
| 453 |
+
def build_medians(ds) -> Dict[str, Dict[str, Tuple[Optional[float], int]]]:
|
| 454 |
+
groups: Dict[str, list] = {}
|
| 455 |
for d in ds:
|
| 456 |
+
groups.setdefault(d["seg"], []).append(d)
|
| 457 |
med = {}
|
| 458 |
for k, g in groups.items():
|
| 459 |
m = {}
|
|
|
|
| 465 |
|
| 466 |
|
| 467 |
# ============================================================================
|
| 468 |
+
# VALUATION ENGINE (fiyat-bazlı fair value; fundamental yoktur)
|
| 469 |
# ============================================================================
|
| 470 |
|
| 471 |
+
def dep_value(anchor, g0, wacc, tg, years=5) -> Optional[Dict[str, float]]:
|
| 472 |
+
"""Discounted Expected Price: anchor fiyatı 5 yıl boyunca g0 -> tg doğrusal sönen drift ile büyütülür,
|
| 473 |
+
gereken getiri (wacc) ile bugüne iskonto edilir. DCF'in nakit akışsız karşılığıdır."""
|
| 474 |
+
if None in (anchor, g0, wacc, tg) or anchor <= 0 or wacc <= tg:
|
| 475 |
return None
|
| 476 |
+
p = anchor
|
| 477 |
for t in range(1, years + 1):
|
| 478 |
g = g0 + (tg - g0) * (t - 1) / (years - 1)
|
| 479 |
+
p *= 1 + g
|
| 480 |
+
return {"p_future": p, "per_unit": p / (1 + wacc) ** years}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
|
| 482 |
|
| 483 |
def assumptions(P, rg=None, rw=None):
|
|
|
|
| 487 |
return (1 + rg) * (1 + i * P["pt"]) - 1, (1 + rw) * (1 + i) - 1, (1 + P["rtg"]) * (1 + i) - 1 # g0, wacc, tg (nominal)
|
| 488 |
|
| 489 |
|
| 490 |
+
def anchor_of(d) -> Optional[float]:
|
| 491 |
+
v = [x for x in (d.get("trend_fair"), d.get("vwap365"), d.get("sma200")) if x is not None and x > 0]
|
| 492 |
+
return float(np.median(v)) if len(v) >= 2 else None
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def dep_try(anchor, g0, wacc, tg):
|
| 496 |
+
if anchor is None:
|
| 497 |
+
return None, "needs >=2 price anchors (trend / VWAP / SMA200)"
|
| 498 |
+
if anchor <= 0:
|
| 499 |
+
return None, "non-positive anchor"
|
|
|
|
| 500 |
if wacc <= tg:
|
| 501 |
+
return None, "discount rate <= terminal drift"
|
| 502 |
+
r = dep_value(anchor, g0, wacc, tg)
|
| 503 |
+
if r is None or r["per_unit"] <= 0:
|
| 504 |
+
return None, "value <= 0"
|
| 505 |
+
return r["per_unit"], f"anchor {fpx(anchor)} → {fpx(r['p_future'])} (5y) → discounted"
|
| 506 |
|
| 507 |
|
| 508 |
def relative_value(d, med) -> Tuple[Optional[float], str]:
|
| 509 |
+
m = med.get(d["seg"])
|
| 510 |
+
if not m:
|
| 511 |
+
return None, "no peer group"
|
| 512 |
+
vals = []
|
| 513 |
+
if m["p_vwap"][0] and d.get("vwap365"):
|
| 514 |
+
vals.append(m["p_vwap"][0] * d["vwap365"])
|
| 515 |
+
if m["p_sma200"][0] and d.get("sma200"):
|
| 516 |
+
vals.append(m["p_sma200"][0] * d["sma200"])
|
| 517 |
+
if m["p_trend"][0] and d.get("trend_fair"):
|
| 518 |
+
vals.append(m["p_trend"][0] * d["trend_fair"])
|
| 519 |
vals = [v for v in vals if v > 0]
|
| 520 |
if not vals:
|
| 521 |
return None, f"<{MIN_PEERS} valid peers"
|
| 522 |
+
return float(np.mean(vals)), f"{len(vals)} category-median multiple(s)"
|
| 523 |
|
| 524 |
|
| 525 |
def intrinsic(d, P, med, rg=None, rw=None):
|
| 526 |
g0, wacc, tg = assumptions(P, rg, rw)
|
| 527 |
m: Dict[str, Tuple[Optional[float], str]] = {}
|
| 528 |
+
tf = d.get("trend_fair")
|
| 529 |
+
m["Log-Trend Regression"] = ((tf, f"trend {fp(d.get('trend_g'))}/yıl, z={fm(d.get('trend_z'))}") if tf else (None, "needs >=120 days of candles"))
|
| 530 |
+
vw = d.get("vwap365")
|
| 531 |
+
m["VWAP 365D"] = ((vw, "hacim-ağırlıklı maliyet bazı vekili") if vw else (None, "needs >=120 days"))
|
| 532 |
+
s2 = d.get("sma200")
|
| 533 |
+
m["SMA 200D"] = ((s2, "200 günlük ortalama") if s2 else (None, "needs >=200 days"))
|
| 534 |
+
m["Discounted Expected Price"] = dep_try(anchor_of(d), g0, wacc, tg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
m["Relative"] = relative_value(d, med)
|
| 536 |
vals = [v for v, _ in m.values() if v is not None and v > 0]
|
| 537 |
return m, (float(np.median(vals)) if vals else None)
|
| 538 |
|
| 539 |
|
| 540 |
def reverse_dcf(d, P):
|
| 541 |
+
base = anchor_of(d)
|
| 542 |
+
if base is None or base <= 0:
|
| 543 |
+
return None, "anchor unavailable (needs >=2 of trend / VWAP / SMA200)"
|
|
|
|
|
|
|
| 544 |
_, wacc0, tg = assumptions(P)
|
| 545 |
|
| 546 |
def solve(wacc):
|
| 547 |
if wacc <= tg:
|
| 548 |
return None
|
| 549 |
+
f = lambda g: dep_value(base, g, wacc, tg)["per_unit"]
|
| 550 |
lo, hi = -0.9, 3.0
|
| 551 |
if not (f(lo) <= d["price"] <= f(hi)):
|
| 552 |
return None
|
|
|
|
| 560 |
rw = P["rw"] + dw
|
| 561 |
w = (1 + rw) * (1 + P["infl"]) - 1
|
| 562 |
g = solve(w)
|
| 563 |
+
rows.append({"Real discount rate": fp(rw), "Nominal discount rate": fp(w), "Implied drift (nominal, yr1)": fp(g),
|
| 564 |
"Implied (real)": fp(real_ret(g, P["infl"]))})
|
| 565 |
return rows, "ok"
|
| 566 |
|
|
|
|
| 578 |
return out, None
|
| 579 |
pw = sum(o["Prob"] * o["Target"] for o in out)
|
| 580 |
nom = pw / d["price"] - 1
|
| 581 |
+
tot = nom # spot: temettü/gelir yok
|
| 582 |
+
return out, dict(pw=pw, nominal=nom, total=tot, real=real_ret(tot, P["infl"]), real_price=real_ret(nom, P["infl"]), dy_known=True)
|
|
|
|
|
|
|
| 583 |
|
| 584 |
|
| 585 |
def validate_probs(bear, base):
|
|
|
|
| 590 |
|
| 591 |
|
| 592 |
# ============================================================================
|
| 593 |
+
# QUALITY, MARKET HEALTH, RISK
|
| 594 |
# ============================================================================
|
| 595 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
def quality(d):
|
| 597 |
+
q = {k: d.get(k) for k in ("ret30", "ret90", "ret365", "vol30", "vol90", "vol365", "sharpe", "sortino", "maxdd", "dd_high",
|
| 598 |
+
"trend_z", "spread_bps", "vq30", "vq90", "vol_ratio", "vcv", "worst90", "var95", "hist_days")}
|
| 599 |
+
q["sma_gap"] = div(d.get("sma50"), d.get("sma200"))
|
| 600 |
+
q["sma_gap"] = None if q["sma_gap"] is None else q["sma_gap"] - 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
return q
|
| 602 |
|
| 603 |
|
| 604 |
+
def market_health(d):
|
| 605 |
+
"""9 ikili bileşenli piyasa sağlığı skoru (Piotroski benzeri). Hesaplanamayan bileşen N/A."""
|
|
|
|
| 606 |
def cmp(a, b, op):
|
| 607 |
return None if a is None or b is None else op(a, b)
|
| 608 |
|
| 609 |
+
fr = d.get("funding")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
comps = [
|
| 611 |
+
("1 Price > SMA200", cmp(d.get("price"), d.get("sma200"), lambda a, b: a > b)),
|
| 612 |
+
("2 SMA50 > SMA200", cmp(d.get("sma50"), d.get("sma200"), lambda a, b: a > b)),
|
| 613 |
+
("3 90d return > 0", cmp(d.get("ret90"), 0, lambda a, b: a > b)),
|
| 614 |
+
("4 Volatility falling (30d < 90d)", cmp(d.get("vol30"), d.get("vol90"), lambda a, b: a < b)),
|
| 615 |
+
("5 Volume not shrinking (30d >= 90d avg)", cmp(d.get("vq30"), d.get("vq90"), lambda a, b: a >= b)),
|
| 616 |
+
("6 Drawdown from 1Y high < 40%", cmp(d.get("dd_high"), 0.40, lambda a, b: a < b)),
|
| 617 |
+
("7 Sharpe (1Y) > 0", cmp(d.get("sharpe"), 0, lambda a, b: a > b)),
|
| 618 |
+
("8 Spread < 10 bps", cmp(d.get("spread_bps"), 10, lambda a, b: a < b)),
|
| 619 |
+
("9 Funding not crowded (|f| < 0.03%/8h)", None if fr is None else abs(fr) < 0.0003),
|
| 620 |
]
|
| 621 |
n = sum(1 for _, v in comps if v is not None)
|
| 622 |
return sum(1 for _, v in comps if v), n, comps
|
| 623 |
|
| 624 |
|
| 625 |
def risk_engine(d, q, cov):
|
|
|
|
|
|
|
| 626 |
fl = []
|
| 627 |
|
| 628 |
def add(name, w, cond, detail):
|
| 629 |
fl.append((name, w, None if cond is None else ("TRIGGERED" if cond else "OK"), detail))
|
| 630 |
|
| 631 |
+
price, s2, fr = d.get("price"), d.get("sma200"), d.get("funding")
|
| 632 |
+
add("Extreme volatility", 12, None if q["vol90"] is None else q["vol90"] > 1.2, "90d annualized vol > 120%")
|
| 633 |
+
add("Deep drawdown", 12, None if q["dd_high"] is None else q["dd_high"] > 0.5, "price >50% below 1Y high")
|
| 634 |
+
add("Downtrend", 10, None if s2 is None else price < s2, "price < SMA200")
|
| 635 |
+
add("Thin liquidity", 10, None if q["vq30"] is None else q["vq30"] < 5e6, "30d avg daily volume < 5M USDT")
|
| 636 |
+
add("Wide spread", 8, None if q["spread_bps"] is None else q["spread_bps"] > 15, "bid/ask spread > 15 bps")
|
| 637 |
+
add("Volume collapse", 8, None if q["vol_ratio"] is None else q["vol_ratio"] < 0.5, "30d avg volume < 50% of 90d avg")
|
| 638 |
+
add("Crash day", 8, None if q["worst90"] is None else q["worst90"] < -0.15, "worst daily return (90d) < -15%")
|
| 639 |
+
add("Tail risk (VaR95)", 8, None if q["var95"] is None else q["var95"] > 0.08, "daily 95% VaR (90d) > 8%")
|
| 640 |
+
add("Overextension", 8, anyc(None if q["trend_z"] is None else q["trend_z"] > 2,
|
| 641 |
+
None if d.get("p_sma200") is None else d["p_sma200"] > 1.6),
|
| 642 |
+
"trend z-score > 2 or price > 1.6x SMA200")
|
| 643 |
+
add("Funding crowding", 6, None if fr is None else abs(fr) > 0.0005, "|funding| > 0.05% per 8h")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
add("Data reliability", 10, cov < 0.7 or bool(d["flags"]), f"coverage<70% or integrity flags ({len(d['flags'])})")
|
| 645 |
ev_w = sum(w for _, w, st, _ in fl if st is not None)
|
| 646 |
score = None if ev_w == 0 else 100 * sum(w for _, w, st, _ in fl if st == "TRIGGERED") / ev_w
|
|
|
|
| 651 |
# SCORING & ANALYSIS
|
| 652 |
# ============================================================================
|
| 653 |
|
| 654 |
+
WEIGHTS = {"Majors": (.30, .25, .25, .20), "Meme": (.25, .15, .20, .40)}
|
| 655 |
+
DEFAULT_W = (.30, .25, .20, .25)
|
| 656 |
|
| 657 |
|
| 658 |
def coverage(d):
|
| 659 |
+
f = [d.get("price"), d.get("vq30"), d.get("spread_bps"), d.get("sma50"), d.get("sma200"), d.get("vwap365"),
|
| 660 |
+
d.get("trend_fair"), d.get("vol90"), d.get("sharpe"), d.get("maxdd"), d.get("ret90"), d.get("ret365")]
|
|
|
|
|
|
|
| 661 |
return sum(x is not None for x in f) / len(f)
|
| 662 |
|
| 663 |
|
| 664 |
def analyze(d, P, med, probs=(0.25, 0.5, 0.25)):
|
| 665 |
A: Dict[str, Any] = {"d": d}
|
| 666 |
+
infl = P["infl"]
|
| 667 |
q = quality(d)
|
| 668 |
A["q"] = q
|
| 669 |
A["methods"], A["iv"] = intrinsic(d, P, med)
|
| 670 |
A["mos"] = (A["iv"] - d["price"]) / A["iv"] if A["iv"] else None
|
| 671 |
rv, A["rel_note"] = relative_value(d, med)
|
| 672 |
A["rel_mos"] = (rv - d["price"]) / rv if rv else None
|
| 673 |
+
A["pio"] = market_health(d)
|
| 674 |
A["cov"] = coverage(d)
|
| 675 |
A["risk"], A["flags"] = risk_engine(d, q, A["cov"])
|
| 676 |
+
rret365, rret90 = real_ret(q["ret365"], infl), real_ret(q["ret90"], infl)
|
| 677 |
+
liq = math.log10(q["vq30"]) if q["vq30"] and q["vq30"] > 0 else None
|
| 678 |
+
|
| 679 |
+
vi = [(lin(A["mos"], -0.3, 0.5), 4), (lin(A["rel_mos"], -0.3, 0.5), 2), (lin(d.get("trend_z"), 2, -2), 2),
|
| 680 |
+
(lin(d.get("p_sma200"), 1.6, 0.8), 1), (lin(q["dd_high"], 0.0, 0.6), 1)]
|
| 681 |
+
qi = [(lin(rret365, -0.5, 1.5), 3), (lin(q["sharpe"], -0.5, 2.0), 3), (lin(q["sortino"], -0.5, 3.0), 2),
|
| 682 |
+
(lin(q["maxdd"], 0.8, 0.25), 2), (lin(rret90, -0.3, 0.5), 1), (lin(q["vcv"], 2, 0.3), 1)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
sc, n, _ = A["pio"]
|
| 684 |
+
si = [(lin(liq, 6, 9), 3), (lin(q["spread_bps"], 30, 2), 2), (lin(q["hist_days"], 90, 500), 2),
|
| 685 |
+
(lin(q["vol90"], 1.5, 0.4), 2), (lin(sc / n, 0.3, 0.8) if n >= 5 else None, 2)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 686 |
A["val"], A["val_cov"] = wavg(vi)
|
| 687 |
A["qual"], A["qual_cov"] = wavg(qi)
|
| 688 |
A["str"], A["str_cov"] = wavg(si)
|
| 689 |
+
w = WEIGHTS.get(d["seg"], DEFAULT_W)
|
| 690 |
comps = [A["val"], A["qual"], A["str"], None if A["risk"] is None else 100 - A["risk"]]
|
| 691 |
if A["val"] is None or A["qual"] is None:
|
| 692 |
A["composite"] = None
|
| 693 |
else:
|
| 694 |
av = [(c, wt) for c, wt in zip(comps, w) if c is not None]
|
| 695 |
A["composite"] = sum(c * wt for c, wt in av) / sum(wt for _, wt in av) * min(1.0, A["cov"] / 0.7)
|
| 696 |
+
sev = any("stale" in f or "missing daily" in f or "deviates" in f for f in d["flags"])
|
| 697 |
lvl = 2 if A["cov"] >= 0.85 else 1 if A["cov"] >= 0.65 else 0
|
| 698 |
lvl = max(lvl - (1 if sev else 0), 0)
|
| 699 |
A["conf"] = ["Low", "Medium", "High"][lvl]
|
|
|
|
| 710 |
sym = norm_sym(ticker)
|
| 711 |
d = fetch(sym)
|
| 712 |
if d is None:
|
| 713 |
+
return None, f"⚠️ {sym or '?'} OKX'ten çekilemedi (parite yok, geçmiş yetersiz ya da ağ hatası)."
|
| 714 |
return analyze(d, P, build_medians(cached_ds()), probs), None
|
| 715 |
|
| 716 |
|
| 717 |
+
def price_fig(d, extra=None):
|
| 718 |
try:
|
| 719 |
+
h = d["hist"]
|
| 720 |
+
if h is None or h.empty:
|
| 721 |
return None
|
| 722 |
+
f = go.Figure(go.Scatter(x=h["date"], y=h["close"], mode="lines", name="Close"))
|
| 723 |
for nm, v in (extra or {}).items():
|
| 724 |
if v:
|
| 725 |
f.add_hline(y=v, line_dash="dot", annotation_text=nm)
|
| 726 |
+
f.update_layout(title=f"{d['sym']} — 1Y (OKX, USDT)", template="plotly_white", height=380, margin=dict(l=10, r=10, t=40, b=10))
|
| 727 |
return f
|
| 728 |
except Exception:
|
| 729 |
return None
|
|
|
|
| 734 |
sc, n, _ = A["pio"]
|
| 735 |
e = A["exp"]
|
| 736 |
return {
|
| 737 |
+
"Pair": d["sym"], "Segment": d["seg"], "Tier": d["sector"], "Price": rsig(d["price"]),
|
| 738 |
"Composite": None if A["composite"] is None else round(A["composite"], 1),
|
| 739 |
+
"Fair value": rsig(A["iv"]),
|
| 740 |
"MoS %": None if A["mos"] is None else round(A["mos"] * 100, 1),
|
| 741 |
+
"vs VWAP365 %": None if d["p_vwap"] is None else round((d["p_vwap"] - 1) * 100, 1),
|
| 742 |
+
"Ret 90d %": pct(q["ret90"]), "Vol 90d %": pct(q["vol90"], 0),
|
| 743 |
+
"Sharpe 1Y": None if q["sharpe"] is None else round(q["sharpe"], 2),
|
| 744 |
+
"MaxDD 1Y %": pct(q["maxdd"], 0),
|
| 745 |
+
"Health": f"{sc}/{n} (cov {n}/9)" if n else "N/A",
|
| 746 |
"Risk": None if A["risk"] is None else round(A["risk"], 0),
|
| 747 |
+
"Exp Return % (12M)": None if not e else round(e["total"] * 100, 1),
|
| 748 |
"Real Exp Return %": None if not e or e["real"] is None else round(e["real"] * 100, 1),
|
| 749 |
+
"Vol30d avg (M USDT)": None if q["vq30"] is None else round(q["vq30"] / 1e6, 1),
|
| 750 |
+
"Funding % (8h)": None if d["funding"] is None else round(d["funding"] * 100, 4),
|
| 751 |
"Coverage %": round(A["cov"] * 100), "Confidence": A["conf"],
|
| 752 |
}
|
| 753 |
|
| 754 |
|
| 755 |
+
def run_scan(force, extra, P, progress, n=DEFAULT_UNIVERSE_N):
|
| 756 |
+
base, src = build_universe(int(n))
|
| 757 |
+
syms = base + [norm_sym(x) for x in (extra or "").split(",") if x.strip()]
|
| 758 |
+
syms = [s for s in dict.fromkeys(syms) if s]
|
| 759 |
+
LAST["universe"] = syms
|
| 760 |
ds, failed = [], []
|
| 761 |
for i, s in enumerate(syms):
|
| 762 |
progress((i + 1) / len(syms), desc=f"Fetching {s}")
|
|
|
|
| 769 |
rows = [scan_row(analyze(d, P, med)) for d in ds]
|
| 770 |
df = pd.DataFrame(rows)
|
| 771 |
LAST["df"], LAST["failed"] = df, failed
|
| 772 |
+
return df, failed, src
|
| 773 |
|
| 774 |
|
| 775 |
def apply_filters(seg, minc, minm, sortby, desc):
|
|
|
|
| 783 |
if minm > -100:
|
| 784 |
x = x[x["MoS %"].fillna(-1e9) >= minm]
|
| 785 |
x = x.sort_values(sortby, ascending=not desc, na_position="last")
|
| 786 |
+
f = tempfile.NamedTemporaryFile(delete=False, suffix=".csv", prefix="okx_v1_")
|
| 787 |
x.to_csv(f.name, index=False)
|
| 788 |
return x, f.name
|
| 789 |
|
| 790 |
|
| 791 |
def md_report(A, P):
|
| 792 |
d = A["d"]
|
| 793 |
+
L = [f"## {d['sym']} — OKX Spot",
|
| 794 |
+
f"**{d['seg']}** · {d['sector']} · Fiyat **{fpx(d['price'])} USDT** · 24s {fp(d['chg24'])} · "
|
| 795 |
+
f"Son kapanmış mum: {d['last_dt'].date() if d['last_dt'] is not None else 'N/A'}",
|
| 796 |
f"**Composite {fm(A['composite'], 0)}** | Valuation {fm(A['val'], 0)} | Quality {fm(A['qual'], 0)} | "
|
| 797 |
f"Strength {fm(A['str'], 0)} | Risk {fm(A['risk'], 0)} (düşük=iyi) | Coverage {fp(A['cov'], 0)} | "
|
| 798 |
f"Confidence **{A['conf']}**",
|
| 799 |
+
f"**Fair value [D]:** {fpx(A['iv'])} USDT · Margin of Safety {fp(A['mos'])}"]
|
| 800 |
+
if d["funding"] is not None:
|
| 801 |
+
L.append(f"**Perp [R]:** funding {fp(d['funding'], 4)} / 8s · Open interest {fm((d['oi_usd'] or 0) / 1e6, 1) if d['oi_usd'] else 'N/A'} M USD")
|
| 802 |
+
L.append("\n| Yöntem | Değer | Not |\n|---|---|---|")
|
| 803 |
for k, (v, n) in A["methods"].items():
|
| 804 |
+
L.append(f"| {k} | {fpx(v)} | {n} |")
|
| 805 |
if d["flags"]:
|
| 806 |
L.append("\n**⚠️ Veri bayrakları:** " + "; ".join(d["flags"]))
|
| 807 |
+
L.append("\n*Fair value = mevcut yöntemlerin medyanı (fiyat-hacim bazlı referans, fundamental değil). "
|
| 808 |
+
"Skorlar evren içi göreceli, açıklanabilir heuristiklerdir.*")
|
| 809 |
return "\n".join(L)
|
| 810 |
|
| 811 |
|
|
|
|
| 828 |
def chk(name, cond):
|
| 829 |
res.append(("✅ " if cond else "❌ ") + name)
|
| 830 |
|
| 831 |
+
r = dep_value(100, 0, 0.10, 0)
|
| 832 |
+
chk("DEP: g=0,tg=0, 10% → 100/1.1^5", r is not None and abs(r["per_unit"] - 100 / 1.1 ** 5) < 1e-9)
|
| 833 |
+
chk("DEP refuses wacc<=tg", dep_value(100, 0.1, 0.05, 0.05) is None)
|
| 834 |
+
chk("DEP refuses anchor<=0", dep_value(-5, 0.1, 0.2, 0.05) is None)
|
| 835 |
+
chk("DEP refuses missing input", dep_value(None, 0.1, 0.2, 0.05) is None)
|
|
|
|
| 836 |
chk("Real return 50% nom / 25% infl = 20%", abs(real_ret(0.5, 0.25) - 0.2) < 1e-12)
|
| 837 |
chk("div by zero → None", div(1, 0) is None and div(None, 2) is None)
|
| 838 |
P = dict(infl=0.30, rw=0.10, rtg=0.01, rg=0.03, pt=0.5, conv=0.5)
|
| 839 |
g0, w, tg = assumptions(P)
|
| 840 |
+
chk("Discount rate > terminal drift (defaults)", w > tg)
|
| 841 |
chk("Probabilities 25/50 → 25/50/25 sum=1", validate_probs(25, 50) is not None and abs(sum(validate_probs(25, 50)) - 1) < 1e-9)
|
| 842 |
chk("Probabilities 70/50 rejected", validate_probs(70, 50) is None)
|
| 843 |
+
# reverse valuation consistency
|
| 844 |
+
d = {"trend_fair": 100.0, "vwap365": 100.0, "sma200": 100.0, "price": 100 / 1.1 ** 5}
|
| 845 |
P2 = dict(infl=0.0, rw=0.10, rtg=0.0, rg=0.0, pt=0.0, conv=0.5)
|
| 846 |
rows, _ = reverse_dcf(d, P2)
|
| 847 |
+
chk("Reverse: price = 100/1.1^5 → implied drift≈0",
|
| 848 |
+
rows is not None and abs(float(rows[1]["Implied drift (nominal, yr1)"].rstrip("%"))) < 0.05)
|
| 849 |
+
# market health incompleteness
|
| 850 |
+
sc, n, _ = market_health({})
|
| 851 |
+
chk("Market health all-missing → 0 available (not 9/9)", n == 0 and sc == 0)
|
| 852 |
# peer rule
|
| 853 |
+
ds = [{"seg": "X", "p_vwap": 1.0 + 0.1 * i, **{m: None for m in MULTS if m != "p_vwap"}} for i in range(4)]
|
| 854 |
+
chk("Category median needs >=5 peers", build_medians(ds)["X"]["p_vwap"][0] is None)
|
| 855 |
+
ds += [{"seg": "X", "p_vwap": 1.2, **{m: None for m in MULTS if m != "p_vwap"}}]
|
| 856 |
+
chk("Category median with 5 peers computed", build_medians(ds)["X"]["p_vwap"][0] is not None)
|
| 857 |
+
chk("Symbol normalisation", norm_sym("btc") == "BTC-USDT" and norm_sym("eth/usdt") == "ETH-USDT"
|
| 858 |
+
and norm_sym("SOLUSDT") == "SOL-USDT" and norm_sym("btc-usdt-swap") == "BTC-USDT" and norm_sym("BTC-USDC") == "BTC-USDC")
|
| 859 |
+
chk("Stablecoin / leveraged exclusion", is_excluded("USDC") and is_excluded("BTC3L") and not is_excluded("BTC"))
|
| 860 |
+
# metrics on a deterministic exponential series (+0.1% / day)
|
| 861 |
+
nd = 400
|
| 862 |
+
close = 100 * 1.001 ** np.arange(nd)
|
| 863 |
+
conf = pd.DataFrame({"close": close, "high": close * 1.01, "vol": np.full(nd, 1000.0), "volq": close * 1000.0})
|
| 864 |
+
mt = metrics_from_candles(conf, float(close[-1] * 1.001), 99.9, 100.1)
|
| 865 |
+
chk("Metrics: log-trend growth ≈ 1.001^365-1", mt["trend_g"] is not None and abs(mt["trend_g"] - (1.001 ** 365 - 1)) < 1e-6)
|
| 866 |
+
chk("Metrics: monotonic series → max drawdown 0", mt["maxdd"] is not None and abs(mt["maxdd"]) < 1e-12)
|
| 867 |
+
chk("Metrics: SMA200 = mean of last 200 closes", mt["sma200"] is not None and abs(mt["sma200"] - close[-200:].mean()) < 1e-9)
|
| 868 |
+
chk("Metrics: spread 20 bps for 99.9/100.1", mt["spread_bps"] is not None and abs(mt["spread_bps"] - 20.0) < 0.01)
|
| 869 |
+
chk("Metrics: 90d return", mt["ret90"] is not None and abs(mt["ret90"] - (close[-1] * 1.001 / close[-90] - 1)) < 1e-12)
|
| 870 |
return "\n".join(res)
|
| 871 |
|
| 872 |
|
|
|
|
| 874 |
# GRADIO UI
|
| 875 |
# ============================================================================
|
| 876 |
|
| 877 |
+
SORT_COLS = ["Composite", "MoS %", "vs VWAP365 %", "Ret 90d %", "Vol 90d %", "Sharpe 1Y", "MaxDD 1Y %", "Risk",
|
| 878 |
+
"Exp Return % (12M)", "Real Exp Return %", "Vol30d avg (M USDT)", "Coverage %"]
|
| 879 |
+
|
| 880 |
+
|
| 881 |
def build_ui():
|
| 882 |
+
with gr.Blocks(title="OKX Value Intelligence v1", theme=gr.themes.Soft()) as demo:
|
| 883 |
+
gr.Markdown("# OKX Value Intelligence v1\n**⚠️ Veri: OKX public API (spot fiyat/hacim, günlük mumlar, perp funding/OI). "
|
| 884 |
+
"Kripto varlıkların bilançosu yoktur: 'Fair value' fiyat-hacim verisinden türetilen istatistiksel referanstır, içsel değer değildir. "
|
| 885 |
"Eksik veri `N/A` gösterilir, asla sıfırla doldurulmaz. 12 aylık senaryolar heuristiktir, istatistiksel tahmin değildir. "
|
| 886 |
"Yatırım tavsiyesi değildir.**")
|
| 887 |
with gr.Accordion("⚙️ Varsayımlar [A] (tüm sekmeler için)", open=False):
|
| 888 |
with gr.Row():
|
| 889 |
s_infl = gr.Slider(0, 20, 3, step=0.5, label="Enflasyon (%)")
|
| 890 |
+
s_rw = gr.Slider(1, 40, 8, step=0.5, label="Reel iskonto / gereken getiri (%)")
|
| 891 |
+
s_rtg = gr.Slider(0, 15, 3, step=0.25, label="Reel terminal drift (%)")
|
| 892 |
with gr.Row():
|
| 893 |
+
s_rg = gr.Slider(-30, 60, 8, step=0.5, label="Baz reel drift (%)")
|
| 894 |
+
s_pt = gr.Slider(0, 100, 50, step=5, label="Enflasyonun nominal drift'e geçişi (%)")
|
| 895 |
s_cv = gr.Slider(0, 100, 50, step=5, label="12 ay içinde değer-fiyat farkının kapanma oranı (%)")
|
| 896 |
PI = [s_infl, s_rw, s_rtg, s_rg, s_pt, s_cv]
|
| 897 |
|
|
|
|
| 900 |
with gr.Row():
|
| 901 |
scan_btn = gr.Button("Run Scanner", variant="primary", scale=2)
|
| 902 |
force = gr.Checkbox(label="Force refresh", value=False)
|
| 903 |
+
perp = gr.Checkbox(label="Perp verisi (funding/OI)", value=True)
|
| 904 |
+
with gr.Row():
|
| 905 |
+
nuni = gr.Slider(20, 200, DEFAULT_UNIVERSE_N, step=10, label="Evren: 24s hacme göre ilk N OKX USDT paritesi")
|
| 906 |
+
extra = gr.Textbox(label="Ek pariteler (virgülle, örn: PEPE,WIF,SOL-USDT)", value="")
|
| 907 |
with gr.Row():
|
| 908 |
+
f_seg = gr.Dropdown(["All"] + SEGMENTS, value="All", label="Segment")
|
| 909 |
f_c = gr.Slider(0, 100, 0, step=5, label="Min composite")
|
| 910 |
f_m = gr.Slider(-100, 100, -100, step=5, label="Min MoS % (-100 = kapalı)")
|
| 911 |
with gr.Row():
|
| 912 |
+
f_sort = gr.Dropdown(SORT_COLS, value="Composite", label="Sırala")
|
|
|
|
|
|
|
| 913 |
f_desc = gr.Checkbox(label="Azalan", value=True)
|
| 914 |
f_btn = gr.Button("Filtreyi uygula")
|
| 915 |
status = gr.Markdown()
|
|
|
|
| 917 |
csv = gr.File(label="CSV indir")
|
| 918 |
FI = [f_seg, f_c, f_m, f_sort, f_desc]
|
| 919 |
|
| 920 |
+
def do_scan(frc, prp, nu, ext, *a, progress=gr.Progress()):
|
| 921 |
P = mk_params(*a[:6])
|
| 922 |
seg, c, m, so, de = a[6:]
|
| 923 |
+
SETTINGS["perp"] = bool(prp)
|
| 924 |
+
df, failed, src = run_scan(frc, ext, P, progress, nu)
|
| 925 |
if df.empty:
|
| 926 |
+
return None, None, "⚠️ Veri çekilemedi (OKX'e erişilemiyor olabilir)."
|
| 927 |
x, path = apply_filters(seg, c, m, so, de)
|
| 928 |
+
msg = f"✅ {len(df)} parite tarandı · evren: {src} · coverage ort. %{df['Coverage %'].mean():.0f}"
|
| 929 |
if failed:
|
| 930 |
msg += f" · ⚠️ Çekilemeyen ({len(failed)}): " + ", ".join(failed)
|
| 931 |
return x, path, msg
|
|
|
|
| 934 |
x, path = apply_filters(*a)
|
| 935 |
return x, path
|
| 936 |
|
| 937 |
+
scan_btn.click(do_scan, [force, perp, nuni, extra] + PI + FI, [table, csv, status])
|
| 938 |
f_btn.click(do_filter, FI, [table, csv])
|
| 939 |
|
| 940 |
+
with gr.Tab("🔍 Asset Analysis"):
|
| 941 |
with gr.Row():
|
| 942 |
+
t1 = gr.Textbox(value="BTC", label="Parite (örn: BTC, ETH-USDT)", scale=2)
|
| 943 |
b1 = gr.Button("Analyze", variant="primary")
|
| 944 |
md1 = gr.Markdown()
|
| 945 |
with gr.Row():
|
|
|
|
| 951 |
A, err = get_A(t, P)
|
| 952 |
if err:
|
| 953 |
return err, None, None
|
| 954 |
+
d = A["d"]
|
| 955 |
+
return md_report(A, P), price_fig(d, {"Fair value": A["iv"], "VWAP365": d.get("vwap365"), "SMA200": d.get("sma200")}), radar_fig(A)
|
| 956 |
|
| 957 |
b1.click(company, [t1] + PI, [md1, p1, r1])
|
| 958 |
|
| 959 |
+
with gr.Tab("🧮 Fair Value Calculator"):
|
| 960 |
with gr.Row():
|
| 961 |
+
t2 = gr.Textbox(value="ETH", label="Parite", scale=2)
|
| 962 |
b2 = gr.Button("Calculate", variant="primary")
|
| 963 |
md2 = gr.Markdown()
|
| 964 |
p2 = gr.Plot()
|
|
|
|
| 971 |
d = A["d"]
|
| 972 |
g0, w, tg = assumptions(P)
|
| 973 |
L = [f"### {d['sym']} — {d['seg']}",
|
| 974 |
+
f"**Varsayımlar [A]:** yıl-1 nominal drift {fp(g0)} (reel {fp(P['rg'])}, enflasyon geçişi %{P['pt'] * 100:.0f}) · "
|
| 975 |
+
f"nominal iskonto {fp(w)} · nominal terminal drift {fp(tg)}",
|
| 976 |
+
f"**Fiyat çıpaları [D]:** Log-trend {fpx(d.get('trend_fair'))} · VWAP365 {fpx(d.get('vwap365'))} · SMA200 {fpx(d.get('sma200'))} "
|
| 977 |
+
f"→ DEP çıpası (medyan) **{fpx(anchor_of(d))}**",
|
| 978 |
+
f"**Trend [D]:** yıllık log-trend drift {fp(d.get('trend_g'))} · fiyatın trende göre sapması z={fm(d.get('trend_z'))}σ"]
|
|
|
|
|
|
|
|
|
|
| 979 |
L.append("\n| Yöntem | Değer | Not |\n|---|---|---|")
|
| 980 |
for k, (v, n) in A["methods"].items():
|
| 981 |
+
L.append(f"| {k} | {fpx(v)} | {n} |")
|
| 982 |
+
L.append(f"\n**Fair value (medyan): {fpx(A['iv'])}** · Fiyat {fpx(d['price'])} · MoS {fp(A['mos'])}")
|
| 983 |
names = [k for k, (v, _) in A["methods"].items() if v]
|
| 984 |
vals = [A["methods"][k][0] for k in names]
|
| 985 |
+
f = go.Figure(go.Bar(x=["Price"] + names + ["Fair value"], y=[d["price"]] + vals + [A["iv"] or 0]))
|
| 986 |
f.update_layout(template="plotly_white", height=340, margin=dict(l=10, r=10, t=20, b=10))
|
| 987 |
return "\n".join(L), f
|
| 988 |
|
| 989 |
b2.click(calc, [t2] + PI, [md2, p2])
|
| 990 |
|
| 991 |
+
with gr.Tab("↩️ Reverse Valuation"):
|
| 992 |
with gr.Row():
|
| 993 |
+
t3 = gr.Textbox(value="SOL", label="Parite", scale=2)
|
| 994 |
b3 = gr.Button("Solve", variant="primary")
|
| 995 |
md3 = gr.Markdown()
|
| 996 |
tb3 = gr.Dataframe(interactive=False)
|
|
|
|
| 1005 |
if rows is None:
|
| 1006 |
return f"**{d['sym']}**: ⚠️ {note}", None
|
| 1007 |
g0, _, _ = assumptions(P)
|
| 1008 |
+
md = (f"**{d['sym']}** fiyat {fpx(d['price'])} → piyasanın fiyatladığı yıl-1 drift (5 yıl, terminale doğru sönümlenir; çıpa {fpx(anchor_of(d))}).\n\n"
|
| 1009 |
+
f"Mevcut varsayım yıl-1 drift: {fp(g0)} · Tarihsel log-trend drift (yıllık): {fp(d.get('trend_g'))} · "
|
| 1010 |
+
f"Son 365g getiri: {fp(d.get('ret365'))}\n\n"
|
| 1011 |
+
"Not: Zorunlu bir 'doğru' yoktur; implied drift makul görünmüyorsa piyasa beklentisi ya aşırı iyimser ya da kötümserdir.")
|
|
|
|
| 1012 |
return md, pd.DataFrame(rows)
|
| 1013 |
|
| 1014 |
b3.click(rev, [t3] + PI, [md3, tb3])
|
| 1015 |
|
| 1016 |
+
with gr.Tab("💎 Market Quality"):
|
| 1017 |
with gr.Row():
|
| 1018 |
+
t4 = gr.Textbox(value="BTC", label="Parite", scale=2)
|
| 1019 |
b4 = gr.Button("Show", variant="primary")
|
| 1020 |
md4 = gr.Markdown()
|
| 1021 |
tb4 = gr.Dataframe(interactive=False)
|
|
|
|
| 1028 |
return err, None, None
|
| 1029 |
q, d = A["q"], A["d"]
|
| 1030 |
pc = lambda x: fp(x)
|
| 1031 |
+
rows = [("Return 30d [D]", pc(q["ret30"])), ("Return 90d [D]", pc(q["ret90"])), ("Return 365d [D]", pc(q["ret365"])),
|
| 1032 |
+
("Return 365d (reel) [D]", pc(real_ret(q["ret365"], P["infl"]))),
|
| 1033 |
+
("Volatility 30d (ann.) [D]", pc(q["vol30"])), ("Volatility 90d (ann.) [D]", pc(q["vol90"])),
|
| 1034 |
+
("Volatility 365d (ann.) [D]", pc(q["vol365"])), ("Sharpe 1Y (rf=0) [D]", fm(q["sharpe"])),
|
| 1035 |
+
("Sortino 1Y [D]", fm(q["sortino"])), ("Max drawdown 1Y [D]", pc(q["maxdd"])),
|
| 1036 |
+
("Drawdown from 1Y high [D]", pc(q["dd_high"])), ("SMA50 / SMA200 gap [D]", pc(q["sma_gap"])),
|
| 1037 |
+
("Trend z-score [D]", fm(q["trend_z"])), ("Spread (bps) [R]", fm(q["spread_bps"], 1)),
|
| 1038 |
+
("Avg daily volume 30d (M USDT) [R]", fm(None if q["vq30"] is None else q["vq30"] / 1e6, 1)),
|
| 1039 |
+
("Volume 30d / 90d [D]", fm(q["vol_ratio"])), ("Volume variation (CV) [D]", fm(q["vcv"])),
|
| 1040 |
+
("Worst day (90d) [D]", pc(q["worst90"])), ("Daily VaR95 (90d) [D]", pc(q["var95"])),
|
| 1041 |
+
("Funding (8h) [R]", fp(d["funding"], 4)), ("History (days) [R]", fm(q["hist_days"], 0))]
|
| 1042 |
sc, n, comps = A["pio"]
|
| 1043 |
cp = [(c, "N/A" if v is None else ("1" if v else "0")) for c, v in comps]
|
| 1044 |
+
md = (f"**{d['sym']}** — Market health: **{sc}/{n}** (hesaplanabilen {n}/9)" +
|
| 1045 |
("" if n == 9 else " ⚠️ eksik bileşen var; skor tam değildir.") +
|
| 1046 |
+
(" Perp bulunmayan varlıklarda funding bileşeni N/A olur." if d["funding"] is None else ""))
|
| 1047 |
+
return md, pd.DataFrame(rows, columns=["Metric", "Value"]), pd.DataFrame(cp, columns=["Health component", "Result"])
|
| 1048 |
|
| 1049 |
b4.click(qual, [t4] + PI, [md4, tb4, tb4b])
|
| 1050 |
|
| 1051 |
with gr.Tab("🛡️ Risk Analysis"):
|
| 1052 |
with gr.Row():
|
| 1053 |
+
t5 = gr.Textbox(value="DOGE", label="Parite", scale=2)
|
| 1054 |
b5 = gr.Button("Assess", variant="primary")
|
| 1055 |
md5 = gr.Markdown()
|
| 1056 |
tb5 = gr.Dataframe(interactive=False)
|
|
|
|
| 1066 |
|
| 1067 |
b5.click(risk_tab, [t5] + PI, [md5, tb5])
|
| 1068 |
|
| 1069 |
+
with gr.Tab("🏭 Category Comparison"):
|
| 1070 |
with gr.Row():
|
| 1071 |
+
t6 = gr.Textbox(value="SOL", label="Parite (opsiyonel)", scale=2)
|
| 1072 |
b6 = gr.Button("Compare", variant="primary")
|
| 1073 |
md6 = gr.Markdown()
|
| 1074 |
tb6 = gr.Dataframe(interactive=False)
|
|
|
|
| 1078 |
P = mk_params(*a)
|
| 1079 |
ds = cached_ds()
|
| 1080 |
if not ds:
|
| 1081 |
+
return "Önce Market Scanner'ı çalıştırın (medyanlar önbellekteki paritelerden hesaplanır).", None, None
|
| 1082 |
med = build_medians(ds)
|
| 1083 |
rows = []
|
| 1084 |
+
for seg, m in sorted(med.items()):
|
| 1085 |
+
r = {"Segment": seg, "Peers": sum(1 for d in ds if d["seg"] == seg)}
|
| 1086 |
+
for f in MULTS:
|
| 1087 |
v, n = m[f]
|
| 1088 |
r[f] = None if v is None else round(v, 3)
|
| 1089 |
rows.append(r)
|
| 1090 |
+
fig, md = None, (f"Kategori medyanı için en az {MIN_PEERS} geçerli peer gerekir; yetersizse `None`. "
|
| 1091 |
+
"p_vwap = fiyat/VWAP365, p_sma200 = fiyat/SMA200, p_trend = fiyat/log-trend, p_high = fiyat/1Y zirve.")
|
| 1092 |
d = fetch(norm_sym(t)) if t.strip() else None
|
| 1093 |
if d:
|
| 1094 |
+
m = med.get(d["seg"])
|
| 1095 |
+
keys = ["p_vwap", "p_sma200", "p_trend", "p_high"]
|
| 1096 |
cv_ = [d[k] for k in keys]
|
| 1097 |
mv = [m[k][0] if m else None for k in keys]
|
| 1098 |
+
fig = go.Figure([go.Bar(name=d["sym"], x=keys, y=cv_), go.Bar(name="Category median", x=keys, y=mv)])
|
| 1099 |
fig.update_layout(barmode="group", template="plotly_white", height=340, margin=dict(l=10, r=10, t=20, b=10))
|
| 1100 |
md = f"**{d['sym']}** — {d['seg']} / {d['sector']}. " + md
|
| 1101 |
return md, pd.DataFrame(rows), fig
|
|
|
|
| 1104 |
|
| 1105 |
with gr.Tab("🎯 12-Month Scenarios"):
|
| 1106 |
with gr.Row():
|
| 1107 |
+
t7 = gr.Textbox(value="BTC", label="Parite", scale=2)
|
| 1108 |
pb = gr.Slider(0, 100, 25, step=5, label="Bear olasılığı (%)")
|
| 1109 |
pm = gr.Slider(0, 100, 50, step=5, label="Base olasılığı (%) — Bull = kalan")
|
| 1110 |
b7 = gr.Button("Run", variant="primary")
|
|
|
|
| 1122 |
return err, None, None
|
| 1123 |
d, e = A["d"], A["exp"]
|
| 1124 |
rows = [{"Scenario": o["Scenario"], "Real growth": o["Real growth"], "Real disc. rate": o["Real disc. rate"],
|
| 1125 |
+
"Multiple factor": o["Multiple factor"], "Fair value": rsig(o["Intrinsic"]),
|
| 1126 |
+
"12M target": rsig(o["Target"]), "Probability": f"{o['Prob'] * 100:.0f}%"} for o in A["scen"]]
|
|
|
|
| 1127 |
if e is None:
|
| 1128 |
+
return f"**{d['sym']}**: ⚠️ Senaryo üretilemedi (fair value hesaplanamadı).", pd.DataFrame(rows), None
|
| 1129 |
+
md = (f"**{d['sym']}** · Olasılık-ağırlıklı hedef **{fpx(e['pw'])}** · Beklenen nominal fiyat getirisi {fp(e['nominal'])} · "
|
| 1130 |
+
f"Reel getiri {fp(e['real'])} (spot: temettü/gelir yok, getiri yalnızca fiyat)\n\n"
|
|
|
|
| 1131 |
f"*Heuristik: hedef = fiyat + k×(senaryo değeri − fiyat), k={P['conv'] * 100:.0f}%, çarpan faktörü uygulanır. "
|
| 1132 |
+
"İstatistiksel olarak doğrulanmış tahmin değildir.*")
|
| 1133 |
f = go.Figure(go.Bar(x=["Price", "Bear", "Base", "Bull", "Prob-weighted"],
|
| 1134 |
y=[d["price"]] + [o["Target"] for o in A["scen"]] + [e["pw"]]))
|
| 1135 |
f.update_layout(template="plotly_white", height=340, margin=dict(l=10, r=10, t=20, b=10))
|
|
|
|
| 1143 |
tb8 = gr.Dataframe(interactive=False, wrap=True)
|
| 1144 |
|
| 1145 |
def dq(progress=gr.Progress()):
|
| 1146 |
+
uni = LAST["universe"] or build_universe(DEFAULT_UNIVERSE_N)[0]
|
| 1147 |
rows, failed = [], []
|
| 1148 |
+
for i, s in enumerate(uni):
|
| 1149 |
+
progress((i + 1) / len(uni), desc=f"Checking {s}")
|
| 1150 |
was = _cget(f"t_{s}") is not None
|
| 1151 |
d = fetch(s)
|
| 1152 |
if d is None:
|
| 1153 |
failed.append(s)
|
| 1154 |
continue
|
| 1155 |
c = coverage(d)
|
| 1156 |
+
rows.append({"Pair": d["sym"], "Segment": d["seg"], "Coverage %": round(c * 100),
|
| 1157 |
+
"Last candle": d["last_dt"].date().isoformat() if d["last_dt"] is not None else "N/A",
|
| 1158 |
+
"Age (h)": round(d["age_h"]), "History (days)": d["hist_days"],
|
| 1159 |
+
"Status": "OK" if c >= 0.7 and not d["flags"] else "CHECK",
|
| 1160 |
+
"Flags": "; ".join(d["flags"] + d["notes"]) or "—"})
|
| 1161 |
if not was:
|
| 1162 |
time.sleep(REQUEST_DELAY_SEC * 0.5)
|
| 1163 |
df = pd.DataFrame(rows)
|
|
|
|
| 1176 |
|
| 1177 |
|
| 1178 |
METHODOLOGY = """
|
| 1179 |
+
### Veri
|
| 1180 |
+
- **Kaynak:** yalnızca OKX public API (anahtar gerekmez): spot ticker (fiyat, bid/ask, 24s hacim), 500 günlük **günlük mumlar** (UTC), perp **funding rate** ve **open interest**.
|
| 1181 |
+
- **Evren:** 24s USDT hacmine göre ilk N OKX spot USDT paritesi (stablecoin ve kaldıraçlı tokenlar hariç, min 1M USDT/24s) + eklediğiniz pariteler. Ticker listesi alınamazsa gömülü yedek liste kullanılır.
|
| 1182 |
+
- Metrikler yalnızca **kapanmış** günlük mumlardan hesaplanır; canlı fiyat ticker'dan gelir.
|
| 1183 |
+
|
| 1184 |
+
### Değerleme mimarisi (fundamental yoktur → fiyat-bazlı fair value)
|
| 1185 |
+
- **Log-Trend Regression:** son ≤500 günün ln(fiyat) doğrusal regresyonunun bugünkü değeri; yıllık trend drift'i ve fiyatın trendden σ cinsinden sapması (z) raporlanır.
|
| 1186 |
+
- **VWAP 365D:** hacim-ağırlıklı ortalama fiyat (yatırımcı maliyet bazı vekili). **SMA 200D:** uzun vadeli ortalama.
|
| 1187 |
+
- **Discounted Expected Price (DEP):** DCF'in nakit akışsız karşılığı. Çıpa = (trend, VWAP, SMA200) medyanı; 5 yıl boyunca yıl-1 drift'inden terminal drift'e doğrusal sönen büyüme uygulanır, gereken getiri (iskonto) ile bugüne indirgenir. Çıpa eksikse veya iskonto ≤ terminal drift ise **hesaplanmaz**.
|
| 1188 |
+
- **Relative:** kategori medyanı (en az 5 geçerli peer) p_vwap, p_sma200, p_trend çarpanları × varlığın kendi VWAP/SMA200/trend değeri.
|
| 1189 |
+
- **Fair value = mevcut yöntemlerin medyanı.** MoS = (FV − fiyat)/FV. Bu bir içsel değer değil, istatistiksel referanstır.
|
| 1190 |
+
- **Reverse Valuation:** fiyatı veren yıl-1 drift (bisection), iskonto oranı ±3 puan duyarlılığı.
|
| 1191 |
+
- **Enflasyon:** iskonto ve terminal drift reel girilir, nominale çevrilir; enflasyonun yalnızca ayarlanabilir bir kısmı drift'e yansır. Reel getiri = (1+nominal)/(1+enflasyon) − 1.
|
| 1192 |
+
- **Senaryolar:** Bear/Base/Bull = reel drift ∓4 puan, reel iskonto +3/−2 puan, çarpan faktörü 0.85/1.0/1.10; hedef = fiyat + k×(değer − fiyat). Olasılıklar ayarlanabilir, toplam %100 (Bull = kalan). Spot'ta temettü/gelir olmadığından getiri yalnızca fiyattır.
|
| 1193 |
|
| 1194 |
### Skorlama (0-100, açıklanabilir)
|
| 1195 |
Composite = ağırlıklı ortalama (mevcut bileşenler üzerinden yeniden normalize) × min(1, coverage/0.7).
|
| 1196 |
| Segment | Valuation | Quality | Strength | Risk (ters) |
|
| 1197 |
|---|---|---|---|---|
|
| 1198 |
+
| Majors | 30 | 25 | 25 | 20 |
|
| 1199 |
+
| Meme | 25 | 15 | 20 | 40 |
|
| 1200 |
+
| Diğer (Alt L1, DeFi, Infra/L2, AI/Gaming, Other) | 30 | 25 | 20 | 25 |
|
|
|
|
| 1201 |
|
| 1202 |
+
- **Valuation:** MoS, göreli MoS, trend z-score, fiyat/SMA200, 1Y zirveden iskonto.
|
| 1203 |
+
- **Quality:** reel 365g getiri, Sharpe, Sortino, max drawdown, reel 90g getiri, hacim istikrarı.
|
| 1204 |
+
- **Strength:** likidite (30g ort. hacim), spread, geçmiş uzunluğu, volatilite rejimi, market health skoru.
|
| 1205 |
+
- **Market Health (9 bileşen, Piotroski benzeri):** fiyat>SMA200, SMA50>SMA200, 90g getiri>0, volatilite düşüyor, hacim küçülmüyor, zirveden düşüş<%40, Sharpe>0, spread<10bps, funding kalabalık değil. Hesaplanamayan bileşen "N/A" olur ve skor `x/n` gösterilir.
|
| 1206 |
+
- **Risk (0-100):** 11 bayrak (aşırı volatilite, derin drawdown, düşüş trendi, ince likidite, geniş spread, hacim çöküşü, crash günü, VaR95, aşırı uzama, funding kalabalığı, veri güvenilirliği); yalnızca değerlendirilebilen bayrakların ağırlığı üzerinden hesaplanır.
|
| 1207 |
|
| 1208 |
### Bilinen veri sınırlamaları
|
| 1209 |
+
- Fundamental/on-chain/market-cap verisi yoktur; fair value yalnızca fiyat-hacim geçmişine dayanır ve geçmiş trendin sürmesini ima etmez.
|
| 1210 |
+
- Yeni listelenen paritelerde geçmiş kısadır (SMA200/VWAP/trend hesaplanamayabilir → N/A, düşük coverage).
|
| 1211 |
+
- Kategori sınıflaması gömülü bir haritaya dayanır; haritada olmayan varlıklar "Other" olur. Peer sayısı <5 ise medyan hesaplanmaz.
|
| 1212 |
+
- OKX public API hız limitine tabidir; tarama sırasında istekler yavaşlatılır, 429/50011 hatalarında tekrar denenir.
|
| 1213 |
- Sonuçlar araştırma/eğitim amaçlıdır, yatırım tavsiyesi değildir.
|
| 1214 |
"""
|
| 1215 |
|