150 lines
4.6 KiB
Dart

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<String> watchlist = <String>{
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();
}
}