Market Data Agent: Compliance-First Stock Pipeline


The first version of this agent was too big.

It worked in theory, but it was trying to prove too many things at once: two data providers, Pydantic validation, retry logic, provider switching, timeout handling, and model-specific response quirks. That is useful in a production system, but it made the teaching example harder to understand than it needed to be.

The real lesson is much simpler:

Python gets the market data. The model explains the market data. The model never invents the numbers.

That is the architecture worth keeping.


What This Agent Does

The Market Data Agent is a focused financial data worker. You give it a stock ticker symbol, like AAPL. It pulls the current price, trading volume, 52-week high and low, market cap, and PE ratio from yfinance.

Then it sends those verified numbers to a model through my LiteLLM proxy and asks for a short business-readable summary.

The important part is the separation of responsibility. The model is not trusted to know the stock price. It is not trusted to guess the PE ratio. It is not trusted to invent market cap.

Python fetches the figures. The model only explains what Python already found.

That separation is what makes the output easier to audit.


Why I Built This

If you have ever asked a chatbot for a stock price, you have probably had the same thought I had:

Is this number real?

That question matters. In casual conversation, a wrong number is annoying. In financial reporting, investment research, executive briefings, or compliance work, a wrong number becomes a liability.

So I wanted the agent to follow one rule:

Do not let the model create financial facts.

The model can summarize. The model can make the output easier to read. But every number must come from a data provider that can be checked independently.


What Changed During the Build

The original Post 3 agent was around 287 lines. The simplified version is now about 137 lines.

I removed the parts that distracted from the core architecture:

  • Removed Finnhub provider switching.
  • Removed the local litellm package dependency.
  • Removed Pydantic from this teaching version.
  • Removed broad retry complexity.
  • Renamed env.template to .env.template.
  • Standardized LITELLM_BASE_URL to http://localhost:4000, because the SSH tunnel is now the normal route.
  • Kept yfinance as the only data provider.
  • Kept ticker validation.
  • Kept the separation between verified data retrieval and model summarization.

The mistake was trying to make the teaching repo feel too production-like. Smaller code shows the architecture better.


The Architecture

The agent now has four simple steps:

  1. Load configuration.
  2. Validate the ticker.
  3. Fetch verified market data with yfinance.
  4. Ask the model to summarize only those verified values.

That is it.

No hidden orchestration. No extra provider layer. No state graph. No framework ceremony.


Step 1: Environment Setup

The .env.template file now matches the actual setup:

LITELLM_BASE_URL=http://localhost:4000
MODEL_NAME=YOUR_MODEL_NAME_HERE
LITELLM_API_KEY=YOUR_API_KEY_HERE
LITELLM_TIMEOUT=60

The localhost:4000 value matters because I am running LiteLLM through the SSH tunnel. That keeps the code pointed at a local address while the actual proxy can still live on the VPS.

The agent checks these values before doing any work:

REQUIRED_ENV_VARS = ["LITELLM_BASE_URL", "MODEL_NAME", "LITELLM_API_KEY"]

def check_env() -> None:
    """Stop early if the LiteLLM configuration is incomplete."""
    missing = [name for name in REQUIRED_ENV_VARS if not os.getenv(name)]
    if missing:
        print("Missing required environment variables:")
        for name in missing:
            print(f"  - {name}")
        print("Create a .env file from .env.template and try again.")
        sys.exit(1)

This is the pre-flight check. If the proxy URL, model name, or API key is missing, the agent stops before making network calls.


Step 2: Ticker Validation

Before the agent calls the data provider, it cleans and validates the ticker.

def clean_ticker(ticker: str) -> str:
    """Normalize and validate a stock ticker before calling the data provider."""
    ticker = ticker.strip().upper()
    if not re.fullmatch(r"[A-Z.\-]{1,10}", ticker):
        raise ValueError("Use a valid ticker symbol like AAPL, MSFT, or BRK-B.")
    return ticker

This keeps bad input from reaching the provider. aapl becomes AAPL. Something malformed gets rejected immediately with a readable message.


Step 3: Fetch Verified Market Data

The data retrieval layer is intentionally small now.

def fetch_market_data(ticker: str) -> dict:
    """Fetch verified market data from yfinance."""
    ticker = clean_ticker(ticker)
    info = yf.Ticker(ticker).info
    if not info or "currentPrice" not in info:
        raise ValueError(f"Could not retrieve market data for {ticker}.")

    return {
        "ticker": ticker,
        "current_price": float(info["currentPrice"]),
        "volume": int(info.get("volume") or 0),
        "week_52_high": float(info.get("fiftyTwoWeekHigh") or 0),
        "week_52_low": float(info.get("fiftyTwoWeekLow") or 0),
        "market_cap": info.get("marketCap"),
        "pe_ratio": info.get("trailingPE"),
        "currency": info.get("currency", "USD"),
        "data_timestamp": datetime.now(timezone.utc).isoformat(),
        "source": "yfinance",
    }

This is where the financial facts come from. The model has not been called yet.

