GoshawkVortexAI commited on
Commit
98511f0
·
verified ·
1 Parent(s): d223152

Update app.py

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