ADR 0107 — Strategy Library: user-defined rule grammar + enforcement posture
Status: Accepted
Date: 2026-05-29 UTC
Deciders: product owner (Kristerpher), software-architect
Scope: Raptor (backend_v2/), Antlers (frontend/raxx-next/)
Context
Raxx's core product thesis is that users fail not from a skill gap but a structure gap: they know their rules, but emotion overrides them at execution time. The Strategy Library is the mechanism that turns a user's stated rules into system-enforced constraints that evaluate on every trade before emotion gets a vote.
Phases 1–5 shipped across PRs #3005–#3024 without a governing ADR. Decisions accumulated in PR descriptions, inline comments, and project memory. This ADR captures the choices actually made so they are first-class architectural artifacts rather than scattered context.
Decision
The Strategy Library is a per-user rule registry backed by a Postgres
strategies table. Rules are stored as typed nullable columns. At order
submission, Raptor evaluates applicable entry rules server-side and rejects
non-compliant trades with HTTP 422. Exit rules are stored and consumed by the
backtest runner; server-side auto-enforcement of exit rules is deferred to a
future phase.
Language choice rationale
This ADR governs a feature within an existing service (Raptor), not a new standalone service. The Language choice rationale section is not applicable.
Invariants
These constraints are non-negotiable and were operative during all phases:
-
No stored credentials. Strategy records contain only user-authored rule data. No broker token, API key, or credential of any kind is stored alongside or derived from a strategy.
-
Audit trail on every state change. Create, update, and delete of a strategy row is an auditable event affecting user-directed trading behaviour.
-
Paper-first gating. Entry rule enforcement applies equally on paper and live order paths. The enforcement layer does not fork by execution mode.
-
Per-user scope and ownership. A user may only read, write, or apply their own strategies. No cross-user strategy sharing or inheritance in v1.
Data model
strategies table
strategies
├── id UUID PRIMARY KEY DEFAULT gen_random_uuid()
├── user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE
├── name TEXT NOT NULL
├── description TEXT NULL
│
│ -- Entry rules
├── entry_symbol_allowlist TEXT NULL -- CSV e.g. "SPY,QQQ"; NULL = unrestricted
├── entry_max_position_size NUMERIC NULL -- USD; NULL = unrestricted
├── entry_allowed_sides TEXT NULL -- CSV: "buy", "sell", "both"; NULL = unrestricted
│
│ -- Credit rule (options)
├── credit_min_amount NUMERIC NULL -- USD net credit minimum; NULL = no minimum
│
│ -- Exit rules (stored; not auto-enforced server-side in v1)
├── exit_profit_target_pct NUMERIC(8,4) NULL -- WHOLE number: 20 = 20%
├── exit_stop_loss_pct NUMERIC(8,4) NULL -- WHOLE number, negative: -50 = -50%
├── exit_max_dte INTEGER NULL -- days-to-expiry at entry; NULL = unrestricted
│
├── created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
└── updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
NULL semantics: NULL on any rule column means "no constraint on this dimension." A strategy where every rule column is NULL is valid; it acts as decorative metadata only (name + description) and passes all enforcement checks.
Unit convention (critical): All percentage fields (exit_profit_target_pct,
exit_stop_loss_pct) store WHOLE numbers. 20 means 20 %; 0.20 is invalid.
This convention was established in PR #3018 (hot-fix) after the original
NUMERIC(6,4) column rejected the fractional values that SQLite had silently
tolerated during development. The column was widened to NUMERIC(8,4) and all
runtime * 100 scaling was removed. Stored value must equal displayed value.
Rule grammar (7 fields)
| Field | Type | Enforcement point | Semantics |
|---|---|---|---|
entry_symbol_allowlist |
CSV text | Server-side, order submission | Trade symbol must be in list; NULL = any symbol allowed |
entry_max_position_size |
USD | Server-side, order submission | Notional value of order must not exceed this amount |
entry_allowed_sides |
CSV text | Server-side, order submission | Order side (buy/sell) must be in list |
credit_min_amount |
USD | Server-side, multi-leg options | Sum net credit of the order must meet or exceed this amount |
exit_profit_target_pct |
Whole % | Backtest runner (v1); auto-close job (future) | Target profit expressed as whole percentage of entry credit/debit |
exit_stop_loss_pct |
Whole %, negative | Backtest runner (v1); auto-close job (future) | Maximum loss as whole percentage; negative convention (e.g. -50) |
exit_max_dte |
Integer days | Backtest runner (v1); auto-close job (future) | Maximum days-to-expiry at order entry for options |
Enforcement posture
Entry rules — server-side enforced (PR #3024, Bundle 1)
/api/trade-window/orders evaluates entry_symbol_allowlist,
entry_max_position_size, and entry_allowed_sides before forwarding to the
broker adapter. A non-compliant request returns HTTP 422 with a structured error
body (error code STRATEGY_RULE_VIOLATION, human-readable detail string naming
the violated field). The enforcement runs after session auth and before any
broker API call.
For multi-leg options orders, credit_min_amount is additionally evaluated by
summing the net credit across legs. Violation returns HTTP 422 with the same
error shape.
Exit rules — stored only (v1)
exit_profit_target_pct, exit_stop_loss_pct, and exit_max_dte are
persisted and read by the backtest runner. They are not auto-enforced by a
server-side job in v1. A future Phase 6 design may add a position-monitor job
that evaluates open positions against exit rules on a scheduled basis.
Phase 2–3c posture (resolved technical debt)
During Phases 2–3c, compliance checks ran client-side in Antlers (soft-warn, no server block). This was acknowledged in code comments but not formally documented. PR #3024 (Bundle 1) promoted enforcement to the server. The client-trusted soft-warn posture is retired.
APIs / contracts
Routes are all under /api/strategies, session-gated, and ownership-checked:
GET /api/strategies -> list all strategies for current user
POST /api/strategies -> create a strategy
GET /api/strategies/<id> -> get one strategy (ownership check)
PUT /api/strategies/<id> -> update a strategy (ownership check)
DELETE /api/strategies/<id> -> delete a strategy (ownership check)
Enforcement occurs at:
POST /api/trade-window/orders -> entry rules evaluated here (post-Bundle-1)
POST /api/orders/multi-leg -> credit_min_amount evaluated here
GET /api/backtest/run -> exit rules consumed here
Phase delivery map
| Phase | PR | What shipped |
|---|---|---|
| Phase 1 | #3005 | CRUD shell: strategies table, migration, basic CRUD routes |
| Phase 2 | #3007 | Rule grammar (7 fields), Trade compliance pill (client-side soft-warn) |
| Phase 3a | #3010 | Single-leg options rule support |
| Phase 3b | #3013 | Multi-leg options, Orders panel integration |
| Phase 3c | #3015 | Chain-linked dropdowns, Preview panel |
| Phase 4a | #3014 | Backtest integration — runner reads exit rules, returns equity curve + stats |
| Phase 5 | #3016 | Templates: 5 client-side prefill templates |
| Hot-fix | #3018 | Whole-pct unit convention + NUMERIC(8,4) column widening |
| Bundle 1 | #3024 | Server-side entry rule enforcement; client-trust posture retired |
| Templates v2 | (this PR) | 3 server-side templates with compliance gating (FLAG_STRATEGY_TEMPLATES_V2) |
Templates
Client-side templates (Phase 5)
Five client-side prefill templates live in
frontend/raxx-next/lib/strategyTemplates.ts:
- Iron Condor
- Vertical Spread
- Long Equity Swing
- Short Equity Swing
- Start from Scratch (empty)
Templates are purely client-side. They pre-fill the strategy form; the resulting
strategy is persisted like any user-authored strategy via POST /api/strategies.
Server-side templates v2 (FLAG_STRATEGY_TEMPLATES_V2)
Three server-side templates ship behind FLAG_STRATEGY_TEMPLATES_V2 (default off).
Each template adds compliance rules beyond the generic 7-column rule grammar and
is backed by a row in the strategy_template_definitions table.
| Template key | Display name | Compliance rules |
|---|---|---|
covered_call |
Covered Call | shares_required (>=100/contract), min_dte (>=7), otm_only (strike >= price), credit_min (>=configurable $/share) |
cash_secured_put |
Cash-Secured Put | collateral_required (strike×100×contracts), min_dte (>=7), otm_only (strike <= price), credit_min (>=configurable $/share) |
vertical_credit_spread |
Vertical Credit Spread | same_expiry (both legs), min_dte (>=7), width_positive (strikes differ), credit_min_pct_of_width (>=25% of width) |
Routes (flag-gated):
GET /api/strategies/templates -> list all 3 templates
POST /api/strategies/templates/validate -> dry-run compliance check; returns 422 on violation
Implementation:
- backend_v2/api/services/strategy_templates/ — validator modules + registry
- backend_v2/alembic/versions/0024_strategy_template_definitions.py — DB table + seed rows
- console/migrations/versions/0140_promote_strategy_templates_v2.py — B1 promotion
Non-compliant validation requests return HTTP 422:
{"error": "template_compliance_violation", "violations": [{"rule": "...", "message": "..."}]}
Violation rule names are namespaced by template: covered_call.shares_required,
cash_secured_put.collateral_required, vertical_credit_spread.same_expiry, etc.
Context params (shares_owned, cash_available, underlying_price) are pre-fetched by
the caller — the validators are pure functions with no DB access.
Templates are purely client-side. There is no backend template registry in v1.
A user selecting a template pre-fills the strategy form; the resulting strategy
is persisted like any user-authored strategy via POST /api/strategies.
Backtest integration
backend_v2/api/services/backtest_runner.py reads strategy exit rules and
replays them against historical bar data. The runner returns:
- Trade entries and exits
- Equity curve
- Summary statistics (win rate, avg profit, max drawdown, etc.)
Options strategies return {"error": "strategy_not_equity_compatible"} in v1.
Options backtesting is scoped to Phase 4b (future).
Migrations
The strategies table was introduced in Phase 1 (PR #3005) and subsequently
widened in the PR #3018 hot-fix:
- Phase 1 migration: create table with initial schema
- PR #3018 migration:
ALTER COLUMN exit_profit_target_pct TYPE NUMERIC(8,4),ALTER COLUMN exit_stop_loss_pct TYPE NUMERIC(8,4), drop any* 100scaling from application layer
Rollback: standard Alembic downgrade. The column-widen migration downgrade
narrows the column back to NUMERIC(6,4) — safe only if no values with more
than 4 total digits before the decimal are present (pre-hot-fix data used
fractional values which the downgrade would reject; operator review required
before applying downgrade on a production database with real data).
Rollout plan
Phases 1–5 shipped behind the standard feature-flag posture and are now GA for all sessions. Bundle 1 (server-side enforcement) deployed with PR #3024.
No further rollout gates are pending for v1. Future work (Phase 6: auto-close job) will require its own dark → flag → beta → GA rollout and a new ADR.
Security considerations
-
PII collected: Strategy records contain only user-authored trading rules (symbol names, numeric thresholds, text). No financial account numbers, no brokerage credentials, no personally sensitive data beyond the user's own stated rule preferences.
-
Retention period: Strategy rows follow the user account retention policy. Cascade delete on
users.idensures strategy data is purged when a user account is deleted. No independent retention clock. -
Deletion on DSR:
ON DELETE CASCADEonuser_idforeign key covers DSR erasure automatically when the user row is deleted. No additional purge job required for strategy data. -
Audit trail: Create, update, and delete of any strategy row must be recorded in the unified audit log (ADR-0058) with
actor_id,strategy_id,action, and a before/after JSON snapshot of the changed fields. Audit events are append-only; no strategy change is silent. -
Stored credentials: None. Strategies contain no credential material.
-
Breach notification path: Strategy data is low-sensitivity (user's own trading rules, no financial account data). A breach exposing strategy rows triggers the standard GDPR 72-hour notification path (ADR-0003) but does not trigger the high-severity credential-exposure path.
-
Secrets location + rotation: No secrets specific to the Strategy Library. Raptor's standard DB credentials apply.
-
Kill-switch: Entry rule enforcement can be bypassed per-flow via the paper-first override mechanism. No strategy-library-specific kill switch exists; disabling enforcement globally would require a Raptor code change. A feature flag
FLAG_STRATEGY_ENFORCEMENT_ENABLEDis a recommended future addition (see Open questions).
Alternatives considered
Store rules as JSON blob, not typed columns
Rejected. Typed nullable columns make enforcement logic straightforward
(if strategy.entry_max_position_size is not None) and allow the DB to enforce
types. A JSON blob would require application-level parsing on every evaluation
and would make schema evolution invisible to DB tooling. Cost: slightly wider
table; benefit: correctness and tooling clarity.
Client-side enforcement only (remain at Phase 3c posture)
Rejected at Bundle 1. Client-trusted soft-warn means a sophisticated user can bypass enforcement by modifying the request. Rules that govern trade execution must be enforced at the server boundary. This was the defining decision of PR #3024.
Enforce exit rules server-side at order submission (not just at backtest)
Deferred. At order submission we can record the user's exit intent, but enforcing it requires a position-monitor job that evaluates open positions continuously. That job is Phase 6 scope. Shipping it in v1 would require durable scheduler infrastructure (Celery beat or equivalent) that is not in scope for the current launch posture.
Open questions
-
FLAG_STRATEGY_ENFORCEMENT_ENABLEDkill-switch. No feature flag gates entry rule enforcement. A flag is recommended so enforcement can be disabled per-environment without a code deploy in the event of a false-positive rejection. Requires a new flag + B1 promotion migration (perfeedback_new_flag_needs_b1_migration_same_pr). -
Audit-log integration completeness. Resolved. Strategy CUD events are now emitted to
audit_logviacustomer_session_service.write_auditinside the same DB transaction as the mutation:strategy.create,strategy.update(withfields_changeddiff),strategy.delete(with name snapshot), andstrategy.access_denied(on ownership-check failure). Coverage intests/test_strategies_audit_log.py. -
Phase 4b: options backtesting. Scoped but undesigned. Will require its own ADR covering options-pricing model choice and integration with the backtest runner.
-
Phase 6: auto-close job. Exit rules are stored but not auto-enforced. The auto-close path requires a durable scheduler, position-monitor logic, and broker API integration for close orders. Requires its own ADR when scoped.
-
Multi-strategy per trade. v1 assumes one active strategy per user is applied to an order (or no strategy). If users request multiple concurrent strategies with rule composition (AND/OR), a grammar extension ADR is needed before implementation.
-
docs/architecture/multi-tenant-alpaca.mdstaleness. This doc references per-user Alpaca OAuth which was superseded by ADR-0013 (now Deferred) and the current broker-adapter pattern (ADR-0052). That document should be updated or deprecated in a separate cleanup wave; it is not addressed in this PR.
Revisit when
- Phase 6 (auto-close job) is scoped — the enforcement posture section requires update when exit rules gain server-side enforcement.
- Options backtesting (Phase 4b) ships — backtest integration section needs extension.
- Multi-strategy composition is requested — the data model may require a join table or rule-set grammar extension.
- Entry rule enforcement produces false-positive rejections in production — evaluate the kill-switch open question.