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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +370 -293
app.py CHANGED
@@ -1,13 +1,21 @@
1
  """
2
- BIST Value Intelligence — Conservative Value Screener
3
- =====================================================
4
  A transparent, data-honest value screening application for Borsa Istanbul.
5
 
6
- DESIGN PHILOSOPHY:
7
- - Never fabricate missing data. Missing data = None + confidence penalty.
8
- - Clearly distinguish between computed metrics and unavailable fields.
9
- - Use only yfinance as data source (no KAP API key required).
10
- - All assumptions are explicit in code.
 
 
 
 
 
 
 
 
11
 
12
  LIMITATIONS (READ BEFORE USE):
13
  - yfinance fundamentals are NOT point-in-time. Do not use for backtesting.
@@ -36,8 +44,6 @@ logging.basicConfig(
36
  )
37
  logger = logging.getLogger(__name__)
38
 
39
- # BIST universe: core liquid names. Extend cautiously—non-BIST-100
40
- # fundamentals coverage in yfinance is poor and produces misleading scores.
41
  BIST_UNIVERSE = [
42
  "AEFES.IS", "AKBNK.IS", "AKSA.IS", "AKSEN.IS", "ALARK.IS",
43
  "ARCLK.IS", "ASELS.IS", "ASTOR.IS", "BIMAS.IS", "BRSAN.IS",
@@ -51,13 +57,25 @@ BIST_UNIVERSE = [
51
  "ZOREN.IS",
52
  ]
53
 
54
- # Cache configuration
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  CACHE_TTL_HOURS = 6
56
  _cache: Dict[str, Tuple[datetime, Any]] = {}
57
 
58
 
59
  def _cache_get(key: str) -> Optional[Any]:
60
- """Retrieve from cache if TTL valid."""
61
  if key in _cache:
62
  ts, data = _cache[key]
63
  if datetime.now() - ts < timedelta(hours=CACHE_TTL_HOURS):
@@ -66,10 +84,21 @@ def _cache_get(key: str) -> Optional[Any]:
66
 
67
 
68
  def _cache_set(key: str, data: Any) -> None:
69
- """Store in cache."""
70
  _cache[key] = (datetime.now(), data)
71
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  # ============================================================================
74
  # DATA ACQUISITION
75
  # ============================================================================
@@ -78,7 +107,6 @@ def fetch_ticker_data(symbol: str, force_refresh: bool = False) -> Optional[Dict
78
  """
79
  Fetch all available data for a single ticker.
80
  Returns None if the ticker cannot be retrieved at all.
