cyberhybridhub/server/lib/trading/prospective_guess_selection.dart
2026-06-03 04:21:42 -05:00

271 lines
9.4 KiB
Dart

import 'dart:convert';
import 'package:postgres/postgres.dart';
import 'market_history_config.dart';
import 'market_history_question_audit.dart';
import 'market_history_session_slot.dart';
import 'prospective_guess_assignments_db.dart';
import 'user_trading_state_db.dart';
/// Unassigned top-half symbols for the user's active session-half slot pair.
class ProspectiveSlotBatch {
const ProspectiveSlotBatch({
required this.olderSlotStart,
required this.newerSlotStart,
required this.unassignedAssets,
});
final DateTime olderSlotStart;
final DateTime newerSlotStart;
final List<QuestionAuditAsset> unassignedAssets;
}
/// Picks prospective guess questions by session-half slot progression.
///
/// User state stores the older slot edge in `guess_score.slot_start`. Each slot
/// pair gets the full top-half volume batch at once (one question per symbol in
/// that batch). [slot_start] advances after every top-half symbol is answered.
class ProspectiveGuessSelection {
ProspectiveGuessSelection({
required Connection connection,
UserTradingStateDb? tradingStateDb,
ProspectiveGuessAssignmentsDb? assignmentsDb,
MarketHistoryQuestionAudit? questionAudit,
this.windowDays = MarketHistoryConfig.windowDays,
}) : _connection = connection,
_tradingStateDb = tradingStateDb ?? UserTradingStateDb(connection),
_assignmentsDb =
assignmentsDb ?? ProspectiveGuessAssignmentsDb(connection),
_questionAudit =
questionAudit ?? MarketHistoryQuestionAudit(connection: connection);
final Connection _connection;
final UserTradingStateDb _tradingStateDb;
final ProspectiveGuessAssignmentsDb _assignmentsDb;
final MarketHistoryQuestionAudit _questionAudit;
final int windowDays;
static DateTime earliestPlayableSlotStart(DateTime now, int windowDays) {
return MarketHistorySessionSlot.windowFirstSlotStart(
now.toUtc(),
windowDays,
);
}
/// Next symbol for [firebaseUid] when batching one question at a time.
///
/// Returns null when caught up or when the current slot pair already has
/// pending assignments.
Future<Map<String, dynamic>?> pickForUser(
String firebaseUid, {
DateTime? now,
}) async {
final ProspectiveSlotBatch? batch =
await resolveUnassignedBatch(firebaseUid, now: now);
if (batch == null || batch.unassignedAssets.isEmpty) {
return null;
}
return buildPickForAsset(
asset: batch.unassignedAssets.first,
olderSlotStart: batch.olderSlotStart,
newerSlotStart: batch.newerSlotStart,
);
}
/// All top-half symbols in the active slot pair that still need a question.
///
/// Advances [slot_start] past completed pairs. Returns null when caught up or
/// when every top-half symbol in the active pair is already assigned.
Future<ProspectiveSlotBatch?> resolveUnassignedBatch(
String firebaseUid, {
DateTime? now,
}) async {
final DateTime tick = (now ?? DateTime.now()).toUtc();
final DateTime lastCompleted =
MarketHistorySessionSlot.lastCompletedSlotStart(tick);
var olderSlot = await _tradingStateDb.ensureGuessSlotStart(
firebaseUid,
defaultSlot: earliestPlayableSlotStart(tick, windowDays),
);
olderSlot = MarketHistorySessionSlot.slotStartContaining(olderSlot);
for (var step = 0; step < 512; step++) {
final DateTime? newerSlot =
MarketHistorySessionSlot.nextSlotStart(olderSlot);
if (newerSlot == null || newerSlot.isAfter(lastCompleted)) {
return null;
}
final List<QuestionAuditAsset> topHalf =
await _topHalfAssetsForSlotPair(
olderSlotStart: olderSlot,
newerSlotStart: newerSlot,
);
if (topHalf.isEmpty) {
olderSlot = newerSlot;
await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot);
continue;
}
final Set<String> topSymbols =
topHalf.map((QuestionAuditAsset a) => a.symbol).toSet();
final Set<String> answeredSymbols =
await _assignmentsDb.answeredSymbolsForSlotPair(
firebaseUid: firebaseUid,
olderSlotStart: olderSlot,
newerSlotStart: newerSlot,
);
if (topSymbols.every(answeredSymbols.contains)) {
olderSlot = newerSlot;
await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot);
continue;
}
final List<QuestionAuditAsset> unassigned =
await _unassignedAssetsForSlotPair(
firebaseUid: firebaseUid,
olderSlotStart: olderSlot,
newerSlotStart: newerSlot,
topHalf: topHalf,
);
if (unassigned.isNotEmpty) {
await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot);
return ProspectiveSlotBatch(
olderSlotStart: olderSlot,
newerSlotStart: newerSlot,
unassignedAssets: unassigned,
);
}
if (!topSymbols.every(answeredSymbols.contains)) {
return null;
}
olderSlot = newerSlot;
await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot);
}
return null;
}
Future<Map<String, dynamic>> buildPickForAsset({
required QuestionAuditAsset asset,
required DateTime olderSlotStart,
required DateTime newerSlotStart,
}) async {
final DateTime older =
MarketHistorySessionSlot.slotStartContaining(olderSlotStart.toUtc());
final DateTime newer =
MarketHistorySessionSlot.slotStartContaining(newerSlotStart.toUtc());
final String id = await _upsertProspectiveRow(
asset: asset,
olderSlotStart: older,
newerSlotStart: newer,
);
return <String, dynamic>{
'id': id,
'questionText': _questionText(asset.symbol),
'correctAnswer': asset.priceDelta,
'symbol': asset.symbol,
'olderSlotStart': older.toIso8601String(),
'newerSlotStart': newer.toIso8601String(),
'priceDeltaPct': asset.priceDelta,
};
}
Future<List<QuestionAuditAsset>> _unassignedAssetsForSlotPair({
required String firebaseUid,
required DateTime olderSlotStart,
required DateTime newerSlotStart,
required List<QuestionAuditAsset> topHalf,
}) async {
final List<QuestionAuditAsset> unassigned = <QuestionAuditAsset>[];
for (final QuestionAuditAsset asset in topHalf) {
final bool alreadyAssigned =
await _assignmentsDb.hasAssignmentForSymbolSlotPair(
firebaseUid: firebaseUid,
olderSlotStart: olderSlotStart,
newerSlotStart: newerSlotStart,
symbol: asset.symbol,
);
if (!alreadyAssigned) {
unassigned.add(asset);
}
}
return unassigned;
}
Future<List<QuestionAuditAsset>> _topHalfAssetsForSlotPair({
required DateTime olderSlotStart,
required DateTime newerSlotStart,
}) async {
final List<QuestionAuditAsset> assets =
await _questionAudit.assetsForSlotPair(
newerSlotStart: newerSlotStart,
olderSlotStart: olderSlotStart,
);
return questionAuditTopHalfVolumeAssets(assets);
}
Future<String> _upsertProspectiveRow({
required QuestionAuditAsset asset,
required DateTime olderSlotStart,
required DateTime newerSlotStart,
}) async {
final DateTime older =
MarketHistorySessionSlot.slotStartContaining(olderSlotStart.toUtc());
final DateTime newer =
MarketHistorySessionSlot.slotStartContaining(newerSlotStart.toUtc());
final DateTime compareUntil = MarketHistorySessionSlot.endExclusive(newer);
final DateTime refreshedAt = DateTime.now().toUtc();
final Result result = await _connection.execute(
Sql.named(
'''
INSERT INTO market_history_prospective_questions (
compare_until, newer_slot_start, older_slot_start,
symbol, question_text, correct_answer,
price_delta_pct, volume_delta_pct, avg_volume_usd,
older_slot, newer_slot, refreshed_at
) VALUES (
@compare_until, @newer_slot_start, @older_slot_start,
@symbol, @question_text, @correct_answer,
@price_delta_pct, @volume_delta_pct, @avg_volume_usd,
@older_slot::jsonb, @newer_slot::jsonb, @refreshed_at
)
ON CONFLICT (symbol, older_slot_start, newer_slot_start)
DO UPDATE SET
compare_until = EXCLUDED.compare_until,
question_text = EXCLUDED.question_text,
correct_answer = EXCLUDED.correct_answer,
price_delta_pct = EXCLUDED.price_delta_pct,
volume_delta_pct = EXCLUDED.volume_delta_pct,
avg_volume_usd = EXCLUDED.avg_volume_usd,
older_slot = EXCLUDED.older_slot,
newer_slot = EXCLUDED.newer_slot,
refreshed_at = EXCLUDED.refreshed_at
RETURNING id
''',
),
parameters: <String, dynamic>{
'compare_until': compareUntil,
'newer_slot_start': newer,
'older_slot_start': older,
'symbol': asset.symbol,
'question_text': _questionText(asset.symbol),
'correct_answer': asset.priceDelta,
'price_delta_pct': asset.priceDelta,
'volume_delta_pct': asset.volumeDelta,
'avg_volume_usd': questionAuditAvgVolumeUsd(asset),
'older_slot': jsonEncode(asset.olderSlot.toJson()),
'newer_slot': jsonEncode(asset.newerSlot.toJson()),
'refreshed_at': refreshedAt,
},
);
return result.first[0].toString();
}
static String _questionText(String symbol) =>
'What was the percent price change for $symbol from the prior '
'session half to the latest?';
}