Skip to content

Beyond Extraction: Engineering an LLM Agent for Context‑Aware NEPSE Trade Decisions

Ever stared at a breaking news alert about a Nepali firm and wondered whether you should buy, sell, or just ignore it? I was in that exact spot last month when a sudden board‑room reshuffle at Nabil Bank hit my Slack channel. The event extraction pipeline I built earlier gave me a clean JSON payload, but nothing told me how the news meshed with the stock’s recent volatility or whether a 5‑day moving average hinted at a reversal. This post is for data engineers and quant developers who already have a stream of structured financial events and need a production‑grade decision layer that respects market context, validates its own output, and fails fast when something looks off. I’ll walk you through a concrete agent that turns a news event into a BUY/SELL/HOLD signal, complete with a rationale you can audit.

Key Takeaways

  • Typed Pydantic models turn loosely‑structured event JSON into safe, self‑documenting objects.
  • Fetching the last 30 days of NEPSE daily data, computing a 5‑day moving average and volatility, provides the market context an LLM needs to reason about impact.
  • A multi‑turn prompt with explicit JSON schema instructions, coupled with validation‑driven re‑prompting, yields consistently parseable trade recommendations.
  • Graceful error handling (network failures, schema mismatches) keeps the agent alive in a low‑liquidity market where false signals are costly.
  • In production you should cache market slices, rate‑limit LLM calls, and log every rationale for compliance audits.

The Problem

Our earlier self‑healing extraction pipeline (see How I Built a Self‑Healing LLM Pipeline for Real‑Time Nepal Financial Event Extraction) gives us FinancialEvent objects, but a trading system needs to answer two questions:

  1. What does the event mean for the stock given its recent price behaviour?
  2. Can we trust the LLM’s recommendation enough to let a downstream order‑router act on it?

Both require merging live news with historic market data and a validation loop that catches malformed LLM output before it reaches the trade engine.

Data and Sources

The market slice comes from the public NEPSE daily price archive maintained by the Nepal Stock Exchange. I host a copy on GitHub for reproducibility:

Data accessed on 2026-10-28.

Loading the Data

We pull the CSV into a pandas.DataFrame, parse dates, and keep only the columns we need. A small helper also caches the DataFrame in memory to avoid repeated downloads.

import pandas as pd
import requests
from io import StringIO

_NE PSE_URL = "https://raw.githubusercontent.com/mausamadhikari/nepse-data/main/nepse_historical_daily.csv"
_market_cache: pd.DataFrame | None = None

def load_market_data() -> pd.DataFrame:
    global _market_cache
    if _market_cache is not None:
        return _market_cache
    try:
        resp = requests.get(_NEPSE_URL, timeout=10)
        resp.raise_for_status()
        df = pd.read_csv(StringIO(resp.text), parse_dates=["date"])
        _market_cache = df
        return df
    except requests.RequestException as e:
        raise RuntimeError(f"Failed to download market data: {e}") from e

Step 1 — Ingesting Structured Financial Events

This step turns the raw dict we receive from the extraction pipeline into a typed object. Using Pydantic gives us runtime validation and clear error messages.

from pydantic import BaseModel, Field, ValidationError
from typing import Literal

class FinancialEvent(BaseModel):
    symbol: str = Field(..., description="NEPSE ticker, e.g., 'NABIL'")
    event_type: Literal["CEO_CHANGE", "MERGER", "EARNINGS", "REGULATORY"] = Field(...,
        description="High‑level classification")
    headline: str
    timestamp: str  # ISO‑8601 string

When the pipeline emits a dict, we simply call FinancialEvent.parse_obj(payload). If the ticker is unknown we raise a ValueError later.

Step 2 — Retrieving Contextual Market Data

With the ticker in hand we slice the last 30 days of price data, compute a 5‑day moving average and daily volatility. The resulting JSON fragment is short enough for the LLM prompt but rich enough to convey trend.

def get_market_context(symbol: str, days: int = 30) -> dict:
    df = load_market_data()
    if symbol not in df["symbol"].unique():
        raise ValueError(f"Symbol {symbol} not found in market data")
    recent = (
        df[df["symbol"] == symbol]
        .sort_values("date")
        .tail(days)
        .reset_index(drop=True)
    )
    recent["ma_5"] = recent["close"].rolling(5).mean()
    recent["vol"] = recent["close"].pct_change().abs()
    context = {
        "latest_close": recent["close"].iloc[-1],
        "ma_5": round(recent["ma_5"].iloc[-1], 2),
        "volatility": round(recent["vol"].mean() * 100, 2),  # percent
        "price_history": recent[["date", "close"]].to_dict(orient="records"),
    }
    return context

