cyberhybridhub/server/test/trading/guardrails_test.dart

177 lines
5.2 KiB
Dart

import 'package:cyberhybridhub_server/trading/guardrails.dart';
import 'package:cyberhybridhub_server/trading/trading_config.dart';
import 'package:test/test.dart';
EffectiveTradingConfig _config({
bool enabled = true,
String mode = 'paper',
List<String> symbols = const <String>['SPY'],
int maxOrdersPerDay = 3,
num maxNotionalUsdPer4h = 100,
bool requireQuestion = true,
List<String> blocklist = const <String>[],
}) {
return EffectiveTradingConfig.fromJson(<String, dynamic>{
'version': 1,
'enabled': enabled,
'mode': mode,
'data_inputs': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'primary_watchlist',
'symbols': symbols,
'metrics': <String>['last_trade'],
},
],
'rules': <Map<String, dynamic>>[],
'guardrails': <String, dynamic>{
'max_orders_per_day': maxOrdersPerDay,
'max_notional_usd_per_4h': maxNotionalUsdPer4h,
'require_question_before_order': requireQuestion,
'symbols_blocklist': blocklist,
},
});
}
GuardrailDecision _check(
Guardrails g, {
required EffectiveTradingConfig config,
String symbol = 'SPY',
num notionalUsd = 10,
int dailyOrderCount = 0,
num notionalUsdInWindow = 0,
bool hasUnansweredQuestion = false,
bool questionAnswered = true,
}) {
return g.check(
config: config,
symbol: symbol,
notionalUsd: notionalUsd,
dailyOrderCount: dailyOrderCount,
notionalUsdInWindow: notionalUsdInWindow,
hasUnansweredQuestion: hasUnansweredQuestion,
questionAnswered: questionAnswered,
);
}
void main() {
group('Guardrails.check', () {
test('allows a paper order within all limits', () {
final GuardrailDecision decision =
_check(Guardrails(), config: _config());
expect(decision.allowed, isTrue);
});
test('rejects when trading is disabled', () {
final GuardrailDecision decision = _check(
Guardrails(),
config: _config(enabled: false),
);
expect(decision.reason, GuardrailRejectionReason.tradingDisabled);
});
test('rejects blocklisted symbol', () {
final GuardrailDecision decision = _check(
Guardrails(),
config: _config(blocklist: <String>['SPY']),
);
expect(decision.reason, GuardrailRejectionReason.blocklistedSymbol);
});
test('rejects symbol not in watchlist', () {
final GuardrailDecision decision = _check(
Guardrails(),
config: _config(symbols: <String>['AAPL']),
);
expect(decision.reason, GuardrailRejectionReason.symbolNotInWatchlist);
});
test('rejects when daily order count reaches config max', () {
final GuardrailDecision decision = _check(
Guardrails(),
config: _config(maxOrdersPerDay: 3),
dailyOrderCount: 3,
);
expect(
decision.reason,
GuardrailRejectionReason.maxOrdersPerDayExceeded,
);
});
test('rejects when 4h-window notional plus new order would exceed config',
() {
final GuardrailDecision decision = _check(
Guardrails(),
config: _config(maxNotionalUsdPer4h: 100),
notionalUsd: 50,
notionalUsdInWindow: 60,
);
expect(
decision.reason,
GuardrailRejectionReason.maxNotionalUsdPer4hExceeded,
);
});
test('allows a later order when prior 4h window has rolled off', () {
// Caller queries trade_orders with submitted_at >= now() - 4h, so older
// orders aren't counted here.
final GuardrailDecision decision = _check(
Guardrails(),
config: _config(maxNotionalUsdPer4h: 100),
notionalUsd: 40,
notionalUsdInWindow: 0,
);
expect(decision.allowed, isTrue);
});
test('Guardrails.windowDuration is 4 hours by default', () {
expect(Guardrails().windowDuration, const Duration(hours: 4));
});
test('rejects when require_question_before_order and no answer on file',
() {
final GuardrailDecision decision = _check(
Guardrails(),
config: _config(),
questionAnswered: false,
);
expect(decision.reason, GuardrailRejectionReason.questionRequired);
});
test('rejects when an unanswered question is still open', () {
final GuardrailDecision decision = _check(
Guardrails(),
config: _config(),
hasUnansweredQuestion: true,
);
expect(decision.reason, GuardrailRejectionReason.unansweredQuestion);
});
test('server ceiling overrides high config max_notional_usd_per_4h', () {
final Guardrails g = Guardrails(serverMaxNotionalUsd: 25);
final GuardrailDecision decision = _check(
g,
config: _config(maxNotionalUsdPer4h: 10000),
notionalUsd: 100,
);
expect(
decision.reason,
GuardrailRejectionReason.serverMaxNotionalUsdExceeded,
);
});
test('refuses live mode unless allowLive=true', () {
final GuardrailDecision blocked = _check(
Guardrails(),
config: _config(mode: 'live'),
);
expect(blocked.reason, GuardrailRejectionReason.livePaperMismatch);
final GuardrailDecision allowed = _check(
Guardrails(allowLive: true),
config: _config(mode: 'live'),
);
expect(allowed.allowed, isTrue);
});
});
}