Ana içeriğe geç
Kesit Python
Orta · Orta bitirme

Orta bitirme projesi: Ilgaz Bank işlem analizi

Orta seviyede öğrendiğin her şeyi tek projede birleştirirsin: dataclass ile işlem modeli, CSV ayrıştırma, kategori toplamları, çok koşullu şüpheli işlem kuralı, JSON rapor ve unittest ile doğrulama.

Uygulamada aç, 20 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

Proje tanımı

Ilgaz Bank'ın kart operasyon ekibi senden küçük ama gerçekçi bir araç istiyor: günlük kart işlemlerini okuyacak, kategori bazında harcamayı özetleyecek, şüpheli görünen işlemleri işaretleyecek ve sonucu JSON raporu olarak verecek. Ekip, kuralların test edilmiş olmasını şart koşuyor; çünkü yanlış pozitif bir müşterinin kartını gereksiz yere bloke eder, yanlış negatif ise dolandırıcılığı kaçırır.

Bu ders diğerlerinden farklı: yeni kavram öğretmez, öğrendiklerini birleştirir. Alıştırmalar birbirini tamamlayan altı parçadır; her parçada bir önceki parçanın fonksiyonu hazır gelir. Aşağıda her parçanın kuramı ve örnek çıktısı var; alıştırmalarda kendi sürümünü yazacaksın.

ParçaNe yaparKullandığın dersler
1. ModelTransaction dataclass'ı ve CSV'den okumadataclass-typing, json-csv, tarih-saat, decimal
2. ÖzetKategoriye göre toplamcollections, kapsamli-ifadeler
3. Kural A-BGece saati ve yüksek tutartarih-saat, fonksiyonel
4. Kural CAynı kartta 10 dakikada 3 işlemiteratorler, tarih-saat, sıralama
5. RaporJSON çıktı, tip dönüşümüjson-csv, decimal
6. Testunittest ile kural testleritest-yazma

Parça 1: Veri modeli ve CSV okuma

Her işlem beş alan taşır: işlem kimliği, kart numarası (maskeli), zaman, tutar, kategori. Tutar para olduğu için Decimal, zaman datetime. frozen=True ile işlem nesnesi değiştirilemez olur: analiz sırasında kimse bir tutarı yanlışlıkla güncelleyemez. CSV metni csv.DictReader ile okunur, her satır Transaction'a dönüştürülür.

import csv
import io
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal

@dataclass(frozen=True)
class Transaction:
    id: str
    kart: str
    zaman: datetime
    tutar: Decimal
    kategori: str

VERI = """id,kart,zaman,tutar,kategori
T1,****1111,2024-03-15 09:12:00,245.90,market
T2,****1111,2024-03-15 13:40:00,1200.00,elektronik
T3,****2222,2024-03-15 02:15:00,8500.00,kuyumcu
T4,****3333,2024-03-15 23:50:00,60.00,yakit
T5,****3333,2024-03-15 23:54:00,75.50,yakit
T6,****3333,2024-03-15 23:58:00,90.00,market
T7,****2222,2024-03-15 18:00:00,430.00,market
"""

def csv_oku(metin: str) -> list[Transaction]:
    islemler = []
    for satir in csv.DictReader(io.StringIO(metin)):
        islemler.append(Transaction(
            id=satir["id"],
            kart=satir["kart"],
            zaman=datetime.strptime(satir["zaman"], "%Y-%m-%d %H:%M:%S"),
            tutar=Decimal(satir["tutar"]),
            kategori=satir["kategori"],
        ))
    return islemler

islemler = csv_oku(VERI)
print(len(islemler), "işlem okundu")
print(islemler[2])
print("tutar türü:", type(islemler[0].tutar).__name__, "| saat:", islemler[2].zaman.hour)
Dönüşüm tek yerdeMetinden tipe dönüşüm yalnızca csv_oku içinde yapılır. Sonraki hiçbir parça "acaba tutar string mi" diye düşünmez; bu, katmanlı tasarımın en sade halidir.

Parça 2: Kategoriye göre toplam

Klasik gruplama: sözlükte biriktir. defaultdict(Decimal) başlangıç değeri olarak Decimal(0) verir, böylece += doğrudan çalışır. Çıktıyı toplam tutara göre azalan sıralamak raporun okunurluğunu artırır.

from collections import defaultdict
from decimal import Decimal

# Parça 1'deki islemler listesini yeniden kuralım (kısa sürüm)
from dataclasses import dataclass
from datetime import datetime

@dataclass(frozen=True)
class Transaction:
    id: str
    kart: str
    zaman: datetime
    tutar: Decimal
    kategori: str