81
- All individual fields may be None if unavailable.
82
  """
83
  cache_key = f"ticker_{symbol}"
84
  if not force_refresh:
@@ -90,18 +118,13 @@ def fetch_ticker_data(symbol: str, force_refresh: bool = False) -> Optional[Dict
90
  ticker = yf.Ticker(symbol)
91
  info = ticker.info or {}
92
 
93
- # Core price data
94
  price = info.get("currentPrice") or info.get("regularMarketPrice")
95
  if price is None:
96
- # Fallback: try history
97
  hist = ticker.history(period="5d")
98
  if not hist.empty:
99
  price = float(hist["Close"].iloc[-1])
100
 
101
- # Market cap
102
  market_cap = info.get("marketCap")
103
-
104
- # Valuation multiples
105
  trailing_pe = info.get("trailingPE")
106
  forward_pe = info.get("forwardPE")
107
  price_to_book = info.get("priceToBook")
@@ -110,36 +133,36 @@ def fetch_ticker_data(symbol: str, force_refresh: bool = False) -> Optional[Dict
110
  enterprise_value = info.get("enterpriseValue")
111
  earnings_yield = (1.0 / trailing_pe) if trailing_pe and trailing_pe > 0 else None
112
 
113
- # Profitability
114
  roe = info.get("returnOnEquity")
115
  roa = info.get("returnOnAssets")
116
  gross_margin = info.get("grossMargins")
117
  operating_margin = info.get("operatingMargins")
118
  profit_margin = info.get("profitMargins")
119
 
120
- # Balance sheet
121
  debt_to_equity = info.get("debtToEquity")
122
  current_ratio = info.get("currentRatio")
123
  total_debt = info.get("totalDebt")
124
  total_cash = info.get("totalCash")
125
 
126
- # Cash flow
127
  free_cashflow = info.get("freeCashflow")
128
  operating_cashflow = info.get("operatingCashflow")
129
  fcf_yield = None
130
  if free_cashflow is not None and market_cap and market_cap > 0:
131
  fcf_yield = free_cashflow / market_cap
132
 
133
- # Per-share data for Graham
134
  trailing_eps = info.get("trailingEps")
135
  book_value_per_share = info.get("bookValue")
136
 
137
- # Sector
138
  sector = info.get("sector", "Unknown")
139
  industry = info.get("industry", "Unknown")
140
 
141
- # Dividend
142
  dividend_yield = info.get("dividendYield")
 
 
 
 
 
143
 
144
  result = {
145
  "symbol": symbol.replace(".IS", ""),
@@ -172,7 +195,6 @@ def fetch_ticker_data(symbol: str, force_refresh: bool = False) -> Optional[Dict
172
  "data_timestamp": datetime.now().isoformat(),
173
  }
174
 
175
- # Graham Number: sqrt(22.5 * EPS * BVPS)
176
  if (trailing_eps is not None and trailing_eps > 0
177
  and book_value_per_share is not None and book_value_per_share > 0):
178
  result["graham_number"] = float(np.sqrt(22.5 * trailing_eps * book_value_per_share))
@@ -193,17 +215,13 @@ def fetch_ticker_data(symbol: str, force_refresh: bool = False) -> Optional[Dict
193
 
194
  def compute_piotroski_score(data: Dict[str, Any]) -> Optional[int]:
195
  """
196
- Compute Piotroski F-Score using available data.
197
- Returns None if insufficient data.
198
-
199
- Note: True Piotroski requires year-over-year changes in 9 metrics.
200
- Without historical statements, we compute a reduced version using
201
- only current-period available fields. This is explicitly a partial score.
202
  """
203
  score = 0
204
  components_available = 0
 
205
 
206
- # Profitability signals (4 components if data exists)
207
  if data.get("roa") is not None:
208
  components_available += 1
209
  if data["roa"] > 0:
@@ -216,55 +234,56 @@ def compute_piotroski_score(data: Dict[str, Any]) -> Optional[int]:
216
 
217
  if data.get("roa") is not None and data.get("operating_cashflow") is not None:
218
  components_available += 1
219
- # CFO > ROA (approximation: CFO/assets > net income/assets)
220
  if data["operating_cashflow"] > 0 and data["roa"] > 0:
221
  score += 1
222
 
223
- # Leverage/liquidity (2 components)
224
- if data.get("debt_to_equity") is not None:
225
  components_available += 1
226
- # Lower leverage is better; use 100 as threshold (yfinance reports as percentage)
227
  if data["debt_to_equity"] < 100:
228
  score += 1
229
 
230
- if data.get("current_ratio") is not None:
 
231
  components_available += 1
232
  if data["current_ratio"] > 1.0:
233
  score += 1
234
 
235
- # Require at least 4 components to return a score
236
- if components_available < 4:
237
  return None
238
 
239
- # Scale to 9-point maximum based on available components
240
- # This is NOT a true Piotroski score and should be labeled accordingly
241
  max_possible = components_available
242
  scaled = round((score / max_possible) * 9) if max_possible > 0 else 0
243
  return min(9, max(0, scaled))
244
 
245
 
246
- def compute_valuation_score(data: Dict[str, Any], sector_medians: Dict[str, float]) -> Tuple[Optional[float], int]:
 
 
 
247
  """
