Kafka Fundamentals

Core concepts

Kafka topic with 3 partitions: Topic: raw-ticks Partition 0: [msg0] [msg3] [msg6] ... ← Consumer A reads these Partition 1: [msg1] [msg4] [msg7] ... ← Consumer B reads these Partition 2: [msg2] [msg5] [msg8] ... ← Consumer C reads these Key = instrument symbol (RELIANCE, TCS, INFY) RELIANCE ticks → always Partition 0 (consistent ordering per symbol)

Commit strategies

KRaft Mode — Why Remove ZooKeeper?

Kafka with ZooKeeper (old): Kafka KRaft (new): ZooKeeper Ensemble Kafka Controller Quorum ┌──────┐ ┌──────┐ ┌──────┐ ┌────────────────────┐ │ ZK1 │ │ ZK2 │ │ ZK3 │ │ Controller nodes │ └──────┘ └──────┘ └──────┘ │ (Raft consensus) │ ↕ cluster metadata ↕ └────────────────────┘ Kafka Brokers ↕ metadata ┌──────┐ ┌──────┐ Kafka Brokers │ B1 │ │ B2 │ ┌──────┐ ┌──────┐ └──────┘ └──────┘ │ B1 │ │ B2 │ └──────┘ └──────┘ Problems: - Separate ZK cluster to operate Benefits of KRaft: - ZK becomes bottleneck at scale - Single system to operate - Controller election via ZK = slow - Faster controller election - Split-brain risk - Higher partition count possible - Simpler ops for small teams

For a startup, KRaft mode eliminates the need to run a separate ZooKeeper cluster. In Tredye's OCI deployment, this means 3 fewer containers to manage, fewer failure modes, and simpler debugging.

Interview Q&A

Q: When would you NOT use KRaft?

A: KRaft became production-ready in Kafka 3.3 (Nov 2022). If running Kafka <3.3, or if your ops team has deep ZooKeeper expertise and existing tooling, ZooKeeper is still fine. KRaft is the future — Kafka 4.0 removes ZooKeeper support entirely. For a new system (Tredye, started 2025), KRaft is the obvious choice.

Snappy Compression

CodecRatioCompress SpeedDecompress SpeedBest for
None1xVery small messages
Snappy1.5-2xFastVery fastHigh-throughput, CPU-sensitive
LZ41.5-2xVery fastVery fastSimilar to Snappy
Gzip2.5-3xSlowModerateStorage-optimized, low throughput
ZSTD2-3xModerateFastBalance of ratio + speed

Market tick data is repetitive numerical JSON — ideal for Snappy. Each tick looks like {"symbol":"RELIANCE","ltp":2450.50,"vol":1234567,"ts":1710000000}. Snappy compresses this ~40% with negligible CPU overhead, reducing network bandwidth between Kafka brokers and consumers.

The 4-Stage Pipeline

