İçeriğe geç
Teknik YazıÜrün

Açılış Zili: Piyasayı Tek Yerden Takip Et

Bilanço takvimi bir sitede, makro veriler başka sitede, notlarım bir metin dosyasındaydı. En çok uğraştığım şey veri çekmek değil, bir sayının ne zaman yalan söylemeye başladığını anlamak oldu.

12 Ağustos 202611 dk okuma

Sabahları aynı beş sekmeyi açıyordum. Birinde o gün bilanço açıklayacak şirketler, birinde makro takvim, birinde haberler, birinde takip listem, bir de kendi notlarımın durduğu bir metin dosyası. Hepsi ayrı yerdeydi ve hiçbiri diğerinin ne dediğini bilmiyordu. Açılış Zili bu beş sekmeyi tek sayfaya indirme denemesi.

Yatırım tavsiyesi veren bir site değil; kendi takibim için bir araç. Ama "kendim için" olması işi kolaylaştırmadı — tam tersine, her gün kendim bakacağım için her sayının doğru olmasını istedim. Bu yazı da çoğunlukla o meselenin etrafında dönüyor.

Sitede Ne Var

Yirmi dört sayfa var ama omurga dört parça.

  1. 1

    Bugün

    Açılışa ne kaldı, bugün hangi şirketler bilanço açıklıyor, hangi makro veri saat kaçta geliyor, endeksler nerede. Günün özeti de burada duruyor.

  2. 2

    Bilançolar

    Takvim, geçmiş dönemler ve asıl mesele: her bilanço için yazılan analizler. Sitenin en çok uğraştığım kısmı burası.

  3. 3

    Piyasa

    Endeksler, sektörler, 500'ü aşkın şirketlik dizin, karşılaştırma ekranı, makro göstergeler ve haberler.

  4. 4

    Yazılar

    Günlük ve haftalık bülten, olay bazlı uzun yazılar, bir de terimleri açıklayan rehber bölümü.

Takip listesi, favoriler ve hesap tarafı da var ama onlar standart iş. Anlatmaya değer olan bilanço analizleri.

Bir Bilanço Analizi Neyden Oluşuyor

Her analiz veritabanında tek bir satır ve o satırın şekli epeyce düşünülmüş bir şey. "Şu şirket şunu açıkladı" değil; bir görüş, o görüşün gerekçesi ve gerekçenin dayandığı sayılar bir arada duruyor.

lib/schema.ts
export const earningsAnalyses = pgTable("earnings_analyses", {
  symbol:      text("symbol").notNull(),
  periodLabel: text("period_label").notNull(),   // "4Ç FY2026"
  reportDate:  date("report_date").notNull(),

  /** 0–100. Görüşün kendisi değil, GEREKÇESİNİN YOĞUNLUĞU. */
  score:   integer("score").notNull(),
  verdict: text("verdict").notNull(),            // buy | hold | sell
  /** Kartlarda görünen tek cümlelik hikâye. */
  headline: text("headline").notNull(),

  summary:   jsonb("summary").$type<string[]>().notNull(),
  analysis:  jsonb("analysis").$type<{ title: string; body: string }[]>().notNull(),
  strengths: jsonb("strengths").$type<string[]>(),
  risks:     jsonb("risks").$type<string[]>(),
  /** "Katalizörler" değil: Beklenen Gelişmeler. Tarih taşır. */
  upcoming:  jsonb("upcoming").$type<string[]>(),
})

İki alan üzerinde uzun düşündüm. Birincisi `score`. İlk hâlinde "ne kadar iyi bir yatırım" anlamındaydı ve bu saçmaydı — 100 üzerinden 73 vermek neye dayanıyor? Şimdi ölçtüğü şey farklı: gerekçe ne kadar sağlam, kaç veriye dayanıyor. Görüşün kendisini değil, arkasındaki dolgunun yoğunluğunu.

İkincisi `upcoming`. Başta adı "katalizörler"di. Finans dilinde o kelime "fiyatı yukarı itecek şey" anlamında kullanılıyor, yani tarafsız değil. "Beklenen Gelişmeler" yaptım ve her maddeye tarih koyma kuralı ekledim. Tarihi olmayan bir beklenti, beklenti değil temenni.

Asıl Karar: Oran Değil, Oranın Böleni

Şimdi bu projede verdiğim en iyi karara geliyorum. Fark etmem epey sürdü.

