222 lines
7.0 KiB
Dart
222 lines
7.0 KiB
Dart
/// Parsed trading configuration (template + user override merged).
|
|
class EffectiveTradingConfig {
|
|
EffectiveTradingConfig({
|
|
required this.version,
|
|
required this.enabled,
|
|
required this.mode,
|
|
required this.dataInputs,
|
|
required this.rules,
|
|
required this.guardrails,
|
|
this.templateName,
|
|
});
|
|
|
|
final int version;
|
|
final bool enabled;
|
|
final String mode;
|
|
final List<DataInputConfig> dataInputs;
|
|
final List<TradingRuleConfig> rules;
|
|
final GuardrailsConfig guardrails;
|
|
final String? templateName;
|
|
|
|
factory EffectiveTradingConfig.fromJson(
|
|
Map<String, dynamic> json, {
|
|
String? templateName,
|
|
bool? userEnabled,
|
|
}) {
|
|
final bool configEnabled = json['enabled'] as bool? ?? false;
|
|
return EffectiveTradingConfig(
|
|
version: (json['version'] as num?)?.toInt() ?? 1,
|
|
enabled: userEnabled ?? configEnabled,
|
|
mode: json['mode'] as String? ?? 'paper',
|
|
dataInputs: _parseDataInputs(json['data_inputs']),
|
|
rules: _parseRules(json['rules']),
|
|
guardrails: GuardrailsConfig.fromJson(
|
|
json['guardrails'] as Map<String, dynamic>? ?? <String, dynamic>{},
|
|
),
|
|
templateName: templateName,
|
|
);
|
|
}
|
|
|
|
static List<DataInputConfig> _parseDataInputs(Object? raw) {
|
|
if (raw is! List) {
|
|
return <DataInputConfig>[];
|
|
}
|
|
return raw
|
|
.whereType<Map>()
|
|
.map((Map<dynamic, dynamic> m) =>
|
|
DataInputConfig.fromJson(Map<String, dynamic>.from(m)))
|
|
.toList();
|
|
}
|
|
|
|
static List<TradingRuleConfig> _parseRules(Object? raw) {
|
|
if (raw is! List) {
|
|
return <TradingRuleConfig>[];
|
|
}
|
|
return raw
|
|
.whereType<Map>()
|
|
.map((Map<dynamic, dynamic> m) =>
|
|
TradingRuleConfig.fromJson(Map<String, dynamic>.from(m)))
|
|
.toList();
|
|
}
|
|
|
|
/// Deep-merge [override] onto [base]. Lists with `id` fields merge by id.
|
|
static Map<String, dynamic> mergeJson(
|
|
Map<String, dynamic> base,
|
|
Map<String, dynamic> override,
|
|
) {
|
|
final Map<String, dynamic> result = Map<String, dynamic>.from(base);
|
|
for (final MapEntry<String, dynamic> entry in override.entries) {
|
|
final Object? baseValue = base[entry.key];
|
|
final Object? overrideValue = entry.value;
|
|
if (entry.key == 'data_inputs' || entry.key == 'rules') {
|
|
result[entry.key] = _mergeListById(
|
|
baseValue is List ? baseValue : <Object?>[],
|
|
overrideValue is List ? overrideValue : <Object?>[],
|
|
);
|
|
} else if (baseValue is Map<String, dynamic> &&
|
|
overrideValue is Map<String, dynamic>) {
|
|
result[entry.key] = mergeJson(baseValue, overrideValue);
|
|
} else {
|
|
result[entry.key] = overrideValue;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
static List<Object?> _mergeListById(List<Object?> base, List<Object?> override) {
|
|
final Map<String, Map<String, dynamic>> byId = <String, Map<String, dynamic>>{};
|
|
for (final Object? item in base) {
|
|
if (item is Map) {
|
|
final Map<String, dynamic> map = Map<String, dynamic>.from(item);
|
|
final String? id = map['id'] as String?;
|
|
if (id != null) {
|
|
byId[id] = map;
|
|
}
|
|
}
|
|
}
|
|
for (final Object? item in override) {
|
|
if (item is Map) {
|
|
final Map<String, dynamic> patch = Map<String, dynamic>.from(item);
|
|
final String? id = patch['id'] as String?;
|
|
if (id == null) {
|
|
continue;
|
|
}
|
|
byId[id] = byId.containsKey(id) ? mergeJson(byId[id]!, patch) : patch;
|
|
}
|
|
}
|
|
return byId.values.toList();
|
|
}
|
|
}
|
|
|
|
class DataInputConfig {
|
|
DataInputConfig({
|
|
required this.id,
|
|
required this.source,
|
|
required this.assetClass,
|
|
required this.symbols,
|
|
required this.feed,
|
|
required this.pollIntervalSeconds,
|
|
required this.metrics,
|
|
});
|
|
|
|
final String id;
|
|
final String source;
|
|
final String assetClass;
|
|
final List<String> symbols;
|
|
final String feed;
|
|
final int pollIntervalSeconds;
|
|
final List<String> metrics;
|
|
|
|
factory DataInputConfig.fromJson(Map<String, dynamic> json) {
|
|
return DataInputConfig(
|
|
id: json['id']! as String,
|
|
source: json['source'] as String? ?? 'alpaca',
|
|
assetClass: json['asset_class'] as String? ?? 'us_equity',
|
|
symbols: (json['symbols'] as List<dynamic>? ?? <dynamic>[])
|
|
.map((dynamic s) => s as String)
|
|
.toList(),
|
|
feed: json['feed'] as String? ?? 'iex',
|
|
pollIntervalSeconds:
|
|
(json['poll_interval_seconds'] as num?)?.toInt() ?? 60,
|
|
metrics: (json['metrics'] as List<dynamic>? ?? <dynamic>[])
|
|
.map((dynamic m) => m as String)
|
|
.toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class TradingRuleConfig {
|
|
TradingRuleConfig({
|
|
required this.id,
|
|
required this.type,
|
|
required this.symbol,
|
|
required this.refMetric,
|
|
required this.thresholdPct,
|
|
required this.questionTemplate,
|
|
required this.maxStalenessSeconds,
|
|
this.onAnswerMatch,
|
|
});
|
|
|
|
final String id;
|
|
final String type;
|
|
final String symbol;
|
|
final String refMetric;
|
|
final num thresholdPct;
|
|
final String questionTemplate;
|
|
final int maxStalenessSeconds;
|
|
final Map<String, dynamic>? onAnswerMatch;
|
|
|
|
factory TradingRuleConfig.fromJson(Map<String, dynamic> json) {
|
|
return TradingRuleConfig(
|
|
id: json['id']! as String,
|
|
type: json['type']! as String,
|
|
symbol: json['symbol']! as String,
|
|
refMetric: json['ref_metric'] as String? ?? 'prev_close',
|
|
thresholdPct: json['threshold_pct'] as num? ?? 0,
|
|
questionTemplate: json['question_template'] as String? ?? '',
|
|
maxStalenessSeconds:
|
|
(json['max_staleness_seconds'] as num?)?.toInt() ?? 900,
|
|
onAnswerMatch: json['on_answer_match'] is Map
|
|
? Map<String, dynamic>.from(json['on_answer_match'] as Map)
|
|
: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
class GuardrailsConfig {
|
|
GuardrailsConfig({
|
|
required this.maxOrdersPerDay,
|
|
required this.maxNotionalUsdPer4h,
|
|
required this.requireQuestionBeforeOrder,
|
|
required this.symbolsBlocklist,
|
|
});
|
|
|
|
final int maxOrdersPerDay;
|
|
|
|
/// Cap on total order notional placed in any rolling 4-hour window.
|
|
///
|
|
/// Phase 1 enforces this against [trade_orders.submitted_at]; the same value
|
|
/// is referenced via [Guardrails.windowDuration] when the caller computes the
|
|
/// running total.
|
|
final num maxNotionalUsdPer4h;
|
|
|
|
final bool requireQuestionBeforeOrder;
|
|
final List<String> symbolsBlocklist;
|
|
|
|
factory GuardrailsConfig.fromJson(Map<String, dynamic> json) {
|
|
final num? per4h = json['max_notional_usd_per_4h'] as num?;
|
|
// Back-compat: read legacy key if present, but new configs should use _per_4h.
|
|
final num? legacyPerDay = json['max_notional_usd_per_day'] as num?;
|
|
return GuardrailsConfig(
|
|
maxOrdersPerDay: (json['max_orders_per_day'] as num?)?.toInt() ?? 3,
|
|
maxNotionalUsdPer4h: per4h ?? legacyPerDay ?? 100,
|
|
requireQuestionBeforeOrder:
|
|
json['require_question_before_order'] as bool? ?? true,
|
|
symbolsBlocklist: (json['symbols_blocklist'] as List<dynamic>? ??
|
|
<dynamic>[])
|
|
.map((dynamic s) => s as String)
|
|
.toList(),
|
|
);
|
|
}
|
|
}
|