Risk Profiles
A named, reusable bundle of copy-risk settings, attachable to many copy_relationships (risk_profiles table, see 20260807034924_copy_engine.sql). Lives at src/lib/risk-profiles/ (CRUD) and src/lib/copy-engine/ (enforcement) — Risk Profiles don't have their own engine; they configure the one Copy Engine every relationship already runs through.
Fields
| Field | Column | Enforced by |
|---|---|---|
| Lot Scaling | lot_mode / lot_value | RiskCalculationService.computeVolume (lot-scaling.ts) |
| Max Drawdown | max_drawdown_percent | checkAccountRisk (risk-checks.ts) — every event type |
| Daily Loss Limit | daily_loss_limit_percent | checkAccountRisk (risk-checks.ts) — every event type |
| Sync Stop Loss / Take Profit | sync_sltp | replicateModify |
| Sync Partial Closes | sync_partial_close | replicatePartialClose |
| Maximum Open Trades | max_open_trades | checkMaxOpenTrades (risk-profile-filters.ts) — open only |
| Hard Position Size Limit | max_position_size | clampToMaxPositionSize (lot-scaling.ts) — open only |
| Time Filter | time_filter (jsonb) | checkTimeFilter (risk-profile-filters.ts) — open only |
| News Filter | news_filter (jsonb) | checkNewsFilter (risk-profile-filters.ts) — open only |
The bold rows were added by 20260808000000_risk_profiles_advanced_filters.sql — purely additive, every column defaults to "no restriction" so profiles created before this migration behave exactly as they did before it.
Why the four new checks only ever gate open events
TradeReplicationService.replicate() runs checkAccountRisk (Max Drawdown / Daily Loss Limit) before dispatching to open/close/modify/partial_close — that's existing, unchanged behavior. The four new checks are deliberately not added to that same universal gate. They're called from inside replicateOpen only:
- Maximum Open Trades is a count-based cap. If it also blocked
close, an account that reached its cap could never mirror-close back down again — the opposite of what a risk control is for.
- Time Filter / News Filter answer "should a *new* copy open
right now" — they have no coherent meaning against a close/modify of a trade the account already has.
- Hard Position Size Limit only makes sense at the moment a volume
is computed, which only happens on open.
A risk control should never be the reason a user can't close or manage a position they already have open.
Maximum Open Trades
Counts trades on the follower account with status in ('open', 'pending') (SupabaseCopyPersistence.getAccountSnapshot's openTradesCount, fetched in the same query pass every other account-risk check already uses — no extra round trip). null = no cap.
Hard Position Size Limit
Clamps, never rejects: if the computed opening volume exceeds max_position_size, the trade still opens, just capped down to that size (re-rounded through the same roundLot every other lot computation uses). The follower still participates in an oversized master trade instead of missing it entirely. The copy log message notes (clamped to N max position size) when this fires.
Time Filter
{ enabled, sessions: MarketSessionName[], days: number[] }. Reuses src/lib/automation/market-sessions.ts's isSessionActive() — the same real FX session windows (Sydney/Tokyo/London/New York, UTC) the Automation Engine's market_open/market_close triggers and Weekend Protection template already use, so "London session" means the same thing everywhere in this codebase. days is UTC day-of-week (0 = Sunday … 6 = Saturday). Either list left empty means "no restriction on that dimension" — enabled must still be true for either to apply at all.
News Filter
{ enabled, blockMinutes }. Reuses the exact same signal the Automation Engine's news_event trigger already reads (context-builder.ts's freshHighImpactBriefAt, sourced from the most recent market_brief-typed ai_insights row) via a new CopyPersistencePort.getFreshHighImpactBriefAt(userId) method — not a second news source. New copies are blocked for blockMinutes after that brief's created_at. Same honest scope as the Automation Engine's own trigger: a best-effort "was a brief just (re)generated" signal, not a live economic calendar. Only queried when a profile actually has news_filter.enabled — relationships without it never pay for the extra read.
UI
src/components/risk-profiles/risk-profile-dialog.tsx (create/edit form) and risk-profiles-list.tsx (summary cards) — both under /dashboard/risk-manager and /dashboard/copier. Form validation beyond the DB's own CHECK constraints lives in src/app/dashboard/risk-manager/actions.ts's validateFields.
Tests
tests/copy-engine-risk-profile-filters.test.ts— pure function unit tests.tests/copy-engine-risk-calculation-service.test.ts— facade delegation +clampToMaxPositionSize.tests/copy-engine-persistence.test.ts—resolveRiskSettings(profile vs. fallback defaults).tests/copy-engine-replication-service.test.ts— full integration throughTradeReplicationService, including the "these never block close/modify/partial_close" guarantee.