GoshawkVortexAI commited on
Commit
ef6dd5e
·
verified ·
1 Parent(s): 56687a8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +284 -13
app.py CHANGED
@@ -4,7 +4,7 @@ BIST Value Intelligence v3 — Compact Value & Intrinsic Valuation Platform
4
  Data integrity > model complexity. Intrinsic value > momentum. Margin of safety > prediction.
5
 
6
  Veri etiketleri: [R] raporlanan [D] türetilen [A] kullanıcı varsayımı
7
- UYARI: yfinance verisi point-in-time DEĞİLDİR; backtest için kullanılamaz.
8
  """
9
 
10
  import logging
@@ -128,6 +128,14 @@ def norm_sym(t: str) -> str:
128
  return t if t.endswith(".IS") else t + ".IS"
129
 
130
 
 
 
 
 
 
 
 
 
131
  def mk_params(infl, rw, rtg, rg, pt, cv) -> Dict[str, float]:
132
  return dict(infl=infl / 100, rw=rw / 100, rtg=rtg / 100, rg=rg / 100, pt=pt / 100, conv=cv / 100)
133
 
@@ -288,12 +296,8 @@ def fetch(symbol: str, force: bool = False) -> Optional[Dict[str, Any]]:
288
  net_debt = (s["debt"][0] - s["cash"][0]) if (s["debt"][0] is not None and s["cash"][0] is not None) else None
289
  fcf0, int0 = s["fcf"][0], s["interest"][0]
290
  fcff = (fcf0 + int0 * (1 - tr)) if (fcf0 is not None and int0 is not None) else None # unlevered -> net debt bridge
291
- oe, oe_parts = None, None
292
  ni0, da0, cx0, wc0 = s["ni"][0], s["da"][0], s["capex"][0], s["dwc"][0]
293
- if None not in (ni0, da0, cx0, wc0):
294
- maint = min(cx0, da0) # [D] estimate: maintenance capex ~ min(total capex, D&A)
295
- oe = ni0 + da0 - maint + wc0
296
- oe_parts = dict(ni=ni0, da=da0, capex_total=cx0, maint_est=maint, dwc=wc0)
297
  ev = (mcap + net_debt) if (mcap and net_debt is not None) else num(info.get("enterpriseValue"))
298
  ebitda0 = s["ebitda"][0]
299
  industry = info.get("industry") or "Unknown"
@@ -310,7 +314,7 @@ def fetch(symbol: str, force: bool = False) -> Optional[Dict[str, Any]]:
310
  "shares": shares, "sector": info.get("sector") or "Unknown", "industry": industry, "seg": seg,
311
  "bvps": bvps, "eps": eps, "dy": dy, "s": s, "flags": flags, "unit_ok": unit_ok, "stmt_date": sd, "age": age,
312
  "tax_rate": tr, "tax_assumed": tax_assumed, "net_debt": net_debt, "fcff": fcff, "fcf0": fcf0, "oe": oe,
313
- "oe_parts": oe_parts, "ev": ev, "sust_roe": sust_roe,
314
  "pe": price / eps if eps and eps > 0 else None,
315
  "fpe": num(info.get("forwardPE")) if (num(info.get("forwardPE")) or 0) > 0 else None,
316
  "pb": price / bvps if bvps and bvps > 0 else None,
@@ -626,7 +630,7 @@ def analyze(d, P, med, probs=(0.25, 0.5, 0.25)):
626
  vi += [(lin(d["pb"], 1.5, 0.4), 3), (pe_s, 1)]
627
  else:
628
  vi += [(pe_s, 1), (pb_s, 1), (lin(d["ev_ebitda"], 15, 3), 1), (lin(d["fcf_yield"], -0.02, 0.12), 1),
629
- (lin(d["earn_yield"], 0, 0.25), 1)]
630
  if bank:
631
  qi = [(lin(rroe, -0.1, 0.2), 4), (lin(q["roa"], 0, 0.03), 2), (lin(q["ni_cv"], 2, 0.3), 2),
632
  (lin(d["dy"], 0, 0.08), 1), (lin(rni, -0.2, 0.3), 1)]
@@ -693,6 +697,7 @@ def scan_row(A):
693
  d, q = A["d"], A["q"]
694
  sc, n, _ = A["pio"]
695
  e = A["exp"]
 
696
  return {
697
  "Ticker": d["sym"], "Segment": d["seg"], "Sector": d["sector"], "Price": round(d["price"], 2),
698
  "Composite": None if A["composite"] is None else round(A["composite"], 1),
@@ -702,6 +707,9 @@ def scan_row(A):
702
  "ROE %": None if q["roe"] is None else round(q["roe"] * 100, 1),
703
  "ROIC %": None if q["roic"] is None else round(q["roic"] * 100, 1),
704
  "FCF Yield %": None if d["fcf_yield"] is None else round(d["fcf_yield"] * 100, 1),
 
 
 
705
  "Piotroski": f"{sc}/{n} (cov {n}/9)" if n else "N/A",
706
  "Risk": None if A["risk"] is None else round(A["risk"], 0),
707
  "Exp Return % (nom, incl div)": None if not e else round(e["total"] * 100, 1),
@@ -756,6 +764,8 @@ def md_report(A, P):
756
  L.append("\n| Yöntem | Değer/hisse | Not |\n|---|---|---|")
757
  for k, (v, n) in A["methods"].items():
758
  L.append(f"| {k} | {fm(v)} | {n} |")
 
 
759
  if d["flags"]:
760
  L.append("\n**⚠️ Veri bayrakları:** " + "; ".join(d["flags"]))
761
  L.append("\n*Intrinsic = mevcut yöntemlerin medyanı. Skorlar evren içi göreceli, açıklanabilir heuristiklerdir.*")
@@ -810,16 +820,255 @@ def self_test() -> str:
810
  chk("Sector median with 5 peers computed", build_medians(ds)[("Industrial", "X")]["pe"][0] == 12.0 or build_medians(ds)[("Industrial", "X")]["pe"][0] is not None)
811
  chk("Segment classification", segment_of("Banks - Regional", "GARAN") == "Bank" and segment_of("Real Estate", "EKGYO") == "GYO"
812
  and segment_of("Insurance - Life", "ANHYT") == "Insurance" and segment_of("Steel", "EREGL") == "Industrial")
 
 
 
 
 
 
 
 
 
 
 
813
  return "\n".join(res)
814
 
815
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
816
  # ============================================================================
817
  # GRADIO UI
818
  # ============================================================================
819
 
820
  def build_ui():
821
  with gr.Blocks(title="BIST Value Intelligence v3", theme=gr.themes.Soft()) as demo:
822
- gr.Markdown("# BIST Value Intelligence v3\n**⚠️ yfinance verisi point-in-time değildir; backtest için kullanılamaz. "
823
  "Eksik veri `N/A` gösterilir, asla sıfırla doldurulmaz. 12 aylık senaryolar heuristiktir, istatistiksel tahmin değildir. "
824
  "Yatırım tavsiyesi değildir.**")
825
  with gr.Accordion("⚙️ Varsayımlar [A] (tüm sekmeler için)", open=False):
@@ -844,7 +1093,7 @@ def build_ui():
844
  f_c = gr.Slider(0, 100, 0, step=5, label="Min composite")
845
  f_m = gr.Slider(-100, 100, -100, step=5, label="Min MoS % (-100 = kapalı)")
846
  with gr.Row():
847
- f_sort = gr.Dropdown(["Composite", "MoS %", "P/E", "P/B", "ROE %", "ROIC %", "FCF Yield %", "Risk",
848
  "Exp Return % (nom, incl div)", "Real Exp Return %", "Coverage %"],
849
  value="Composite", label="Sırala")
850
  f_desc = gr.Checkbox(label="Azalan", value=True)
@@ -913,7 +1162,7 @@ def build_ui():
913
  if d["oe_parts"]:
914
  o = d["oe_parts"]
915
  L.append(f"**Owner Earnings [D]:** NI {fm(o['ni'], 0)} + D&A {fm(o['da'], 0)} − bakım capex (tahmin=min(capex, D&A)) "
916
- f"{fm(o['maint_est'], 0)} + ΔİS sermayesi {fm(o['dwc'], 0)} = **{fm(d['oe'], 0)}** (toplam capex {fm(o['capex_total'], 0)})")
917
  L.append("\n| Yöntem | Değer | Not |\n|---|---|---|")
918
  for k, (v, n) in A["methods"].items():
919
  L.append(f"| {k} | {fm(v)} | {n} |")
@@ -1101,6 +1350,28 @@ def build_ui():
1101
 
1102
  b8.click(dq, None, [md8, tb8])
1103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1104
  with gr.Tab("📐 Methodology"):
1105
  gr.Markdown(METHODOLOGY)
1106
  b9 = gr.Button("Run self-test (synthetic data)")
@@ -1112,7 +1383,7 @@ def build_ui():
1112
  METHODOLOGY = """