Tredye Data Pipeline: Kite WebSocket API (Zerodha) │ raw market tick every ~100ms │ { symbol, ltp, volume, oi, timestamp } ▼ ┌─────────────────────┐ │ Stage 1: │ │ data-ingestion │ Python FastAPI service │ │ Subscribes to Kite WS └──────────┬──────────┘ Publishes to topic: raw-ticks │ (key = symbol for partition ordering) ▼ ┌─────────────────────┐ │ Stage 2: │ │ candle-builder │ Python service │ │ Aggregates ticks into OHLCV candles └──────────┬──────────┘ by timeframe (1m, 5m, 15m, 1h) │ Publishes to topic: candles ▼ ┌─────────────────────┐ │ Stage 3: │ │ rsi-calculator │ Python service │ │ Incremental RSI (Wilder's EMA) └──────────┬──────────┘ Per symbol, per timeframe │ Publishes to topic: indicators ▼ ┌─────────────────────┐ │ Stage 4: │ │ divergence-detector│ Python service │ │ Compares RSI vs price using ZigZag └──────────┬──────────┘ Publishes to topic: signals │ ▼ Redis pub/sub → WebSocket → Browser (Next.js)

How <50ms is measured

# Each service adds an ingress_ts header when producing
producer.produce(
    topic='candles',
    key=symbol,
    value=candle_data,
    headers={
        'ingress_ts': str(original_tick_timestamp),  # from stage 1
        'stage2_ts': str(int(time.time() * 1000)),   # this stage
    }
)

# Final consumer (divergence-detector) computes e2e latency
ingress_ts = int(headers['ingress_ts'])
e2e_ms = int(time.time() * 1000) - ingress_ts
# Log to metrics: e2e_ms should be <50

RSI — Wilder's Smoothing

Standard RSI formula

# Naive RSI: recalculate from scratch each tick
# For 14-period RSI on new candle:
# Need last 14 closes → compute 14 gains/losses → average → RSI
# Problem: requires storing and iterating 14+ candles every tick

# Wilder's incremental RSI (used in Tredye):
# Only need previous avg_gain and avg_loss — O(1) per tick

class IncrementalRSI:
    def __init__(self, period=14):
        self.period = period
        self.avg_gain = None
        self.avg_loss = None
        self.prev_close = None
        self.candle_count = 0

    def update(self, close: float) -> float | None:
        if self.prev_close is None:
            self.prev_close = close
            return None

        change = close - self.prev_close
        gain = max(change, 0)
        loss = max(-change, 0)
        self.prev_close = close
        self.candle_count += 1

        if self.candle_count <= self.period:
            # Initial period: simple average
            if self.avg_gain is None:
                self.avg_gain = gain
                self.avg_loss = loss
            else:
                self.avg_gain = (self.avg_gain * (self.candle_count - 1) + gain) / self.candle_count
                self.avg_loss = (self.avg_loss * (self.candle_count - 1) + loss) / self.candle_count

            if self.candle_count < self.period:
                return None
        else:
            # Wilder's smoothing: EMA-like with 1/period as alpha
            self.avg_gain = (self.avg_gain * (self.period - 1) + gain) / self.period
            self.avg_loss = (self.avg_loss * (self.period - 1) + loss) / self.period

        if self.avg_loss == 0:
            return 100.0  # no losses = overbought

        rs = self.avg_gain / self.avg_loss
        return 100 - (100 / (1 + rs))
Interview Q&A

Q: Why not use standard Welles Wilder RSI calculation?

A: Welles Wilder is the same algorithm — "Wilder's smoothing" IS Welles Wilder's method. The distinction is incremental vs batch: standard implementations recalculate from the full candle history each time. With 500 trading instruments each emitting ticks every 100ms, recalculating 300+ candles per tick per symbol = 500 × 300 = 150,000 candle operations per tick. Incremental RSI reduces this to 500 × 1 = 500 operations, enabling <50ms throughput.

ZigZag Algorithm

What ZigZag does

ZigZag filters out noise to identify only significant price swings — local maxima (highs) and minima (lows) that exceed a threshold percentage.

def zigzag(prices: list[float], threshold_pct: float = 0.02) -> list[dict]:
    """
    Returns significant pivot points (highs/lows) where price moved
    at least threshold_pct (2%) from the previous pivot.
    """
    if len(prices) < 2:
        return []

    pivots = []
    direction = None  # 'up' or 'down'
    last_pivot_price = prices[0]
    last_pivot_idx = 0

    for i, price in enumerate(prices[1:], 1):
        change = (price - last_pivot_price) / last_pivot_price

        if direction is None:
            if change >= threshold_pct:
                direction = 'up'
                pivots.append({'idx': last_pivot_idx, 'price': last_pivot_price, 'type': 'low'})
            elif change <= -threshold_pct:
                direction = 'down'
                pivots.append({'idx': last_pivot_idx, 'price': last_pivot_price, 'type': 'high'})

        elif direction == 'up':
            if price > last_pivot_price:
                # Extend the high
                last_pivot_price = price
                last_pivot_idx = i
            elif (last_pivot_price - price) / last_pivot_price >= threshold_pct:
                # Significant reversal down
                pivots.append({'idx': last_pivot_idx, 'price': last_pivot_price, 'type': 'high'})
                direction = 'down'
                last_pivot_price = price
                last_pivot_idx = i

        elif direction == 'down':
            if price < last_pivot_price:
                last_pivot_price = price
                last_pivot_idx = i
            elif (price - last_pivot_price) / last_pivot_price >= threshold_pct:
                pivots.append({'idx': last_pivot_idx, 'price': last_pivot_price, 'type': 'low'})
                direction = 'up'
                last_pivot_price = price
                last_pivot_idx = i

    return pivots

Divergence Detection

Types of RSI divergence

Regular Bullish Divergence (buy signal): Price: Lower Low RSI: Higher Low ──────────────── ───────────────── \ / \ / \ / ← lower \ / ← higher ─────────\/ ──────\/ Price makes lower low RSI makes higher low → bearish momentum weakening → potential reversal up Regular Bearish Divergence (sell signal): Price: Higher High RSI: Lower High ─────────────── ───────────────── /\ /\ / \ ← higher / \ ← lower ──────/ \── ──────/ \── Price higher high RSI lower high → bullish momentum weakening → potential reversal down
def detect_divergence(price_pivots: list, rsi_pivots: list) -> list[dict]:
    """Match corresponding price and RSI swing points, detect divergence."""
    signals = []

    # Align pivots by index (both derived from same candle series)
    price_lows = [p for p in price_pivots if p['type'] == 'low']
    rsi_lows   = [p for p in rsi_pivots   if p['type'] == 'low']
    price_highs = [p for p in price_pivots if p['type'] == 'high']
    rsi_highs   = [p for p in rsi_pivots   if p['type'] == 'high']

    # Regular bullish: price lower low + RSI higher low
    for i in range(1, min(len(price_lows), len(rsi_lows))):
        p_prev, p_curr = price_lows[i-1], price_lows[i]
        r_prev, r_curr = rsi_lows[i-1], rsi_lows[i]

        if p_curr['price'] < p_prev['price'] and r_curr['price'] > r_prev['price']:
            signals.append({
                'type': 'regular_bullish',
                'candle_idx': p_curr['idx'],
                'strength': (r_curr['price'] - r_prev['price']) / r_prev['price'],
            })

    # Regular bearish: price higher high + RSI lower high
    for i in range(1, min(len(price_highs), len(rsi_highs))):
        p_prev, p_curr = price_highs[i-1], price_highs[i]
        r_prev, r_curr = rsi_highs[i-1], rsi_highs[i]

        if p_curr['price'] > p_prev['price'] and r_curr['price'] < r_prev['price']:
            signals.append({
                'type': 'regular_bearish',
                'candle_idx': p_curr['idx'],
                'strength': (r_prev['price'] - r_curr['price']) / r_prev['price'],
            })

    return signals
Interview Q&A

Q: What is "hidden divergence" vs "regular divergence"?

A: Regular divergence signals a trend reversal. Hidden divergence signals trend continuation. Hidden bullish: price makes higher low (uptrend) but RSI makes lower low → bullish continuation. Hidden bearish: price makes lower high (downtrend) but RSI makes higher high → bearish continuation. Tredye detects both, labeling them separately so traders can use the right signal for their strategy.

Q: Are RSI divergence signals reliable?

A: They're indicators, not predictions. Divergence signals are more reliable at RSI extremes (>70 or <30) and on higher timeframes (15m, 1h vs 1m). The system presents them as alerts for human review, not automated trading signals. Backtesting showed ~60-65% accuracy on 15m timeframe with >30% price move divergence — better than random, but requires confirmation.