Step 3 — Engineering the LLM Decision Prompt for Financial Interpretation

The prompt tells the model to act as a “NEPSE analyst”, gives it the event and market context, and forces a JSON output that matches TradeRecommendation (defined next). We keep the system message short to reduce token waste.

import json
import openai

class TradeRecommendation(BaseModel):
    symbol: str
    decision: Literal["BUY", "SELL", "HOLD"]
    target_price: float | None = None
    rationale: str

SYSTEM_MSG = {
    "role": "system",
    "content": (
        "You are a senior analyst for the Nepal Stock Exchange. "
        "Given a financial event and recent market context, "
        "return a JSON object with fields: symbol, decision, target_price (optional), rationale."
    ),
}

def build_user_message(event: FinancialEvent, market_ctx: dict) -> dict:
    return {
        "role": "user",
        "content": json.dumps({
            "event": event.dict(),
            "market_context": market_ctx,
        }, indent=2),
    }

Step 4 — Implementing Self‑Correction for Robust Trade Rationales

We call the LLM, attempt to parse the JSON with Pydantic, and on ValidationError re‑prompt with the error details. The loop caps at two retries to avoid endless cycles.

def query_llm(event: FinancialEvent, market_ctx: dict) -> TradeRecommendation:
    messages = [SYSTEM_MSG, build_user_message(event, market_ctx)]
    for attempt in range(2):  # one retry allowed
        resp = openai.ChatCompletion.create(
            model="gpt-4o-mini",
            messages=messages,
            temperature=0.0,
        )
        raw = resp.choices[0].message.content.strip()
        try:
            return TradeRecommendation.parse_raw(raw)
        except ValidationError as ve:
            # Construct a corrective prompt
            error_msg = f"Validation error: {ve.errors()}. Please return a valid JSON."
            messages.append({"role": "assistant", "content": raw})
            messages.append({"role": "user", "content": error_msg})
    raise RuntimeError("LLM failed to produce a valid TradeRecommendation after retry.")

Step 5 — Simulating Agent Action and Logging

Finally we glue everything together, print a human‑readable summary, and append a line to trade_ledger.csv. The ledger is useful for back‑testing and compliance.

import csv
from datetime import datetime

LEDGER_PATH = "trade_ledger.csv"

def log_trade(rec: TradeRecommendation, event: FinancialEvent) -> None:
    file_exists = False
    try:
        with open(LEDGER_PATH, "r"):
            file_exists = True
    except FileNotFoundError:
        pass
    with open(LEDGER_PATH, "a", newline="") as f:
        writer = csv.writer(f)
        if not file_exists:
            writer.writerow(["timestamp", "symbol", "decision", "target_price", "rationale", "event_headline"])
        writer.writerow([
            datetime.utcnow().isoformat(),
            rec.symbol,
            rec.decision,
            rec.target_price or "",
            rec.rationale,
            event.headline,
        ])

def simulate_agent(event_payload: dict):
    try:
        event = FinancialEvent.parse_obj(event_payload)
    except ValidationError as ve:
        print(f"Event payload invalid: {ve}")
        return
    try:
        market_ctx = get_market_context(event.symbol)
    except ValueError as ve:
        print(f"Market context error: {ve}")
        return
    recommendation = query_llm(event, market_ctx)
    print(f"\n=== Recommendation for {event.symbol} ===")
    print(f"Decision   : {recommendation.decision}")
    print(f"Target     : {recommendation.target_price or 'N/A'}")
    print(f"Rationale  : {recommendation.rationale}\n")
    log_trade(recommendation, event)

Limitations and Tradeoffs

This design assumes the LLM’s knowledge of Nepali corporate law is sufficient; in reality you may need a domain‑specific fine‑tuned model. The market context slice is limited to 30 days and simple indicators—more sophisticated features (order‑book depth, macro‑economic releases) would improve signal quality but increase latency. Caching reduces API calls but adds stale‑data risk if the CSV updates during market hours. Finally, the self‑correction loop only retries once; a production system should implement exponential back‑off and circuit‑breaker patterns.