248
- Compute valuation score (0-100). Returns (score, confidence_0_to_100).
249
- Confidence reflects data completeness.
250
  """
251
  scores = []
252
  weights = []
253
  missing = 0
254
  total = 0
255
 
256
- # Earnings Yield (higher = better value)
 
 
257
  total += 1
258
  ey = data.get("earnings_yield")
259
  if ey is not None and ey > 0:
260
- # Normalize: 0% = 0, 15%+ = 100
261
- s = min(100, ey * 100 / 0.15 * 100)
262
  scores.append(s)
263
- weights.append(0.25)
264
  else:
265
  missing += 1
266
 
267
- # P/B vs sector (lower = better)
268
  total += 1
269
  pb = data.get("price_to_book")
270
  sector_pb = sector_medians.get(data.get("sector", ""), {}).get("pb")
@@ -272,92 +291,102 @@ def compute_valuation_score(data: Dict[str, Any], sector_medians: Dict[str, floa
272
  ratio = pb / sector_pb
273
  s = max(0, min(100, (1.5 - ratio) / 1.5 * 100))
274
  scores.append(s)
275
- weights.append(0.25)
276
  else:
277
  missing += 1
278
 
279
- # EV/EBITDA vs sector
280
- total += 1
281
- evebitda = data.get("ev_to_ebitda")
282
- sector_ev = sector_medians.get(data.get("sector", ""), {}).get("ev_ebitda")
283
- if evebitda is not None and evebitda > 0 and sector_ev and sector_ev > 0:
284
- ratio = evebitda / sector_ev
285
- s = max(0, min(100, (1.5 - ratio) / 1.5 * 100))
286
- scores.append(s)
287
- weights.append(0.25)
288
- else:
289
- missing += 1
290
-
291
- # FCF Yield
292
- total += 1
293
- fcfy = data.get("fcf_yield")
294
- if fcfy is not None:
295
- s = min(100, max(0, fcfy * 100 / 0.08 * 100))
296
- scores.append(s)
297
- weights.append(0.25)
298
- else:
299
- missing += 1
 
 
 
300
 
301
  if not scores:
302
  return None, 0
303
 
304
- # Weighted average
305
  total_weight = sum(weights)
306
  if total_weight == 0:
307
  return None, 0
308
  final_score = sum(s * w for s, w in zip(scores, weights)) / total_weight
309
 
310
- # Confidence: penalize missing data
311
  confidence = max(0, 100 - (missing / total) * 60)
312
  return final_score, int(confidence)
313
 
314
 
315
  def compute_quality_score(data: Dict[str, Any]) -> Tuple[Optional[float], int]:
316
- """Compute quality score (0-100) with confidence."""
 
 
 
317
  scores = []
318
  weights = []
319
  missing = 0
320
  total = 0
 
321
 
322
- # ROE (higher = better)
323
  total += 1
324
  roe = data.get("roe")
325
  if roe is not None:
326
- s = min(100, max(0, roe * 100 / 0.25 * 100))
327
- scores.append(s)
328
- weights.append(0.3)
329
- else:
330
- missing += 1
331
-
332
- # Operating margin
333
- total += 1
334
- om = data.get("operating_margin")
335
- if om is not None:
336
- s = min(100, max(0, om * 100 / 0.20 * 100))
337
- scores.append(s)
338
- weights.append(0.25)
339
- else:
340
- missing += 1
341
-
342
- # Debt/Equity (lower = better)
343
- total += 1
344
- de = data.get("debt_to_equity")
345
- if de is not None and de >= 0:
346
- s = max(0, min(100, (150 - de) / 150 * 100))
347
  scores.append(s)
348
- weights.append(0.25)
349
  else:
350
  missing += 1
351
 
352
- # Current ratio
353
- total += 1
354
- cr = data.get("current_ratio")
355
- if cr is not None:
356
- s = min(100, max(0, (cr - 0.5) / 2.0 * 100))
357
- scores.append(s)
358
- weights.append(0.20)
359
- else:
360
- missing += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
 
362
  if not scores:
363
  return None, 0
@@ -370,22 +399,14 @@ def compute_quality_score(data: Dict[str, Any]) -> Tuple[Optional[float], int]:
370
 
371
  def compute_composite_score(
372
  data: Dict[str, Any],
373
- sector_medians: Dict[str, float],
374
  ) -> Dict[str, Any]:
375
- """
376
- Compute composite score with explicit component breakdown.
377
- Missing data reduces confidence; it does NOT boost scores.
378
- """
379
  val_score, val_conf = compute_valuation_score(data, sector_medians)
380
  qual_score, qual_conf = compute_quality_score(data)
381
-
382
  piotroski = compute_piotroski_score(data)
383
 
384
- # Weighted composite: 50% valuation, 50% quality (simplified from spec
385
- # because growth data is unavailable)
386
  components = []
387
  weights = []
388
-
389
  if val_score is not None:
390
  components.append(val_score * 0.50)
391
  weights.append(0.50)
@@ -399,10 +420,11 @@ def compute_composite_score(
399
  else:
400
  total_w = sum(weights)
401
  composite = sum(c * w for c, w in zip(components, weights)) / total_w
402
- # Overall confidence: weighted average of component confidences
403
- overall_conf = int((val_conf * 0.5 + qual_conf * 0.5)) if (val_score is not None and qual_score is not None) else max(val_conf, qual_conf)
 
 
404
 
405
- # Margin of Safety vs Graham Number
406
  mos = None
407
  graham = data.get("graham_number")
408
  price = data.get("price")
@@ -420,47 +442,46 @@ def compute_composite_score(
420
 
421
 
422
  # ============================================================================
423
- # FORECASTING (Conservative, assumption-explicit)
424
  # ============================================================================
425
 
426
- def compute_12m_forecast(data: Dict[str, Any], score_result: Dict[str, Any]) -> Dict[str, Any]:
 
 
 
 
427
  """
