177 lines
5.2 KiB
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);
|
|
});
|
|
});
|
|
}
|