227 lines
8.0 KiB
Dart
227 lines
8.0 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';
|
|
|
|
/// 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<Map<String, dynamic>?> 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<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 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 <String, dynamic>{
|
|
'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<List<QuestionAuditAsset>> _topHalfAssetsForSlotPair({
|
|
required DateTime olderSlotStart,
|
|
required DateTime newerSlotStart,
|
|
}) async {
|
|
final List<QuestionAuditAsset> assets =
|
|
await _questionAudit.assetsForSlotPair(
|
|
newerSlotStart: newerSlotStart,
|
|
olderSlotStart: olderSlotStart,
|
|
);
|
|
return questionAuditTopHalfVolumeAssets(assets);
|
|
}
|
|
|
|
/// Highest-volume symbol in [topHalf] the user has not yet been assigned.
|
|
Future<QuestionAuditAsset?> _pickNextAssetForSlotPair({
|
|
required String firebaseUid,
|
|
required DateTime olderSlotStart,
|
|
required DateTime newerSlotStart,
|
|
required List<QuestionAuditAsset> 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<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?';
|
|
}
|