İlk sürümde analizin içinde F/K oranı yazılı duruyordu; sağlayıcıdan hazır geliyordu, ben de kaydediyordum. Sonra şunu gördüm: analiz üç hafta önce yazılmış, içinde "F/K 24,1" diyor. Sayfanın en üstünde ise canlı fiyat var ve o fiyatla F/K artık 27,8. Aynı sayfada birbiriyle çelişen iki sayı, hangisinin neden farklı olduğu hiçbir yerde yazmıyor.

Bir oranın payı fiyattır ve fiyat her gün değişir. Yani kaydettiğin an doğru olan oran, ertesi gün sessizce yanlış olmaya başlar.

Çözüm oranı hiç saklamamak oldu. Bölenini saklıyorum; oranı sunum katmanı, sayfadaki o anki fiyatla kuruyor.

lib/schema.ts
/* ORAN DEĞİL GİRDİ yazılır. Bölenler burada; oranı sunum katmanı
   sayfadaki canlı fiyatla kuruyor. Böylece okuyucu çarpıp
   doğrulayabiliyor ve sayfada iki farklı fiyat dolaşmıyor. */

/** Son dört çeyreğin toplam hisse başı kârı — F/K'nin böleni. */
epsTtm: doublePrecision("eps_ttm"),

/** PEG'in böleni — beklenen yıllık kâr büyümesi, yüzde. */
growthPct: doublePrecision("growth_pct"),
/** Büyümenin TANIMI — "ileriye dönük 3 yıl", "son 12 ay". Ekranda yazılı. */
growthBasis: text("growth_basis"),

Bunu yaparken sağlayıcının hazır F/K değerini kendi hesabımla karşılaştırdım. SNDK'da %5,6 sapıyordu — büyük ihtimalle farklı bir dönem penceresi kullanıyor ama hangisi olduğunu hiçbir yerde söylemiyor.

`growthBasis` alanı da aynı dertten doğdu. PEG oranının asıl sorunu, hangi büyümenin bölündüğünün söylenmemesi. Aynı gün, aynı şirket için iki kaynağa baktım: biri ileriye dönük tahmini kullanıyordu, öteki son on iki ayı.

MU · aynı gün, aynı şirket, iki kaynak

Son 12 ay üzerinden

PEG 0,04

İleriye dönük

PEG 0,12

Üç kat fark. Sayı tek başına yazılırsa okuyucunun bunu fark etme şansı yok. Bu yüzden büyümenin tanımı ayrı bir alan ve ekranda oranın hemen yanında duruyor.

İpucu

Kural şu hâle geldi: türetilmiş bir sayıyı saklama, girdilerini sakla. Girdi zamanla eskimeyen bir gerçek; türetilmiş sayı bir anın fotoğrafı ve o an geçtiğinde kimseye haber vermeden yanlış oluyor.

Bir Metriği Kaldırmak da Bir Karar

PD/DD oranının böleni bir süre şemada durdu, sonra migration 0012 ile geri aldım. Sebep teknik değildi: sektöre bağlı bir ölçü. Bankada ve gayrimenkul yatırım ortaklığında fiyatın kurulduğu yer; yarı iletkende neredeyse gürültü.

Yani "bu şirkette anlamlı mı" sorusu her analizde yeniden verilmesi gereken bir yargı çağrısıydı. Bir alanın doldurulup doldurulmayacağı her seferinde tartışma açıyorsa, o alan şemada olmamalı.

Aynı mantıkla Brent petrol fiyatını da kaldırdım. Ekranda "74,20 $" yazıyordu ama FRED'in kullandığı seri günlerce geriden geliyor. Bir haftalık fiyatı bugünmüş gibi göstermektense metriği silmek doğru.

Uydurma Veri Yok

Yukarıdaki iki karar da tek bir ilkeden çıkıyor ve o ilke sitenin her yerinde geçerli: bir sayı gösteriliyorsa, nereden geldiği ve ne zaman alındığı da gösterilir.

  • Her kartın altında `kaynak · saat` damgası var.
  • Sağlayıcı kesin saat vermiyorsa ekranda `~` ile yaklaşık olduğu söyleniyor. Bilanço saatleri hep böyle — sağlayıcı yalnızca "açılış öncesi" ya da "kapanış sonrası" diyor, dakika vermiyor.
  • Fiyatlar Alpaca'nın IEX beslemesinden geliyor; konsolide banttan birkaç sent sapabilir ve bu ekranda yazıyor.
  • Endeksler ETF üzerinden izleniyor (QQQ, SPY, DIA, IWM) ve arayüzde belirtiliyor.
  • Veri yoksa kart boş kalıyor. Hiçbir aşamada tahmini değer üretilmiyor.

