GoshawkVortexAI commited on
Commit
ccef12a
·
verified ·
1 Parent(s): 12dc8ff

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +604 -541
app.py CHANGED
@@ -1,63 +1,68 @@
1
  """
2
- US Value Intelligence v3 — Compact Value & Intrinsic Valuation Platform
3
- =========================================================================
4
- Data integrity > model complexity. Intrinsic value > momentum. Margin of safety > prediction.
5
-
6
- Veri etiketleri: [R] raporlanan [D] türetilen [A] kullanıcı varsayımı
7
- UYARI: yfinance verisi point-in-time DEĞİLDİR; backtest için kullanılamaz.
 
 
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 yfinance as yf
21
 
22
  logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
23
- logger = logging.getLogger("us_v3")
24
-
25
- US_UNIVERSE = [
26
- # Teknoloji
27
- "AAPL", "MSFT", "NVDA", "GOOGL", "META", "AVGO", "ORCL", "ADBE", "CRM", "CSCO",
28
- "INTC", "AMD", "QCOM", "TXN", "IBM", "INTU", "AMAT", "MU", "NOW", "ACN",
29
- # Tüketici
30
- "AMZN", "TSLA", "HD", "MCD", "NKE", "SBUX", "LOW", "TJX", "COST", "WMT",
31
- "PG", "KO", "PEP", "PM", "MO", "MDLZ", "CL", "TGT",
32
- # Sağlık
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
- LAST: Dict[str, Any] = {"df": None, "failed": []}
 
 
 
 
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
- t = (t or "").strip().upper().replace(".", "-") # Yahoo: BRK.B -> BRK-B
138
- return t
 
 
 
 
 
 
 
 
 
 
 
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 & INTEGRITY
147
  # ============================================================================
148
 
149
- STMT = {
150
- "ni": (("Net Income", "Net Income Common Stockholders"), "fin"),
151
- "rev": (("Total Revenue", "Operating Revenue"), "fin"),
152
- "gp": (("Gross Profit",), "fin"),
153
- "opinc": (("Operating Income", "EBIT"), "fin"),
154
- "ebitda": (("EBITDA", "Normalized EBITDA"), "fin"),
155
- "interest": (("Interest Expense", "Interest Expense Non Operating"), "fin"),
156
- "tax": (("Tax Provision",), "fin"),
157
- "pretax": (("Pretax Income",), "fin"),
158
- "ta": (("Total Assets",), "bs"),
159
- "eq": (("Stockholders Equity", "Common Stock Equity", "Total Equity Gross Minority Interest"), "bs"),
160
- "debt": (("Total Debt",), "bs"),
161
- "ltd": (("Long Term Debt", "Long Term Debt And Capital Lease Obligation"), "bs"),
162
- "cash": (("Cash Cash Equivalents And Short Term Investments", "Cash And Cash Equivalents"), "bs"),
163
- "ca": (("Current Assets",), "bs"),
164
- "cl": (("Current Liabilities",), "bs"),
165
- "shares": (("Ordinary Shares Number", "Share Issued"), "bs"),
166
- "cfo": (("Operating Cash Flow", "Cash Flow From Continuing Operating Activities"), "cf"),
167
- "capex": (("Capital Expenditure",), "cf"),
168
- "fcf": (("Free Cash Flow",), "cf"),
169
- "da": (("Depreciation And Amortization", "Depreciation Amortization Depletion"), "cf"),
170
- "dwc": (("Change In Working Capital",), "cf"),
171
- }
172
 
173
 
174
- def stmt(df, names, i=0) -> Optional[float]:
175
- if df is None or getattr(df, "empty", True) or i >= df.shape[1]:
176
- return None
177
- for n in names:
178
- if n in df.index:
179
- r = df.loc[n]
180
- if isinstance(r, pd.DataFrame):
181
- r = r.iloc[0]
182
- return num(r.iloc[i])
183
- return None
184
 
185
 
186
- def segment_of(industry: str, sym: str) -> str:
187
- ind = (industry or "").lower()
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 fetch(symbol: str, force: bool = False) -> Optional[Dict[str, Any]]:
198
- key = f"t_{symbol}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  if not force:
200
  c = _cget(key)
201
  if c is not None:
202
  return c
203
  try:
204
- t = yf.Ticker(symbol)
205
- info = t.info or {}
206
- fin, bs, cf = t.financials, t.balance_sheet, t.cashflow
207
- try:
208
- dv = t.dividends
209
- except Exception:
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", symbol, e)
217
  return None
218
- if price is None or price <= 0:
 
 
 
 
 
 
219
  return None
220
 
221
  flags: List[str] = []
222
- dfs = {"fin": fin, "bs": bs, "cf": cf}
223
- s = {k: [stmt(dfs[src], names, i) for i in range(3)] for k, (names, src) in STMT.items()}
224
- s["capex"] = [abs(x) if x is not None else None for x in s["capex"]]
225
- s["interest"] = [abs(x) if x is not None else None for x in s["interest"]]
226
- for i in range(3):
227
- if s["fcf"][i] is None and s["cfo"][i] is not None and s["capex"][i] is not None:
228
- s["fcf"][i] = s["cfo"][i] - s["capex"][i]
229
- if s["ebitda"][i] is None and s["opinc"][i] is not None and s["da"][i] is not None:
230
- s["ebitda"][i] = s["opinc"][i] + s["da"][i]
231
- if s["debt"][0] is None:
232
- s["debt"][0] = num(info.get("totalDebt"))
233
- if s["debt"][0] is not None:
234
- flags.append("debt from info (not statement)")
235
- if s["cash"][0] is None:
236
- s["cash"][0] = num(info.get("totalCash"))
237
- if s["cash"][0] is not None:
238
- flags.append("cash from info (not statement)")
239
-
240
- # period / currency / unit validation
241
- sd = None
242
- try:
243
- sd = pd.Timestamp(fin.columns[0]).to_pydatetime() if fin is not None and not fin.empty else None
244
- for nm, df in (("balance sheet", bs), ("cash flow", cf)):
245
- if df is not None and not df.empty and sd is not None and pd.Timestamp(df.columns[0]).to_pydatetime() != sd:
246
- flags.append(f"{nm} period != income statement period")
247
- except Exception:
248
- pass
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": symbol, "name": info.get("shortName") or symbol, "price": price, "mcap": mcap,
320
- "shares": shares, "sector": info.get("sector") or "Unknown", "industry": industry, "seg": seg,
321
- "bvps": bvps, "eps": eps, "dy": dy, "s": s, "flags": flags, "unit_ok": unit_ok, "stmt_date": sd, "age": age,
322
- "tax_rate": tr, "tax_assumed": tax_assumed, "net_debt": net_debt, "fcff": fcff, "fcf0": fcf0, "oe": oe,
323
- "oe_parts": oe_parts, "ev": ev, "sust_roe": sust_roe,
324
- "pe": price / eps if eps and eps > 0 else None,
325
- "fpe": num(info.get("forwardPE")) if (num(info.get("forwardPE")) or 0) > 0 else None,
326
- "pb": price / bvps if bvps and bvps > 0 else None,
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 = ["pe", "fpe", "pb", "ev_ebitda", "ev_fcf", "ps", "fcf_yield", "earn_yield"]
341
- POS_ONLY = {"pe", "fpe", "pb", "ev_ebitda", "ev_fcf", "ps"}
342
 
343
 
344
- def build_medians(ds) -> Dict[Tuple[str, str], Dict[str, Tuple[Optional[float], int]]]:
345
- groups: Dict[Tuple[str, str], list] = {}
346
  for d in ds:
347
- groups.setdefault((d["seg"], d["sector"]), []).append(d)
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 dcf_value(base, g0, wacc, tg, shares, net_debt, years=5) -> Optional[Dict[str, float]]:
363
- """5-year explicit DCF, growth fades linearly g0 -> tg. Returns None if inputs insufficient."""
364
- if None in (base, g0, wacc, tg, shares, net_debt) or base <= 0 or shares <= 0 or wacc <= tg:
 
365
  return None
366
- flows, f = [], base
367
  for t in range(1, years + 1):
368
  g = g0 + (tg - g0) * (t - 1) / (years - 1)
369
- f *= 1 + g
370
- flows.append(f)
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 dcf_try(base, g0, wacc, tg, shares, nd, ok):
386
- if not ok:
387
- return None, "currency/unit mismatch"
388
- if base is None:
389
- return None, "essential inputs missing"
390
- if base <= 0:
391
- return None, "non-positive base cash flow"
392
- if not shares or shares <= 0:
393
- return None, "shares unavailable"
394
- if nd is None:
395
- return None, "net debt unavailable"
396
  if wacc <= tg:
397
- return None, "discount rate <= terminal growth"
398
- r = dcf_value(base, g0, wacc, tg, shares, nd)
399
- if r is None or r["per_share"] <= 0:
400
- return None, "equity value <= 0"
401
- return r["per_share"], f"TV = {r['tv_share'] * 100:.0f}% of EV"
402
 
403
 
404
  def relative_value(d, med) -> Tuple[Optional[float], str]:
405
- m = med.get((d["seg"], d["sector"]))
406
- if not m or not d["unit_ok"]:
407
- return None, "no peer group / unit mismatch"
408
- seg, vals = d["seg"], []
409
- if m["pb"][0] and d["bvps"] and d["bvps"] > 0:
410
- vals.append(m["pb"][0] * d["bvps"])
411
- if seg != "REIT" and m["pe"][0] and d["eps"] and d["eps"] > 0:
412
- vals.append(m["pe"][0] * d["eps"])
413
- if seg == "Industrial" and m["ev_ebitda"][0] and (d["s"]["ebitda"][0] or 0) > 0 and d["net_debt"] is not None and d["shares"]:
414
- vals.append((m["ev_ebitda"][0] * d["s"]["ebitda"][0] - d["net_debt"]) / d["shares"])
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)} sector-median multiple(s)"
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
- seg, bv = d["seg"], d["bvps"]
425
- if seg in ("Bank", "Insurance"):
426
- roe = d["sust_roe"]
427
- if d["unit_ok"] and roe is not None and bv and bv > 0 and wacc > tg:
428
- m["Justified P/B"] = (max((roe - tg) / (wacc - tg), 0) * bv, f"sust. ROE {fp(roe)}, COE {fp(wacc)}, g {fp(tg)}")
429
- else:
430
- m["Justified P/B"] = (None, "ROE/book value unavailable")
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
- if d["seg"] in ("Bank", "Insurance"):
444
- return None, "FCF-based reverse DCF not applicable to banks/insurers"
445
- base, shares, nd = d["fcff"], d["shares"], d["net_debt"]
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: dcf_value(base, g, wacc, tg, shares, nd)["per_share"]
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 WACC": fp(w), "Implied FCF growth (nominal, yr1)": fp(g),
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
- dy = d["dy"] or 0.0
486
- tot = nom + dy
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, PIOTROSKI, RISK
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
- s, q = d["s"], {}
511
- ni0, eq0, ta0, rev0 = s["ni"][0], s["eq"][0], s["ta"][0], s["rev"][0]
512
- q["roe"] = div(ni0, eq0) if (eq0 or 0) > 0 else None
513
- q["roa"] = div(ni0, ta0)
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 piotroski(d):
536
- s = d["s"]
537
-
538
  def cmp(a, b, op):
539
  return None if a is None or b is None else op(a, b)
540
 
541
- roa = [div(s["ni"][i], s["ta"][i]) for i in (0, 1)]
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 Net income > 0", cmp(s["ni"][0], 0, lambda a, b: a > b)),
548
- ("2 CFO > 0", cmp(s["cfo"][0], 0, lambda a, b: a > b)),
549
- ("3 ROA improved", cmp(roa[0], roa[1], lambda a, b: a > b)),
550
- ("4 CFO > net income", cmp(s["cfo"][0], s["ni"][0], lambda a, b: a > b)),
551
- ("5 Leverage decreased", cmp(lev[0], lev[1], lambda a, b: a < b)),
552
- ("6 Current ratio improved", cmp(cr[0], cr[1], lambda a, b: a > b)),
553
- ("7 No share dilution", cmp(s["shares"][0], s["shares"][1], lambda a, b: a <= b * 1.005)),
554
- ("8 Gross margin improved", cmp(gm[0], gm[1], lambda a, b: a > b)),
555
- ("9 Asset turnover improved", cmp(at[0], at[1], lambda a, b: a > b)),
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
- add("Financial distress", 12, anyc(None if s["eq"][0] is None else s["eq"][0] <= 0,
570
- None if q["icov"] is None else q["icov"] < 1.5,
571
- None if (ni0 is None or s["cfo"][0] is None) else (ni0 < 0 and s["cfo"][0] < 0)),
572
- "equity<=0, interest cover<1.5, or NI<0 & CFO<0")
573
- lev = anyc(None if q["eq_ta"] is None else q["eq_ta"] < 0.06) if bank else anyc(
574
- None if q["nd_ebitda"] is None else q["nd_ebitda"] > 4, None if q["de"] is None else q["de"] > 200)
575
- add("Leverage", 12, lev, "bank: equity/assets<6%; else ND/EBITDA>4 or D/E>200%")
576
- add("Liquidity", 10, None if bank else (None if q["cr"] is None else q["cr"] < 1), "current ratio < 1")
577
- add("Negative FCF", 10, None if bank else (None if s["fcf"][0] is None else s["fcf"][0] < 0), "FCF < 0")
578
- ed = None
579
- if ni0 is not None and ni1 is not None:
580
- ed = ni0 < 0 or (ni1 > 0 and ni0 < 0.8 * ni1)
581
- add("Earnings deterioration", 10, ed, "NI < 0 or down >20% y/y")
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 = {"Industrial": (.35, .30, .20, .15), "Bank": (.40, .35, .10, .15),
604
- "Insurance": (.40, .30, .15, .15), "REIT": (.45, .20, .20, .15)}
605
 
606
 
607
  def coverage(d):
608
- s, seg = d["s"], d["seg"]
609
- f = [d["price"], d["mcap"], d["shares"], d["pb"], d["pe"], s["ni"][0], s["eq"][0], s["ta"][0], d["bvps"]]
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, seg = P["infl"], d["seg"]
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"] = piotroski(d)
625
  A["cov"] = coverage(d)
626
  A["risk"], A["flags"] = risk_engine(d, q, A["cov"])
627
- bank = seg in ("Bank", "Insurance")
628
- rroe, rroic = real_ret(q["roe"], infl), real_ret(q["roic"], infl)
629
- rrev, rni = real_ret(q["rev_g"], infl), real_ret(q["ni_g"], infl)
630
- pe_s = (0.0 if d["eps"] <= 0 else lin(d["pe"], 25, 4)) if d["eps"] is not None else None
631
- pb_s = lin(d["pb"], 4, 0.5) if d["pb"] else (0.0 if d["bvps"] is not None and d["bvps"] <= 0 else None)
632
- vi = [(lin(A["mos"], -0.3, 0.5), 4), (lin(A["rel_mos"], -0.3, 0.5), 2)]
633
- if bank:
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
- e0 = d["s"]["ebitda"][0]
648
- if bank:
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 = (not d["unit_ok"]) or any("stale" in f or "period" in f for f in d["flags"])
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} verisi çekilemedi."
684
  return analyze(d, P, build_medians(cached_ds()), probs), None
685
 
686
 
687
- def price_fig(sym, extra=None):
688
  try:
689
- h = yf.Ticker(norm_sym(sym)).history(period="1y")
690
- if h.empty:
691
  return None
692
- f = go.Figure(go.Scatter(x=h.index, y=h["Close"], mode="lines", name="Close"))
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
- "Ticker": d["sym"], "Segment": d["seg"], "Sector": d["sector"], "Price": round(d["price"], 2),
708
  "Composite": None if A["composite"] is None else round(A["composite"], 1),
709
- "Intrinsic": None if A["iv"] is None else round(A["iv"], 2),
710
  "MoS %": None if A["mos"] is None else round(A["mos"] * 100, 1),
711
- "P/E": None if d["pe"] is None else round(d["pe"], 1), "P/B": None if d["pb"] is None else round(d["pb"], 2),
712
- "ROE %": None if q["roe"] is None else round(q["roe"] * 100, 1),
713
- "ROIC %": None if q["roic"] is None else round(q["roic"] * 100, 1),
714
- "FCF Yield %": None if d["fcf_yield"] is None else round(d["fcf_yield"] * 100, 1),
715
- "Piotroski": f"{sc}/{n} (cov {n}/9)" if n else "N/A",
716
  "Risk": None if A["risk"] is None else round(A["risk"], 0),
717
- "Exp Return % (nom, incl div)": None if not e else round(e["total"] * 100, 1),
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
- syms = US_UNIVERSE + [norm_sym(x) for x in (extra or "").split(",") if x.strip()]
725
- syms = list(dict.fromkeys(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="us_v3_")
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']} — {d['name']}",
760
- f"**{d['seg']}** · {d['sector']} / {d['industry']} · Fiyat **{fm(d['price'])} USD** · Mali tablo: "
761
- f"{d['stmt_date'].date() if d['stmt_date'] else 'N/A'}",
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"**Intrinsic value [D]:** {fm(A['iv'])} USD · Margin of Safety {fp(A['mos'])}"]
766
- L.append("\n| Yöntem | Değer/hisse | Not |\n|---|---|---|")
 
 
767
  for k, (v, n) in A["methods"].items():
768
- L.append(f"| {k} | {fm(v)} | {n} |")
769
  if d["flags"]:
770
  L.append("\n**⚠️ Veri bayrakları:** " + "; ".join(d["flags"]))
771
- L.append("\n*Intrinsic = mevcut yöntemlerin medyanı. Skorlar evren içi göreceli, açıklanabilir heuristiklerdir.*")
 
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 = dcf_value(100, 0, 0.10, 0, 10, 200)
795
- chk("DCF: g=0,tg=0 → EV=1000", r is not None and abs(r["ev"] - 1000) < 1e-6)
796
- chk("EV→Equity: 1000-200=800, 80/share", r is not None and abs(r["equity"] - 800) < 1e-6 and abs(r["per_share"] - 80) < 1e-6)
797
- chk("DCF refuses wacc<=tg", dcf_value(100, 0.1, 0.05, 0.05, 10, 0) is None)
798
- chk("DCF refuses FCF<=0", dcf_value(-5, 0.1, 0.2, 0.05, 10, 0) is None)
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("WACC > terminal growth (defaults)", w > tg)
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 DCF consistency
808
- d = {"fcff": 100.0, "shares": 10.0, "net_debt": 200.0, "price": 80.0, "unit_ok": True, "seg": "Industrial"}
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 DCF: price 80 → implied g≈0", rows is not None and rows[1]["Implied FCF growth (nominal, yr1)"] == "0.0%")
812
- # Piotroski incompleteness
813
- empty = {"s": {k: [None, None, None] for k in STMT}}
814
- sc, n, _ = piotroski(empty)
815
- chk("Piotroski all-missing → 0 available (not 9/9)", n == 0 and sc == 0)
816
  # peer rule
817
- ds = [{"seg": "Industrial", "sector": "X", "pe": 10.0 + i, **{m: None for m in MULTS if m != "pe"}} for i in range(4)]
818
- chk("Sector median needs >=5 peers", build_medians(ds)[("Industrial", "X")]["pe"][0] is None)
819
- ds += [{"seg": "Industrial", "sector": "X", "pe": 20.0, **{m: None for m in MULTS if m != "pe"}}]
820
- chk("Sector median with 5 peers computed", build_medians(ds)[("Industrial", "X")]["pe"][0] == 12.0 or build_medians(ds)[("Industrial", "X")]["pe"][0] is not None)
821
- chk("Segment classification", segment_of("Banks - Regional", "JPM") == "Bank" and segment_of("REIT - Industrial", "PLD") == "REIT"
822
- and segment_of("Insurance - Life", "MET") == "Insurance" and segment_of("Steel", "NUE") == "Industrial")
 
 
 
 
 
 
 
 
 
 
 
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="US Value Intelligence v3", theme=gr.themes.Soft()) as demo:
832
- gr.Markdown("# US Value Intelligence v3\n**⚠️ yfinance verisi point-in-time değildir; backtest için kullanılamaz. "
 
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, 20, 7, step=0.5, label="Reel iskonto oranı (%)")
839
- s_rtg = gr.Slider(0, 5, 1, step=0.25, label="Reel terminal büyüme (%)")
840
  with gr.Row():
841
- s_rg = gr.Slider(-10, 20, 3, step=0.5, label="Baz reel büyüme (%)")
842
- s_pt = gr.Slider(0, 100, 50, step=5, label="Enflasyonun nominal büyümeye geçişi (%)")
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
- extra = gr.Textbox(label="Ek tickerlar (virgülle, örn: BRK-B,AAPL)", value="")
 
 
 
852
  with gr.Row():
853
- f_seg = gr.Dropdown(["All", "Industrial", "Bank", "Insurance", "REIT"], value="All", label="Segment")
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(["Composite", "MoS %", "P/E", "P/B", "ROE %", "ROIC %", "FCF Yield %", "Risk",
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
- df, failed = run_scan(frc, ext, P, progress)
 
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)} hisse tarandı · coverage ort. %{df['Coverage %'].mean():.0f}"
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("🔍 Company Analysis"):
887
  with gr.Row():
888
- t1 = gr.Textbox(value="THYAO", label="Ticker", scale=2)
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
- return md_report(A, P), price_fig(A["d"]["sym"], {"Intrinsic": A["iv"]}), radar_fig(A)
 
901
 
902
  b1.click(company, [t1] + PI, [md1, p1, r1])
903
 
904
- with gr.Tab("🧮 Intrinsic Value Calculator"):
905
  with gr.Row():
906
- t2 = gr.Textbox(value="EREGL", label="Ticker", scale=2)
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 büyüme {fp(g0)} (reel {fp(P['rg'])}, enflasyon geçişi %{P['pt'] * 100:.0f}) · "
920
- f"nominal iskonto {fp(w)} · nominal terminal {fp(tg)}",
921
- f"**Girdiler [R/D]:** FCFF {fm(d['fcff'], 0)} (FCF + faiz×(1−vergi{' varsayım' if d['tax_assumed'] else ''})) · "
922
- f"net borç {fm(d['net_debt'], 0)} · hisse {fm(d['shares'], 0)} · vergi {fp(d['tax_rate'], 0)}"]
923
- if d["oe_parts"]:
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} | {fm(v)} | {n} |")
930
- L.append(f"\n**Intrinsic (medyan): {fm(A['iv'])}** · Fiyat {fm(d['price'])} · MoS {fp(A['mos'])}")
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 + ["Intrinsic"], y=[d["price"]] + vals + [A["iv"] or 0]))
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 DCF"):
940
  with gr.Row():
941
- t3 = gr.Textbox(value="TOASO", label="Ticker", scale=2)
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
- h = d["s"]["fcf"]
957
- hist = f"{fp((h[0] / h[2]) ** 0.5 - 1)}" if (h[0] or 0) > 0 and (h[2] or 0) > 0 else "N/A"
958
- md = (f"**{d['sym']}** fiyat {fm(d['price'])} → piyasanın fiyatladığı FCF büyümesi (5 yıl, terminale doğru sönümlenir).\n\n"
959
- f"Mevcut varsayım yıl-1 büyüme: {fp(g0)} · Tarihsel FCF CAGR (2y, nominal): {hist}\n\n"
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("💎 Financial Quality"):
966
  with gr.Row():
967
- t4 = gr.Textbox(value="BIMAS", label="Ticker", scale=2)
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 = [("ROE [D]", pc(q["roe"])), ("ROE (reel) [D]", pc(real_ret(q["roe"], P["infl"]))), ("ROA [D]", pc(q["roa"])),
981
- ("ROIC [D]", pc(q["roic"])), ("Gross margin [D]", pc(q["gm"])), ("Operating margin [D]", pc(q["opm"])),
982
- ("Net margin [D]", pc(q["npm"])), ("Debt/Equity % [D]", fm(q["de"], 0)), ("Net debt/EBITDA [D]", fm(q["nd_ebitda"])),
983
- ("Current ratio [D]", fm(q["cr"])), ("Interest coverage [D]", fm(q["icov"])), ("FCF/NI [D]", fm(q["fcf_conv"])),
984
- ("FCF variation (CV) [D]", fm(q["fcf_cv"])), ("Revenue growth nominal [D]", pc(q["rev_g"])),
985
- ("Revenue growth real [D]", pc(real_ret(q["rev_g"], P["infl"]))), ("Earnings growth nominal [D]", pc(q["ni_g"])),
986
- ("Earnings growth real [D]", pc(real_ret(q["ni_g"], P["infl"]))), ("Equity/Assets [D]", pc(q["eq_ta"]))]
 
 
 
 
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']}** — Piotroski: **{sc}/{n}** (hesaplanabilen {n}/9)" +
990
  ("" if n == 9 else " ⚠️ eksik bileşen var; skor tam değildir.") +
991
- (" Bankalar için Piotroski sınırlı anlam taşır." if d["seg"] in ("Bank", "Insurance") else ""))
992
- return md, pd.DataFrame(rows, columns=["Metric", "Value"]), pd.DataFrame(cp, columns=["Piotroski component", "Result"])
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="PGSUS", label="Ticker", scale=2)
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("🏭 Sector Comparison"):
1015
  with gr.Row():
1016
- t6 = gr.Textbox(value="EREGL", label="Ticker (opsiyonel)", scale=2)
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 hisselerden hesaplanır).", None, None
1027
  med = build_medians(ds)
1028
  rows = []
1029
- for (seg, sec), m in sorted(med.items()):
1030
- r = {"Segment": seg, "Sector": sec, "Peers": sum(1 for d in ds if d["seg"] == seg and d["sector"] == sec)}
1031
- for f in ("pe", "pb", "ev_ebitda", "ev_fcf", "ps", "fcf_yield", "earn_yield"):
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"Sektör medyanı için en az {MIN_PEERS} geçerli peer gerekir; yetersizse `None`."
 
1036
  d = fetch(norm_sym(t)) if t.strip() else None
1037
  if d:
1038
- m = med.get((d["seg"], d["sector"]))
1039
- keys = ["pe", "pb", "ev_ebitda", "ps"]
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="Sector median", x=keys, y=mv)])
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="AKBNK", label="Ticker", scale=2)
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"], "Intrinsic": None if o["Intrinsic"] is None else round(o["Intrinsic"], 2),
1070
- "12M target": None if o["Target"] is None else round(o["Target"], 2),
1071
- "Probability": f"{o['Prob'] * 100:.0f}%"} for o in A["scen"]]
1072
  if e is None:
1073
- return f"**{d['sym']}**: ⚠️ Senaryo üretilemedi (intrinsic value hesaplanamadı).", pd.DataFrame(rows), None
1074
- md = (f"**{d['sym']}** · Olasılık-ağırlıklı hedef **{fm(e['pw'])}** · Beklenen nominal fiyat getirisi {fp(e['nominal'])} · "
1075
- f"Temettü-dahil nominal {fp(e['total'])}" + ("" if e["dy_known"] else " (temettü verisi yok, 0 sayıldı)") +
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. Temettü yalnızca bir kez eklenir.*")
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(US_UNIVERSE):
1094
- progress((i + 1) / len(US_UNIVERSE), desc=f"Checking {s}")
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({"Ticker": d["sym"], "Segment": d["seg"], "Coverage %": round(c * 100),
1102
- "Statement date": d["stmt_date"].date().isoformat() if d["stmt_date"] else "N/A",
1103
- "Age (days)": d["age"], "Status": "OK" if c >= 0.7 and not d["flags"] else "CHECK",
1104
- "Flags": "; ".join(d["flags"]) or "—"})
 
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
- ### Değerleme mimarisi
1124
- - **DCF (sanayi/REIT dışı):** FCFF = FCF + faiz×(1−vergi) (Yahoo FCF'si faiz sonrası olduğu için borç çift sayılmasın diye). 5 yıl, büyüme yıl-1'den terminale doğrusal sönümlenir; EV − net borç = özsermaye; / hisse. Girdi eksik, FCFF ≤ 0 veya iskonto ≤ terminal büyüme ise **hesaplanmaz**.
1125
- - **Owner Earnings:** NI + D&A − bakım capex + ΔİSermayesi. Bakım capex = min(toplam capex, D&A) **tahminidir**; veri eksikse yöntem kullanılmaz. Özsermaye nakit akışı olduğu için net borç düşülmez.
1126
- - **Banka/Sigorta:** Gordon gerekçeli P/B = (sürdürülebilir ROE − g)/(COE − g) × defter değeri. FCF/EV-EBITDA kullanılmaz. Sermaye yeterliliği/aktif kalitesi Yahoo'da yok.
1127
- - **REIT:** NAV vekili = defter değeri (ekspertiz NAV'ı yok), P/NAV. Doluluk verisi yok.
1128
- - **Relative:** sektör medyanı (en az 5 geçerli peer) P/E, P/B, EV/EBITDA → hisse başına değer.
1129
- - **Intrinsic = mevcut yöntemlerin medyanı.** MoS = (IV − fiyat)/IV.
1130
- - **Reverse DCF:** fiyatı veren yıl-1 FCF büyümesi (bisection), iskonto oranı ±3 puan duyarlılığı.
1131
- - **Enflasyon:** iskonto ve terminal büyüme reel girilir, nominale çevrilir; enflasyonun yalnızca ayarlanabilir bir kısmı (geçiş oranı) kâr büyümesine yansır. Reel getiri = (1+nominal)/(1+enflasyon) − 1.
1132
- - **Senaryolar:** Bear/Base/Bull = reel büyüme ∓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).
 
 
 
 
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
- | Sanayi | 35 | 30 | 20 | 15 |
1139
- | Banka | 40 | 35 | 10 | 15 |
1140
- | Sigorta | 40 | 30 | 15 | 15 |
1141
- | REIT | 45 | 20 | 20 | 15 |
1142
 
1143
- Risk (0-100): 10 bayrak (distress, kaldıraç, likidite, negatif FCF, kâr/marj bozulması, CFO-NI ayrışması, seyrelme, aşırı değerleme, veri güvenilirliği); yalnızca değerlendirilebilen bayrakların ağırlığı üzerinden hesaplanır. Piotroski 9 bileşenli; hesaplanamayan bileşen "N/A" olur ve skor `x/n` olarak gösterilir.
 
 
 
 
1144
 
1145
  ### Bilinen veri sınırlamaları
1146
- - yfinance point-in-time değildir; backtest geçersizdir. Bilanço tarihleri ve kapsam hisseden hisseye değişir.
1147
- - Yıllık tablolar gecikmelidir; Yıllık tablolar GAAP/ADR farklarına ve tek seferlik kalemlere duyarlıdır; yabancı (ADR) şirketlerde para birimi uyuşmazlığı bayraklanır.
1148
- - Evren: gömülü liste + eklediğiniz tickerlar (tüm ABD piyasası Yahoo'dan ücretsiz çekilemez).
1149
- - Sektör/segment sınıflaması Yahoo `industry` alanına dayanır; peer sayısı <5 ise medyan hesaplanmaz.
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