Skip to content

Real-Time Streaming Forecasts for Nepal's Stock Market: MLOps and Generative AI at Scale

Yesterday I tried to run my batch‑oriented liquidity forecaster on a live NEPSE feed and watched the latency balloon as soon as the order book turned sparse. If you’ve already built a nightly model for Nepal’s market, you know the next logical step is “intraday” – but the moment you add a real‑time stream you hit three blockers: blocking HTTP calls, rolling feature windows that never finish, and a model that can’t adapt without a full retrain. In this tutorial I’ll show you how to stitch together an asynchronous poller, a sliding‑window feature buffer, an incremental scikit‑learn model, and a lightweight LLM‑style summarizer so you can push predictions every few seconds while the market is open. By the end you’ll have a runnable script that streams JSONPlaceholder todos (our stand‑in for live ticks), computes rolling completion‑rate metrics, continuously updates an SGDRegressor, and prints a human‑readable market brief.

Key Takeaways

  • Asynchronous httpx.AsyncClient lets you poll high‑frequency feeds without freezing the event loop.
  • A collections.deque buffer provides O(1) sliding‑window updates for rolling technical indicators.
  • SGDRegressor.partial_fit turns a batch model into an online learner that adapts to drift on the fly.
  • Simple prompt templates combined with a local LLM (or a rule‑based stub) can turn numeric forecasts into executive‑level commentary.
  • The pattern scales to real market feeds; just replace the placeholder URL and tune the polling interval.

The Problem

Our earlier liquidity forecaster ran once per night on a static CSV dump of NEPSE trades. Intraday traders need a stream that delivers a fresh price‑direction signal every few seconds, yet the exchange only pushes sparse order‑book updates and occasional news blobs. The challenge is to ingest these micro‑batches without blocking, compute rolling indicators on the fly, keep the model current without costly full retraining, and finally surface the prediction as a concise market brief that non‑technical stakeholders can read.

Data and Sources

We simulate a live tick feed with the public JSONPlaceholder todos endpoint: https://jsonplaceholder.typicode.com/todos. Each todo represents a “tick” – userId maps to a stock symbol, completed indicates whether the trade was executed, and title carries a free‑form comment. In production you would swap this URL for the NEPSE WebSocket or HTTP streaming endpoint.
Data accessed on 2026-10-12.

Loading the Data

We fetch the JSON payload asynchronously, convert it to a pandas DataFrame, and normalize the fields we care about. The function also catches network errors and returns an empty list on failure so the pipeline can keep running.

import asyncio, httpx, pandas as pd

async def fetch_batch(session: httpx.AsyncClient, url: str) -> pd.DataFrame:
    try:
        resp = await session.get(url, timeout=5.0)
        resp.raise_for_status()
        raw = resp.json()
        df = pd.DataFrame(raw)
        # Normalization: map userId → symbol, completed → executed flag
        df.rename(columns={'userId': 'symbol_id',
                           'completed': 'executed',
                           'title': 'note'}, inplace=True)
        return df
    except (httpx.RequestError, httpx.HTTPStatusError) as exc:
        print(f"⚠️ fetch error: {exc}")
        return pd.DataFrame(columns=['symbol_id', 'executed', 'note'])

The Core Logic

Four functions compose the streaming pipeline.

from collections import deque
from sklearn.linear_model import SGDRegressor
import numpy as np

# 1️⃣ Sliding‑window buffer for each symbol
class WindowBuffer:
    def __init__(self, size: int = 20):
        self.size = size
        self.buffers = {}  # symbol_id → deque

    def add(self, symbol_id: int, executed: int):
        buf = self.buffers.setdefault(symbol_id, deque(maxlen=self.size))
        buf.append(executed)

    def ma(self, symbol_id: int) -> float:
        buf = self.buffers.get(symbol_id, [])
        return np.mean(buf) if buf else 0.0

