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)""")
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()
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))
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')
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"])
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.
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;
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/keysgives a free key with 25 calls a day. Put it inAFA_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)