DEV Community

Naren karthi
Naren karthi

Posted on

How to Build an Autonomous Trading Agent with Python

🚀 End‑to‑End Tutorial

Build a “Crypto‑AI Oracle” – an on‑chain price‑prediction service powered by a TensorFlow model

In this tutorial you will:

  1. Collect historical price data for a crypto token (e.g., ETH).
  2. Train a lightweight LSTM model that predicts the next‑day price.
  3. Expose the model through a FastAPI web service.
  4. Wrap the service in a Chainlink External Adapter (Node.js) so a Solidity contract can request a prediction.
  5. Deploy the smart contract to an Ethereum testnet (Sepolia) and register it with a Chainlink oracle.
  6. Call the contract from a front‑end (React) and see the on‑chain prediction.

📋 Table of Contents

Step Title Files
0 Prerequisites & Environment Setup
1 Data collection & preprocessing data/
2 Model training & export model/train.py, model/price_lstm.h5
3 FastAPI inference server api/main.py
4 Chainlink External Adapter (Node.js) adapter/index.js
5 Solidity Oracle contract contracts/CryptoAIOracle.sol
6 Hardhat deployment & verification scripts/deploy.js
7 Front‑end demo (React) frontend/
8 Testing & troubleshooting
9 Next steps & security notes

0️⃣ Prerequisites & Environment Setup