Frequently Asked Questions

What if the ticker in the event isn’t present in the historical CSV?

The get_market_context function raises a ValueError. In a live system you’d catch it, flag the event as “untradeable”, and optionally fall back to sector‑level averages.

Can I swap OpenAI for another LLM provider?

Yes. The prompt structure is provider‑agnostic; replace the openai.ChatCompletion.create call with the equivalent endpoint and adjust the response field extraction.

How do I avoid hitting rate limits during news spikes?

Batch multiple events into a single request (concatenate them in the user message) and add a simple retry decorator that respects 429 responses with exponential back‑off.

Is the JSON schema flexible enough for more complex strategies?

Absolutely. Extend TradeRecommendation with fields like stop_loss, position_size, or confidence_score, then update the system message to reflect the new contract.

Do I need to store the LLM rationale for compliance?

In regulated environments you should. The log_trade function already persists the rationale alongside the decision, making audits straightforward.

What I'd Change

If I were to ship this today, I’d replace the raw CSV download with a streaming market‑data service that pushes minute‑level bars, and I’d move the LLM call into an asynchronous worker pool behind a task queue (e.g., Celery). That eliminates the single‑thread bottleneck and lets you throttle calls based on real‑time rate‑limit feedback. Moreover, I’d embed a lightweight Monte‑Carlo back‑test that runs the recommendation against the last 90 days before committing to any live order—an extra safety net that paid off during my first production trial.

Complete Script

The full runnable script combining all steps:

#!/usr/bin/env python3
"""
Beyond Extraction: LLM Agent for Context‑Aware NEPSE Trade Decisions
Requires: pandas, requests, openai, pydantic
"""

import csv
import json
import sys
from datetime import datetime
from io import StringIO

import openai
import pandas as pd
import requests
from pydantic import BaseModel, Field, ValidationError
from typing import Literal

# ----------------------------------------------------------------------
# Configuration
# ----------------------------------------------------------------------
_NE PSE_URL = "https://raw.githubusercontent.com/mausamadhikari/nepse-data/main/nepse_historical_daily.csv"
_LEDGER_PATH = "trade_ledger.csv"
_OPENAI_MODEL = "gpt-4o-mini"
openai.api_key = "YOUR_OPENAI_API_KEY"  # <-- set env var or replace here

_market_cache: pd.DataFrame | None = None

# ----------------------------------------------------------------------
# Data loading
# ----------------------------------------------------------------------
def load_market_data() -> pd.DataFrame:
    """Download NEPSE daily CSV once and cache it."""
    global _market_cache
    if _market_cache is not None:
        return _market_cache
    try:
        resp = requests.get(_NE PSE_URL, timeout=10)
        resp.raise_for_status()
        df = pd.read_csv(StringIO(resp.text), parse_dates=["date"])
        _market_cache = df
        return df
    except requests.RequestException as e:
        raise RuntimeError(f"Failed to download market data: {e}") from e


def get_market_context(symbol: str, days: int = 30) -> dict:
    """Slice recent price history and compute simple indicators."""
    df = load_market_data()
    if symbol not in df["symbol"].unique():
        raise ValueError(f"Symbol {symbol} not found in market data")
    recent = (
        df[df["symbol"] == symbol]
        .sort_values("date")
        .tail(days)
        .reset_index(drop=True)
    )
    recent["ma_5"] = recent["close"].rolling(5).mean()
    recent["vol"] = recent["close"].pct_change().abs()
    context = {
        "latest_close": recent["close"].iloc[-1],
        "ma_5": round(recent["ma_5"].iloc[-1], 2),
        "volatility": round(recent["vol"].mean() * 100, 2),
        "price_history": recent[["date", "close"]].to_dict(orient="records"),
    }
    return context


# ----------------------------------------------------------------------
# Typed models
# ----------------------------------------------------------------------
class FinancialEvent(BaseModel):
    symbol: str = Field(..., description="NEPSE ticker, e.g., 'NABIL'")
    event_type: Literal["CEO_CHANGE", "MERGER", "EARNINGS", "REGULATORY"] = Field(
        ..., description="High‑level classification"
    )
    headline: str
    timestamp: str  # ISO‑8601


