import 'trading_config.dart'; /// Why a trade was rejected. `null` means the guardrails passed. enum GuardrailRejectionReason { tradingDisabled, blocklistedSymbol, symbolNotInWatchlist, maxOrdersPerDayExceeded, maxNotionalUsdPer4hExceeded, serverMaxNotionalUsdExceeded, questionRequired, unansweredQuestion, livePaperMismatch, } /// Outcome of a guardrail check. class GuardrailDecision { GuardrailDecision._({required this.allowed, this.reason, this.detail}); factory GuardrailDecision.allow() => GuardrailDecision._(allowed: true); factory GuardrailDecision.reject( GuardrailRejectionReason reason, { String? detail, }) => GuardrailDecision._(allowed: false, reason: reason, detail: detail); final bool allowed; final GuardrailRejectionReason? reason; final String? detail; @override String toString() => allowed ? 'GuardrailDecision.allow' : 'GuardrailDecision.reject(${reason!.name}${detail == null ? '' : ': $detail'})'; } /// Pre-trade safety checks executed before any Alpaca POST. /// /// Guardrails take precedence over user-supplied config: even if config sets /// `max_notional_usd_per_4h` higher than [serverMaxNotionalUsd], the server /// ceiling wins. /// /// The notional cap is a **rolling 4-hour window** — not a calendar-day cap — /// so a strategy can "double down" later in the day when a thesis continues /// to look attractive, while still bounding the blast radius of a runaway /// rule. Order-count is still a calendar-day cap. class Guardrails { Guardrails({ this.serverMaxNotionalUsd = 50, this.allowLive = false, this.windowDuration = const Duration(hours: 4), }); /// Hard server-side ceiling per order regardless of user config. final num serverMaxNotionalUsd; /// Server-wide live-trading allow flag (mirrors `ALPACA_ALLOW_LIVE`). final bool allowLive; /// Width of the rolling window used for [maxNotionalUsdPer4h]. Callers must /// compute [notionalUsdInWindow] using this same duration (typically a /// `SELECT … WHERE submitted_at >= now() - INTERVAL '4 hours'` on /// `trade_orders`). final Duration windowDuration; GuardrailDecision check({ required EffectiveTradingConfig config, required String symbol, required num notionalUsd, required int dailyOrderCount, required num notionalUsdInWindow, required bool hasUnansweredQuestion, required bool questionAnswered, }) { if (!config.enabled) { return GuardrailDecision.reject( GuardrailRejectionReason.tradingDisabled, detail: 'user trading config is disabled', ); } if (config.mode == 'live' && !allowLive) { return GuardrailDecision.reject( GuardrailRejectionReason.livePaperMismatch, detail: 'live mode requires ALPACA_ALLOW_LIVE=true', ); } if (config.guardrails.symbolsBlocklist.contains(symbol)) { return GuardrailDecision.reject( GuardrailRejectionReason.blocklistedSymbol, detail: symbol, ); } final Set watchlist = { for (final DataInputConfig input in config.dataInputs) ...input.symbols, }; if (watchlist.isNotEmpty && !watchlist.contains(symbol)) { return GuardrailDecision.reject( GuardrailRejectionReason.symbolNotInWatchlist, detail: symbol, ); } if (notionalUsd > serverMaxNotionalUsd) { return GuardrailDecision.reject( GuardrailRejectionReason.serverMaxNotionalUsdExceeded, detail: '$notionalUsd > server ceiling $serverMaxNotionalUsd', ); } if (dailyOrderCount >= config.guardrails.maxOrdersPerDay) { return GuardrailDecision.reject( GuardrailRejectionReason.maxOrdersPerDayExceeded, detail: '$dailyOrderCount >= ${config.guardrails.maxOrdersPerDay}', ); } if (notionalUsdInWindow + notionalUsd > config.guardrails.maxNotionalUsdPer4h) { return GuardrailDecision.reject( GuardrailRejectionReason.maxNotionalUsdPer4hExceeded, detail: '${notionalUsdInWindow + notionalUsd} > ${config.guardrails.maxNotionalUsdPer4h} in ${windowDuration.inHours}h window', ); } if (config.guardrails.requireQuestionBeforeOrder) { if (!questionAnswered) { return GuardrailDecision.reject( GuardrailRejectionReason.questionRequired, detail: 'no confirming answer on file', ); } if (hasUnansweredQuestion) { return GuardrailDecision.reject( GuardrailRejectionReason.unansweredQuestion, detail: 'resolve open question before submitting order', ); } } return GuardrailDecision.allow(); } }