Compliance gate
Every rebalance plan passes through the compliance agent before any broker call. Hard / soft / warn vetos. Approve / Override / Reject buttons. Every decision lands in an immutable JSONL ledger. This is what makes LLM-driven trading safer than a vanilla 'AI trading bot.'
What it is · how it works · why it matters
A deterministic veto layer between the optimizer and the broker — every rebalance plan passes through hard/soft/warn rules before any broker call.
ComplianceBusAgent on the A2A bus. Hard vetos block submit (position cap, restricted list, wash-sale). Soft vetos require PM ack (turnover, sector concentration). Warns are informational. PM clicks Approve / Override / Reject — all three audit to immutable JSONL.
All veto rules are deterministic Python — they execute regardless of what the LLM proposes. Hard vetos cannot be bypassed; soft vetos require explicit PM acknowledgment; every decision lands in the immutable audit ledger.
Overview
The compliance agent runs as ComplianceBusAgent on the A2A bus (and is also directly callable from the rebalance modal). It receives RebalanceConstructed events and emits either RebalanceCleared or RebalanceBlocked. Source: src/traderspace/bus/agents/compliance.py.
Three veto levels
| level | behavior | example |
|---|---|---|
| Hard | Block submit entirely. Cannot be approved. | Single-name exceeds max_position_pct; restricted-list name; wash-sale violation |
| Soft | Surface warning; PM must explicitly acknowledge. | Turnover > 20%; sector concentration above target |
| Warn | Log to audit but don't gate. | Low diversification; high portfolio realized vol |
Built-in rules
| rule | level | default param |
|---|---|---|
max_position_pct | hard | 0.25 (25%) |
restricted_list | hard | empty (set per tenant) |
wash_sale_window_days | hard | 30 |
turnover_pct_warn | soft | 0.20 (20% per rebalance) |
sector_concentration_max | soft | 0.45 (45% in one sector) |
min_diversification | warn | 4 non-zero positions |
realized_vol_target | warn | 0.22 (22%) |
Approve / Override / Reject
Three buttons in the rebalance modal:
- Approve — submits orders to the active broker. Hard veto must be clean. Audit row gets
decision="approve". - Override — does not submit. Logs your reason as the chosen action in the DPO dataset. Used when you disagree with the proposal but don't want to take the trade either. Audit row gets
decision="override". - Reject — kills the plan. No order submission. Tells the system "this whole plan was wrong." Audit row gets
decision="reject".
All three write to data/audit/rebalance_decisions.jsonl — append-only, never edited. This file is the source of truth for the DPO flywheel; never delete it.
PM pin — hold a position without trading it
Sometimes the PM agrees with the plan as a whole but disagrees with a single proposed trade — “I like my NVDA position where it is, don't touch it.” The dashboard's rebalance card lets you express that directly: every row in the Order preview has an × (Hold) button. Clicking it pins that name at its current position.
What that does, mechanically:
/api/rebalance/planaccepts anexclude=SYM1,SYM2query parameter. The names are case-insensitive; the chips strip above the order table shows the active pin set.- cuFOLIO still solves over the full universe — pinned names participate in the optimization context, scenarios, and forward stats. We don't lie to the solver. Active regimes still apply.
- After the solve, for every pinned name we override
target_shares := current_shares. That forcesdelta_shares = 0, side becomesHOLD, the order'spinnedflag istrue, andtarget_weightis reported as the actual held weight rather than cuFOLIO's would-be target. - The rebalance card's order list filters out zero-delta rows, so pinned names disappear from the visible trades — but they remain in the response with
pinned=truefor the audit trail. - The exclusion set persists in
sessionStorageundernvtrader.rebal.excluded, so a page refresh doesn't lose your work; the chips include a clear button to release every pin.
This is distinct from the three compliance levels above: pin is a PM-side soft override on individual orders, not a rule-driven veto on the plan. It coexists with compliance — if the rest of the plan still passes hard vetos, Approve will submit only the un-pinned orders. The compliance ledger will reflect what was actually submitted, and pinned rows show up in the plan payload so post-hoc review can see exactly which names the PM held back.
The chip strip also exposes a ↻ backtest w/o these button, which POSTs /api/backtest/run with the same name list as exclude: [...]. In backtest context there's no live position to “hold,” so exclude there has the natural meaning of “drop these from the sleeve universe entirely” — a way to ask “what would my sleeve do if these names weren't in it?” without editing the YAML.
Configuring the rules
Per-tenant overrides live in configs/compliance/<tenant_id>.yaml. Example:
max_position_pct: 0.15 # tighter than default
restricted_list:
- "TSLA"
- "GME"
turnover_pct_warn: 0.10 # tighter warn
sector_concentration_max: 0.35
The compliance agent merges defaults + per-tenant + per-sleeve YAML configs at solve time.
Reading the verdict
{
"verdict": "blocked",
"hard": [
{"rule": "max_position_pct", "symbol": "NVDA", "value": 0.31, "limit": 0.25}
],
"soft": [
{"rule": "turnover_pct_warn", "value": 0.27, "limit": 0.20}
],
"warn": [],
"summary": "1 hard veto, 1 soft warning. Cannot submit; lower NVDA or raise cap."
}
Hard veto means cannot submit. Soft warnings need PM ack. Warns are informational.
Adding a rule
Edit src/traderspace/bus/agents/compliance.py. Each rule is a function that takes (plan, config) and returns a list of finding dicts. Add to the appropriate HARD / SOFT / WARN tuple.
def _check_max_options_notional(plan, cfg):
notional = sum(o.notional for o in plan.options)
if notional > cfg.get("max_options_notional", 0.0):
return [{"rule": "max_options_notional", "value": notional, "limit": cfg["max_options_notional"]}]
return []
HARD += (_check_max_options_notional,)
REST surface
| Verb | Path | Purpose |
|---|---|---|
| POST | /api/compliance/check | Body: {plan, sleeve_id}. Returns verdict + findings. |
| GET | /api/compliance/config | Merged config for the current tenant + sleeve. |
| GET | /api/audit/decisions?since=ISO | Decision ledger. |