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'; /// 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 one question per top-half volume asset /// ([market_history_prospective_assignments] enforces uniqueness per symbol). /// [slot_start] advances after every top-half symbol in the pair 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 no assignment exists for the pair. /// /// Returns null when caught up, when a pending assignment already exists, or /// when every top-half symbol in the current pair has been answered. Future?> pickForUser( 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; } if (await _assignmentsDb.hasPendingAssignmentForSlotPair( firebaseUid: firebaseUid, olderSlotStart: olderSlot, newerSlotStart: newerSlot, )) { return null; } final List topHalf = await _topHalfAssetsForSlotPair( olderSlotStart: olderSlot, newerSlotStart: newerSlot, ); if (topHalf.isEmpty) { olderSlot = newerSlot; await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot); continue; } final Set topSymbols = topHalf.map((QuestionAuditAsset a) => a.symbol).toSet(); final Set answeredSymbols = await _assignmentsDb.answeredSymbolsForSlotPair( firebaseUid: firebaseUid, olderSlotStart: olderSlot, newerSlotStart: newerSlot, ); if (topSymbols.every(answeredSymbols.contains)) { olderSlot = newerSlot; await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot); continue; } final QuestionAuditAsset? asset = await _pickNextAssetForSlotPair( firebaseUid: firebaseUid, olderSlotStart: olderSlot, newerSlotStart: newerSlot, topHalf: topHalf, ); if (asset != null) { await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot); final String id = await _upsertProspectiveRow( asset: asset, olderSlotStart: olderSlot, newerSlotStart: newerSlot, ); return { 'id': id, 'questionText': _questionText(asset.symbol), 'correctAnswer': asset.priceDelta, 'symbol': asset.symbol, 'olderSlotStart': olderSlot.toIso8601String(), 'newerSlotStart': newerSlot.toIso8601String(), 'priceDeltaPct': asset.priceDelta, }; } olderSlot = newerSlot; await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot); } return null; } Future> _topHalfAssetsForSlotPair({ required DateTime olderSlotStart, required DateTime newerSlotStart, }) async { final List assets = await _questionAudit.assetsForSlotPair( newerSlotStart: newerSlotStart, olderSlotStart: olderSlotStart, ); return questionAuditTopHalfVolumeAssets(assets); } /// Highest-volume symbol in [topHalf] the user has not yet been assigned. Future _pickNextAssetForSlotPair({ required String firebaseUid, required DateTime olderSlotStart, required DateTime newerSlotStart, required List topHalf, }) async { for (final QuestionAuditAsset asset in topHalf) { final bool alreadyAssigned = await _assignmentsDb.hasAssignmentForSymbolSlotPair( firebaseUid: firebaseUid, olderSlotStart: olderSlotStart, newerSlotStart: newerSlotStart, symbol: asset.symbol, ); if (!alreadyAssigned) { return asset; } } return null; } Future _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: { '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?'; }