240 lines
6.9 KiB
Dart
240 lines
6.9 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:postgres/postgres.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
/// Persisted trade order audit row.
|
|
class TradeOrder {
|
|
TradeOrder({
|
|
required this.id,
|
|
required this.firebaseUid,
|
|
required this.clientOrderId,
|
|
required this.symbol,
|
|
required this.side,
|
|
required this.orderType,
|
|
required this.status,
|
|
this.alpacaOrderId,
|
|
this.notionalUsd,
|
|
this.qty,
|
|
this.questionId,
|
|
this.ruleId,
|
|
this.submittedAt,
|
|
this.filledAt,
|
|
this.raw,
|
|
this.createdAt,
|
|
});
|
|
|
|
final String id;
|
|
final String firebaseUid;
|
|
final String clientOrderId;
|
|
final String? alpacaOrderId;
|
|
final String symbol;
|
|
final String side;
|
|
final String orderType;
|
|
final num? notionalUsd;
|
|
final num? qty;
|
|
final String status;
|
|
final String? questionId;
|
|
final String? ruleId;
|
|
final DateTime? submittedAt;
|
|
final DateTime? filledAt;
|
|
final Map<String, dynamic>? raw;
|
|
final DateTime? createdAt;
|
|
}
|
|
|
|
/// Postgres access for [trade_orders].
|
|
class TradeOrdersDb {
|
|
TradeOrdersDb(this._connection);
|
|
|
|
final Connection _connection;
|
|
static const Uuid _uuid = Uuid();
|
|
|
|
/// Counts non-terminal orders submitted at or after [since].
|
|
///
|
|
/// Used by guardrails for `max_orders_per_day` (pass UTC start-of-day) and
|
|
/// rolling-window order-count checks.
|
|
Future<int> countOrdersSince(String firebaseUid, DateTime since) async {
|
|
final Result result = await _connection.execute(
|
|
Sql.named(
|
|
'''
|
|
SELECT COUNT(*)
|
|
FROM trade_orders
|
|
WHERE firebase_uid = @uid
|
|
AND submitted_at IS NOT NULL
|
|
AND submitted_at >= @since
|
|
AND status NOT IN ('rejected', 'canceled', 'failed')
|
|
''',
|
|
),
|
|
parameters: <String, dynamic>{
|
|
'uid': firebaseUid,
|
|
'since': since.toUtc(),
|
|
},
|
|
);
|
|
if (result.isEmpty) return 0;
|
|
final Object? raw = result.first[0];
|
|
if (raw is num) return raw.toInt();
|
|
return num.parse(raw.toString()).toInt();
|
|
}
|
|
|
|
/// Sums `notional_usd` of non-terminal orders submitted at or after [since].
|
|
///
|
|
/// Used by guardrails for the rolling 4-hour notional cap.
|
|
Future<num> notionalUsdInWindow(String firebaseUid, DateTime since) async {
|
|
final Result result = await _connection.execute(
|
|
Sql.named(
|
|
'''
|
|
SELECT COALESCE(SUM(notional_usd), 0)
|
|
FROM trade_orders
|
|
WHERE firebase_uid = @uid
|
|
AND submitted_at IS NOT NULL
|
|
AND submitted_at >= @since
|
|
AND status NOT IN ('rejected', 'canceled', 'failed')
|
|
''',
|
|
),
|
|
parameters: <String, dynamic>{
|
|
'uid': firebaseUid,
|
|
'since': since.toUtc(),
|
|
},
|
|
);
|
|
if (result.isEmpty) return 0;
|
|
final Object? raw = result.first[0];
|
|
if (raw is num) return raw;
|
|
return num.parse(raw.toString());
|
|
}
|
|
|
|
Future<TradeOrder?> findByClientOrderId(String clientOrderId) async {
|
|
final Result result = await _connection.execute(
|
|
Sql.named(
|
|
'''
|
|
SELECT id, firebase_uid, client_order_id, alpaca_order_id, symbol, side,
|
|
order_type, notional_usd, qty, status, question_id, rule_id,
|
|
submitted_at, filled_at, raw, created_at
|
|
FROM trade_orders
|
|
WHERE client_order_id = @client_order_id
|
|
''',
|
|
),
|
|
parameters: <String, dynamic>{'client_order_id': clientOrderId},
|
|
);
|
|
if (result.isEmpty) {
|
|
return null;
|
|
}
|
|
return _rowToOrder(result.first);
|
|
}
|
|
|
|
/// Inserts a pending order. Returns existing row if [clientOrderId] already exists.
|
|
Future<TradeOrder> insertOrder({
|
|
required String firebaseUid,
|
|
required String clientOrderId,
|
|
required String symbol,
|
|
required String side,
|
|
required String orderType,
|
|
required String status,
|
|
String? alpacaOrderId,
|
|
num? notionalUsd,
|
|
num? qty,
|
|
String? questionId,
|
|
String? ruleId,
|
|
Map<String, dynamic>? raw,
|
|
}) async {
|
|
final TradeOrder? existing = await findByClientOrderId(clientOrderId);
|
|
if (existing != null) {
|
|
return existing;
|
|
}
|
|
|
|
final String id = _uuid.v4();
|
|
try {
|
|
final Result result = await _connection.execute(
|
|
Sql.named(
|
|
'''
|
|
INSERT INTO trade_orders (
|
|
id, firebase_uid, client_order_id, alpaca_order_id, symbol, side,
|
|
order_type, notional_usd, qty, status, question_id, rule_id, raw,
|
|
submitted_at
|
|
) VALUES (
|
|
@id::uuid, @uid, @client_order_id, @alpaca_order_id, @symbol, @side,
|
|
@order_type, @notional_usd, @qty, @status, @question_id::uuid,
|
|
@rule_id, @raw::jsonb, @submitted_at
|
|
)
|
|
RETURNING id, firebase_uid, client_order_id, alpaca_order_id, symbol, side,
|
|
order_type, notional_usd, qty, status, question_id, rule_id,
|
|
submitted_at, filled_at, raw, created_at
|
|
''',
|
|
),
|
|
parameters: <String, dynamic>{
|
|
'id': id,
|
|
'uid': firebaseUid,
|
|
'client_order_id': clientOrderId,
|
|
'alpaca_order_id': alpacaOrderId,
|
|
'symbol': symbol,
|
|
'side': side,
|
|
'order_type': orderType,
|
|
'notional_usd': notionalUsd,
|
|
'qty': qty,
|
|
'status': status,
|
|
'question_id': questionId,
|
|
'rule_id': ruleId,
|
|
'raw': raw == null ? null : jsonEncode(raw),
|
|
'submitted_at': DateTime.now().toUtc(),
|
|
},
|
|
);
|
|
return _rowToOrder(result.first);
|
|
} on ServerException catch (e) {
|
|
if (e.code == '23505') {
|
|
final TradeOrder? raced = await findByClientOrderId(clientOrderId);
|
|
if (raced != null) {
|
|
return raced;
|
|
}
|
|
}
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
TradeOrder _rowToOrder(ResultRow row) {
|
|
final Object idValue = row[0]!;
|
|
final String id = idValue is String ? idValue : idValue.toString();
|
|
final Object? questionRaw = row[10];
|
|
final String? questionId =
|
|
questionRaw == null ? null : questionRaw.toString();
|
|
|
|
final Object? rawValue = row[14];
|
|
Map<String, dynamic>? raw;
|
|
if (rawValue is Map<String, dynamic>) {
|
|
raw = rawValue;
|
|
} else if (rawValue != null) {
|
|
raw = jsonDecode(rawValue.toString()) as Map<String, dynamic>;
|
|
}
|
|
|
|
return TradeOrder(
|
|
id: id,
|
|
firebaseUid: row[1]! as String,
|
|
clientOrderId: row[2]! as String,
|
|
alpacaOrderId: row[3] as String?,
|
|
symbol: row[4]! as String,
|
|
side: row[5]! as String,
|
|
orderType: row[6]! as String,
|
|
notionalUsd: _readOptionalNumeric(row[7]),
|
|
qty: _readOptionalNumeric(row[8]),
|
|
status: row[9]! as String,
|
|
questionId: questionId,
|
|
ruleId: row[11] as String?,
|
|
submittedAt: row[12] as DateTime?,
|
|
filledAt: row[13] as DateTime?,
|
|
raw: raw,
|
|
createdAt: row[15] as DateTime?,
|
|
);
|
|
}
|
|
|
|
static num? _readOptionalNumeric(Object? value) {
|
|
if (value == null) {
|
|
return null;
|
|
}
|
|
if (value is num) {
|
|
return value;
|
|
}
|
|
if (value is String) {
|
|
return num.parse(value);
|
|
}
|
|
return num.parse(value.toString());
|
|
}
|
|
}
|