Write a pre-trade hook for an AI trading agent

Build a small Python service that checks every AI agent order before it reaches the broker: verify the HMAC signature, apply size, symbol, hours and daily loss rules, and answer.

An order card enters a shield marked with a key and leaves by one of three paths: allow, deny or hold.

A pre-trade hook is a small web service you run yourself. Before an AI agent’s order reaches the broker, the platform sends the order to your hook and waits for an answer: allow it, deny it, or hold it for a person to approve. The agent cannot skip the hook, cannot argue with it, and cannot see its code.

That makes the hook the right home for hard risk rules. A line in a prompt such as “never trade more than half a lot” is a request to a model. The same rule in a hook is a check that runs on every order, whatever the model decided.

This post builds a working hook in Python, under 100 lines, with four example rules: a maximum volume, a symbol list, trading hours in UTC and a daily loss limit. It also covers the parts people get wrong: signatures, timeouts and what happens when the hook is down. The field and header names come from Tickwire’s hook format, but the design applies to any service of this kind.

How the round trip works

For each agent order, Tickwire sends an HTTPS POST to your hook’s URL. The body is JSON and the request carries four headers:

HeaderValue
X-Tickwire-Eventpretrade.decision_request
X-Tickwire-Hook-IdYour hook’s id, hk_...
X-Tickwire-TimestampUnix time in seconds
X-Tickwire-Signaturesha256= followed by a hex HMAC-SHA256

The main fields of the body:

  • request_id (dr_...): the id of this decision request. Your answer must echo it.
  • deadline_ms: how long Tickwire will wait for you. The default is 2,000 ms.
  • account_id and platform (mt4 or mt5).
  • actor: who asked, with kind, id and label, for example agent:eurusd-scout.
  • reason: the agent’s own sentence about why it wants this trade.
  • order: the order exactly as the agent sent it, with actionType, symbol, volume (in lots), openPrice, stopLoss, takeProfit, positionId and so on.
  • position: the position the order acts on, when it acts on one, else null.
  • account: a fresh read from the terminal just before the call, with balance, equity, margin, free_margin, currency, leverage, open_positions, source and taken_at. If the terminal could not answer in time, account is null and account_error says why. It is never guessed.
  • idempotency_key and hold_id_if_held, which you need only if you use holds.

Your hook answers with HTTP 200 and a JSON body:

{ "request_id": "dr_...", "decision": "deny", "message": "daily loss limit reached" }

decision is allow, deny or hold. The message (cut at 500 characters) is shown to the agent and to you. The answer must be signed the same way as the request, with its own X-Tickwire-Timestamp and X-Tickwire-Signature headers. Echoing request_id ties your answer to this one order, so a recorded allow can’t be replayed for a different one.

The signature, exactly

The signature is HMAC-SHA256 (a keyed hash: only someone holding the secret can produce it) over three things joined together: the timestamp as text, a dot, and the raw body bytes.

signature = "sha256=" + hex( HMAC_SHA256( secret, "<timestamp>.<raw body>" ) )

Three rules keep this correct:

  1. Sign and verify the raw bytes. Don’t parse the JSON and dump it again before checking. Key order and spacing change, and the signature will no longer match.
  2. Compare in constant time. Use hmac.compare_digest, not ==. A plain comparison can leak, through timing, how many characters matched.
  3. Reject stale timestamps. Tickwire refuses answers whose timestamp is more than 5 minutes off its clock. Apply the same window to requests, so a captured request can’t be replayed later. Keep your server’s clock synced.

The secret starts with twhs_ and is shown once, when you register the hook. Keep it in an environment variable or a secret store, never in source code.

The hook in Python

This uses FastAPI. Any framework works if you can read the raw body and set response headers.

import hashlib, hmac, json, os, time
from datetime import datetime, timezone
from fastapi import FastAPI, Request, Response

SECRET = os.environ["HOOK_SECRET"].encode()  # twhs_..., shown once at registration
MAX_SKEW_S = 300

# Example rules. Yours will differ.
MAX_LOTS = 0.50
SYMBOLS = {"EURUSD", "GBPUSD", "USDJPY"}
OPEN_HOUR_UTC, CLOSE_HOUR_UTC = 7, 20   # new exposure 07:00 to 19:59 UTC only
MAX_DAILY_LOSS = 0.03                   # 3% below the day's first equity reading