islemler = [
    Transaction("T1", "****1111", datetime(2024, 3, 15, 9, 12), Decimal("245.90"), "market"),
    Transaction("T2", "****1111", datetime(2024, 3, 15, 13, 40), Decimal("1200.00"), "elektronik"),
    Transaction("T3", "****2222", datetime(2024, 3, 15, 2, 15), Decimal("8500.00"), "kuyumcu"),
    Transaction("T7", "****2222", datetime(2024, 3, 15, 18, 0), Decimal("430.00"), "market"),
]

def kategori_toplam(islemler: list[Transaction]) -> dict[str, Decimal]:
    toplam: dict[str, Decimal] = defaultdict(Decimal)
    for t in islemler:
        toplam[t.kategori] += t.tutar
    return dict(sorted(toplam.items(), key=lambda kv: kv[1], reverse=True))

for kategori, tutar in kategori_toplam(islemler).items():
    print(f"{kategori:12} {tutar:>10}")

Parça 3 ve 4: Şüpheli işlem kuralları

Operasyon ekibinin üç kuralı var; bir işlem bunlardan en az birine uyarsa şüpheli sayılır ve hangi kurallara takıldığı raporda görünür:

İlk iki kural tek işleme bakar; birer satırlık saf fonksiyonlardır. Üçüncü kural işlemler arası ilişkiye bakar: karta göre grupla, zamana göre sırala, her işlem için "bu işlemle başlayan 10 dakikalık pencerede kaç işlem var?" diye say; 3 ve üstüyse penceredeki tüm işlemleri işaretle.

from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta
from decimal import Decimal

@dataclass(frozen=True)
class Transaction:
    id: str
    kart: str
    zaman: datetime
    tutar: Decimal
    kategori: str

islemler = [
    Transaction("T3", "****2222", datetime(2024, 3, 15, 2, 15), Decimal("8500.00"), "kuyumcu"),
    Transaction("T4", "****3333", datetime(2024, 3, 15, 23, 50), Decimal("60.00"), "yakit"),
    Transaction("T5", "****3333", datetime(2024, 3, 15, 23, 54), Decimal("75.50"), "yakit"),
    Transaction("T6", "****3333", datetime(2024, 3, 15, 23, 58), Decimal("90.00"), "market"),
    Transaction("T7", "****2222", datetime(2024, 3, 15, 18, 0), Decimal("430.00"), "market"),
]

def gece_mi(t: Transaction) -> bool:
    return t.zaman.hour >= 23 or t.zaman.hour < 6

def yuksek_mi(t: Transaction, esik: Decimal = Decimal("5000")) -> bool:
    return t.tutar >= esik

def sik_islem_idleri(islemler: list[Transaction], pencere_dk: int = 10, esik: int = 3) -> set[str]:
    karta_gore: dict[str, list[Transaction]] = defaultdict(list)
    for t in islemler:
        karta_gore[t.kart].append(t)
    supheli: set[str] = set()
    pencere = timedelta(minutes=pencere_dk)
    for grup in karta_gore.values():
        grup.sort(key=lambda t: t.zaman)
        for i, baslangic in enumerate(grup):
            icindekiler = [t for t in grup[i:] if t.zaman - baslangic.zaman <= pencere]
            if len(icindekiler) >= esik:
                supheli.update(t.id for t in icindekiler)
    return supheli

def supheli_isaretle(islemler: list[Transaction]) -> dict[str, list[str]]:
    sik = sik_islem_idleri(islemler)
    sonuc = {}
    for t in islemler:
        nedenler = []
        if gece_mi(t): nedenler.append("gece")
        if yuksek_mi(t): nedenler.append("yuksek_tutar")
        if t.id in sik: nedenler.append("sik_islem")
        if nedenler:
            sonuc[t.id] = nedenler
    return sonuc

for tid, nedenler in supheli_isaretle(islemler).items():
    print(tid, "->", ", ".join(nedenler))

T3 iki kurala birden takıldı (gece ve yüksek tutar); T4-T5-T6 ise hem gece hem sık işlem. T7 temiz. Pencere mantığında grup[i:] dilimi yalnızca ileriye bakar; sıralı liste sayesinde geçmişe bakmaya gerek yoktur.

Sınır değerleri test et23:00 tam saati gece mi? 5000.00 tam tutar yüksek mi? 10 dakika 0 saniye pencerede mi? Kuralları yazarken >= ile > arasındaki farkı iş birimine sor ve testte sabitle. Bu projede üç sınır da dahil (>=) kabul edildi.

Parça 5: JSON rapor

