GoshawkVortexAI commited on
Commit
04d8532
·
verified ·
1 Parent(s): b0e33b8

Upload app (16).py

Browse files
Files changed (1) hide show
  1. app (16).py +1145 -0
app (16).py ADDED
@@ -0,0 +1,1145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BIST 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("bist_v3")
24
+
25
+ BIST_UNIVERSE = [
26
+ # Mevcut çekirdek liste
27
+ "AEFES", "AKBNK", "AKSA", "AKSEN", "ALARK", "ARCLK", "ASELS", "ASTOR", "BIMAS", "BRSAN",
28
+ "CCOLA", "CIMSA", "DOAS", "DOHOL", "ECILC", "EKGYO", "ENJSA", "ENKAI", "EREGL", "FROTO",
29
+ "GARAN", "GUBRF", "HEKTS", "ISCTR", "KCHOL", "KRDMD", "MGROS", "ODAS", "PETKM", "PGSUS",
30
+ "SAHOL", "SASA", "SISE", "SKBNK", "TCELL", "THYAO", "TKFEN", "TOASO", "TSKB", "TTKOM",
31
+ "TUPRS", "ULKER", "VAKBN", "VESBE", "YKBNK", "ZOREN", "ISGYO", "TRGYO", "HALKB", "AKGRT",
32
+ "ANSGR", "TURSG", "ANHYT", "RAYSG",
33
+ # Genişletme (>=100 hisse için)
34
+ "AGHOL", "AHGAZ", "ALBRK", "ALFAS", "ARDYZ", "AYDEM", "BAGFS", "BERA", "BIOEN", "BRISA",
35
+ "BRYAT", "BTCIM", "BUCIM", "CANTE", "CWENE", "EGEEN", "ENERY", "EUPWR", "GENIL", "GESAN",
36
+ "GLYHO", "GWIND", "IPEKE", "ISDMR", "ISMEN", "KARSN", "KCAER", "KLSER", "KONTR", "KONYA",
37
+ "KOZAA", "KOZAL", "KTLEV", "LOGO", "MAVI", "MIATK", "MPARK", "OTKAR", "OYAKC", "PSGYO",
38
+ "QUAGR", "REEDR", "SELEC", "SMRTG", "SNGYO", "SOKM", "TABGD", "TAVHL", "TMSN", "TTRAK",
39
+ "TKNSA", "TUKAS", "ULUUN", "VAKKO", "VESTL", "YEOTK", "ZRGYO", "ECZYT", "EBEBK", "FENER",
40
+ "CLEBI", "DOCO", "EUREN", "INVEO", "KMPUR", "LMKDC", "NTHOL", "ONCSM", "PASEU", "TRMET",
41
+ ]
42
+ BIST_UNIVERSE = list(dict.fromkeys(BIST_UNIVERSE)) # tekrarları temizle
43
+ assert len(BIST_UNIVERSE) >= 100, f"Evren {len(BIST_UNIVERSE)} hisse; en az 100 olmalı"
44
+ BIST_UNIVERSE = [s + ".IS" for s in BIST_UNIVERSE]
45
+
46
+ REQUEST_DELAY_SEC = 0.3
47
+ CACHE_TTL_HOURS = 6
48
+ MIN_PEERS = 5
49
+ _cache: Dict[str, Tuple[datetime, Any]] = {}
50
+ LAST: Dict[str, Any] = {"df": None, "failed": []}
51
+
52
+
53
+ def _cget(k):
54
+ if k in _cache:
55
+ ts, v = _cache[k]
56
+ if datetime.now() - ts < timedelta(hours=CACHE_TTL_HOURS):
57
+ return v
58
+ return None
59
+
60
+
61
+ def _cset(k, v):
62
+ _cache[k] = (datetime.now(), v)
63
+
64
+
65
+ # ============================================================================
66
+ # SMALL HELPERS
67
+ # ============================================================================
68
+
69
+ def num(x) -> Optional[float]:
70
+ try:
71
+ if x is None:
72
+ return None
73
+ v = float(x)
74
+ return None if (np.isnan(v) or np.isinf(v)) else v
75
+ except Exception:
76
+ return None
77
+
78
+
79
+ def div(a, b) -> Optional[float]:
80
+ a, b = num(a), num(b)
81
+ if a is None or b is None or b == 0:
82
+ return None
83
+ return a / b
84
+
85
+
86
+ def growth(cur, prev) -> Optional[float]:
87
+ cur, prev = num(cur), num(prev)
88
+ if cur is None or prev is None or prev <= 0:
89
+ return None
90
+ return cur / prev - 1
91
+
92
+
93
+ def real_ret(nominal, infl) -> Optional[float]:
94
+ return None if nominal is None else (1 + nominal) / (1 + infl) - 1
95
+
96
+
97
+ def lin(x, bad, good) -> Optional[float]:
98
+ """Linear 0-100 scale; works for bad>good as well."""
99
+ if x is None:
100
+ return None
101
+ return float(np.clip((x - bad) / (good - bad), 0, 1) * 100)
102
+
103
+
104
+ def wavg(items) -> Tuple[Optional[float], float]:
105
+ v = [(s, w) for s, w in items if s is not None]
106
+ tw = sum(w for _, w in items)
107
+ if not v or tw == 0:
108
+ return None, 0.0
109
+ sw = sum(w for _, w in v)
110
+ return sum(s * w for s, w in v) / sw, sw / tw
111
+
112
+
113
+ def anyc(*c) -> Optional[bool]:
114
+ v = [x for x in c if x is not None]
115
+ return None if not v else any(v)
116
+
117
+
118
+ def fm(x, d=2):
119
+ return "N/A" if x is None else f"{x:,.{d}f}"
120
+
121
+
122
+ def fp(x, d=1):
123
+ return "N/A" if x is None else f"{x * 100:.{d}f}%"
124
+
125
+
126
+ def norm_sym(t: str) -> str:
127
+ t = (t or "").strip().upper()
128
+ return t if t.endswith(".IS") else t + ".IS"
129
+
130
+
131
+ def mk_params(infl, rw, rtg, rg, pt, cv) -> Dict[str, float]:
132
+ return dict(infl=infl / 100, rw=rw / 100, rtg=rtg / 100, rg=rg / 100, pt=pt / 100, conv=cv / 100)
133
+
134
+
135
+ # ============================================================================
136
+ # DATA ACQUISITION & INTEGRITY
137
+ # ============================================================================
138
+
139
+ STMT = {
140
+ "ni": (("Net Income", "Net Income Common Stockholders"), "fin"),
141
+ "rev": (("Total Revenue", "Operating Revenue"), "fin"),
142
+ "gp": (("Gross Profit",), "fin"),
143
+ "opinc": (("Operating Income", "EBIT"), "fin"),
144
+ "ebitda": (("EBITDA", "Normalized EBITDA"), "fin"),
145
+ "interest": (("Interest Expense", "Interest Expense Non Operating"), "fin"),
146
+ "tax": (("Tax Provision",), "fin"),
147
+ "pretax": (("Pretax Income",), "fin"),
148
+ "ta": (("Total Assets",), "bs"),
149
+ "eq": (("Stockholders Equity", "Common Stock Equity", "Total Equity Gross Minority Interest"), "bs"),
150
+ "debt": (("Total Debt",), "bs"),
151
+ "ltd": (("Long Term Debt", "Long Term Debt And Capital Lease Obligation"), "bs"),
152
+ "cash": (("Cash Cash Equivalents And Short Term Investments", "Cash And Cash Equivalents"), "bs"),
153
+ "ca": (("Current Assets",), "bs"),
154
+ "cl": (("Current Liabilities",), "bs"),
155
+ "shares": (("Ordinary Shares Number", "Share Issued"), "bs"),
156
+ "cfo": (("Operating Cash Flow", "Cash Flow From Continuing Operating Activities"), "cf"),
157
+ "capex": (("Capital Expenditure",), "cf"),
158
+ "fcf": (("Free Cash Flow",), "cf"),
159
+ "da": (("Depreciation And Amortization", "Depreciation Amortization Depletion"), "cf"),
160
+ "dwc": (("Change In Working Capital",), "cf"),
161
+ }
162
+
163
+
164
+ def stmt(df, names, i=0) -> Optional[float]:
165
+ if df is None or getattr(df, "empty", True) or i >= df.shape[1]:
166
+ return None
167
+ for n in names:
168
+ if n in df.index:
169
+ r = df.loc[n]
170
+ if isinstance(r, pd.DataFrame):
171
+ r = r.iloc[0]
172
+ return num(r.iloc[i])
173
+ return None
174
+
175
+
176
+ def segment_of(industry: str, sym: str) -> str:
177
+ ind = (industry or "").lower()
178
+ if "bank" in ind:
179
+ return "Bank"
180
+ if "insurance" in ind:
181
+ return "Insurance"
182
+ if "reit" in ind or "real estate" in ind or sym.upper().endswith("GYO"):
183
+ return "GYO"
184
+ return "Industrial"
185
+
186
+
187
+ def fetch(symbol: str, force: bool = False) -> Optional[Dict[str, Any]]:
188
+ key = f"t_{symbol}"
189
+ if not force:
190
+ c = _cget(key)
191
+ if c is not None:
192
+ return c
193
+ try:
194
+ t = yf.Ticker(symbol)
195
+ info = t.info or {}
196
+ fin, bs, cf = t.financials, t.balance_sheet, t.cashflow
197
+ try:
198
+ dv = t.dividends
199
+ except Exception:
200
+ dv = None
201
+ price = num(info.get("currentPrice")) or num(info.get("regularMarketPrice"))
202
+ if price is None:
203
+ h = t.history(period="5d")
204
+ price = num(h["Close"].iloc[-1]) if not h.empty else None
205
+ except Exception as e:
206
+ logger.warning("%s fetch failed: %s", symbol, e)
207
+ return None
208
+ if price is None or price <= 0:
209
+ return None
210
+
211
+ flags: List[str] = []
212
+ dfs = {"fin": fin, "bs": bs, "cf": cf}
213
+ s = {k: [stmt(dfs[src], names, i) for i in range(3)] for k, (names, src) in STMT.items()}
214
+ s["capex"] = [abs(x) if x is not None else None for x in s["capex"]]
215
+ s["interest"] = [abs(x) if x is not None else None for x in s["interest"]]
216
+ for i in range(3):
217
+ if s["fcf"][i] is None and s["cfo"][i] is not None and s["capex"][i] is not None:
218
+ s["fcf"][i] = s["cfo"][i] - s["capex"][i]
219
+ if s["ebitda"][i] is None and s["opinc"][i] is not None and s["da"][i] is not None:
220
+ s["ebitda"][i] = s["opinc"][i] + s["da"][i]
221
+ if s["debt"][0] is None:
222
+ s["debt"][0] = num(info.get("totalDebt"))
223
+ if s["debt"][0] is not None:
224
+ flags.append("debt from info (not statement)")
225
+ if s["cash"][0] is None:
226
+ s["cash"][0] = num(info.get("totalCash"))
227
+ if s["cash"][0] is not None:
228
+ flags.append("cash from info (not statement)")
229
+
230
+ # period / currency / unit validation
231
+ sd = None
232
+ try:
233
+ sd = pd.Timestamp(fin.columns[0]).to_pydatetime() if fin is not None and not fin.empty else None
234
+ for nm, df in (("balance sheet", bs), ("cash flow", cf)):
235
+ if df is not None and not df.empty and sd is not None and pd.Timestamp(df.columns[0]).to_pydatetime() != sd:
236
+ flags.append(f"{nm} period != income statement period")
237
+ except Exception:
238
+ pass
239
+ age = (datetime.now() - sd).days if sd else None
240
+ if age is not None and age > 550:
241
+ flags.append(f"stale statements ({age} days)")
242
+ if sd is None:
243
+ flags.append("no annual statements")
244
+ fc, cur = info.get("financialCurrency"), info.get("currency")
245
+ unit_ok = (fc is None) or (cur is None) or (fc == cur)
246
+ if not unit_ok:
247
+ flags.append(f"currency mismatch (fin {fc} vs price {cur})")
248
+ if fc is None:
249
+ flags.append("financial currency unknown")
250
+
251
+ shares = num(info.get("sharesOutstanding")) or s["shares"][0]
252
+ mcap = num(info.get("marketCap")) or (price * shares if shares else None)
253
+ if shares and mcap and abs(price * shares / mcap - 1) > 0.35:
254
+ flags.append("shares x price differs >35% from market cap")
255
+ eq0, ta0 = s["eq"][0], s["ta"][0]
256
+ if eq0 is not None and ta0 is not None and eq0 > ta0 * 1.01:
257
+ flags.append("equity > total assets (suspicious)")
258
+ 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:
259
+ flags.append("|net income| > revenue (suspicious)")
260
+
261
+ bvps = num(info.get("bookValue")) or div(eq0, shares)
262
+ eps = num(info.get("trailingEps")) or div(s["ni"][0], shares)
263
+ 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:
264
+ flags.append("EPS x shares differs >50% from net income")
265
+
266
+ # dividends: 0 only if history fetched and empty; None if unavailable
267
+ dy = None
268
+ try:
269
+ if dv is not None:
270
+ if len(dv) > 0:
271
+ cutoff = pd.Timestamp.now(tz=dv.index.tz) - pd.Timedelta(days=365)
272
+ dy = float(dv[dv.index >= cutoff].sum()) / price
273
+ else:
274
+ dy = 0.0
275
+ except Exception:
276
+ dy = None
277
+ if dy is None:
278
+ y = num(info.get("dividendYield"))
279
+ if y is not None:
280
+ dy = y / 100 if y > 1 else y
281
+
282
+ tax_assumed = False
283
+ tr = div(s["tax"][0], s["pretax"][0]) if (s["pretax"][0] or 0) > 0 else None
284
+ if tr is None:
285
+ tr, tax_assumed = 0.25, True
286
+ tr = float(np.clip(tr, 0, 0.35))
287
+
288
+ net_debt = (s["debt"][0] - s["cash"][0]) if (s["debt"][0] is not None and s["cash"][0] is not None) else None
289
+ fcf0, int0 = s["fcf"][0], s["interest"][0]
290
+ fcff = (fcf0 + int0 * (1 - tr)) if (fcf0 is not None and int0 is not None) else None # unlevered -> net debt bridge
291
+ oe, oe_parts = None, None
292
+ ni0, da0, cx0, wc0 = s["ni"][0], s["da"][0], s["capex"][0], s["dwc"][0]
293
+ if None not in (ni0, da0, cx0, wc0):
294
+ maint = min(cx0, da0) # [D] estimate: maintenance capex ~ min(total capex, D&A)
295
+ oe = ni0 + da0 - maint + wc0
296
+ oe_parts = dict(ni=ni0, da=da0, capex_total=cx0, maint_est=maint, dwc=wc0)
297
+ ev = (mcap + net_debt) if (mcap and net_debt is not None) else num(info.get("enterpriseValue"))
298
+ ebitda0 = s["ebitda"][0]
299
+ industry = info.get("industry") or "Unknown"
300
+ seg = segment_of(industry, symbol.replace(".IS", ""))
301
+ roes = [div(s["ni"][i], s["eq"][i]) for i in range(3) if (s["eq"][i] or 0) > 0]
302
+ roes = [r for r in roes if r is not None]
303
+ ir = num(info.get("returnOnEquity"))
304
+ if ir is not None:
305
+ roes.append(ir)
306
+ sust_roe = float(np.clip(np.mean(roes), -1, 1.5)) if roes else None
307
+
308
+ d = {
309
+ "sym": symbol.replace(".IS", ""), "name": info.get("shortName") or symbol, "price": price, "mcap": mcap,
310
+ "shares": shares, "sector": info.get("sector") or "Unknown", "industry": industry, "seg": seg,
311
+ "bvps": bvps, "eps": eps, "dy": dy, "s": s, "flags": flags, "unit_ok": unit_ok, "stmt_date": sd, "age": age,
312
+ "tax_rate": tr, "tax_assumed": tax_assumed, "net_debt": net_debt, "fcff": fcff, "fcf0": fcf0, "oe": oe,
313
+ "oe_parts": oe_parts, "ev": ev, "sust_roe": sust_roe,
314
+ "pe": price / eps if eps and eps > 0 else None,
315
+ "fpe": num(info.get("forwardPE")) if (num(info.get("forwardPE")) or 0) > 0 else None,
316
+ "pb": price / bvps if bvps and bvps > 0 else None,
317
+ "ps": div(mcap, s["rev"][0]) if (s["rev"][0] or 0) > 0 else None,
318
+ "ev_ebitda": div(ev, ebitda0) if (ebitda0 or 0) > 0 and ev else None,
319
+ "ev_fcf": div(ev, fcf0) if (fcf0 or 0) > 0 and ev else None,
320
+ "fcf_yield": div(fcf0, mcap), "earn_yield": div(eps, price), "fetched": datetime.now().isoformat(),
321
+ }
322
+ _cset(key, d)
323
+ return d
324
+
325
+
326
+ def cached_ds() -> List[Dict[str, Any]]:
327
+ return [v for k, (ts, v) in _cache.items() if k.startswith("t_") and v is not None]
328
+
329
+
330
+ MULTS = ["pe", "fpe", "pb", "ev_ebitda", "ev_fcf", "ps", "fcf_yield", "earn_yield"]
331
+ POS_ONLY = {"pe", "fpe", "pb", "ev_ebitda", "ev_fcf", "ps"}
332
+
333
+
334
+ def build_medians(ds) -> Dict[Tuple[str, str], Dict[str, Tuple[Optional[float], int]]]:
335
+ groups: Dict[Tuple[str, str], list] = {}
336
+ for d in ds:
337
+ groups.setdefault((d["seg"], d["sector"]), []).append(d)
338
+ med = {}
339
+ for k, g in groups.items():
340
+ m = {}
341
+ for f in MULTS:
342
+ v = [x[f] for x in g if x.get(f) is not None and (f not in POS_ONLY or (0 < x[f] < 300))]
343
+ m[f] = (float(np.median(v)) if len(v) >= MIN_PEERS else None, len(v))
344
+ med[k] = m
345
+ return med
346
+
347
+
348
+ # ============================================================================
349
+ # VALUATION ENGINE
350
+ # ============================================================================
351
+
352
+ def dcf_value(base, g0, wacc, tg, shares, net_debt, years=5) -> Optional[Dict[str, float]]:
353
+ """5-year explicit DCF, growth fades linearly g0 -> tg. Returns None if inputs insufficient."""
354
+ if None in (base, g0, wacc, tg, shares, net_debt) or base <= 0 or shares <= 0 or wacc <= tg:
355
+ return None
356
+ flows, f = [], base
357
+ for t in range(1, years + 1):
358
+ g = g0 + (tg - g0) * (t - 1) / (years - 1)
359
+ f *= 1 + g
360
+ flows.append(f)
361
+ pv = sum(fl / (1 + wacc) ** (i + 1) for i, fl in enumerate(flows))
362
+ pvtv = flows[-1] * (1 + tg) / (wacc - tg) / (1 + wacc) ** years
363
+ ev = pv + pvtv
364
+ eq = ev - net_debt
365
+ return {"ev": ev, "equity": eq, "per_share": eq / shares, "tv_share": pvtv / ev if ev else 0.0}
366
+
367
+
368
+ def assumptions(P, rg=None, rw=None):
369
+ rg = P["rg"] if rg is None else rg
370
+ rw = P["rw"] if rw is None else rw
371
+ i = P["infl"]
372
+ return (1 + rg) * (1 + i * P["pt"]) - 1, (1 + rw) * (1 + i) - 1, (1 + P["rtg"]) * (1 + i) - 1 # g0, wacc, tg (nominal)
373
+
374
+
375
+ def dcf_try(base, g0, wacc, tg, shares, nd, ok):
376
+ if not ok:
377
+ return None, "currency/unit mismatch"
378
+ if base is None:
379
+ return None, "essential inputs missing"
380
+ if base <= 0:
381
+ return None, "non-positive base cash flow"
382
+ if not shares or shares <= 0:
383
+ return None, "shares unavailable"
384
+ if nd is None:
385
+ return None, "net debt unavailable"
386
+ if wacc <= tg:
387
+ return None, "discount rate <= terminal growth"
388
+ r = dcf_value(base, g0, wacc, tg, shares, nd)
389
+ if r is None or r["per_share"] <= 0:
390
+ return None, "equity value <= 0"
391
+ return r["per_share"], f"TV = {r['tv_share'] * 100:.0f}% of EV"
392
+
393
+
394
+ def relative_value(d, med) -> Tuple[Optional[float], str]:
395
+ m = med.get((d["seg"], d["sector"]))
396
+ if not m or not d["unit_ok"]:
397
+ return None, "no peer group / unit mismatch"
398
+ seg, vals = d["seg"], []
399
+ if m["pb"][0] and d["bvps"] and d["bvps"] > 0:
400
+ vals.append(m["pb"][0] * d["bvps"])
401
+ if seg != "GYO" and m["pe"][0] and d["eps"] and d["eps"] > 0:
402
+ vals.append(m["pe"][0] * d["eps"])
403
+ 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"]:
404
+ vals.append((m["ev_ebitda"][0] * d["s"]["ebitda"][0] - d["net_debt"]) / d["shares"])
405
+ vals = [v for v in vals if v > 0]
406
+ if not vals:
407
+ return None, f"<{MIN_PEERS} valid peers"
408
+ return float(np.mean(vals)), f"{len(vals)} sector-median multiple(s)"
409
+
410
+
411
+ def intrinsic(d, P, med, rg=None, rw=None):
412
+ g0, wacc, tg = assumptions(P, rg, rw)
413
+ m: Dict[str, Tuple[Optional[float], str]] = {}
414
+ seg, bv = d["seg"], d["bvps"]
415
+ if seg in ("Bank", "Insurance"):
416
+ roe = d["sust_roe"]
417
+ if d["unit_ok"] and roe is not None and bv and bv > 0 and wacc > tg:
418
+ m["Justified P/B"] = (max((roe - tg) / (wacc - tg), 0) * bv, f"sust. ROE {fp(roe)}, COE {fp(wacc)}, g {fp(tg)}")
419
+ else:
420
+ m["Justified P/B"] = (None, "ROE/book value unavailable")
421
+ m["DCF"] = (None, "not applied to banks/insurers")
422
+ elif seg == "GYO":
423
+ 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"))
424
+ else:
425
+ m["DCF"] = dcf_try(d["fcff"], g0, wacc, tg, d["shares"], d["net_debt"], d["unit_ok"])
426
+ m["Owner Earnings"] = dcf_try(d["oe"], g0, wacc, tg, d["shares"], 0.0, d["unit_ok"]) # equity CF: no debt bridge
427
+ m["Relative"] = relative_value(d, med)
428
+ vals = [v for v, _ in m.values() if v is not None and v > 0]
429
+ return m, (float(np.median(vals)) if vals else None)
430
+
431
+
432
+ def reverse_dcf(d, P):
433
+ if d["seg"] in ("Bank", "Insurance"):
434
+ return None, "FCF-based reverse DCF not applicable to banks/insurers"
435
+ base, shares, nd = d["fcff"], d["shares"], d["net_debt"]
436
+ if not d["unit_ok"] or base is None or base <= 0 or not shares or nd is None:
437
+ return None, "essential inputs missing or FCFF <= 0"
438
+ _, wacc0, tg = assumptions(P)
439
+
440
+ def solve(wacc):
441
+ if wacc <= tg:
442
+ return None
443
+ f = lambda g: dcf_value(base, g, wacc, tg, shares, nd)["per_share"]
444
+ lo, hi = -0.9, 3.0
445
+ if not (f(lo) <= d["price"] <= f(hi)):
446
+ return None
447
+ for _ in range(80):
448
+ mid = (lo + hi) / 2
449
+ lo, hi = (mid, hi) if f(mid) < d["price"] else (lo, mid)
450
+ return (lo + hi) / 2
451
+
452
+ rows = []
453
+ for dw in (-0.03, 0.0, 0.03):
454
+ rw = P["rw"] + dw
455
+ w = (1 + rw) * (1 + P["infl"]) - 1
456
+ g = solve(w)
457
+ rows.append({"Real discount rate": fp(rw), "Nominal WACC": fp(w), "Implied FCF growth (nominal, yr1)": fp(g),
458
+ "Implied (real)": fp(real_ret(g, P["infl"]))})
459
+ return rows, "ok"
460
+
461
+
462
+ def scenarios(d, P, med, probs):
463
+ SC = [("Bear", -0.04, 0.03, 0.85), ("Base", 0.0, 0.0, 1.0), ("Bull", 0.04, -0.02, 1.10)]
464
+ out, ok = [], True
465
+ for (nm, dg, dw, mult), p in zip(SC, probs):
466
+ _, iv = intrinsic(d, P, med, rg=P["rg"] + dg, rw=max(P["rw"] + dw, 0.0))
467
+ tgt = None if iv is None else (d["price"] + P["conv"] * (iv - d["price"])) * mult
468
+ ok &= tgt is not None
469
+ out.append({"Scenario": nm, "Real growth": fp(P["rg"] + dg), "Real disc. rate": fp(max(P["rw"] + dw, 0)),
470
+ "Multiple factor": mult, "Intrinsic": iv, "Target": tgt, "Prob": p})
471
+ if not ok:
472
+ return out, None
473
+ pw = sum(o["Prob"] * o["Target"] for o in out)
474
+ nom = pw / d["price"] - 1
475
+ dy = d["dy"] or 0.0
476
+ tot = nom + dy
477
+ return out, dict(pw=pw, nominal=nom, total=tot, real=real_ret(tot, P["infl"]), real_price=real_ret(nom, P["infl"]),
478
+ dy_known=d["dy"] is not None)
479
+
480
+
481
+ def validate_probs(bear, base):
482
+ bull = 100 - bear - base
483
+ if bear < 0 or base < 0 or bull < -1e-9:
484
+ return None
485
+ return (bear / 100, base / 100, bull / 100)
486
+
487
+
488
+ # ============================================================================
489
+ # QUALITY, PIOTROSKI, RISK
490
+ # ============================================================================
491
+
492
+ def cv(series):
493
+ v = [x for x in series if x is not None]
494
+ if len(v) < 3 or np.mean(v) <= 0:
495
+ return None
496
+ return float(np.std(v) / np.mean(v))
497
+
498
+
499
+ def quality(d):
500
+ s, q = d["s"], {}
501
+ ni0, eq0, ta0, rev0 = s["ni"][0], s["eq"][0], s["ta"][0], s["rev"][0]
502
+ q["roe"] = div(ni0, eq0) if (eq0 or 0) > 0 else None
503
+ q["roa"] = div(ni0, ta0)
504
+ ic = None
505
+ if None not in (eq0, s["debt"][0], s["cash"][0]):
506
+ ic = eq0 + s["debt"][0] - s["cash"][0]
507
+ 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
508
+ q["gm"] = div(s["gp"][0], rev0)
509
+ q["opm"] = div(s["opinc"][0], rev0)
510
+ q["npm"] = div(ni0, rev0)
511
+ q["de"] = div(s["debt"][0], eq0) * 100 if (eq0 or 0) > 0 and s["debt"][0] is not None else None
512
+ e0 = s["ebitda"][0]
513
+ q["nd_ebitda"] = div(d["net_debt"], e0) if (e0 or 0) > 0 and d["net_debt"] is not None else None
514
+ q["cr"] = div(s["ca"][0], s["cl"][0])
515
+ q["icov"] = div(s["opinc"][0], s["interest"][0]) if (s["interest"][0] or 0) > 0 else None
516
+ q["fcf_conv"] = div(s["fcf"][0], ni0) if (ni0 or 0) > 0 else None
517
+ q["fcf_cv"] = cv(s["fcf"])
518
+ q["ni_cv"] = cv(s["ni"])
519
+ q["rev_g"] = growth(rev0, s["rev"][1])
520
+ q["ni_g"] = growth(ni0, s["ni"][1])
521
+ q["eq_ta"] = div(eq0, ta0)
522
+ return q
523
+
524
+
525
+ def piotroski(d):
526
+ s = d["s"]
527
+
528
+ def cmp(a, b, op):
529
+ return None if a is None or b is None else op(a, b)
530
+
531
+ roa = [div(s["ni"][i], s["ta"][i]) for i in (0, 1)]
532
+ cr = [div(s["ca"][i], s["cl"][i]) for i in (0, 1)]
533
+ lev = [div(s["ltd"][i] if s["ltd"][i] is not None else s["debt"][i], s["ta"][i]) for i in (0, 1)]
534
+ gm = [div(s["gp"][i], s["rev"][i]) for i in (0, 1)]
535
+ at = [div(s["rev"][i], s["ta"][i]) for i in (0, 1)]
536
+ comps = [
537
+ ("1 Net income > 0", cmp(s["ni"][0], 0, lambda a, b: a > b)),
538
+ ("2 CFO > 0", cmp(s["cfo"][0], 0, lambda a, b: a > b)),
539
+ ("3 ROA improved", cmp(roa[0], roa[1], lambda a, b: a > b)),
540
+ ("4 CFO > net income", cmp(s["cfo"][0], s["ni"][0], lambda a, b: a > b)),
541
+ ("5 Leverage decreased", cmp(lev[0], lev[1], lambda a, b: a < b)),
542
+ ("6 Current ratio improved", cmp(cr[0], cr[1], lambda a, b: a > b)),
543
+ ("7 No share dilution", cmp(s["shares"][0], s["shares"][1], lambda a, b: a <= b * 1.005)),
544
+ ("8 Gross margin improved", cmp(gm[0], gm[1], lambda a, b: a > b)),
545
+ ("9 Asset turnover improved", cmp(at[0], at[1], lambda a, b: a > b)),
546
+ ]
547
+ n = sum(1 for _, v in comps if v is not None)
548
+ return sum(1 for _, v in comps if v), n, comps
549
+
550
+
551
+ def risk_engine(d, q, cov):
552
+ s, bank = d["s"], d["seg"] in ("Bank", "Insurance")
553
+ ni0, ni1 = s["ni"][0], s["ni"][1]
554
+ fl = []
555
+
556
+ def add(name, w, cond, detail):
557
+ fl.append((name, w, None if cond is None else ("TRIGGERED" if cond else "OK"), detail))
558
+
559
+ add("Financial distress", 12, anyc(None if s["eq"][0] is None else s["eq"][0] <= 0,
560
+ None if q["icov"] is None else q["icov"] < 1.5,
561
+ None if (ni0 is None or s["cfo"][0] is None) else (ni0 < 0 and s["cfo"][0] < 0)),
562
+ "equity<=0, interest cover<1.5, or NI<0 & CFO<0")
563
+ lev = anyc(None if q["eq_ta"] is None else q["eq_ta"] < 0.06) if bank else anyc(
564
+ None if q["nd_ebitda"] is None else q["nd_ebitda"] > 4, None if q["de"] is None else q["de"] > 200)
565
+ add("Leverage", 12, lev, "bank: equity/assets<6%; else ND/EBITDA>4 or D/E>200%")
566
+ add("Liquidity", 10, None if bank else (None if q["cr"] is None else q["cr"] < 1), "current ratio < 1")
567
+ add("Negative FCF", 10, None if bank else (None if s["fcf"][0] is None else s["fcf"][0] < 0), "FCF < 0")
568
+ ed = None
569
+ if ni0 is not None and ni1 is not None:
570
+ ed = ni0 < 0 or (ni1 > 0 and ni0 < 0.8 * ni1)
571
+ add("Earnings deterioration", 10, ed, "NI < 0 or down >20% y/y")
572
+ md = None
573
+ if not bank and q["opm"] is not None and s["opinc"][1] is not None and (s["rev"][1] or 0) > 0:
574
+ md = q["opm"] < s["opinc"][1] / s["rev"][1] - 0.02
575
+ add("Margin deterioration", 8, md, "operating margin down >2 pts")
576
+ dv = None if bank or ni0 is None or s["cfo"][0] is None or ni0 <= 0 else s["cfo"][0] < 0.5 * ni0
577
+ add("CFO vs NI divergence", 10, dv, "CFO < 50% of positive NI")
578
+ dil = cmp_g = growth(s["shares"][0], s["shares"][1])
579
+ add("Dilution", 8, None if dil is None else dil > 0.03, "share count +3% y/y")
580
+ 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),
581
+ None if d["pb"] is None else d["pb"] > 6)
582
+ add("Extreme valuation", 10, ev, "P/E>40, loss-making, or P/B>6")
583
+ add("Data reliability", 10, cov < 0.7 or bool(d["flags"]), f"coverage<70% or integrity flags ({len(d['flags'])})")
584
+ ev_w = sum(w for _, w, st, _ in fl if st is not None)
585
+ score = None if ev_w == 0 else 100 * sum(w for _, w, st, _ in fl if st == "TRIGGERED") / ev_w
586
+ return score, fl
587
+
588
+
589
+ # ============================================================================
590
+ # SCORING & ANALYSIS
591
+ # ============================================================================
592
+
593
+ WEIGHTS = {"Industrial": (.35, .30, .20, .15), "Bank": (.40, .35, .10, .15),
594
+ "Insurance": (.40, .30, .15, .15), "GYO": (.45, .20, .20, .15)}
595
+
596
+
597
+ def coverage(d):
598
+ s, seg = d["s"], d["seg"]
599
+ f = [d["price"], d["mcap"], d["shares"], d["pb"], d["pe"], s["ni"][0], s["eq"][0], s["ta"][0], d["bvps"]]
600
+ if seg not in ("Bank", "Insurance"):
601
+ f += [s["rev"][0], s["cfo"][0], s["debt"][0], s["cash"][0], d["fcff"], s["ebitda"][0], d["oe"]]
602
+ return sum(x is not None for x in f) / len(f)
603
+
604
+
605
+ def analyze(d, P, med, probs=(0.25, 0.5, 0.25)):
606
+ A: Dict[str, Any] = {"d": d}
607
+ infl, seg = P["infl"], d["seg"]
608
+ q = quality(d)
609
+ A["q"] = q
610
+ A["methods"], A["iv"] = intrinsic(d, P, med)
611
+ A["mos"] = (A["iv"] - d["price"]) / A["iv"] if A["iv"] else None
612
+ rv, A["rel_note"] = relative_value(d, med)
613
+ A["rel_mos"] = (rv - d["price"]) / rv if rv else None
614
+ A["pio"] = piotroski(d)
615
+ A["cov"] = coverage(d)
616
+ A["risk"], A["flags"] = risk_engine(d, q, A["cov"])
617
+ bank = seg in ("Bank", "Insurance")
618
+ rroe, rroic = real_ret(q["roe"], infl), real_ret(q["roic"], infl)
619
+ rrev, rni = real_ret(q["rev_g"], infl), real_ret(q["ni_g"], infl)
620
+ pe_s = (0.0 if d["eps"] <= 0 else lin(d["pe"], 25, 4)) if d["eps"] is not None else None
621
+ 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)
622
+ vi = [(lin(A["mos"], -0.3, 0.5), 4), (lin(A["rel_mos"], -0.3, 0.5), 2)]
623
+ if bank:
624
+ vi += [(pe_s, 2), (pb_s, 3), (lin(d["dy"], 0, 0.08), 1)]
625
+ elif seg == "GYO":
626
+ vi += [(lin(d["pb"], 1.5, 0.4), 3), (pe_s, 1)]
627
+ else:
628
+ vi += [(pe_s, 1), (pb_s, 1), (lin(d["ev_ebitda"], 15, 3), 1), (lin(d["fcf_yield"], -0.02, 0.12), 1),
629
+ (lin(d["earn_yield"], 0, 0.25), 1)]
630
+ if bank:
631
+ qi = [(lin(rroe, -0.1, 0.2), 4), (lin(q["roa"], 0, 0.03), 2), (lin(q["ni_cv"], 2, 0.3), 2),
632
+ (lin(d["dy"], 0, 0.08), 1), (lin(rni, -0.2, 0.3), 1)]
633
+ else:
634
+ qi = [(lin(rroe, -0.1, 0.2), 3), (lin(rroic, -0.05, 0.15), 3), (lin(q["opm"], 0.02, 0.25), 2),
635
+ (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)]
636
+ sc, n, _ = A["pio"]
637
+ e0 = d["s"]["ebitda"][0]
638
+ if bank:
639
+ si = [(lin(q["eq_ta"], 0.04, 0.12), 4), (lin(q["roa"], 0, 0.02), 1)]
640
+ else:
641
+ 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)
642
+ if q["nd_ebitda"] is None and d["net_debt"] is not None and d["net_debt"] <= 0 and (e0 or 0) > 0:
643
+ nde = 100.0 # net cash
644
+ si = [(nde, 3), (lin(q["cr"], 0.8, 2.0), 2), (lin(q["icov"], 1, 8), 2), (lin(q["de"], 250, 30), 2),
645
+ (lin(sc / n, 0.3, 0.8) if n >= 5 else None, 2)]
646
+ A["val"], A["val_cov"] = wavg(vi)
647
+ A["qual"], A["qual_cov"] = wavg(qi)
648
+ A["str"], A["str_cov"] = wavg(si)
649
+ w = WEIGHTS[seg]
650
+ comps = [A["val"], A["qual"], A["str"], None if A["risk"] is None else 100 - A["risk"]]
651
+ if A["val"] is None or A["qual"] is None:
652
+ A["composite"] = None
653
+ else:
654
+ av = [(c, wt) for c, wt in zip(comps, w) if c is not None]
655
+ A["composite"] = sum(c * wt for c, wt in av) / sum(wt for _, wt in av) * min(1.0, A["cov"] / 0.7)
656
+ sev = (not d["unit_ok"]) or any("stale" in f or "period" in f for f in d["flags"])
657
+ lvl = 2 if A["cov"] >= 0.85 else 1 if A["cov"] >= 0.65 else 0
658
+ lvl = max(lvl - (1 if sev else 0), 0)
659
+ A["conf"] = ["Low", "Medium", "High"][lvl]
660
+ pr = validate_probs(probs[0] * 100, probs[1] * 100) or (0.25, 0.5, 0.25)
661
+ A["scen"], A["exp"] = scenarios(d, P, med, pr)
662
+ return A
663
+
664
+
665
+ # ============================================================================
666
+ # OUTPUT BUILDERS
667
+ # ============================================================================
668
+
669
+ def get_A(ticker, P, probs=(0.25, 0.5, 0.25)):
670
+ sym = norm_sym(ticker)
671
+ d = fetch(sym)
672
+ if d is None:
673
+ return None, f"⚠️ {sym} verisi çekilemedi."
674
+ return analyze(d, P, build_medians(cached_ds()), probs), None
675
+
676
+
677
+ def price_fig(sym, extra=None):
678
+ try:
679
+ h = yf.Ticker(norm_sym(sym)).history(period="1y")
680
+ if h.empty:
681
+ return None
682
+ f = go.Figure(go.Scatter(x=h.index, y=h["Close"], mode="lines", name="Close"))
683
+ for nm, v in (extra or {}).items():
684
+ if v:
685
+ f.add_hline(y=v, line_dash="dot", annotation_text=nm)
686
+ f.update_layout(title=f"{sym} — 1Y", template="plotly_white", height=380, margin=dict(l=10, r=10, t=40, b=10))
687
+ return f
688
+ except Exception:
689
+ return None
690
+
691
+
692
+ def scan_row(A):
693
+ d, q = A["d"], A["q"]
694
+ sc, n, _ = A["pio"]
695
+ e = A["exp"]
696
+ return {
697
+ "Ticker": d["sym"], "Segment": d["seg"], "Sector": d["sector"], "Price": round(d["price"], 2),
698
+ "Composite": None if A["composite"] is None else round(A["composite"], 1),
699
+ "Intrinsic": None if A["iv"] is None else round(A["iv"], 2),
700
+ "MoS %": None if A["mos"] is None else round(A["mos"] * 100, 1),
701
+ "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),
702
+ "ROE %": None if q["roe"] is None else round(q["roe"] * 100, 1),
703
+ "ROIC %": None if q["roic"] is None else round(q["roic"] * 100, 1),
704
+ "FCF Yield %": None if d["fcf_yield"] is None else round(d["fcf_yield"] * 100, 1),
705
+ "Piotroski": f"{sc}/{n} (cov {n}/9)" if n else "N/A",
706
+ "Risk": None if A["risk"] is None else round(A["risk"], 0),
707
+ "Exp Return % (nom, incl div)": None if not e else round(e["total"] * 100, 1),
708
+ "Real Exp Return %": None if not e or e["real"] is None else round(e["real"] * 100, 1),
709
+ "Coverage %": round(A["cov"] * 100), "Confidence": A["conf"],
710
+ }
711
+
712
+
713
+ def run_scan(force, extra, P, progress):
714
+ syms = BIST_UNIVERSE + [norm_sym(x) for x in (extra or "").split(",") if x.strip()]
715
+ syms = list(dict.fromkeys(syms))
716
+ ds, failed = [], []
717
+ for i, s in enumerate(syms):
718
+ progress((i + 1) / len(syms), desc=f"Fetching {s}")
719
+ was_cached = (not force) and _cget(f"t_{s}") is not None
720
+ d = fetch(s, force)
721
+ (ds.append(d) if d else failed.append(s))
722
+ if not was_cached:
723
+ time.sleep(REQUEST_DELAY_SEC)
724
+ med = build_medians(ds)
725
+ rows = [scan_row(analyze(d, P, med)) for d in ds]
726
+ df = pd.DataFrame(rows)
727
+ LAST["df"], LAST["failed"] = df, failed
728
+ return df, failed
729
+
730
+
731
+ def apply_filters(seg, minc, minm, sortby, desc):
732
+ df = LAST["df"]
733
+ if df is None or df.empty:
734
+ return None, None
735
+ x = df.copy()
736
+ if seg != "All":
737
+ x = x[x["Segment"] == seg]
738
+ x = x[x["Composite"].fillna(-1) >= minc]
739
+ if minm > -100:
740
+ x = x[x["MoS %"].fillna(-1e9) >= minm]
741
+ x = x.sort_values(sortby, ascending=not desc, na_position="last")
742
+ f = tempfile.NamedTemporaryFile(delete=False, suffix=".csv", prefix="bist_v3_")
743
+ x.to_csv(f.name, index=False)
744
+ return x, f.name
745
+
746
+
747
+ def md_report(A, P):
748
+ d = A["d"]
749
+ L = [f"## {d['sym']} — {d['name']}",
750
+ f"**{d['seg']}** · {d['sector']} / {d['industry']} · Fiyat **{fm(d['price'])} TRY** · Mali tablo: "
751
+ f"{d['stmt_date'].date() if d['stmt_date'] else 'N/A'}",
752
+ f"**Composite {fm(A['composite'], 0)}** | Valuation {fm(A['val'], 0)} | Quality {fm(A['qual'], 0)} | "
753
+ f"Strength {fm(A['str'], 0)} | Risk {fm(A['risk'], 0)} (düşük=iyi) | Coverage {fp(A['cov'], 0)} | "
754
+ f"Confidence **{A['conf']}**",
755
+ f"**Intrinsic value [D]:** {fm(A['iv'])} TRY · Margin of Safety {fp(A['mos'])}"]
756
+ L.append("\n| Yöntem | Değer/hisse | Not |\n|---|---|---|")
757
+ for k, (v, n) in A["methods"].items():
758
+ L.append(f"| {k} | {fm(v)} | {n} |")
759
+ if d["flags"]:
760
+ L.append("\n**⚠️ Veri bayrakları:** " + "; ".join(d["flags"]))
761
+ L.append("\n*Intrinsic = mevcut yöntemlerin medyanı. Skorlar evren içi göreceli, açıklanabilir heuristiklerdir.*")
762
+ return "\n".join(L)
763
+
764
+
765
+ def radar_fig(A):
766
+ cats = ["Valuation", "Quality", "Strength", "Safety (100-Risk)"]
767
+ v = [A["val"], A["qual"], A["str"], None if A["risk"] is None else 100 - A["risk"]]
768
+ v = [x or 0 for x in v]
769
+ f = go.Figure(go.Scatterpolar(r=v + v[:1], theta=cats + cats[:1], fill="toself"))
770
+ f.update_layout(polar=dict(radialaxis=dict(range=[0, 100])), height=340, margin=dict(l=30, r=30, t=30, b=20))
771
+ return f
772
+
773
+
774
+ # ============================================================================
775
+ # SELF TEST (synthetic)
776
+ # ============================================================================
777
+
778
+ def self_test() -> str:
779
+ res = []
780
+
781
+ def chk(name, cond):
782
+ res.append(("✅ " if cond else "❌ ") + name)
783
+
784
+ r = dcf_value(100, 0, 0.10, 0, 10, 200)
785
+ chk("DCF: g=0,tg=0 → EV=1000", r is not None and abs(r["ev"] - 1000) < 1e-6)
786
+ 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)
787
+ chk("DCF refuses wacc<=tg", dcf_value(100, 0.1, 0.05, 0.05, 10, 0) is None)
788
+ chk("DCF refuses FCF<=0", dcf_value(-5, 0.1, 0.2, 0.05, 10, 0) is None)
789
+ chk("DCF refuses missing input", dcf_value(100, 0.1, 0.2, 0.05, None, 0) is None)
790
+ chk("Real return 50% nom / 25% infl = 20%", abs(real_ret(0.5, 0.25) - 0.2) < 1e-12)
791
+ chk("div by zero → None", div(1, 0) is None and div(None, 2) is None)
792
+ P = dict(infl=0.30, rw=0.10, rtg=0.01, rg=0.03, pt=0.5, conv=0.5)
793
+ g0, w, tg = assumptions(P)
794
+ chk("WACC > terminal growth (defaults)", w > tg)
795
+ 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)
796
+ chk("Probabilities 70/50 rejected", validate_probs(70, 50) is None)
797
+ # reverse DCF consistency
798
+ d = {"fcff": 100.0, "shares": 10.0, "net_debt": 200.0, "price": 80.0, "unit_ok": True, "seg": "Industrial"}
799
+ P2 = dict(infl=0.0, rw=0.10, rtg=0.0, rg=0.0, pt=0.0, conv=0.5)
800
+ rows, _ = reverse_dcf(d, P2)
801
+ chk("Reverse DCF: price 80 → implied g≈0", rows is not None and rows[1]["Implied FCF growth (nominal, yr1)"] == "0.0%")
802
+ # Piotroski incompleteness
803
+ empty = {"s": {k: [None, None, None] for k in STMT}}
804
+ sc, n, _ = piotroski(empty)
805
+ chk("Piotroski all-missing → 0 available (not 9/9)", n == 0 and sc == 0)
806
+ # peer rule
807
+ ds = [{"seg": "Industrial", "sector": "X", "pe": 10.0 + i, **{m: None for m in MULTS if m != "pe"}} for i in range(4)]
808
+ chk("Sector median needs >=5 peers", build_medians(ds)[("Industrial", "X")]["pe"][0] is None)
809
+ ds += [{"seg": "Industrial", "sector": "X", "pe": 20.0, **{m: None for m in MULTS if m != "pe"}}]
810
+ 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)
811
+ chk("Segment classification", segment_of("Banks - Regional", "GARAN") == "Bank" and segment_of("Real Estate", "EKGYO") == "GYO"
812
+ and segment_of("Insurance - Life", "ANHYT") == "Insurance" and segment_of("Steel", "EREGL") == "Industrial")
813
+ return "\n".join(res)
814
+
815
+
816
+ # ============================================================================
817
+ # GRADIO UI
818
+ # ============================================================================
819
+
820
+ def build_ui():
821
+ with gr.Blocks(title="BIST Value Intelligence v3", theme=gr.themes.Soft()) as demo:
822
+ gr.Markdown("# BIST Value Intelligence v3\n**⚠️ yfinance verisi point-in-time değildir; backtest için kullanılamaz. "
823
+ "Eksik veri `N/A` gösterilir, asla sıfırla doldurulmaz. 12 aylık senaryolar heuristiktir, istatistiksel tahmin değildir. "
824
+ "Yatırım tavsiyesi değildir.**")
825
+ with gr.Accordion("⚙️ Varsayımlar [A] (tüm sekmeler için)", open=False):
826
+ with gr.Row():
827
+ s_infl = gr.Slider(0, 100, 30, step=1, label="Enflasyon (%)")
828
+ s_rw = gr.Slider(1, 30, 10, step=0.5, label="Reel iskonto oranı (%)")
829
+ s_rtg = gr.Slider(0, 5, 1, step=0.25, label="Reel terminal büyüme (%)")
830
+ with gr.Row():
831
+ s_rg = gr.Slider(-10, 20, 3, step=0.5, label="Baz reel büyüme (%)")
832
+ s_pt = gr.Slider(0, 100, 50, step=5, label="Enflasyonun nominal büyümeye geçişi (%)")
833
+ s_cv = gr.Slider(0, 100, 50, step=5, label="12 ay içinde değer-fiyat farkının kapanma oranı (%)")
834
+ PI = [s_infl, s_rw, s_rtg, s_rg, s_pt, s_cv]
835
+
836
+ with gr.Tabs():
837
+ with gr.Tab("📊 Market Scanner"):
838
+ with gr.Row():
839
+ scan_btn = gr.Button("Run Scanner", variant="primary", scale=2)
840
+ force = gr.Checkbox(label="Force refresh", value=False)
841
+ extra = gr.Textbox(label="Ek tickerlar (virgülle, örn: TRGYO,KONTR)", value="")
842
+ with gr.Row():
843
+ f_seg = gr.Dropdown(["All", "Industrial", "Bank", "Insurance", "GYO"], value="All", label="Segment")
844
+ f_c = gr.Slider(0, 100, 0, step=5, label="Min composite")
845
+ f_m = gr.Slider(-100, 100, -100, step=5, label="Min MoS % (-100 = kapalı)")
846
+ with gr.Row():
847
+ f_sort = gr.Dropdown(["Composite", "MoS %", "P/E", "P/B", "ROE %", "ROIC %", "FCF Yield %", "Risk",
848
+ "Exp Return % (nom, incl div)", "Real Exp Return %", "Coverage %"],
849
+ value="Composite", label="Sırala")
850
+ f_desc = gr.Checkbox(label="Azalan", value=True)
851
+ f_btn = gr.Button("Filtreyi uygula")
852
+ status = gr.Markdown()
853
+ table = gr.Dataframe(interactive=False, wrap=True)
854
+ csv = gr.File(label="CSV indir")
855
+ FI = [f_seg, f_c, f_m, f_sort, f_desc]
856
+
857
+ def do_scan(frc, ext, *a, progress=gr.Progress()):
858
+ P = mk_params(*a[:6])
859
+ seg, c, m, so, de = a[6:]
860
+ df, failed = run_scan(frc, ext, P, progress)
861
+ if df.empty:
862
+ return None, None, "⚠️ Veri çekilemedi."
863
+ x, path = apply_filters(seg, c, m, so, de)
864
+ msg = f"✅ {len(df)} hisse tarandı · coverage ort. %{df['Coverage %'].mean():.0f}"
865
+ if failed:
866
+ msg += f" · ⚠️ Çekilemeyen ({len(failed)}): " + ", ".join(failed)
867
+ return x, path, msg
868
+
869
+ def do_filter(*a):
870
+ x, path = apply_filters(*a)
871
+ return x, path
872
+
873
+ scan_btn.click(do_scan, [force, extra] + PI + FI, [table, csv, status])
874
+ f_btn.click(do_filter, FI, [table, csv])
875
+
876
+ with gr.Tab("🔍 Company Analysis"):
877
+ with gr.Row():
878
+ t1 = gr.Textbox(value="THYAO", label="Ticker", scale=2)
879
+ b1 = gr.Button("Analyze", variant="primary")
880
+ md1 = gr.Markdown()
881
+ with gr.Row():
882
+ p1 = gr.Plot()
883
+ r1 = gr.Plot()
884
+
885
+ def company(t, *a):
886
+ P = mk_params(*a)
887
+ A, err = get_A(t, P)
888
+ if err:
889
+ return err, None, None
890
+ return md_report(A, P), price_fig(A["d"]["sym"], {"Intrinsic": A["iv"]}), radar_fig(A)
891
+
892
+ b1.click(company, [t1] + PI, [md1, p1, r1])
893
+
894
+ with gr.Tab("🧮 Intrinsic Value Calculator"):
895
+ with gr.Row():
896
+ t2 = gr.Textbox(value="EREGL", label="Ticker", scale=2)
897
+ b2 = gr.Button("Calculate", variant="primary")
898
+ md2 = gr.Markdown()
899
+ p2 = gr.Plot()
900
+
901
+ def calc(t, *a):
902
+ P = mk_params(*a)
903
+ A, err = get_A(t, P)
904
+ if err:
905
+ return err, None
906
+ d = A["d"]
907
+ g0, w, tg = assumptions(P)
908
+ L = [f"### {d['sym']} — {d['seg']}",
909
+ 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}) · "
910
+ f"nominal iskonto {fp(w)} · nominal terminal {fp(tg)}",
911
+ f"**Girdiler [R/D]:** FCFF {fm(d['fcff'], 0)} (FCF + faiz×(1−vergi{' varsayım' if d['tax_assumed'] else ''})) · "
912
+ f"net borç {fm(d['net_debt'], 0)} · hisse {fm(d['shares'], 0)} · vergi {fp(d['tax_rate'], 0)}"]
913
+ if d["oe_parts"]:
914
+ o = d["oe_parts"]
915
+ 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)) "
916
+ f"{fm(o['maint_est'], 0)} + ΔİS sermayesi {fm(o['dwc'], 0)} = **{fm(d['oe'], 0)}** (toplam capex {fm(o['capex_total'], 0)})")
917
+ L.append("\n| Yöntem | Değer | Not |\n|---|---|---|")
918
+ for k, (v, n) in A["methods"].items():
919
+ L.append(f"| {k} | {fm(v)} | {n} |")
920
+ L.append(f"\n**Intrinsic (medyan): {fm(A['iv'])}** · Fiyat {fm(d['price'])} · MoS {fp(A['mos'])}")
921
+ names = [k for k, (v, _) in A["methods"].items() if v]
922
+ vals = [A["methods"][k][0] for k in names]
923
+ f = go.Figure(go.Bar(x=["Price"] + names + ["Intrinsic"], y=[d["price"]] + vals + [A["iv"] or 0]))
924
+ f.update_layout(template="plotly_white", height=340, margin=dict(l=10, r=10, t=20, b=10))
925
+ return "\n".join(L), f
926
+
927
+ b2.click(calc, [t2] + PI, [md2, p2])
928
+
929
+ with gr.Tab("↩️ Reverse DCF"):
930
+ with gr.Row():
931
+ t3 = gr.Textbox(value="TOASO", label="Ticker", scale=2)
932
+ b3 = gr.Button("Solve", variant="primary")
933
+ md3 = gr.Markdown()
934
+ tb3 = gr.Dataframe(interactive=False)
935
+
936
+ def rev(t, *a):
937
+ P = mk_params(*a)
938
+ A, err = get_A(t, P)
939
+ if err:
940
+ return err, None
941
+ d = A["d"]
942
+ rows, note = reverse_dcf(d, P)
943
+ if rows is None:
944
+ return f"**{d['sym']}**: ⚠️ {note}", None
945
+ g0, _, _ = assumptions(P)
946
+ h = d["s"]["fcf"]
947
+ 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"
948
+ 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"
949
+ f"Mevcut varsayım yıl-1 büyüme: {fp(g0)} · Tarihsel FCF CAGR (2y, nominal): {hist}\n\n"
950
+ "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.")
951
+ return md, pd.DataFrame(rows)
952
+
953
+ b3.click(rev, [t3] + PI, [md3, tb3])
954
+
955
+ with gr.Tab("💎 Financial Quality"):
956
+ with gr.Row():
957
+ t4 = gr.Textbox(value="BIMAS", label="Ticker", scale=2)
958
+ b4 = gr.Button("Show", variant="primary")
959
+ md4 = gr.Markdown()
960
+ tb4 = gr.Dataframe(interactive=False)
961
+ tb4b = gr.Dataframe(interactive=False)
962
+
963
+ def qual(t, *a):
964
+ P = mk_params(*a)
965
+ A, err = get_A(t, P)
966
+ if err:
967
+ return err, None, None
968
+ q, d = A["q"], A["d"]
969
+ pc = lambda x: fp(x)
970
+ rows = [("ROE [D]", pc(q["roe"])), ("ROE (reel) [D]", pc(real_ret(q["roe"], P["infl"]))), ("ROA [D]", pc(q["roa"])),
971
+ ("ROIC [D]", pc(q["roic"])), ("Gross margin [D]", pc(q["gm"])), ("Operating margin [D]", pc(q["opm"])),
972
+ ("Net margin [D]", pc(q["npm"])), ("Debt/Equity % [D]", fm(q["de"], 0)), ("Net debt/EBITDA [D]", fm(q["nd_ebitda"])),
973
+ ("Current ratio [D]", fm(q["cr"])), ("Interest coverage [D]", fm(q["icov"])), ("FCF/NI [D]", fm(q["fcf_conv"])),
974
+ ("FCF variation (CV) [D]", fm(q["fcf_cv"])), ("Revenue growth nominal [D]", pc(q["rev_g"])),
975
+ ("Revenue growth real [D]", pc(real_ret(q["rev_g"], P["infl"]))), ("Earnings growth nominal [D]", pc(q["ni_g"])),
976
+ ("Earnings growth real [D]", pc(real_ret(q["ni_g"], P["infl"]))), ("Equity/Assets [D]", pc(q["eq_ta"]))]
977
+ sc, n, comps = A["pio"]
978
+ cp = [(c, "N/A" if v is None else ("1" if v else "0")) for c, v in comps]
979
+ md = (f"**{d['sym']}** — Piotroski: **{sc}/{n}** (hesaplanabilen {n}/9)" +
980
+ ("" if n == 9 else " ⚠️ eksik bileşen var; skor tam değildir.") +
981
+ (" Bankalar için Piotroski sınırlı anlam taşır." if d["seg"] in ("Bank", "Insurance") else ""))
982
+ return md, pd.DataFrame(rows, columns=["Metric", "Value"]), pd.DataFrame(cp, columns=["Piotroski component", "Result"])
983
+
984
+ b4.click(qual, [t4] + PI, [md4, tb4, tb4b])
985
+
986
+ with gr.Tab("🛡️ Risk Analysis"):
987
+ with gr.Row():
988
+ t5 = gr.Textbox(value="PGSUS", label="Ticker", scale=2)
989
+ b5 = gr.Button("Assess", variant="primary")
990
+ md5 = gr.Markdown()
991
+ tb5 = gr.Dataframe(interactive=False)
992
+
993
+ def risk_tab(t, *a):
994
+ P = mk_params(*a)
995
+ A, err = get_A(t, P)
996
+ if err:
997
+ return err, None
998
+ rows = [{"Flag": n, "Weight": w, "Status": s or "N/A", "Rule": dt} for n, w, s, dt in A["flags"]]
999
+ return (f"**{A['d']['sym']}** Risk skoru: **{fm(A['risk'], 0)}/100** (yalnızca değerlendirilebilen bayraklar üzerinden; "
1000
+ f"düşük = iyi). Coverage {fp(A['cov'], 0)}."), pd.DataFrame(rows)
1001
+
1002
+ b5.click(risk_tab, [t5] + PI, [md5, tb5])
1003
+
1004
+ with gr.Tab("🏭 Sector Comparison"):
1005
+ with gr.Row():
1006
+ t6 = gr.Textbox(value="EREGL", label="Ticker (opsiyonel)", scale=2)
1007
+ b6 = gr.Button("Compare", variant="primary")
1008
+ md6 = gr.Markdown()
1009
+ tb6 = gr.Dataframe(interactive=False)
1010
+ p6 = gr.Plot()
1011
+
1012
+ def sector(t, *a):
1013
+ P = mk_params(*a)
1014
+ ds = cached_ds()
1015
+ if not ds:
1016
+ return "Önce Market Scanner'ı çalıştırın (medyanlar önbellekteki hisselerden hesaplanır).", None, None
1017
+ med = build_medians(ds)
1018
+ rows = []
1019
+ for (seg, sec), m in sorted(med.items()):
1020
+ r = {"Segment": seg, "Sector": sec, "Peers": sum(1 for d in ds if d["seg"] == seg and d["sector"] == sec)}
1021
+ for f in ("pe", "pb", "ev_ebitda", "ev_fcf", "ps", "fcf_yield", "earn_yield"):
1022
+ v, n = m[f]
1023
+ r[f] = None if v is None else round(v, 3)
1024
+ rows.append(r)
1025
+ fig, md = None, f"Sektör medyanı için en az {MIN_PEERS} geçerli peer gerekir; yetersizse `None`."
1026
+ d = fetch(norm_sym(t)) if t.strip() else None
1027
+ if d:
1028
+ m = med.get((d["seg"], d["sector"]))
1029
+ keys = ["pe", "pb", "ev_ebitda", "ps"]
1030
+ cv_ = [d[k] for k in keys]
1031
+ mv = [m[k][0] if m else None for k in keys]
1032
+ fig = go.Figure([go.Bar(name=d["sym"], x=keys, y=cv_), go.Bar(name="Sector median", x=keys, y=mv)])
1033
+ fig.update_layout(barmode="group", template="plotly_white", height=340, margin=dict(l=10, r=10, t=20, b=10))
1034
+ md = f"**{d['sym']}** — {d['seg']} / {d['sector']}. " + md
1035
+ return md, pd.DataFrame(rows), fig
1036
+
1037
+ b6.click(sector, [t6] + PI, [md6, tb6, p6])
1038
+
1039
+ with gr.Tab("🎯 12-Month Scenarios"):
1040
+ with gr.Row():
1041
+ t7 = gr.Textbox(value="AKBNK", label="Ticker", scale=2)
1042
+ pb = gr.Slider(0, 100, 25, step=5, label="Bear olasılığı (%)")
1043
+ pm = gr.Slider(0, 100, 50, step=5, label="Base olasılığı (%) — Bull = kalan")
1044
+ b7 = gr.Button("Run", variant="primary")
1045
+ md7 = gr.Markdown()
1046
+ tb7 = gr.Dataframe(interactive=False)
1047
+ p7 = gr.Plot()
1048
+
1049
+ def scen(t, bear, base, *a):
1050
+ P = mk_params(*a)
1051
+ pr = validate_probs(bear, base)
1052
+ if pr is None:
1053
+ return "⚠️ Bear + Base 100'ü aşamaz (Bull = 100 − Bear − Base).", None, None
1054
+ A, err = get_A(t, P, pr)
1055
+ if err:
1056
+ return err, None, None
1057
+ d, e = A["d"], A["exp"]
1058
+ rows = [{"Scenario": o["Scenario"], "Real growth": o["Real growth"], "Real disc. rate": o["Real disc. rate"],
1059
+ "Multiple factor": o["Multiple factor"], "Intrinsic": None if o["Intrinsic"] is None else round(o["Intrinsic"], 2),
1060
+ "12M target": None if o["Target"] is None else round(o["Target"], 2),
1061
+ "Probability": f"{o['Prob'] * 100:.0f}%"} for o in A["scen"]]
1062
+ if e is None:
1063
+ return f"**{d['sym']}**: ⚠️ Senaryo üretilemedi (intrinsic value hesaplanamadı).", pd.DataFrame(rows), None
1064
+ md = (f"**{d['sym']}** · Olasılık-ağırlıklı hedef **{fm(e['pw'])}** · Beklenen nominal fiyat getirisi {fp(e['nominal'])} · "
1065
+ f"Temettü-dahil nominal {fp(e['total'])}" + ("" if e["dy_known"] else " (temettü verisi yok, 0 sayıldı)") +
1066
+ f" · Reel (temettü-dahil) {fp(e['real'])} · Reel fiyat getirisi {fp(e['real_price'])}\n\n"
1067
+ f"*Heuristik: hedef = fiyat + k×(senaryo değeri − fiyat), k={P['conv'] * 100:.0f}%, çarpan faktörü uygulanır. "
1068
+ "İstatistiksel olarak doğrulanmış tahmin değildir. Temettü yalnızca bir kez eklenir.*")
1069
+ f = go.Figure(go.Bar(x=["Price", "Bear", "Base", "Bull", "Prob-weighted"],
1070
+ y=[d["price"]] + [o["Target"] for o in A["scen"]] + [e["pw"]]))
1071
+ f.update_layout(template="plotly_white", height=340, margin=dict(l=10, r=10, t=20, b=10))
1072
+ return md, pd.DataFrame(rows), f
1073
+
1074
+ b7.click(scen, [t7, pb, pm] + PI, [md7, tb7, p7])
1075
+
1076
+ with gr.Tab("📋 Data Quality Monitor"):
1077
+ b8 = gr.Button("Check Data Quality", variant="primary")
1078
+ md8 = gr.Markdown()
1079
+ tb8 = gr.Dataframe(interactive=False, wrap=True)
1080
+
1081
+ def dq(progress=gr.Progress()):
1082
+ rows, failed = [], []
1083
+ for i, s in enumerate(BIST_UNIVERSE):
1084
+ progress((i + 1) / len(BIST_UNIVERSE), desc=f"Checking {s}")
1085
+ was = _cget(f"t_{s}") is not None
1086
+ d = fetch(s)
1087
+ if d is None:
1088
+ failed.append(s.replace(".IS", ""))
1089
+ continue
1090
+ c = coverage(d)
1091
+ rows.append({"Ticker": d["sym"], "Segment": d["seg"], "Coverage %": round(c * 100),
1092
+ "Statement date": d["stmt_date"].date().isoformat() if d["stmt_date"] else "N/A",
1093
+ "Age (days)": d["age"], "Status": "OK" if c >= 0.7 and not d["flags"] else "CHECK",
1094
+ "Flags": "; ".join(d["flags"]) or "—"})
1095
+ if not was:
1096
+ time.sleep(REQUEST_DELAY_SEC * 0.5)
1097
+ df = pd.DataFrame(rows)
1098
+ avg = f"{df['Coverage %'].mean():.0f}%" if not df.empty else "N/A"
1099
+ return (f"Ortalama coverage: **{avg}** · Başarılı {len(rows)} · Çekilemeyen {len(failed)}: "
1100
+ + (", ".join(failed) or "—")), df
1101
+
1102
+ b8.click(dq, None, [md8, tb8])
1103
+
1104
+ with gr.Tab("📐 Methodology"):
1105
+ gr.Markdown(METHODOLOGY)
1106
+ b9 = gr.Button("Run self-test (synthetic data)")
1107
+ o9 = gr.Textbox(label="Self-test", lines=18, interactive=False)
1108
+ b9.click(self_test, None, o9)
1109
+ return demo
1110
+
1111
+
1112
+ METHODOLOGY = """
1113
+ ### Değerleme mimarisi
1114
+ - **DCF (sanayi/GYO 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**.
1115
+ - **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.
1116
+ - **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.
1117
+ - **GYO:** NAV vekili = defter değeri (ekspertiz NAV'ı yok), P/NAV. Doluluk verisi yok.
1118
+ - **Relative:** sektör medyanı (en az 5 geçerli peer) P/E, P/B, EV/EBITDA → hisse başına değer.
1119
+ - **Intrinsic = mevcut yöntemlerin medyanı.** MoS = (IV − fiyat)/IV.
1120
+ - **Reverse DCF:** fiyatı veren yıl-1 FCF büyümesi (bisection), iskonto oranı ±3 puan duyarlılığı.
1121
+ - **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.
1122
+ - **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).
1123
+
1124
+ ### Skorlama (0-100, açıklanabilir)
1125
+ Composite = ağırlıklı ortalama (mevcut bileşenler üzerinden yeniden normalize) × min(1, coverage/0.7).
1126
+ | Segment | Valuation | Quality | Strength | Risk (ters) |
1127
+ |---|---|---|---|---|
1128
+ | Sanayi | 35 | 30 | 20 | 15 |
1129
+ | Banka | 40 | 35 | 10 | 15 |
1130
+ | Sigorta | 40 | 30 | 15 | 15 |
1131
+ | GYO | 45 | 20 | 20 | 15 |
1132
+
1133
+ 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.
1134
+
1135
+ ### Bilinen veri sınırlamaları
1136
+ - yfinance point-in-time değildir; backtest geçersizdir. Bilanço tarihleri ve kapsam hisseden hisseye değişir.
1137
+ - Yıllık tablolar gecikmelidir; TFRS/enflasyon muhasebesi (TMS 29) etkileri nominal rakamları çarpıtabilir.
1138
+ - Evren: gömülü liste + eklediğiniz tickerlar (tüm BIST listesi Yahoo'dan ücretsiz çekilemez).
1139
+ - Sektör/segment sınıflaması Yahoo `industry` alanına dayanır; peer sayısı <5 ise medyan hesaplanmaz.
1140
+ - Sonuçlar araştırma/eğitim amaçlıdır, yatırım tavsiyesi değildir.
1141
+ """
1142
+
1143
+ if __name__ == "__main__":
1144
+ logger.info("Self-test:\n%s", self_test())
1145
+ build_ui().launch(server_name="0.0.0.0", server_port=7860)