OPENING = {
    "ORDER_TYPE_BUY", "ORDER_TYPE_SELL",
    "ORDER_TYPE_BUY_LIMIT", "ORDER_TYPE_SELL_LIMIT",
    "ORDER_TYPE_BUY_STOP", "ORDER_TYPE_SELL_STOP",
    "ORDER_TYPE_BUY_STOP_LIMIT", "ORDER_TYPE_SELL_STOP_LIMIT",
}

app = FastAPI()
day_start_equity: dict[tuple[str, str], float] = {}  # (account_id, UTC date) -> equity


def sign(ts: str, raw: bytes) -> str:
    mac = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256)
    return "sha256=" + mac.hexdigest()


def verified(headers, raw: bytes) -> bool:
    ts = headers.get("X-Tickwire-Timestamp", "")
    sig = headers.get("X-Tickwire-Signature", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > MAX_SKEW_S:
        return False
    return hmac.compare_digest(sign(ts, raw), sig)


def answer(request_id: str, decision: str, message: str) -> Response:
    body = json.dumps({"request_id": request_id, "decision": decision,
                       "message": message}).encode()
    ts = str(int(time.time()))
    return Response(content=body, media_type="application/json",
                    headers={"X-Tickwire-Timestamp": ts,
                             "X-Tickwire-Signature": sign(ts, body)})


def decide(req: dict) -> tuple[str, str]:
    order = req.get("order") or {}
    action = order.get("actionType", "")

    # Closing, cancelling and modifying reduce or reshape risk: let them through.
    if action not in OPENING:
        return "allow", "not a new position"

    symbol = order.get("symbol", "")
    if symbol not in SYMBOLS:
        return "deny", f"{symbol} is not on this agent's symbol list"

    volume = float(order.get("volume") or 0)
    if volume <= 0 or volume > MAX_LOTS:
        return "deny", f"volume {volume} lots is outside 0 to {MAX_LOTS} lots"

    now = datetime.now(timezone.utc)
    if not OPEN_HOUR_UTC <= now.hour < CLOSE_HOUR_UTC:
        return "deny", f"no new positions outside {OPEN_HOUR_UTC}:00 to {CLOSE_HOUR_UTC}:00 UTC"

    account = req.get("account")
    if account is None or account.get("equity") is None:
        return "deny", "no fresh account read, so the loss limit can't be checked"
    equity = float(account["equity"])
    start = day_start_equity.setdefault((req["account_id"], now.date().isoformat()), equity)
    if equity <= start * (1 - MAX_DAILY_LOSS):
        return "deny", f"daily loss limit reached: equity {equity:.2f} vs {start:.2f} at day start"

    return "allow", "within limits"


@app.post("/decide")
async def decide_endpoint(request: Request) -> Response:
    raw = await request.body()
    if not verified(request.headers, raw):
        return Response(status_code=401)  # treated as no valid answer: the order is not sent
    req = json.loads(raw)
    if req.get("type") != "pretrade.decision_request":
        return Response(status_code=400)
    decision, message = decide(req)
    return answer(req["request_id"], decision, message)

Run it with uvicorn hook:app behind HTTPS. The hook URL must use https.

Notes on the rules

Let reductions through. Closing a position, cancelling a pending order or tightening a stop lowers risk. A hook that denies those can trap you in a bad position, so the example allows every action that is not a new order. If you want to stop an agent from widening stops, add a rule for POSITION_MODIFY that compares the new stopLoss with the one in position.

Symbol names vary by broker. Many brokers add a suffix, so EURUSD may be EURUSD.r or EURUSDm on your account. Put the exact names in the list.

Hours are in UTC, on your clock. The example uses the hook server’s own UTC time, not broker server time. Broker time is often a different zone and changes with daylight saving. If your rules follow the broker’s trading day, convert deliberately. See broker server time is not UTC.

The daily loss rule needs a starting point. The request carries equity now, not equity at the start of the day. The example stores the first equity it sees each UTC day, which has two weak spots: it is lost on restart, and if the first order of the day comes after a loss, the baseline is already low. In a real hook, record equity at your day boundary from a scheduled job and keep it in a database.

No account read means no new exposure. When account is null, the example denies new positions instead of guessing. Never treat a missing read as zero loss.

Timeouts, holds and failing closed

Your hook has a time budget, timeout_ms, which defaults to 2,000 ms and can be set from 100 to 10,000. Tickwire treats anything other than a valid, signed, on-time answer as no answer: a timeout, a connection error, a status other than 200, a bad or missing signature, a stale timestamp, a request_id that doesn’t match, or a decision it doesn’t recognise. By default the hook fails closed: the order is not sent and the agent gets hook_unavailable.

There is a fail_mode: "open" setting that sends orders when the hook has an outage (a timeout, a failed connection or a 5xx). Even then, a bad signature or a malformed answer still fails closed. Think hard before choosing it. A guard that steps aside whenever it is down protects you only on good days.

Keep the hook fast. Do not call slow services inside it. Read anything you need, such as the day’s starting equity, from memory or a local store.

To ask a person first, answer hold. Nothing is sent. The hold gets the id from hold_id_if_held, and your service or a person approves or rejects it later with a signed call to POST /v1/pretrade-holds/{hold_id}/release. An approved hold is sent once, with the original idempotency key. You can set how long a hold lives with hold_ttl_seconds in your answer. A hold that expires is never sent.

Register and test it

Register the hook in the dashboard under AI agents > Pre-trade hooks, or with the API using a full API key:

curl -s -X POST https://api.tickwire.io/v1/pretrade-hooks \
  -H "Authorization: Bearer $TICKWIRE_API_KEY" \
  -H "content-type: application/json" \
  -H "Idempotency-Key: guard-<ACCOUNT_ID>" -H "X-Reason: add risk guard" \
  -d '{"url": "https://guard.example.com/decide", "account_id": "<ACCOUNT_ID>",
       "applies_to": "agents", "fail_mode": "closed", "timeout_ms": 2000}'