428
  Scenario-based 12-month price target.
429
-
430
  METHODOLOGY (explicit):
431
- - Bear: Price reverts to 70% of current P/E multiple (multiple compression)
432
- - Base: Price grows with earnings yield (conservative proxy for earnings growth)
433
- - Bull: Price re-rates to 120% of current P/E multiple
434
-
435
- These are NOT statistically calibrated forecasts. They are
436
- transparent heuristic scenarios. Do not treat them as predictions.
437
  """
438
  price = data.get("price")
439
- pe = data.get("trailing_pe")
440
  earnings_yield = data.get("earnings_yield")
441
 
442
  if price is None or price <= 0:
443
  return {"bear": None, "base": None, "bull": None, "expected": None,
444
  "expected_return": None, "note": "Insufficient price data"}
445
 
446
- # Base case: earnings power drives price
447
- # If earnings yield = 10%, implied annual return ≈ 10% (simplistic)
448
- base_growth = 0.08 # Conservative 8% nominal (below Turkish historical inflation)
449
  if earnings_yield and earnings_yield > 0:
450
- base_growth = min(0.20, max(0.03, earnings_yield * 0.8))
451
 
452
- bear_return = -0.20 # 20% downside in bear
453
  bull_return = base_growth * 1.8
454
 
455
  bear = price * (1 + bear_return)
456
  base = price * (1 + base_growth)
457
  bull = price * (1 + bull_return)
458
 
459
- # Probability-weighted (20/55/25 from spec)
460
  expected = bear * 0.20 + base * 0.55 + bull * 0.25
461
  expected_return = (expected - price) / price
462
 
463
- # Add dividend yield if available
464
  div_yield = data.get("dividend_yield") or 0
465
  total_return = expected_return + div_yield
466
 
@@ -472,24 +493,25 @@ def compute_12m_forecast(data: Dict[str, Any], score_result: Dict[str, Any]) ->
472
  "expected_return": round(expected_return, 4),
473
  "total_expected_return": round(total_return, 4),
474
  "dividend_yield": div_yield,
475
- "base_assumption": f"{base_growth*100:.1f}% base growth",
476
  "note": "Heuristic scenario, not calibrated forecast",
477
  }
478
 
479
 
480
  # ============================================================================
481
- # SECTOR MEDIANS (computed from universe)
482
  # ============================================================================
483
 
484
  def compute_sector_medians(all_data: List[Dict[str, Any]]) -> Dict[str, Dict[str, float]]:
485
- """Compute median multiples per sector from available data."""
 
 
 
486
  sectors: Dict[str, Dict[str, List[float]]] = {}
487
-
488
  for d in all_data:
489
  sector = d.get("sector", "Unknown")
490
  if sector not in sectors:
491
  sectors[sector] = {"pb": [], "ev_ebitda": [], "pe": []}
492
-
493
  if d.get("price_to_book") and d["price_to_book"] > 0:
494
  sectors[sector]["pb"].append(d["price_to_book"])
495
  if d.get("ev_to_ebitda") and d["ev_to_ebitda"] > 0:
@@ -501,7 +523,8 @@ def compute_sector_medians(all_data: List[Dict[str, Any]]) -> Dict[str, Dict[str
501
  for sector, metrics in sectors.items():
502
  medians[sector] = {}
503
  for key, values in metrics.items():
504
- if values:
 
505
  medians[sector][key] = float(np.median(values))
506
  return medians
507
 
@@ -510,56 +533,72 @@ def compute_sector_medians(all_data: List[Dict[str, Any]]) -> Dict[str, Dict[str
510
  # SCANNER
511
  # ============================================================================
512
 
513
- def run_scanner(force_refresh: bool = False) -> pd.DataFrame:
514
- """Run full universe scan. Returns DataFrame for display."""
 
 
 
 
 
 
 
515
  all_data = []
516
  failed = []
517
 
 
518
  for i, sym in enumerate(BIST_UNIVERSE):
519
- logger.info(f"Scanning {sym} ({i+1}/{len(BIST_UNIVERSE)})")
 
 
520
  data = fetch_ticker_data(sym, force_refresh)
521
  if data is not None and data.get("price") is not None:
522
  all_data.append(data)
523
  else:
524
- failed.append(sym)
 
 
 
525
 
526
  if not all_data:
527
- return pd.DataFrame()
528
 
529
  sector_medians = compute_sector_medians(all_data)
530
 
531
  rows = []
532
  for d in all_data:
533
  score_result = compute_composite_score(d, sector_medians)
534
- forecast = compute_12m_forecast(d, score_result)
 
535
 
536
  rows.append({
537
  "Symbol": d["symbol"],
538
- "Price": d["price"],
539
  "Sector": d["sector"],
540
- "Composite Score": round(score_result["composite_score"], 1) if score_result["composite_score"] else None,
541
- "Valuation Score": round(score_result["valuation_score"], 1) if score_result["valuation_score"] else None,
542
- "Quality Score": round(score_result["quality_score"], 1) if score_result["quality_score"] else None,
543
- "Piotroski (partial)": score_result["piotroski"],
 
 
544
  "P/E": round(d["trailing_pe"], 2) if d["trailing_pe"] else None,
545
  "P/B": round(d["price_to_book"], 2) if d["price_to_book"] else None,
546
- "ROE": round(d["roe"] * 100, 1) if d["roe"] else None,
547
- "FCF Yield": round(d["fcf_yield"] * 100, 1) if d["fcf_yield"] else None,
548
- "Graham Number": round(d["graham_number"], 2) if d["graham_number"] else None,
549
- "Margin of Safety": round(score_result["margin_of_safety"] * 100, 1) if score_result["margin_of_safety"] else None,
550
- "12M Base Target": forecast["base"],
551
- "Expected Return": round(forecast["total_expected_return"] * 100, 1) if forecast["total_expected_return"] else None,
552
  "Confidence": score_result["confidence"],
553
  })
554
 
555
  df = pd.DataFrame(rows)
556
- # Sort by composite score, nulls last
557
- df = df.sort_values("Composite Score", ascending=False, na_position="last")
558
- return df
559
 
560
 
561
- def get_company_detail(symbol_input: str) -> Tuple[str, Optional[go.Figure]]:
562
- """Return detailed markdown report and price chart for a company."""
 
 
563
  symbol = symbol_input.strip().upper()
564
  if not symbol.endswith(".IS"):
565
  symbol = symbol + ".IS"
@@ -568,63 +607,95 @@ def get_company_detail(symbol_input: str) -> Tuple[str, Optional[go.Figure]]:
568
  if data is None or data.get("price") is None:
569
  return f"## {symbol}\n\nNo data available. Check ticker validity.", None
570
 
571
- # Recompute score (need sector medians from current cache)
572
  all_cached = []
573
  for key in _cache:
574
  if key.startswith("ticker_"):
575
  _, val = _cache[key]
576
  if val is not None:
577
  all_cached.append(val)
578
- sector_medians = compute_sector_medians(all_cached) if all_cached else {}
579
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  score = compute_composite_score(data, sector_medians)
581
- forecast = compute_12m_forecast(data, score)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
 
583
- # Build markdown report
584
  lines = [
585
  f"## {data['symbol']} — Company Detail",
586
  "",
587
  f"**Sector**: {data['sector']} | **Industry**: {data['industry']}",
 
588
  f"**Data timestamp**: {data['data_timestamp']}",
589
- "",
590
  "### Valuation",
591
  "",
592
- f"| Metric | Value |",
593
- f"|---|---|",
594
- f"| Price | {data['price']} |",
595
- f"| Market Cap | {data['market_cap']:,} |" if data['market_cap'] else "| Market Cap | N/A |",
596
- f"| Trailing P/E | {data['trailing_pe']:.1f} |" if data['trailing_pe'] else "| Trailing P/E | N/A |",
597
- f"| P/B | {data['price_to_book']:.2f} |" if data['price_to_book'] else "| P/B | N/A |",
598
- f"| EV/EBITDA | {data['ev_to_ebitda']:.1f} |" if data['ev_to_ebitda'] else "| EV/EBITDA | N/A |",
599
- f"| FCF Yield | {data['fcf_yield']*100:.1f}% |" if data['fcf_yield'] else "| FCF Yield | N/A |",
600
- f"| Graham Number | {data['graham_number']:.2f} |" if data['graham_number'] else "| Graham Number | N/A |",
601
- f"| Margin of Safety | {score['margin_of_safety']*100:.1f}% |" if score['margin_of_safety'] else "| Margin of Safety | N/A |",
 
602
  "",
603
  "### Quality",
604
  "",
605
- f"| Metric | Value |",
606
- f"|---|---|",
607
- f"| ROE | {data['roe']*100:.1f}% |" if data['roe'] else "| ROE | N/A |",
608
- f"| ROA | {data['roa']*100:.1f}% |" if data['roa'] else "| ROA | N/A |",
609
- f"| Operating Margin | {data['operating_margin']*100:.1f}% |" if data['operating_margin'] else "| Operating Margin | N/A |",
610
- f"| Debt/Equity | {data['debt_to_equity']:.1f} |" if data['debt_to_equity'] is not None else "| Debt/Equity | N/A |",
611
- f"| Current Ratio | {data['current_ratio']:.2f} |" if data['current_ratio'] else "| Current Ratio | N/A |",
612
- f"| Piotroski (partial) | {score['piotroski']}/9 |" if score['piotroski'] is not None else "| Piotroski (partial) | N/A |",
613
  "",
614
  "### Scores",
615
  "",
616
- f"- **Composite**: {score['composite_score']:.1f}/100 (confidence: {score['confidence']}%)" if score['composite_score'] else "- **Composite**: N/A",
617
- f"- **Valuation**: {score['valuation_score']:.1f}/100" if score['valuation_score'] else "- **Valuation**: N/A",
618
- f"- **Quality**: {score['quality_score']:.1f}/100" if score['quality_score'] else "- **Quality**: N/A",
619
  "",
620
  "### 12-Month Scenarios (heuristic, not calibrated)",
621
  "",
622
- f"| Scenario | Target | Return |",
623
- f"|---|---|---|",
624
- f"| Bear | {forecast['bear']} | {(forecast['bear']/data['price']-1)*100:.1f}% |" if forecast['bear'] else "| Bear | N/A | N/A |",
625
- f"| Base | {forecast['base']} | {(forecast['base']/data['price']-1)*100:.1f}% |" if forecast['base'] else "| Base | N/A | N/A |",
626
- f"| Bull | {forecast['bull']} | {(forecast['bull']/data['price']-1)*100:.1f}% |" if forecast['bull'] else "| Bull | N/A | N/A |",
627
- f"| **Probability-weighted** | **{forecast['expected']}** | **{forecast['total_expected_return']*100:.1f}%** |" if forecast['expected'] else "| Expected | N/A | N/A |",
 
 
628
  "",
629
  f"*{forecast['note']}*",
630
  "",
@@ -634,7 +705,6 @@ def get_company_detail(symbol_input: str) -> Tuple[str, Optional[go.Figure]]:
634
 
635
  markdown = "\n".join(lines)
636
 
637
- # Price chart
638
  try:
639
  ticker = yf.Ticker(symbol)
640
  hist = ticker.history(period="1y")
@@ -659,17 +729,19 @@ def get_company_detail(symbol_input: str) -> Tuple[str, Optional[go.Figure]]:
659
 
660
 
661
  # ============================================================================
662
- # DATA QUALITY MONITOR
663
  # ============================================================================
664
 
665
- def data_quality_report() -> pd.DataFrame:
666
- """Report data completeness across the universe."""
667
  rows = []
668
- for sym in BIST_UNIVERSE:
 
 
 
669
  data = fetch_ticker_data(sym)
670
  if data is None:
671
  rows.append({"Symbol": sym.replace(".IS", ""), "Status": "FETCH FAILED",
672
- "Fields Available": 0, "Critical Missing": "ALL"})
673
  continue
674
 
675
  critical_fields = ["price", "trailing_pe", "price_to_book", "roe", "fcf_yield"]
@@ -679,10 +751,13 @@ def data_quality_report() -> pd.DataFrame:
679
  rows.append({
680
  "Symbol": data["symbol"],
681
  "Status": "OK" if available >= 3 else "INCOMPLETE",
682
- "Fields Available": f"{available}/{len(critical_fields)}",
683
- "Critical Missing": ", ".join(missing) if missing else "—",
684
  "Sector": data.get("sector", "Unknown"),
 
685
  })
 
 
686
 
687
  return pd.DataFrame(rows)
688
 
@@ -694,131 +769,133 @@ def data_quality_report() -> pd.DataFrame:
694
  def build_ui():
695
  with gr.Blocks(title="BIST Value Intelligence", theme=gr.themes.Soft()) as demo:
696
  gr.Markdown("""