Rapor üç bölüm taşır: genel özet (işlem sayısı, toplam tutar), kategori toplamları ve şüpheli işlemler. json.dumps Decimal ve datetime'ı tanımaz; ya rapor sözlüğünü kurarken metne çevirirsin ya da default= parametresine bir dönüştürücü verirsin. Para alanlarını metin olarak yazmak ("8500.00") float'a çevirmekten güvenlidir: alıcı sistem tekrar Decimal'e dönebilir.

import json
from datetime import datetime
from decimal import Decimal

def json_uyumlu(nesne):
    if isinstance(nesne, Decimal):
        return str(nesne)
    if isinstance(nesne, datetime):
        return nesne.isoformat()
    raise TypeError(f"JSON'a çevrilemez: {type(nesne).__name__}")

rapor = {
    "tarih": datetime(2024, 3, 15).date().isoformat(),
    "islem_sayisi": 7,
    "toplam_tutar": Decimal("10601.40"),
    "kategori_toplam": {"kuyumcu": Decimal("8500.00"), "elektronik": Decimal("1200.00")},
    "supheli": [
        {"id": "T3", "kart": "****2222", "zaman": datetime(2024, 3, 15, 2, 15), "tutar": Decimal("8500.00"), "nedenler": ["gece", "yuksek_tutar"]},
    ],
}
metin = json.dumps(rapor, default=json_uyumlu, ensure_ascii=False, indent=2)
print(metin)
geri = json.loads(metin)
print("geri okunan toplam:", Decimal(geri["toplam_tutar"]) + Decimal("0.60"))

Parça 6: Testler

Kurallar test edilmeden teslim edilmez. Her kural için en az bir pozitif, bir negatif ve bir sınır testi yazarsın. Test verisi küçük ve elle hesaplanabilir olmalı; büyük CSV'yi teste sokma. Aşağıdaki örnek yalnızca gece kuralını test eder; alıştırmada üç kuralı birden yazacaksın.

import io
import unittest
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal

@dataclass(frozen=True)
class Transaction:
    id: str
    kart: str
    zaman: datetime
    tutar: Decimal
    kategori: str

def gece_mi(t):
    return t.zaman.hour >= 23 or t.zaman.hour < 6

def islem(saat, dakika=0, tutar="100"):
    return Transaction("X", "****0000", datetime(2024, 3, 15, saat, dakika), Decimal(tutar), "market")

class GeceKuraliTest(unittest.TestCase):
    def test_gece_yarisi_sonrasi(self):
        self.assertTrue(gece_mi(islem(2, 15)))
    def test_sinir_23_dahil(self):
        self.assertTrue(gece_mi(islem(23, 0)))
    def test_sinir_6_haric(self):
        self.assertFalse(gece_mi(islem(6, 0)))
    def test_ogle_degil(self):
        self.assertFalse(gece_mi(islem(13, 40)))

suite = unittest.defaultTestLoader.loadTestsFromTestCase(GeceKuraliTest)
adlar = [t.id().split(".")[-1] for t in suite]
sonuc = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
print(len(adlar), "test:", adlar)
print("başarılı:", sonuc.wasSuccessful())

islem(...) yardımcı fonksiyonu test verisini tek satırda kurar; test gövdeleri yalnızca kuralı konuşur. Bu küçük "fabrika" kalıbı, test dosyalarını okunur tutmanın en ucuz yoludur.

Derinleş

Derinleş: Projeyi üretim kalitesine taşımak

Altı parçayla çalışan bir analiz aracın var. Kart operasyon ekibi aracı her gün kullanmaya başlayınca yeni istekler gelir: "Bozuk satır yüzünden tüm rapor çöktü", "Eşiği 5000'den 7500'e çekmek için yazılımcı mı bekleyeceğiz?", "Dünün sonuçlarını nerede saklıyoruz?", "Bunu her sabah komut satırından çalıştırmak istiyoruz". Bu bölüm, orta seviyede öğrendiğin her şeyi (doğrulama, yapılandırma, SQLite, argparse, logging, test, Git) aynı projede bir araya getirir. Alıştırmalar da bu ihtiyaçları birer birer kapatır.

1. Girdiyi doğrula, hatayı topla

Gerçek dosyada bazı satırlar bozuktur: tarih yanlış biçimde, tutar boş, kategori eksik. İki strateji vardır: hızlı başarısız ol (fail fast: ilk hatada dur) ve hataları topla (geçerli satırları işle, bozukları satır numarasıyla raporla). Günlük operasyon raporlarında ikincisi tercih edilir; ama bozuk satır oranı bir eşiği aşarsa (ör. %5) dosyanın tamamı reddedilir, çünkü o noktada sorun tek tük satırda değil, kaynaktadır.

