393 lines
13 KiB
Dart
393 lines
13 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:postgres/postgres.dart';
|
|
|
|
import 'market_history_session_slot.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 num scoreDelta,
|
|
required String symbol,
|
|
required DateTime at,
|
|
required bool directionCorrect,
|
|
}) 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 num total = (prior['total'] as num? ?? 0) + scoreDelta;
|
|
final int answersTotal =
|
|
((prior['answers_total'] as num?)?.toInt() ?? 0) + 1;
|
|
final int answersCorrect =
|
|
((prior['answers_correct'] as num?)?.toInt() ?? 0) +
|
|
(directionCorrect ? 1 : 0);
|
|
final Object? slotWire = prior['slot_start'];
|
|
context[guessScoreContextKey] = <String, dynamic>{
|
|
'total': total,
|
|
'answers_total': answersTotal,
|
|
'answers_correct': answersCorrect,
|
|
if (slotWire != null) 'slot_start': slotWire,
|
|
'last': <String, dynamic>{
|
|
'score_delta': scoreDelta,
|
|
'direction_correct': directionCorrect,
|
|
'symbol': symbol,
|
|
'at': at.toUtc().toIso8601String(),
|
|
},
|
|
};
|
|
await _writeContext(firebaseUid, context, touchEvalAt: true);
|
|
}
|
|
|
|
Future<DateTime?> getGuessSlotStart(String firebaseUid) async {
|
|
final Map<String, dynamic>? score = await getGuessScore(firebaseUid);
|
|
if (score == null) {
|
|
return null;
|
|
}
|
|
return MarketHistorySessionSlot.parseWire(score['slot_start'] as String?);
|
|
}
|
|
|
|
/// Initializes [defaultSlot] when missing; returns the active older-slot edge.
|
|
Future<DateTime> ensureGuessSlotStart(
|
|
String firebaseUid, {
|
|
required DateTime defaultSlot,
|
|
}) async {
|
|
final DateTime? existing = await getGuessSlotStart(firebaseUid);
|
|
if (existing != null) {
|
|
return existing;
|
|
}
|
|
final DateTime slot =
|
|
MarketHistorySessionSlot.slotStartContaining(defaultSlot.toUtc());
|
|
await setGuessSlotStart(firebaseUid, slot);
|
|
return slot;
|
|
}
|
|
|
|
Future<void> setGuessSlotStart(String firebaseUid, DateTime slotStart) 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>{},
|
|
);
|
|
prior['slot_start'] = MarketHistorySessionSlot.slotStartWire(slotStart);
|
|
context[guessScoreContextKey] = prior;
|
|
await _writeContext(firebaseUid, context, touchEvalAt: false);
|
|
}
|
|
|
|
/// Clears cumulative guess score and answer statistics for [firebaseUid].
|
|
Future<void> resetGuessScore(
|
|
String firebaseUid, {
|
|
required DateTime slotStart,
|
|
}) async {
|
|
await ensureExists(firebaseUid);
|
|
await setGuessScore(
|
|
firebaseUid,
|
|
<String, dynamic>{
|
|
'total': 0,
|
|
'answers_total': 0,
|
|
'answers_correct': 0,
|
|
'slot_start': MarketHistorySessionSlot.slotStartWire(slotStart),
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Replaces `guess_score` in context (merges into full user context).
|
|
Future<void> setGuessScore(
|
|
String firebaseUid,
|
|
Map<String, dynamic> guessScore,
|
|
) async {
|
|
await ensureExists(firebaseUid);
|
|
final Map<String, dynamic> context = await getContext(firebaseUid);
|
|
context[guessScoreContextKey] = guessScore;
|
|
await _writeContext(firebaseUid, context, touchEvalAt: false);
|
|
}
|
|
|
|
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>;
|
|
}
|
|
}
|