150 lines
4.6 KiB
Dart
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();
|
|
}
|
|
}
|