import csv, io
from datetime import datetime
from decimal import Decimal, InvalidOperation

VERI = """id,kart,zaman,tutar,kategori
T1,****1111,2024-03-15 09:12:00,245.90,market
T2,****1111,15.03.2024 13:40,1200.00,elektronik
T3,****2222,2024-03-15 02:15:00,,kuyumcu
T4,****3333,2024-03-15 23:50:00,-60.00,yakit
T5,****3333,2024-03-15 23:54:00,75.50,
"""

gecerli, hatalar = [], []
for no, s in enumerate(csv.DictReader(io.StringIO(VERI)), start=2):   # 1. satır başlık
    try:
        zaman = datetime.strptime(s["zaman"], "%Y-%m-%d %H:%M:%S")
        tutar = Decimal(s["tutar"])
        if tutar <= 0:
            raise ValueError("tutar pozitif olmalı")
        if not s["kategori"].strip():
            raise ValueError("kategori boş")
    except InvalidOperation:
        hatalar.append((no, s["id"], "tutar sayı değil"))
        continue
    except ValueError as e:
        hatalar.append((no, s["id"], str(e)[:30]))
        continue
    gecerli.append((s["id"], zaman.hour, tutar))

print("geçerli:", gecerli)
for h in hatalar:
    print("satır", *h)
oran = len(hatalar) / (len(hatalar) + len(gecerli))
print(f"hata oranı %{oran * 100:.0f} ->", "DOSYAYI REDDET" if oran > 0.05 else "devam")
InvalidOperation bir ValueError değildirDecimal("") ve Decimal("abc") decimal.InvalidOperation fırlatır; bu sınıf ArithmeticError'dan türer. Yalnızca except ValueError yazarsan boş tutarlı satır tüm programı düşürür.

2. Kurallar veri olsun: kayıt defteri ve ayar dosyası

Kural eklemek için kurallari_uygula fonksiyonunu her seferinde değiştirmek yerine kuralları bir kayıt defterine (registry) koy: ad → fonksiyon. Eşikler ise koddan çıkıp bir ayar sözlüğüne (gerçekte JSON/TOML dosyası) taşınır. Ayardan gelen değerler metin ya da float olabileceği için okurken tek noktada doğru tipe çevrilir.

import json
from datetime import datetime
from decimal import Decimal

AYAR_JSON = '{"yuksek_esik": "5000.00", "gece_bas": 23, "gece_bit": 6, "riskli_kategoriler": ["kuyumcu", "kripto"]}'
ham = json.loads(AYAR_JSON)
ayar = {**ham, "yuksek_esik": Decimal(ham["yuksek_esik"]), "riskli_kategoriler": set(ham["riskli_kategoriler"])}

KURALLAR = {}
def kural(ad):
    def kaydet(f):
        KURALLAR[ad] = f
        return f
    return kaydet

@kural("gece")
def _gece(t, a):
    return t["zaman"].hour >= a["gece_bas"] or t["zaman"].hour < a["gece_bit"]

@kural("yuksek")
def _yuksek(t, a):
    return t["tutar"] >= a["yuksek_esik"]

@kural("riskli_kategori")
def _kategori(t, a):
    return t["kategori"] in a["riskli_kategoriler"]

islem = {"id": "T3", "zaman": datetime(2024, 3, 15, 2, 15), "tutar": Decimal("8500.00"), "kategori": "kuyumcu"}
print(sorted(KURALLAR))
print([ad for ad, f in KURALLAR.items() if f(islem, ayar)])

3. Sakla: SQLite'ta kuruş olarak

Sonuçları her gün bir SQLite dosyasına yazarsan "geçen hafta kaç şüpheli işlem vardı" sorusu tek sorgu olur. Para için SQLite'ın REAL tipi float'tır; tutarı tam sayı kuruş olarak saklamak hem toplamları kesin yapar hem de Decimal'e geri dönüşü kolaylaştırır. Zamanı ISO 8601 metni olarak yaz.

import sqlite3
from decimal import Decimal

con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE islem (id TEXT PRIMARY KEY, zaman TEXT, tutar_kurus INTEGER, kategori TEXT)")
veriler = [("T1", "2024-03-15T09:12:00", Decimal("0.10"), "market"),
           ("T2", "2024-03-15T13:40:00", Decimal("0.20"), "market")]
con.executemany("INSERT INTO islem VALUES (?, ?, ?, ?)",
                [(i, z, int(t * 100), k) for i, z, t, k in veriler])