Tool Version (tested) Install command
Node.js 20.x `curl -fsSL https://deb.nodesource.com/setup_20.x
Python 3.11 {% raw %}sudo apt-get install python3.11 python3.11-venv
Git latest sudo apt-get install git
Docker 27.x Follow Docker’s official guide
Hardhat 2.22.x npm i -g hardhat
Solidity compiler 0.8.24 Comes with Hardhat
Chainlink CLI (optional) 1.2.x npm i -g @chainlink/cli
Metamask Chrome/Firefox extension
Testnet ETH Sepolia faucet https://sepoliafaucet.com

Tip: All commands below assume a Unix‑like shell (Linux/macOS). Windows users can use WSL2 or Git‑Bash.

1️⃣ Create a monorepo folder

mkdir crypto-ai-oracle && cd crypto-ai-oracle
git init
Enter fullscreen mode Exit fullscreen mode

2️⃣ Initialise Python & Node workspaces

# Python virtual env for data/model
python3.11 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
# Install data + ML libs
pip install pandas numpy yfinance tensorflow==2.16.1 fastapi uvicorn python-dotenv

# Node workspace (for adapter & hardhat)
mkdir node && cd node
npm init -y
npm i axios ethers @chainlink/contracts @chainlink/external-adapter
cd ..
Enter fullscreen mode Exit fullscreen mode

3️⃣ Initialise Hardhat project

npx hardhat init
# Choose "Create a basic sample project"
Enter fullscreen mode Exit fullscreen mode

You should now have a contracts/, scripts/, test/ folder.


1️⃣ Data Collection & Pre‑processing

We’ll pull daily OHLCV data for ETH‑USDT from Yahoo Finance (via yfinance) and create a CSV that the model can consume.

data/collect.py

# data/collect.py
import yfinance as yf
import pandas as pd
from datetime import datetime, timedelta

# Pull the last 3 years of daily data
ticker = "ETH-USD"
end = datetime.utcnow()
start = end - timedelta(days=3*365)

df = yf.download(ticker, start=start, end=end, interval="1d")
df = df[['Open', 'High', 'Low', 'Close', 'Volume']]

# Simple feature engineering: use close price and pct change
df['Pct_Change'] = df['Close'].pct_change()
df = df.dropna()

# Save for later
df.to_csv('data/eth_daily.csv')
print(f"Saved {len(df)} rows to data/eth_daily.csv")
Enter fullscreen mode Exit fullscreen mode
python data/collect.py
Enter fullscreen mode Exit fullscreen mode

You should now have data/eth_daily.csv.


2️⃣ Model Training & Export

We’ll train a tiny LSTM that looks at the past 7 days and predicts the next day’s close price.

model/train.py

# model/train.py
import pandas as pd
import numpy as np
import tensorflow as tf
from tensorflow.keras import layers, callbacks, models
from sklearn.preprocessing import MinMaxScaler
import joblib
import os

DATA_PATH = "../data/eth_daily.csv"
MODEL_PATH = "price_lstm.h5"
SCALER_PATH = "scaler.save"

# 1️⃣ Load & scale
df = pd.read_csv(DATA_PATH, parse_dates=['Date'])
close = df['Close'].values.reshape(-1, 1)

scaler = MinMaxScaler()
scaled = scaler.fit_transform(close)

# 2️⃣ Build sequences (7‑day look‑back)
look_back = 7
X, y = [], []
for i in range(len(scaled) - look_back):
    X.append(scaled[i:i+look_back])
    y.append(scaled[i+look_back])
X, y = np.array(X), np.array(y)

# 3️⃣ Train / validation split
split = int(0.85 * len(X))
X_train, X_val = X[:split], X[split:]
y_train, y_val = y[:split], y[split:]

# 4️⃣ Model definition
model = models.Sequential([
    layers.LSTM(32, input_shape=(look_back, 1)),
    layers.Dense(1)
])
model.compile(optimizer='adam', loss='mse')
model.summary()

# 5️⃣ Callbacks
es = callbacks.EarlyStopping(patience=10, restore_best_weights=True)

# 6️⃣ Train
model.fit(
    X_train, y_train,
    validation_data=(X_val, y_val),
    epochs=200,
    batch_size=16,
    callbacks=[es],
    verbose=2
)

# 7️⃣ Save model & scaler
os.makedirs('model', exist_ok=True)
model.save('model/' + MODEL_PATH)
joblib.dump(scaler, 'model/' + SCALER_PATH)

print(f"✅ Model saved to model/{MODEL_PATH}")
print(f"✅ Scaler saved to model/{SCALER_PATH}")
Enter fullscreen mode Exit fullscreen mode
python model/train.py
Enter fullscreen mode Exit fullscreen mode

You should now have:

model/price_lstm.h5
model/scaler.save
Enter fullscreen mode Exit fullscreen mode

Why MinMax? The LSTM works best on data scaled to [0,1] and we’ll invert the scaling before returning the price.


3️⃣ FastAPI Inference Server

This service loads the model once on start‑up and answers HTTP POST /predict with the next‑day price.

Project layout

api/
 ├─ main.py
 ├─ requirements.txt
 └─ model/   ← copy model files here (price_lstm.h5, scaler.save)
Enter fullscreen mode Exit fullscreen mode

api/requirements.txt

fastapi
uvicorn[standard]
tensorflow==2.16.1
python-dotenv
joblib
numpy
Enter fullscreen mode Exit fullscreen mode

api/main.py

# api/main.py
import os
import joblib
import numpy as np
import tensorflow as tf
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List
from dotenv import load_dotenv

load_dotenv()          # optional: load PORT, LOG_LEVEL, etc.

app = FastAPI(title="Crypto AI Oracle API", version="0.1.0")

# -------------------------------------------------
# 1️⃣ Load model & scaler once (global)
MODEL_PATH = os.path.join(os.path.dirname(__file__), "model/price_lstm.h5")
SCALER_PATH = os.path.join(os.path.dirname(__file__), "model/scaler.save")

try:
    model = tf.keras.models.load_model(MODEL_PATH)
    scaler = joblib.load(SCALER_PATH)
except Exception as exc:
    raise RuntimeError(f"Failed to load model/scaler: {exc}")

# -------------------------------------------------
# 2️⃣ Request schema
class PredictRequest(BaseModel):
    # Last 7 close prices (float) – order oldest → newest
    recent_closes: List[float]

# -------------------------------------------------
# 3️⃣ Helper: turn raw closes → model input
def create_input(closes: List[float]) -> np.ndarray:
    if len(closes) != 7:
        raise ValueError("Exactly 7 recent close prices required")
    arr = np.array(closes).reshape(-1, 1)
    scaled = scaler.transform(arr)                # shape (7,1)
    return scaled.reshape(1, 7, 1)                # (batch, time, features)

# -------------------------------------------------
# 4️⃣ Endpoint
@app.post("/predict")
def predict(req: PredictRequest):
    try:
        x = create_input(req.recent_closes)      # (1,7,1)
        y_hat_scaled = model.predict(x)          # (1,1)
        # Inverse scaling
        y_hat = scaler.inverse_transform(y_hat_scaled)
        pred_price = float(y_hat[0][0])
        return {"prediction": pred_price}
    except Exception as e:
        raise HTTPException(status_code=400, detail=str(e))
Enter fullscreen mode Exit fullscreen mode

Run locally

cd api
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --host 0.0.0.0 --port 8000
Enter fullscreen mode Exit fullscreen mode

Test with curl:

curl -X POST http://localhost:8000/predict \
  -H "Content-Type: application/json" \
  -d '{"recent_closes":[1850,1865,1840,1855,1860,1870,1885]}'
Enter fullscreen mode Exit fullscreen mode

You should receive JSON like:

{"prediction": 1892.34}
Enter fullscreen mode Exit fullscreen mode

4️⃣ Chainlink External Adapter (Node.js)

Chainlink nodes call external adapters (HTTP services) to fetch off‑chain data.

Our adapter will be a thin wrapper around the FastAPI endpoint, adding Chainlink‑specific response formatting.

Folder layout

adapter/
 ├─ index.js
 ├─ package.json
 └─ .env
Enter fullscreen mode Exit fullscreen mode

adapter/package.json

{
  "name": "crypto-ai-adapter",
  "version": "1.0.0",
  "description": "Chainlink EA for price prediction",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "axios": "^1.7.2",
    "dotenv": "^16.4.5",
    "express": "^4.19.2"
  }
}
Enter fullscreen mode Exit fullscreen mode
cd adapter
npm i
Enter fullscreen mode Exit fullscreen mode

.env

API_URL=http://host.docker.internal:8000/predict   # points to FastAPI container
PORT=8080
Enter fullscreen mode Exit fullscreen mode

Note: host.docker.internal works when the adapter runs in Docker on the same host as the FastAPI container. If you run everything locally without Docker, replace with http://localhost:8000/predict.

adapter/index.js

// adapter/index.js
import express from "express";
import axios from "axios";
import dotenv from "dotenv";

dotenv.config();

const app = express();
app.use(express.json());

const API_URL = process.env.API_URL || "http://localhost:8000/predict";
const PORT = process.env.PORT || 8080;

/**
 * Chainlink expects a response shape:
 * {
 *   jobRunID: string,
 *   data: { result: any },
 *   result: any,
 *   statusCode: number
 * }
 */
app.post("/", async (req, res) => {
  const { id, data } = req.body;   // `id` is the jobRunID
  const recentCloses = data?.recent_closes; // Array of 7 numbers

  if (!Array.isArray(recentCloses) || recentCloses.length !== 7) {
    return res.status(400).json({
      jobRunID: id,
      statusCode: 400,
      error: "recent_closes must be an array of 7 numbers",
    });
  }

  try {
    const response = await axios.post(API_URL, { recent_closes: recentCloses });
    const prediction = response.data.prediction;

    return res.json({
      jobRunID: id,
      data: { result: prediction },
      result: prediction,
      statusCode: 200,
    });
  } catch (e) {
    console.error("Adapter error:", e.message);
    return res.status(500).json({
      jobRunID: id,
      statusCode: 500,
      error: e.message,
    });
  }
});

app.listen(PORT, () => console.log(`🔗 EA listening on ${PORT}`));
Enter fullscreen mode Exit fullscreen mode

Run the adapter

npm run start
Enter fullscreen mode Exit fullscreen mode

You should see 🔗 EA listening on 8080.

If you hit it manually:

curl -X POST http://localhost:8080/ \
  -H "Content-Type: application/json" \
  -d '{"id":"test123","data":{"recent_closes":[1850,1865,1840,1855,1860,1870,1885]}}'
Enter fullscreen mode Exit fullscreen mode

You’ll get a Chainlink‑shaped JSON back.


5️⃣ Solidity Oracle Contract

The contract will:

  1. Request a prediction from a Chainlink node (via an oracle contract).
  2. Receive the result in fulfillOracleRequest.
  3. Store the price in a public variable lastPrediction.

contracts/CryptoAIOracle.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.sol";
import "@chainlink/contracts/src/v0.8/ChainlinkClient.sol";

/**
 * @title CryptoAIOracle
 * @notice Demonstrates a Chainlink request that calls an external
 *         adapter which in turn queries a TensorFlow model.
 */
contract CryptoAIOracle is ChainlinkClient {
    using Chainlink for Chainlink.Request;

    // --- State ----------------------------------------------------
    uint256 public lastPrediction;
    address public oracle;
    bytes32 public jobId;
    uint256 public fee; // in LINK (scaled by 10**18)

    // --- Events ---------------------------------------------------
    event PredictionRequested(bytes32 indexed requestId);
    event PredictionFulfilled(bytes32 indexed requestId, uint256 price);

    // --- Constructor -----------------------------------------------
    constructor(
        address _link,
        address _oracle,
        bytes32 _jobId,
        uint256 _fee
    ) {
        setChainlinkToken(_link);
        oracle = _oracle;
        jobId = _jobId;
        fee = _fee; // e.g., 0.1 * 10**18 LINK
    }

    /**
     * @notice Initiates a request to the external adapter.
     * @param recentCloses An array of 7 ETH close prices (uint256 with 8 decimals).
     *                     The front‑end should convert e.g., 1850.1234 → 185012340000.
     */
    function requestPrediction(uint256[7] calldata recentCloses) external returns (bytes32) {
        Chainlink.Request memory req = buildChainlinkRequest(jobId, address(this), this.fulfill.selector);

        // Convert the uint256 array to a JSON array of numbers.
        // Chainlink's `addUintArray` does the conversion automatically.
        req.addUintArray("recent_closes", recentCloses);

        // Send the request
        bytes32 requestId = sendChainlinkRequestTo(oracle, req, fee);
        emit PredictionRequested(requestId);
        return requestId;
    }

    /**
     * @notice Called by the oracle when the data is ready.
     * @param _requestId The request identifier.
     * @param _price The predicted price, scaled by 1e8 (same as input).
     */
    function fulfill(bytes32 _requestId, uint256 _price) public recordChainlinkFulfillment(_requestId) {
        lastPrediction = _price;
        emit PredictionFulfilled(_requestId, _price);
    }

    // ----------------------------------------------------------------
    // Helper: withdraw LINK from the contract (owner only in production)
    function withdrawLink(address to, uint256 amount) external {
        require(LinkTokenInterface(chainlinkTokenAddress()).transfer(to, amount), "LINK transfer failed");
    }
}
Enter fullscreen mode Exit fullscreen mode

5️⃣ Deploy the contract with Hardhat

5.1 Add environment variables

Create a .env in the project root:


dotenv
# .env
SEPOLIA_RPC_URL=https://sepolia.infura.io/v

#coding #tutorial #web3 #AI
Enter fullscreen mode Exit fullscreen mode

Top comments (0)