1113
  ### Değerleme mimarisi
1114
  - **DCF (sanayi/GYO dışı):** FCFF = FCF + faiz×(1−vergi) (Yahoo FCF'si faiz sonrası olduğu için borç çift sayılmasın diye). 5 yıl, büyüme yıl-1'den terminale doğrusal sönümlenir; EV − net borç = özsermaye; / hisse. Girdi eksik, FCFF ≤ 0 veya iskonto ≤ terminal büyüme ise **hesaplanmaz**.
1115
- - **Owner Earnings:** NI + D&A − bakım capex + ΔİSermayesi. Bakım capex = min(toplam capex, D&A) **tahminidir**; veri eksikse yöntem kullanılmaz. Özsermaye nakit akışı olduğu için net borç düşülmez.
1116
  - **Banka/Sigorta:** Gordon gerekçeli P/B = (sürdürülebilir ROE − g)/(COE − g) × defter değeri. FCF/EV-EBITDA kullanılmaz. Sermaye yeterliliği/aktif kalitesi Yahoo'da yok.
1117
  - **GYO:** NAV vekili = defter değeri (ekspertiz NAV'ı yok), P/NAV. Doluluk verisi yok.
1118
  - **Relative:** sektör medyanı (en az 5 geçerli peer) P/E, P/B, EV/EBITDA → hisse başına değer.
@@ -1133,7 +1404,7 @@ Composite = ağırlıklı ortalama (mevcut bileşenler üzerinden yeniden normal
1133
  Risk (0-100): 10 bayrak (distress, kaldıraç, likidite, negatif FCF, kâr/marj bozulması, CFO-NI ayrışması, seyrelme, aşırı değerleme, veri güvenilirliği); yalnızca değerlendirilebilen bayrakların ağırlığı üzerinden hesaplanır. Piotroski 9 bileşenli; hesaplanamayan bileşen "N/A" olur ve skor `x/n` olarak gösterilir.
1134
 
1135
  ### Bilinen veri sınırlamaları
1136
- - yfinance point-in-time değildir; backtest geçersizdir. Bilanço tarihleri ve kapsam hisseden hisseye değişir.
1137
  - Yıllık tablolar gecikmelidir; TFRS/enflasyon muhasebesi (TMS 29) etkileri nominal rakamları çarpıtabilir.
1138
  - Evren: gömülü liste + eklediğiniz tickerlar (tüm BIST listesi Yahoo'dan ücretsiz çekilemez).
1139
  - Sektör/segment sınıflaması Yahoo `industry` alanına dayanır; peer sayısı <5 ise medyan hesaplanmaz.
 
4
  Data integrity > model complexity. Intrinsic value > momentum. Margin of safety > prediction.
5
 
6
  Veri etiketleri: [R] raporlanan [D] türetilen [A] kullanıcı varsayımı
7
+ UYARI: yfinance verisi point-in-time DEĞİLDİR; Backtest sekmesi yalnızca DENEYSEL/göstergedir (bkz. sınırlamalar).
8
  """
9
 
10
  import logging
 
128
  return t if t.endswith(".IS") else t + ".IS"
129
 
130
 
131
+ def owner_earnings(ni, da, capex, dwc):
132
+ """OE = NI + D&A − bakım capex + ΔİSermayesi; bakım capex = min(toplam capex, D&A) [D tahmin]. Eksik girdi → None."""
133
+ if None in (ni, da, capex, dwc):
134
+ return None, None
135
+ maint = min(capex, da)
136
+ return ni + da - maint + dwc, dict(ni=ni, da=da, capex_total=capex, maint_est=maint, dwc=dwc)
137
+
138
+
139
  def mk_params(infl, rw, rtg, rg, pt, cv) -> Dict[str, float]:
140
  return dict(infl=infl / 100, rw=rw / 100, rtg=rtg / 100, rg=rg / 100, pt=pt / 100, conv=cv / 100)
141
 
 
296
  net_debt = (s["debt"][0] - s["cash"][0]) if (s["debt"][0] is not None and s["cash"][0] is not None) else None
297
  fcf0, int0 = s["fcf"][0], s["interest"][0]
298
  fcff = (fcf0 + int0 * (1 - tr)) if (fcf0 is not None and int0 is not None) else None # unlevered -> net debt bridge
 
299
  ni0, da0, cx0, wc0 = s["ni"][0], s["da"][0], s["capex"][0], s["dwc"][0]
300
+ oe, oe_parts = owner_earnings(ni0, da0, cx0, wc0) # bakım capex = min(capex, D&A) [D tahmin]
 
 
 
301
  ev = (mcap + net_debt) if (mcap and net_debt is not None) else num(info.get("enterpriseValue"))
302
  ebitda0 = s["ebitda"][0]
303
  industry = info.get("industry") or "Unknown"
 
314
  "shares": shares, "sector": info.get("sector") or "Unknown", "industry": industry, "seg": seg,
315
  "bvps": bvps, "eps": eps, "dy": dy, "s": s, "flags": flags, "unit_ok": unit_ok, "stmt_date": sd, "age": age,
316
  "tax_rate": tr, "tax_assumed": tax_assumed, "net_debt": net_debt, "fcff": fcff, "fcf0": fcf0, "oe": oe,
317
+ "oe_yield": div(oe, mcap) if unit_ok else None, "oe_parts": oe_parts, "ev": ev, "sust_roe": sust_roe,
318
  "pe": price / eps if eps and eps > 0 else None,
319
  "fpe": num(info.get("forwardPE")) if (num(info.get("forwardPE")) or 0) > 0 else None,
320
  "pb": price / bvps if bvps and bvps > 0 else None,
 
630
  vi += [(lin(d["pb"], 1.5, 0.4), 3), (pe_s, 1)]
631
  else:
632
  vi += [(pe_s, 1), (pb_s, 1), (lin(d["ev_ebitda"], 15, 3), 1), (lin(d["fcf_yield"], -0.02, 0.12), 1),
633
+ (lin(d["oe_yield"], -0.02, 0.12), 1), (lin(d["earn_yield"], 0, 0.25), 1)]
634
  if bank:
635
  qi = [(lin(rroe, -0.1, 0.2), 4), (lin(q["roa"], 0, 0.03), 2), (lin(q["ni_cv"], 2, 0.3), 2),
636
  (lin(d["dy"], 0, 0.08), 1), (lin(rni, -0.2, 0.3), 1)]
 
697
  d, q = A["d"], A["q"]
698
  sc, n, _ = A["pio"]
699
  e = A["exp"]
700
+ oe_v = A["methods"].get("Owner Earnings", (None, ""))[0]
701
  return {
702
  "Ticker": d["sym"], "Segment": d["seg"], "Sector": d["sector"], "Price": round(d["price"], 2),
703
  "Composite": None if A["composite"] is None else round(A["composite"], 1),
 
707
  "ROE %": None if q["roe"] is None else round(q["roe"] * 100, 1),
708
  "ROIC %": None if q["roic"] is None else round(q["roic"] * 100, 1),
709
  "FCF Yield %": None if d["fcf_yield"] is None else round(d["fcf_yield"] * 100, 1),
710
+ "OE Yield %": None if d["oe_yield"] is None else round(d["oe_yield"] * 100, 1),
711
+ "OE Value": None if oe_v is None else round(oe_v, 2),
712
+ "OE MoS %": None if oe_v is None else round((oe_v - d["price"]) / oe_v * 100, 1),
713
  "Piotroski": f"{sc}/{n} (cov {n}/9)" if n else "N/A",
714
  "Risk": None if A["risk"] is None else round(A["risk"], 0),
715
  "Exp Return % (nom, incl div)": None if not e else round(e["total"] * 100, 1),
 
764
  L.append("\n| Yöntem | Değer/hisse | Not |\n|---|---|---|")
765
  for k, (v, n) in A["methods"].items():
766
  L.append(f"| {k} | {fm(v)} | {n} |")
767
+ if d["oe"] is not None:
768
+ L.append(f"\n**Owner Earnings [D]:** {fm(d['oe'], 0)} · OE Yield {fp(d['oe_yield'])}")
769
  if d["flags"]:
770
  L.append("\n**⚠️ Veri bayrakları:** " + "; ".join(d["flags"]))
771
  L.append("\n*Intrinsic = mevcut yöntemlerin medyanı. Skorlar evren içi göreceli, açıklanabilir heuristiklerdir.*")
 
820
  chk("Sector median with 5 peers computed", build_medians(ds)[("Industrial", "X")]["pe"][0] == 12.0 or build_medians(ds)[("Industrial", "X")]["pe"][0] is not None)
821
  chk("Segment classification", segment_of("Banks - Regional", "GARAN") == "Bank" and segment_of("Real Estate", "EKGYO") == "GYO"
822
  and segment_of("Insurance - Life", "ANHYT") == "Insurance" and segment_of("Steel", "EREGL") == "Industrial")
823
+ oe, _ = owner_earnings(100, 50, 80, -10)
824
+ chk("OE: NI100 + DA50 − min(capex80, DA50) + ΔWC(−10) = 90", oe == 90)
825
+ chk("OE: eksik girdi → None (0 değil)", owner_earnings(100, None, 80, 0)[0] is None)
826
+ pr = [dict(end=pd.Timestamp("2023-12-31"), oe=1, fcf=1, ni=1, shares=1)]
827
+ chk("Backtest: tablo dönem sonu+gecikmeden önce kullanılmaz (look-ahead yok)",
828
+ pick_period(pr, pd.Timestamp("2024-02-15"), 90) is None and pick_period(pr, pd.Timestamp("2024-04-15"), 90) is not None)
829
+ chk("Backtest: >550 gün eski tablo reddedilir", pick_period(pr, pd.Timestamp("2025-09-01"), 90) is None)
830
+ st = bt_stats(pd.Series([100, 110, 121], index=pd.to_datetime(["2020-01-01", "2021-01-01", "2022-01-01"])))
831
+ chk("Backtest: 100→121 / 2 yıl → CAGR≈10%", st is not None and abs(st["cagr"] - 0.10) < 0.005)
832
+ st2 = bt_stats(pd.Series([100, 80, 90], index=pd.to_datetime(["2020-01-01", "2020-06-01", "2021-01-01"])))
833
+ chk("Backtest: max drawdown −20%", st2 is not None and abs(st2["mdd"] + 0.2) < 1e-9)
834
  return "\n".join(res)
835
 
836
 
837
+ # ============================================================================
838
+ # BACKTEST (DENEYSEL, OE odaklı) — point-in-time DEĞİL, yalnızca gösterge
839
+ # ============================================================================
840
+ BT_DEFAULT_LAG = 90 # yıllık tablo, dönem sonu + N gün sonra "yayınlanmış" sayılır
841
+ BT_MAX_AGE = 550 # dönem sonu üzerinden bu kadar gün geçmiş tablo kullanılmaz
842
+ BT_SIGS = {"OE": "OE Yield (top N)", "FCF": "FCF Yield (top N)", "NI": "Kazanç Getirisi (top N)"}
843
+
844
+
845
+ def stmt_at(df, names, date):
846
+ """Tabloda tam o dönem sonu sütununu bul (kolon sırasına güvenme)."""
847
+ if df is None or getattr(df, "empty", True):
848
+ return None
849
+ cols = [pd.Timestamp(c).normalize() for c in df.columns]
850
+ try:
851
+ i = cols.index(pd.Timestamp(date).normalize())
852
+ except ValueError:
853
+ return None
854
+ return stmt(df, names, i)
855
+
856
+
857
+ def _naive_index(s):
858
+ s = s[~s.index.duplicated(keep="last")].sort_index()
859
+ s.index = pd.DatetimeIndex(s.index).tz_localize(None).normalize()
860
+ return s
861
+
862
+
863
+ def fetch_bt(symbol: str, force: bool = False) -> Optional[Dict[str, Any]]:
864
+ key = f"bt_{symbol}"
865
+ if not force:
866
+ c = _cget(key)
867
+ if c is not None:
868
+ return c
869
+ try:
870
+ t = yf.Ticker(symbol)
871
+ info = t.info or {}
872
+ fin, bs, cf = t.financials, t.balance_sheet, t.cashflow
873
+ h = t.history(period="max", auto_adjust=False)
874
+ try:
875
+ spl = t.splits
876
+ except Exception:
877
+ spl = None
878
+ except Exception as e:
879
+ logger.warning("%s bt fetch failed: %s", symbol, e)
880
+ return None
881
+ if h is None or h.empty or fin is None or fin.empty or "Close" not in h:
882
+ return None
883
+ close = _naive_index(h["Close"].dropna()) # split-ayarlı, temettü-ayarsız → piyasa değeri için
884
+ adj = _naive_index(h["Adj Close"].dropna()) if "Adj Close" in h else close # toplam getiri için
885
+ if spl is not None and len(spl):
886
+ spl = _naive_index(spl)
887
+ fc, cur = info.get("financialCurrency"), info.get("currency")
888
+ unit_ok = (fc is None) or (cur is None) or (fc == cur)
889
+ seg = segment_of(info.get("industry") or "", symbol.replace(".IS", ""))
890
+ shares_now = num(info.get("sharesOutstanding"))
891
+ periods = []
892
+ for c in fin.columns:
893
+ end = pd.Timestamp(c).normalize()
894
+ ni = stmt_at(fin, STMT["ni"][0], end)
895
+ da, cx, wc = (stmt_at(cf, STMT[k][0], end) for k in ("da", "capex", "dwc"))
896
+ cx = abs(cx) if cx is not None else None
897
+ cfo, fcf = stmt_at(cf, STMT["cfo"][0], end), stmt_at(cf, STMT["fcf"][0], end)
898
+ if fcf is None and cfo is not None and cx is not None:
899
+ fcf = cfo - cx
900
+ oe, _ = owner_earnings(ni, da, cx, wc)
901
+ sh = stmt_at(bs, STMT["shares"][0], end)
902
+ if sh is not None and spl is not None and len(spl):
903
+ sh *= float(spl[spl.index > end].prod()) # bedelsiz/bölünme sonrası hisse sayısına getir (fiyat serisiyle tutarlı)
904
+ elif sh is None:
905
+ sh = shares_now # fallback: güncel hisse sayısı (güncel split-ayarlı fiyatla tutarlı)
906
+ periods.append(dict(end=end, oe=oe, fcf=fcf, ni=ni, shares=sh))
907
+ periods.sort(key=lambda p: p["end"])
908
+ d = dict(sym=symbol.replace(".IS", ""), seg=seg, unit_ok=unit_ok, periods=periods, close=close, adj=adj)
909
+ _cset(key, d)
910
+ return d
911
+
912
+
913
+ def pick_period(periods, date, lag):
914
+ """Look-ahead koruması: yalnızca (dönem sonu + lag) <= tarih olan ve çok eski olmayan en güncel tablo."""
915
+ best = None
916
+ for p in periods:
917
+ if p["end"] + pd.Timedelta(days=lag) <= date and (date - p["end"]).days <= BT_MAX_AGE:
918
+ if best is None or p["end"] > best["end"]:
919
+ best = p
920
+ return best
921
+
922
+
923
+ def px_asof(s, date, tol=10):
924
+ i = s.index.searchsorted(date, side="right") - 1
925
+ if i < 0 or (date - s.index[i]).days > tol:
926
+ return None
927
+ return float(s.iloc[i])
928
+
929
+
930
+ def bt_signals(bd, date, lag):
931
+ p = pick_period(bd["periods"], date, lag)
932
+ if p is None or not bd["unit_ok"] or not p["shares"] or p["shares"] <= 0:
933
+ return None
934
+ c = px_asof(bd["close"], date)
935
+ if c is None or c <= 0:
936
+ return None
937
+ mc = c * p["shares"]
938
+ return {"OE": div(p["oe"], mc), "FCF": div(p["fcf"], mc), "NI": div(p["ni"], mc), "end": p["end"]}
939
+
940
+
941
+ def bt_stats(curve):
942
+ c = curve.dropna()
943
+ if len(c) < 3:
944
+ return None
945
+ yrs = (c.index[-1] - c.index[0]).days / 365.25
946
+ tot = c.iloc[-1] / c.iloc[0] - 1
947
+ cagr = (1 + tot) ** (1 / yrs) - 1 if yrs >= 0.25 and tot > -1 else None
948
+ vol = float(c.pct_change().dropna().std() * np.sqrt(252))
949
+ mdd = float((c / c.cummax() - 1).min())
950
+ return dict(total=tot, cagr=cagr, vol=vol, mdd=mdd, rv=(cagr / vol) if (cagr is not None and vol > 0) else None)
951
+
952
+
953
+ def run_backtest(syms, step_m, top_n, lag, cost_bps, progress):
954
+ data, failed = {}, []
955
+ for i, s in enumerate(syms):
956
+ progress((i + 1) / len(syms), desc=f"Fetching {s}")
957
+ was = _cget(f"bt_{s}") is not None
958
+ bd = fetch_bt(s)
959
+ if bd is None:
960
+ failed.append(s.replace(".IS", ""))
961
+ elif bd["seg"] == "Industrial": # OE banka/sigorta/GYO için anlamlı değil
962
+ data[s] = bd
963
+ if not was:
964
+ time.sleep(REQUEST_DELAY_SEC)
965
+ ends = [p["end"] for bd in data.values() for p in bd["periods"]]
966
+ if not ends:
967
+ return None, None, None, "⚠️ Backtest için yeterli veri çekilemedi."
968
+ T = max(bd["close"].index[-1] for bd in data.values())
969
+ start = (min(ends) + pd.Timedelta(days=lag)).replace(day=1)
970
+ dates = [d for d in pd.date_range(start, T, freq=pd.DateOffset(months=int(step_m))) if d < T]
971
+ keys = list(BT_SIGS) + ["EW"]
972
+ equity, hold, segs, per_ret = {k: 100.0 for k in keys}, {k: [] for k in keys}, {k: [] for k in keys}, {k: [] for k in keys}
973
+ started, n_reb, first_date, n_hold = False, 0, None, []
974
+ for j, D in enumerate(dates):
975
+ D1 = dates[j + 1] if j + 1 < len(dates) else T
976
+ sig = {s: bt_signals(bd, D, lag) for s, bd in data.items()}
977
+ sig = {s: v for s, v in sig.items() if v is not None}
978
+ n_oe = sum(1 for v in sig.values() if v["OE"] is not None)
979
+ if not started:
980
+ if n_oe < max(10, top_n):
981
+ continue
982
+ started, first_date = True, D
983
+ n_reb += 1
984
+ for k in keys:
985
+ if k == "EW":
986
+ new_h = [s for s, v in sig.items() if v["OE"] is not None]
987
+ else:
988
+ rk = sorted(((v[k], s) for s, v in sig.items() if v[k] is not None and v[k] > 0), reverse=True)
989
+ new_h = [s for _, s in rk[:top_n]] if len(rk) >= 3 else hold[k]
990
+ rels = []
991
+ for s in new_h:
992
+ a = data[s]["adj"]
993
+ a0 = px_asof(a, D)
994
+ seg = a[(a.index > D) & (a.index <= D1)]
995
+ if a0 is None or seg.empty:
996
+ continue
997
+ rels.append(seg / a0)
998
+ old = hold[k]
999
+ turn = (1 - len(set(new_h) & set(old)) / max(len(new_h), 1)) if old else 1.0
1000
+ equity[k] *= 1 - cost_bps / 1e4 * (turn * 2 if old else 1)
1001
+ hold[k] = new_h
1002
+ if not rels:
1003
+ continue
1004
+ path = pd.concat(rels, axis=1).sort_index().ffill().fillna(1.0).mean(axis=1)
1005
+ per_ret[k].append(float(path.iloc[-1] - 1))
1006
+ segs[k].append(path * equity[k])
1007
+ equity[k] = float(segs[k][-1].iloc[-1])
1008
+ if k == "OE":
1009
+ n_hold.append(len(rels))
1010
+ if not started or not segs["OE"]:
1011
+ return None, None, None, ("⚠️ Yeterli sinyal yok: yfinance yıllık tablo geçmişi kısa/eksik olduğundan "
1012
+ "en az 10 hissede OE hesaplanabilen bir başlangıç tarihi bulunamadı.")
1013
+ curves = {}
1014
+ for k in keys:
1015
+ if segs[k]:
1016
+ s = pd.concat(segs[k])
1017
+ curves[k] = pd.concat([pd.Series({first_date: 100.0}), s[~s.index.duplicated(keep="last")]]).sort_index()
1018
+ try: # benchmark: BIST100
1019
+ hb = _naive_index(yf.Ticker("XU100.IS").history(start=(first_date - pd.Timedelta(days=10)).strftime("%Y-%m-%d"))["Close"].dropna())
1020
+ b0 = px_asof(hb, first_date)
1021
+ if b0:
1022
+ curves["XU100"] = hb[hb.index >= first_date] / b0 * 100
1023
+ except Exception as e:
1024
+ logger.warning("XU100 fetch failed: %s", e)
1025
+ names = {**BT_SIGS, "EW": "Evren eşit ağırlık (OE verisi olan)", "XU100": "BIST100 (XU100)"}
1026
+ fig = go.Figure()
1027
+ for k, c in curves.items():
1028
+ fig.add_trace(go.Scatter(x=c.index, y=c.values, mode="lines", name=names[k],
1029
+ line=dict(width=3 if k == "OE" else 1.5, dash="dot" if k in ("EW", "XU100") else "solid")))
1030
+ fig.update_layout(title="Kümülatif değer (başlangıç=100, nominal TRY, toplam getiri)", template="plotly_white", height=420,
1031
+ margin=dict(l=10, r=10, t=40, b=10), legend=dict(orientation="h", y=-0.15))
1032
+ rows = []
1033
+ for k, c in curves.items():
1034
+ st = bt_stats(c)
1035
+ if st:
1036
+ rows.append({"Strateji": names[k], "Toplam %": round(st["total"] * 100, 1),
1037
+ "CAGR %": None if st["cagr"] is None else round(st["cagr"] * 100, 1),
1038
+ "Vol % (yıllık)": round(st["vol"] * 100, 1), "Max DD %": round(st["mdd"] * 100, 1),
1039
+ "CAGR/Vol": None if st["rv"] is None else round(st["rv"], 2)})
1040
+ stats = pd.DataFrame(rows)
1041
+ pairs = list(zip(per_ret["OE"], per_ret["EW"]))
1042
+ hit = (sum(1 for a, b in pairs if a > b) / len(pairs)) if pairs else None
1043
+ # güncel OE sepeti (bugünkü sinyal)
1044
+ cur = {s: bt_signals(bd, T, lag) for s, bd in data.items()}
1045
+ cur = {s: v for s, v in cur.items() if v and v["OE"] is not None and v["OE"] > 0}
1046
+ top = sorted(cur.items(), key=lambda kv: kv[1]["OE"], reverse=True)[:top_n]
1047
+ picks = pd.DataFrame([{"Ticker": data[s]["sym"], "OE Yield %": round(v["OE"] * 100, 1),
1048
+ "FCF Yield %": None if v["FCF"] is None else round(v["FCF"] * 100, 1),
1049
+ "Kazanç Get. %": None if v["NI"] is None else round(v["NI"] * 100, 1),
1050
+ "Tablo dönemi": v["end"].date().isoformat()} for s, v in top])
1051
+ msg = (f"✅ {len(data)} sanayi hissesi · {n_reb} rebalans ({first_date.date()} → {T.date()}) · ort. {np.mean(n_hold):.0f} hisse/sepet · "
1052
+ f"OE sepeti, evren eşit ağırlığı {('%' + format(hit * 100, '.0f') + ' dönemde geçti') if hit is not None else 'N/A'} "
1053
+ f"({len(pairs)} dönem)." + (f" ⚠️ Çekilemeyen ({len(failed)}): " + ", ".join(failed) if failed else ""))
1054
+ return fig, stats, picks, msg
1055
+
1056
+
1057
+ BT_NOTES = """**⚠️ Deneysel — sonuçlar yatırım kararı için kullanılamaz.**
1058
+ - **Dönem kısa:** yfinance yalnızca ~4 yıllık yıllık tablo verir → backtest ~3 yıl; istatistiksel olarak anlamsızdır.
1059
+ - **Look-ahead kısmen önlenir:** tablo, dönem sonu + gecikme (varsayılan 90 gün) sonra kullanılır; gerçek yayın tarihi bilinmez, yeniden düzenlemeler (TMS 29 dahil) yansımaz.
1060
+ - **Survivorship bias:** evren bugünün hisse listesi; çıkmış/sönmüş hisseler yok.
1061
+ - **Hisse sayısı:** dönem sonu bilanço hisse sayısı × sonraki bölünme/bedelsiz çarpanı; sermaye artırımları/geri alımlar tam yakalanmaz.
1062
+ - Banka/sigorta/GYO hariç (OE anlamlı değil). Getiriler nominal TRY, temettü dahil (Adj Close); maliyet tek yön bps, eşit ağırlık, buy-and-hold dönem içi."""
1063
+
1064
+
1065
  # ============================================================================
1066
  # GRADIO UI
1067
  # ============================================================================
1068
 
1069
  def build_ui():
1070
  with gr.Blocks(title="BIST Value Intelligence v3", theme=gr.themes.Soft()) as demo:
1071
+ gr.Markdown("# BIST Value Intelligence v3\n**⚠️ yfinance verisi point-in-time değildir; Backtest sekmesi yalnızca deneyseldir/göstergedir. "
1072
  "Eksik veri `N/A` gösterilir, asla sıfırla doldurulmaz. 12 aylık senaryolar heuristiktir, istatistiksel tahmin değildir. "
1073
  "Yatırım tavsiyesi değildir.**")
1074
  with gr.Accordion("⚙️ Varsayımlar [A] (tüm sekmeler için)", open=False):
 
1093
  f_c = gr.Slider(0, 100, 0, step=5, label="Min composite")
1094
  f_m = gr.Slider(-100, 100, -100, step=5, label="Min MoS % (-100 = kapalı)")
1095
  with gr.Row():
1096
+ f_sort = gr.Dropdown(["Composite", "MoS %", "P/E", "P/B", "ROE %", "ROIC %", "FCF Yield %", "OE Yield %", "OE MoS %", "Risk",
1097
  "Exp Return % (nom, incl div)", "Real Exp Return %", "Coverage %"],
1098
  value="Composite", label="Sırala")
1099
  f_desc = gr.Checkbox(label="Azalan", value=True)
 
1162
  if d["oe_parts"]:
1163
  o = d["oe_parts"]
1164
  L.append(f"**Owner Earnings [D]:** NI {fm(o['ni'], 0)} + D&A {fm(o['da'], 0)} − bakım capex (tahmin=min(capex, D&A)) "
1165
+ f"{fm(o['maint_est'], 0)} + ΔİS sermayesi {fm(o['dwc'], 0)} = **{fm(d['oe'], 0)}** (toplam capex {fm(o['capex_total'], 0)}) · OE Yield {fp(d['oe_yield'])}")
1166
  L.append("\n| Yöntem | Değer | Not |\n|---|---|---|")
1167
  for k, (v, n) in A["methods"].items():
1168
  L.append(f"| {k} | {fm(v)} | {n} |")
 
1350
 
1351
  b8.click(dq, None, [md8, tb8])
1352
 
1353
+ with gr.Tab("📈 Backtest (OE, deneysel)"):
1354
+ gr.Markdown(BT_NOTES)
1355
+ with gr.Row():
1356
+ bt_top = gr.Slider(5, 30, 10, step=1, label="Sepet büyüklüğü (top N)")
1357
+ bt_step = gr.Dropdown([3, 6, 12], value=3, label="Rebalans (ay)")
1358
+ bt_lag = gr.Slider(30, 180, BT_DEFAULT_LAG, step=15, label="Yayın gecikmesi (gün)")
1359
+ with gr.Row():
1360
+ bt_cost = gr.Slider(0, 100, 20, step=5, label="İşlem maliyeti (bps, tek yön)")
1361
+ bt_n = gr.Slider(20, len(BIST_UNIVERSE), len(BIST_UNIVERSE), step=1, label="Evren: ilk N hisse (az = hızlı)")
1362
+ bt_btn = gr.Button("Backtest çalıştır", variant="primary")
1363
+ bt_msg = gr.Markdown()
1364
+ bt_plot = gr.Plot()
1365
+ bt_stats_df = gr.Dataframe(interactive=False, label="Performans")
1366
+ gr.Markdown("**Güncel OE sepeti** (bugünkü sinyal; öneri değildir)")
1367
+ bt_picks = gr.Dataframe(interactive=False)
1368
+
1369
+ def do_bt(top, step, lag, cost, n, progress=gr.Progress()):
1370
+ fig, stats, picks, msg = run_backtest(BIST_UNIVERSE[: int(n)], int(step), int(top), int(lag), float(cost), progress)
1371
+ return msg, fig, stats, picks
1372
+
1373
+ bt_btn.click(do_bt, [bt_top, bt_step, bt_lag, bt_cost, bt_n], [bt_msg, bt_plot, bt_stats_df, bt_picks])
1374
+
1375
  with gr.Tab("📐 Methodology"):
1376
  gr.Markdown(METHODOLOGY)
1377
  b9 = gr.Button("Run self-test (synthetic data)")
 
1383
  METHODOLOGY = """
1384
  ### Değerleme mimarisi
1385
  - **DCF (sanayi/GYO dışı):** FCFF = FCF + faiz×(1−vergi) (Yahoo FCF'si faiz sonrası olduğu için borç çift sayılmasın diye). 5 yıl, büyüme yıl-1'den terminale doğrusal sönümlenir; EV − net borç = özsermaye; / hisse. Girdi eksik, FCFF ≤ 0 veya iskonto ≤ terminal büyüme ise **hesaplanmaz**.
1386
+ - **Owner Earnings:** NI + D&A − bakım capex + ΔİSermayesi. Bakım capex = min(toplam capex, D&A) **tahminidir**; veri eksikse yöntem kullanılmaz. Özsermaye nakit akışı olduğu için net borç düşülmez. **OE Yield** = OE / piyasa değeri; sanayi segmentinde Valuation skoruna (ağırlık 1) dahildir, taramada OE Yield / OE Value / OE MoS sütunları vardır.
1387
  - **Banka/Sigorta:** Gordon gerekçeli P/B = (sürdürülebilir ROE − g)/(COE − g) × defter değeri. FCF/EV-EBITDA kullanılmaz. Sermaye yeterliliği/aktif kalitesi Yahoo'da yok.
1388
  - **GYO:** NAV vekili = defter değeri (ekspertiz NAV'ı yok), P/NAV. Doluluk verisi yok.
1389
  - **Relative:** sektör medyanı (en az 5 geçerli peer) P/E, P/B, EV/EBITDA → hisse başına değer.
 
1404
  Risk (0-100): 10 bayrak (distress, kaldıraç, likidite, negatif FCF, kâr/marj bozulması, CFO-NI ayrışması, seyrelme, aşırı değerleme, veri güvenilirliği); yalnızca değerlendirilebilen bayrakların ağırlığı üzerinden hesaplanır. Piotroski 9 bileşenli; hesaplanamayan bileşen "N/A" olur ve skor `x/n` olarak gösterilir.
1405
 
1406
  ### Bilinen veri sınırlamaları
1407
+ - yfinance point-in-time değildir; Backtest sekmesi deneyseldir (≈3 yıl, survivorship bias, tahmini yayın gecikmesi) ve kanıt değil göstergedir. Bilanço tarihleri ve kapsam hisseden hisseye değişir.
1408
  - Yıllık tablolar gecikmelidir; TFRS/enflasyon muhasebesi (TMS 29) etkileri nominal rakamları çarpıtabilir.
1409
  - Evren: gömülü liste + eklediğiniz tickerlar (tüm BIST listesi Yahoo'dan ücretsiz çekilemez).
1410
  - Sektör/segment sınıflaması Yahoo `industry` alanına dayanır; peer sayısı <5 ise medyan hesaplanmaz.