kurus = con.execute("SELECT SUM(tutar_kurus) FROM islem WHERE kategori = 'market'").fetchone()[0]
print(Decimal(kurus) / 100, "| float olsaydı:", 0.10 + 0.20)

4. Komut satırı aracı ve proje ağacı

Son adım aracı paketlemek. Proje git-proje-yapisi dersindeki src yerleşimine oturur; giriş noktası argparse ile parametre alır, sonucu stdout'a ya da dosyaya yazar, hata durumunda sıfır dışı çıkış kodu döner ki zamanlayıcı (cron, Windows Görev Zamanlayıcı) sorunu fark etsin.

kart-analiz/
├── pyproject.toml            # [project.scripts] kart-analiz = "kart_analiz.cli:main"
├── README.md   .gitignore   .env.example
├── ayar.json                 # eşikler: kod değişmeden ayarlanır
├── src/kart_analiz/
│   ├── model.py              # Transaction, csv_oku, doğrulama
│   ├── kurallar.py           # kayıt defteri + kurallar
│   ├── rapor.py              # JSON / CSV çıktı
│   ├── depo.py               # SQLite
│   └── cli.py                # argparse + logging
└── tests/  test_model.py  test_kurallar.py  test_rapor.py

# Kendi bilgisayarında:
python -m venv .venv && source .venv/bin/activate     # Windows: .venv\Scripts\Activate.ps1
python -m pip install -e .
kart-analiz islemler.csv --ayar ayar.json --cikti rapor.json --veritabani gecmis.db -v
python -m unittest discover -s tests
Üretim kontrol listesiBu projede karşılığıİlgili ders
Bozuk veri programı düşürmüyorSatır bazında doğrulama, hata raporu, eşikhatalar, json-csv
Eşikler koddan ayrıayar.json + tek noktada tip dönüşümüyapilandirma
Para kesinDecimal; SQLite'ta kuruş, JSON'da metinstandart-kutuphane
Ne olduğu izlenebilirlogging: kaç satır okundu, kaç hata, kaç işaretlogging
Kurallar güvenilirSınır değer testleri, mock'suz saf fonksiyonlartest-yazma
Kurulum tekrarlanabilirpyproject + kilit dosyasısanal-ortam-pip
Değişiklikler izlenebilirGit, PR, Conventional Commitsgit-proje-yapisi
Portföy içinBu projeyi GitHub'a koyarken README'ye bir örnek CSV, tek satırlık çalıştırma komutu, örnek çıktı ve "tasarım kararları" bölümü (neden Decimal, neden kayıt defteri, neden kuruş) ekle. Mülakatta en çok bu kararlar sorulur.
Gerçek işteBankalarda bu tür kural motorları genellikle önce basit eşiklerle başlar; kurallar ayar dosyasında risk ekibinin sorumluluğundadır, kod ise yazılım ekibinde. Her kuralın kaç işlemi işaretlediği günlük izlenir: bir kural birden bire çok fazla işaretliyorsa ya veri kaynağı bozulmuştur ya da eşik yanlış girilmiştir. İşaretlenen işlemler analistin önüne puan sırasıyla gelir; zamanla bu kuralların yanına makine öğrenmesi modelleri eklenir (ileri seviye ml dersleri), ama açıklanabilir kurallar denetim için her zaman kalır.
Uzmanlaş

Bu projeyi üretime taşısaydın

Gerçek bir kart izleme sistemi aynı iskeleti büyütür. Kurallar bir listede tutulur: her kural (ad, fonksiyon) çifti olur ve supheli_isaretle listeyi dolaşır; yeni kural eklemek if satırı eklemek değil, listeye eleman eklemek haline gelir. Eşikler (5000 TL, 10 dakika, 3 işlem) koda değil yapılandırmaya yazılır; operasyon ekibi kodu değiştirmeden ayarı değiştirir. Sık işlem kuralı milyonlarca işlemde O(n²)'ye yaklaşır; sıralı listede iki işaretçiyle (sliding window) tek geçişe iner, İleri seviyedeki performans dersinin konusu.

Veri CSV'den değil veritabanından gelir: sqlite3 dersindeki iş bölümü burada da geçerli; kart ve tarih aralığı filtresini SQL yapar, kuralları Python uygular. Rapor JSON olarak bir kuyruk ya da API'ye gider, operasyon ekibi panelde görür. Testler CI'da her commit'te koşar; bir kuralın eşiği değişince önce test güncellenir. Bu dersteki altı parça, o sistemin küçültülmüş ama eksiksiz bir modelidir: model, hesap, kural, rapor, test. Bir mülakatta "orta seviye Python projesi anlat" dendiğinde anlatacağın şey tam olarak bu.

Alıştırmalara geç (20)