The agent records the source as yfinance, which matters because every number should be traceable.


Step 4: Summarize the Verified Data

The model receives a compact facts line. It does not receive an open-ended request to “analyze the stock.”

def summarize_market_data(market_data: dict) -> str:
    """Ask the model to summarize only the verified data Python fetched."""
    facts = (
        f"Ticker {market_data['ticker']}; current price {market_data['current_price']} "
        f"{market_data['currency']}; volume {market_data['volume']}; 52-week high "
        f"{market_data['week_52_high']}; 52-week low {market_data['week_52_low']}; "
        f"market cap {market_data.get('market_cap')}; PE ratio {market_data.get('pe_ratio')}; "
        f"source {market_data['source']}."
    )

Then the agent sends those facts through the OpenAI SDK pointed at LiteLLM:

client = OpenAI(
    base_url=os.getenv("LITELLM_BASE_URL"),
    api_key=os.getenv("LITELLM_API_KEY"),
    timeout=float(os.getenv("LITELLM_TIMEOUT", "60")),
)

The prompt is deliberately narrow:

response = client.chat.completions.create(
    model=os.getenv("MODEL_NAME"),
    messages=[
        {
            "role": "system",
            "content": (
                "Return only the final answer. Write one short plain-English "
                "paragraph summarizing verified market data. Do not use bullet "
                "points, predictions, recommendations, or financial advice."
            ),
        },
        {
            "role": "user",
            "content": f"Write one complete sentence using only these facts: {facts}",
        },
    ],
    max_tokens=800,
)

In the repo, I will remove the “plain-English” wording from the next cleanup pass because I do not like how that phrase sounds in the article. The point is simple: the model should write a short, readable summary without adding advice or predictions.


The Mistakes I Hit

This build had several mistakes, and they were useful.

The first mistake was overbuilding the example. I originally had yfinance, Finnhub, Pydantic, retries, provider switching, and extra error handling. That made the file harder to read. The simplified version shows the same pattern with much less code.

The second mistake was including litellm in requirements.txt. The code was not importing litellm directly. It was using the OpenAI SDK pointed at a LiteLLM proxy. That means the local litellm package was unnecessary and created dependency pressure. Removing it made the environment cleaner.

The third mistake was the env template name. The README said to copy .env.template, but the file was named env.template. That is the kind of small mismatch that wastes time. The file is now correctly named .env.template.

The fourth issue was model behavior. The gemini-flash route was returning truncated summaries. One test came back as:

Market data encompasses a

That was not an agent architecture problem. It was the model route returning incomplete content.

Then I tested deepseek-v4-pro. That exposed a different issue: the route spent tokens in reasoning output before returning a final answer. With a low token budget, the final answer came back empty. Raising max_tokens to 800 gave the model enough room to produce the final content.

The fifth issue was Windows console encoding. DeepSeek returned a non-standard hyphen character, and Windows could not print it. I fixed that with:

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")

That is not glamorous, but it matters. A demo that crashes while printing a valid response still feels broken.


The Successful Run

The final verified run used deepseek-v4-pro through LiteLLM and AAPL as the ticker.

The agent printed verified data first:

Ticker:       AAPL
Price:        294.8 USD
Volume:       43,943,518
52-Week High: 295.27
52-Week Low:  193.46
Market Cap:   4,329,832,448,000.00
PE Ratio:     35.73
Source:       yfinance

Then it printed the model summary:

According to yfinance, ticker AAPL currently trades at 294.8 USD with a volume of 43943518, a 52-week high of 295.27 USD, a 52-week low of 193.46 USD, a market capitalization of 4329832448000 USD, and a PE ratio of 35.733334.

That is the behavior I wanted. The numbers came from Python. The model turned them into a readable sentence.


What I Would Do Differently

For production, I would not rely only on yfinance. It is convenient for demos, but it is not the provider I would choose for serious financial workflows. I would use a paid market data provider with better uptime, rate limits, and support.

I would also add caching. If ten people ask for AAPL within five minutes, the agent should not fetch the same data and call the model ten times. A small Redis cache with a five-minute TTL would cut unnecessary traffic and cost.

I would also add a stronger source record. Right now the output says source: yfinance. In production, I would record provider name, fetch time, request ID, and maybe the raw provider response hash. That would make audits stronger.

But for this post, those additions would distract from the lesson.

The clean teaching version should stay small.


What This Means

The main idea is compliance by separation.

The model is not the source of truth. The model is the communicator.

Python gets the numbers. Python prints the numbers. Python records the source. The model only summarizes what it was given.

That is the pattern I would use anywhere financial accuracy matters: market briefings, internal dashboards, analyst support, executive summaries, or client reports.

When the numbers matter, do not ask the model to know them. Make the system fetch them.


Tools Used


(c) 2026 NosisTech LLC. Licensed under CC BY 4.0. Use freely, just credit us.

WEEKLY BUILD NOTES

One documented agent build in your inbox, every week

Real systems, real code, the errors left in. No spam, unsubscribe anytime.