class TradeRecommendation(BaseModel):
    symbol: str
    decision: Literal["BUY", "SELL", "HOLD"]
    target_price: float | None = None
    rationale: str


# ----------------------------------------------------------------------
# Prompt engineering
# ----------------------------------------------------------------------
SYSTEM_MSG = {
    "role": "system",
    "content": (
        "You are a senior analyst for the Nepal Stock Exchange. "
        "Given a financial event and recent market context, "
        "return a JSON object with fields: symbol, decision, target_price (optional), rationale."
    ),
}


def build_user_message(event: FinancialEvent, market_ctx: dict) -> dict:
    return {
        "role": "user",
        "content": json.dumps(
            {"event": event.dict(), "market_context": market_ctx}, indent=2
        ),
    }


def query_llm(event: FinancialEvent, market_ctx: dict) -> TradeRecommendation:
    """Call OpenAI, validate output, retry once on schema errors."""
    messages = [SYSTEM_MSG, build_user_message(event, market_ctx)]
    for attempt in range(2):
        resp = openai.ChatCompletion.create(
            model=_OPENAI_MODEL,
            messages=messages,
            temperature=0.0,
        )
        raw = resp.choices[0].message.content.strip()
        try:
            return TradeRecommendation.parse_raw(raw)
        except ValidationError as ve:
            error_msg = f"Validation error: {ve.errors()}. Please return a valid JSON."
            messages.append({"role": "assistant", "content": raw})
            messages.append({"role": "user", "content": error_msg})
    raise RuntimeError("LLM failed to produce a valid TradeRecommendation after retry.")


# ----------------------------------------------------------------------
# Logging / simulation
# ----------------------------------------------------------------------
def log_trade(rec: TradeRecommendation, event: FinancialEvent) -> None:
    file_exists = False
    try:
        with open(_LEDGER_PATH, "r"):
            file_exists = True
    except FileNotFoundError:
        pass
    with open(_LEDGER_PATH, "a", newline="") as f:
        writer = csv.writer(f)
        if not file_exists:
            writer.writerow(
                ["timestamp", "symbol", "decision", "target_price", "rationale", "event_headline"]
            )
        writer.writerow(
            [
                datetime.utcnow().isoformat(),
                rec.symbol,
                rec.decision,
                rec.target_price or "",
                rec.rationale,
                event.headline,
            ]
        )


def simulate_agent(event_payload: dict) -> None:
    try:
        event = FinancialEvent.parse_obj(event_payload)
    except ValidationError as ve:
        print(f"❌ Event payload invalid: {ve}")
        return

    try:
        market_ctx = get_market_context(event.symbol)
    except ValueError as ve:
        print(f"❌ Market context error: {ve}")
        return

    try:
        recommendation = query_llm(event, market_ctx)
    except RuntimeError as e:
        print(f"❌ LLM error: {e}")
        return

    print(f"\n=== Recommendation for {event.symbol} ===")
    print(f"Decision   : {recommendation.decision}")
    print(f"Target     : {recommendation.target_price or 'N/A'}")
    print(f"Rationale  : {recommendation.rationale}\n")

    log_trade(recommendation, event)


# ----------------------------------------------------------------------
# Entry point
# ----------------------------------------------------------------------
def main() -> None:
    if len(sys.argv) != 2:
        print("Usage: python script.py ")
        sys.exit(1)

    try:
        with open(sys.argv[1], "r") as f:
            payload = json.load(f)
    except Exception as e:
        print(f"❌ Failed to read input file: {e}")
        sys.exit(1)

    simulate_agent(payload)


if __name__ == "__main__":
    main()

Expected Output

Running the script with a JSON file that looks like the example below:

{
  "symbol": "NABIL",
  "event_type": "CEO_CHANGE",
  "headline": "Nabil Bank appoints new CEO amid regulatory scrutiny",
  "timestamp": "2026-10-27T08:15:00Z"
}

should print something akin to:


=== Recommendation for NABIL ===
Decision   : BUY
Target     : 23.5
Rationale  : The CEO change is positive for governance. Recent 5‑day MA (22.9) is below the latest close (23.0) and volatility is low (1.2%). Expect a modest upside, target set at 5% above current price.

and append a line to trade_ledger.csv with the same fields.

إرسال تعليق

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