diff --git a/lib/admin/models/question_audit_asset.dart b/lib/admin/models/question_audit_asset.dart index f767b3e..d15e3ab 100644 --- a/lib/admin/models/question_audit_asset.dart +++ b/lib/admin/models/question_audit_asset.dart @@ -3,6 +3,7 @@ class QuestionAuditBarSlot { required this.asOf, required this.avgPrice, required this.volume, + required this.volumeUsd, this.open, this.high, this.low, @@ -16,16 +17,20 @@ class QuestionAuditBarSlot { final num? close; final num avgPrice; final num volume; + final num volumeUsd; factory QuestionAuditBarSlot.fromJson(Map json) { + final num avgPrice = json['avgPrice'] as num; + final num volume = json['volume'] as num; return QuestionAuditBarSlot( asOf: DateTime.parse(json['asOf']! as String).toUtc(), open: json['open'] as num?, high: json['high'] as num?, low: json['low'] as num?, close: json['close'] as num?, - avgPrice: json['avgPrice'] as num, - volume: json['volume'] as num, + avgPrice: avgPrice, + volume: volume, + volumeUsd: json['volumeUsd'] as num? ?? volume * avgPrice, ); } } diff --git a/lib/admin/widgets/market_history_question_audit_sheet.dart b/lib/admin/widgets/market_history_question_audit_sheet.dart index 421a353..b9507b7 100644 --- a/lib/admin/widgets/market_history_question_audit_sheet.dart +++ b/lib/admin/widgets/market_history_question_audit_sheet.dart @@ -366,7 +366,7 @@ class _AssetTileState extends State<_AssetTile> { mainAxisSize: MainAxisSize.min, children: [ Text( - 'P:${_AuditFormat.delta(asset.priceDelta)}', + 'P:${_AuditFormat.percent(asset.priceDelta)}', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, @@ -375,7 +375,7 @@ class _AssetTileState extends State<_AssetTile> { ), const SizedBox(height: 2), Text( - 'V:${_AuditFormat.delta(asset.volumeDelta)}', + 'V:${_AuditFormat.percent(asset.volumeDelta)}', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, @@ -555,16 +555,14 @@ abstract final class _AuditFormat { return AppColors.textPrimary; } - static String delta(num value) { - final num rounded = value.abs() >= 1000 - ? (value * 100).round() / 100 - : (value * 10000).round() / 10000; + static String percent(num value) { + final num rounded = (value * 100).round() / 100; final String text = rounded == rounded.roundToDouble() ? rounded.round().toString() : rounded.toStringAsFixed(2); if (value > 0) { - return '+$text'; + return '+$text%'; } - return text; + return '$text%'; } } diff --git a/lib/guid/guid_glyph_shape.dart b/lib/guid/guid_glyph_shape.dart index 44dc8a5..3295eb2 100644 --- a/lib/guid/guid_glyph_shape.dart +++ b/lib/guid/guid_glyph_shape.dart @@ -3,6 +3,8 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + /// Deterministic irregular polygon + palette derived from a question UUID. /// /// Parses the canonical 16-byte UUID (hyphens optional) and maps byte pairs to @@ -83,25 +85,53 @@ class GuidGlyphShape { return out; } - /// Display-only fill; base identity color shifts with slider value. + /// True when the slider is at neutral zero (no directional guess). + static bool isNeutralZero(num displayValue) => displayValue == 0; + + /// 0 at zero, 1 at ±10. + static double displayIntensity(num displayValue) => + isNeutralZero(displayValue) ? 0 : (displayValue.abs() / 10).clamp(0.0, 1.0); + + static const Color _negativeAccent = Color(0xFFF87171); + static const Color _neutralBlend = Color(0xFF64748B); + + /// Progressive green (+) / red (−) fill; crystal white at zero. Color displayFillColor(num displayValue) { - final double t = displayT(displayValue); - final HSLColor hsl = HSLColor.fromColor(fillColor()); - final double hueShift = t * (12 + (bytes[7] % 20)); - final double lightness = (hsl.lightness + t * 0.12).clamp(0.35, 0.68); - final double saturation = - (hsl.saturation + t.abs() * 0.1).clamp(0.5, 0.85); - return hsl - .withHue((hsl.hue + hueShift) % 360) - .withLightness(lightness) - .withSaturation(saturation) - .toColor(); + if (isNeutralZero(displayValue)) { + return const Color(0xFFF8FCFF); + } + final double intensity = displayIntensity(displayValue); + final Color target = + displayValue > 0 ? AppColors.success : _negativeAccent; + return Color.lerp(fillColor(), target, 0.25 + intensity * 0.75)!; } - Color displayStrokeColor(num displayValue) => - HSLColor.fromColor(displayFillColor(displayValue)) - .withLightness(0.38) - .toColor(); + Color displayStrokeColor(num displayValue) { + if (isNeutralZero(displayValue)) { + return const Color(0xFFE2E8F0); + } + final double intensity = displayIntensity(displayValue); + final Color target = + displayValue > 0 ? AppColors.success : _negativeAccent; + return Color.lerp(_neutralBlend, target, 0.35 + intensity * 0.65)!; + } + + /// Furthest unit-circle distance after [displayUnitVertices] warp (for glow sizing). + double displayMaxUnitRadius(num displayValue) { + double maxR = 0; + for (final Offset p in displayUnitVertices(displayValue)) { + maxR = math.max(maxR, p.distance); + } + return maxR > 0 ? maxR : 0.5; + } + + /// Glow color for shadows (crystal white at zero, green +, red −). + Color displayGlowColor(num displayValue) { + if (isNeutralZero(displayValue)) { + return const Color(0xFFF8FCFF); + } + return displayValue > 0 ? AppColors.success : _negativeAccent; + } double displayStrokeWidth(num displayValue) { final double t = displayT(displayValue); @@ -154,28 +184,48 @@ class QuestionGuidGlyph extends StatelessWidget { @override Widget build(BuildContext context) { final GuidGlyphShape shape = GuidGlyphShape.fromGuid(guid); - final Color glow = shape.displayFillColor(displayValue); + final double intensity = GuidGlyphShape.displayIntensity(displayValue); + final Color glow = shape.displayGlowColor(displayValue); + final double strokeWidth = shape.displayStrokeWidth(displayValue); + final double baseRadius = (size / 2 - strokeWidth - 2).clamp(18.0, size / 2); + // Slightly larger bounds when warped / off-zero so the halo follows the shape. + final double paintedRadius = baseRadius * (1 + intensity * 0.1); + + // Shadow box matches painted extent; blur/spread grow with that diameter. + final double bodyDiameter = paintedRadius * 2; + final double blur = bodyDiameter * (0.2 + intensity * 0.16); + final double spread = bodyDiameter * (0.03 + intensity * 0.06); + final double glowAlpha = 0.38 + intensity * 0.42; return SizedBox( width: size, height: size, - child: DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: glow.withValues( - alpha: 0.32 + GuidGlyphShape.displayT(displayValue).abs() * 0.18, + child: Center( + child: Container( + width: bodyDiameter, + height: bodyDiameter, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.transparent, + boxShadow: [ + BoxShadow( + color: glow.withValues(alpha: glowAlpha), + blurRadius: blur, + spreadRadius: spread * 0.35, ), - blurRadius: 16 + GuidGlyphShape.displayT(displayValue).abs() * 6, - spreadRadius: 2, + BoxShadow( + color: glow.withValues(alpha: glowAlpha * 0.45), + blurRadius: blur * 1.75, + spreadRadius: spread, + ), + ], + ), + child: CustomPaint( + painter: _GuidGlyphPainter( + shape: shape, + displayValue: displayValue, + paintedRadius: paintedRadius, ), - ], - ), - child: CustomPaint( - painter: _GuidGlyphPainter( - shape: shape, - displayValue: displayValue, ), ), ), @@ -187,15 +237,19 @@ class _GuidGlyphPainter extends CustomPainter { _GuidGlyphPainter({ required this.shape, required this.displayValue, + required this.paintedRadius, }); final GuidGlyphShape shape; final num displayValue; + final double paintedRadius; @override void paint(Canvas canvas, Size size) { final Offset center = Offset(size.width / 2, size.height / 2); - final double scale = size.shortestSide * 0.42; + final double maxUnitR = shape.displayMaxUnitRadius(displayValue); + final double scale = paintedRadius / maxUnitR; + final double strokeWidth = shape.displayStrokeWidth(displayValue); final List unit = shape.displayUnitVertices(displayValue); final Path path = Path(); @@ -211,7 +265,6 @@ class _GuidGlyphPainter extends CustomPainter { final Color fill = shape.displayFillColor(displayValue); final Color stroke = shape.displayStrokeColor(displayValue); - final double strokeWidth = shape.displayStrokeWidth(displayValue); canvas.drawPath( path, @@ -232,5 +285,6 @@ class _GuidGlyphPainter extends CustomPainter { @override bool shouldRepaint(covariant _GuidGlyphPainter oldDelegate) => oldDelegate.shape.bytes != shape.bytes || - oldDelegate.displayValue != displayValue; + oldDelegate.displayValue != displayValue || + oldDelegate.paintedRadius != paintedRadius; } diff --git a/lib/models/guess_score_summary.dart b/lib/models/guess_score_summary.dart new file mode 100644 index 0000000..747d664 --- /dev/null +++ b/lib/models/guess_score_summary.dart @@ -0,0 +1,52 @@ +/// Cumulative guess / prospective-question score from the API. +class GuessScoreSummary { + const GuessScoreSummary({ + required this.total, + required this.answersTotal, + required this.answersCorrect, + required this.percentCorrect, + this.slotStart, + this.newerSlotStart, + }); + + final num total; + final int answersTotal; + final int answersCorrect; + final num percentCorrect; + + /// Older edge of the active session-half pair in the history window. + final DateTime? slotStart; + + /// Newer edge of the active pair (next session half after [slotStart]). + final DateTime? newerSlotStart; + + static const GuessScoreSummary empty = GuessScoreSummary( + total: 0, + answersTotal: 0, + answersCorrect: 0, + percentCorrect: 0, + ); + + factory GuessScoreSummary.fromJson(Map json) { + final int answersTotal = (json['answersTotal'] as num?)?.toInt() ?? 0; + final int answersCorrect = (json['answersCorrect'] as num?)?.toInt() ?? 0; + final num? percentRaw = json['percentCorrect'] as num?; + final num percentCorrect = percentRaw ?? + (answersTotal > 0 ? (answersCorrect / answersTotal) * 100 : 0); + return GuessScoreSummary( + total: json['total'] as num? ?? 0, + answersTotal: answersTotal, + answersCorrect: answersCorrect, + percentCorrect: percentCorrect, + slotStart: _parseOptionalUtc(json['slotStart'] as String?), + newerSlotStart: _parseOptionalUtc(json['newerSlotStart'] as String?), + ); + } + + static DateTime? _parseOptionalUtc(String? wire) { + if (wire == null || wire.isEmpty) { + return null; + } + return DateTime.tryParse(wire)?.toUtc(); + } +} diff --git a/lib/models/question_submit_result.dart b/lib/models/question_submit_result.dart new file mode 100644 index 0000000..8286598 --- /dev/null +++ b/lib/models/question_submit_result.dart @@ -0,0 +1,15 @@ +import 'guess_score_summary.dart'; +import 'incoming_question.dart'; + +/// Outcome of submitting an answer to the questions API. +class QuestionSubmitResult { + const QuestionSubmitResult({ + required this.unansweredCount, + required this.score, + this.nextQuestion, + }); + + final int unansweredCount; + final GuessScoreSummary score; + final IncomingQuestion? nextQuestion; +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 6b7f696..0cb4d6d 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import '../admin/widgets/admin_app_bar_action.dart'; import '../models/app_user.dart'; +import '../models/guess_score_summary.dart'; import '../models/incoming_question.dart'; +import '../utils/guess_slot_format.dart'; import '../models/sync_result.dart'; import '../models/user_profile.dart'; import '../repositories/user_profile_repository.dart'; @@ -47,20 +49,31 @@ class HomeScreen extends StatelessWidget { QuestionsHubService.instance.hasPendingQuestion, QuestionsHubService.instance.pendingQuestion, QuestionsHubService.instance.pendingQuestionCount, + QuestionsHubService.instance.guessScoreSummary, ]), builder: (BuildContext context, Widget? child) { final int count = QuestionsHubService.instance.pendingQuestionCount.value; final bool hasPending = QuestionsHubService.instance.hasPendingQuestion.value; - if (!hasPending || count < 1) { - return const SizedBox.shrink(); - } - final int displayCount = count; - return _QuestionEnvelopeButton( - count: displayCount, - onPressed: () => - QuestionsHubService.instance.openQuestionPanel(), + final GuessScoreSummary score = + QuestionsHubService.instance.guessScoreSummary.value; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _CumulativeScoreChip( + summary: score, + onPressed: () => _showGuessScoreStatsDialog(context, score), + ), + if (hasPending && count >= 1) ...[ + const SizedBox(width: 8), + _QuestionEnvelopeButton( + count: count, + onPressed: () => + QuestionsHubService.instance.openQuestionPanel(), + ), + ], + ], ); }, ), @@ -305,6 +318,173 @@ class _QuestionEnvelopeButton extends StatelessWidget { } } +void _showGuessScoreStatsDialog(BuildContext context, GuessScoreSummary summary) { + final String totalText = _formatScoreNumber(summary.total); + final String percentText = _formatScoreNumber(summary.percentCorrect); + final String? slotText = summary.slotStart == null + ? null + : formatGuessSlotRange( + slotStart: summary.slotStart!, + newerSlotStart: summary.newerSlotStart, + ); + + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + key: const Key('guess-score-stats-dialog'), + title: const Text('Score statistics'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (slotText != null) + _ScoreStatRow( + key: const Key('guess-score-slot-row'), + label: 'Time slot', + value: slotText, + ), + _ScoreStatRow(label: 'Total score', value: totalText), + _ScoreStatRow( + label: 'Questions answered', + value: '${summary.answersTotal}', + ), + _ScoreStatRow(label: 'Percent correct', value: '$percentText%'), + ], + ), + actions: [ + TextButton( + key: const Key('guess-score-reset-button'), + onPressed: () => _confirmResetGuessScore(dialogContext), + child: const Text('Reset'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); +} + +String _formatScoreNumber(num value) { + return value == value.roundToDouble() + ? value.toStringAsFixed(0) + : value.toStringAsFixed(2); +} + +Future _confirmResetGuessScore(BuildContext context) async { + final bool? confirmed = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + key: const Key('guess-score-reset-confirm-dialog'), + title: const Text('Reset score?'), + content: const Text( + 'This clears your total score and answer statistics. ' + 'You cannot undo this.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + key: const Key('guess-score-reset-confirm-button'), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Reset'), + ), + ], + ); + }, + ); + if (confirmed != true || !context.mounted) { + return; + } + + final bool ok = + await QuestionsHubService.instance.resetGuessScoreSummary(); + if (!context.mounted) { + return; + } + if (ok) { + Navigator.of(context).pop(); + } +} + +class _ScoreStatRow extends StatelessWidget { + const _ScoreStatRow({ + super.key, + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: Theme.of(context).textTheme.bodyLarge), + Text( + value, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w700, + color: AppColors.accent, + ), + ), + ], + ), + ); + } +} + +class _CumulativeScoreChip extends StatelessWidget { + const _CumulativeScoreChip({ + required this.summary, + required this.onPressed, + }); + + final GuessScoreSummary summary; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final String scoreText = summary.total == summary.total.roundToDouble() + ? summary.total.toStringAsFixed(0) + : summary.total.toStringAsFixed(2); + return Material( + color: Colors.transparent, + child: InkWell( + key: const Key('topbar-cumulative-score'), + onTap: onPressed, + borderRadius: BorderRadius.circular(999), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppColors.accent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: AppColors.accent.withValues(alpha: 0.35)), + ), + child: Text( + 'Score: $scoreText', + style: const TextStyle( + color: AppColors.accent, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ), + ), + ); + } +} + class _SyncStatusChip extends StatelessWidget { const _SyncStatusChip({required this.status}); diff --git a/lib/services/questions_api_service.dart b/lib/services/questions_api_service.dart index 535cb56..b219989 100644 --- a/lib/services/questions_api_service.dart +++ b/lib/services/questions_api_service.dart @@ -4,7 +4,9 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../config/api_config.dart'; +import '../models/guess_score_summary.dart'; import '../models/incoming_question.dart'; +import '../models/question_submit_result.dart'; import 'auth_service.dart'; /// HTTP client for question queue, answers, and deferrals. @@ -13,11 +15,16 @@ class QuestionsApiService { final http.Client _client; - /// Ensures a starter question exists for the signed-in user (login only). - Future bootstrapOnLogin() async { + /// Ensures login question state is initialized for the signed-in user. + /// + /// Returns the first unanswered question when present and always attempts to + /// return persisted [GuessScoreSummary] for this Firebase user when the API + /// includes it in the bootstrap payload. + Future<({IncomingQuestion? question, GuessScoreSummary? score})> + bootstrapOnLogin() async { final String? token = await AuthService.instance.getIdToken(); if (token == null) { - return null; + return (question: null, score: null); } final http.Response response = await _client.post( @@ -28,17 +35,23 @@ class QuestionsApiService { debugPrint( 'bootstrapOnLogin failed: ${response.statusCode} ${response.body}', ); - return null; + return (question: null, score: null); } final Map body = jsonDecode(response.body) as Map; final Map? questionJson = body['question'] as Map?; - if (questionJson == null) { - return null; - } - return IncomingQuestion.fromJson(questionJson); + final Map? scoreJson = + body['score'] as Map?; + return ( + question: questionJson == null + ? null + : IncomingQuestion.fromJson(questionJson), + score: scoreJson == null + ? null + : GuessScoreSummary.fromJson(scoreJson), + ); } Future> fetchUnanswered() async { @@ -69,7 +82,7 @@ class QuestionsApiService { .toList(); } - Future submitAnswer({ + Future submitAnswer({ required String questionId, num answer = 0, }) async { @@ -92,7 +105,19 @@ class QuestionsApiService { final Map body = jsonDecode(response.body) as Map; - return (body['unansweredCount'] as num?)?.toInt(); + final Map? scoreJson = + body['score'] as Map?; + final Map? nextQuestionJson = + body['nextQuestion'] as Map?; + return QuestionSubmitResult( + unansweredCount: (body['unansweredCount'] as num?)?.toInt() ?? 0, + score: scoreJson == null + ? GuessScoreSummary.empty + : GuessScoreSummary.fromJson(scoreJson), + nextQuestion: nextQuestionJson == null + ? null + : IncomingQuestion.fromJson(nextQuestionJson), + ); } Future deferQuestion({required String questionId}) async { @@ -117,6 +142,60 @@ class QuestionsApiService { return (body['unansweredCount'] as num?)?.toInt(); } + Future fetchGuessScoreSummary() async { + final String? token = await AuthService.instance.getIdToken(); + if (token == null) { + return null; + } + + final http.Response response = await _client.get( + Uri.parse('$apiBaseUrl/v1/me/questions/score'), + headers: _authHeaders(token), + ); + if (response.statusCode != 200) { + debugPrint( + 'fetchGuessScoreSummary failed: ${response.statusCode} ${response.body}', + ); + return null; + } + + final Map body = + jsonDecode(response.body) as Map; + final Map? scoreJson = + body['score'] as Map?; + if (scoreJson == null) { + return GuessScoreSummary.empty; + } + return GuessScoreSummary.fromJson(scoreJson); + } + + Future resetGuessScoreSummary() async { + final String? token = await AuthService.instance.getIdToken(); + if (token == null) { + return null; + } + + final http.Response response = await _client.post( + Uri.parse('$apiBaseUrl/v1/me/questions/score/reset'), + headers: _authHeaders(token), + ); + if (response.statusCode != 200) { + debugPrint( + 'resetGuessScoreSummary failed: ${response.statusCode} ${response.body}', + ); + return null; + } + + final Map body = + jsonDecode(response.body) as Map; + final Map? scoreJson = + body['score'] as Map?; + if (scoreJson == null) { + return GuessScoreSummary.empty; + } + return GuessScoreSummary.fromJson(scoreJson); + } + Map _authHeaders(String token) { return { 'Authorization': 'Bearer $token', diff --git a/lib/services/questions_hub_service.dart b/lib/services/questions_hub_service.dart index 7d6b9c3..0d7fd29 100644 --- a/lib/services/questions_hub_service.dart +++ b/lib/services/questions_hub_service.dart @@ -4,7 +4,9 @@ import 'package:flutter/foundation.dart'; import 'package:signalr_netcore/signalr_client.dart'; import '../config/api_config.dart'; +import '../models/guess_score_summary.dart'; import '../models/incoming_question.dart'; +import '../models/question_submit_result.dart'; import 'auth_service.dart'; import 'questions_api_service.dart'; @@ -24,23 +26,39 @@ class QuestionsHubService { final ValueNotifier> questionQueue = ValueNotifier>([]); final ValueNotifier questionActionBusy = ValueNotifier(false); + final ValueNotifier guessScoreSummary = + ValueNotifier(GuessScoreSummary.empty); HubConnection? _connection; bool _connecting = false; IncomingQuestion? get currentQuestion { + final IncomingQuestion? pending = pendingQuestion.value; final List queue = questionQueue.value; + // After an answer, [pendingQuestion] advances but the old card can remain at + // queue[0] until the queue is replaced — prefer the active pending target. + if (pending != null && + (queue.isEmpty || queue.first.id != pending.id)) { + return pending; + } if (queue.isNotEmpty) { return queue.first; } - return pendingQuestion.value; + return pending; } - /// Login hook: create starter question if needed, then open SignalR. + /// Login hook: load persisted score, bootstrap question state, then SignalR. Future onLogin() async { - final IncomingQuestion? bootstrapped = await _api.bootstrapOnLogin(); - if (bootstrapped != null) { - _applyIncoming(bootstrapped); + await _refreshGuessScoreSummary(); + final ({IncomingQuestion? question, GuessScoreSummary? score}) boot = + await _api.bootstrapOnLogin(); + if (boot.score != null) { + guessScoreSummary.value = boot.score!; + } else { + await _refreshGuessScoreSummary(); + } + if (boot.question != null) { + _applyIncoming(boot.question!); } await connect(); } @@ -180,43 +198,91 @@ class QuestionsHubService { } } - /// Swipe right: submit default answer (0). + /// Swipe right: submit slider value as the answer. Future submitCurrentAnswer({num answer = 0}) async { final IncomingQuestion? question = currentQuestion; if (question == null || questionActionBusy.value) { return; } + final String answeredQuestionId = question.id; questionActionBusy.value = true; try { - final int? serverCount = await _api.submitAnswer( - questionId: question.id, + final QuestionSubmitResult? result = await _api.submitAnswer( + questionId: answeredQuestionId, answer: answer, ); - if (serverCount == null) { + if (result == null) { return; } - if (serverCount == 0) { + guessScoreSummary.value = result.score; + + if (result.nextQuestion != null) { + _setActiveQuestion( + result.nextQuestion!, + unansweredCount: result.unansweredCount, + ); + return; + } + + if (result.unansweredCount == 0) { _clearPendingUi(); return; } final List refreshed = await _api.fetchUnanswered(); - if (refreshed.isEmpty) { + final List remaining = refreshed + .where((IncomingQuestion q) => q.id != answeredQuestionId) + .toList(); + if (remaining.isEmpty) { _clearPendingUi(); return; } - questionQueue.value = refreshed; - pendingQuestion.value = refreshed.first; - pendingQuestionCount.value = serverCount; - hasPendingQuestion.value = true; + _setActiveQuestion( + remaining.first, + unansweredCount: result.unansweredCount, + queue: remaining, + ); } finally { questionActionBusy.value = false; } } + /// Makes [question] the sole active card (queue head matches [pendingQuestion]). + void _setActiveQuestion( + IncomingQuestion question, { + required int unansweredCount, + List? queue, + }) { + final int count = unansweredCount < 1 ? 1 : unansweredCount; + pendingQuestion.value = question; + pendingQuestionCount.value = count; + hasPendingQuestion.value = true; + if (questionPanelOpen.value) { + questionQueue.value = queue ?? [question]; + } + } + + Future _refreshGuessScoreSummary() async { + final GuessScoreSummary? score = await _api.fetchGuessScoreSummary(); + if (score == null) { + return; + } + guessScoreSummary.value = score; + } + + /// Clears cumulative guess score and answer statistics on the server. + Future resetGuessScoreSummary() async { + final GuessScoreSummary? score = await _api.resetGuessScoreSummary(); + if (score == null) { + return false; + } + guessScoreSummary.value = score; + return true; + } + void _syncPendingFromQueue(int count) { final List queue = questionQueue.value; if (queue.isEmpty || count == 0) { @@ -253,4 +319,9 @@ class QuestionsHubService { } _clearPendingUi(); } + + /// Clears in-memory score (call on sign-out; score remains on server per UID). + void clearGuessScoreCache() { + guessScoreSummary.value = GuessScoreSummary.empty; + } } diff --git a/lib/utils/guess_slot_format.dart b/lib/utils/guess_slot_format.dart new file mode 100644 index 0000000..754e585 --- /dev/null +++ b/lib/utils/guess_slot_format.dart @@ -0,0 +1,21 @@ +/// Labels for market-history session-half slot instants (UTC wire times). +String formatGuessSlotInstant(DateTime slotStart) { + final DateTime utc = slotStart.toUtc(); + final String month = utc.month.toString().padLeft(2, '0'); + final String day = utc.day.toString().padLeft(2, '0'); + final String hour = utc.hour.toString().padLeft(2, '0'); + final String minute = utc.minute.toString().padLeft(2, '0'); + return '$month/$day $hour:$minute'; +} + +/// Active guess pair: older session half → next session half. +String formatGuessSlotRange({ + required DateTime slotStart, + DateTime? newerSlotStart, +}) { + if (newerSlotStart == null) { + return '${formatGuessSlotInstant(slotStart)} UTC'; + } + return '${formatGuessSlotInstant(slotStart)} – ' + '${formatGuessSlotInstant(newerSlotStart)} UTC'; +} diff --git a/lib/widgets/profile_session.dart b/lib/widgets/profile_session.dart index 9c08195..c3581e0 100644 --- a/lib/widgets/profile_session.dart +++ b/lib/widgets/profile_session.dart @@ -51,6 +51,7 @@ class _ProfileSessionState extends State { @override void dispose() { _syncStatusSubscription?.cancel(); + QuestionsHubService.instance.clearGuessScoreCache(); unawaited(QuestionsHubService.instance.disconnect()); UserProfileRepository.instance.endSession(); super.dispose(); diff --git a/lib/widgets/swipe_question_tile.dart b/lib/widgets/swipe_question_tile.dart index b1b0be9..31e8330 100644 --- a/lib/widgets/swipe_question_tile.dart +++ b/lib/widgets/swipe_question_tile.dart @@ -45,6 +45,10 @@ class _SwipeQuestionTileState extends State /// Updated each build from the tile height so ±10 reaches near the track edges. double _maxVerticalDrag = 120; + bool get _atZero => _snappedSliderValue == 0; + + bool get _horizontalSwipeEnabled => !widget.busy && !_acting && !_atZero; + @override void initState() { super.initState(); @@ -90,7 +94,7 @@ class _SwipeQuestionTileState extends State } Future _releaseDrag() async { - if (_acting || widget.busy) { + if (_acting || widget.busy || _atZero) { setState(() => _dragOffset = 0); return; } @@ -121,11 +125,9 @@ class _SwipeQuestionTileState extends State @override Widget build(BuildContext context) { final double width = MediaQuery.sizeOf(context).width; - final double progress = (_dragOffset / _swipeThreshold).clamp(-1.0, 1.0); return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { - // Container vertical padding (24×2) + track insets (8×2). const double outerVerticalPadding = 48; const double trackVerticalInset = 16; final double innerHeight = @@ -139,27 +141,8 @@ class _SwipeQuestionTileState extends State return Stack( alignment: Alignment.center, + clipBehavior: Clip.none, children: [ - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Colors.redAccent.withValues( - alpha: 0.15 + 0.35 * (-progress).clamp(0.0, 1.0), - ), - AppColors.surfaceElevated, - AppColors.success.withValues( - alpha: 0.15 + 0.35 * progress.clamp(0.0, 1.0), - ), - ], - ), - ), - ), - ), Transform.translate( offset: Offset(_dragOffset, 0), child: AnimatedBuilder( @@ -176,86 +159,98 @@ class _SwipeQuestionTileState extends State ); }, child: GestureDetector( - onHorizontalDragUpdate: widget.busy || _acting - ? null - : (DragUpdateDetails details) { + onHorizontalDragUpdate: _horizontalSwipeEnabled + ? (DragUpdateDetails details) { setState(() { _dragOffset += details.delta.dx; _dragOffset = _dragOffset.clamp(-width * 0.55, width * 0.55); }); - }, - onHorizontalDragEnd: widget.busy || _acting - ? null - : (_) => unawaited(_releaseDrag()), - child: Material( - color: AppColors.surfaceElevated, - elevation: 4, - shadowColor: Colors.black45, + } + : null, + onHorizontalDragEnd: _horizontalSwipeEnabled + ? (_) => unawaited(_releaseDrag()) + : null, + child: ClipRRect( borderRadius: BorderRadius.circular(16), - child: Container( - width: constraints.maxWidth, - constraints: const BoxConstraints(minHeight: 220), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 24, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - ), - child: Stack( - alignment: Alignment.center, - children: [ - Positioned( - top: 8, - bottom: 8, - left: constraints.maxWidth * 0.22, - right: constraints.maxWidth * 0.22, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - color: Color.lerp( - AppColors.surfaceElevated, - AppColors.surface, - 0.45, - ), - ), - ), + child: Material( + color: Colors.transparent, + elevation: 0, + child: Container( + width: constraints.maxWidth, + constraints: const BoxConstraints(minHeight: 220), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 24, + ), + decoration: BoxDecoration( + color: AppColors.surfaceElevated.withValues( + alpha: 0.9, ), - Positioned( - top: 8, - bottom: 8, - left: constraints.maxWidth * 0.22, - right: constraints.maxWidth * 0.22, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onVerticalDragUpdate: widget.busy || _acting - ? null - : (DragUpdateDetails details) { - setState(() { - _verticalOffset -= details.delta.dy; - _verticalOffset = _verticalOffset.clamp( - -_maxVerticalDrag, - _maxVerticalDrag, - ); - _maybeTriggerSnapFeedback( - _snappedSliderValue, - ); - }); - }, - child: Center( - child: Transform.translate( - offset: Offset(0, -_snappedVerticalOffset), - child: QuestionGuidGlyph( - guid: widget.questionId, - size: _glyphSize, - displayValue: _snappedSliderValue, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppColors.accent.withValues(alpha: 0.18), + width: 1, + ), + ), + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + Positioned( + top: 8, + bottom: 8, + left: constraints.maxWidth * 0.22, + right: constraints.maxWidth * 0.22, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: AppColors.surface.withValues( + alpha: 0.6, ), ), ), ), - ), - ], + Positioned( + top: 8, + bottom: 8, + left: constraints.maxWidth * 0.22, + right: constraints.maxWidth * 0.22, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: widget.busy || _acting + ? null + : (DragUpdateDetails details) { + setState(() { + _verticalOffset -= details.delta.dy; + _verticalOffset = + _verticalOffset.clamp( + -_maxVerticalDrag, + _maxVerticalDrag, + ); + if (_atZero) { + _dragOffset = 0; + } + _maybeTriggerSnapFeedback( + _snappedSliderValue, + ); + }); + }, + child: Center( + child: Transform.translate( + offset: + Offset(0, -_snappedVerticalOffset), + child: QuestionGuidGlyph( + guid: widget.questionId, + size: _glyphSize, + displayValue: _snappedSliderValue, + ), + ), + ), + ), + ), + ], + ), ), ), ), @@ -263,10 +258,13 @@ class _SwipeQuestionTileState extends State ), ), if (widget.busy) - const Positioned.fill( - child: ColoredBox( - color: Color(0x66000000), - child: Center(child: CircularProgressIndicator()), + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: const ColoredBox( + color: Color(0x66000000), + child: Center(child: CircularProgressIndicator()), + ), ), ), ], @@ -275,4 +273,3 @@ class _SwipeQuestionTileState extends State ); } } - diff --git a/server/README.md b/server/README.md index 93341c2..d34cd29 100644 --- a/server/README.md +++ b/server/README.md @@ -53,8 +53,10 @@ trading tables between cases. Optional override: `TEST_DATABASE_URL`. | `GET` | `/v1/me/profile` | `Authorization: Bearer ` | | `PUT` | `/v1/me/profile` | same | | `POST` | `/v1/me/incoming-question` | same — pushes a question to the client via SignalR | -| `POST` | `/v1/me/questions/bootstrap` | ensure starter question at login | +| `POST` | `/v1/me/questions/bootstrap` | create one random prospective question on login (fallback to oldest unanswered) | | `GET` | `/v1/me/questions` | list unanswered questions (queue order) | +| `GET` | `/v1/me/questions/score` | current cumulative prospective-question score | +| `POST` | `/v1/me/questions/score/reset` | reset guess score and answer statistics to zero | | `POST` | `/v1/me/questions/{id}/answer` | submit answer (`{"answer": 0}` default) | | `POST` | `/v1/me/questions/{id}/defer` | move question to end of queue | @@ -62,11 +64,12 @@ trading tables between cases. Optional override: `TEST_DATABASE_URL`. Hub URL: `http://localhost:3000/hubs/questions` -The Flutter app calls `POST /v1/me/questions/bootstrap` once at login to ensure a -starter question exists (random correct answer from -10 to 10) when the user has none. -After sign-in it connects to SignalR and listens for `ReceiveQuestion`. On each new -WebSocket connection the API only delivers an existing unanswered question — it does -not create new rows. +The Flutter app calls `POST /v1/me/questions/bootstrap` once at login. The API ensures a +`users` row exists, picks a random row from `market_history_prospective_questions`, and +creates a user question linked by `metadata.prospective_question_id` (falls back to +oldest unanswered when no prospective rows exist). After sign-in it connects to SignalR +and listens for `ReceiveQuestion`. On each new WebSocket connection the API only delivers +an existing unanswered question — it does not create new rows. Client payload (correct answer is not sent): @@ -85,6 +88,36 @@ Client payload (correct answer is not sent): `questions` table: `id` (UUID), `assigned_user_id`, `question_text`, `user_response` (nullable), `correct_answer`, `created_at`, `modified_at`. +`market_history_prospective_answers` table snapshots answered prospective-question context: +`question_id`, `prospective_question_id`, `symbol`, `older_slot_start`, +`newer_slot_start`, `expected_percent_increase_price`, and `user_slider_value`. + +Guess score (`user_trading_state.context.guess_score`, keyed by Firebase UID): + +- Persists across devices and logins for the same `firebase_uid`. +- `GET /v1/me/questions/score` and login bootstrap `score` repair counters/total + from `market_history_prospective_answers` when stored JSON is missing or stale. + +Prospective guess progression (`user_trading_state.context.guess_score`): + +- `slot_start` — older session-half edge for the active pair; advances when every + top-50% volume symbol in `(slot_start → next slot)` has been answered. +- Reset sets `slot_start` to the earliest slot in the rolling history window and + clears that user's `market_history_prospective_answers` and + `market_history_prospective_assignments` rows. +- **Assignments** (`market_history_prospective_assignments`, migration `014`): + one row per `(user, older_slot_start, newer_slot_start)` written when the + question is created (`pending` until answered). Survives logins; unique + constraint blocks a second asset for the same slot pair before answer. +- Question pick uses bar audit data for that slot pair (not a global random row). + +Prospective answer scoring (`user_trading_state.context.guess_score`): + +| `PROSPECTIVE_ANSWER_CLOSENESS_ENABLED` | Correct sign | Wrong sign | +|----------------------------------------|--------------|------------| +| `false` (default) | `+1` | `-1` | +| `true` | `+1` plus up to `+1` closeness (full bonus within ±1 of expected %, else fractional) | `-2` | + ## Background question pipeline A background worker runs inside the API process (enabled by default). On each @@ -96,6 +129,7 @@ enqueues pipeline questions when the user's queue is not full. | `QUESTION_WORKER_ENABLED` | `true` | Set to `false` to disable the worker | | `QUESTION_WORKER_INTERVAL_SECONDS` | `60` | Seconds between maintenance cycles | | `QUESTION_PIPELINE_TEST_MODE` | `false` | Use random -10..10 starter-style questions instead of API copy | +| `PROSPECTIVE_ANSWER_CLOSENESS_ENABLED` | `false` | Enable closeness bonus scoring (`+1`/`-2` with magnitude bonus) instead of simple `+1`/`-1` | **External APIs used** @@ -141,6 +175,20 @@ Before each worker or admin pipeline, orphaned `market_data_sync_runs` rows with crashed prior sync cannot block new work. **Universe** / **cleanup** keep hour-based cadence. `guess_weekly_move` reads these bars. +**Worker pipeline** (each tick when sync enabled): `universe` → `backfill` → `cleanup` → +prospective-question refresh (silent; not logged to `market_data_sync_runs`). + +**Prospective questions** (`market_history_prospective_questions`, migration `012`): +- **Identity:** `UNIQUE (symbol, older_slot_start, newer_slot_start)` — one row per + asset per canonical slot pair (slot starts snapped to session-half boundaries). +- **Refresh:** top 50% by mean dollar volume for the latest completed pair; `INSERT … + ON CONFLICT DO UPDATE`; prune symbols that dropped out of the top half for that pair; + `pg_advisory_xact_lock` so overlapping ticks cannot duplicate rows. +- **Answer:** `correct_answer` = percent change in average OHLC price (older → newer). +- **Cleanup:** `cleanup` deletes rows with `older_slot_start` before the retention + cutoff (`MARKET_HISTORY_RETENTION_DAYS`, same as `market_data_snapshots`). Refresh + also purges expired rows before upserting. + Requires `TRADING_ENABLED=true` when `MARKET_HISTORY_SYNC_ENABLED=true`. **Migration `008`:** legacy `1Day` / `4Hour` history cleanup and `slots_synced` on sync runs. diff --git a/server/bin/server.dart b/server/bin/server.dart index 5d9e0e5..38cc7e7 100644 --- a/server/bin/server.dart +++ b/server/bin/server.dart @@ -19,9 +19,11 @@ import '../lib/handlers/trading_dev_handler.dart'; import '../lib/pipeline/question_pipeline.dart'; import '../lib/question_service.dart'; import '../lib/questions_db.dart'; +import '../lib/trading/prospective_answer_scoring.dart'; import '../lib/trading/guardrails.dart'; import '../lib/trading/market_data_db.dart'; import '../lib/trading/market_data_history.dart'; +import '../lib/trading/market_history_prospective_questions.dart'; import '../lib/trading/market_history_api_rate_limiter.dart'; import '../lib/trading/market_history_query.dart'; import '../lib/trading/market_data_ingest.dart'; @@ -52,6 +54,8 @@ Future main() async { } final ServerEnv env = ServerEnv.load(); + ProspectiveAnswerScoring.closenessExtraPointsEnabled = + env.prospectiveAnswerClosenessEnabled; final ProfileDb db = await ProfileDb.connect(env.databaseUrl); await db.migrate(); @@ -166,6 +170,8 @@ Future main() async { connection: db.connection, windowDays: mh.retentionDays, ); + final MarketHistoryProspectiveQuestions prospectiveQuestions = + MarketHistoryProspectiveQuestions(connection: db.connection); if (env.marketHistorySyncEnabled) { marketHistoryScheduler = MarketHistoryScheduler( connection: db.connection, @@ -181,6 +187,11 @@ Future main() async { backfillIsDue: historySync.hasPendingSlots, runCleanup: (DateTime now) => retention.run(archive: mh.archiveEnabled, now: now), + runProspectiveQuestions: (DateTime now) => prospectiveQuestions.refresh( + now: now, + windowDays: mh.windowDays, + retentionDays: mh.retentionDays, + ), ); } if (env.adminPortalEnabled) { diff --git a/server/lib/env.dart b/server/lib/env.dart index a3ba9cd..adab221 100644 --- a/server/lib/env.dart +++ b/server/lib/env.dart @@ -12,6 +12,7 @@ class ServerEnv { required this.questionWorkerEnabled, required this.questionWorkerIntervalSeconds, required this.questionPipelineTestMode, + required this.prospectiveAnswerClosenessEnabled, required this.tradingEnabled, required this.tradingWorkerIngestEnabled, required this.tradingWorkerEvalEnabled, @@ -28,6 +29,11 @@ class ServerEnv { final bool questionWorkerEnabled; final int questionWorkerIntervalSeconds; final bool questionPipelineTestMode; + + /// When true, prospective answers earn closeness bonus points and wrong + /// direction scores `-2` instead of `-1`. Default false (`+1`/`-1` only). + final bool prospectiveAnswerClosenessEnabled; + final bool tradingEnabled; final bool tradingWorkerIngestEnabled; final bool tradingWorkerEvalEnabled; @@ -88,6 +94,10 @@ class ServerEnv { int.tryParse(env['QUESTION_WORKER_INTERVAL_SECONDS'] ?? '60') ?? 60; final bool pipelineTestMode = (env['QUESTION_PIPELINE_TEST_MODE'] ?? 'false').toLowerCase() == 'true'; + final bool prospectiveAnswerClosenessEnabled = + (env['PROSPECTIVE_ANSWER_CLOSENESS_ENABLED'] ?? 'false') + .toLowerCase() == + 'true'; final bool tradingEnabled = (env['TRADING_ENABLED'] ?? 'false').toLowerCase() == 'true'; final bool tradingWorkerIngestEnabled = @@ -139,6 +149,7 @@ class ServerEnv { questionWorkerEnabled: workerEnabled, questionWorkerIntervalSeconds: workerIntervalSeconds, questionPipelineTestMode: pipelineTestMode, + prospectiveAnswerClosenessEnabled: prospectiveAnswerClosenessEnabled, tradingEnabled: tradingEnabled, tradingWorkerIngestEnabled: tradingWorkerIngestEnabled, tradingWorkerEvalEnabled: tradingWorkerEvalEnabled, diff --git a/server/lib/handlers/market_history_admin_handler.dart b/server/lib/handlers/market_history_admin_handler.dart index c3bbe90..e5c8322 100644 --- a/server/lib/handlers/market_history_admin_handler.dart +++ b/server/lib/handlers/market_history_admin_handler.dart @@ -65,7 +65,7 @@ Handler marketHistoryAdminHandler({ SELECT id, kind, started_at, finished_at, rows_written, rows_removed, slots_synced, backfill_items, error FROM market_data_sync_runs - WHERE 1=1 + WHERE kind <> 'prospective_questions' ''', ); final Map params = {'limit': limit}; diff --git a/server/lib/handlers/questions_handler.dart b/server/lib/handlers/questions_handler.dart index 3ec1115..21b9dcb 100644 --- a/server/lib/handlers/questions_handler.dart +++ b/server/lib/handlers/questions_handler.dart @@ -26,13 +26,16 @@ Handler questionsHandler({ return _jsonResponse(401, {'error': 'Unauthorized'}); } try { - final Map question = - await questionService.ensureStarterQuestionOnLogin(firebaseUid); + final Map? question = + await questionService.bootstrapOnLogin(firebaseUid); final int unansweredCount = await questionsDb.countUnansweredQuestions(firebaseUid); + final Map score = + await questionsDb.getGuessScoreSummary(firebaseUid); return _jsonResponse(200, { 'question': question, 'unansweredCount': unansweredCount, + 'score': score, }); } catch (e, st) { stderr.writeln('Bootstrap questions error: $e\n$st'); @@ -66,6 +69,40 @@ Handler questionsHandler({ } }); + router.get('$questionsBasePath/score', (Request request) async { + final String? firebaseUid = await _verify(auth, request); + if (firebaseUid == null) { + return _jsonResponse(401, {'error': 'Unauthorized'}); + } + try { + final Map score = + await questionsDb.getGuessScoreSummary(firebaseUid); + return _jsonResponse(200, { + 'score': score, + }); + } catch (e, st) { + stderr.writeln('Get questions score error: $e\n$st'); + return _jsonResponse(500, {'error': 'Internal error'}); + } + }); + + router.post('$questionsBasePath/score/reset', (Request request) async { + final String? firebaseUid = await _verify(auth, request); + if (firebaseUid == null) { + return _jsonResponse(401, {'error': 'Unauthorized'}); + } + try { + final Map score = + await questionsDb.resetGuessScoreSummary(firebaseUid); + return _jsonResponse(200, { + 'score': score, + }); + } catch (e, st) { + stderr.writeln('Reset questions score error: $e\n$st'); + return _jsonResponse(500, {'error': 'Internal error'}); + } + }); + router.post( '$questionsBasePath//answer', (Request request, String id) async { @@ -95,11 +132,23 @@ Handler questionsHandler({ userResponse: answer, ); } - final int unansweredCount = + var unansweredCount = await questionsDb.countUnansweredQuestions(firebaseUid); + Map? nextQuestion; + if (unansweredCount == 0) { + nextQuestion = + await questionService.ensureProspectiveQuestionQueued(firebaseUid); + if (nextQuestion != null) { + unansweredCount = 1; + } + } + final Map score = + await questionsDb.getGuessScoreSummary(firebaseUid); return _jsonResponse(200, { 'question': updated, 'unansweredCount': unansweredCount, + 'score': score, + if (nextQuestion != null) 'nextQuestion': nextQuestion, }); } catch (e, st) { stderr.writeln('Answer question error: $e\n$st'); diff --git a/server/lib/pipeline/question_pipeline.dart b/server/lib/pipeline/question_pipeline.dart index fbf6153..b7a937c 100644 --- a/server/lib/pipeline/question_pipeline.dart +++ b/server/lib/pipeline/question_pipeline.dart @@ -8,7 +8,7 @@ import '../trading/trading_pipeline.dart'; import 'branch_decision.dart'; import 'external_data_fetcher.dart'; -/// Same format as [QuestionsDb.createStarterQuestion] for local pipeline testing. +/// Test-mode question shape for local pipeline testing. abstract final class PipelineTestQuestions { static const String text = 'Starter question: enter a whole number between -10 and 10.'; @@ -156,6 +156,16 @@ class QuestionPipeline { if (pipelineKey == null || pipelineStep == null) { return; } + + // Trading answers (including scoring) must run even when the queue is full. + if (pipelineKey == PipelineKeys.trading && _tradingPipeline != null) { + await _tradingPipeline.handleAnswer( + firebaseUid: firebaseUid, + answeredQuestion: answeredQuestion, + userResponse: userResponse, + ); + } + if (!await _canEnqueue(firebaseUid)) { return; } @@ -188,13 +198,7 @@ class QuestionPipeline { context: context, ); case PipelineKeys.trading: - if (_tradingPipeline != null) { - await _tradingPipeline.handleAnswer( - firebaseUid: firebaseUid, - answeredQuestion: answeredQuestion, - userResponse: userResponse, - ); - } + break; } } diff --git a/server/lib/question_service.dart b/server/lib/question_service.dart index 680ca7a..aa38d1b 100644 --- a/server/lib/question_service.dart +++ b/server/lib/question_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'questions_db.dart'; import 'signalr/questions_hub_connections.dart'; +import 'trading/prospective_guess_assignments_db.dart'; /// Creates questions in Postgres and delivers them over SignalR. class QuestionService { @@ -15,20 +16,139 @@ class QuestionService { final QuestionsDb _questionsDb; final QuestionsHubConnections _hubConnections; - /// Called at login: ensures a starter question exists when the user has none. - Future> ensureStarterQuestionOnLogin( + ProspectiveGuessAssignmentsDb get _assignmentsDb => + ProspectiveGuessAssignmentsDb(_questionsDb.connection); + + /// Called at login: ensures one unanswered prospective question when possible. + Future?> bootstrapOnLogin( String firebaseUid, ) async { - final Map question = - await _questionsDb.getOrCreateStarterQuestion(firebaseUid); - final int unansweredCount = + await _questionsDb.ensureUserExists(firebaseUid); + return ensureProspectiveQuestionQueued(firebaseUid); + } + + /// Creates the next slot-based prospective question when the queue is empty. + Future?> ensureProspectiveQuestionQueued( + String firebaseUid, { + DateTime? now, + }) async { + await _questionsDb.ensureUserExists(firebaseUid); + + final String? pendingQuestionId = + await _assignmentsDb.findPendingQuestionId(firebaseUid); + if (pendingQuestionId != null) { + final Map? question = await _questionsDb.findQuestionById( + questionId: pendingQuestionId, + assignedUserId: firebaseUid, + ); + if (question != null && question['userResponse'] == null) { + final int unansweredCount = + await _questionsDb.countUnansweredQuestions(firebaseUid); + return _questionsDb.toClientPayload( + question, + unansweredCount: unansweredCount > 0 ? unansweredCount : 1, + ); + } + } + + final int queued = await _questionsDb.countUnansweredQuestions(firebaseUid); - final Map payload = _questionsDb.toClientPayload( - question, - unansweredCount: unansweredCount, + if (queued > 0) { + final Map? existing = + await _questionsDb.findUnansweredQuestion(firebaseUid); + if (existing == null) { + return null; + } + return _questionsDb.toClientPayload( + existing, + unansweredCount: queued, + ); + } + + final Map? prospective = + await _createProspectiveQuestionWithAssignment( + firebaseUid, + now: now, ); - await _hubConnections.pushQuestion(firebaseUid, payload); - return payload; + if (prospective == null) { + return null; + } + + return prospective; + } + + /// Picks, creates, and records one prospective question when none is queued. + /// + /// Retries when a concurrent request claims the same user/slot/symbol first. + Future?> _createProspectiveQuestionWithAssignment( + String firebaseUid, { + DateTime? now, + }) async { + for (var attempt = 0; attempt < 8; attempt++) { + final Map? picked = + await _questionsDb.pickProspectiveQuestionForUser( + firebaseUid, + now: now, + ); + if (picked == null) { + return null; + } + + final DateTime olderSlotStart = DateTime.parse( + picked['olderSlotStart']! as String, + ); + final DateTime newerSlotStart = DateTime.parse( + picked['newerSlotStart']! as String, + ); + final String symbol = picked['symbol']! as String; + + if (await _assignmentsDb.hasAssignmentForSymbolSlotPair( + firebaseUid: firebaseUid, + olderSlotStart: olderSlotStart, + newerSlotStart: newerSlotStart, + symbol: symbol, + )) { + continue; + } + + final Map question = await _questionsDb.createQuestion( + assignedUserId: firebaseUid, + questionText: picked['questionText']! as String, + correctAnswer: picked['correctAnswer']! as num, + sourceTag: 'market_history:prospective', + pipelineKey: 'trading', + pipelineStep: 'guess_weekly_move:await_answer', + metadata: { + 'prospective_question_id': picked['id'], + 'symbol': symbol, + 'older_slot_start': picked['olderSlotStart'], + 'newer_slot_start': picked['newerSlotStart'], + 'price_delta_pct': picked['priceDeltaPct'], + }, + ); + + final bool assigned = await _assignmentsDb.insertPendingIfAbsent( + firebaseUid: firebaseUid, + olderSlotStart: olderSlotStart, + newerSlotStart: newerSlotStart, + symbol: symbol, + prospectiveQuestionId: picked['id']! as String, + questionId: question['id']! as String, + ); + if (!assigned) { + await _questionsDb.deleteUnansweredQuestion( + questionId: question['id']! as String, + assignedUserId: firebaseUid, + ); + continue; + } + + return _questionsDb.toClientPayload( + question, + unansweredCount: 1, + ); + } + return null; } /// Inserts a question and pushes it to connected SignalR clients. diff --git a/server/lib/questions_db.dart b/server/lib/questions_db.dart index d7da70c..f9942d2 100644 --- a/server/lib/questions_db.dart +++ b/server/lib/questions_db.dart @@ -1,15 +1,23 @@ import 'dart:convert'; -import 'dart:math'; import 'package:postgres/postgres.dart'; import 'package:uuid/uuid.dart'; +import 'trading/market_history_config.dart'; +import 'trading/prospective_answer_scoring.dart'; +import 'trading/guess_score_store.dart'; +import 'trading/prospective_guess_assignments_db.dart'; +import 'trading/prospective_guess_selection.dart'; +import 'trading/user_trading_state_db.dart'; + /// Postgres access for the questions table. class QuestionsDb { QuestionsDb(this._connection); final Connection _connection; + Connection get connection => _connection; + static const Uuid _uuid = Uuid(); Future ensureUserExists(String firebaseUid) async { @@ -69,7 +77,31 @@ class QuestionsDb { return result.map(_rowFromResult).toList(); } + /// Removes an unanswered question owned by [assignedUserId]. + Future deleteUnansweredQuestion({ + required String questionId, + required String assignedUserId, + }) async { + await _connection.execute( + Sql.named( + ''' + DELETE FROM questions + WHERE id = @id::uuid + AND assigned_user_id = @uid + AND user_response IS NULL + ''', + ), + parameters: { + 'id': questionId, + 'uid': assignedUserId, + }, + ); + } + /// Records [userResponse] for an unanswered question owned by [assignedUserId]. + /// + /// When the answered question metadata includes `prospective_question_id`, + /// also snapshots the answer into `market_history_prospective_answers`. Future?> submitAnswer({ required String questionId, required String assignedUserId, @@ -99,6 +131,38 @@ class QuestionsDb { if (result.isEmpty) { return null; } + final Map updated = _rowFromResult(result.first); + await _recordProspectiveAnswer(updated); + await ProspectiveGuessAssignmentsDb(_connection).markAnsweredByQuestionId( + questionId: questionId, + answeredAt: now, + ); + await _gradeProspectiveAnswerIfNeeded(updated); + return updated; + } + + Future?> findQuestionById({ + required String questionId, + required String assignedUserId, + }) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT id, assigned_user_id, question_text, user_response, correct_answer, + created_at, modified_at, source_tag, pipeline_key, pipeline_step, + metadata + FROM questions + WHERE id = @id::uuid AND assigned_user_id = @uid + ''', + ), + parameters: { + 'id': questionId, + 'uid': assignedUserId, + }, + ); + if (result.isEmpty) { + return null; + } return _rowFromResult(result.first); } @@ -225,58 +289,51 @@ class QuestionsDb { return (result.first[0]! as num).toInt(); } - /// Returns an existing unanswered question or creates the starter question. - Future> getOrCreateStarterQuestion( - String assignedUserId, - ) async { - final Map? existing = - await findUnansweredQuestion(assignedUserId); - if (existing != null) { - return existing; - } - return createStarterQuestion(assignedUserId); + /// Cumulative guess score for [firebaseUid] (Firebase auth UID), persisted in + /// `user_trading_state.context.guess_score` and repaired from answer history. + Future> getGuessScoreSummary(String firebaseUid) async { + await ensureUserExists(firebaseUid); + return GuessScoreStore.loadSummary(_connection, firebaseUid); } - /// Creates the starter test question with a random correct answer in [-10, 10]. - Future> createStarterQuestion(String assignedUserId) async { - await ensureUserExists(assignedUserId); - final int correctAnswer = Random().nextInt(21) - 10; - final String id = _uuid.v4(); - final DateTime now = DateTime.now().toUtc(); - const String questionText = - 'Starter question: enter a whole number between -10 and 10.'; - + /// Resets guess score counters and slot progression to the earliest window slot. + Future> resetGuessScoreSummary( + String firebaseUid, { + DateTime? now, + }) async { + final DateTime tick = (now ?? DateTime.now()).toUtc(); + final DateTime earliest = ProspectiveGuessSelection.earliestPlayableSlotStart( + tick, + MarketHistoryConfig.windowDays, + ); await _connection.execute( Sql.named( ''' - INSERT INTO questions ( - id, assigned_user_id, question_text, user_response, correct_answer, - created_at, modified_at - ) VALUES ( - @id::uuid, @assigned_user_id, @question_text, NULL, @correct_answer, - @created_at, @modified_at - ) + DELETE FROM market_history_prospective_answers + WHERE assigned_user_id = @uid ''', ), - parameters: { - 'id': id, - 'assigned_user_id': assignedUserId, - 'question_text': questionText, - 'correct_answer': correctAnswer, - 'created_at': now, - 'modified_at': now, - }, + parameters: {'uid': firebaseUid}, ); + await ProspectiveGuessAssignmentsDb(_connection).deleteAllForUser( + firebaseUid, + ); + await UserTradingStateDb(_connection).resetGuessScore( + firebaseUid, + slotStart: earliest, + ); + return getGuessScoreSummary(firebaseUid); + } - return { - 'id': id, - 'assignedUserId': assignedUserId, - 'text': questionText, - 'userResponse': null, - 'correctAnswer': correctAnswer, - 'createdAt': now.toIso8601String(), - 'modifiedAt': now.toIso8601String(), - }; + /// Slot-progressive prospective question for [firebaseUid], or null when caught up. + Future?> pickProspectiveQuestionForUser( + String firebaseUid, { + DateTime? now, + }) async { + return ProspectiveGuessSelection(connection: _connection).pickForUser( + firebaseUid, + now: now, + ); } Future> createQuestion({ @@ -403,6 +460,101 @@ class QuestionsDb { return _readNumeric(value); } + Future _gradeProspectiveAnswerIfNeeded( + Map answeredQuestion, + ) async { + final Map metadata = Map.from( + answeredQuestion['metadata'] as Map? ?? + {}, + ); + final bool isProspective = metadata['prospective_question_id'] != null || + answeredQuestion['sourceTag'] == 'market_history:prospective'; + if (!isProspective) { + return; + } + + final num? userResponse = + _readOptionalNumeric(answeredQuestion['userResponse']); + if (userResponse == null) { + return; + } + + final ProspectiveAnswerGrade grade = gradeProspectiveAnswer( + userResponse: userResponse, + correctAnswer: _readNumeric(answeredQuestion['correctAnswer']), + ); + final String symbol = metadata['symbol'] as String? ?? ''; + final String? modifiedAtWire = answeredQuestion['modifiedAt'] as String?; + final DateTime at = modifiedAtWire == null + ? DateTime.now().toUtc() + : DateTime.parse(modifiedAtWire).toUtc(); + + await recordProspectiveGuessScore( + tradingStateDb: UserTradingStateDb(_connection), + firebaseUid: answeredQuestion['assignedUserId']! as String, + grade: grade, + symbol: symbol, + at: at, + ); + } + + Future _recordProspectiveAnswer(Map answeredQuestion) async { + final Map metadata = Map.from( + answeredQuestion['metadata'] as Map? ?? + {}, + ); + final String? prospectiveQuestionId = + metadata['prospective_question_id'] as String?; + if (prospectiveQuestionId == null || prospectiveQuestionId.isEmpty) { + return; + } + final num? userSliderValue = + _readOptionalNumeric(answeredQuestion['userResponse']); + if (userSliderValue == null) { + return; + } + final String questionId = answeredQuestion['id']! as String; + final String assignedUserId = answeredQuestion['assignedUserId']! as String; + final String? modifiedAtWire = answeredQuestion['modifiedAt'] as String?; + final DateTime answeredAt = modifiedAtWire == null + ? DateTime.now().toUtc() + : DateTime.parse(modifiedAtWire).toUtc(); + + await _connection.execute( + Sql.named( + ''' + INSERT INTO market_history_prospective_answers ( + question_id, assigned_user_id, prospective_question_id, + symbol, older_slot_start, newer_slot_start, + expected_percent_increase_price, user_slider_value, answered_at + ) + SELECT + @question_id::uuid, @assigned_user_id, p.id, + p.symbol, p.older_slot_start, p.newer_slot_start, + p.price_delta_pct, @user_slider_value, @answered_at + FROM market_history_prospective_questions p + WHERE p.id = @prospective_question_id::uuid + ON CONFLICT (question_id) DO UPDATE + SET + prospective_question_id = EXCLUDED.prospective_question_id, + symbol = EXCLUDED.symbol, + older_slot_start = EXCLUDED.older_slot_start, + newer_slot_start = EXCLUDED.newer_slot_start, + expected_percent_increase_price = EXCLUDED.expected_percent_increase_price, + user_slider_value = EXCLUDED.user_slider_value, + answered_at = EXCLUDED.answered_at + ''', + ), + parameters: { + 'question_id': questionId, + 'assigned_user_id': assignedUserId, + 'prospective_question_id': prospectiveQuestionId, + 'user_slider_value': userSliderValue, + 'answered_at': answeredAt, + }, + ); + } + Map _rowToJson({ required String id, required String assignedUserId, diff --git a/server/lib/trading/guess_score_store.dart b/server/lib/trading/guess_score_store.dart new file mode 100644 index 0000000..1b4cfbd --- /dev/null +++ b/server/lib/trading/guess_score_store.dart @@ -0,0 +1,208 @@ +import 'package:postgres/postgres.dart'; + +import 'market_history_session_slot.dart'; +import 'prospective_answer_scoring.dart'; +import 'user_trading_state_db.dart'; + +/// Loads and repairs per-user guess score in [user_trading_state] (Firebase UID). +abstract final class GuessScoreStore { + /// Ensures [user_trading_state] exists and returns API-shaped score summary. + /// + /// Counters and total are reconciled from [market_history_prospective_answers] + /// when stored JSON is missing or behind answer history (cross-device safe). + static Future> loadSummary( + Connection connection, + String firebaseUid, + ) async { + final UserTradingStateDb tradingStateDb = UserTradingStateDb(connection); + await tradingStateDb.ensureExists(firebaseUid); + + Map stored = Map.from( + await tradingStateDb.getGuessScore(firebaseUid) ?? {}, + ); + + final _AnswerStats answerStats = await _fetchAnswerStats( + connection, + firebaseUid, + ); + final num recomputedTotal = await _recomputeTotalScore( + connection, + firebaseUid, + ); + + final int storedAnswersTotal = + _readInt(stored['answers_total']); + final num storedTotal = _readNum(stored['total']); + + final bool needsRepair = answerStats.count > 0 && + (stored.isEmpty || + answerStats.count > storedAnswersTotal || + (storedTotal == 0 && recomputedTotal != 0)); + + if (needsRepair) { + stored = { + 'total': recomputedTotal, + 'answers_total': answerStats.count, + 'answers_correct': answerStats.correct, + if (stored['slot_start'] != null) 'slot_start': stored['slot_start'], + if (stored['last'] != null) 'last': stored['last'], + }; + final String? slotFromAssignments = await _latestSlotWire( + connection, + firebaseUid, + ); + if (stored['slot_start'] == null && slotFromAssignments != null) { + stored['slot_start'] = slotFromAssignments; + } + await tradingStateDb.setGuessScore(firebaseUid, stored); + } else if (stored['slot_start'] == null) { + final String? slotFromAssignments = await _latestSlotWire( + connection, + firebaseUid, + ); + if (slotFromAssignments != null) { + stored['slot_start'] = slotFromAssignments; + await tradingStateDb.setGuessScore(firebaseUid, stored); + } + } + + return _toApiSummary(stored); + } + + static Map _toApiSummary(Map score) { + final int answersTotal = _readInt(score['answers_total']); + final int answersCorrect = _readInt(score['answers_correct']); + final num percentCorrect = answersTotal > 0 + ? (answersCorrect / answersTotal) * 100 + : 0; + final DateTime? slotStart = + MarketHistorySessionSlot.parseWire(score['slot_start'] as String?); + final DateTime? newerSlotStart = slotStart == null + ? null + : MarketHistorySessionSlot.nextSlotStart(slotStart); + return { + 'total': _readNum(score['total']), + 'answersTotal': answersTotal, + 'answersCorrect': answersCorrect, + 'percentCorrect': percentCorrect, + if (slotStart != null) 'slotStart': slotStart.toIso8601String(), + if (newerSlotStart != null) + 'newerSlotStart': newerSlotStart.toIso8601String(), + if (score['last'] != null) + 'last': Map.from(score['last'] as Map), + }; + } + + static Future<_AnswerStats> _fetchAnswerStats( + Connection connection, + String firebaseUid, + ) async { + final Result result = await connection.execute( + Sql.named( + ''' + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER ( + WHERE ( + sign(user_slider_value) = sign(expected_percent_increase_price) + ) + OR ( + user_slider_value = 0 + AND expected_percent_increase_price = 0 + ) + )::int AS correct + FROM market_history_prospective_answers + WHERE assigned_user_id = @uid + ''', + ), + parameters: {'uid': firebaseUid}, + ); + if (result.isEmpty) { + return const _AnswerStats(count: 0, correct: 0); + } + return _AnswerStats( + count: (result.first[0]! as num).toInt(), + correct: (result.first[1]! as num).toInt(), + ); + } + + static Future _recomputeTotalScore( + Connection connection, + String firebaseUid, + ) async { + final Result rows = await connection.execute( + Sql.named( + ''' + SELECT user_slider_value, expected_percent_increase_price + FROM market_history_prospective_answers + WHERE assigned_user_id = @uid + ORDER BY answered_at ASC + ''', + ), + parameters: {'uid': firebaseUid}, + ); + num total = 0; + for (final ResultRow row in rows) { + final ProspectiveAnswerGrade grade = gradeProspectiveAnswer( + userResponse: _readNum(row[0]), + correctAnswer: _readNum(row[1]), + ); + total += grade.answerScore; + } + return total; + } + + static Future _latestSlotWire( + Connection connection, + String firebaseUid, + ) async { + final Result result = await connection.execute( + Sql.named( + ''' + SELECT older_slot_start + FROM market_history_prospective_assignments + WHERE assigned_user_id = @uid + ORDER BY created_at DESC + LIMIT 1 + ''', + ), + parameters: {'uid': firebaseUid}, + ); + if (result.isEmpty) { + return null; + } + return MarketHistorySessionSlot.slotStartWire( + (result.first[0]! as DateTime).toUtc(), + ); + } + + static int _readInt(Object? value) { + if (value == null) { + return 0; + } + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return int.parse(value.toString()); + } + + static num _readNum(Object? value) { + if (value == null) { + return 0; + } + if (value is num) { + return value; + } + return num.parse(value.toString()); + } +} + +class _AnswerStats { + const _AnswerStats({required this.count, required this.correct}); + + final int count; + final int correct; +} diff --git a/server/lib/trading/market_data_retention.dart b/server/lib/trading/market_data_retention.dart index 04480bc..e79665f 100644 --- a/server/lib/trading/market_data_retention.dart +++ b/server/lib/trading/market_data_retention.dart @@ -1,6 +1,7 @@ import 'package:postgres/postgres.dart'; import 'market_history_config.dart'; +import 'market_history_prospective_questions.dart'; import 'market_history_session_slot.dart'; import 'sync_run_recorder.dart'; @@ -101,6 +102,10 @@ class MarketDataRetention { totalRemoved += removed; } + totalRemoved += await MarketHistoryProspectiveQuestions( + connection: _connection, + ).deleteExpiredBefore(cutoff); + return SyncRunCounts(rowsRemoved: totalRemoved); } diff --git a/server/lib/trading/market_history_prospective_questions.dart b/server/lib/trading/market_history_prospective_questions.dart new file mode 100644 index 0000000..a9e0f4c --- /dev/null +++ b/server/lib/trading/market_history_prospective_questions.dart @@ -0,0 +1,277 @@ +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'; + +/// Result of one [MarketHistoryProspectiveQuestions.refresh]. +class ProspectiveQuestionsRefreshResult { + ProspectiveQuestionsRefreshResult({ + required this.compareUntil, + required this.rowsWritten, + required this.rowsPruned, + required this.rowsExpiredRemoved, + required this.symbolCount, + this.error, + }); + + final DateTime? compareUntil; + final int rowsWritten; + final int rowsPruned; + final int rowsExpiredRemoved; + final int symbolCount; + final String? error; + + bool get succeeded => error == null; +} + +/// Top-half-by-volume prospective questions for session-half slot pairs. +/// +/// Identity is `(symbol, older_slot_start, newer_slot_start)` — enforced by DB +/// unique constraint and [refresh] upserts. Expired rows are removed on cleanup +/// (and at refresh) when bar history is purged. +class MarketHistoryProspectiveQuestions { + MarketHistoryProspectiveQuestions({ + required Connection connection, + MarketHistoryQuestionAudit? questionAudit, + }) : _connection = connection, + _questionAudit = + questionAudit ?? MarketHistoryQuestionAudit(connection: connection); + + /// Worker stage label (not written to `market_data_sync_runs`). + static const String kind = 'prospective_questions'; + + /// Single-flight refresh across worker ticks (`pg_advisory_xact_lock`). + static const int refreshAdvisoryLockKey = 824238151; + + final Connection _connection; + final MarketHistoryQuestionAudit _questionAudit; + + /// Upserts top-50% volume questions for the current default slot pair. + /// + /// Runs in one transaction with an advisory lock so concurrent ticks cannot + /// duplicate rows. Prunes symbols that left the top half for this slot pair. + /// Drops rows older than [retentionDays] (same cutoff as bar cleanup). + Future refresh({ + required DateTime now, + int windowDays = MarketHistoryConfig.windowDays, + int? retentionDays, + }) async { + final int effectiveRetention = retentionDays ?? windowDays; + try { + final QuestionAuditPage page = await _questionAudit.page( + now: now, + windowDays: windowDays, + ); + final List top = + questionAuditTopHalfVolumeAssets(page.assets); + final _SlotPairSyncCounts counts = await _syncCurrentSlotPair( + compareUntil: page.compareUntil, + newerSlotStart: page.newerSlotStart, + olderSlotStart: page.olderSlotStart, + assets: top, + refreshedAt: now.toUtc(), + retentionDays: effectiveRetention, + ); + return ProspectiveQuestionsRefreshResult( + compareUntil: page.compareUntil, + rowsWritten: counts.upserted, + rowsPruned: counts.pruned, + rowsExpiredRemoved: counts.expiredRemoved, + symbolCount: counts.upserted, + error: null, + ); + } catch (e) { + return ProspectiveQuestionsRefreshResult( + compareUntil: null, + rowsWritten: 0, + rowsPruned: 0, + rowsExpiredRemoved: 0, + symbolCount: 0, + error: e.toString(), + ); + } + } + + Future<_SlotPairSyncCounts> _syncCurrentSlotPair({ + required DateTime compareUntil, + required DateTime newerSlotStart, + required DateTime olderSlotStart, + required List assets, + required DateTime refreshedAt, + required int retentionDays, + }) async { + final DateTime older = MarketHistorySessionSlot.slotStartContaining( + olderSlotStart.toUtc(), + ); + final DateTime newer = MarketHistorySessionSlot.slotStartContaining( + newerSlotStart.toUtc(), + ); + final DateTime until = compareUntil.toUtc(); + final DateTime cutoff = MarketHistorySessionSlot.windowFirstSlotStart( + refreshedAt, + retentionDays, + ); + + int upserted = 0; + int pruned = 0; + int expiredRemoved = 0; + + await _connection.runTx((TxSession tx) async { + await tx.execute( + Sql.named('SELECT pg_advisory_xact_lock(@key)'), + parameters: {'key': refreshAdvisoryLockKey}, + ); + + final Result expired = await tx.execute( + Sql.named( + ''' + DELETE FROM market_history_prospective_questions + WHERE older_slot_start < @cutoff + ''', + ), + parameters: {'cutoff': cutoff}, + ); + expiredRemoved = expired.affectedRows; + + for (final QuestionAuditAsset asset in assets) { + await tx.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, + newer_slot_start = EXCLUDED.newer_slot_start, + 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 + ''', + ), + parameters: { + 'compare_until': until, + '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, + }, + ); + upserted++; + } + + final List symbols = + assets.map((QuestionAuditAsset a) => a.symbol).toList(); + final Result prunedResult; + if (symbols.isEmpty) { + prunedResult = await tx.execute( + Sql.named( + ''' + DELETE FROM market_history_prospective_questions + WHERE older_slot_start = @older_slot_start + AND newer_slot_start = @newer_slot_start + ''', + ), + parameters: { + 'older_slot_start': older, + 'newer_slot_start': newer, + }, + ); + } else { + prunedResult = await tx.execute( + Sql.named( + ''' + DELETE FROM market_history_prospective_questions + WHERE older_slot_start = @older_slot_start + AND newer_slot_start = @newer_slot_start + AND NOT (symbol = ANY(@symbols)) + ''', + ), + parameters: { + 'older_slot_start': older, + 'newer_slot_start': newer, + 'symbols': symbols, + }, + ); + } + pruned = prunedResult.affectedRows; + }); + + return _SlotPairSyncCounts( + upserted: upserted, + pruned: pruned, + expiredRemoved: expiredRemoved, + ); + } + + Future _latestCompareUntil() async { + final Result result = await _connection.execute( + ''' + SELECT compare_until + FROM market_history_prospective_questions + ORDER BY compare_until DESC + LIMIT 1 + ''', + ); + if (result.isEmpty) { + return null; + } + return (result.first[0]! as DateTime).toUtc(); + } + + static String _questionText(String symbol) => + 'What was the percent price change for $symbol from the prior ' + 'session half to the latest?'; + + /// Removes rows whose older slot references bars purged by history retention. + /// + /// Same boundary as `market_data_snapshots.as_of < cutoff`. Called from + /// [MarketDataRetention] cleanup and from [refresh]. + Future deleteExpiredBefore(DateTime cutoff) async { + final Result result = await _connection.execute( + Sql.named( + ''' + DELETE FROM market_history_prospective_questions + WHERE older_slot_start < @cutoff + ''', + ), + parameters: {'cutoff': cutoff.toUtc()}, + ); + return result.affectedRows; + } +} + +class _SlotPairSyncCounts { + const _SlotPairSyncCounts({ + required this.upserted, + required this.pruned, + required this.expiredRemoved, + }); + + final int upserted; + final int pruned; + final int expiredRemoved; +} diff --git a/server/lib/trading/market_history_question_audit.dart b/server/lib/trading/market_history_question_audit.dart index f4fa984..b3e0cd8 100644 --- a/server/lib/trading/market_history_question_audit.dart +++ b/server/lib/trading/market_history_question_audit.dart @@ -8,12 +8,13 @@ import 'market_history_config.dart'; import 'market_history_session_slot.dart'; import 'tradable_assets_db.dart'; -/// One 4-hour bar snapshot used in question audit comparisons. +/// One session-half bar snapshot used in question audit comparisons. class QuestionAuditBarSlot { QuestionAuditBarSlot({ required this.asOf, required this.avgPrice, required this.volume, + required this.volumeUsd, this.open, this.high, this.low, @@ -26,7 +27,10 @@ class QuestionAuditBarSlot { final num? low; final num? close; final num avgPrice; + /// Share/base volume from Alpaca (`v`). final num volume; + /// Dollar notional for this slot (see [questionAuditBarVolumeUsd]). + final num volumeUsd; Map toJson() => { 'asOf': asOf.toIso8601String(), @@ -36,10 +40,56 @@ class QuestionAuditBarSlot { if (close != null) 'close': close, 'avgPrice': avgPrice, 'volume': volume, + 'volumeUsd': volumeUsd, }; } -/// One tradable symbol's last-two 4-hour bar deltas for question auditing. +/// Dollar volume for one bar. +/// +/// Alpaca `v` is share count (US equities) or base-currency size (crypto/USD +/// pairs). Uses [raw] `volume_usd` / `v_usd` when present; otherwise +/// [volume] × [avgPrice] for USD-quoted symbols. +num questionAuditBarVolumeUsd({ + required num volume, + required num avgPrice, + Map? raw, +}) { + if (raw != null) { + for (final String key in ['volume_usd', 'v_usd', 'dollar_volume']) { + final num? usd = MarketDataDb.readOptionalNumeric(raw[key]); + if (usd != null) { + return usd; + } + } + } + return volume * avgPrice; +} + +/// Mean dollar volume across the older and newer slots (list sort key). +num questionAuditAvgVolumeUsd(QuestionAuditAsset asset) { + return (asset.olderSlot.volumeUsd + asset.newerSlot.volumeUsd) / 2; +} + +/// Top half of [assets] by count (must already be sorted by volume descending). +List questionAuditTopHalfVolumeAssets( + List assets, +) { + if (assets.isEmpty) { + return assets; + } + final int topCount = (assets.length / 2).ceil(); + return assets.take(topCount).toList(growable: false); +} + +/// Percent change from [older] to [newer]: `((newer - older) / older) * 100`. +num questionAuditPercentChange({required num newer, required num older}) { + if (older == 0) { + return newer == 0 ? 0 : (newer > 0 ? 100 : -100); + } + return ((newer - older) / older) * 100; +} + +/// One tradable symbol's last-two session-half bar percent changes for auditing. class QuestionAuditAsset { QuestionAuditAsset({ required this.symbol, @@ -88,14 +138,21 @@ QuestionAuditBarSlot barRowToSlot(_BarRow row) { final num? low = raw == null ? null : MarketDataDb.readOptionalNumeric(raw['l']); final num? close = raw == null ? row.closePrice : MarketDataDb.readOptionalNumeric(raw['c']); + final num avgPrice = averageBarPrice(closePrice: row.closePrice, raw: raw); + final num volume = row.volume!; return QuestionAuditBarSlot( asOf: row.asOf, open: open, high: high, low: low, close: close ?? row.closePrice, - avgPrice: averageBarPrice(closePrice: row.closePrice, raw: raw), - volume: row.volume!, + avgPrice: avgPrice, + volume: volume, + volumeUsd: questionAuditBarVolumeUsd( + volume: volume, + avgPrice: avgPrice, + raw: raw, + ), ); } @@ -416,11 +473,20 @@ class MarketHistoryQuestionAudit { try { final QuestionAuditBarSlot newerBar = barRowToSlot(newerRow); final QuestionAuditBarSlot olderBar = barRowToSlot(olderRow); + if (olderBar.avgPrice == 0 || olderBar.volume == 0) { + continue; + } assets.add( QuestionAuditAsset( symbol: entry.key, - priceDelta: newerBar.avgPrice - olderBar.avgPrice, - volumeDelta: newerBar.volume - olderBar.volume, + priceDelta: questionAuditPercentChange( + newer: newerBar.avgPrice, + older: olderBar.avgPrice, + ), + volumeDelta: questionAuditPercentChange( + newer: newerBar.volume, + older: olderBar.volume, + ), olderSlot: olderBar, newerSlot: newerBar, ), @@ -430,10 +496,14 @@ class MarketHistoryQuestionAudit { } } - assets.sort( - (QuestionAuditAsset a, QuestionAuditAsset b) => - a.symbol.compareTo(b.symbol), - ); + assets.sort((QuestionAuditAsset a, QuestionAuditAsset b) { + final int byVolume = + questionAuditAvgVolumeUsd(b).compareTo(questionAuditAvgVolumeUsd(a)); + if (byVolume != 0) { + return byVolume; + } + return a.symbol.compareTo(b.symbol); + }); return assets; } diff --git a/server/lib/trading/prospective_answer_scoring.dart b/server/lib/trading/prospective_answer_scoring.dart new file mode 100644 index 0000000..6200a45 --- /dev/null +++ b/server/lib/trading/prospective_answer_scoring.dart @@ -0,0 +1,111 @@ +import 'dart:math' as math; + +import 'user_trading_state_db.dart'; + +/// Runtime scoring mode for prospective / guess-the-move answers. +/// +/// Set from [ServerEnv.prospectiveAnswerClosenessEnabled] at server startup. +class ProspectiveAnswerScoring { + ProspectiveAnswerScoring._(); + + /// When false (default): correct sign `+1`, wrong sign `-1`. + /// + /// When true: correct sign `+1` plus up to `+1` closeness; wrong sign `-2`. + static bool closenessExtraPointsEnabled = false; +} + +/// Breakdown of one prospective / guess-the-move answer grade. +class ProspectiveAnswerGrade { + const ProspectiveAnswerGrade({ + required this.directionPoint, + required this.closenessPoint, + required this.answerScore, + required this.absError, + required this.sameDirection, + }); + + final num directionPoint; + final num closenessPoint; + final num answerScore; + final num absError; + final bool sameDirection; +} + +/// Full credit when absolute error is within this many percentage points. +const num prospectiveAnswerFullCreditTolerance = 1; + +/// Grades a slider answer against the expected percent move. +ProspectiveAnswerGrade gradeProspectiveAnswer({ + required num userResponse, + required num correctAnswer, +}) { + final bool sameDirection = _sameDirection( + userResponse: userResponse, + correctAnswer: correctAnswer, + ); + final num absError = (userResponse - correctAnswer).abs(); + + if (!ProspectiveAnswerScoring.closenessExtraPointsEnabled) { + final num directionPoint = sameDirection ? 1 : -1; + return ProspectiveAnswerGrade( + directionPoint: directionPoint, + closenessPoint: 0, + answerScore: directionPoint, + absError: absError, + sameDirection: sameDirection, + ); + } + + final num directionPoint = sameDirection ? 1 : -2; + final num closenessPoint = sameDirection + ? _closenessPoint( + userResponse: userResponse, + correctAnswer: correctAnswer, + ) + : 0; + return ProspectiveAnswerGrade( + directionPoint: directionPoint, + closenessPoint: closenessPoint, + answerScore: directionPoint + closenessPoint, + absError: absError, + sameDirection: sameDirection, + ); +} + +/// Adds [grade] to cumulative `guess_score` in [user_trading_state]. +Future recordProspectiveGuessScore({ + required UserTradingStateDb tradingStateDb, + required String firebaseUid, + required ProspectiveAnswerGrade grade, + required String symbol, + required DateTime at, +}) async { + await tradingStateDb.recordGuessScore( + firebaseUid: firebaseUid, + scoreDelta: grade.answerScore, + symbol: symbol, + at: at, + directionCorrect: grade.sameDirection, + ); +} + +bool _sameDirection({ + required num userResponse, + required num correctAnswer, +}) { + return userResponse.compareTo(0) == correctAnswer.compareTo(0); +} + +num _closenessPoint({ + required num userResponse, + required num correctAnswer, +}) { + final num absError = (userResponse - correctAnswer).abs(); + if (absError <= prospectiveAnswerFullCreditTolerance) { + return 1; + } + final num scale = math.max(1, correctAnswer.abs()); + final num normalizedError = + (absError - prospectiveAnswerFullCreditTolerance) / scale; + return math.max(0, 1 - normalizedError); +} diff --git a/server/lib/trading/prospective_guess_assignments_db.dart b/server/lib/trading/prospective_guess_assignments_db.dart new file mode 100644 index 0000000..b2f8b20 --- /dev/null +++ b/server/lib/trading/prospective_guess_assignments_db.dart @@ -0,0 +1,257 @@ +import 'package:postgres/postgres.dart'; + +import 'market_history_session_slot.dart'; + +/// Persisted guess slot assignment (one row per user, slot pair, and symbol). +class ProspectiveGuessAssignmentsDb { + ProspectiveGuessAssignmentsDb(this._connection); + + final Connection _connection; + + static const String statusPending = 'pending'; + static const String statusAnswered = 'answered'; + + /// Question id for a pending assignment whose question is still unanswered. + Future findPendingQuestionId(String firebaseUid) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT a.question_id + FROM market_history_prospective_assignments a + INNER JOIN questions q ON q.id = a.question_id + WHERE a.assigned_user_id = @uid + AND a.status = @pending + AND q.user_response IS NULL + ORDER BY a.created_at ASC + LIMIT 1 + ''', + ), + parameters: { + 'uid': firebaseUid, + 'pending': statusPending, + }, + ); + if (result.isEmpty) { + return null; + } + return result.first[0].toString(); + } + + Future hasPendingAssignmentForSlotPair({ + required String firebaseUid, + required DateTime olderSlotStart, + required DateTime newerSlotStart, + }) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT 1 + FROM market_history_prospective_assignments a + INNER JOIN questions q ON q.id = a.question_id + WHERE a.assigned_user_id = @uid + AND a.older_slot_start = @older + AND a.newer_slot_start = @newer + AND a.status = @pending + AND q.user_response IS NULL + LIMIT 1 + ''', + ), + parameters: { + 'uid': firebaseUid, + 'older': _slot(olderSlotStart), + 'newer': _slot(newerSlotStart), + 'pending': statusPending, + }, + ); + return result.isNotEmpty; + } + + Future hasAssignmentForSymbolSlotPair({ + required String firebaseUid, + required DateTime olderSlotStart, + required DateTime newerSlotStart, + required String symbol, + }) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT 1 + FROM market_history_prospective_assignments + WHERE assigned_user_id = @uid + AND older_slot_start = @older + AND newer_slot_start = @newer + AND symbol = @symbol + LIMIT 1 + ''', + ), + parameters: { + 'uid': firebaseUid, + 'older': _slot(olderSlotStart), + 'newer': _slot(newerSlotStart), + 'symbol': symbol, + }, + ); + return result.isNotEmpty; + } + + Future> assignedSymbolsForSlotPair({ + required String firebaseUid, + required DateTime olderSlotStart, + required DateTime newerSlotStart, + }) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT symbol + FROM market_history_prospective_assignments + WHERE assigned_user_id = @uid + AND older_slot_start = @older + AND newer_slot_start = @newer + ''', + ), + parameters: { + 'uid': firebaseUid, + 'older': _slot(olderSlotStart), + 'newer': _slot(newerSlotStart), + }, + ); + return result.map((ResultRow row) => row[0]! as String).toSet(); + } + + Future> answeredSymbolsForSlotPair({ + required String firebaseUid, + required DateTime olderSlotStart, + required DateTime newerSlotStart, + }) async { + final Result result = await _connection.execute( + Sql.named( + ''' + SELECT symbol + FROM market_history_prospective_assignments + WHERE assigned_user_id = @uid + AND older_slot_start = @older + AND newer_slot_start = @newer + AND status = @answered + ''', + ), + parameters: { + 'uid': firebaseUid, + 'older': _slot(olderSlotStart), + 'newer': _slot(newerSlotStart), + 'answered': statusAnswered, + }, + ); + return result.map((ResultRow row) => row[0]! as String).toSet(); + } + + /// Inserts a pending row when none exists for this user/slot pair/symbol. + /// + /// Returns false when an assignment already exists (unique constraint). + Future insertPendingIfAbsent({ + required String firebaseUid, + required DateTime olderSlotStart, + required DateTime newerSlotStart, + required String symbol, + required String prospectiveQuestionId, + required String questionId, + }) async { + final Result result = await _connection.execute( + Sql.named( + ''' + INSERT INTO market_history_prospective_assignments ( + assigned_user_id, + older_slot_start, + newer_slot_start, + symbol, + prospective_question_id, + question_id, + status + ) VALUES ( + @uid, + @older, + @newer, + @symbol, + @prospective_question_id::uuid, + @question_id::uuid, + @pending + ) + ON CONFLICT (assigned_user_id, older_slot_start, newer_slot_start, symbol) + DO NOTHING + RETURNING id + ''', + ), + parameters: { + 'uid': firebaseUid, + 'older': _slot(olderSlotStart), + 'newer': _slot(newerSlotStart), + 'symbol': symbol, + 'prospective_question_id': prospectiveQuestionId, + 'question_id': questionId, + 'pending': statusPending, + }, + ); + return result.isNotEmpty; + } + + Future insertPending({ + required String firebaseUid, + required DateTime olderSlotStart, + required DateTime newerSlotStart, + required String symbol, + required String prospectiveQuestionId, + required String questionId, + }) async { + final bool inserted = await insertPendingIfAbsent( + firebaseUid: firebaseUid, + olderSlotStart: olderSlotStart, + newerSlotStart: newerSlotStart, + symbol: symbol, + prospectiveQuestionId: prospectiveQuestionId, + questionId: questionId, + ); + if (!inserted) { + throw StateError( + 'Assignment already exists for $firebaseUid $symbol ' + '${_slot(olderSlotStart).toIso8601String()}', + ); + } + } + + Future markAnsweredByQuestionId({ + required String questionId, + required DateTime answeredAt, + }) async { + await _connection.execute( + Sql.named( + ''' + UPDATE market_history_prospective_assignments + SET status = @answered, + answered_at = @answered_at + WHERE question_id = @question_id::uuid + AND status = @pending + ''', + ), + parameters: { + 'question_id': questionId, + 'answered': statusAnswered, + 'answered_at': answeredAt.toUtc(), + 'pending': statusPending, + }, + ); + } + + Future deleteAllForUser(String firebaseUid) async { + await _connection.execute( + Sql.named( + ''' + DELETE FROM market_history_prospective_assignments + WHERE assigned_user_id = @uid + ''', + ), + parameters: {'uid': firebaseUid}, + ); + } + + static DateTime _slot(DateTime value) => + MarketHistorySessionSlot.slotStartContaining(value.toUtc()); +} diff --git a/server/lib/trading/prospective_guess_selection.dart b/server/lib/trading/prospective_guess_selection.dart new file mode 100644 index 0000000..1c0af2c --- /dev/null +++ b/server/lib/trading/prospective_guess_selection.dart @@ -0,0 +1,226 @@ +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?'; +} diff --git a/server/lib/trading/trading_pipeline.dart b/server/lib/trading/trading_pipeline.dart index a5f3bc8..8ed8467 100644 --- a/server/lib/trading/trading_pipeline.dart +++ b/server/lib/trading/trading_pipeline.dart @@ -9,6 +9,7 @@ import 'guardrails.dart'; import 'market_data_db.dart'; import '../market_history_env.dart'; import 'market_history_query.dart'; +import 'prospective_answer_scoring.dart'; import 'rule_engine.dart'; import 'symbol_obfuscator.dart'; import 'trading_config.dart'; @@ -244,15 +245,22 @@ class TradingPipeline { await _tradingStateDb.getRuleState(firebaseUid, ruleId); if (rule.type == 'guess_weekly_move') { - await _handleGuessAnswer( - firebaseUid: firebaseUid, - rule: rule, - questionId: questionId, - userResponse: userResponse, - correctAnswer: correctAnswer, - priorState: priorState, - now: now, + final Map metadata = Map.from( + answeredQuestion['metadata'] as Map? ?? + {}, ); + // Login/bootstrap prospective questions are graded in [QuestionsDb.submitAnswer]. + if (metadata['prospective_question_id'] == null) { + await _handleGuessAnswer( + firebaseUid: firebaseUid, + rule: rule, + questionId: questionId, + userResponse: userResponse, + correctAnswer: correctAnswer, + priorState: priorState, + now: now, + ); + } return; } @@ -381,13 +389,17 @@ class TradingPipeline { required Map? priorState, required DateTime now, }) async { - final int scoreDelta = userResponse == correctAnswer ? 1 : -1; + final ProspectiveAnswerGrade grade = gradeProspectiveAnswer( + userResponse: userResponse, + correctAnswer: correctAnswer, + ); final String symbol = (priorState?['symbol'] as String?) ?? rule.symbol; - await _tradingStateDb.recordGuessScore( + await recordProspectiveGuessScore( + tradingStateDb: _tradingStateDb, firebaseUid: firebaseUid, - scoreDelta: scoreDelta, + grade: grade, symbol: symbol, at: now, ); @@ -396,8 +408,12 @@ class TradingPipeline { ...?priorState, 'phase': TradingPhases.done, 'question_id': questionId, - 'answer': userResponse == correctAnswer ? 'match' : 'miss', - 'score_delta': scoreDelta, + 'answer': grade.sameDirection ? 'match' : 'miss', + 'score_delta': grade.answerScore, + 'direction_point': grade.directionPoint, + 'closeness_point': grade.closenessPoint, + 'answer_score': grade.answerScore, + 'error_abs': grade.absError, 'answered_at': now.toIso8601String(), }; await _tradingStateDb.setRuleState( diff --git a/server/lib/trading/user_trading_state_db.dart b/server/lib/trading/user_trading_state_db.dart index c1f0467..457cb04 100644 --- a/server/lib/trading/user_trading_state_db.dart +++ b/server/lib/trading/user_trading_state_db.dart @@ -2,6 +2,8 @@ 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); @@ -190,20 +192,31 @@ class UserTradingStateDb { Future recordGuessScore({ required String firebaseUid, - required int scoreDelta, + required num scoreDelta, required String symbol, required DateTime at, + required bool directionCorrect, }) async { await ensureExists(firebaseUid); final Map context = await getContext(firebaseUid); final Map prior = Map.from( context[guessScoreContextKey] as Map? ?? {}, ); - final int total = ((prior['total'] as num?)?.toInt() ?? 0) + scoreDelta; + 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] = { 'total': total, + 'answers_total': answersTotal, + 'answers_correct': answersCorrect, + if (slotWire != null) 'slot_start': slotWire, 'last': { 'score_delta': scoreDelta, + 'direction_correct': directionCorrect, 'symbol': symbol, 'at': at.toUtc().toIso8601String(), }, @@ -211,6 +224,68 @@ class UserTradingStateDb { await _writeContext(firebaseUid, context, touchEvalAt: true); } + Future getGuessSlotStart(String firebaseUid) async { + final Map? 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 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 setGuessSlotStart(String firebaseUid, DateTime slotStart) async { + await ensureExists(firebaseUid); + final Map context = await getContext(firebaseUid); + final Map prior = Map.from( + context[guessScoreContextKey] as Map? ?? {}, + ); + 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 resetGuessScore( + String firebaseUid, { + required DateTime slotStart, + }) async { + await ensureExists(firebaseUid); + await setGuessScore( + firebaseUid, + { + 'total': 0, + 'answers_total': 0, + 'answers_correct': 0, + 'slot_start': MarketHistorySessionSlot.slotStartWire(slotStart), + }, + ); + } + + /// Replaces `guess_score` in context (merges into full user context). + Future setGuessScore( + String firebaseUid, + Map guessScore, + ) async { + await ensureExists(firebaseUid); + final Map context = await getContext(firebaseUid); + context[guessScoreContextKey] = guessScore; + await _writeContext(firebaseUid, context, touchEvalAt: false); + } + Future getGuessSymbolLastPickedAt( String firebaseUid, String symbol, diff --git a/server/lib/workers/market_history_scheduler.dart b/server/lib/workers/market_history_scheduler.dart index 5b9761d..71b838c 100644 --- a/server/lib/workers/market_history_scheduler.dart +++ b/server/lib/workers/market_history_scheduler.dart @@ -4,6 +4,7 @@ import 'package:postgres/postgres.dart'; import '../trading/market_data_history.dart'; import '../trading/market_data_retention.dart'; +import '../trading/market_history_prospective_questions.dart'; import '../trading/sync_run_recorder.dart'; import '../trading/tradable_assets_sync.dart'; import 'market_history_scheduler_config.dart'; @@ -15,7 +16,13 @@ class MarketHistorySchedulerReport { final List ranStages; } -/// Market-history pipeline: universe → backfill → cleanup. +/// Market-history pipeline: universe → backfill → cleanup → prospective questions. +/// +/// **Prospective questions** ([MarketHistoryProspectiveQuestions.refresh]): upserts +/// top-50% volume symbols for the latest two session-half slots under a DB unique +/// key `(symbol, older_slot_start, newer_slot_start)` and an advisory lock. **Cleanup** +/// ([MarketDataRetention]) deletes bar rows and prospective rows with +/// `older_slot_start` before the retention cutoff. /// /// Before each run, stale or orphaned in-progress `market_data_sync_runs` rows /// are aborted so a hung prior sync cannot block the worker. @@ -26,12 +33,14 @@ class MarketHistoryScheduler { Future Function(DateTime now)? runUniverse, Future Function(DateTime now)? runBackfill, Future Function(DateTime now)? runCleanup, + Future Function(DateTime now)? runProspectiveQuestions, Future Function(DateTime now)? backfillIsDue, }) : _connection = connection, _recorder = SyncRunRecorder(connection), _runUniverse = runUniverse, _runBackfill = runBackfill, _runCleanup = runCleanup, + _runProspectiveQuestions = runProspectiveQuestions, _backfillIsDue = backfillIsDue; final Connection _connection; @@ -40,6 +49,7 @@ class MarketHistoryScheduler { final Future Function(DateTime now)? _runUniverse; final Future Function(DateTime now)? _runBackfill; final Future Function(DateTime now)? _runCleanup; + final Future Function(DateTime now)? _runProspectiveQuestions; final Future Function(DateTime now)? _backfillIsDue; bool _pipelineActive = false; @@ -87,6 +97,17 @@ class MarketHistoryScheduler { ran: ran, ); + if (_runProspectiveQuestions != null) { + try { + await _runProspectiveQuestions(tick); + ran.add(MarketHistoryProspectiveQuestions.kind); + } catch (e, st) { + stderr.writeln( + 'Market history prospective questions refresh failed: $e\n$st', + ); + } + } + return MarketHistorySchedulerReport(ranStages: ran); } finally { _pipelineActive = false; @@ -143,6 +164,12 @@ class MarketHistoryScheduler { int cadenceHours, { Future Function(DateTime now)? slotGate, }) async { + // Slot-gated stages (backfill) must run as soon as pending work exists, + // regardless of syncHourUtc. + if (slotGate != null) { + return slotGate(now); + } + final DateTime? last = await _lastFinishedAt(kind); if (config.syncHourUtc != null) { @@ -154,10 +181,6 @@ class MarketHistoryScheduler { } } - if (slotGate != null) { - return slotGate(now); - } - if (last == null) { return true; } diff --git a/server/migrations/011_market_history_prospective_questions.sql b/server/migrations/011_market_history_prospective_questions.sql new file mode 100644 index 0000000..bedc661 --- /dev/null +++ b/server/migrations/011_market_history_prospective_questions.sql @@ -0,0 +1,25 @@ +-- Prospective guess-the-move questions from the last two session-half bars. +-- Refreshed by the market-history worker (top 50% symbols by avg dollar volume). + +CREATE TABLE IF NOT EXISTS market_history_prospective_questions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + compare_until TIMESTAMPTZ NOT NULL, + newer_slot_start TIMESTAMPTZ NOT NULL, + older_slot_start TIMESTAMPTZ NOT NULL, + symbol TEXT NOT NULL, + question_text TEXT NOT NULL, + correct_answer NUMERIC NOT NULL, + price_delta_pct NUMERIC NOT NULL, + volume_delta_pct NUMERIC NOT NULL, + avg_volume_usd NUMERIC NOT NULL, + older_slot JSONB NOT NULL, + newer_slot JSONB NOT NULL, + refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (compare_until, symbol) +); + +CREATE INDEX IF NOT EXISTS market_history_prospective_questions_compare_until_idx + ON market_history_prospective_questions (compare_until DESC); + +CREATE INDEX IF NOT EXISTS market_history_prospective_questions_volume_idx + ON market_history_prospective_questions (compare_until, avg_volume_usd DESC); diff --git a/server/migrations/012_prospective_questions_slot_pair_key.sql b/server/migrations/012_prospective_questions_slot_pair_key.sql new file mode 100644 index 0000000..0c080e9 --- /dev/null +++ b/server/migrations/012_prospective_questions_slot_pair_key.sql @@ -0,0 +1,14 @@ +-- One question per symbol per canonical (older, newer) session-half slot pair. + +ALTER TABLE market_history_prospective_questions + DROP CONSTRAINT IF EXISTS market_history_prospective_questions_compare_until_symbol_key; + +ALTER TABLE market_history_prospective_questions + DROP CONSTRAINT IF EXISTS market_history_prospective_questions_slot_pair_symbol_key; + +ALTER TABLE market_history_prospective_questions + ADD CONSTRAINT market_history_prospective_questions_slot_pair_symbol_key + UNIQUE (symbol, older_slot_start, newer_slot_start); + +CREATE INDEX IF NOT EXISTS market_history_prospective_questions_slot_pair_idx + ON market_history_prospective_questions (older_slot_start, newer_slot_start); diff --git a/server/migrations/013_market_history_prospective_answers.sql b/server/migrations/013_market_history_prospective_answers.sql new file mode 100644 index 0000000..a4799e5 --- /dev/null +++ b/server/migrations/013_market_history_prospective_answers.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS market_history_prospective_answers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + question_id UUID NOT NULL REFERENCES questions (id) ON DELETE CASCADE, + assigned_user_id TEXT NOT NULL REFERENCES users (firebase_uid) ON DELETE CASCADE, + prospective_question_id UUID NOT NULL REFERENCES market_history_prospective_questions (id) ON DELETE CASCADE, + symbol TEXT NOT NULL, + older_slot_start TIMESTAMPTZ NOT NULL, + newer_slot_start TIMESTAMPTZ NOT NULL, + expected_percent_increase_price NUMERIC NOT NULL, + user_slider_value NUMERIC NOT NULL, + answered_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (question_id) +); + +CREATE INDEX IF NOT EXISTS market_history_prospective_answers_user_answered_idx + ON market_history_prospective_answers (assigned_user_id, answered_at DESC); + +CREATE INDEX IF NOT EXISTS market_history_prospective_answers_prospective_idx + ON market_history_prospective_answers (prospective_question_id); diff --git a/server/migrations/014_market_history_prospective_assignments.sql b/server/migrations/014_market_history_prospective_assignments.sql new file mode 100644 index 0000000..988b1bf --- /dev/null +++ b/server/migrations/014_market_history_prospective_assignments.sql @@ -0,0 +1,23 @@ +-- One guess assignment per user per session-half slot pair (issued at question create). + +CREATE TABLE IF NOT EXISTS market_history_prospective_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + assigned_user_id TEXT NOT NULL REFERENCES users (firebase_uid) ON DELETE CASCADE, + older_slot_start TIMESTAMPTZ NOT NULL, + newer_slot_start TIMESTAMPTZ NOT NULL, + symbol TEXT NOT NULL, + prospective_question_id UUID NOT NULL REFERENCES market_history_prospective_questions (id) ON DELETE CASCADE, + question_id UUID NOT NULL REFERENCES questions (id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'answered')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + answered_at TIMESTAMPTZ, + UNIQUE (assigned_user_id, older_slot_start, newer_slot_start) +); + +CREATE INDEX IF NOT EXISTS market_history_prospective_assignments_user_pending_idx + ON market_history_prospective_assignments (assigned_user_id, status) + WHERE status = 'pending'; + +CREATE INDEX IF NOT EXISTS market_history_prospective_assignments_question_idx + ON market_history_prospective_assignments (question_id); diff --git a/server/migrations/015_prospective_assignments_per_symbol.sql b/server/migrations/015_prospective_assignments_per_symbol.sql new file mode 100644 index 0000000..eeda348 --- /dev/null +++ b/server/migrations/015_prospective_assignments_per_symbol.sql @@ -0,0 +1,14 @@ +-- One guess assignment per user per slot pair per symbol (top-half assets). + +ALTER TABLE market_history_prospective_assignments + DROP CONSTRAINT IF EXISTS market_history_prospective_as_assigned_user_id_older_slot_s_key; + +ALTER TABLE market_history_prospective_assignments + DROP CONSTRAINT IF EXISTS market_history_prospective_assignments_assigned_user_id_older_slot_s_key; + +ALTER TABLE market_history_prospective_assignments + DROP CONSTRAINT IF EXISTS market_history_prospective_assignments_user_slot_symbol_key; + +ALTER TABLE market_history_prospective_assignments + ADD CONSTRAINT market_history_prospective_assignments_user_slot_symbol_key + UNIQUE (assigned_user_id, older_slot_start, newer_slot_start, symbol); diff --git a/server/migrations/016_prospective_answers_user_symbol_slot_key.sql b/server/migrations/016_prospective_answers_user_symbol_slot_key.sql new file mode 100644 index 0000000..eb5e6a3 --- /dev/null +++ b/server/migrations/016_prospective_answers_user_symbol_slot_key.sql @@ -0,0 +1,8 @@ +-- One answered guess per user per asset per session-half slot pair. + +ALTER TABLE market_history_prospective_answers + DROP CONSTRAINT IF EXISTS market_history_prospective_answers_user_symbol_slot_key; + +ALTER TABLE market_history_prospective_answers + ADD CONSTRAINT market_history_prospective_answers_user_symbol_slot_key + UNIQUE (assigned_user_id, symbol, older_slot_start, newer_slot_start); diff --git a/server/test/helpers/test_db.dart b/server/test/helpers/test_db.dart index 86a01dd..11cce15 100644 --- a/server/test/helpers/test_db.dart +++ b/server/test/helpers/test_db.dart @@ -11,7 +11,7 @@ import 'package:cyberhybridhub_server/trading/user_trading_state_db.dart'; import 'package:dotenv/dotenv.dart'; import 'package:postgres/postgres.dart'; -/// Integration test Postgres: [cyberhybridhub_test] with migrations 001–010. +/// Integration test Postgres: [cyberhybridhub_test] with migrations 001–016. class TestDb { TestDb._(this.db, this._connection, this.databaseUrl); @@ -124,6 +124,8 @@ class TestDb { ''' TRUNCATE TABLE trade_orders, + market_history_prospective_assignments, + market_history_prospective_questions, market_data_snapshots, market_data_sync_runs, tradable_assets, diff --git a/server/test/integration/market_data_retention_test.dart b/server/test/integration/market_data_retention_test.dart index a3b6923..f6c07f4 100644 --- a/server/test/integration/market_data_retention_test.dart +++ b/server/test/integration/market_data_retention_test.dart @@ -51,7 +51,7 @@ void main() { final MarketDataDb db = testDb!.marketDataDb; final DateTime now = retentionNow; final DateTime cutoff = - MarketHistorySessionSlot.windowFirstSlotStart(now, 7); + MarketHistorySessionSlot.windowFirstSlotStart(now, 5); await db.upsertSnapshot( symbol: 'SPY', @@ -88,6 +88,57 @@ void main() { expect((remaining.first[0]! as num).toInt(), 2); }); + test('deletes prospective questions when older slot is before cutoff', + () async { + if (testDb == null) { + markTestSkipped( + 'Set DATABASE_URL or TEST_DATABASE_URL for integration tests', + ); + return; + } + + final DateTime now = retentionNow; + final DateTime cutoff = + MarketHistorySessionSlot.windowFirstSlotStart(now, 5); + final DateTime olderSlot = cutoff.subtract(const Duration(days: 2)); + final DateTime newerSlot = cutoff.add(const Duration(hours: 3, minutes: 15)); + + await testDb!.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 + ) VALUES ( + @compare_until, @newer_slot_start, @older_slot_start, + 'SPY', 'test', 10, 10, 0, 1000, + '{}'::jsonb, '{}'::jsonb + ) + ''', + ), + parameters: { + 'compare_until': newerSlot.add(const Duration(hours: 3, minutes: 15)), + 'newer_slot_start': newerSlot, + 'older_slot_start': olderSlot, + }, + ); + + final MarketDataRetentionResult result = await MarketDataRetention( + connection: testDb!.connection, + windowDays: 5, + ).runCleanup(now: now); + + expect(result.error, isNull); + expect(result.rowsRemoved, greaterThanOrEqualTo(1)); + + final Result remaining = await testDb!.connection.execute( + 'SELECT COUNT(*)::int FROM market_history_prospective_questions', + ); + expect((remaining.first[0]! as num).toInt(), 0); + }); + test('empty table returns rowsRemoved 0 without throwing', () async { if (testDb == null) { markTestSkipped( @@ -118,7 +169,7 @@ void main() { final DateTime now = retentionNow; final DateTime oldAsOf = MarketHistorySessionSlot.windowFirstSlotStart( now, - 7, + 5, ).subtract(const Duration(days: 2)); await testDb!.connection.execute( @@ -161,7 +212,7 @@ void main() { final DateTime now = retentionNow; final DateTime cutoff = - MarketHistorySessionSlot.windowFirstSlotStart(now, 7); + MarketHistorySessionSlot.windowFirstSlotStart(now, 5); await testDb!.marketDataDb.upsertSnapshot( symbol: 'SPY', metric: 'bar', @@ -198,7 +249,7 @@ void main() { final MarketDataDb db = testDb!.marketDataDb; final DateTime now = retentionNow; final DateTime cutoff = - MarketHistorySessionSlot.windowFirstSlotStart(now, 7); + MarketHistorySessionSlot.windowFirstSlotStart(now, 5); final MarketDataSnapshot kept = await db.upsertSnapshot( symbol: 'SPY', @@ -236,7 +287,7 @@ void main() { final DateTime now = retentionNow; final DateTime cutoff = - MarketHistorySessionSlot.windowFirstSlotStart(now, 7); + MarketHistorySessionSlot.windowFirstSlotStart(now, 5); await testDb!.marketDataDb.upsertSnapshot( symbol: 'SPY', metric: 'bar', @@ -281,7 +332,7 @@ void main() { final DateTime now = retentionNow; final DateTime cutoff = - MarketHistorySessionSlot.windowFirstSlotStart(now, 7); + MarketHistorySessionSlot.windowFirstSlotStart(now, 5); await testDb!.marketDataDb.upsertSnapshot( symbol: 'SPY', metric: 'bar', @@ -320,7 +371,7 @@ void main() { final DateTime now = retentionNow; final DateTime cutoff = - MarketHistorySessionSlot.windowFirstSlotStart(now, 7); + MarketHistorySessionSlot.windowFirstSlotStart(now, 5); await testDb!.marketDataDb.upsertSnapshot( symbol: 'SPY', metric: 'bar', diff --git a/server/test/integration/market_history_admin_handler_test.dart b/server/test/integration/market_history_admin_handler_test.dart index 36dc521..851f846 100644 --- a/server/test/integration/market_history_admin_handler_test.dart +++ b/server/test/integration/market_history_admin_handler_test.dart @@ -505,7 +505,9 @@ void main() { Sql.named( ''' INSERT INTO tradable_assets (symbol, asset_class, status, tradable, refreshed_at) - VALUES ('AAA', 'us_equity', 'active', true, @refreshed_at) + VALUES + ('AAA', 'us_equity', 'active', true, @refreshed_at), + ('BBB', 'us_equity', 'active', true, @refreshed_at) ''', ), parameters: {'refreshed_at': now}, @@ -518,6 +520,7 @@ void main() { required num low, required num close, required num volume, + String symbol = 'AAA', }) async { await testDb!.connection.execute( Sql.named( @@ -525,11 +528,12 @@ void main() { INSERT INTO market_data_snapshots ( symbol, asset_class, feed, metric, timeframe, price, volume, as_of, raw ) VALUES ( - 'AAA', 'us_equity', 'iex', 'bar', 'sessionHalf', @close, @volume, @as_of, @raw::jsonb + @symbol, 'us_equity', 'iex', 'bar', 'sessionHalf', @close, @volume, @as_of, @raw::jsonb ) ''', ), parameters: { + 'symbol': symbol, 'as_of': asOf, 'close': close, 'volume': volume, @@ -569,6 +573,24 @@ void main() { close: 12, volume: 150, ); + await insertBar( + asOf: olderSlot, + open: 20, + high: 22, + low: 18, + close: 20, + volume: 200, + symbol: 'BBB', + ); + await insertBar( + asOf: newerSlot, + open: 24, + high: 26, + low: 22, + close: 24, + volume: 300, + symbol: 'BBB', + ); final Handler handler = marketHistoryAdminHandler( auth: _FakeAuthVerifier(), @@ -599,11 +621,13 @@ void main() { MarketHistorySessionSlot.endExclusive(newerSlot), ); final List assets = body['assets'] as List; - expect(assets, hasLength(1)); + expect(assets, hasLength(2)); + expect((assets[0] as Map)['symbol'], 'BBB'); + expect((assets[1] as Map)['symbol'], 'AAA'); - final Map aaa = assets.first as Map; + final Map aaa = assets[1] as Map; expect(aaa['symbol'], 'AAA'); - expect(aaa['priceDelta'], 2); + expect(aaa['priceDelta'], 20); expect(aaa['volumeDelta'], 50); expect(aaa.containsKey('raw'), isFalse); @@ -613,9 +637,11 @@ void main() { aaa['newerSlot'] as Map; expect(older['avgPrice'], 10); expect(older['volume'], 100); + expect(older['volumeUsd'], 1000); expect(older['open'], 10); expect(newer['avgPrice'], 12); expect(newer['volume'], 150); + expect(newer['volumeUsd'], 1800); expect(newer['close'], 12); expect( DateTime.parse(body['newerSlotStart'] as String).toUtc(), diff --git a/server/test/integration/market_history_prospective_questions_test.dart b/server/test/integration/market_history_prospective_questions_test.dart new file mode 100644 index 0000000..89aeca1 --- /dev/null +++ b/server/test/integration/market_history_prospective_questions_test.dart @@ -0,0 +1,878 @@ +@Tags(['integration', 'postgres']) +library; + +import 'dart:convert'; + +import 'package:cyberhybridhub_server/question_service.dart'; +import 'package:cyberhybridhub_server/trading/market_data_db.dart'; +import 'package:cyberhybridhub_server/trading/market_history_prospective_questions.dart'; +import 'package:cyberhybridhub_server/trading/market_history_config.dart'; +import 'package:cyberhybridhub_server/trading/prospective_answer_scoring.dart'; +import 'package:cyberhybridhub_server/trading/prospective_guess_assignments_db.dart'; +import 'package:cyberhybridhub_server/trading/prospective_guess_selection.dart'; +import 'package:cyberhybridhub_server/trading/market_history_session_slot.dart'; +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_db.dart'; + +void main() { + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + test('refresh stores top 50% volume symbols with price pct answers', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final DateTime now = DateTime.now().toUtc(); + final DateTime newerSlot = + MarketHistorySessionSlot.lastCompletedSlotStart(now); + final DateTime olderSlot = + MarketHistorySessionSlot.previousSlotStart(newerSlot)!; + + await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO tradable_assets (symbol, asset_class, status, tradable, refreshed_at) + VALUES + ('HIGH', 'us_equity', 'active', true, @refreshed_at), + ('MID', 'us_equity', 'active', true, @refreshed_at), + ('LOW', 'us_equity', 'active', true, @refreshed_at) + ''', + ), + parameters: {'refreshed_at': now}, + ); + + Future insertBar({ + required String symbol, + required DateTime asOf, + required num open, + required num high, + required num low, + required num close, + required num volume, + }) async { + await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO market_data_snapshots ( + symbol, asset_class, feed, metric, timeframe, price, volume, as_of, raw + ) VALUES ( + @symbol, 'us_equity', 'iex', 'bar', 'sessionHalf', @close, @volume, @as_of, @raw::jsonb + ) + ''', + ), + parameters: { + 'symbol': symbol, + 'as_of': asOf, + 'close': close, + 'volume': volume, + 'raw': jsonEncode({ + 'o': open, + 'h': high, + 'l': low, + 'c': close, + 'v': volume, + 'slot_start': MarketHistorySessionSlot.slotStartWire(asOf), + }), + }, + ); + } + + // HIGH: avg vol usd = (5000+6000)/2 = 5500 + await insertBar( + symbol: 'HIGH', + asOf: olderSlot, + open: 10, + high: 10, + low: 10, + close: 10, + volume: 500, + ); + await insertBar( + symbol: 'HIGH', + asOf: newerSlot, + open: 12, + high: 12, + low: 12, + close: 12, + volume: 500, + ); + // MID: avg vol usd = 3000 + await insertBar( + symbol: 'MID', + asOf: olderSlot, + open: 20, + high: 20, + low: 20, + close: 20, + volume: 150, + ); + await insertBar( + symbol: 'MID', + asOf: newerSlot, + open: 20, + high: 20, + low: 20, + close: 20, + volume: 150, + ); + // LOW: avg vol usd = 500 + await insertBar( + symbol: 'LOW', + asOf: olderSlot, + open: 5, + high: 5, + low: 5, + close: 5, + volume: 100, + ); + await insertBar( + symbol: 'LOW', + asOf: newerSlot, + open: 5, + high: 5, + low: 5, + close: 5, + volume: 100, + ); + + final MarketHistoryProspectiveQuestions sync = + MarketHistoryProspectiveQuestions(connection: testDb!.connection); + final ProspectiveQuestionsRefreshResult result = + await sync.refresh(now: now); + + expect(result.succeeded, isTrue); + expect(result.rowsWritten, 2); + + final Result rows = await testDb!.connection.execute( + ''' + SELECT symbol, correct_answer, price_delta_pct, avg_volume_usd + FROM market_history_prospective_questions + ORDER BY avg_volume_usd DESC + ''', + ); + expect(rows, hasLength(2)); + expect(rows[0][0], 'HIGH'); + expect(rows[1][0], 'MID'); + expect(MarketDataDb.readOptionalNumeric(rows[0][1]), 20); + expect(MarketDataDb.readOptionalNumeric(rows[0][2]), 20); + expect(MarketDataDb.readOptionalNumeric(rows[1][1]), 0); + expect(MarketDataDb.readOptionalNumeric(rows[0][3]), closeTo(5500, 0.01)); + + final Result syncRuns = await testDb!.connection.execute( + ''' + SELECT COUNT(*)::int FROM market_data_sync_runs + WHERE kind = 'prospective_questions' + ''', + ); + expect((syncRuns.first[0]! as num).toInt(), 0); + }); + + test('refresh is idempotent for same slot pair', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final DateTime now = DateTime.now().toUtc(); + final DateTime newerSlot = + MarketHistorySessionSlot.lastCompletedSlotStart(now); + final DateTime olderSlot = + MarketHistorySessionSlot.previousSlotStart(newerSlot)!; + + await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO tradable_assets (symbol, asset_class, status, tradable, refreshed_at) + VALUES ('AAA', 'us_equity', 'active', true, @refreshed_at) + ''', + ), + parameters: {'refreshed_at': now}, + ); + + for (final DateTime asOf in [olderSlot, newerSlot]) { + await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO market_data_snapshots ( + symbol, asset_class, feed, metric, timeframe, price, volume, as_of, raw + ) VALUES ( + 'AAA', 'us_equity', 'iex', 'bar', 'sessionHalf', 10, 100, @as_of, @raw::jsonb + ) + ''', + ), + parameters: { + 'as_of': asOf, + 'raw': jsonEncode({ + 'o': 10, + 'h': 10, + 'l': 10, + 'c': 10, + 'v': 100, + 'slot_start': MarketHistorySessionSlot.slotStartWire(asOf), + }), + }, + ); + } + + final MarketHistoryProspectiveQuestions sync = + MarketHistoryProspectiveQuestions(connection: testDb!.connection); + await sync.refresh(now: now); + await sync.refresh(now: now); + + final Result rows = await testDb!.connection.execute( + ''' + SELECT COUNT(*)::int FROM market_history_prospective_questions + WHERE symbol = 'AAA' + ''', + ); + expect((rows.first[0]! as num).toInt(), 1); + }); + + test('refresh prunes symbol dropped from top half for slot pair', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + final DateTime now = DateTime.now().toUtc(); + final DateTime newerSlot = + MarketHistorySessionSlot.lastCompletedSlotStart(now); + final DateTime olderSlot = + MarketHistorySessionSlot.previousSlotStart(newerSlot)!; + final DateTime compareUntil = + MarketHistorySessionSlot.endExclusive(newerSlot); + + await testDb!.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 + ) VALUES ( + @compare_until, @newer, @older, + 'STALE', 'test', 0, 0, 0, 1, + '{}'::jsonb, '{}'::jsonb + ) + ''', + ), + parameters: { + 'compare_until': compareUntil, + 'newer': newerSlot, + 'older': olderSlot, + }, + ); + + await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO tradable_assets (symbol, asset_class, status, tradable, refreshed_at) + VALUES ('AAA', 'us_equity', 'active', true, @refreshed_at) + ''', + ), + parameters: {'refreshed_at': now}, + ); + + for (final DateTime asOf in [olderSlot, newerSlot]) { + await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO market_data_snapshots ( + symbol, asset_class, feed, metric, timeframe, price, volume, as_of, raw + ) VALUES ( + 'AAA', 'us_equity', 'iex', 'bar', 'sessionHalf', 10, 1000, @as_of, @raw::jsonb + ) + ''', + ), + parameters: { + 'as_of': asOf, + 'raw': jsonEncode({ + 'o': 10, + 'h': 10, + 'l': 10, + 'c': 10, + 'v': 1000, + 'slot_start': MarketHistorySessionSlot.slotStartWire(asOf), + }), + }, + ); + } + + await MarketHistoryProspectiveQuestions(connection: testDb!.connection) + .refresh(now: now); + + final Result rows = await testDb!.connection.execute( + Sql.named( + ''' + SELECT symbol FROM market_history_prospective_questions + WHERE older_slot_start = @older AND newer_slot_start = @newer + ORDER BY symbol + ''', + ), + parameters: { + 'older': olderSlot, + 'newer': newerSlot, + }, + ); + expect(rows.map((ResultRow r) => r[0]).toList(), ['AAA']); + }); + + test('submitAnswer stores linked prospective answer snapshot', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + ProspectiveAnswerScoring.closenessExtraPointsEnabled = true; + addTearDown(() { + ProspectiveAnswerScoring.closenessExtraPointsEnabled = false; + }); + + const String uid = 'prospective-answer-uid'; + final DateTime now = DateTime.now().toUtc(); + final DateTime newerSlot = + MarketHistorySessionSlot.lastCompletedSlotStart(now); + final DateTime olderSlot = + MarketHistorySessionSlot.previousSlotStart(newerSlot)!; + + await testDb!.seedUser(uid); + + final Result insertedProspective = await testDb!.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 + ) VALUES ( + @compare_until, @newer_slot_start, @older_slot_start, + 'SPY', 'What was SPY move?', 4.25, + 4.25, 2.0, 123456, + '{}'::jsonb, '{}'::jsonb + ) + RETURNING id + ''', + ), + parameters: { + 'compare_until': MarketHistorySessionSlot.endExclusive(newerSlot), + 'newer_slot_start': newerSlot, + 'older_slot_start': olderSlot, + }, + ); + final String prospectiveId = insertedProspective.first[0].toString(); + + final Map question = await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: 'Guess the move', + correctAnswer: 4.25, + sourceTag: 'market_history:prospective', + pipelineKey: 'trading', + pipelineStep: 'guess_weekly_move:await_answer', + metadata: { + 'prospective_question_id': prospectiveId, + }, + ); + + await testDb!.questionsDb.submitAnswer( + questionId: question['id']! as String, + assignedUserId: uid, + userResponse: 3.5, + ); + + final Result answers = await testDb!.connection.execute( + Sql.named( + ''' + SELECT assigned_user_id, prospective_question_id, symbol, + older_slot_start, newer_slot_start, + expected_percent_increase_price, user_slider_value + FROM market_history_prospective_answers + WHERE question_id = @question_id::uuid + ''', + ), + parameters: { + 'question_id': question['id']! as String, + }, + ); + + expect(answers, hasLength(1)); + final ResultRow answerRow = answers.first; + expect(answerRow[0], uid); + expect(answerRow[1].toString(), prospectiveId); + expect(answerRow[2], 'SPY'); + expect((answerRow[3]! as DateTime).toUtc(), olderSlot); + expect((answerRow[4]! as DateTime).toUtc(), newerSlot); + expect(MarketDataDb.readOptionalNumeric(answerRow[5]), 4.25); + expect(MarketDataDb.readOptionalNumeric(answerRow[6]), 3.5); + + final Map? guessScore = + await testDb!.userTradingStateDb.getGuessScore(uid); + expect(guessScore, isNotNull); + expect(MarketDataDb.readOptionalNumeric(guessScore!['total']), 2); + expect((guessScore['answers_total'] as num).toInt(), 1); + expect((guessScore['answers_correct'] as num).toInt(), 1); + + final Map summary = + await testDb!.questionsDb.getGuessScoreSummary(uid); + expect(summary['answersTotal'], 1); + expect(summary['answersCorrect'], 1); + expect(summary['percentCorrect'], 100); + + final Map resetSummary = + await testDb!.questionsDb.resetGuessScoreSummary(uid, now: now); + expect(resetSummary['total'], 0); + expect(resetSummary['answersTotal'], 0); + expect(resetSummary['answersCorrect'], 0); + expect(resetSummary['percentCorrect'], 0); + expect(resetSummary['slotStart'], isNotNull); + + final Map? guessScoreAfterReset = + await testDb!.userTradingStateDb.getGuessScore(uid); + expect(guessScoreAfterReset, isNotNull); + expect(MarketDataDb.readOptionalNumeric(guessScoreAfterReset!['total']), 0); + expect((guessScoreAfterReset['answers_total'] as num).toInt(), 0); + expect((guessScoreAfterReset['answers_correct'] as num).toInt(), 0); + expect( + MarketHistorySessionSlot.parseWire( + guessScoreAfterReset['slot_start'] as String?, + ), + ProspectiveGuessSelection.earliestPlayableSlotStart( + now, + MarketHistoryConfig.windowDays, + ), + ); + + final Result assignmentCount = await testDb!.connection.execute( + Sql.named( + ''' + SELECT COUNT(*)::int + FROM market_history_prospective_assignments + WHERE assigned_user_id = @uid + ''', + ), + parameters: {'uid': uid}, + ); + expect((assignmentCount.first[0]! as num).toInt(), 0); + }); + + test('assignments issue every top-half asset before advancing slot pair', + () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'prospective-slot-progression-uid'; + final DateTime now = DateTime.now().toUtc(); + final DateTime olderSlot = + ProspectiveGuessSelection.earliestPlayableSlotStart(now, 5); + final DateTime? newerSlotRaw = + MarketHistorySessionSlot.nextSlotStart(olderSlot); + if (newerSlotRaw == null) { + markTestSkipped('No newer slot after earliest window slot'); + return; + } + final DateTime newerSlot = + MarketHistorySessionSlot.slotStartContaining(newerSlotRaw); + + await testDb!.seedUser(uid); + await testDb!.userTradingStateDb.setGuessSlotStart(uid, olderSlot); + + await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO tradable_assets (symbol, asset_class, status, tradable, refreshed_at) + VALUES + ('HIGH', 'us_equity', 'active', true, @refreshed_at), + ('MID', 'us_equity', 'active', true, @refreshed_at), + ('LOW', 'us_equity', 'active', true, @refreshed_at) + ''', + ), + parameters: {'refreshed_at': now}, + ); + + Future insertBar({ + required String symbol, + required DateTime asOf, + required num volume, + }) async { + await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO market_data_snapshots ( + symbol, asset_class, feed, metric, timeframe, price, volume, as_of, raw + ) VALUES ( + @symbol, 'us_equity', 'iex', 'bar', 'sessionHalf', 10, @volume, @as_of, @raw::jsonb + ) + ''', + ), + parameters: { + 'symbol': symbol, + 'as_of': asOf, + 'volume': volume, + 'raw': jsonEncode({ + 'o': 10, + 'h': 10, + 'l': 10, + 'c': 10, + 'v': volume, + 'slot_start': MarketHistorySessionSlot.slotStartWire(asOf), + }), + }, + ); + } + + await insertBar(symbol: 'HIGH', asOf: olderSlot, volume: 500); + await insertBar(symbol: 'HIGH', asOf: newerSlot, volume: 500); + await insertBar(symbol: 'MID', asOf: olderSlot, volume: 150); + await insertBar(symbol: 'MID', asOf: newerSlot, volume: 150); + await insertBar(symbol: 'LOW', asOf: olderSlot, volume: 100); + await insertBar(symbol: 'LOW', asOf: newerSlot, volume: 100); + + final DateTime? nextOlderSlotRaw = + MarketHistorySessionSlot.nextSlotStart(newerSlot); + if (nextOlderSlotRaw == null) { + markTestSkipped('No second slot pair for progression fixture'); + return; + } + final DateTime nextOlderSlot = + MarketHistorySessionSlot.slotStartContaining(nextOlderSlotRaw); + final DateTime? nextNewerSlotRaw = + MarketHistorySessionSlot.nextSlotStart(nextOlderSlot); + if (nextNewerSlotRaw == null) { + markTestSkipped('No newer slot for second pair fixture'); + return; + } + final DateTime nextNewerSlot = + MarketHistorySessionSlot.slotStartContaining(nextNewerSlotRaw); + await insertBar(symbol: 'HIGH', asOf: nextOlderSlot, volume: 500); + await insertBar(symbol: 'HIGH', asOf: nextNewerSlot, volume: 500); + await insertBar(symbol: 'MID', asOf: nextOlderSlot, volume: 150); + await insertBar(symbol: 'MID', asOf: nextNewerSlot, volume: 150); + + final QuestionService service = testDb!.questionService(); + final ProspectiveGuessSelection picker = ProspectiveGuessSelection( + connection: testDb!.connection, + ); + + final Map? firstPayload = + await service.ensureProspectiveQuestionQueued(uid, now: now); + expect(firstPayload, isNotNull); + + final Result assignmentRows = await testDb!.connection.execute( + Sql.named( + ''' + SELECT symbol, status, older_slot_start, newer_slot_start + FROM market_history_prospective_assignments + WHERE assigned_user_id = @uid + ORDER BY created_at ASC + ''', + ), + parameters: {'uid': uid}, + ); + expect(assignmentRows, hasLength(1)); + expect(assignmentRows.first[1], ProspectiveGuessAssignmentsDb.statusPending); + expect(assignmentRows.first[0], 'HIGH'); + expect((assignmentRows.first[2]! as DateTime).toUtc(), olderSlot); + expect((assignmentRows.first[3]! as DateTime).toUtc(), newerSlot); + + expect(await picker.pickForUser(uid, now: now), isNull); + + final Map? reloginPayload = + await service.ensureProspectiveQuestionQueued(uid, now: now); + expect(reloginPayload, isNotNull); + expect(reloginPayload!['id'], firstPayload!['id']); + + final List> queued = + await testDb!.questionsDb.listUnansweredQuestions(uid); + expect(queued, hasLength(1)); + await testDb!.questionsDb.submitAnswer( + questionId: queued.single['id']! as String, + assignedUserId: uid, + userResponse: 0, + ); + + final Map? secondPayload = + await service.ensureProspectiveQuestionQueued(uid, now: now); + expect(secondPayload, isNotNull); + expect(secondPayload!['id'], isNot(firstPayload['id'])); + + final Result secondPairAssignments = await testDb!.connection.execute( + Sql.named( + ''' + SELECT symbol, status, older_slot_start, newer_slot_start + FROM market_history_prospective_assignments + WHERE assigned_user_id = @uid + ORDER BY created_at ASC + ''', + ), + parameters: {'uid': uid}, + ); + expect(secondPairAssignments, hasLength(2)); + expect(secondPairAssignments.first[0], 'HIGH'); + expect(secondPairAssignments.last[0], 'MID'); + expect(secondPairAssignments.last[1], + ProspectiveGuessAssignmentsDb.statusPending); + expect((secondPairAssignments.last[2]! as DateTime).toUtc(), olderSlot); + expect((secondPairAssignments.last[3]! as DateTime).toUtc(), newerSlot); + + await testDb!.questionsDb.submitAnswer( + questionId: secondPayload['id']! as String, + assignedUserId: uid, + userResponse: 0, + ); + + final Map? thirdPayload = + await service.ensureProspectiveQuestionQueued(uid, now: now); + expect(thirdPayload, isNotNull); + expect(thirdPayload!['id'], isNot(firstPayload['id'])); + expect(thirdPayload['id'], isNot(secondPayload['id'])); + + final Result allAssignments = await testDb!.connection.execute( + Sql.named( + ''' + SELECT symbol, older_slot_start, newer_slot_start + FROM market_history_prospective_assignments + WHERE assigned_user_id = @uid + ORDER BY created_at ASC + ''', + ), + parameters: {'uid': uid}, + ); + expect(allAssignments, hasLength(3)); + expect(allAssignments.last[0], 'HIGH'); + expect((allAssignments.last[1]! as DateTime).toUtc(), newerSlot); + expect((allAssignments.last[2]! as DateTime).toUtc(), nextOlderSlot); + }); + + test('blocks duplicate assignment for same user symbol and slot pair', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'prospective-dedupe-uid'; + final DateTime now = DateTime.now().toUtc(); + final DateTime olderSlot = + ProspectiveGuessSelection.earliestPlayableSlotStart(now, 5); + final DateTime? newerSlotRaw = + MarketHistorySessionSlot.nextSlotStart(olderSlot); + if (newerSlotRaw == null) { + markTestSkipped('No newer slot after earliest window slot'); + return; + } + final DateTime newerSlot = + MarketHistorySessionSlot.slotStartContaining(newerSlotRaw); + + await testDb!.seedUser(uid); + await testDb!.userTradingStateDb.setGuessSlotStart(uid, olderSlot); + + await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO tradable_assets (symbol, asset_class, status, tradable, refreshed_at) + VALUES ('DEDUP', 'us_equity', 'active', true, @refreshed_at) + ''', + ), + parameters: {'refreshed_at': now}, + ); + + for (final DateTime asOf in [olderSlot, newerSlot]) { + await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO market_data_snapshots ( + symbol, asset_class, feed, metric, timeframe, price, volume, as_of, raw + ) VALUES ( + 'DEDUP', 'us_equity', 'iex', 'bar', 'sessionHalf', 10, 500, @as_of, @raw::jsonb + ) + ''', + ), + parameters: { + 'as_of': asOf, + 'raw': jsonEncode({ + 'o': 10, + 'h': 10, + 'l': 10, + 'c': 10, + 'v': 500, + 'slot_start': MarketHistorySessionSlot.slotStartWire(asOf), + }), + }, + ); + } + + final ProspectiveGuessAssignmentsDb assignmentsDb = + ProspectiveGuessAssignmentsDb(testDb!.connection); + final QuestionService service = testDb!.questionService(); + + final Map? firstPayload = + await service.ensureProspectiveQuestionQueued(uid, now: now); + expect(firstPayload, isNotNull); + + expect( + await assignmentsDb.hasAssignmentForSymbolSlotPair( + firebaseUid: uid, + olderSlotStart: olderSlot, + newerSlotStart: newerSlot, + symbol: 'DEDUP', + ), + isTrue, + ); + + final Result existingAssignment = await testDb!.connection.execute( + Sql.named( + ''' + SELECT prospective_question_id, question_id + FROM market_history_prospective_assignments + WHERE assigned_user_id = @uid + AND symbol = 'DEDUP' + AND older_slot_start = @older + AND newer_slot_start = @newer + ''', + ), + parameters: { + 'uid': uid, + 'older': olderSlot, + 'newer': newerSlot, + }, + ); + expect(existingAssignment, hasLength(1)); + + final Map orphanQuestion = + await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: 'orphan duplicate attempt', + correctAnswer: 0, + ); + expect( + await assignmentsDb.insertPendingIfAbsent( + firebaseUid: uid, + olderSlotStart: olderSlot, + newerSlotStart: newerSlot, + symbol: 'DEDUP', + prospectiveQuestionId: existingAssignment.first[0].toString(), + questionId: orphanQuestion['id']! as String, + ), + isFalse, + ); + await testDb!.questionsDb.deleteUnansweredQuestion( + questionId: orphanQuestion['id']! as String, + assignedUserId: uid, + ); + + final Map? pendingPayload = + await service.ensureProspectiveQuestionQueued(uid, now: now); + expect(pendingPayload, isNotNull); + expect(pendingPayload!['id'], firstPayload!['id']); + + final Result assignmentCount = await testDb!.connection.execute( + Sql.named( + ''' + SELECT COUNT(*)::int + FROM market_history_prospective_assignments + WHERE assigned_user_id = @uid + AND symbol = 'DEDUP' + AND older_slot_start = @older + AND newer_slot_start = @newer + ''', + ), + parameters: { + 'uid': uid, + 'older': olderSlot, + 'newer': newerSlot, + }, + ); + expect((assignmentCount.first[0]! as num).toInt(), 1); + }); + + test('bootstrapOnLogin creates slot-based prospective question', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + const String uid = 'prospective-bootstrap-uid'; + final DateTime now = DateTime.now().toUtc(); + final DateTime olderSlot = + ProspectiveGuessSelection.earliestPlayableSlotStart(now, 5); + final DateTime? newerSlotRaw = + MarketHistorySessionSlot.nextSlotStart(olderSlot); + if (newerSlotRaw == null) { + markTestSkipped('No newer slot after earliest window slot'); + return; + } + final DateTime newerSlot = + MarketHistorySessionSlot.slotStartContaining(newerSlotRaw); + + await testDb!.seedUser(uid); + + await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO tradable_assets (symbol, asset_class, status, tradable, refreshed_at) + VALUES ('BOOT', 'us_equity', 'active', true, @refreshed_at) + ''', + ), + parameters: {'refreshed_at': now}, + ); + + for (final DateTime asOf in [olderSlot, newerSlot]) { + await testDb!.connection.execute( + Sql.named( + ''' + INSERT INTO market_data_snapshots ( + symbol, asset_class, feed, metric, timeframe, price, volume, as_of, raw + ) VALUES ( + 'BOOT', 'us_equity', 'iex', 'bar', 'sessionHalf', 10, 1000, @as_of, @raw::jsonb + ) + ''', + ), + parameters: { + 'as_of': asOf, + 'raw': jsonEncode({ + 'o': 10, + 'h': 10, + 'l': 10, + 'c': 10, + 'v': 1000, + 'slot_start': MarketHistorySessionSlot.slotStartWire(asOf), + }), + }, + ); + } + + final QuestionService service = testDb!.questionService(); + final Map? payload = await service.bootstrapOnLogin(uid); + expect(payload, isNotNull); + + final List> queued = + await testDb!.questionsDb.listUnansweredQuestions(uid); + expect(queued, hasLength(1)); + final Map created = queued.single; + final Map metadata = + created['metadata'] as Map; + expect(metadata['symbol'], 'BOOT'); + expect(metadata['older_slot_start'], olderSlot.toIso8601String()); + expect(metadata['newer_slot_start'], newerSlot.toIso8601String()); + expect(created['text'], contains('BOOT')); + }); +} diff --git a/server/test/integration/market_history_scheduler_test.dart b/server/test/integration/market_history_scheduler_test.dart index 54337e0..656159e 100644 --- a/server/test/integration/market_history_scheduler_test.dart +++ b/server/test/integration/market_history_scheduler_test.dart @@ -323,7 +323,7 @@ void main() { expect(rows, hasLength(4)); }); - test('syncHourUtc blocks before hour and same UTC day', () async { + test('syncHourUtc blocks universe/cleanup but not slot-gated backfill', () async { if (testDb == null) { markTestSkipped( 'Set DATABASE_URL or TEST_DATABASE_URL for integration tests', @@ -331,13 +331,16 @@ void main() { return; } + bool backfillPending = true; final MarketHistoryScheduler s = scheduler( config: const MarketHistorySchedulerConfig(syncHourUtc: 10), + backfillIsDue: (DateTime now) async => backfillPending, runUniverse: (DateTime now) async { await recordStage(TradableAssetsSync.kind, now); }, runBackfill: (DateTime now) async { await recordStage(MarketDataHistorySync.kind, now); + backfillPending = false; }, runCleanup: (DateTime now) async { await recordStage(MarketDataRetention.kind, now); @@ -346,12 +349,15 @@ void main() { final MarketHistorySchedulerReport beforeHour = await s.runIfDue(DateTime.utc(2026, 5, 26, 9)); - expect(beforeHour.ranStages, isEmpty); + expect(beforeHour.ranStages, [MarketDataHistorySync.kind]); final DateTime tRun = DateTime.utc(2026, 5, 26, 11); final MarketHistorySchedulerReport first = await s.runIfDue(tRun); - expect(first.ranStages, hasLength(3)); + expect(first.ranStages, [ + TradableAssetsSync.kind, + MarketDataRetention.kind, + ]); final MarketHistorySchedulerReport sameDay = await s.runIfDue(DateTime.utc(2026, 5, 26, 12)); diff --git a/server/test/integration/market_history_worker_wireup_test.dart b/server/test/integration/market_history_worker_wireup_test.dart index 37a9ccc..948b005 100644 --- a/server/test/integration/market_history_worker_wireup_test.dart +++ b/server/test/integration/market_history_worker_wireup_test.dart @@ -5,6 +5,7 @@ import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart'; import 'package:cyberhybridhub_server/question_service.dart'; import 'package:cyberhybridhub_server/questions_db.dart'; import 'package:cyberhybridhub_server/signalr/questions_hub_connections.dart'; +import 'package:cyberhybridhub_server/trading/market_history_prospective_questions.dart'; import 'package:cyberhybridhub_server/workers/market_history_scheduler.dart'; import 'package:cyberhybridhub_server/workers/question_background_worker.dart'; import 'package:test/test.dart'; @@ -108,5 +109,32 @@ void main() { expect(tradingRan, isTrue); }); + + test('scheduler runs prospective questions after pipeline stages', () async { + if (testDb == null) { + markTestSkipped( + 'Set DATABASE_URL or TEST_DATABASE_URL for integration tests', + ); + return; + } + + final List stages = []; + final MarketHistoryScheduler scheduler = MarketHistoryScheduler( + connection: testDb!.connection, + runUniverse: (_) async { + stages.add('universe'); + }, + runBackfill: (_) async {}, + runCleanup: (_) async {}, + runProspectiveQuestions: (_) async { + stages.add(MarketHistoryProspectiveQuestions.kind); + }, + ); + + await scheduler.runIfDue(DateTime.utc(2026, 6, 1, 12)); + + expect(stages, contains('universe')); + expect(stages.last, MarketHistoryProspectiveQuestions.kind); + }); }); } diff --git a/server/test/integration/trading_pipeline_guess_weekly_move_test.dart b/server/test/integration/trading_pipeline_guess_weekly_move_test.dart index e6f3c11..31039a8 100644 --- a/server/test/integration/trading_pipeline_guess_weekly_move_test.dart +++ b/server/test/integration/trading_pipeline_guess_weekly_move_test.dart @@ -6,8 +6,8 @@ import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart'; import 'package:cyberhybridhub_server/trading/market_history_config.dart'; import 'package:cyberhybridhub_server/trading/market_history_query.dart'; import 'package:cyberhybridhub_server/trading/tradable_assets_db.dart'; +import 'package:cyberhybridhub_server/trading/prospective_answer_scoring.dart'; import 'package:cyberhybridhub_server/trading/trading_pipeline.dart'; -import 'package:cyberhybridhub_server/trading/user_trading_state_db.dart'; import 'package:postgres/postgres.dart'; import 'package:test/test.dart'; @@ -19,6 +19,7 @@ void main() { setUpAll(() async { testDb = await TestDb.open(); + ProspectiveAnswerScoring.closenessExtraPointsEnabled = true; }); tearDown(() async { @@ -28,6 +29,7 @@ void main() { }); tearDownAll(() async { + ProspectiveAnswerScoring.closenessExtraPointsEnabled = false; await testDb?.close(); }); @@ -134,7 +136,7 @@ void main() { expect(metadata['guess_symbol'], 'SPY'); }); - test('matching direction records score_delta +1', () async { + test('matching direction and within ±1 records full 2-point score', () async { if (testDb == null) { markTestSkipped( 'Set DATABASE_URL or TEST_DATABASE_URL for integration tests', @@ -155,23 +157,66 @@ void main() { await testDb!.questionsDb.submitAnswer( questionId: open.single['id'] as String, assignedUserId: uid, - userResponse: 10, + userResponse: 9, ); await pipeline.handleAnswer( firebaseUid: uid, answeredQuestion: updated!, - userResponse: 10, + userResponse: 9, ); final Map? score = await testDb!.userTradingStateDb.getGuessScore(uid); expect(score, isNotNull); - expect(score!['total'], 1); - expect((score['last'] as Map)['score_delta'], 1); + expect(score!['total'], 2); + expect((score['last'] as Map)['score_delta'], 2); + + final Map? ruleState = + await testDb!.userTradingStateDb.getRuleState(uid, 'guess_weekly_move'); + expect(ruleState!['direction_point'], 1); + expect(ruleState['closeness_point'], 1); + expect(ruleState['answer_score'], 2); }); - test('non-matching direction records score_delta -1', () async { + test('matching direction but farther magnitude records fractional score', + () async { + if (testDb == null) { + markTestSkipped( + 'Set DATABASE_URL or TEST_DATABASE_URL for integration tests', + ); + return; + } + + const String uid = 'guess-score-miss-uid'; + await _enableGuessRule(uid); + await _seedGuessUniverse(); + + final TradingPipeline pipeline = await _guessPipeline(); + await pipeline.evaluate(uid); + + final List> open = + await testDb!.questionsDb.listUnansweredQuestions(uid); + final Map? updated = + await testDb!.questionsDb.submitAnswer( + questionId: open.single['id'] as String, + assignedUserId: uid, + userResponse: 6, + ); + + await pipeline.handleAnswer( + firebaseUid: uid, + answeredQuestion: updated!, + userResponse: 6, + ); + + final Map? score = + await testDb!.userTradingStateDb.getGuessScore(uid); + expect(score!['total'], closeTo(1.7, 0.001)); + expect((score['last'] as Map)['score_delta'], closeTo(1.7, 0.001)); + }); + + test('non-matching direction records -2 score and ignores closeness', () async { if (testDb == null) { markTestSkipped( 'Set DATABASE_URL or TEST_DATABASE_URL for integration tests', @@ -203,8 +248,14 @@ void main() { final Map? score = await testDb!.userTradingStateDb.getGuessScore(uid); - expect(score!['total'], -1); - expect((score['last'] as Map)['score_delta'], -1); + expect(score!['total'], -2); + expect((score['last'] as Map)['score_delta'], -2); + + final Map? ruleState = + await testDb!.userTradingStateDb.getRuleState(uid, 'guess_weekly_move'); + expect(ruleState!['direction_point'], -2); + expect(ruleState['closeness_point'], 0); + expect(ruleState['answer_score'], -2); }); test('handleAnswer never stages pending orders; actuator not invoked', diff --git a/server/test/trading/guess_score_store_test.dart b/server/test/trading/guess_score_store_test.dart new file mode 100644 index 0000000..8f7be6c --- /dev/null +++ b/server/test/trading/guess_score_store_test.dart @@ -0,0 +1,114 @@ +@Tags(['integration', 'postgres']) +library; + +import 'package:cyberhybridhub_server/trading/guess_score_store.dart'; +import 'package:cyberhybridhub_server/trading/market_history_session_slot.dart'; +import 'package:cyberhybridhub_server/trading/prospective_answer_scoring.dart'; +import 'package:cyberhybridhub_server/trading/user_trading_state_db.dart'; +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; + +import '../helpers/test_db.dart'; + +void main() { + TestDb? testDb; + + setUpAll(() async { + testDb = await TestDb.open(); + }); + + tearDown(() async { + if (testDb != null) { + await testDb!.truncateTradingTables(); + } + }); + + tearDownAll(() async { + await testDb?.close(); + }); + + test('loadSummary repairs score from prospective answers for firebase uid', () async { + if (testDb == null) { + markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); + return; + } + + ProspectiveAnswerScoring.closenessExtraPointsEnabled = true; + addTearDown(() { + ProspectiveAnswerScoring.closenessExtraPointsEnabled = false; + }); + + const String uid = 'guess-score-repair-uid'; + final DateTime now = DateTime.now().toUtc(); + final DateTime newerSlot = + MarketHistorySessionSlot.lastCompletedSlotStart(now); + final DateTime olderSlot = + MarketHistorySessionSlot.previousSlotStart(newerSlot)!; + + await testDb!.seedUser(uid); + + final Result prospective = await testDb!.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 + ) VALUES ( + @compare_until, @newer, @older, + 'SPY', 'move?', 4.0, + 4.0, 1.0, 1000, + '{}'::jsonb, '{}'::jsonb + ) + RETURNING id + ''', + ), + parameters: { + 'compare_until': MarketHistorySessionSlot.endExclusive(newerSlot), + 'newer': newerSlot, + 'older': olderSlot, + }, + ); + final String prospectiveId = prospective.first[0].toString(); + + final Map question = await testDb!.questionsDb.createQuestion( + assignedUserId: uid, + questionText: 'Guess', + correctAnswer: 4.0, + sourceTag: 'market_history:prospective', + metadata: { + 'prospective_question_id': prospectiveId, + }, + ); + + await testDb!.questionsDb.submitAnswer( + questionId: question['id']! as String, + assignedUserId: uid, + userResponse: 3.5, + ); + + await testDb!.connection.execute( + Sql.named( + ''' + UPDATE user_trading_state + SET context = '{}'::jsonb + WHERE firebase_uid = @uid + ''', + ), + parameters: {'uid': uid}, + ); + + final Map summary = + await GuessScoreStore.loadSummary(testDb!.connection, uid); + + expect(summary['answersTotal'], 1); + expect(summary['answersCorrect'], 1); + expect(summary['total'], 2); + + final Map? repaired = + await testDb!.userTradingStateDb.getGuessScore(uid); + expect(repaired, isNotNull); + expect((repaired!['answers_total'] as num).toInt(), 1); + }); +} diff --git a/server/test/trading/market_history_question_audit_test.dart b/server/test/trading/market_history_question_audit_test.dart index b188fd5..4b3401c 100644 --- a/server/test/trading/market_history_question_audit_test.dart +++ b/server/test/trading/market_history_question_audit_test.dart @@ -34,6 +34,106 @@ void main() { }); }); + group('questionAuditBarVolumeUsd', () { + test('converts share volume to USD with avg price', () { + expect( + questionAuditBarVolumeUsd(volume: 150, avgPrice: 12), + 1800, + ); + }); + + test('uses raw volume_usd when already in dollars', () { + expect( + questionAuditBarVolumeUsd( + volume: 999, + avgPrice: 50, + raw: {'volume_usd': 2500}, + ), + 2500, + ); + }); + }); + + group('questionAuditTopHalfVolumeAssets', () { + test('keeps top half by count when sorted by volume desc', () { + QuestionAuditAsset asset(String symbol, num volumeUsd) { + return QuestionAuditAsset( + symbol: symbol, + priceDelta: 0, + volumeDelta: 0, + olderSlot: QuestionAuditBarSlot( + asOf: DateTime.utc(2026, 5, 30, 8), + avgPrice: 10, + volume: volumeUsd, + volumeUsd: volumeUsd, + ), + newerSlot: QuestionAuditBarSlot( + asOf: DateTime.utc(2026, 5, 30, 12), + avgPrice: 10, + volume: volumeUsd, + volumeUsd: volumeUsd, + ), + ); + } + + final List top = questionAuditTopHalfVolumeAssets( + [ + asset('HIGH', 5000), + asset('MID', 3000), + asset('LOW', 1000), + ], + ); + expect(top, hasLength(2)); + expect(top.map((QuestionAuditAsset a) => a.symbol).toList(), + ['HIGH', 'MID']); + }); + }); + + group('questionAuditAvgVolumeUsd', () { + test('averages older and newer slot dollar volumes', () { + final QuestionAuditAsset asset = QuestionAuditAsset( + symbol: 'AAA', + priceDelta: 0, + volumeDelta: 0, + olderSlot: QuestionAuditBarSlot( + asOf: DateTime.utc(2026, 5, 30, 8), + avgPrice: 10, + volume: 100, + volumeUsd: 1000, + ), + newerSlot: QuestionAuditBarSlot( + asOf: DateTime.utc(2026, 5, 30, 12), + avgPrice: 12, + volume: 150, + volumeUsd: 1800, + ), + ); + expect(questionAuditAvgVolumeUsd(asset), 1400); + }); + }); + + group('questionAuditPercentChange', () { + test('computes percent change from older to newer', () { + expect( + questionAuditPercentChange(newer: 12, older: 10), + 20, + ); + expect( + questionAuditPercentChange(newer: 150, older: 100), + 50, + ); + expect( + questionAuditPercentChange(newer: 60, older: 100), + -40, + ); + }); + + test('handles zero older baseline', () { + expect(questionAuditPercentChange(newer: 0, older: 0), 0); + expect(questionAuditPercentChange(newer: 5, older: 0), 100); + }); + }); + group('compareUntil navigation', () { final DateTime now = DateTime.utc(2026, 6, 2, 21); late DateTime defaultUntil; diff --git a/server/test/trading/prospective_answer_scoring_test.dart b/server/test/trading/prospective_answer_scoring_test.dart new file mode 100644 index 0000000..6064d60 --- /dev/null +++ b/server/test/trading/prospective_answer_scoring_test.dart @@ -0,0 +1,56 @@ +import 'package:cyberhybridhub_server/trading/prospective_answer_scoring.dart'; +import 'package:test/test.dart'; + +void main() { + tearDown(() { + ProspectiveAnswerScoring.closenessExtraPointsEnabled = false; + }); + + group('gradeProspectiveAnswer (default simple scoring)', () { + test('correct direction earns +1', () { + final ProspectiveAnswerGrade grade = gradeProspectiveAnswer( + userResponse: 3.5, + correctAnswer: 4.25, + ); + expect(grade.directionPoint, 1); + expect(grade.closenessPoint, 0); + expect(grade.answerScore, 1); + }); + + test('wrong direction earns -1', () { + final ProspectiveAnswerGrade grade = gradeProspectiveAnswer( + userResponse: -5, + correctAnswer: 4, + ); + expect(grade.directionPoint, -1); + expect(grade.closenessPoint, 0); + expect(grade.answerScore, -1); + }); + }); + + group('gradeProspectiveAnswer (closeness enabled)', () { + setUp(() { + ProspectiveAnswerScoring.closenessExtraPointsEnabled = true; + }); + + test('correct direction within tolerance earns 2 points', () { + final ProspectiveAnswerGrade grade = gradeProspectiveAnswer( + userResponse: 4, + correctAnswer: 4.5, + ); + expect(grade.directionPoint, 1); + expect(grade.closenessPoint, 1); + expect(grade.answerScore, 2); + }); + + test('wrong direction earns -2 with no closeness', () { + final ProspectiveAnswerGrade grade = gradeProspectiveAnswer( + userResponse: -5, + correctAnswer: 4, + ); + expect(grade.directionPoint, -2); + expect(grade.closenessPoint, 0); + expect(grade.answerScore, -2); + }); + }); +} diff --git a/test/admin/services/market_history_admin_api_test.dart b/test/admin/services/market_history_admin_api_test.dart index 0cac9b8..aa1493b 100644 --- a/test/admin/services/market_history_admin_api_test.dart +++ b/test/admin/services/market_history_admin_api_test.dart @@ -212,7 +212,7 @@ void main() { 'assets': >[ { 'symbol': 'AAA', - 'priceDelta': 2.5, + 'priceDelta': 25, 'volumeDelta': 50, 'olderSlot': { 'asOf': '2026-05-30T08:00:00Z', @@ -248,9 +248,11 @@ void main() { expect(report.stepOlderCompareUntil, DateTime.utc(2026, 5, 30, 12)); expect(report.assets, hasLength(1)); expect(report.assets.single.symbol, 'AAA'); - expect(report.assets.single.priceDelta, 2.5); + expect(report.assets.single.priceDelta, 25); expect(report.assets.single.volumeDelta, 50); expect(report.assets.single.olderSlot.avgPrice, 10); + expect(report.assets.single.olderSlot.volumeUsd, 1000); expect(report.assets.single.newerSlot.close, 12); + expect(report.assets.single.newerSlot.volumeUsd, 1875); }); } diff --git a/test/admin/widgets/market_history_question_audit_sheet_test.dart b/test/admin/widgets/market_history_question_audit_sheet_test.dart index 39539ac..9545e6a 100644 --- a/test/admin/widgets/market_history_question_audit_sheet_test.dart +++ b/test/admin/widgets/market_history_question_audit_sheet_test.dart @@ -10,7 +10,7 @@ import 'package:http/testing.dart'; QuestionAuditAsset _sampleAsset() { return QuestionAuditAsset( symbol: 'AAA', - priceDelta: 2.5, + priceDelta: 25, volumeDelta: -40, olderSlot: QuestionAuditBarSlot( asOf: DateTime.utc(2026, 5, 30, 8), @@ -20,6 +20,7 @@ QuestionAuditAsset _sampleAsset() { close: 10, avgPrice: 10, volume: 100, + volumeUsd: 1000, ), newerSlot: QuestionAuditBarSlot( asOf: DateTime.utc(2026, 5, 30, 12), @@ -29,6 +30,7 @@ QuestionAuditAsset _sampleAsset() { close: 12.5, avgPrice: 12.5, volume: 60, + volumeUsd: 750, ), ); } @@ -134,6 +136,20 @@ void main() { expect(find.text('05/30 04:00 – 05/30 08:00 UTC'), findsOneWidget); }); + testWidgets('tile shows percent price and volume change', ( + WidgetTester tester, + ) async { + await _pumpSheet( + tester, + _FakeAuditApi([ + _sampleReport(canStepOlder: false, canStepNewer: false), + ]), + ); + + expect(find.text('P:+25%'), findsOneWidget); + expect(find.text('V:-40%'), findsOneWidget); + }); + testWidgets('tap expands slot detail panels', (WidgetTester tester) async { await _pumpSheet( tester, diff --git a/test/guess_slot_format_test.dart b/test/guess_slot_format_test.dart new file mode 100644 index 0000000..7e6a2c4 --- /dev/null +++ b/test/guess_slot_format_test.dart @@ -0,0 +1,14 @@ +import 'package:cyberhybridhub/utils/guess_slot_format.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('formatGuessSlotRange shows older to newer UTC instants', () { + expect( + formatGuessSlotRange( + slotStart: DateTime.utc(2026, 5, 26, 13, 30), + newerSlotStart: DateTime.utc(2026, 5, 26, 16, 45), + ), + '05/26 13:30 – 05/26 16:45 UTC', + ); + }); +} diff --git a/test/home_screen_score_test.dart b/test/home_screen_score_test.dart new file mode 100644 index 0000000..83f7ced --- /dev/null +++ b/test/home_screen_score_test.dart @@ -0,0 +1,101 @@ +import 'package:cyberhybridhub/models/app_user.dart'; +import 'package:cyberhybridhub/models/guess_score_summary.dart'; +import 'package:cyberhybridhub/screens/home_screen.dart'; +import 'package:cyberhybridhub/services/questions_hub_service.dart'; +import 'package:cyberhybridhub/theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('top bar score chip opens statistics dialog', ( + WidgetTester tester, + ) async { + final QuestionsHubService hub = QuestionsHubService.instance; + hub.hasPendingQuestion.value = false; + hub.pendingQuestionCount.value = 0; + hub.guessScoreSummary.value = GuessScoreSummary( + total: 3.5, + answersTotal: 4, + answersCorrect: 3, + percentCorrect: 75, + slotStart: DateTime.utc(2026, 5, 26, 13, 30), + newerSlotStart: DateTime.utc(2026, 5, 26, 16, 45), + ); + + addTearDown(() { + hub.hasPendingQuestion.value = false; + hub.pendingQuestionCount.value = 0; + hub.guessScoreSummary.value = GuessScoreSummary.empty; + }); + + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: const HomeScreen( + user: AppUser(uid: 'u1', displayName: 'Test User'), + ), + ), + ); + + expect(find.byKey(const Key('topbar-cumulative-score')), findsOneWidget); + expect(find.text('Score: 3.50'), findsOneWidget); + + await tester.tap(find.byKey(const Key('topbar-cumulative-score'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('guess-score-stats-dialog')), findsOneWidget); + expect(find.text('Score statistics'), findsOneWidget); + expect(find.byKey(const Key('guess-score-slot-row')), findsOneWidget); + expect(find.text('Time slot'), findsOneWidget); + expect(find.text('05/26 13:30 – 05/26 16:45 UTC'), findsOneWidget); + expect(find.text('Questions answered'), findsOneWidget); + expect(find.text('4'), findsOneWidget); + expect(find.text('Percent correct'), findsOneWidget); + expect(find.text('75%'), findsOneWidget); + }); + + testWidgets('score statistics reset shows confirmation dialog', ( + WidgetTester tester, + ) async { + final QuestionsHubService hub = QuestionsHubService.instance; + hub.guessScoreSummary.value = const GuessScoreSummary( + total: 5, + answersTotal: 2, + answersCorrect: 1, + percentCorrect: 50, + ); + + addTearDown(() { + hub.guessScoreSummary.value = GuessScoreSummary.empty; + }); + + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: const HomeScreen( + user: AppUser(uid: 'u1', displayName: 'Test User'), + ), + ), + ); + + await tester.tap(find.byKey(const Key('topbar-cumulative-score'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('guess-score-reset-button'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('guess-score-reset-confirm-dialog')), + findsOneWidget, + ); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('guess-score-reset-confirm-dialog')), + findsNothing, + ); + expect(find.byKey(const Key('guess-score-stats-dialog')), findsOneWidget); + }); +}