Set exactly one of account_id or agent_key_id. There is one hook per account and one per agent key, and when both exist both are asked, the account’s first. applies_to: "all" also sends your own API-key trades on that account through the hook. The answer contains secret once; store it as HOOK_SECRET.

Before you trust it, test it. FastAPI’s test client lets you sign a request locally:

from fastapi.testclient import TestClient
from hook import app, sign
import json, time

client = TestClient(app)
req = {"type": "pretrade.decision_request", "request_id": "dr_test", "account_id": "acc_test",
       "order": {"actionType": "ORDER_TYPE_BUY", "symbol": "EURUSD", "volume": 2.0},
       "account": {"equity": 10000.0}}
raw = json.dumps(req).encode()
ts = str(int(time.time()))
r = client.post("/decide", content=raw,
                headers={"X-Tickwire-Timestamp": ts, "X-Tickwire-Signature": sign(ts, raw)})
assert r.json()["decision"] == "deny"   # 2.0 lots is above MAX_LOTS
  • Check that each rule denies what it should, and that a normal order is allowed.
  • Send a request with a wrong signature and confirm the hook returns 401.
  • Stop the hook and have the agent place a tiny order. It should get hook_unavailable, with nothing sent.
  • Practise on a demo account before you risk money.

Checklist

  • Signature checked on the raw body with hmac.compare_digest
  • Timestamps older than 5 minutes refused
  • Every answer signed and echoing request_id
  • Closes and cancels allowed, new exposure checked
  • Exact broker symbol names in the list
  • Hours in UTC, or converted from broker time on purpose
  • Day-start equity stored outside the process
  • Fails closed, and you tested that by stopping it
  • Answers well inside timeout_ms

Doing this with Tickwire

Tickwire calls your hook for every agent order on MT4 and MT5 accounts, after its own checks on key scope, read-only access and kill switches. Tickwire has no built-in size or loss limits and doesn’t judge whether a trade is good; the rules are yours. The AI agents page shows the request and answer side by side, risk controls for AI trading agents explains where a hook fits, and build an AI trading agent covers the agent side, including what hook_denied and held mean to it.

Trading carries risk, and you can lose money. This post explains software and how it behaves; it is not investment advice.

Try it on your own account

7-day free trial. See pricing or read the quickstart.