697
- # BIST Value Intelligence
698
  ### Conservative Value Screening for Borsa Istanbul
699
 
700
- **⚠️ Important limitations:**
701
- - Data from Yahoo Finance is **not point-in-time**. Do not use for backtesting.
702
- - Coverage for non-BIST-100 stocks is incomplete. Missing data is shown as `N/A`, never imputed.
703
- - Scores are **relative rankings within this universe**, not absolute quality measures.
704
- - The 12-month scenarios are **transparent heuristics**, not statistically calibrated forecasts.
705
- - This tool is for research and education. Not investment advice.
 
706
  """)
707
 
708
  with gr.Tabs():
709
- # Tab 1: Scanner
710
  with gr.Tab("📊 Market Scanner"):
711
  with gr.Row():
712
- scan_btn = gr.Button("Run Scanner", variant="primary", scale=1)
713
- refresh_cb = gr.Checkbox(label="Force refresh (bypass cache)", value=False, scale=1)
 
 
 
 
 
 
714
 
715
  status_text = gr.Textbox(label="Status", interactive=False, visible=False)
 
716
  scanner_table = gr.Dataframe(
717
- label="BIST Universe Scan Results",
718
- interactive=False,
719
- wrap=True,
720
  )
721
 
722
- def do_scan(force):
723
- status_text = gr.update(visible=True, value="Scanning universe...")
724
- df = run_scanner(force_refresh=force)
 
 
 
725
  if df.empty:
726
- return gr.update(value=None), gr.update(visible=True, value="No data retrieved. Check network.")
727
- return df, gr.update(visible=True, value=f"Scanned {len(df)} stocks. Sorted by composite score.")
728
-
729
- scan_btn.click(do_scan, inputs=[refresh_cb], outputs=[scanner_table, status_text])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
730
 
731
- # Tab 2: Company Detail
732
  with gr.Tab("🔍 Company Analysis"):
733
  with gr.Row():
734
- ticker_input = gr.Textbox(label="Ticker (e.g., THYAO, GARAN)", value="THYAO")
735
- detail_btn = gr.Button("Analyze", variant="primary")
 
 
736
 
737
  detail_md = gr.Markdown()
738
  price_chart = gr.Plot()
739
 
740
  detail_btn.click(
741
  get_company_detail,
742
- inputs=[ticker_input],
743
  outputs=[detail_md, price_chart],
744
  )
745
 
746
- # Tab 3: Data Quality
747
  with gr.Tab("📋 Data Quality Monitor"):
748
  gr.Markdown("""