# 2️⃣ Incremental model – predicts next execution probability
class OnlineForecaster:
    def __init__(self):
        # SGDRegressor expects 2‑D X, y in {0,1}
        self.model = SGDRegressor(loss='log', max_iter=1, learning_rate='optimal')
        self.initialized = False

    def update(self, X: np.ndarray, y: np.ndarray):
        if not self.initialized:
            # First call needs a class list for partial_fit
            self.model.partial_fit(X, y, classes=np.array([0, 1]))
            self.initialized = True
        else:
            self.model.partial_fit(X, y)

    def predict(self, X: np.ndarray) -> float:
        return float(self.model.predict(X)[0])

3️⃣ Generative commentary – a lightweight template that could be swapped for llama.cpp or any local LLM. It receives the symbol, the rolling MA, and the model’s probability, then returns a short English sentence.

def generate_comment(symbol_id: int, ma: float, prob: float) -> str:
    trend = "upward" if prob > 0.6 else "downward" if prob < 0.4 else "neutral"
    return (f"Symbol {symbol_id} shows a {trend} momentum (rolling exec rate "
            f"{ma:.2%}, predicted execution probability {prob:.2%}).")

4️⃣ Orchestrator – ties everything together in an async loop that polls every 2 seconds, updates the window, fits the model, and prints the commentary.

async def stream_loop(url: str, interval: float = 2.0):
    buffer = WindowBuffer(size=30)
    forecaster = OnlineForecaster()
    async with httpx.AsyncClient() as client:
        while True:
            batch = await fetch_batch(client, url)
            if batch.empty:
                await asyncio.sleep(interval)
                continue

            # Process each row as a micro‑tick
            for _, row in batch.iterrows():
                symbol = int(row['symbol_id'])
                executed = int(row['executed'])
                buffer.add(symbol, executed)

                # Feature vector: [rolling MA]
                X = np.array([[buffer.ma(symbol)]])
                y = np.array([executed])

                forecaster.update(X, y)
                prob = forecaster.predict(X)

                comment = generate_comment(symbol, X[0,0], prob)
                print(comment)

            await asyncio.sleep(interval)

Putting It Together

The entry point launches the async stream. We also guard against KeyboardInterrupt so the script exits cleanly.

def main():
    url = "https://jsonplaceholder.typicode.com/todos"
    try:
        asyncio.run(stream_loop(url))
    except KeyboardInterrupt:
        print("\n🛑 Streaming stopped by user.")

if __name__ == "__main__":
    main()

Complete Script

The full runnable script combining all steps:

#!/usr/bin/env python3
import asyncio
import httpx
import pandas as pd
import numpy as np
from collections import deque
from sklearn.linear_model import SGDRegressor

# ---------- Data Ingestion ----------
async def fetch_batch(session: httpx.AsyncClient, url: str) -> pd.DataFrame:
    try:
        resp = await session.get(url, timeout=5.0)
        resp.raise_for_status()
        raw = resp.json()
        df = pd.DataFrame(raw)
        df.rename(columns={'userId': 'symbol_id',
                           'completed': 'executed',
                           'title': 'note'}, inplace=True)
        return df
    except (httpx.RequestError, httpx.HTTPStatusError) as exc:
        print(f"⚠️ fetch error: {exc}")
        return pd.DataFrame(columns=['symbol_id', 'executed', 'note'])

# ---------- Sliding Window ----------
class WindowBuffer:
    def __init__(self, size: int = 20):
        self.size = size
        self.buffers = {}

    def add(self, symbol_id: int, executed: int):
        buf = self.buffers.setdefault(symbol_id, deque(maxlen=self.size))
        buf.append(executed)

    def ma(self, symbol_id: int) -> float:
        buf = self.buffers.get(symbol_id, [])
        return np.mean(buf) if buf else 0.0

# ---------- Incremental Model ----------
class OnlineForecaster:
    def __init__(self):
        self.model = SGDRegressor(loss='log', max_iter=1, learning_rate='optimal')
        self.initialized = False

    def update(self, X: np.ndarray, y: np.ndarray):
        if not self.initialized:
            self.model.partial_fit(X, y, classes=np.array([0, 1]))
            self.initialized = True
        else:
            self.model.partial_fit(X, y)

    def predict(self, X: np.ndarray) -> float:
        return float(self.model.predict(X)[0])

