DEV Community

Agent Risk Desk
Agent Risk Desk

Posted on

Logging every pre-trade check so you can audit an agent's trades later

An agent places an order on Tuesday. On Friday you want to know why it bought 12 shares and not 40, or why a different order never went out. If all you kept is the broker's order list, you can't answer that. The broker only knows what was sent.

So I store the result of every pre-trade check in a table, next to the order. Approved orders and rejected ones both get a row. This post has the code.

The table

SQLite is enough for one agent. The same columns work in Postgres.

import json, os, sqlite3, time, requests

AFA = "https://agent-financial-advisor.agent-financial-advisor.workers.dev/v1/tools/pre_trade_check"

db = sqlite3.connect("audit.db")
db.execute("""create table if not exists order_audit (
  id integer primary key, ts text, symbol text, side text, qty real,
  decision text, failed text, request text, response text,
  broker_order_id text)""")
Enter fullscreen mode Exit fullscreen mode

request and response hold the full JSON. decision and failed are copies of two fields so you can filter without parsing JSON.

Check, then log

def check_and_log(body):
    key = os.environ.get("AFA_API_KEY")
    headers = {"authorization": f"Bearer {key}"} if key else {}
    r = requests.post(AFA, json=body, headers=headers, timeout=10)
    r.raise_for_status()
    result = r.json()
    o = body["order"]
    failed = [c["id"] for c in result["checks"] if c["status"] == "fail"]
    cur = db.execute(
        "insert into order_audit (ts, symbol, side, qty, decision, failed, request, response) "
        "values (?,?,?,?,?,?,?,?)",
        (time.strftime("%Y-%m-%dT%H:%M:%S%z"), o["symbol"], o["side"], o["quantity"],
         result["decision"], ",".join(failed), json.dumps(body), json.dumps(result)))
    db.commit()
    return result, cur.lastrowid

def record_order_id(row_id, broker_order_id):
    db.execute("update order_audit set broker_order_id=? where id=?", (broker_order_id, row_id))
    db.commit()
Enter fullscreen mode Exit fullscreen mode

The row is written before anything goes to the broker. If the submit call crashes, you still have the check. After a successful submit, record_order_id fills in the broker's id so the two sides join.

The request body is worth keeping. It has the equity, cash and positions the check saw at that moment. You can't rebuild that from the account later.

Using it

result, row_id = check_and_log(body)
if result["decision"] != "reject":
    order = trading.submit_order(MarketOrderRequest(symbol="NVDA", qty=12, side=OrderSide.BUY,
                                                    time_in_force=TimeInForce.DAY))
    record_order_id(row_id, str(order.id))
Enter fullscreen mode Exit fullscreen mode

body is built the same way as in my earlier post on the Alpaca check: account, positions, the order, and a last price.

Reading it back

I ran two orders through this on a test cash account: $12,000 equity, $3,000 cash, 30 shares of AAPL. The first was 40 NVDA at $185 with a stop at $170. The second was 12 shares. The table afterward:

(1, 'NVDA', 40.0, 'reject',  'buying_power,concentration,risk_per_trade', None)
(2, 'NVDA', 12.0, 'approve', '', 'fake-order-id-123')
Enter fullscreen mode Exit fullscreen mode

The order id there is a placeholder, since I didn't send anything to a broker. Now the Friday question, why didn't order 1 go out:

row = db.execute("select response from order_audit where id = 1").fetchone()
for c in json.loads(row[0])["checks"]:
    if c["status"] != "pass":
        print(c["id"], c["message"])
Enter fullscreen mode Exit fullscreen mode
buying_power Cost $7400 exceeds cash $3000.
concentration NVDA would be 61.7% of equity (limit 20%). Max additional quantity: 12.
risk_per_trade Risk to stop $600 = 5% of equity (limit 2%). Quantity for limit: 16.
Enter fullscreen mode Exit fullscreen mode

That is the whole answer. Three rules failed, each with the number it used. Nobody has to guess what the model was thinking.

The response also carries rules_version, which was 2026-10-18 on this run. I'd keep it with the row. If a rule changes later, you can tell which version judged an old order.

Queries I actually use

-- how often does each check fail
select failed, count(*) from order_audit where decision = 'reject' group by failed;

-- orders that were approved but have no broker id (the submit step failed)
select id, ts, symbol from order_audit where decision != 'reject' and broker_order_id is null;
Enter fullscreen mode Exit fullscreen mode

The second one catches a bug in your own code, which is a good reason to have the table. If the same check fails over and over, the agent is sizing orders wrong and the prompt needs work.

Limits

  • The first 5 calls a day need no signup. POST /v1/keys gives a free key with 25 calls a day. Put it in AFA_API_KEY.
  • The table is only as good as the inputs. If your agent passes a stale price, the log records the stale price. That is still useful, because you can see it.
  • Don't put API keys or account numbers in request. The body I send has none, but check yours before you add fields.
  • It's rule-based analysis for software, not investment advice.

The product is the Agent Financial Advisor API. There is also an MCP server at /mcp on the same host, and the tool is pre_trade_check.

Disclosure: I built this. If you log something else per order that I should add to the response, tell me in the comments. I realy would like to know what people keep.

Top comments (0)