cyberhybridhub/server/lib/trading/user_trading_state_db.dart
2026-05-31 11:17:12 -05:00

318 lines
10 KiB
Dart

import 'dart:convert';
import 'package:postgres/postgres.dart';
/// Per-user trading worker cursor ([user_trading_state]).
class UserTradingStateDb {
UserTradingStateDb(this._connection);
final Connection _connection;
static const String ingestContextKey = 'ingest_last_fetch';
static const String rulesContextKey = 'rules';
static const String pendingOrdersContextKey = 'pending_orders';
static const String skippedContextKey = 'skipped';
static const String guessScoreContextKey = 'guess_score';
static const String guessSymbolCooldownContextKey = 'guess_symbol_cooldown';
Future<void> ensureExists(String firebaseUid) async {
await _connection.execute(
Sql.named(
'''
INSERT INTO user_trading_state (firebase_uid)
VALUES (@uid)
ON CONFLICT (firebase_uid) DO NOTHING
''',
),
parameters: <String, dynamic>{'uid': firebaseUid},
);
}
Future<Map<String, dynamic>> getContext(String firebaseUid) async {
await ensureExists(firebaseUid);
final Result result = await _connection.execute(
Sql.named(
'SELECT context FROM user_trading_state WHERE firebase_uid = @uid',
),
parameters: <String, dynamic>{'uid': firebaseUid},
);
if (result.isEmpty) {
return <String, dynamic>{};
}
return _readJsonMap(result.first[0]);
}
/// ISO-8601 timestamp of last successful fetch for [dataInputId], or null.
Future<DateTime?> getInputLastFetch(
String firebaseUid,
String dataInputId,
) async {
final Map<String, dynamic> context = await getContext(firebaseUid);
final Map<String, dynamic> ingest = Map<String, dynamic>.from(
context[ingestContextKey] as Map? ?? <String, dynamic>{},
);
final String? raw = ingest[dataInputId] as String?;
if (raw == null || raw.isEmpty) {
return null;
}
return DateTime.parse(raw).toUtc();
}
Future<void> recordInputFetch(
String firebaseUid,
String dataInputId,
DateTime fetchedAt,
) async {
await ensureExists(firebaseUid);
final Map<String, dynamic> context = await getContext(firebaseUid);
final Map<String, dynamic> ingest = Map<String, dynamic>.from(
context[ingestContextKey] as Map? ?? <String, dynamic>{},
);
ingest[dataInputId] = fetchedAt.toUtc().toIso8601String();
context[ingestContextKey] = ingest;
await _connection.execute(
Sql.named(
'''
UPDATE user_trading_state
SET context = @context::jsonb,
last_ingest_at = @last_ingest_at,
updated_at = now()
WHERE firebase_uid = @uid
''',
),
parameters: <String, dynamic>{
'uid': firebaseUid,
'context': jsonEncode(context),
'last_ingest_at': fetchedAt.toUtc(),
},
);
}
Future<Map<String, dynamic>?> getRuleState(
String firebaseUid,
String ruleId,
) async {
final Map<String, dynamic> context = await getContext(firebaseUid);
final Map<String, dynamic> rules = Map<String, dynamic>.from(
context[rulesContextKey] as Map? ?? <String, dynamic>{},
);
final Map? raw = rules[ruleId] as Map?;
if (raw == null) {
return null;
}
return Map<String, dynamic>.from(raw);
}
Future<DateTime?> getRuleLastFiredAt(
String firebaseUid,
String ruleId,
) async {
final Map<String, dynamic>? state = await getRuleState(firebaseUid, ruleId);
final String? raw = state?['last_fired_at'] as String?;
if (raw == null || raw.isEmpty) {
return null;
}
return DateTime.parse(raw).toUtc();
}
Future<void> setRuleState({
required String firebaseUid,
required String ruleId,
required Map<String, dynamic> state,
}) async {
await ensureExists(firebaseUid);
final Map<String, dynamic> context = await getContext(firebaseUid);
final Map<String, dynamic> rules = Map<String, dynamic>.from(
context[rulesContextKey] as Map? ?? <String, dynamic>{},
);
rules[ruleId] = state;
context[rulesContextKey] = rules;
await _writeContext(firebaseUid, context, touchEvalAt: true);
}
Future<List<Map<String, dynamic>>> listPendingOrders(
String firebaseUid,
) async {
final Map<String, dynamic> context = await getContext(firebaseUid);
final List<dynamic> raw =
context[pendingOrdersContextKey] as List<dynamic>? ?? <dynamic>[];
return raw
.whereType<Map>()
.map((Map<dynamic, dynamic> m) => Map<String, dynamic>.from(m))
.toList();
}
Future<void> addPendingOrder({
required String firebaseUid,
required Map<String, dynamic> order,
}) async {
await ensureExists(firebaseUid);
final Map<String, dynamic> context = await getContext(firebaseUid);
final List<dynamic> existing =
context[pendingOrdersContextKey] as List<dynamic>? ?? <dynamic>[];
final List<Map<String, dynamic>> orders = <Map<String, dynamic>>[
...existing.whereType<Map>().map(
(Map<dynamic, dynamic> m) => Map<String, dynamic>.from(m),
),
order,
];
context[pendingOrdersContextKey] = orders;
await _writeContext(firebaseUid, context, touchEvalAt: true);
}
Future<void> removePendingOrder({
required String firebaseUid,
required String clientOrderId,
}) async {
await ensureExists(firebaseUid);
final Map<String, dynamic> context = await getContext(firebaseUid);
final List<dynamic> existing =
context[pendingOrdersContextKey] as List<dynamic>? ?? <dynamic>[];
final List<Map<String, dynamic>> kept = existing
.whereType<Map>()
.map((Map<dynamic, dynamic> m) => Map<String, dynamic>.from(m))
.where((Map<String, dynamic> m) =>
m['client_order_id'] != clientOrderId)
.toList();
context[pendingOrdersContextKey] = kept;
await _writeContext(firebaseUid, context, touchEvalAt: false);
}
Future<Map<String, dynamic>?> getGuessScore(String firebaseUid) async {
final Map<String, dynamic> context = await getContext(firebaseUid);
final Object? raw = context[guessScoreContextKey];
if (raw is Map) {
return Map<String, dynamic>.from(raw);
}
return null;
}
Future<void> recordGuessScore({
required String firebaseUid,
required int scoreDelta,
required String symbol,
required DateTime at,
}) async {
await ensureExists(firebaseUid);
final Map<String, dynamic> context = await getContext(firebaseUid);
final Map<String, dynamic> prior = Map<String, dynamic>.from(
context[guessScoreContextKey] as Map? ?? <String, dynamic>{},
);
final int total = ((prior['total'] as num?)?.toInt() ?? 0) + scoreDelta;
context[guessScoreContextKey] = <String, dynamic>{
'total': total,
'last': <String, dynamic>{
'score_delta': scoreDelta,
'symbol': symbol,
'at': at.toUtc().toIso8601String(),
},
};
await _writeContext(firebaseUid, context, touchEvalAt: true);
}
Future<DateTime?> getGuessSymbolLastPickedAt(
String firebaseUid,
String symbol,
) async {
final Map<String, dynamic> context = await getContext(firebaseUid);
final Map<String, dynamic> cooldown = Map<String, dynamic>.from(
context[guessSymbolCooldownContextKey] as Map? ?? <String, dynamic>{},
);
final String? raw = cooldown[symbol] as String?;
if (raw == null || raw.isEmpty) {
return null;
}
return DateTime.parse(raw).toUtc();
}
Future<bool> isGuessSymbolOnCooldown({
required String firebaseUid,
required String symbol,
required DateTime now,
int cooldownHours = 24,
}) async {
final DateTime? last =
await getGuessSymbolLastPickedAt(firebaseUid, symbol);
if (last == null) {
return false;
}
return now.difference(last) < Duration(hours: cooldownHours);
}
Future<void> recordGuessSymbolPicked({
required String firebaseUid,
required String symbol,
required DateTime at,
}) async {
await ensureExists(firebaseUid);
final Map<String, dynamic> context = await getContext(firebaseUid);
final Map<String, dynamic> cooldown = Map<String, dynamic>.from(
context[guessSymbolCooldownContextKey] as Map? ?? <String, dynamic>{},
);
cooldown[symbol] = at.toUtc().toIso8601String();
context[guessSymbolCooldownContextKey] = cooldown;
await _writeContext(firebaseUid, context, touchEvalAt: true);
}
Future<void> recordSkip({
required String firebaseUid,
required String ruleId,
required String questionId,
required DateTime at,
}) async {
await ensureExists(firebaseUid);
final Map<String, dynamic> context = await getContext(firebaseUid);
final List<dynamic> existing =
context[skippedContextKey] as List<dynamic>? ?? <dynamic>[];
final List<Map<String, dynamic>> skipped = <Map<String, dynamic>>[
...existing.whereType<Map>().map(
(Map<dynamic, dynamic> m) => Map<String, dynamic>.from(m),
),
<String, dynamic>{
'rule_id': ruleId,
'question_id': questionId,
'at': at.toUtc().toIso8601String(),
},
];
context[skippedContextKey] = skipped;
await _writeContext(firebaseUid, context, touchEvalAt: false);
}
Future<void> _writeContext(
String firebaseUid,
Map<String, dynamic> context, {
required bool touchEvalAt,
}) async {
final String setEval = touchEvalAt ? ', last_eval_at = now()' : '';
await _connection.execute(
Sql.named(
'''
UPDATE user_trading_state
SET context = @context::jsonb,
updated_at = now()
$setEval
WHERE firebase_uid = @uid
''',
),
parameters: <String, dynamic>{
'uid': firebaseUid,
'context': jsonEncode(context),
},
);
}
Map<String, dynamic> _readJsonMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return Map<String, dynamic>.from(value);
}
if (value == null) {
return <String, dynamic>{};
}
return jsonDecode(value.toString()) as Map<String, dynamic>;
}
}