749
- Shows which fundamental fields are available for each ticker.
750
- **Critical fields**: price, trailing_pe, price_to_book, roe, fcf_yield.
751
-
752
- If fewer than 3 of 5 are available, the stock's score confidence is severely reduced.
753
  """)
754
  quality_btn = gr.Button("Check Data Quality", variant="primary")
755
  quality_table = gr.Dataframe(label="Field Availability", interactive=False)
756
 
757
- def do_quality():
758
- return data_quality_report()
759
 
760
  quality_btn.click(do_quality, outputs=[quality_table])
761
 
762
- # Tab 4: Methodology
763
- with gr.Tab("📐 Methodology"):
764
  gr.Markdown("""
765
- ### Scoring Methodology
766
-
767
- **Valuation Score (50% weight in composite)**
768
- - Earnings yield (25%): normalized to 15% = 100
769
- - P/B vs sector median (25%): lower is better
770
- - EV/EBITDA vs sector median (25%): lower is better
771
- - FCF yield (25%): normalized to 8% = 100
772
-
773
- **Quality Score (50% weight in composite)**
774
- - ROE (30%): normalized to 25% = 100
775
- - Operating margin (25%): normalized to 20% = 100
776
- - Debt/Equity (25%): lower is better, 150 = 0
777
- - Current ratio (20%): 2.0 = 100, 0.5 = 0
778
-
779
- **Piotroski F-Score (partial)**
780
- - Computed from available current-period fields only
781
- - Does NOT include year-over-year changes (historical statements unavailable)
782
- - Scaled to 9-point maximum based on available components
783
- - **Labeled "partial" throughout the UI**
784
-
785
- **Graham Number**
786
- - `sqrt(22.5 × EPS × Book Value per Share)`
787
- - Requires positive EPS and positive BVPS
788
- - Returns `N/A` otherwise
789
-
790
- **12-Month Scenarios**
791
- - Bear: 20% downside (multiple compression)
792
- - Base: Earnings-yield-proportional growth, capped at 20%, floored at 3%
793
- - Bull: Base × 1.8
794
- - Probability-weighted: 20% bear, 55% base, 25% bull
795
-
796
- **Confidence Score**
797
- - Reflects data completeness
798
- - Missing critical fields reduce confidence
799
- - High confidence with missing data is impossible by design
800
-
801
- ### What This Tool Does NOT Do
802
-
803
- - ❌ DCF valuation (requires growth assumptions we cannot validate)
804
- - ❌ Monte Carlo simulation (requires calibrated return distributions)
805
- - ❌ Machine learning (requires point-in-time data for training)
806
- - ❌ Historical backtesting (survivorship and look-ahead bias unavoidable with current data)
807
- - ❌ Fabricate missing data
808
-
809
- ### Why the Limitations Exist
810
-
811
- Yahoo Finance fundamentals for BIST stocks are:
812
- 1. **Not point-in-time**: the API returns current snapshots, not historical filing data
813
- 2. **Incomplete**: many small/mid-cap stocks lack ROE, FCF, and debt figures
814
- 3. **Inconsistent**: field availability changes without notice
815
-
816
- KAP (the official Turkish disclosure platform) is the authoritative source, but programmatic access requires an MKK API key and IP whitelisting. The `kap-mcp` library exists for this purpose [citation:2][citation:12] but cannot be used without institutional credentials.
817
-
818
- For a production-grade system, you need either:
819
- - KAP API access (via MKK)
820
- - A paid data vendor (Bloomberg, Refinitiv, Matriks, İş Yatırım)
821
- - BilancoVeri's API (free but needs validation) [citation:11]
822
  """)
823
 
824
  return demo
 
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.
 
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",
 
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):
 
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
  # ============================================================================
 
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:
 
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")
 
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", ""),
 
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))
 
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:
 
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")
 
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
 
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)
 
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")
 
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
 
 
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:
 
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
 
 
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"
 
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
  "",
 
705
 
706
  markdown = "\n".join(lines)
707
 
 
708
  try:
709
  ticker = yf.Ticker(symbol)
710
  hist = ticker.history(period="1y")
 
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"]
 
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
 
 
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