Bu ilke sağlayıcı katmanını da şekillendirdi. Dört API var — Alpaca, Finnhub, FRED, TCMB — dördü de farklı şekil döndürüyor ve farklı biçimde patlıyor. Hepsini tek sonuç tipine indirdim; başarısızlık da bir değer olarak dönüyor, istisna fırlatılmıyor.

lib/providers/types.ts
type ProviderResult<T> =
  | { ok: true;  data: T; source: string; fetchedAt: Date }
  | { ok: false; source: string; reason: FailReason; message: string }

type FailReason = "missing-key" | "rate-limited" | "not-found" | "upstream-error"

Sıra hep aynı: canlı kaynak → yedek kaynak → veritabanındaki son bilinen değer (üstünde "güncel olmayabilir" damgasıyla) → hata. Pratik faydası şu: projeyi klonlayıp hiçbir anahtar girmeden `npm run dev` diyebiliyorsun. İlgili kartlar "veri alınamadı" gösteriyor, sayfanın geri kalanı çalışıyor.

513 İstek: Beni En Çok Şaşırtan Hata

Şirketler dizini 513 sembol listeliyor ve sayfa çok yavaştı. Nedenini bulamıyordum, çünkü fiyat çağrısı tek bir çağrıydı ve cache'ten geliyordu.

Sonra fiyatları veritabanına yazan koda baktım. Her kotasyon için ayrı bir `insert` vardı, hepsi `Promise.all` ile paralel. Masum görünüyor — ama `@neondatabase/serverless` HTTP üzerinden konuşuyor, kalıcı bağlantı yok. Her `insert` ayrı bir round-trip.

Şirketler dizini · tek sayfa görüntüleme

Satır başına insert

513 istek

Gruplu tek upsert

2 istek

Üstelik Alpaca yanıtı cache'ten gelse bile bu yazma çalıştığı için, cache isabeti de aynı bedeli ödüyordu.

lib/providers/index.ts
// 500'erlik gruplar hâlinde tek upsert.
// 11 kolon × 500 = 5500 parametre — Postgres'in 65535 sınırının altında.
await db
  .insert(quotesCache)
  .values(batch)
  .onConflictDoUpdate({
    target: quotesCache.symbol,
    set: { price: sql`excluded.price`, updatedAt: sql`excluded.updated_at` },
  })

Tazelik Sabit Değil, Duruma Bağlı

Siteyi birkaç arkadaşıma gösterdiğimde ilk gelen geri bildirim "fiyatlar güncellenmiyor, çalışıyor mu bu?" oldu. Çalışıyordu; cache süresini 60 saniye koymuştum ve insan sayfayı 60 saniyede bir yenilemiyor.

Süreyi düşürmekten çekiniyordum, "her ziyaretçi bir istek demek" sanıyordum. Yanlışmış: Next.js'in data cache'i sunucuda paylaşımlı, yani sağlayıcıya giden istek trafikle değil yalnızca TTL ile artıyor. 15 saniyelik TTL dakikada en fazla 4 istek demek; Alpaca'nın ücretsiz katmanı 200 kabul ediyor.

lib/market-hours.ts
export function quoteTtlSeconds(status: MarketStatus): number {
  switch (status.session) {
    case "regular":     return 15   // seans içinde taze olsun
    case "pre-market":
    case "after-hours": return 60   // hareket az
    default:            return 900  // kapalı: fiyat durgun, kotayı harcama
  }
}

Bir de Saatler Vardı

Bunu sona bıraktım çünkü projenin özü değil — ama beklediğimden çok vaktimi aldı.

Türkiye yaz saati uygulamıyor, ABD uyguluyor. Yani New York ile aramızdaki fark sabit değil: yazın 7 saat, kışın 8. Borsanın açılışı her zaman 09:30 New York saatidir ama benim duvar saatimde yazın 16:30'a, kışın 17:30'a denk gelir.

Dikkat

İlk sürümde `TR_OFFSET = 7` diye bir sabit tanımlamıştım. Mart sonunda ABD yaz saatine geçince sitedeki bütün saatler bir saat kaydı ve bunu günler sonra fark ettim. Sayı yanlış olduğunda uygulama çökmüyor, sessizce yalan söylüyor.

