Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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 |
-
|
| 7 |
-
-
|
| 8 |
-
-
|
| 9 |
-
-
|
| 10 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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 |
-
|
| 197 |
-
|
| 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 |
-
#
|
| 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 |
-
|
|
|
|
| 231 |
components_available += 1
|
| 232 |
if data["current_ratio"] > 1.0:
|
| 233 |
score += 1
|
| 234 |
|
| 235 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 247 |
"""
|
| 248 |
-
|
| 249 |
-
|
| 250 |
"""
|
| 251 |
scores = []
|
| 252 |
weights = []
|
| 253 |
missing = 0
|
| 254 |
total = 0
|
| 255 |
|
| 256 |
-
|
|
|
|
|
|
|
| 257 |
total += 1
|
| 258 |
ey = data.get("earnings_yield")
|
| 259 |
if ey is not None and ey > 0:
|
| 260 |
-
#
|
| 261 |
-
s = min(100, ey
|
| 262 |
scores.append(s)
|
| 263 |
-
weights.append(0.25)
|
| 264 |
else:
|
| 265 |
missing += 1
|
| 266 |
|
| 267 |
-
# P/B vs sector
|
| 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 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 317 |
scores = []
|
| 318 |
weights = []
|
| 319 |
missing = 0
|
| 320 |
total = 0
|
|
|
|
| 321 |
|
| 322 |
-
# ROE
|
| 323 |
total += 1
|
| 324 |
roe = data.get("roe")
|
| 325 |
if roe is not None:
|
| 326 |
-
s = min(100, max(0, roe
|
| 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.
|
| 349 |
else:
|
| 350 |
missing += 1
|
| 351 |
|
| 352 |
-
#
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 403 |
-
|
|
|
|
|
|
|
| 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
|
| 424 |
# ============================================================================
|
| 425 |
|
| 426 |
-
def compute_12m_forecast(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
"""
|
| 428 |
Scenario-based 12-month price target.
|
| 429 |
-
|
| 430 |
METHODOLOGY (explicit):
|
| 431 |
-
- Bear:
|
| 432 |
-
- Base:
|
| 433 |
-
- Bull:
|
| 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 |
-
#
|
| 447 |
-
|
| 448 |
-
base_growth =
|
| 449 |
if earnings_yield and earnings_yield > 0:
|
| 450 |
-
base_growth = min(0.
|
| 451 |
|
| 452 |
-
bear_return = -0.20
|
| 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 |
-
#
|
| 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
|
| 476 |
"note": "Heuristic scenario, not calibrated forecast",
|
| 477 |
}
|
| 478 |
|
| 479 |
|
| 480 |
# ============================================================================
|
| 481 |
-
# SECTOR MEDIANS
|
| 482 |
# ============================================================================
|
| 483 |
|
| 484 |
def compute_sector_medians(all_data: List[Dict[str, Any]]) -> Dict[str, Dict[str, float]]:
|
| 485 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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(
|
| 514 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
all_data = []
|
| 516 |
failed = []
|
| 517 |
|
|
|
|
| 518 |
for i, sym in enumerate(BIST_UNIVERSE):
|
| 519 |
-
|
|
|
|
|
|
|
| 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 |
-
"
|
| 541 |
-
"
|
| 542 |
-
"
|
| 543 |
-
"
|
|
|
|
|
|
|
| 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
|
| 549 |
-
"
|
| 550 |
-
"12M Base
|
| 551 |
-
"
|
| 552 |
"Confidence": score_result["confidence"],
|
| 553 |
})
|
| 554 |
|
| 555 |
df = pd.DataFrame(rows)
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
return df
|
| 559 |
|
| 560 |
|
| 561 |
-
def get_company_detail(
|
| 562 |
-
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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 |
-
|
| 593 |
-
|
| 594 |
-
f"| Price | {data['price']} |",
|
| 595 |
-
f"| Market Cap | {data['market_cap']
|
| 596 |
-
f"| Trailing P/E | {data['trailing_pe']
|
| 597 |
-
f"| P/
|
| 598 |
-
f"|
|
| 599 |
-
f"|
|
| 600 |
-
f"|
|
| 601 |
-
f"|
|
|
|
|
| 602 |
"",
|
| 603 |
"### Quality",
|
| 604 |
"",
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
f"| ROE | {data['roe']*100
|
| 608 |
-
f"| ROA | {data['roa']*100
|
| 609 |
-
f"| Operating Margin | {data['operating_margin']*100
|
| 610 |
-
f"| Debt/Equity | {data['debt_to_equity']
|
| 611 |
-
f"| Current Ratio | {data['current_ratio']
|
| 612 |
-
f"| Piotroski (partial) | {score['piotroski']
|
| 613 |
"",
|
| 614 |
"### Scores",
|
| 615 |
"",
|
| 616 |
-
f"- **Composite**: {score['composite_score']
|
| 617 |
-
f"- **Valuation**: {score['valuation_score']
|
| 618 |
-
f"- **Quality**: {score['quality_score']
|
| 619 |
"",
|
| 620 |
"### 12-Month Scenarios (heuristic, not calibrated)",
|
| 621 |
"",
|
| 622 |
-
f"
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
f"|
|
| 627 |
-
f"|
|
|
|
|
|
|
|
| 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
|
| 663 |
# ============================================================================
|
| 664 |
|
| 665 |
-
def data_quality_report() -> pd.DataFrame:
|
| 666 |
-
"""Report data completeness across the universe."""
|
| 667 |
rows = []
|
| 668 |
-
|
|
|
|
|
|
|
|
|
|
| 669 |
data = fetch_ticker_data(sym)
|
| 670 |
if data is None:
|
| 671 |
rows.append({"Symbol": sym.replace(".IS", ""), "Status": "FETCH FAILED",
|
| 672 |
-
"Fields
|
| 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
|
| 683 |
-
"
|
| 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 |
-
**⚠️
|
| 701 |
-
-
|
| 702 |
-
-
|
| 703 |
-
-
|
| 704 |
-
-
|
| 705 |
-
-
|
|
|
|
| 706 |
""")
|
| 707 |
|
| 708 |
with gr.Tabs():
|
| 709 |
-
#
|
| 710 |
with gr.Tab("📊 Market Scanner"):
|
| 711 |
with gr.Row():
|
| 712 |
-
scan_btn = gr.Button("Run Scanner", variant="primary", scale=
|
| 713 |
-
refresh_cb = gr.Checkbox(label="Force refresh
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 724 |
-
|
|
|
|
|
|
|
|
|
|
| 725 |
if df.empty:
|
| 726 |
-
return
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 730 |
|
| 731 |
-
#
|
| 732 |
with gr.Tab("🔍 Company Analysis"):
|
| 733 |
with gr.Row():
|
| 734 |
-
ticker_input = gr.Textbox(label="Ticker (e.g., THYAO, GARAN)", value="THYAO")
|
| 735 |
-
|
|
|
|
|
|
|
| 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 |
-
#
|
| 747 |
with gr.Tab("📋 Data Quality Monitor"):
|
| 748 |
gr.Markdown("""
|
| 749 |
-
|
| 750 |
-
|
| 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 |
-
#
|
| 763 |
-
with gr.Tab("📐 Methodology"):
|
| 764 |
gr.Markdown("""
|
| 765 |
-
###
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
-
|
| 798 |
-
-
|
| 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
|