# ---------- Generative Commentary ----------
def generate_comment(symbol_id: int, ma: float, prob: float) -> str:
    trend = "upward" if prob > 0.6 else "downward" if prob < 0.4 else "neutral"
    return (f"Symbol {symbol_id} shows a {trend} momentum (rolling exec rate "
            f"{ma:.2%}, predicted execution probability {prob:.2%}).")

# ---------- Orchestration ----------
async def stream_loop(url: str, interval: float = 2.0):
    buffer = WindowBuffer(size=30)
    forecaster = OnlineForecaster()
    async with httpx.AsyncClient() as client:
        while True:
            batch = await fetch_batch(client, url)
            if batch.empty:
                await asyncio.sleep(interval)
                continue

            for _, row in batch.iterrows():
                symbol = int(row['symbol_id'])
                executed = int(row['executed'])
                buffer.add(symbol, executed)

                X = np.array([[buffer.ma(symbol)]])
                y = np.array([executed])

                forecaster.update(X, y)
                prob = forecaster.predict(X)

                comment = generate_comment(symbol, X[0,0], prob)
                print(comment)

            await asyncio.sleep(interval)

def main():
    url = "https://jsonplaceholder.typicode.com/todos"
    try:
        asyncio.run(stream_loop(url))
    except KeyboardInterrupt:
        print("\n🛑 Streaming stopped by user.")

if __name__ == "__main__":
    main()

Expected Output

Running the script prints a line every 2 seconds similar to:

Symbol 3 shows a neutral momentum (rolling exec rate 45.00%, predicted execution probability 48.73%).  
Symbol 7 shows an upward momentum (rolling exec rate 60.00%, predicted execution probability 62.10%).  
…

Because the placeholder data is static, the numbers will converge quickly; with a live NEPSE feed you’ll see the probabilities drift as market conditions change.

Limitations and Tradeoffs

This architecture is great for sub‑second to few‑second latency scenarios, but it isn’t built for high‑frequency trading where microseconds matter – a C++‑based order‑book processor would be required. The rolling‑window buffer lives entirely in memory; a symbol universe of thousands would need a more scalable store (Redis or a time‑series DB). Using SGDRegressor assumes a linear relationship; for highly non‑linear price dynamics you’d swap in an online tree ensemble or a tiny neural net. Finally, the “LLM” commentary is a stub; integrating a true local LLM adds GPU/CPU overhead and model‑version management.

Frequently Asked Questions

Can I replace the placeholder URL with a real WebSocket feed?

Yes. Replace fetch_batch with an async WebSocket client (e.g., websockets) that yields JSON messages, then feed them into the same buffer/model pipeline.

Why use SGDRegressor.partial_fit instead of fit?

partial_fit updates model weights incrementally, avoiding a full retrain on every micro‑batch and keeping CPU usage low – essential for a continuously running service.

What if the incoming JSON schema changes?

Guard the parsing step with try/except KeyError and emit a warning; you can also version‑control the schema mapping in a small config file.

Is the deque buffer thread‑safe?

In a single‑threaded asyncio loop it’s safe. If you move to a multi‑process architecture you’d need a process‑safe queue or external store.

How do I persist model state across restarts?

Serialize the SGDRegressor with joblib.dump after each successful batch and reload it at startup; similarly, pickle the WindowBuffer contents.

What I'd Change

If I were to production‑ize this for NEPSE, I’d swap the in‑memory deque for a Redis Stream so multiple workers can share state, upgrade the linear model to an online gradient‑boosted tree (e.g., river library) for better non‑linear capture, and plug a locally‑served Llama 2 model behind a FastAPI endpoint to generate richer, context‑aware commentaries. Those upgrades raise operational complexity, but they close the gap between a demo and a reliable intraday forecasting service that can survive data‑drift, scale to hundreds of symbols, and deliver analyst‑grade narratives on the fly.

Post a Comment

Hi! How can we help you? Send us a message and we'll get back to you.