Doğru çözüm hiçbir yerde saat aritmetiği yapmamaktı: bütün dönüşümler tek dosyada ve `Intl` üzerinden, o günün tarihiyle hesaplanıyor. Seans durumu da göründüğünden karmaşık — ön seans, ana seans, akşam seansı, kapalı; üstüne yarım günler (Şükran Günü ertesi borsa 13:00'te kapanıyor).

Bir de şu var: Türkçe okuyan biri için "09:30 açılış" doğru ama işe yaramaz bir cümle. Bu yüzden TR arayüzde birincil saat İstanbul, künyede duran ikincil saat New York; İngilizceye geçince sıra tersine dönüyor.

Yazıları Kim Yazıyor

Bültenler ve analizler bir dil modeli tarafından yazılıyor, ama sunucuda hiçbir model çağrısı yok — üretimde API anahtarı tanımlı bile değil.

Mimari şöyle: claude.ai tarafında kurulmuş zamanlanmış görevler belirli saatlerde uyanıyor, sitenin korumalı bir ucundan o günün verisini çekiyor, yazıyı yazıyor ve başka bir korumalı uca gönderiyor. Site yalnızca veritabanından okuyor.

GörevNe zamanNereye
Bilanço analiziher gün 09:00 TR/bilancolar/analizler
Günlük bültenher gün 16:00 TRAna sayfa · Günün Özeti
Olay yazısıher gün 23:30 TR/mercek
Haftalık bültenpazartesi 09:30 TR/bulten

Saatlerin seçimi de bir hatadan çıktı. Günlük bülten uzun süre sabah 09:00'da koşuyordu, oysa veriyi veritabanına yazan cron 13:30'da çalışıyor. Yani bülten senkrondan dört buçuk saat önce yazılıyor ve her sabah bir önceki günün makro değerleriyle çıkıyordu.

Gövde doğrulaması başarısız olunca 400 ile birlikte beklenen şemayı da geri yazıyorum. Sebebi basit: karşı taraf bir model ve hata mesajını okuyup kendini düzeltebiliyor.

Yazılara Görsel Koymadım, Çizdirdim

Uzun yazıların görsele ihtiyacı vardı ama görsel demek telif, kaynak arama ve her yazı için ayrı iş demek. Onun yerine metinden çizim yapan bir blok ailesi yazdım.

md
::: pay Optik Modül Pazar Payı
Zhongji Innolight | 27
Coherent | 18
Diğerleri | 55
:::

::: oncesi Piyasa Değeri
52,5 Mr $ | 12 Haziran
19 Mr $ | 29 Temmuz
:::

Site bunu yığılmış çubuk ve öncesi-sonrası karşılaştırması olarak çiziyor. `oncesi` bloğu aradaki yüzde değişimi kendisi hesaplıyor — yazan hesaplamıyor, dolayısıyla yanlış hesaplayamıyor. Altı görsel blok var, hepsinde satırlar `|` ile ayrılıyor.

Asıl kazancı estetik değil bakım: hiçbir yerde görsel barındırmıyorum ve tema değiştiğinde çizimler de değişiyor. Bir PNG bunu yapamaz.

Rakamlarla

Açılış Zili

24
sayfa
15
Postgres tablosu
13
migration
4
veri sağlayıcı
500+
takip edilen şirket
2
dil

Teknoloji: Next.js 16 (App Router, Turbopack), React 19, Tailwind v4 — tokenlar `@theme inline` içinde, config dosyası yok. Neon PostgreSQL + Drizzle, next-auth v5, grafikler lightweight-charts.

Geriye Dönüp Bakınca

Bu projeye "borsa verisi çekmek zor olacak" diye başladım. Veri çekmek en kolay kısımmış: dört API, birkaç fetch, bitti.

Zor olan, o verinin dürüst biçimde ekrana çıkması. Bir sayının yanlış olması uygulamayı çökertmiyor, sadece güvenilmez yapıyor — ve fark etmen günler sürebiliyor. Kaydettiğim F/K'nin ertesi gün yanlış olmaya başlaması gibi.

Öğrendiğim şey tek cümle: türetilmiş sayıyı saklama, girdisini sakla. Bunu daha önce de duymuştum ama neden önemli olduğunu ancak kendi sayfamda birbiriyle çelişen iki fiyat görünce anladım.

Site canlıda, kodun tamamı açık. Şu an her sabah beş sekme yerine bir sekme açıyorum; projeyi başarılı sayma ölçütüm bu.