How to Build an Optionomics Trade Ideas Webhook, MCP, LLM, and Paper-Trading Bot
Receive AI trade ideas by webhook, enrich them with Optionomics MCP, ask an LLM for a structured decision, and route approved orders to a broker paper account
Introduction
This tutorial builds a complete reference bot that receives new Optionomics Trade Ideas through a webhook, enriches the idea with live Optionomics market context through the Optionomics MCP server, asks an LLM for a structured execution decision, applies deterministic risk gates, and then sends an order to an Alpaca paper-trading account.
The important word is reference.
The bot is designed to show a correct, working integration pattern. It is not a promise that any automated trading strategy will be profitable. It is not personalized investment advice. It does not turn Optionomics Trade Ideas into instructions to trade. It treats an Optionomics Trade Idea as one input into a controlled, auditable workflow.
The default implementation is intentionally conservative:
- It uses paper trading by default.
- It receives only
trade_ideawebhook payloads. - It uses Optionomics MCP for additional context instead of relying only on the webhook.
- It asks the model for a structured decision instead of free-form text.
- It applies deterministic gates after the LLM response.
- It places equity market orders on the underlying symbol, not options orders.
- It skips neutral ideas.
- It skips bearish ideas unless you explicitly enable short selling.
- It stores every received webhook and decision in SQLite for auditability.
- It includes a
DRY_RUN=truemode so you can verify the full pipeline before sending broker orders.
By the end, you will be able to run:
uv run fastapi dev app/main.py
Then point an Optionomics Trade Ideas alert webhook at:
https://your-bot-domain.example/webhooks/optionomics/trade-ideas?secret=your-long-random-secret
When a matching Trade Idea fires, the bot will:
- Validate the webhook secret.
- Validate and normalize the JSON payload.
- Deduplicate the webhook event.
- Ask an LLM to inspect the idea using Optionomics MCP.
- Require a strict JSON decision from the model.
- Apply deterministic risk gates.
- Log the decision.
- Submit a paper order only if every gate passes and
DRY_RUN=false.
This tutorial uses the current integration standards that matter for this workflow:
- Python 3.12+ for modern typing and runtime behavior.
- uv for project and dependency management.
- FastAPI with
fastapi[standard]for the webhook server. - Pydantic v2 and pydantic-settings for request validation and environment config.
- OpenAI Responses API for LLM orchestration.
- OpenAI remote MCP tools for connecting the model to Optionomics MCP.
- Optionomics MCP Streamable HTTP endpoint at
https://optionomics.ai/mcp. - Structured Outputs with Pydantic models so the model returns a typed decision.
- alpaca-py, the current Alpaca Python SDK, for broker paper orders.
- SQLite from the Python standard library for a simple local ledger.
What This Bot Does and Does Not Do
Before writing code, be clear about the job of each system.
Optionomics Trade Ideas provide a machine-generated thesis on a symbol. A Trade Idea may include direction, strategy label, entry reference, target, stop, model name, and related criteria. The idea is a research starting point, not an order ticket.
The webhook is the trigger. It tells your bot that a new idea matching your alert criteria exists.
The MCP server is the market-data context layer. It lets the LLM retrieve Optionomics context such as quotes, options flow, unusual activity, gamma exposure, IV context, news, earnings, events, and market overview.
The LLM is the research summarizer and classifier. It reads the webhook, can call Optionomics MCP tools, and returns a structured decision.
The deterministic risk gates are the final authority. They decide whether the model’s output is allowed to become an order.
The broker client is only the order router. In this tutorial, it submits a paper equity market order through Alpaca.
This bot does not:
- Guarantee profitable trades.
- Trade options contracts automatically.
- Convert
buy_call,spread, or other strategy labels into options orders. - Override your broker rules, PDT status, buying power, short-selling restrictions, margin requirements, or options approval.
- Replace your own testing, risk policy, compliance process, or supervision.
Why not options orders in the starter bot? Because options execution requires broker-specific contract selection, expiration selection, strike selection, bid/ask checks, liquidity filters, spread handling, options approval, multi-leg routing, exercise/assignment risk controls, and position lifecycle management. A public tutorial should not pretend that a strategy label can be safely converted into a live options order. The working implementation below starts with the underlying equity in a paper account so the integration is testable end to end.
Prerequisites
You need five things.
- An Optionomics account with Trade Ideas access.
- Optionomics alert delivery configured for Trade Ideas webhooks.
- An Optionomics Vega subscription and API token for MCP access.
- An OpenAI API key for the Responses API.
- An Alpaca paper-trading account if you want the broker-order step.
Trade Ideas are available on Theta and Vega plans. MCP and REST API keys require Vega. If you only want to test webhook receipt and logging, keep DRY_RUN=true; if you want MCP enrichment, you need MCP credentials.
The Trade Idea Webhook Payload
When a Trade Ideas alert delivers to a generic webhook, the bot should expect a JSON payload like this:
{
"alert_name": "High confidence bullish ideas",
"source": "trade_idea",
"symbol": "NVDA",
"direction": "bullish",
"strategy": "buy_call",
"model": "swing",
"entry_price": 142.5,
"target_price": 148.0,
"stop_price": 139.0,
"triggered_at": "2026-06-02T14:22:00Z",
"matched_criteria": {
"direction": "bullish",
"confidence_min": 0.75
}
}
The bot below validates these fields:
| Field | Purpose |
|---|---|
alert_name |
The alert rule name you configured in Optionomics |
source |
Must be trade_idea for this bot |
symbol |
Underlying ticker symbol |
direction |
bullish, bearish, or neutral |
strategy |
Strategy label from the Trade Idea |
model |
Trade Idea model or pipeline label, when present |
entry_price |
Reference entry level, when present |
target_price |
Reference target level, when present |
stop_price |
Reference stop level, when present |
triggered_at |
ISO 8601 timestamp of webhook delivery |
matched_criteria |
Criteria from your alert rule that matched |
The webhook payload is deliberately compact. The bot uses MCP to ask for additional market context instead of expecting the webhook to carry every possible data point.
The Architecture
Here is the full flow:
Optionomics Trade Idea created
|
v
Optionomics Alert rule matches
|
v
Generic webhook delivery
|
v
FastAPI /webhooks/optionomics/trade-ideas
|
v
Validate secret, payload, and duplicate fingerprint
|
v
OpenAI Responses API request
|
v
Model can call Optionomics MCP tools
|
v
Structured TradingDecision returned
|
v
Deterministic risk gates
|
v
SQLite ledger
|
v
Alpaca paper market order when approved
The webhook endpoint returns 200 quickly and runs processing in a FastAPI background task. Optionomics generic webhook deliveries should receive a successful HTTP response quickly; the public docs specify a 10 second timeout. If you deploy this for serious use, replace the in-process background task with a durable queue such as Redis Queue, Dramatiq, Celery, or your cloud provider’s queue. The in-process version is excellent for a tutorial and local testing, but a process restart can interrupt pending work.
Why Use MCP Instead of Only the Webhook?
The webhook tells you what fired. MCP helps the LLM investigate what else is happening now.
For example, a bullish buy_call idea on NVDA may be much more interesting if current Optionomics context also shows supportive flow, constructive levels, relevant news, and no obvious event-risk conflict. It may be less interesting if flow is mixed, the market is reversing, the symbol is near a major gamma level, or recent news changes the setup.
Optionomics MCP exposes market intelligence tools such as:
| Tool category | What the model can inspect |
|---|---|
| Stock quote | Current or historical price, OHLC, volume, and change |
| Options chain | Strikes, expirations, prices, open interest, and Greeks |
| Option metrics | Put/call ratio, IV rank, IV percentile, GEX, DEX, max pain, walls |
| Options flow | Bullish and bearish flow context |
| Net flow | Cumulative call and put premium over time |
| Unusual activity | High-premium and high-volume alerts |
| Dark pool levels | Large-print support and resistance context |
| Support and resistance | Key levels derived from options activity |
| Market overview | SPY, QQQ, DIA, IWM, and broad-market context |
| Gamma exposure | GEX strike levels and gamma regime context |
| Trend analysis | Price trend, momentum, volatility, and levels |
| IV term structure | IV30, IV60, IV90, IV rank, and realized-vs-implied context |
| Price history | Daily OHLCV, returns, drawdown, and realized volatility |
| Earnings, news, events | Company and macro catalysts |
The model does not need custom Python functions for each tool. It receives the Optionomics MCP server as a remote tool source and decides which tools to call.
Create the Python Project
The complete example app is checked in at:
docs/examples/optionomics-webhook-trading-bot/
You can run that app directly, or recreate it from scratch with the steps below.
Install uv if you do not already have it:
curl -LsSf https://astral.sh/uv/install.sh | sh
Create the project:
uv init --python 3.12 optionomics-webhook-trading-bot
cd optionomics-webhook-trading-bot
Add dependencies:
uv add "fastapi[standard]" openai pydantic-settings alpaca-py python-dotenv
uv add --dev ruff pytest
Create the app package:
mkdir -p app
touch app/__init__.py
Create .gitignore:
.env
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
bot.sqlite3
Do not commit .env, broker keys, Optionomics tokens, OpenAI keys, webhook secrets, or SQLite ledgers containing trading history.
Configure Environment Variables
Create .env.example:
OPENAI_API_KEY=sk-your-openai-key
OPENAI_MODEL=gpt-5.5
OPTIONOMICS_EMAIL=[email protected]
OPTIONOMICS_TOKEN=your-optionomics-api-token
OPTIONOMICS_MCP_URL=https://optionomics.ai/mcp
WEBHOOK_SECRET=replace-with-a-long-random-secret
DRY_RUN=true
MIN_LLM_CONFIDENCE=0.72
MAX_NOTIONAL_USD=250
ALLOW_SHORT_SELLING=false
ALPACA_API_KEY=your-alpaca-paper-key
ALPACA_SECRET_KEY=your-alpaca-paper-secret
ALPACA_PAPER=true
DATABASE_PATH=bot.sqlite3
Copy it:
cp .env.example .env
Edit .env with real values.
Generate a strong webhook secret. For example:
python -c "import secrets; print(secrets.token_urlsafe(32))"
Keep DRY_RUN=true until you have verified webhook delivery, MCP access, LLM decisions, ledger writes, and broker credentials.
Leave ALPACA_PAPER=true. The tutorial code below intentionally refuses non-paper Alpaca mode.
Verify Optionomics MCP Access
Before wiring a bot, verify the Optionomics MCP server accepts your credentials.
Run this with your Optionomics email and token:
curl -s -w "\n%{http_code}\n" \
-X POST \
-H "Content-Type: application/json" \
-H "X-USER-EMAIL: $OPTIONOMICS_EMAIL" \
-H "X-USER-TOKEN: $OPTIONOMICS_TOKEN" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"trade-bot-test","version":"1.0"}}}' \
https://optionomics.ai/mcp
Expected outcomes:
| Status | Meaning |
|---|---|
200 |
Credentials and MCP access are working |
401 |
Email or token is invalid |
403 |
Credentials are valid, but the account does not have MCP access |
MCP access requires Vega. The bot can receive webhooks without MCP, but this tutorial’s LLM enrichment step requires MCP.
Complete FastAPI Bot Code
Create app/main.py with the complete code below.
from __future__ import annotations
import base64
import hashlib
import json
import logging
import re
import secrets
import sqlite3
from contextlib import closing
from datetime import UTC, datetime
from functools import lru_cache
from pathlib import Path
from typing import Annotated, Any, Literal
from alpaca.trading.client import TradingClient
from alpaca.trading.enums import OrderSide, TimeInForce
from alpaca.trading.requests import MarketOrderRequest
from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Query
from openai import OpenAI, OpenAIError
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("optionomics_trade_bot")
LLM_INSTRUCTIONS = """
You are a cautious trading research assistant inside a paper-trading bot.
You will receive one Optionomics Trade Idea webhook. You may use Optionomics
MCP tools to inspect current and historical market context for the symbol.
Your job is not to guarantee outcomes. Your job is to decide whether the
webhook deserves a small paper equity order on the underlying symbol.
Rules:
- Treat the webhook as a research trigger, not as an order instruction.
- Use Optionomics MCP tools when market context would improve the decision.
- Separate observed data from interpretation.
- Prefer "skip" when evidence is mixed, stale, missing, or unclear.
- Prefer "skip" when the idea direction is neutral.
- Return "buy" only for bullish ideas with supportive context.
- Return "sell_short" only for bearish ideas with supportive context.
- Never return an options order. This bot trades only the underlying equity.
- Never exceed the maximum notional specified by the user prompt.
- Include concise rationale and risk notes.
- Do not include hidden chain-of-thought. Return only the structured decision.
""".strip()
class Settings(BaseSettings):
"""Runtime settings loaded from environment variables."""
openai_api_key: str = Field(alias="OPENAI_API_KEY")
openai_model: str = Field(default="gpt-5.5", alias="OPENAI_MODEL")
optionomics_email: str = Field(alias="OPTIONOMICS_EMAIL")
optionomics_token: str = Field(alias="OPTIONOMICS_TOKEN")
optionomics_mcp_url: str = Field(
default="https://optionomics.ai/mcp",
alias="OPTIONOMICS_MCP_URL",
)
webhook_secret: str = Field(alias="WEBHOOK_SECRET", min_length=16)
dry_run: bool = Field(default=True, alias="DRY_RUN")
min_llm_confidence: float = Field(default=0.72, ge=0.0, le=1.0, alias="MIN_LLM_CONFIDENCE")
max_notional_usd: float = Field(default=250.0, gt=0.0, alias="MAX_NOTIONAL_USD")
allow_short_selling: bool = Field(default=False, alias="ALLOW_SHORT_SELLING")
alpaca_api_key: str | None = Field(default=None, alias="ALPACA_API_KEY")
alpaca_secret_key: str | None = Field(default=None, alias="ALPACA_SECRET_KEY")
alpaca_paper: bool = Field(default=True, alias="ALPACA_PAPER")
database_path: str = Field(default="bot.sqlite3", alias="DATABASE_PATH")
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
class TradeIdeaWebhook(BaseModel):
"""Validated Optionomics Trade Idea webhook payload."""
alert_name: str
source: Literal["trade_idea"]
symbol: str
direction: Literal["bullish", "bearish", "neutral"]
strategy: str
model: str | None = None
entry_price: float | None = None
target_price: float | None = None
stop_price: float | None = None
triggered_at: datetime
matched_criteria: dict[str, Any] = Field(default_factory=dict)
model_config = ConfigDict(extra="ignore")
@field_validator("symbol")
@classmethod
def normalize_symbol(cls, value: str) -> str:
symbol = value.strip().upper()
if not re.fullmatch(r"[A-Z][A-Z0-9.]{0,9}", symbol):
raise ValueError("symbol must be a valid ticker")
return symbol
class TradingDecision(BaseModel):
"""Structured LLM output used by deterministic risk gates."""
action: Literal["skip", "buy", "sell_short"]
symbol: str
notional_usd: float = Field(ge=0.0)
confidence: float = Field(ge=0.0, le=1.0)
rationale: str
risk_notes: list[str] = Field(default_factory=list)
@field_validator("symbol")
@classmethod
def normalize_symbol(cls, value: str) -> str:
return value.strip().upper()
def utc_now_iso() -> str:
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
class Ledger:
"""Small SQLite ledger for received webhooks, decisions, and orders."""
def __init__(self, database_path: str) -> None:
self.path = Path(database_path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self.initialize()
def initialize(self) -> None:
with closing(sqlite3.connect(self.path, timeout=10)) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS webhook_events (
fingerprint TEXT PRIMARY KEY,
status TEXT NOT NULL,
payload_json TEXT NOT NULL,
decision_json TEXT,
order_json TEXT,
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.commit()
def reserve(self, fingerprint: str, payload: TradeIdeaWebhook) -> bool:
now = utc_now_iso()
payload_json = json.dumps(payload.model_dump(mode="json"), sort_keys=True)
with closing(sqlite3.connect(self.path, timeout=10)) as conn:
cursor = conn.execute(
"""
INSERT OR IGNORE INTO webhook_events
(fingerprint, status, payload_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
""",
(fingerprint, "queued", payload_json, now, now),
)
conn.commit()
return cursor.rowcount == 1
def finish(
self,
fingerprint: str,
status: str,
decision: TradingDecision,
order_payload: dict[str, Any] | None = None,
) -> None:
now = utc_now_iso()
decision_json = json.dumps(decision.model_dump(mode="json"), sort_keys=True)
order_json = json.dumps(order_payload or {}, sort_keys=True)
with closing(sqlite3.connect(self.path, timeout=10)) as conn:
conn.execute(
"""
UPDATE webhook_events
SET status = ?, decision_json = ?, order_json = ?, error = NULL, updated_at = ?
WHERE fingerprint = ?
""",
(status, decision_json, order_json, now, fingerprint),
)
conn.commit()
def fail(self, fingerprint: str, error: str) -> None:
now = utc_now_iso()
with closing(sqlite3.connect(self.path, timeout=10)) as conn:
conn.execute(
"""
UPDATE webhook_events
SET status = ?, error = ?, updated_at = ?
WHERE fingerprint = ?
""",
("failed", error[:1000], now, fingerprint),
)
conn.commit()
app = FastAPI(title="Optionomics Trade Ideas Trading Bot", version="1.0.0")
@lru_cache
def get_settings() -> Settings:
return Settings()
@lru_cache
def get_openai_client(api_key: str) -> OpenAI:
return OpenAI(api_key=api_key)
def build_optionomics_bearer_token(settings: Settings) -> str:
raw_credentials = f"{settings.optionomics_email}:{settings.optionomics_token}"
encoded_credentials = base64.b64encode(raw_credentials.encode("utf-8")).decode("ascii")
return f"Bearer {encoded_credentials}"
def optionomics_mcp_tool(settings: Settings) -> dict[str, Any]:
return {
"type": "mcp",
"server_label": "optionomics",
"server_description": "Optionomics market intelligence, options flow, Greeks, volatility, levels, news, events, and earnings data.",
"server_url": settings.optionomics_mcp_url,
"headers": {
"Authorization": build_optionomics_bearer_token(settings),
},
"require_approval": "never",
}
def fingerprint_for(payload: TradeIdeaWebhook) -> str:
material = "|".join(
[
payload.alert_name,
payload.source,
payload.symbol,
payload.direction,
payload.strategy,
payload.triggered_at.isoformat(),
]
)
return hashlib.sha256(material.encode("utf-8")).hexdigest()
def verify_webhook_secret(settings: Settings, supplied_secret: str | None) -> None:
if not supplied_secret:
raise HTTPException(status_code=401, detail="Missing webhook secret")
if not secrets.compare_digest(supplied_secret, settings.webhook_secret):
raise HTTPException(status_code=401, detail="Invalid webhook secret")
def build_llm_input(payload: TradeIdeaWebhook, settings: Settings) -> str:
payload_json = json.dumps(payload.model_dump(mode="json"), indent=2, sort_keys=True)
return f"""
A new Optionomics Trade Idea webhook was received.
Webhook payload:
{payload_json}
Bot configuration:
- Maximum notional allowed: ${settings.max_notional_usd:.2f}
- Minimum LLM confidence required by deterministic gate: {settings.min_llm_confidence:.2f}
- Short selling enabled by deterministic gate: {settings.allow_short_selling}
- Broker order type after approval: paper equity market order on the underlying symbol
Use Optionomics MCP tools if they help evaluate current quote, options flow,
unusual activity, gamma exposure, IV context, events, news, or market backdrop.
Return a structured decision. Choose "skip" unless the webhook direction and
current context are aligned enough for a small paper equity order.
""".strip()
def ask_llm_for_decision(payload: TradeIdeaWebhook, settings: Settings) -> TradingDecision:
client = get_openai_client(settings.openai_api_key)
try:
response = client.responses.parse(
model=settings.openai_model,
tools=[optionomics_mcp_tool(settings)],
input=[
{"role": "system", "content": LLM_INSTRUCTIONS},
{"role": "user", "content": build_llm_input(payload, settings)},
],
text_format=TradingDecision,
)
except OpenAIError as exc:
raise RuntimeError("OpenAI or Optionomics MCP request failed") from exc
decision = response.output_parsed
if decision is None:
raise RuntimeError("The model did not return a structured trading decision")
return decision
def skip_decision(decision: TradingDecision, reason: str) -> TradingDecision:
notes = [*decision.risk_notes, reason]
rationale = f"{decision.rationale} Risk gate: {reason}"
return decision.model_copy(
update={
"action": "skip",
"notional_usd": 0.0,
"rationale": rationale,
"risk_notes": notes,
}
)
def apply_risk_gates(
payload: TradeIdeaWebhook,
decision: TradingDecision,
settings: Settings,
) -> TradingDecision:
if decision.symbol != payload.symbol:
return skip_decision(decision, "LLM decision symbol does not match webhook symbol")
if decision.confidence < settings.min_llm_confidence:
return skip_decision(decision, "LLM confidence is below the configured threshold")
if decision.action == "skip":
return decision.model_copy(update={"notional_usd": 0.0})
if payload.entry_price is None or payload.target_price is None or payload.stop_price is None:
return skip_decision(decision, "Entry, target, and stop levels are required")
if decision.action == "buy":
if payload.direction != "bullish":
return skip_decision(decision, "Buy action is allowed only for bullish trade ideas")
if payload.target_price <= payload.entry_price:
return skip_decision(decision, "Bullish target must be above entry")
if payload.stop_price >= payload.entry_price:
return skip_decision(decision, "Bullish stop must be below entry")
if decision.action == "sell_short":
if not settings.allow_short_selling:
return skip_decision(decision, "Short selling is disabled")
if payload.direction != "bearish":
return skip_decision(decision, "Short action is allowed only for bearish trade ideas")
if payload.target_price >= payload.entry_price:
return skip_decision(decision, "Bearish target must be below entry")
if payload.stop_price <= payload.entry_price:
return skip_decision(decision, "Bearish stop must be above entry")
if decision.notional_usd <= 0:
return skip_decision(decision, "Order notional must be positive")
notional = min(decision.notional_usd, settings.max_notional_usd)
return decision.model_copy(update={"notional_usd": round(notional, 2)})
def submit_paper_order(
decision: TradingDecision,
settings: Settings,
fingerprint: str,
) -> dict[str, Any]:
client_order_id = f"om-{fingerprint[:24]}"
if settings.dry_run:
logger.info("DRY_RUN=true; broker order suppressed for %s", decision.symbol)
return {
"dry_run": True,
"client_order_id": client_order_id,
"symbol": decision.symbol,
"action": decision.action,
"notional_usd": decision.notional_usd,
}
if not settings.alpaca_api_key or not settings.alpaca_secret_key:
raise RuntimeError("Alpaca credentials are required when DRY_RUN=false")
if not settings.alpaca_paper:
raise RuntimeError("This tutorial bot supports paper trading only; set ALPACA_PAPER=true")
trading_client = TradingClient(
settings.alpaca_api_key,
settings.alpaca_secret_key,
paper=settings.alpaca_paper,
)
side = OrderSide.BUY if decision.action == "buy" else OrderSide.SELL
order_data = MarketOrderRequest(
symbol=decision.symbol,
notional=round(decision.notional_usd, 2),
side=side,
time_in_force=TimeInForce.DAY,
client_order_id=client_order_id,
)
order = trading_client.submit_order(order_data=order_data)
return {
"dry_run": False,
"id": str(getattr(order, "id", "")),
"client_order_id": getattr(order, "client_order_id", client_order_id),
"symbol": getattr(order, "symbol", decision.symbol),
"status": str(getattr(order, "status", "unknown")),
"side": str(getattr(order, "side", side)),
"notional_usd": decision.notional_usd,
}
def process_trade_idea(payload: TradeIdeaWebhook, fingerprint: str) -> None:
settings = get_settings()
ledger = Ledger(settings.database_path)
try:
llm_decision = ask_llm_for_decision(payload, settings)
gated_decision = apply_risk_gates(payload, llm_decision, settings)
if gated_decision.action == "skip":
ledger.finish(fingerprint, "skipped", gated_decision)
logger.info("Skipped %s: %s", payload.symbol, gated_decision.rationale)
return
order_payload = submit_paper_order(gated_decision, settings, fingerprint)
final_status = "dry_run" if settings.dry_run else "ordered"
ledger.finish(fingerprint, final_status, gated_decision, order_payload)
logger.info("Processed %s with status %s", payload.symbol, final_status)
except Exception as exc:
ledger.fail(fingerprint, str(exc))
logger.exception("Failed to process webhook %s", fingerprint)
raise
@app.get("/health")
def health() -> dict[str, Any]:
settings = get_settings()
return {
"status": "ok",
"dry_run": settings.dry_run,
"alpaca_paper": settings.alpaca_paper,
}
@app.post("/webhooks/optionomics/trade-ideas")
def receive_trade_idea(
payload: TradeIdeaWebhook,
background_tasks: BackgroundTasks,
secret: Annotated[str | None, Query()] = None,
x_bot_secret: Annotated[str | None, Header(alias="X-BOT-SECRET")] = None,
) -> dict[str, Any]:
settings = get_settings()
supplied_secret = x_bot_secret or secret
verify_webhook_secret(settings, supplied_secret)
fingerprint = fingerprint_for(payload)
ledger = Ledger(settings.database_path)
if not ledger.reserve(fingerprint, payload):
return {
"status": "duplicate",
"fingerprint": fingerprint,
"symbol": payload.symbol,
}
background_tasks.add_task(process_trade_idea, payload, fingerprint)
return {
"status": "queued",
"fingerprint": fingerprint,
"symbol": payload.symbol,
}
That is the full bot.
It includes:
- FastAPI webhook routing.
- Strict request validation.
- Secret checking through either a query parameter or
X-BOT-SECRETheader. - Optionomics MCP authentication through
Authorization: Bearer base64(email:token). - OpenAI Responses API remote MCP tool configuration.
- Pydantic Structured Outputs through
client.responses.parse. - Deterministic risk gates after the LLM response.
- SQLite deduplication and audit logging.
- Alpaca paper order submission with
alpaca-py. - Dry-run support.
Run the Bot Locally
Start the FastAPI server:
uv run fastapi dev app/main.py
Open another terminal and test health:
curl http://127.0.0.1:8000/health
Expected response:
{
"status": "ok",
"dry_run": true,
"alpaca_paper": true
}
Compile the Python code to catch syntax errors:
uv run python -m compileall app
Run Ruff:
uv run ruff check .
Test the Webhook Endpoint with curl
Use your .env value for WEBHOOK_SECRET.
curl -s \
-X POST "http://127.0.0.1:8000/webhooks/optionomics/trade-ideas?secret=$WEBHOOK_SECRET" \
-H "Content-Type: application/json" \
-d '{
"alert_name": "High confidence bullish ideas",
"source": "trade_idea",
"symbol": "NVDA",
"direction": "bullish",
"strategy": "buy_call",
"model": "swing",
"entry_price": 142.5,
"target_price": 148.0,
"stop_price": 139.0,
"triggered_at": "2026-06-02T14:22:00Z",
"matched_criteria": {
"direction": "bullish",
"confidence_min": 0.75
}
}'
Expected immediate response:
{
"status": "queued",
"fingerprint": "...",
"symbol": "NVDA"
}
The background task will then call OpenAI. The model can call Optionomics MCP. If DRY_RUN=true, the broker order is suppressed and a dry-run order payload is written to SQLite.
Send the same payload again and the bot should return:
{
"status": "duplicate",
"fingerprint": "...",
"symbol": "NVDA"
}
That duplicate behavior is important. Webhook senders can retry. Networks can duplicate messages. Broker APIs can also reject duplicate client order IDs. Your bot should never assume every HTTP request is unique.
Inspect the SQLite Ledger
The bot writes to bot.sqlite3 by default.
Inspect recent events:
sqlite3 bot.sqlite3 "SELECT fingerprint, status, created_at, updated_at FROM webhook_events ORDER BY created_at DESC LIMIT 10;"
Inspect the latest decision:
sqlite3 bot.sqlite3 "SELECT decision_json FROM webhook_events ORDER BY created_at DESC LIMIT 1;"
Inspect the latest order payload:
sqlite3 bot.sqlite3 "SELECT order_json FROM webhook_events ORDER BY created_at DESC LIMIT 1;"
You should see a status such as:
| Status | Meaning |
|---|---|
queued |
Accepted, background task not completed yet |
skipped |
LLM or deterministic gate chose not to trade |
dry_run |
Would have ordered, but DRY_RUN=true suppressed broker submission |
ordered |
Order was submitted to Alpaca |
failed |
Processing failed after webhook acceptance |
Configure the Optionomics Alert
Now wire Optionomics to your bot.
In Optionomics:
- Open Alerts Management.
- Click Create Alert.
- Set Alert source to Trade Ideas.
- Choose your criteria.
- Enable the Webhook notification channel.
- Paste your bot webhook URL.
- Save the alert.
For local development, you need a public HTTPS tunnel. For example, with ngrok:
ngrok http 8000
If ngrok gives you:
https://abc123.ngrok-free.app
Then your Optionomics webhook URL is:
https://abc123.ngrok-free.app/webhooks/optionomics/trade-ideas?secret=your-long-random-secret
Treat this full URL as a secret. Because generic webhooks are configured as a URL, the shared secret is included in the URL query string. Avoid logging full request URLs at your reverse proxy. In production, you can also place the bot behind an API gateway that checks the secret before forwarding the request.
Recommended Alert Criteria for Testing
Start narrow.
For example:
Alert source: Trade Ideas
Stock symbol: NVDA
Direction: Bullish
Strategy: Any
Minimum confidence: 0.75
Cooldown: 30 minutes
Channel: Webhook
Do not begin with all symbols, all directions, all strategies, and a low confidence threshold. A noisy alert can generate many webhook events and many LLM calls. Start strict, verify behavior, then broaden criteria slowly.
How the LLM Decision Works
The model receives three things:
- System instructions that define the bot’s role and constraints.
- The Trade Idea webhook payload.
- The Optionomics MCP tool connection.
The tool configuration is this part:
def optionomics_mcp_tool(settings: Settings) -> dict[str, Any]:
return {
"type": "mcp",
"server_label": "optionomics",
"server_description": "Optionomics market intelligence, options flow, Greeks, volatility, levels, news, events, and earnings data.",
"server_url": settings.optionomics_mcp_url,
"headers": {
"Authorization": build_optionomics_bearer_token(settings),
},
"require_approval": "never",
}
For a server-side bot that is explicitly built to use Optionomics market data, require_approval: "never" keeps the workflow simple. If you connect the model to untrusted or user-specific MCP servers, require approvals or add a review step.
The structured output is this Pydantic model:
class TradingDecision(BaseModel):
action: Literal["skip", "buy", "sell_short"]
symbol: str
notional_usd: float = Field(ge=0.0)
confidence: float = Field(ge=0.0, le=1.0)
rationale: str
risk_notes: list[str] = Field(default_factory=list)
Because the code uses client.responses.parse(..., text_format=TradingDecision), the model must return a decision that conforms to that shape. This is safer than asking the model to return free-form prose and then trying to parse it with string matching.
Why Deterministic Gates Still Matter
Never let an LLM be the only control between a webhook and a broker.
The bot applies deterministic checks after the LLM response:
| Gate | Why it exists |
|---|---|
| Symbol must match | Prevents the model from switching symbols |
| LLM confidence threshold | Requires the model to express enough confidence |
buy only for bullish ideas |
Prevents direction mismatch |
sell_short only for bearish ideas |
Prevents direction mismatch |
| Short selling disabled by default | Avoids accidental short exposure |
| Entry, target, and stop required | Requires a complete idea before trading |
| Bullish target above entry | Rejects invalid bullish levels |
| Bullish stop below entry | Rejects invalid bullish levels |
| Bearish target below entry | Rejects invalid bearish levels |
| Bearish stop above entry | Rejects invalid bearish levels |
| Positive notional required | Prevents zero-value or invalid orders |
| Notional capped | Prevents oversized model output |
These gates are not optional. They are the difference between an AI-assisted workflow and a black-box auto-clicker.
Turning on Paper Orders
Keep DRY_RUN=true until the logs and ledger look right.
When ready, confirm these values in .env:
DRY_RUN=false
ALPACA_PAPER=true
ALPACA_API_KEY=your-alpaca-paper-key
ALPACA_SECRET_KEY=your-alpaca-paper-secret
MAX_NOTIONAL_USD=250
ALLOW_SHORT_SELLING=false
Restart FastAPI after changing .env.
The tutorial bot intentionally raises an error if ALPACA_PAPER=false. Treat live trading as a separate project with its own approvals, controls, and go-live checklist.
The bot submits an Alpaca market order like this:
order_data = MarketOrderRequest(
symbol=decision.symbol,
notional=round(decision.notional_usd, 2),
side=side,
time_in_force=TimeInForce.DAY,
client_order_id=client_order_id,
)
order = trading_client.submit_order(order_data=order_data)
This uses notional, not qty, so a small paper account can test fractional equity orders. The bot uses client_order_id derived from the webhook fingerprint so duplicate webhook processing is less likely to create duplicate broker orders.
If ALLOW_SHORT_SELLING=false, bearish ideas are skipped. If you enable short selling, remember that a sell market order may open a short position or reduce an existing long position depending on your account state. Broker behavior, margin requirements, locate availability, and symbol restrictions still apply.
Why the Bot Does Not Use Bracket Orders by Default
Trade Ideas include target and stop references, and the bot validates that the levels make sense. It does not submit bracket orders by default.
That is deliberate.
Broker support for fractional bracket orders, notional bracket orders, stop behavior, extended-hours behavior, order replacement, and partial fills varies by broker and account type. The tutorial uses a plain paper market order so the core integration works consistently.
If you extend this bot, add exit management in a separate, tested module:
- Store target and stop levels in the ledger.
- Subscribe to broker fill updates.
- Create exit orders only after entry fill confirmation.
- Handle partial fills.
- Cancel stale exits.
- Reconcile positions against the broker, not only your database.
- Backtest and paper test the lifecycle before live trading.
Deploying the Bot
For a real deployment, run the bot behind HTTPS. Optionomics should call a public HTTPS endpoint, not a local development server.
Common deployment options:
| Option | Use when |
|---|---|
| Fly.io, Render, Railway | You want a simple Python web service deploy |
| AWS ECS, Fargate, App Runner | You want AWS-native deployment |
| Google Cloud Run | You want scale-to-zero containers |
| Azure Container Apps | You want Azure-native deployment |
| A small VPS | You want direct control and simple ops |
Use a production command such as:
uv run fastapi run app/main.py
Production requirements:
- Use HTTPS.
- Set environment variables through your host’s secret manager.
- Keep
ALPACA_PAPER=trueuntil you have a documented go-live checklist. - Put a durable queue between the webhook and processing step.
- Use a real database if you run multiple replicas.
- Add monitoring for failed events.
- Redact webhook secrets, API keys, bearer tokens, and broker credentials from logs.
- Restrict inbound traffic when possible.
- Add alerting for unexpected order volume.
- Add a kill switch that immediately disables order submission.
Production Hardening Checklist
Before even considering live orders, add these controls.
| Control | Why it matters |
|---|---|
| Durable queue | In-process background tasks can be lost on restart |
| Persistent database | SQLite is fine locally; use Postgres or managed DB for multi-instance deploys |
| Replay protection | Deduplicate webhooks and broker client order IDs |
| Rate limiting | Slow brute-force attempts against the webhook secret |
| Request size limits | Reject unexpectedly large payloads at the edge |
| API gateway or IP allowlist | Add another control before the FastAPI app |
| Daily order cap | Prevents runaway behavior |
| Daily notional cap | Limits capital at risk |
| Per-symbol cap | Prevents concentration |
| Position reconciliation | Broker state is source of truth |
| Market-hours gate | Avoids unintended after-hours behavior |
| Kill switch | Lets you disable trading immediately |
| Alerting | Failed webhook, failed MCP, failed broker, unexpected order volume |
| Human review mode | Require approval before order submission while testing |
| Full audit log | Store webhook, model decision, gates, order, and final broker state |
| Model version logging | Record OPENAI_MODEL used for each decision |
| Prompt version logging | Record strategy/risk prompt version |
| Secret rotation | Rotate webhook, Optionomics, OpenAI, and broker credentials |
Troubleshooting
The webhook returns 401
Your secret query parameter or X-BOT-SECRET header does not match WEBHOOK_SECRET.
Use:
https://your-domain.example/webhooks/optionomics/trade-ideas?secret=your-long-random-secret
The webhook returns 422
FastAPI rejected the JSON payload. Common causes:
- Missing
source. sourceis nottrade_idea.directionis notbullish,bearish, orneutral.triggered_atis not a valid ISO 8601 datetime.symbolcontains unexpected characters.
MCP returns 401
Check OPTIONOMICS_EMAIL and OPTIONOMICS_TOKEN.
Also verify your bearer token code uses:
Authorization: Bearer base64(email:token)
Base64 is not encryption. Treat the encoded value as a secret.
MCP returns 403
Your Optionomics credentials are valid, but the account does not have MCP access. MCP requires Vega.
The model skips everything
That can be correct. The prompt is conservative, and the risk gates are strict.
Check:
MIN_LLM_CONFIDENCEMAX_NOTIONAL_USD- Trade Idea levels
- Whether current market context is mixed
- Whether the idea is neutral or bearish with shorting disabled
The model approves but the gate skips
Inspect decision_json in SQLite. The risk_notes field will include the gate reason.
Common reasons:
- Symbol mismatch.
- Confidence below threshold.
- Missing entry, target, or stop.
- Invalid target/stop geometry.
- Short selling disabled.
- Notional was zero.
Alpaca rejects the order
Check:
- Paper keys, not live keys.
ALPACA_PAPER=true.- Buying power.
- Symbol tradability.
- Market status.
- Fractional trading support.
- Account restrictions.
- Duplicate
client_order_id.
Optionomics webhook times out
The endpoint should return 200 quickly. If it does not, move slow work into a durable queue and return after enqueueing. The tutorial uses FastAPI BackgroundTasks; production systems should use a durable queue.
Extending the Bot to Options Orders
Do not start here. Get the paper equity workflow stable first.
If you later want options execution, add a separate contract-selection and execution layer. At minimum, that layer needs to handle:
- Broker options approval.
- Option contract discovery.
- Expiration selection.
- Strike selection.
- Contract type selection.
- Liquidity filters.
- Bid/ask spread limits.
- Open interest and volume filters.
- Limit order pricing.
- Multi-leg order construction for spreads.
- Fill monitoring.
- Partial fills.
- Exit orders.
- Exercise and assignment risk.
- Corporate actions.
- Expiration handling.
- Broker-specific symbol formats.
MCP can help the model reason about market context, but deterministic code should own contract selection, order validation, sizing, and broker routing.
Final Thoughts
The clean pattern is:
Webhook triggers the workflow.
MCP provides market context.
LLM returns a structured research decision.
Deterministic gates enforce risk policy.
Broker API executes only what survives the gates.
Ledger records everything.
That pattern keeps the exciting part of AI in the right place. The model can summarize complex options context and explain why a setup is or is not worth acting on. Your code still owns validation, sizing, deduplication, limits, execution, logging, and safety.
Start with DRY_RUN=true. Use paper trading. Read every ledger entry. Tighten the gates. Only then decide whether a more advanced execution layer belongs in your own workflow.
Nothing in this tutorial is financial advice. Automated trading can lose money quickly. Treat every bot as untrusted until it has survived extensive backtesting, paper trading, monitoring, and human review.
Master options trading with Optionomics
Get real-time options flow, AI-powered insights, and advanced analytics.
Get started