cyberhybridhub/server/lib/trading/trade_orders_db.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());
}
}