Raxx · internal docs

internal · gated

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:

  1. 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.

  2. Audit trail on every state change. Create, update, and delete of a strategy row is an auditable event affecting user-directed trading behaviour.

  3. Paper-first gating. Entry rule enforcement applies equally on paper and live order paths. The enforcement layer does not fork by execution mode.

  4. 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:

  1. Iron Condor
  2. Vertical Spread
  3. Long Equity Swing
  4. Short Equity Swing
  5. 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:

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:

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


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

  1. FLAG_STRATEGY_ENFORCEMENT_ENABLED kill-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 (per feedback_new_flag_needs_b1_migration_same_pr).

  2. Audit-log integration completeness. Resolved. Strategy CUD events are now emitted to audit_log via customer_session_service.write_audit inside the same DB transaction as the mutation: strategy.create, strategy.update (with fields_changed diff), strategy.delete (with name snapshot), and strategy.access_denied (on ownership-check failure). Coverage in tests/test_strategies_audit_log.py.

  3. Phase 4b: options backtesting. Scoped but undesigned. Will require its own ADR covering options-pricing model choice and integration with the backtest runner.

  4. 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.

  5. 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.

  6. docs/architecture/multi-tenant-alpaca.md staleness. 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