Good question flow

This commit is contained in:
Nathan Anderson 2026-06-02 09:44:53 -05:00
parent 6615dc5d17
commit d8f73a3c38
49 changed files with 3990 additions and 305 deletions

View File

@ -3,6 +3,7 @@ class QuestionAuditBarSlot {
required this.asOf, required this.asOf,
required this.avgPrice, required this.avgPrice,
required this.volume, required this.volume,
required this.volumeUsd,
this.open, this.open,
this.high, this.high,
this.low, this.low,
@ -16,16 +17,20 @@ class QuestionAuditBarSlot {
final num? close; final num? close;
final num avgPrice; final num avgPrice;
final num volume; final num volume;
final num volumeUsd;
factory QuestionAuditBarSlot.fromJson(Map<String, dynamic> json) { factory QuestionAuditBarSlot.fromJson(Map<String, dynamic> json) {
final num avgPrice = json['avgPrice'] as num;
final num volume = json['volume'] as num;
return QuestionAuditBarSlot( return QuestionAuditBarSlot(
asOf: DateTime.parse(json['asOf']! as String).toUtc(), asOf: DateTime.parse(json['asOf']! as String).toUtc(),
open: json['open'] as num?, open: json['open'] as num?,
high: json['high'] as num?, high: json['high'] as num?,
low: json['low'] as num?, low: json['low'] as num?,
close: json['close'] as num?, close: json['close'] as num?,
avgPrice: json['avgPrice'] as num, avgPrice: avgPrice,
volume: json['volume'] as num, volume: volume,
volumeUsd: json['volumeUsd'] as num? ?? volume * avgPrice,
); );
} }
} }

View File

@ -366,7 +366,7 @@ class _AssetTileState extends State<_AssetTile> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
Text( Text(
'P:${_AuditFormat.delta(asset.priceDelta)}', 'P:${_AuditFormat.percent(asset.priceDelta)}',
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -375,7 +375,7 @@ class _AssetTileState extends State<_AssetTile> {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'V:${_AuditFormat.delta(asset.volumeDelta)}', 'V:${_AuditFormat.percent(asset.volumeDelta)}',
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -555,16 +555,14 @@ abstract final class _AuditFormat {
return AppColors.textPrimary; return AppColors.textPrimary;
} }
static String delta(num value) { static String percent(num value) {
final num rounded = value.abs() >= 1000 final num rounded = (value * 100).round() / 100;
? (value * 100).round() / 100
: (value * 10000).round() / 10000;
final String text = rounded == rounded.roundToDouble() final String text = rounded == rounded.roundToDouble()
? rounded.round().toString() ? rounded.round().toString()
: rounded.toStringAsFixed(2); : rounded.toStringAsFixed(2);
if (value > 0) { if (value > 0) {
return '+$text'; return '+$text%';
} }
return text; return '$text%';
} }
} }

View File

@ -3,6 +3,8 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Deterministic irregular polygon + palette derived from a question UUID. /// Deterministic irregular polygon + palette derived from a question UUID.
/// ///
/// Parses the canonical 16-byte UUID (hyphens optional) and maps byte pairs to /// Parses the canonical 16-byte UUID (hyphens optional) and maps byte pairs to
@ -83,25 +85,53 @@ class GuidGlyphShape {
return out; 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) { Color displayFillColor(num displayValue) {
final double t = displayT(displayValue); if (isNeutralZero(displayValue)) {
final HSLColor hsl = HSLColor.fromColor(fillColor()); return const Color(0xFFF8FCFF);
final double hueShift = t * (12 + (bytes[7] % 20)); }
final double lightness = (hsl.lightness + t * 0.12).clamp(0.35, 0.68); final double intensity = displayIntensity(displayValue);
final double saturation = final Color target =
(hsl.saturation + t.abs() * 0.1).clamp(0.5, 0.85); displayValue > 0 ? AppColors.success : _negativeAccent;
return hsl return Color.lerp(fillColor(), target, 0.25 + intensity * 0.75)!;
.withHue((hsl.hue + hueShift) % 360)
.withLightness(lightness)
.withSaturation(saturation)
.toColor();
} }
Color displayStrokeColor(num displayValue) => Color displayStrokeColor(num displayValue) {
HSLColor.fromColor(displayFillColor(displayValue)) if (isNeutralZero(displayValue)) {
.withLightness(0.38) return const Color(0xFFE2E8F0);
.toColor(); }
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) { double displayStrokeWidth(num displayValue) {
final double t = displayT(displayValue); final double t = displayT(displayValue);
@ -154,21 +184,39 @@ class QuestionGuidGlyph extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final GuidGlyphShape shape = GuidGlyphShape.fromGuid(guid); 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( return SizedBox(
width: size, width: size,
height: size, height: size,
child: DecoratedBox( child: Center(
child: Container(
width: bodyDiameter,
height: bodyDiameter,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: Colors.transparent,
boxShadow: <BoxShadow>[ boxShadow: <BoxShadow>[
BoxShadow( BoxShadow(
color: glow.withValues( color: glow.withValues(alpha: glowAlpha),
alpha: 0.32 + GuidGlyphShape.displayT(displayValue).abs() * 0.18, blurRadius: blur,
spreadRadius: spread * 0.35,
), ),
blurRadius: 16 + GuidGlyphShape.displayT(displayValue).abs() * 6, BoxShadow(
spreadRadius: 2, color: glow.withValues(alpha: glowAlpha * 0.45),
blurRadius: blur * 1.75,
spreadRadius: spread,
), ),
], ],
), ),
@ -176,6 +224,8 @@ class QuestionGuidGlyph extends StatelessWidget {
painter: _GuidGlyphPainter( painter: _GuidGlyphPainter(
shape: shape, shape: shape,
displayValue: displayValue, displayValue: displayValue,
paintedRadius: paintedRadius,
),
), ),
), ),
), ),
@ -187,15 +237,19 @@ class _GuidGlyphPainter extends CustomPainter {
_GuidGlyphPainter({ _GuidGlyphPainter({
required this.shape, required this.shape,
required this.displayValue, required this.displayValue,
required this.paintedRadius,
}); });
final GuidGlyphShape shape; final GuidGlyphShape shape;
final num displayValue; final num displayValue;
final double paintedRadius;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final Offset center = Offset(size.width / 2, size.height / 2); 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<Offset> unit = shape.displayUnitVertices(displayValue); final List<Offset> unit = shape.displayUnitVertices(displayValue);
final Path path = Path(); final Path path = Path();
@ -211,7 +265,6 @@ class _GuidGlyphPainter extends CustomPainter {
final Color fill = shape.displayFillColor(displayValue); final Color fill = shape.displayFillColor(displayValue);
final Color stroke = shape.displayStrokeColor(displayValue); final Color stroke = shape.displayStrokeColor(displayValue);
final double strokeWidth = shape.displayStrokeWidth(displayValue);
canvas.drawPath( canvas.drawPath(
path, path,
@ -232,5 +285,6 @@ class _GuidGlyphPainter extends CustomPainter {
@override @override
bool shouldRepaint(covariant _GuidGlyphPainter oldDelegate) => bool shouldRepaint(covariant _GuidGlyphPainter oldDelegate) =>
oldDelegate.shape.bytes != shape.bytes || oldDelegate.shape.bytes != shape.bytes ||
oldDelegate.displayValue != displayValue; oldDelegate.displayValue != displayValue ||
oldDelegate.paintedRadius != paintedRadius;
} }

View File

@ -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<String, dynamic> 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();
}
}

View File

@ -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;
}

View File

@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import '../admin/widgets/admin_app_bar_action.dart'; import '../admin/widgets/admin_app_bar_action.dart';
import '../models/app_user.dart'; import '../models/app_user.dart';
import '../models/guess_score_summary.dart';
import '../models/incoming_question.dart'; import '../models/incoming_question.dart';
import '../utils/guess_slot_format.dart';
import '../models/sync_result.dart'; import '../models/sync_result.dart';
import '../models/user_profile.dart'; import '../models/user_profile.dart';
import '../repositories/user_profile_repository.dart'; import '../repositories/user_profile_repository.dart';
@ -47,20 +49,31 @@ class HomeScreen extends StatelessWidget {
QuestionsHubService.instance.hasPendingQuestion, QuestionsHubService.instance.hasPendingQuestion,
QuestionsHubService.instance.pendingQuestion, QuestionsHubService.instance.pendingQuestion,
QuestionsHubService.instance.pendingQuestionCount, QuestionsHubService.instance.pendingQuestionCount,
QuestionsHubService.instance.guessScoreSummary,
]), ]),
builder: (BuildContext context, Widget? child) { builder: (BuildContext context, Widget? child) {
final int count = final int count =
QuestionsHubService.instance.pendingQuestionCount.value; QuestionsHubService.instance.pendingQuestionCount.value;
final bool hasPending = final bool hasPending =
QuestionsHubService.instance.hasPendingQuestion.value; QuestionsHubService.instance.hasPendingQuestion.value;
if (!hasPending || count < 1) { final GuessScoreSummary score =
return const SizedBox.shrink(); QuestionsHubService.instance.guessScoreSummary.value;
} return Row(
final int displayCount = count; mainAxisSize: MainAxisSize.min,
return _QuestionEnvelopeButton( children: <Widget>[
count: displayCount, _CumulativeScoreChip(
summary: score,
onPressed: () => _showGuessScoreStatsDialog(context, score),
),
if (hasPending && count >= 1) ...<Widget>[
const SizedBox(width: 8),
_QuestionEnvelopeButton(
count: count,
onPressed: () => onPressed: () =>
QuestionsHubService.instance.openQuestionPanel(), 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<void>(
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: <Widget>[
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: <Widget>[
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<void> _confirmResetGuessScore(BuildContext context) async {
final bool? confirmed = await showDialog<bool>(
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: <Widget>[
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: <Widget>[
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 { class _SyncStatusChip extends StatelessWidget {
const _SyncStatusChip({required this.status}); const _SyncStatusChip({required this.status});

View File

@ -4,7 +4,9 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../config/api_config.dart'; import '../config/api_config.dart';
import '../models/guess_score_summary.dart';
import '../models/incoming_question.dart'; import '../models/incoming_question.dart';
import '../models/question_submit_result.dart';
import 'auth_service.dart'; import 'auth_service.dart';
/// HTTP client for question queue, answers, and deferrals. /// HTTP client for question queue, answers, and deferrals.
@ -13,11 +15,16 @@ class QuestionsApiService {
final http.Client _client; final http.Client _client;
/// Ensures a starter question exists for the signed-in user (login only). /// Ensures login question state is initialized for the signed-in user.
Future<IncomingQuestion?> bootstrapOnLogin() async { ///
/// 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(); final String? token = await AuthService.instance.getIdToken();
if (token == null) { if (token == null) {
return null; return (question: null, score: null);
} }
final http.Response response = await _client.post( final http.Response response = await _client.post(
@ -28,17 +35,23 @@ class QuestionsApiService {
debugPrint( debugPrint(
'bootstrapOnLogin failed: ${response.statusCode} ${response.body}', 'bootstrapOnLogin failed: ${response.statusCode} ${response.body}',
); );
return null; return (question: null, score: null);
} }
final Map<String, dynamic> body = final Map<String, dynamic> body =
jsonDecode(response.body) as Map<String, dynamic>; jsonDecode(response.body) as Map<String, dynamic>;
final Map<String, dynamic>? questionJson = final Map<String, dynamic>? questionJson =
body['question'] as Map<String, dynamic>?; body['question'] as Map<String, dynamic>?;
if (questionJson == null) { final Map<String, dynamic>? scoreJson =
return null; body['score'] as Map<String, dynamic>?;
} return (
return IncomingQuestion.fromJson(questionJson); question: questionJson == null
? null
: IncomingQuestion.fromJson(questionJson),
score: scoreJson == null
? null
: GuessScoreSummary.fromJson(scoreJson),
);
} }
Future<List<IncomingQuestion>> fetchUnanswered() async { Future<List<IncomingQuestion>> fetchUnanswered() async {
@ -69,7 +82,7 @@ class QuestionsApiService {
.toList(); .toList();
} }
Future<int?> submitAnswer({ Future<QuestionSubmitResult?> submitAnswer({
required String questionId, required String questionId,
num answer = 0, num answer = 0,
}) async { }) async {
@ -92,7 +105,19 @@ class QuestionsApiService {
final Map<String, dynamic> body = final Map<String, dynamic> body =
jsonDecode(response.body) as Map<String, dynamic>; jsonDecode(response.body) as Map<String, dynamic>;
return (body['unansweredCount'] as num?)?.toInt(); final Map<String, dynamic>? scoreJson =
body['score'] as Map<String, dynamic>?;
final Map<String, dynamic>? nextQuestionJson =
body['nextQuestion'] as Map<String, dynamic>?;
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<int?> deferQuestion({required String questionId}) async { Future<int?> deferQuestion({required String questionId}) async {
@ -117,6 +142,60 @@ class QuestionsApiService {
return (body['unansweredCount'] as num?)?.toInt(); return (body['unansweredCount'] as num?)?.toInt();
} }
Future<GuessScoreSummary?> 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<String, dynamic> body =
jsonDecode(response.body) as Map<String, dynamic>;
final Map<String, dynamic>? scoreJson =
body['score'] as Map<String, dynamic>?;
if (scoreJson == null) {
return GuessScoreSummary.empty;
}
return GuessScoreSummary.fromJson(scoreJson);
}
Future<GuessScoreSummary?> 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<String, dynamic> body =
jsonDecode(response.body) as Map<String, dynamic>;
final Map<String, dynamic>? scoreJson =
body['score'] as Map<String, dynamic>?;
if (scoreJson == null) {
return GuessScoreSummary.empty;
}
return GuessScoreSummary.fromJson(scoreJson);
}
Map<String, String> _authHeaders(String token) { Map<String, String> _authHeaders(String token) {
return <String, String>{ return <String, String>{
'Authorization': 'Bearer $token', 'Authorization': 'Bearer $token',

View File

@ -4,7 +4,9 @@ import 'package:flutter/foundation.dart';
import 'package:signalr_netcore/signalr_client.dart'; import 'package:signalr_netcore/signalr_client.dart';
import '../config/api_config.dart'; import '../config/api_config.dart';
import '../models/guess_score_summary.dart';
import '../models/incoming_question.dart'; import '../models/incoming_question.dart';
import '../models/question_submit_result.dart';
import 'auth_service.dart'; import 'auth_service.dart';
import 'questions_api_service.dart'; import 'questions_api_service.dart';
@ -24,23 +26,39 @@ class QuestionsHubService {
final ValueNotifier<List<IncomingQuestion>> questionQueue = final ValueNotifier<List<IncomingQuestion>> questionQueue =
ValueNotifier<List<IncomingQuestion>>(<IncomingQuestion>[]); ValueNotifier<List<IncomingQuestion>>(<IncomingQuestion>[]);
final ValueNotifier<bool> questionActionBusy = ValueNotifier<bool>(false); final ValueNotifier<bool> questionActionBusy = ValueNotifier<bool>(false);
final ValueNotifier<GuessScoreSummary> guessScoreSummary =
ValueNotifier<GuessScoreSummary>(GuessScoreSummary.empty);
HubConnection? _connection; HubConnection? _connection;
bool _connecting = false; bool _connecting = false;
IncomingQuestion? get currentQuestion { IncomingQuestion? get currentQuestion {
final IncomingQuestion? pending = pendingQuestion.value;
final List<IncomingQuestion> queue = questionQueue.value; final List<IncomingQuestion> 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) { if (queue.isNotEmpty) {
return queue.first; 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<void> onLogin() async { Future<void> onLogin() async {
final IncomingQuestion? bootstrapped = await _api.bootstrapOnLogin(); await _refreshGuessScoreSummary();
if (bootstrapped != null) { final ({IncomingQuestion? question, GuessScoreSummary? score}) boot =
_applyIncoming(bootstrapped); await _api.bootstrapOnLogin();
if (boot.score != null) {
guessScoreSummary.value = boot.score!;
} else {
await _refreshGuessScoreSummary();
}
if (boot.question != null) {
_applyIncoming(boot.question!);
} }
await connect(); await connect();
} }
@ -180,43 +198,91 @@ class QuestionsHubService {
} }
} }
/// Swipe right: submit default answer (0). /// Swipe right: submit slider value as the answer.
Future<void> submitCurrentAnswer({num answer = 0}) async { Future<void> submitCurrentAnswer({num answer = 0}) async {
final IncomingQuestion? question = currentQuestion; final IncomingQuestion? question = currentQuestion;
if (question == null || questionActionBusy.value) { if (question == null || questionActionBusy.value) {
return; return;
} }
final String answeredQuestionId = question.id;
questionActionBusy.value = true; questionActionBusy.value = true;
try { try {
final int? serverCount = await _api.submitAnswer( final QuestionSubmitResult? result = await _api.submitAnswer(
questionId: question.id, questionId: answeredQuestionId,
answer: answer, answer: answer,
); );
if (serverCount == null) { if (result == null) {
return; return;
} }
if (serverCount == 0) { guessScoreSummary.value = result.score;
if (result.nextQuestion != null) {
_setActiveQuestion(
result.nextQuestion!,
unansweredCount: result.unansweredCount,
);
return;
}
if (result.unansweredCount == 0) {
_clearPendingUi(); _clearPendingUi();
return; return;
} }
final List<IncomingQuestion> refreshed = await _api.fetchUnanswered(); final List<IncomingQuestion> refreshed = await _api.fetchUnanswered();
if (refreshed.isEmpty) { final List<IncomingQuestion> remaining = refreshed
.where((IncomingQuestion q) => q.id != answeredQuestionId)
.toList();
if (remaining.isEmpty) {
_clearPendingUi(); _clearPendingUi();
return; return;
} }
questionQueue.value = refreshed; _setActiveQuestion(
pendingQuestion.value = refreshed.first; remaining.first,
pendingQuestionCount.value = serverCount; unansweredCount: result.unansweredCount,
hasPendingQuestion.value = true; queue: remaining,
);
} finally { } finally {
questionActionBusy.value = false; questionActionBusy.value = false;
} }
} }
/// Makes [question] the sole active card (queue head matches [pendingQuestion]).
void _setActiveQuestion(
IncomingQuestion question, {
required int unansweredCount,
List<IncomingQuestion>? queue,
}) {
final int count = unansweredCount < 1 ? 1 : unansweredCount;
pendingQuestion.value = question;
pendingQuestionCount.value = count;
hasPendingQuestion.value = true;
if (questionPanelOpen.value) {
questionQueue.value = queue ?? <IncomingQuestion>[question];
}
}
Future<void> _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<bool> resetGuessScoreSummary() async {
final GuessScoreSummary? score = await _api.resetGuessScoreSummary();
if (score == null) {
return false;
}
guessScoreSummary.value = score;
return true;
}
void _syncPendingFromQueue(int count) { void _syncPendingFromQueue(int count) {
final List<IncomingQuestion> queue = questionQueue.value; final List<IncomingQuestion> queue = questionQueue.value;
if (queue.isEmpty || count == 0) { if (queue.isEmpty || count == 0) {
@ -253,4 +319,9 @@ class QuestionsHubService {
} }
_clearPendingUi(); _clearPendingUi();
} }
/// Clears in-memory score (call on sign-out; score remains on server per UID).
void clearGuessScoreCache() {
guessScoreSummary.value = GuessScoreSummary.empty;
}
} }

View File

@ -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';
}

View File

@ -51,6 +51,7 @@ class _ProfileSessionState extends State<ProfileSession> {
@override @override
void dispose() { void dispose() {
_syncStatusSubscription?.cancel(); _syncStatusSubscription?.cancel();
QuestionsHubService.instance.clearGuessScoreCache();
unawaited(QuestionsHubService.instance.disconnect()); unawaited(QuestionsHubService.instance.disconnect());
UserProfileRepository.instance.endSession(); UserProfileRepository.instance.endSession();
super.dispose(); super.dispose();

View File

@ -45,6 +45,10 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
/// Updated each build from the tile height so ±10 reaches near the track edges. /// Updated each build from the tile height so ±10 reaches near the track edges.
double _maxVerticalDrag = 120; double _maxVerticalDrag = 120;
bool get _atZero => _snappedSliderValue == 0;
bool get _horizontalSwipeEnabled => !widget.busy && !_acting && !_atZero;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -90,7 +94,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
} }
Future<void> _releaseDrag() async { Future<void> _releaseDrag() async {
if (_acting || widget.busy) { if (_acting || widget.busy || _atZero) {
setState(() => _dragOffset = 0); setState(() => _dragOffset = 0);
return; return;
} }
@ -121,11 +125,9 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final double width = MediaQuery.sizeOf(context).width; final double width = MediaQuery.sizeOf(context).width;
final double progress = (_dragOffset / _swipeThreshold).clamp(-1.0, 1.0);
return LayoutBuilder( return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) { builder: (BuildContext context, BoxConstraints constraints) {
// Container vertical padding (24×2) + track insets (8×2).
const double outerVerticalPadding = 48; const double outerVerticalPadding = 48;
const double trackVerticalInset = 16; const double trackVerticalInset = 16;
final double innerHeight = final double innerHeight =
@ -139,27 +141,8 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
return Stack( return Stack(
alignment: Alignment.center, alignment: Alignment.center,
clipBehavior: Clip.none,
children: <Widget>[ children: <Widget>[
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: <Color>[
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( Transform.translate(
offset: Offset(_dragOffset, 0), offset: Offset(_dragOffset, 0),
child: AnimatedBuilder( child: AnimatedBuilder(
@ -176,23 +159,23 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
); );
}, },
child: GestureDetector( child: GestureDetector(
onHorizontalDragUpdate: widget.busy || _acting onHorizontalDragUpdate: _horizontalSwipeEnabled
? null ? (DragUpdateDetails details) {
: (DragUpdateDetails details) {
setState(() { setState(() {
_dragOffset += details.delta.dx; _dragOffset += details.delta.dx;
_dragOffset = _dragOffset =
_dragOffset.clamp(-width * 0.55, width * 0.55); _dragOffset.clamp(-width * 0.55, width * 0.55);
}); });
}, }
onHorizontalDragEnd: widget.busy || _acting : null,
? null onHorizontalDragEnd: _horizontalSwipeEnabled
: (_) => unawaited(_releaseDrag()), ? (_) => unawaited(_releaseDrag())
child: Material( : null,
color: AppColors.surfaceElevated, child: ClipRRect(
elevation: 4,
shadowColor: Colors.black45,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
elevation: 0,
child: Container( child: Container(
width: constraints.maxWidth, width: constraints.maxWidth,
constraints: const BoxConstraints(minHeight: 220), constraints: const BoxConstraints(minHeight: 220),
@ -201,10 +184,18 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
vertical: 24, vertical: 24,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surfaceElevated.withValues(
alpha: 0.9,
),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.accent.withValues(alpha: 0.18),
width: 1,
),
), ),
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
clipBehavior: Clip.none,
children: <Widget>[ children: <Widget>[
Positioned( Positioned(
top: 8, top: 8,
@ -214,10 +205,8 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
color: Color.lerp( color: AppColors.surface.withValues(
AppColors.surfaceElevated, alpha: 0.6,
AppColors.surface,
0.45,
), ),
), ),
), ),
@ -234,10 +223,14 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
: (DragUpdateDetails details) { : (DragUpdateDetails details) {
setState(() { setState(() {
_verticalOffset -= details.delta.dy; _verticalOffset -= details.delta.dy;
_verticalOffset = _verticalOffset.clamp( _verticalOffset =
_verticalOffset.clamp(
-_maxVerticalDrag, -_maxVerticalDrag,
_maxVerticalDrag, _maxVerticalDrag,
); );
if (_atZero) {
_dragOffset = 0;
}
_maybeTriggerSnapFeedback( _maybeTriggerSnapFeedback(
_snappedSliderValue, _snappedSliderValue,
); );
@ -245,7 +238,8 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
}, },
child: Center( child: Center(
child: Transform.translate( child: Transform.translate(
offset: Offset(0, -_snappedVerticalOffset), offset:
Offset(0, -_snappedVerticalOffset),
child: QuestionGuidGlyph( child: QuestionGuidGlyph(
guid: widget.questionId, guid: widget.questionId,
size: _glyphSize, size: _glyphSize,
@ -262,17 +256,20 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
), ),
), ),
), ),
),
if (widget.busy) if (widget.busy)
const Positioned.fill( Positioned.fill(
child: ColoredBox( child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: const ColoredBox(
color: Color(0x66000000), color: Color(0x66000000),
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
), ),
), ),
),
], ],
); );
}, },
); );
} }
} }

View File

@ -53,8 +53,10 @@ trading tables between cases. Optional override: `TEST_DATABASE_URL`.
| `GET` | `/v1/me/profile` | `Authorization: Bearer <Firebase ID token>` | | `GET` | `/v1/me/profile` | `Authorization: Bearer <Firebase ID token>` |
| `PUT` | `/v1/me/profile` | same | | `PUT` | `/v1/me/profile` | same |
| `POST` | `/v1/me/incoming-question` | same — pushes a question to the client via SignalR | | `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` | 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}/answer` | submit answer (`{"answer": 0}` default) |
| `POST` | `/v1/me/questions/{id}/defer` | move question to end of queue | | `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` Hub URL: `http://localhost:3000/hubs/questions`
The Flutter app calls `POST /v1/me/questions/bootstrap` once at login to ensure a The Flutter app calls `POST /v1/me/questions/bootstrap` once at login. The API ensures a
starter question exists (random correct answer from -10 to 10) when the user has none. `users` row exists, picks a random row from `market_history_prospective_questions`, and
After sign-in it connects to SignalR and listens for `ReceiveQuestion`. On each new creates a user question linked by `metadata.prospective_question_id` (falls back to
WebSocket connection the API only delivers an existing unanswered question — it does oldest unanswered when no prospective rows exist). After sign-in it connects to SignalR
not create new rows. 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): 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` `questions` table: `id` (UUID), `assigned_user_id`, `question_text`, `user_response`
(nullable), `correct_answer`, `created_at`, `modified_at`. (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 ## Background question pipeline
A background worker runs inside the API process (enabled by default). On each 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_ENABLED` | `true` | Set to `false` to disable the worker |
| `QUESTION_WORKER_INTERVAL_SECONDS` | `60` | Seconds between maintenance cycles | | `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 | | `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** **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. crashed prior sync cannot block new work.
**Universe** / **cleanup** keep hour-based cadence. `guess_weekly_move` reads these bars. **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`. Requires `TRADING_ENABLED=true` when `MARKET_HISTORY_SYNC_ENABLED=true`.
**Migration `008`:** legacy `1Day` / `4Hour` history cleanup and `slots_synced` on sync runs. **Migration `008`:** legacy `1Day` / `4Hour` history cleanup and `slots_synced` on sync runs.

View File

@ -19,9 +19,11 @@ import '../lib/handlers/trading_dev_handler.dart';
import '../lib/pipeline/question_pipeline.dart'; import '../lib/pipeline/question_pipeline.dart';
import '../lib/question_service.dart'; import '../lib/question_service.dart';
import '../lib/questions_db.dart'; import '../lib/questions_db.dart';
import '../lib/trading/prospective_answer_scoring.dart';
import '../lib/trading/guardrails.dart'; import '../lib/trading/guardrails.dart';
import '../lib/trading/market_data_db.dart'; import '../lib/trading/market_data_db.dart';
import '../lib/trading/market_data_history.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_api_rate_limiter.dart';
import '../lib/trading/market_history_query.dart'; import '../lib/trading/market_history_query.dart';
import '../lib/trading/market_data_ingest.dart'; import '../lib/trading/market_data_ingest.dart';
@ -52,6 +54,8 @@ Future<void> main() async {
} }
final ServerEnv env = ServerEnv.load(); final ServerEnv env = ServerEnv.load();
ProspectiveAnswerScoring.closenessExtraPointsEnabled =
env.prospectiveAnswerClosenessEnabled;
final ProfileDb db = await ProfileDb.connect(env.databaseUrl); final ProfileDb db = await ProfileDb.connect(env.databaseUrl);
await db.migrate(); await db.migrate();
@ -166,6 +170,8 @@ Future<void> main() async {
connection: db.connection, connection: db.connection,
windowDays: mh.retentionDays, windowDays: mh.retentionDays,
); );
final MarketHistoryProspectiveQuestions prospectiveQuestions =
MarketHistoryProspectiveQuestions(connection: db.connection);
if (env.marketHistorySyncEnabled) { if (env.marketHistorySyncEnabled) {
marketHistoryScheduler = MarketHistoryScheduler( marketHistoryScheduler = MarketHistoryScheduler(
connection: db.connection, connection: db.connection,
@ -181,6 +187,11 @@ Future<void> main() async {
backfillIsDue: historySync.hasPendingSlots, backfillIsDue: historySync.hasPendingSlots,
runCleanup: (DateTime now) => runCleanup: (DateTime now) =>
retention.run(archive: mh.archiveEnabled, now: now), retention.run(archive: mh.archiveEnabled, now: now),
runProspectiveQuestions: (DateTime now) => prospectiveQuestions.refresh(
now: now,
windowDays: mh.windowDays,
retentionDays: mh.retentionDays,
),
); );
} }
if (env.adminPortalEnabled) { if (env.adminPortalEnabled) {

View File

@ -12,6 +12,7 @@ class ServerEnv {
required this.questionWorkerEnabled, required this.questionWorkerEnabled,
required this.questionWorkerIntervalSeconds, required this.questionWorkerIntervalSeconds,
required this.questionPipelineTestMode, required this.questionPipelineTestMode,
required this.prospectiveAnswerClosenessEnabled,
required this.tradingEnabled, required this.tradingEnabled,
required this.tradingWorkerIngestEnabled, required this.tradingWorkerIngestEnabled,
required this.tradingWorkerEvalEnabled, required this.tradingWorkerEvalEnabled,
@ -28,6 +29,11 @@ class ServerEnv {
final bool questionWorkerEnabled; final bool questionWorkerEnabled;
final int questionWorkerIntervalSeconds; final int questionWorkerIntervalSeconds;
final bool questionPipelineTestMode; 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 tradingEnabled;
final bool tradingWorkerIngestEnabled; final bool tradingWorkerIngestEnabled;
final bool tradingWorkerEvalEnabled; final bool tradingWorkerEvalEnabled;
@ -88,6 +94,10 @@ class ServerEnv {
int.tryParse(env['QUESTION_WORKER_INTERVAL_SECONDS'] ?? '60') ?? 60; int.tryParse(env['QUESTION_WORKER_INTERVAL_SECONDS'] ?? '60') ?? 60;
final bool pipelineTestMode = final bool pipelineTestMode =
(env['QUESTION_PIPELINE_TEST_MODE'] ?? 'false').toLowerCase() == 'true'; (env['QUESTION_PIPELINE_TEST_MODE'] ?? 'false').toLowerCase() == 'true';
final bool prospectiveAnswerClosenessEnabled =
(env['PROSPECTIVE_ANSWER_CLOSENESS_ENABLED'] ?? 'false')
.toLowerCase() ==
'true';
final bool tradingEnabled = final bool tradingEnabled =
(env['TRADING_ENABLED'] ?? 'false').toLowerCase() == 'true'; (env['TRADING_ENABLED'] ?? 'false').toLowerCase() == 'true';
final bool tradingWorkerIngestEnabled = final bool tradingWorkerIngestEnabled =
@ -139,6 +149,7 @@ class ServerEnv {
questionWorkerEnabled: workerEnabled, questionWorkerEnabled: workerEnabled,
questionWorkerIntervalSeconds: workerIntervalSeconds, questionWorkerIntervalSeconds: workerIntervalSeconds,
questionPipelineTestMode: pipelineTestMode, questionPipelineTestMode: pipelineTestMode,
prospectiveAnswerClosenessEnabled: prospectiveAnswerClosenessEnabled,
tradingEnabled: tradingEnabled, tradingEnabled: tradingEnabled,
tradingWorkerIngestEnabled: tradingWorkerIngestEnabled, tradingWorkerIngestEnabled: tradingWorkerIngestEnabled,
tradingWorkerEvalEnabled: tradingWorkerEvalEnabled, tradingWorkerEvalEnabled: tradingWorkerEvalEnabled,

View File

@ -65,7 +65,7 @@ Handler marketHistoryAdminHandler({
SELECT id, kind, started_at, finished_at, rows_written, rows_removed, SELECT id, kind, started_at, finished_at, rows_written, rows_removed,
slots_synced, backfill_items, error slots_synced, backfill_items, error
FROM market_data_sync_runs FROM market_data_sync_runs
WHERE 1=1 WHERE kind <> 'prospective_questions'
''', ''',
); );
final Map<String, dynamic> params = <String, dynamic>{'limit': limit}; final Map<String, dynamic> params = <String, dynamic>{'limit': limit};

View File

@ -26,13 +26,16 @@ Handler questionsHandler({
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'}); return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
} }
try { try {
final Map<String, dynamic> question = final Map<String, dynamic>? question =
await questionService.ensureStarterQuestionOnLogin(firebaseUid); await questionService.bootstrapOnLogin(firebaseUid);
final int unansweredCount = final int unansweredCount =
await questionsDb.countUnansweredQuestions(firebaseUid); await questionsDb.countUnansweredQuestions(firebaseUid);
final Map<String, dynamic> score =
await questionsDb.getGuessScoreSummary(firebaseUid);
return _jsonResponse(200, <String, dynamic>{ return _jsonResponse(200, <String, dynamic>{
'question': question, 'question': question,
'unansweredCount': unansweredCount, 'unansweredCount': unansweredCount,
'score': score,
}); });
} catch (e, st) { } catch (e, st) {
stderr.writeln('Bootstrap questions error: $e\n$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, <String, dynamic>{'error': 'Unauthorized'});
}
try {
final Map<String, dynamic> score =
await questionsDb.getGuessScoreSummary(firebaseUid);
return _jsonResponse(200, <String, dynamic>{
'score': score,
});
} catch (e, st) {
stderr.writeln('Get questions score error: $e\n$st');
return _jsonResponse(500, <String, dynamic>{'error': 'Internal error'});
}
});
router.post('$questionsBasePath/score/reset', (Request request) async {
final String? firebaseUid = await _verify(auth, request);
if (firebaseUid == null) {
return _jsonResponse(401, <String, dynamic>{'error': 'Unauthorized'});
}
try {
final Map<String, dynamic> score =
await questionsDb.resetGuessScoreSummary(firebaseUid);
return _jsonResponse(200, <String, dynamic>{
'score': score,
});
} catch (e, st) {
stderr.writeln('Reset questions score error: $e\n$st');
return _jsonResponse(500, <String, dynamic>{'error': 'Internal error'});
}
});
router.post( router.post(
'$questionsBasePath/<id>/answer', '$questionsBasePath/<id>/answer',
(Request request, String id) async { (Request request, String id) async {
@ -95,11 +132,23 @@ Handler questionsHandler({
userResponse: answer, userResponse: answer,
); );
} }
final int unansweredCount = var unansweredCount =
await questionsDb.countUnansweredQuestions(firebaseUid); await questionsDb.countUnansweredQuestions(firebaseUid);
Map<String, dynamic>? nextQuestion;
if (unansweredCount == 0) {
nextQuestion =
await questionService.ensureProspectiveQuestionQueued(firebaseUid);
if (nextQuestion != null) {
unansweredCount = 1;
}
}
final Map<String, dynamic> score =
await questionsDb.getGuessScoreSummary(firebaseUid);
return _jsonResponse(200, <String, dynamic>{ return _jsonResponse(200, <String, dynamic>{
'question': updated, 'question': updated,
'unansweredCount': unansweredCount, 'unansweredCount': unansweredCount,
'score': score,
if (nextQuestion != null) 'nextQuestion': nextQuestion,
}); });
} catch (e, st) { } catch (e, st) {
stderr.writeln('Answer question error: $e\n$st'); stderr.writeln('Answer question error: $e\n$st');

View File

@ -8,7 +8,7 @@ import '../trading/trading_pipeline.dart';
import 'branch_decision.dart'; import 'branch_decision.dart';
import 'external_data_fetcher.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 { abstract final class PipelineTestQuestions {
static const String text = static const String text =
'Starter question: enter a whole number between -10 and 10.'; 'Starter question: enter a whole number between -10 and 10.';
@ -156,6 +156,16 @@ class QuestionPipeline {
if (pipelineKey == null || pipelineStep == null) { if (pipelineKey == null || pipelineStep == null) {
return; 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)) { if (!await _canEnqueue(firebaseUid)) {
return; return;
} }
@ -188,13 +198,7 @@ class QuestionPipeline {
context: context, context: context,
); );
case PipelineKeys.trading: case PipelineKeys.trading:
if (_tradingPipeline != null) { break;
await _tradingPipeline.handleAnswer(
firebaseUid: firebaseUid,
answeredQuestion: answeredQuestion,
userResponse: userResponse,
);
}
} }
} }

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'questions_db.dart'; import 'questions_db.dart';
import 'signalr/questions_hub_connections.dart'; import 'signalr/questions_hub_connections.dart';
import 'trading/prospective_guess_assignments_db.dart';
/// Creates questions in Postgres and delivers them over SignalR. /// Creates questions in Postgres and delivers them over SignalR.
class QuestionService { class QuestionService {
@ -15,20 +16,139 @@ class QuestionService {
final QuestionsDb _questionsDb; final QuestionsDb _questionsDb;
final QuestionsHubConnections _hubConnections; final QuestionsHubConnections _hubConnections;
/// Called at login: ensures a starter question exists when the user has none. ProspectiveGuessAssignmentsDb get _assignmentsDb =>
Future<Map<String, dynamic>> ensureStarterQuestionOnLogin( ProspectiveGuessAssignmentsDb(_questionsDb.connection);
/// Called at login: ensures one unanswered prospective question when possible.
Future<Map<String, dynamic>?> bootstrapOnLogin(
String firebaseUid, String firebaseUid,
) async { ) async {
final Map<String, dynamic> question = await _questionsDb.ensureUserExists(firebaseUid);
await _questionsDb.getOrCreateStarterQuestion(firebaseUid); return ensureProspectiveQuestionQueued(firebaseUid);
}
/// Creates the next slot-based prospective question when the queue is empty.
Future<Map<String, dynamic>?> ensureProspectiveQuestionQueued(
String firebaseUid, {
DateTime? now,
}) async {
await _questionsDb.ensureUserExists(firebaseUid);
final String? pendingQuestionId =
await _assignmentsDb.findPendingQuestionId(firebaseUid);
if (pendingQuestionId != null) {
final Map<String, dynamic>? question = await _questionsDb.findQuestionById(
questionId: pendingQuestionId,
assignedUserId: firebaseUid,
);
if (question != null && question['userResponse'] == null) {
final int unansweredCount = final int unansweredCount =
await _questionsDb.countUnansweredQuestions(firebaseUid); await _questionsDb.countUnansweredQuestions(firebaseUid);
final Map<String, dynamic> payload = _questionsDb.toClientPayload( return _questionsDb.toClientPayload(
question, question,
unansweredCount: unansweredCount, unansweredCount: unansweredCount > 0 ? unansweredCount : 1,
); );
await _hubConnections.pushQuestion(firebaseUid, payload); }
return payload; }
final int queued =
await _questionsDb.countUnansweredQuestions(firebaseUid);
if (queued > 0) {
final Map<String, dynamic>? existing =
await _questionsDb.findUnansweredQuestion(firebaseUid);
if (existing == null) {
return null;
}
return _questionsDb.toClientPayload(
existing,
unansweredCount: queued,
);
}
final Map<String, dynamic>? prospective =
await _createProspectiveQuestionWithAssignment(
firebaseUid,
now: now,
);
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<Map<String, dynamic>?> _createProspectiveQuestionWithAssignment(
String firebaseUid, {
DateTime? now,
}) async {
for (var attempt = 0; attempt < 8; attempt++) {
final Map<String, dynamic>? 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<String, dynamic> 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: <String, dynamic>{
'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. /// Inserts a question and pushes it to connected SignalR clients.

View File

@ -1,15 +1,23 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'package:postgres/postgres.dart'; import 'package:postgres/postgres.dart';
import 'package:uuid/uuid.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. /// Postgres access for the questions table.
class QuestionsDb { class QuestionsDb {
QuestionsDb(this._connection); QuestionsDb(this._connection);
final Connection _connection; final Connection _connection;
Connection get connection => _connection;
static const Uuid _uuid = Uuid(); static const Uuid _uuid = Uuid();
Future<void> ensureUserExists(String firebaseUid) async { Future<void> ensureUserExists(String firebaseUid) async {
@ -69,7 +77,31 @@ class QuestionsDb {
return result.map(_rowFromResult).toList(); return result.map(_rowFromResult).toList();
} }
/// Removes an unanswered question owned by [assignedUserId].
Future<void> 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: <String, dynamic>{
'id': questionId,
'uid': assignedUserId,
},
);
}
/// Records [userResponse] for an unanswered question owned by [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<Map<String, dynamic>?> submitAnswer({ Future<Map<String, dynamic>?> submitAnswer({
required String questionId, required String questionId,
required String assignedUserId, required String assignedUserId,
@ -99,6 +131,38 @@ class QuestionsDb {
if (result.isEmpty) { if (result.isEmpty) {
return null; return null;
} }
final Map<String, dynamic> updated = _rowFromResult(result.first);
await _recordProspectiveAnswer(updated);
await ProspectiveGuessAssignmentsDb(_connection).markAnsweredByQuestionId(
questionId: questionId,
answeredAt: now,
);
await _gradeProspectiveAnswerIfNeeded(updated);
return updated;
}
Future<Map<String, dynamic>?> 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: <String, dynamic>{
'id': questionId,
'uid': assignedUserId,
},
);
if (result.isEmpty) {
return null;
}
return _rowFromResult(result.first); return _rowFromResult(result.first);
} }
@ -225,58 +289,51 @@ class QuestionsDb {
return (result.first[0]! as num).toInt(); return (result.first[0]! as num).toInt();
} }
/// Returns an existing unanswered question or creates the starter question. /// Cumulative guess score for [firebaseUid] (Firebase auth UID), persisted in
Future<Map<String, dynamic>> getOrCreateStarterQuestion( /// `user_trading_state.context.guess_score` and repaired from answer history.
String assignedUserId, Future<Map<String, dynamic>> getGuessScoreSummary(String firebaseUid) async {
) async { await ensureUserExists(firebaseUid);
final Map<String, dynamic>? existing = return GuessScoreStore.loadSummary(_connection, firebaseUid);
await findUnansweredQuestion(assignedUserId);
if (existing != null) {
return existing;
}
return createStarterQuestion(assignedUserId);
} }
/// Creates the starter test question with a random correct answer in [-10, 10]. /// Resets guess score counters and slot progression to the earliest window slot.
Future<Map<String, dynamic>> createStarterQuestion(String assignedUserId) async { Future<Map<String, dynamic>> resetGuessScoreSummary(
await ensureUserExists(assignedUserId); String firebaseUid, {
final int correctAnswer = Random().nextInt(21) - 10; DateTime? now,
final String id = _uuid.v4(); }) async {
final DateTime now = DateTime.now().toUtc(); final DateTime tick = (now ?? DateTime.now()).toUtc();
const String questionText = final DateTime earliest = ProspectiveGuessSelection.earliestPlayableSlotStart(
'Starter question: enter a whole number between -10 and 10.'; tick,
MarketHistoryConfig.windowDays,
);
await _connection.execute( await _connection.execute(
Sql.named( Sql.named(
''' '''
INSERT INTO questions ( DELETE FROM market_history_prospective_answers
id, assigned_user_id, question_text, user_response, correct_answer, WHERE assigned_user_id = @uid
created_at, modified_at
) VALUES (
@id::uuid, @assigned_user_id, @question_text, NULL, @correct_answer,
@created_at, @modified_at
)
''', ''',
), ),
parameters: <String, dynamic>{ parameters: <String, dynamic>{'uid': firebaseUid},
'id': id,
'assigned_user_id': assignedUserId,
'question_text': questionText,
'correct_answer': correctAnswer,
'created_at': now,
'modified_at': now,
},
); );
await ProspectiveGuessAssignmentsDb(_connection).deleteAllForUser(
firebaseUid,
);
await UserTradingStateDb(_connection).resetGuessScore(
firebaseUid,
slotStart: earliest,
);
return getGuessScoreSummary(firebaseUid);
}
return <String, dynamic>{ /// Slot-progressive prospective question for [firebaseUid], or null when caught up.
'id': id, Future<Map<String, dynamic>?> pickProspectiveQuestionForUser(
'assignedUserId': assignedUserId, String firebaseUid, {
'text': questionText, DateTime? now,
'userResponse': null, }) async {
'correctAnswer': correctAnswer, return ProspectiveGuessSelection(connection: _connection).pickForUser(
'createdAt': now.toIso8601String(), firebaseUid,
'modifiedAt': now.toIso8601String(), now: now,
}; );
} }
Future<Map<String, dynamic>> createQuestion({ Future<Map<String, dynamic>> createQuestion({
@ -403,6 +460,101 @@ class QuestionsDb {
return _readNumeric(value); return _readNumeric(value);
} }
Future<void> _gradeProspectiveAnswerIfNeeded(
Map<String, dynamic> answeredQuestion,
) async {
final Map<String, dynamic> metadata = Map<String, dynamic>.from(
answeredQuestion['metadata'] as Map<String, dynamic>? ??
<String, dynamic>{},
);
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<void> _recordProspectiveAnswer(Map<String, dynamic> answeredQuestion) async {
final Map<String, dynamic> metadata = Map<String, dynamic>.from(
answeredQuestion['metadata'] as Map<String, dynamic>? ??
<String, dynamic>{},
);
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: <String, dynamic>{
'question_id': questionId,
'assigned_user_id': assignedUserId,
'prospective_question_id': prospectiveQuestionId,
'user_slider_value': userSliderValue,
'answered_at': answeredAt,
},
);
}
Map<String, dynamic> _rowToJson({ Map<String, dynamic> _rowToJson({
required String id, required String id,
required String assignedUserId, required String assignedUserId,

View File

@ -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<Map<String, dynamic>> loadSummary(
Connection connection,
String firebaseUid,
) async {
final UserTradingStateDb tradingStateDb = UserTradingStateDb(connection);
await tradingStateDb.ensureExists(firebaseUid);
Map<String, dynamic> stored = Map<String, dynamic>.from(
await tradingStateDb.getGuessScore(firebaseUid) ?? <String, dynamic>{},
);
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 = <String, dynamic>{
'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<String, dynamic> _toApiSummary(Map<String, dynamic> 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 <String, dynamic>{
'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<String, dynamic>.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: <String, dynamic>{'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<num> _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: <String, dynamic>{'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<String?> _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: <String, dynamic>{'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;
}

View File

@ -1,6 +1,7 @@
import 'package:postgres/postgres.dart'; import 'package:postgres/postgres.dart';
import 'market_history_config.dart'; import 'market_history_config.dart';
import 'market_history_prospective_questions.dart';
import 'market_history_session_slot.dart'; import 'market_history_session_slot.dart';
import 'sync_run_recorder.dart'; import 'sync_run_recorder.dart';
@ -101,6 +102,10 @@ class MarketDataRetention {
totalRemoved += removed; totalRemoved += removed;
} }
totalRemoved += await MarketHistoryProspectiveQuestions(
connection: _connection,
).deleteExpiredBefore(cutoff);
return SyncRunCounts(rowsRemoved: totalRemoved); return SyncRunCounts(rowsRemoved: totalRemoved);
} }

View File

@ -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<ProspectiveQuestionsRefreshResult> 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<QuestionAuditAsset> 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<QuestionAuditAsset> 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: <String, dynamic>{'key': refreshAdvisoryLockKey},
);
final Result expired = await tx.execute(
Sql.named(
'''
DELETE FROM market_history_prospective_questions
WHERE older_slot_start < @cutoff
''',
),
parameters: <String, dynamic>{'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: <String, dynamic>{
'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<String> 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: <String, dynamic>{
'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: <String, dynamic>{
'older_slot_start': older,
'newer_slot_start': newer,
'symbols': symbols,
},
);
}
pruned = prunedResult.affectedRows;
});
return _SlotPairSyncCounts(
upserted: upserted,
pruned: pruned,
expiredRemoved: expiredRemoved,
);
}
Future<DateTime?> _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<int> deleteExpiredBefore(DateTime cutoff) async {
final Result result = await _connection.execute(
Sql.named(
'''
DELETE FROM market_history_prospective_questions
WHERE older_slot_start < @cutoff
''',
),
parameters: <String, dynamic>{'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;
}

View File

@ -8,12 +8,13 @@ import 'market_history_config.dart';
import 'market_history_session_slot.dart'; import 'market_history_session_slot.dart';
import 'tradable_assets_db.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 { class QuestionAuditBarSlot {
QuestionAuditBarSlot({ QuestionAuditBarSlot({
required this.asOf, required this.asOf,
required this.avgPrice, required this.avgPrice,
required this.volume, required this.volume,
required this.volumeUsd,
this.open, this.open,
this.high, this.high,
this.low, this.low,
@ -26,7 +27,10 @@ class QuestionAuditBarSlot {
final num? low; final num? low;
final num? close; final num? close;
final num avgPrice; final num avgPrice;
/// Share/base volume from Alpaca (`v`).
final num volume; final num volume;
/// Dollar notional for this slot (see [questionAuditBarVolumeUsd]).
final num volumeUsd;
Map<String, dynamic> toJson() => <String, dynamic>{ Map<String, dynamic> toJson() => <String, dynamic>{
'asOf': asOf.toIso8601String(), 'asOf': asOf.toIso8601String(),
@ -36,10 +40,56 @@ class QuestionAuditBarSlot {
if (close != null) 'close': close, if (close != null) 'close': close,
'avgPrice': avgPrice, 'avgPrice': avgPrice,
'volume': volume, '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<String, dynamic>? raw,
}) {
if (raw != null) {
for (final String key in <String>['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<QuestionAuditAsset> questionAuditTopHalfVolumeAssets(
List<QuestionAuditAsset> 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 { class QuestionAuditAsset {
QuestionAuditAsset({ QuestionAuditAsset({
required this.symbol, required this.symbol,
@ -88,14 +138,21 @@ QuestionAuditBarSlot barRowToSlot(_BarRow row) {
final num? low = raw == null ? null : MarketDataDb.readOptionalNumeric(raw['l']); final num? low = raw == null ? null : MarketDataDb.readOptionalNumeric(raw['l']);
final num? close = final num? close =
raw == null ? row.closePrice : MarketDataDb.readOptionalNumeric(raw['c']); raw == null ? row.closePrice : MarketDataDb.readOptionalNumeric(raw['c']);
final num avgPrice = averageBarPrice(closePrice: row.closePrice, raw: raw);
final num volume = row.volume!;
return QuestionAuditBarSlot( return QuestionAuditBarSlot(
asOf: row.asOf, asOf: row.asOf,
open: open, open: open,
high: high, high: high,
low: low, low: low,
close: close ?? row.closePrice, close: close ?? row.closePrice,
avgPrice: averageBarPrice(closePrice: row.closePrice, raw: raw), avgPrice: avgPrice,
volume: row.volume!, volume: volume,
volumeUsd: questionAuditBarVolumeUsd(
volume: volume,
avgPrice: avgPrice,
raw: raw,
),
); );
} }
@ -416,11 +473,20 @@ class MarketHistoryQuestionAudit {
try { try {
final QuestionAuditBarSlot newerBar = barRowToSlot(newerRow); final QuestionAuditBarSlot newerBar = barRowToSlot(newerRow);
final QuestionAuditBarSlot olderBar = barRowToSlot(olderRow); final QuestionAuditBarSlot olderBar = barRowToSlot(olderRow);
if (olderBar.avgPrice == 0 || olderBar.volume == 0) {
continue;
}
assets.add( assets.add(
QuestionAuditAsset( QuestionAuditAsset(
symbol: entry.key, symbol: entry.key,
priceDelta: newerBar.avgPrice - olderBar.avgPrice, priceDelta: questionAuditPercentChange(
volumeDelta: newerBar.volume - olderBar.volume, newer: newerBar.avgPrice,
older: olderBar.avgPrice,
),
volumeDelta: questionAuditPercentChange(
newer: newerBar.volume,
older: olderBar.volume,
),
olderSlot: olderBar, olderSlot: olderBar,
newerSlot: newerBar, newerSlot: newerBar,
), ),
@ -430,10 +496,14 @@ class MarketHistoryQuestionAudit {
} }
} }
assets.sort( assets.sort((QuestionAuditAsset a, QuestionAuditAsset b) {
(QuestionAuditAsset a, QuestionAuditAsset b) => final int byVolume =
a.symbol.compareTo(b.symbol), questionAuditAvgVolumeUsd(b).compareTo(questionAuditAvgVolumeUsd(a));
); if (byVolume != 0) {
return byVolume;
}
return a.symbol.compareTo(b.symbol);
});
return assets; return assets;
} }

View File

@ -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<void> 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);
}

View File

@ -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<String?> 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: <String, dynamic>{
'uid': firebaseUid,
'pending': statusPending,
},
);
if (result.isEmpty) {
return null;
}
return result.first[0].toString();
}
Future<bool> 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: <String, dynamic>{
'uid': firebaseUid,
'older': _slot(olderSlotStart),
'newer': _slot(newerSlotStart),
'pending': statusPending,
},
);
return result.isNotEmpty;
}
Future<bool> 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: <String, dynamic>{
'uid': firebaseUid,
'older': _slot(olderSlotStart),
'newer': _slot(newerSlotStart),
'symbol': symbol,
},
);
return result.isNotEmpty;
}
Future<Set<String>> 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: <String, dynamic>{
'uid': firebaseUid,
'older': _slot(olderSlotStart),
'newer': _slot(newerSlotStart),
},
);
return result.map((ResultRow row) => row[0]! as String).toSet();
}
Future<Set<String>> 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: <String, dynamic>{
'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<bool> 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: <String, dynamic>{
'uid': firebaseUid,
'older': _slot(olderSlotStart),
'newer': _slot(newerSlotStart),
'symbol': symbol,
'prospective_question_id': prospectiveQuestionId,
'question_id': questionId,
'pending': statusPending,
},
);
return result.isNotEmpty;
}
Future<void> 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<void> 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: <String, dynamic>{
'question_id': questionId,
'answered': statusAnswered,
'answered_at': answeredAt.toUtc(),
'pending': statusPending,
},
);
}
Future<void> deleteAllForUser(String firebaseUid) async {
await _connection.execute(
Sql.named(
'''
DELETE FROM market_history_prospective_assignments
WHERE assigned_user_id = @uid
''',
),
parameters: <String, dynamic>{'uid': firebaseUid},
);
}
static DateTime _slot(DateTime value) =>
MarketHistorySessionSlot.slotStartContaining(value.toUtc());
}

View File

@ -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<Map<String, dynamic>?> pickForUser(
String firebaseUid, {
DateTime? now,
}) async {
final DateTime tick = (now ?? DateTime.now()).toUtc();
final DateTime lastCompleted =
MarketHistorySessionSlot.lastCompletedSlotStart(tick);
var olderSlot = await _tradingStateDb.ensureGuessSlotStart(
firebaseUid,
defaultSlot: earliestPlayableSlotStart(tick, windowDays),
);
olderSlot = MarketHistorySessionSlot.slotStartContaining(olderSlot);
for (var step = 0; step < 512; step++) {
final DateTime? newerSlot =
MarketHistorySessionSlot.nextSlotStart(olderSlot);
if (newerSlot == null || newerSlot.isAfter(lastCompleted)) {
return null;
}
if (await _assignmentsDb.hasPendingAssignmentForSlotPair(
firebaseUid: firebaseUid,
olderSlotStart: olderSlot,
newerSlotStart: newerSlot,
)) {
return null;
}
final List<QuestionAuditAsset> topHalf =
await _topHalfAssetsForSlotPair(
olderSlotStart: olderSlot,
newerSlotStart: newerSlot,
);
if (topHalf.isEmpty) {
olderSlot = newerSlot;
await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot);
continue;
}
final Set<String> topSymbols =
topHalf.map((QuestionAuditAsset a) => a.symbol).toSet();
final Set<String> answeredSymbols =
await _assignmentsDb.answeredSymbolsForSlotPair(
firebaseUid: firebaseUid,
olderSlotStart: olderSlot,
newerSlotStart: newerSlot,
);
if (topSymbols.every(answeredSymbols.contains)) {
olderSlot = newerSlot;
await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot);
continue;
}
final QuestionAuditAsset? asset = await _pickNextAssetForSlotPair(
firebaseUid: firebaseUid,
olderSlotStart: olderSlot,
newerSlotStart: newerSlot,
topHalf: topHalf,
);
if (asset != null) {
await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot);
final String id = await _upsertProspectiveRow(
asset: asset,
olderSlotStart: olderSlot,
newerSlotStart: newerSlot,
);
return <String, dynamic>{
'id': id,
'questionText': _questionText(asset.symbol),
'correctAnswer': asset.priceDelta,
'symbol': asset.symbol,
'olderSlotStart': olderSlot.toIso8601String(),
'newerSlotStart': newerSlot.toIso8601String(),
'priceDeltaPct': asset.priceDelta,
};
}
olderSlot = newerSlot;
await _tradingStateDb.setGuessSlotStart(firebaseUid, olderSlot);
}
return null;
}
Future<List<QuestionAuditAsset>> _topHalfAssetsForSlotPair({
required DateTime olderSlotStart,
required DateTime newerSlotStart,
}) async {
final List<QuestionAuditAsset> assets =
await _questionAudit.assetsForSlotPair(
newerSlotStart: newerSlotStart,
olderSlotStart: olderSlotStart,
);
return questionAuditTopHalfVolumeAssets(assets);
}
/// Highest-volume symbol in [topHalf] the user has not yet been assigned.
Future<QuestionAuditAsset?> _pickNextAssetForSlotPair({
required String firebaseUid,
required DateTime olderSlotStart,
required DateTime newerSlotStart,
required List<QuestionAuditAsset> topHalf,
}) async {
for (final QuestionAuditAsset asset in topHalf) {
final bool alreadyAssigned =
await _assignmentsDb.hasAssignmentForSymbolSlotPair(
firebaseUid: firebaseUid,
olderSlotStart: olderSlotStart,
newerSlotStart: newerSlotStart,
symbol: asset.symbol,
);
if (!alreadyAssigned) {
return asset;
}
}
return null;
}
Future<String> _upsertProspectiveRow({
required QuestionAuditAsset asset,
required DateTime olderSlotStart,
required DateTime newerSlotStart,
}) async {
final DateTime older =
MarketHistorySessionSlot.slotStartContaining(olderSlotStart.toUtc());
final DateTime newer =
MarketHistorySessionSlot.slotStartContaining(newerSlotStart.toUtc());
final DateTime compareUntil = MarketHistorySessionSlot.endExclusive(newer);
final DateTime refreshedAt = DateTime.now().toUtc();
final Result result = await _connection.execute(
Sql.named(
'''
INSERT INTO market_history_prospective_questions (
compare_until, newer_slot_start, older_slot_start,
symbol, question_text, correct_answer,
price_delta_pct, volume_delta_pct, avg_volume_usd,
older_slot, newer_slot, refreshed_at
) VALUES (
@compare_until, @newer_slot_start, @older_slot_start,
@symbol, @question_text, @correct_answer,
@price_delta_pct, @volume_delta_pct, @avg_volume_usd,
@older_slot::jsonb, @newer_slot::jsonb, @refreshed_at
)
ON CONFLICT (symbol, older_slot_start, newer_slot_start)
DO UPDATE SET
compare_until = EXCLUDED.compare_until,
question_text = EXCLUDED.question_text,
correct_answer = EXCLUDED.correct_answer,
price_delta_pct = EXCLUDED.price_delta_pct,
volume_delta_pct = EXCLUDED.volume_delta_pct,
avg_volume_usd = EXCLUDED.avg_volume_usd,
older_slot = EXCLUDED.older_slot,
newer_slot = EXCLUDED.newer_slot,
refreshed_at = EXCLUDED.refreshed_at
RETURNING id
''',
),
parameters: <String, dynamic>{
'compare_until': compareUntil,
'newer_slot_start': newer,
'older_slot_start': older,
'symbol': asset.symbol,
'question_text': _questionText(asset.symbol),
'correct_answer': asset.priceDelta,
'price_delta_pct': asset.priceDelta,
'volume_delta_pct': asset.volumeDelta,
'avg_volume_usd': questionAuditAvgVolumeUsd(asset),
'older_slot': jsonEncode(asset.olderSlot.toJson()),
'newer_slot': jsonEncode(asset.newerSlot.toJson()),
'refreshed_at': refreshedAt,
},
);
return result.first[0].toString();
}
static String _questionText(String symbol) =>
'What was the percent price change for $symbol from the prior '
'session half to the latest?';
}

View File

@ -9,6 +9,7 @@ import 'guardrails.dart';
import 'market_data_db.dart'; import 'market_data_db.dart';
import '../market_history_env.dart'; import '../market_history_env.dart';
import 'market_history_query.dart'; import 'market_history_query.dart';
import 'prospective_answer_scoring.dart';
import 'rule_engine.dart'; import 'rule_engine.dart';
import 'symbol_obfuscator.dart'; import 'symbol_obfuscator.dart';
import 'trading_config.dart'; import 'trading_config.dart';
@ -244,6 +245,12 @@ class TradingPipeline {
await _tradingStateDb.getRuleState(firebaseUid, ruleId); await _tradingStateDb.getRuleState(firebaseUid, ruleId);
if (rule.type == 'guess_weekly_move') { if (rule.type == 'guess_weekly_move') {
final Map<String, dynamic> metadata = Map<String, dynamic>.from(
answeredQuestion['metadata'] as Map<String, dynamic>? ??
<String, dynamic>{},
);
// Login/bootstrap prospective questions are graded in [QuestionsDb.submitAnswer].
if (metadata['prospective_question_id'] == null) {
await _handleGuessAnswer( await _handleGuessAnswer(
firebaseUid: firebaseUid, firebaseUid: firebaseUid,
rule: rule, rule: rule,
@ -253,6 +260,7 @@ class TradingPipeline {
priorState: priorState, priorState: priorState,
now: now, now: now,
); );
}
return; return;
} }
@ -381,13 +389,17 @@ class TradingPipeline {
required Map<String, dynamic>? priorState, required Map<String, dynamic>? priorState,
required DateTime now, required DateTime now,
}) async { }) async {
final int scoreDelta = userResponse == correctAnswer ? 1 : -1; final ProspectiveAnswerGrade grade = gradeProspectiveAnswer(
userResponse: userResponse,
correctAnswer: correctAnswer,
);
final String symbol = final String symbol =
(priorState?['symbol'] as String?) ?? rule.symbol; (priorState?['symbol'] as String?) ?? rule.symbol;
await _tradingStateDb.recordGuessScore( await recordProspectiveGuessScore(
tradingStateDb: _tradingStateDb,
firebaseUid: firebaseUid, firebaseUid: firebaseUid,
scoreDelta: scoreDelta, grade: grade,
symbol: symbol, symbol: symbol,
at: now, at: now,
); );
@ -396,8 +408,12 @@ class TradingPipeline {
...?priorState, ...?priorState,
'phase': TradingPhases.done, 'phase': TradingPhases.done,
'question_id': questionId, 'question_id': questionId,
'answer': userResponse == correctAnswer ? 'match' : 'miss', 'answer': grade.sameDirection ? 'match' : 'miss',
'score_delta': scoreDelta, 'score_delta': grade.answerScore,
'direction_point': grade.directionPoint,
'closeness_point': grade.closenessPoint,
'answer_score': grade.answerScore,
'error_abs': grade.absError,
'answered_at': now.toIso8601String(), 'answered_at': now.toIso8601String(),
}; };
await _tradingStateDb.setRuleState( await _tradingStateDb.setRuleState(

View File

@ -2,6 +2,8 @@ import 'dart:convert';
import 'package:postgres/postgres.dart'; import 'package:postgres/postgres.dart';
import 'market_history_session_slot.dart';
/// Per-user trading worker cursor ([user_trading_state]). /// Per-user trading worker cursor ([user_trading_state]).
class UserTradingStateDb { class UserTradingStateDb {
UserTradingStateDb(this._connection); UserTradingStateDb(this._connection);
@ -190,20 +192,31 @@ class UserTradingStateDb {
Future<void> recordGuessScore({ Future<void> recordGuessScore({
required String firebaseUid, required String firebaseUid,
required int scoreDelta, required num scoreDelta,
required String symbol, required String symbol,
required DateTime at, required DateTime at,
required bool directionCorrect,
}) async { }) async {
await ensureExists(firebaseUid); await ensureExists(firebaseUid);
final Map<String, dynamic> context = await getContext(firebaseUid); final Map<String, dynamic> context = await getContext(firebaseUid);
final Map<String, dynamic> prior = Map<String, dynamic>.from( final Map<String, dynamic> prior = Map<String, dynamic>.from(
context[guessScoreContextKey] as Map? ?? <String, dynamic>{}, context[guessScoreContextKey] as Map? ?? <String, dynamic>{},
); );
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] = <String, dynamic>{ context[guessScoreContextKey] = <String, dynamic>{
'total': total, 'total': total,
'answers_total': answersTotal,
'answers_correct': answersCorrect,
if (slotWire != null) 'slot_start': slotWire,
'last': <String, dynamic>{ 'last': <String, dynamic>{
'score_delta': scoreDelta, 'score_delta': scoreDelta,
'direction_correct': directionCorrect,
'symbol': symbol, 'symbol': symbol,
'at': at.toUtc().toIso8601String(), 'at': at.toUtc().toIso8601String(),
}, },
@ -211,6 +224,68 @@ class UserTradingStateDb {
await _writeContext(firebaseUid, context, touchEvalAt: true); await _writeContext(firebaseUid, context, touchEvalAt: true);
} }
Future<DateTime?> getGuessSlotStart(String firebaseUid) async {
final Map<String, dynamic>? score = await getGuessScore(firebaseUid);
if (score == null) {
return null;
}
return MarketHistorySessionSlot.parseWire(score['slot_start'] as String?);
}
/// Initializes [defaultSlot] when missing; returns the active older-slot edge.
Future<DateTime> ensureGuessSlotStart(
String firebaseUid, {
required DateTime defaultSlot,
}) async {
final DateTime? existing = await getGuessSlotStart(firebaseUid);
if (existing != null) {
return existing;
}
final DateTime slot =
MarketHistorySessionSlot.slotStartContaining(defaultSlot.toUtc());
await setGuessSlotStart(firebaseUid, slot);
return slot;
}
Future<void> setGuessSlotStart(String firebaseUid, DateTime slotStart) async {
await ensureExists(firebaseUid);
final Map<String, dynamic> context = await getContext(firebaseUid);
final Map<String, dynamic> prior = Map<String, dynamic>.from(
context[guessScoreContextKey] as Map? ?? <String, dynamic>{},
);
prior['slot_start'] = MarketHistorySessionSlot.slotStartWire(slotStart);
context[guessScoreContextKey] = prior;
await _writeContext(firebaseUid, context, touchEvalAt: false);
}
/// Clears cumulative guess score and answer statistics for [firebaseUid].
Future<void> resetGuessScore(
String firebaseUid, {
required DateTime slotStart,
}) async {
await ensureExists(firebaseUid);
await setGuessScore(
firebaseUid,
<String, dynamic>{
'total': 0,
'answers_total': 0,
'answers_correct': 0,
'slot_start': MarketHistorySessionSlot.slotStartWire(slotStart),
},
);
}
/// Replaces `guess_score` in context (merges into full user context).
Future<void> setGuessScore(
String firebaseUid,
Map<String, dynamic> guessScore,
) async {
await ensureExists(firebaseUid);
final Map<String, dynamic> context = await getContext(firebaseUid);
context[guessScoreContextKey] = guessScore;
await _writeContext(firebaseUid, context, touchEvalAt: false);
}
Future<DateTime?> getGuessSymbolLastPickedAt( Future<DateTime?> getGuessSymbolLastPickedAt(
String firebaseUid, String firebaseUid,
String symbol, String symbol,

View File

@ -4,6 +4,7 @@ import 'package:postgres/postgres.dart';
import '../trading/market_data_history.dart'; import '../trading/market_data_history.dart';
import '../trading/market_data_retention.dart'; import '../trading/market_data_retention.dart';
import '../trading/market_history_prospective_questions.dart';
import '../trading/sync_run_recorder.dart'; import '../trading/sync_run_recorder.dart';
import '../trading/tradable_assets_sync.dart'; import '../trading/tradable_assets_sync.dart';
import 'market_history_scheduler_config.dart'; import 'market_history_scheduler_config.dart';
@ -15,7 +16,13 @@ class MarketHistorySchedulerReport {
final List<String> ranStages; final List<String> 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 /// Before each run, stale or orphaned in-progress `market_data_sync_runs` rows
/// are aborted so a hung prior sync cannot block the worker. /// are aborted so a hung prior sync cannot block the worker.
@ -26,12 +33,14 @@ class MarketHistoryScheduler {
Future<void> Function(DateTime now)? runUniverse, Future<void> Function(DateTime now)? runUniverse,
Future<void> Function(DateTime now)? runBackfill, Future<void> Function(DateTime now)? runBackfill,
Future<void> Function(DateTime now)? runCleanup, Future<void> Function(DateTime now)? runCleanup,
Future<void> Function(DateTime now)? runProspectiveQuestions,
Future<bool> Function(DateTime now)? backfillIsDue, Future<bool> Function(DateTime now)? backfillIsDue,
}) : _connection = connection, }) : _connection = connection,
_recorder = SyncRunRecorder(connection), _recorder = SyncRunRecorder(connection),
_runUniverse = runUniverse, _runUniverse = runUniverse,
_runBackfill = runBackfill, _runBackfill = runBackfill,
_runCleanup = runCleanup, _runCleanup = runCleanup,
_runProspectiveQuestions = runProspectiveQuestions,
_backfillIsDue = backfillIsDue; _backfillIsDue = backfillIsDue;
final Connection _connection; final Connection _connection;
@ -40,6 +49,7 @@ class MarketHistoryScheduler {
final Future<void> Function(DateTime now)? _runUniverse; final Future<void> Function(DateTime now)? _runUniverse;
final Future<void> Function(DateTime now)? _runBackfill; final Future<void> Function(DateTime now)? _runBackfill;
final Future<void> Function(DateTime now)? _runCleanup; final Future<void> Function(DateTime now)? _runCleanup;
final Future<void> Function(DateTime now)? _runProspectiveQuestions;
final Future<bool> Function(DateTime now)? _backfillIsDue; final Future<bool> Function(DateTime now)? _backfillIsDue;
bool _pipelineActive = false; bool _pipelineActive = false;
@ -87,6 +97,17 @@ class MarketHistoryScheduler {
ran: ran, 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); return MarketHistorySchedulerReport(ranStages: ran);
} finally { } finally {
_pipelineActive = false; _pipelineActive = false;
@ -143,6 +164,12 @@ class MarketHistoryScheduler {
int cadenceHours, { int cadenceHours, {
Future<bool> Function(DateTime now)? slotGate, Future<bool> Function(DateTime now)? slotGate,
}) async { }) 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); final DateTime? last = await _lastFinishedAt(kind);
if (config.syncHourUtc != null) { if (config.syncHourUtc != null) {
@ -154,10 +181,6 @@ class MarketHistoryScheduler {
} }
} }
if (slotGate != null) {
return slotGate(now);
}
if (last == null) { if (last == null) {
return true; return true;
} }

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -11,7 +11,7 @@ import 'package:cyberhybridhub_server/trading/user_trading_state_db.dart';
import 'package:dotenv/dotenv.dart'; import 'package:dotenv/dotenv.dart';
import 'package:postgres/postgres.dart'; import 'package:postgres/postgres.dart';
/// Integration test Postgres: [cyberhybridhub_test] with migrations 001010. /// Integration test Postgres: [cyberhybridhub_test] with migrations 001016.
class TestDb { class TestDb {
TestDb._(this.db, this._connection, this.databaseUrl); TestDb._(this.db, this._connection, this.databaseUrl);
@ -124,6 +124,8 @@ class TestDb {
''' '''
TRUNCATE TABLE TRUNCATE TABLE
trade_orders, trade_orders,
market_history_prospective_assignments,
market_history_prospective_questions,
market_data_snapshots, market_data_snapshots,
market_data_sync_runs, market_data_sync_runs,
tradable_assets, tradable_assets,

View File

@ -51,7 +51,7 @@ void main() {
final MarketDataDb db = testDb!.marketDataDb; final MarketDataDb db = testDb!.marketDataDb;
final DateTime now = retentionNow; final DateTime now = retentionNow;
final DateTime cutoff = final DateTime cutoff =
MarketHistorySessionSlot.windowFirstSlotStart(now, 7); MarketHistorySessionSlot.windowFirstSlotStart(now, 5);
await db.upsertSnapshot( await db.upsertSnapshot(
symbol: 'SPY', symbol: 'SPY',
@ -88,6 +88,57 @@ void main() {
expect((remaining.first[0]! as num).toInt(), 2); 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: <String, dynamic>{
'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 { test('empty table returns rowsRemoved 0 without throwing', () async {
if (testDb == null) { if (testDb == null) {
markTestSkipped( markTestSkipped(
@ -118,7 +169,7 @@ void main() {
final DateTime now = retentionNow; final DateTime now = retentionNow;
final DateTime oldAsOf = MarketHistorySessionSlot.windowFirstSlotStart( final DateTime oldAsOf = MarketHistorySessionSlot.windowFirstSlotStart(
now, now,
7, 5,
).subtract(const Duration(days: 2)); ).subtract(const Duration(days: 2));
await testDb!.connection.execute( await testDb!.connection.execute(
@ -161,7 +212,7 @@ void main() {
final DateTime now = retentionNow; final DateTime now = retentionNow;
final DateTime cutoff = final DateTime cutoff =
MarketHistorySessionSlot.windowFirstSlotStart(now, 7); MarketHistorySessionSlot.windowFirstSlotStart(now, 5);
await testDb!.marketDataDb.upsertSnapshot( await testDb!.marketDataDb.upsertSnapshot(
symbol: 'SPY', symbol: 'SPY',
metric: 'bar', metric: 'bar',
@ -198,7 +249,7 @@ void main() {
final MarketDataDb db = testDb!.marketDataDb; final MarketDataDb db = testDb!.marketDataDb;
final DateTime now = retentionNow; final DateTime now = retentionNow;
final DateTime cutoff = final DateTime cutoff =
MarketHistorySessionSlot.windowFirstSlotStart(now, 7); MarketHistorySessionSlot.windowFirstSlotStart(now, 5);
final MarketDataSnapshot kept = await db.upsertSnapshot( final MarketDataSnapshot kept = await db.upsertSnapshot(
symbol: 'SPY', symbol: 'SPY',
@ -236,7 +287,7 @@ void main() {
final DateTime now = retentionNow; final DateTime now = retentionNow;
final DateTime cutoff = final DateTime cutoff =
MarketHistorySessionSlot.windowFirstSlotStart(now, 7); MarketHistorySessionSlot.windowFirstSlotStart(now, 5);
await testDb!.marketDataDb.upsertSnapshot( await testDb!.marketDataDb.upsertSnapshot(
symbol: 'SPY', symbol: 'SPY',
metric: 'bar', metric: 'bar',
@ -281,7 +332,7 @@ void main() {
final DateTime now = retentionNow; final DateTime now = retentionNow;
final DateTime cutoff = final DateTime cutoff =
MarketHistorySessionSlot.windowFirstSlotStart(now, 7); MarketHistorySessionSlot.windowFirstSlotStart(now, 5);
await testDb!.marketDataDb.upsertSnapshot( await testDb!.marketDataDb.upsertSnapshot(
symbol: 'SPY', symbol: 'SPY',
metric: 'bar', metric: 'bar',
@ -320,7 +371,7 @@ void main() {
final DateTime now = retentionNow; final DateTime now = retentionNow;
final DateTime cutoff = final DateTime cutoff =
MarketHistorySessionSlot.windowFirstSlotStart(now, 7); MarketHistorySessionSlot.windowFirstSlotStart(now, 5);
await testDb!.marketDataDb.upsertSnapshot( await testDb!.marketDataDb.upsertSnapshot(
symbol: 'SPY', symbol: 'SPY',
metric: 'bar', metric: 'bar',

View File

@ -505,7 +505,9 @@ void main() {
Sql.named( Sql.named(
''' '''
INSERT INTO tradable_assets (symbol, asset_class, status, tradable, refreshed_at) 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: <String, dynamic>{'refreshed_at': now}, parameters: <String, dynamic>{'refreshed_at': now},
@ -518,6 +520,7 @@ void main() {
required num low, required num low,
required num close, required num close,
required num volume, required num volume,
String symbol = 'AAA',
}) async { }) async {
await testDb!.connection.execute( await testDb!.connection.execute(
Sql.named( Sql.named(
@ -525,11 +528,12 @@ void main() {
INSERT INTO market_data_snapshots ( INSERT INTO market_data_snapshots (
symbol, asset_class, feed, metric, timeframe, price, volume, as_of, raw symbol, asset_class, feed, metric, timeframe, price, volume, as_of, raw
) VALUES ( ) 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: <String, dynamic>{ parameters: <String, dynamic>{
'symbol': symbol,
'as_of': asOf, 'as_of': asOf,
'close': close, 'close': close,
'volume': volume, 'volume': volume,
@ -569,6 +573,24 @@ void main() {
close: 12, close: 12,
volume: 150, 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( final Handler handler = marketHistoryAdminHandler(
auth: _FakeAuthVerifier(), auth: _FakeAuthVerifier(),
@ -599,11 +621,13 @@ void main() {
MarketHistorySessionSlot.endExclusive(newerSlot), MarketHistorySessionSlot.endExclusive(newerSlot),
); );
final List<dynamic> assets = body['assets'] as List<dynamic>; final List<dynamic> assets = body['assets'] as List<dynamic>;
expect(assets, hasLength(1)); expect(assets, hasLength(2));
expect((assets[0] as Map<String, dynamic>)['symbol'], 'BBB');
expect((assets[1] as Map<String, dynamic>)['symbol'], 'AAA');
final Map<String, dynamic> aaa = assets.first as Map<String, dynamic>; final Map<String, dynamic> aaa = assets[1] as Map<String, dynamic>;
expect(aaa['symbol'], 'AAA'); expect(aaa['symbol'], 'AAA');
expect(aaa['priceDelta'], 2); expect(aaa['priceDelta'], 20);
expect(aaa['volumeDelta'], 50); expect(aaa['volumeDelta'], 50);
expect(aaa.containsKey('raw'), isFalse); expect(aaa.containsKey('raw'), isFalse);
@ -613,9 +637,11 @@ void main() {
aaa['newerSlot'] as Map<String, dynamic>; aaa['newerSlot'] as Map<String, dynamic>;
expect(older['avgPrice'], 10); expect(older['avgPrice'], 10);
expect(older['volume'], 100); expect(older['volume'], 100);
expect(older['volumeUsd'], 1000);
expect(older['open'], 10); expect(older['open'], 10);
expect(newer['avgPrice'], 12); expect(newer['avgPrice'], 12);
expect(newer['volume'], 150); expect(newer['volume'], 150);
expect(newer['volumeUsd'], 1800);
expect(newer['close'], 12); expect(newer['close'], 12);
expect( expect(
DateTime.parse(body['newerSlotStart'] as String).toUtc(), DateTime.parse(body['newerSlotStart'] as String).toUtc(),

View File

@ -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: <String, dynamic>{'refreshed_at': now},
);
Future<void> 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: <String, dynamic>{
'symbol': symbol,
'as_of': asOf,
'close': close,
'volume': volume,
'raw': jsonEncode(<String, dynamic>{
'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: <String, dynamic>{'refreshed_at': now},
);
for (final DateTime asOf in <DateTime>[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: <String, dynamic>{
'as_of': asOf,
'raw': jsonEncode(<String, dynamic>{
'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: <String, dynamic>{
'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: <String, dynamic>{'refreshed_at': now},
);
for (final DateTime asOf in <DateTime>[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: <String, dynamic>{
'as_of': asOf,
'raw': jsonEncode(<String, dynamic>{
'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: <String, dynamic>{
'older': olderSlot,
'newer': newerSlot,
},
);
expect(rows.map((ResultRow r) => r[0]).toList(), <dynamic>['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: <String, dynamic>{
'compare_until': MarketHistorySessionSlot.endExclusive(newerSlot),
'newer_slot_start': newerSlot,
'older_slot_start': olderSlot,
},
);
final String prospectiveId = insertedProspective.first[0].toString();
final Map<String, dynamic> 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: <String, dynamic>{
'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: <String, dynamic>{
'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<String, dynamic>? 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<String, dynamic> summary =
await testDb!.questionsDb.getGuessScoreSummary(uid);
expect(summary['answersTotal'], 1);
expect(summary['answersCorrect'], 1);
expect(summary['percentCorrect'], 100);
final Map<String, dynamic> 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<String, dynamic>? 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: <String, dynamic>{'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: <String, dynamic>{'refreshed_at': now},
);
Future<void> 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: <String, dynamic>{
'symbol': symbol,
'as_of': asOf,
'volume': volume,
'raw': jsonEncode(<String, dynamic>{
'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<String, dynamic>? 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: <String, dynamic>{'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<String, dynamic>? reloginPayload =
await service.ensureProspectiveQuestionQueued(uid, now: now);
expect(reloginPayload, isNotNull);
expect(reloginPayload!['id'], firstPayload!['id']);
final List<Map<String, dynamic>> 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<String, dynamic>? 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: <String, dynamic>{'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<String, dynamic>? 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: <String, dynamic>{'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: <String, dynamic>{'refreshed_at': now},
);
for (final DateTime asOf in <DateTime>[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: <String, dynamic>{
'as_of': asOf,
'raw': jsonEncode(<String, dynamic>{
'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<String, dynamic>? 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: <String, dynamic>{
'uid': uid,
'older': olderSlot,
'newer': newerSlot,
},
);
expect(existingAssignment, hasLength(1));
final Map<String, dynamic> 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<String, dynamic>? 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: <String, dynamic>{
'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: <String, dynamic>{'refreshed_at': now},
);
for (final DateTime asOf in <DateTime>[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: <String, dynamic>{
'as_of': asOf,
'raw': jsonEncode(<String, dynamic>{
'o': 10,
'h': 10,
'l': 10,
'c': 10,
'v': 1000,
'slot_start': MarketHistorySessionSlot.slotStartWire(asOf),
}),
},
);
}
final QuestionService service = testDb!.questionService();
final Map<String, dynamic>? payload = await service.bootstrapOnLogin(uid);
expect(payload, isNotNull);
final List<Map<String, dynamic>> queued =
await testDb!.questionsDb.listUnansweredQuestions(uid);
expect(queued, hasLength(1));
final Map<String, dynamic> created = queued.single;
final Map<String, dynamic> metadata =
created['metadata'] as Map<String, dynamic>;
expect(metadata['symbol'], 'BOOT');
expect(metadata['older_slot_start'], olderSlot.toIso8601String());
expect(metadata['newer_slot_start'], newerSlot.toIso8601String());
expect(created['text'], contains('BOOT'));
});
}

View File

@ -323,7 +323,7 @@ void main() {
expect(rows, hasLength(4)); 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) { if (testDb == null) {
markTestSkipped( markTestSkipped(
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests', 'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
@ -331,13 +331,16 @@ void main() {
return; return;
} }
bool backfillPending = true;
final MarketHistoryScheduler s = scheduler( final MarketHistoryScheduler s = scheduler(
config: const MarketHistorySchedulerConfig(syncHourUtc: 10), config: const MarketHistorySchedulerConfig(syncHourUtc: 10),
backfillIsDue: (DateTime now) async => backfillPending,
runUniverse: (DateTime now) async { runUniverse: (DateTime now) async {
await recordStage(TradableAssetsSync.kind, now); await recordStage(TradableAssetsSync.kind, now);
}, },
runBackfill: (DateTime now) async { runBackfill: (DateTime now) async {
await recordStage(MarketDataHistorySync.kind, now); await recordStage(MarketDataHistorySync.kind, now);
backfillPending = false;
}, },
runCleanup: (DateTime now) async { runCleanup: (DateTime now) async {
await recordStage(MarketDataRetention.kind, now); await recordStage(MarketDataRetention.kind, now);
@ -346,12 +349,15 @@ void main() {
final MarketHistorySchedulerReport beforeHour = final MarketHistorySchedulerReport beforeHour =
await s.runIfDue(DateTime.utc(2026, 5, 26, 9)); await s.runIfDue(DateTime.utc(2026, 5, 26, 9));
expect(beforeHour.ranStages, isEmpty); expect(beforeHour.ranStages, <String>[MarketDataHistorySync.kind]);
final DateTime tRun = DateTime.utc(2026, 5, 26, 11); final DateTime tRun = DateTime.utc(2026, 5, 26, 11);
final MarketHistorySchedulerReport first = final MarketHistorySchedulerReport first =
await s.runIfDue(tRun); await s.runIfDue(tRun);
expect(first.ranStages, hasLength(3)); expect(first.ranStages, <String>[
TradableAssetsSync.kind,
MarketDataRetention.kind,
]);
final MarketHistorySchedulerReport sameDay = final MarketHistorySchedulerReport sameDay =
await s.runIfDue(DateTime.utc(2026, 5, 26, 12)); await s.runIfDue(DateTime.utc(2026, 5, 26, 12));

View File

@ -5,6 +5,7 @@ import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart';
import 'package:cyberhybridhub_server/question_service.dart'; import 'package:cyberhybridhub_server/question_service.dart';
import 'package:cyberhybridhub_server/questions_db.dart'; import 'package:cyberhybridhub_server/questions_db.dart';
import 'package:cyberhybridhub_server/signalr/questions_hub_connections.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/market_history_scheduler.dart';
import 'package:cyberhybridhub_server/workers/question_background_worker.dart'; import 'package:cyberhybridhub_server/workers/question_background_worker.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@ -108,5 +109,32 @@ void main() {
expect(tradingRan, isTrue); 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<String> stages = <String>[];
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);
});
}); });
} }

View File

@ -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_config.dart';
import 'package:cyberhybridhub_server/trading/market_history_query.dart'; import 'package:cyberhybridhub_server/trading/market_history_query.dart';
import 'package:cyberhybridhub_server/trading/tradable_assets_db.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/trading_pipeline.dart';
import 'package:cyberhybridhub_server/trading/user_trading_state_db.dart';
import 'package:postgres/postgres.dart'; import 'package:postgres/postgres.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@ -19,6 +19,7 @@ void main() {
setUpAll(() async { setUpAll(() async {
testDb = await TestDb.open(); testDb = await TestDb.open();
ProspectiveAnswerScoring.closenessExtraPointsEnabled = true;
}); });
tearDown(() async { tearDown(() async {
@ -28,6 +29,7 @@ void main() {
}); });
tearDownAll(() async { tearDownAll(() async {
ProspectiveAnswerScoring.closenessExtraPointsEnabled = false;
await testDb?.close(); await testDb?.close();
}); });
@ -134,7 +136,7 @@ void main() {
expect(metadata['guess_symbol'], 'SPY'); 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) { if (testDb == null) {
markTestSkipped( markTestSkipped(
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests', 'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
@ -155,23 +157,66 @@ void main() {
await testDb!.questionsDb.submitAnswer( await testDb!.questionsDb.submitAnswer(
questionId: open.single['id'] as String, questionId: open.single['id'] as String,
assignedUserId: uid, assignedUserId: uid,
userResponse: 10, userResponse: 9,
); );
await pipeline.handleAnswer( await pipeline.handleAnswer(
firebaseUid: uid, firebaseUid: uid,
answeredQuestion: updated!, answeredQuestion: updated!,
userResponse: 10, userResponse: 9,
); );
final Map<String, dynamic>? score = final Map<String, dynamic>? score =
await testDb!.userTradingStateDb.getGuessScore(uid); await testDb!.userTradingStateDb.getGuessScore(uid);
expect(score, isNotNull); expect(score, isNotNull);
expect(score!['total'], 1); expect(score!['total'], 2);
expect((score['last'] as Map)['score_delta'], 1); expect((score['last'] as Map)['score_delta'], 2);
final Map<String, dynamic>? 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<Map<String, dynamic>> open =
await testDb!.questionsDb.listUnansweredQuestions(uid);
final Map<String, dynamic>? 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<String, dynamic>? 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) { if (testDb == null) {
markTestSkipped( markTestSkipped(
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests', 'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
@ -203,8 +248,14 @@ void main() {
final Map<String, dynamic>? score = final Map<String, dynamic>? score =
await testDb!.userTradingStateDb.getGuessScore(uid); await testDb!.userTradingStateDb.getGuessScore(uid);
expect(score!['total'], -1); expect(score!['total'], -2);
expect((score['last'] as Map)['score_delta'], -1); expect((score['last'] as Map)['score_delta'], -2);
final Map<String, dynamic>? 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', test('handleAnswer never stages pending orders; actuator not invoked',

View File

@ -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: <String, dynamic>{
'compare_until': MarketHistorySessionSlot.endExclusive(newerSlot),
'newer': newerSlot,
'older': olderSlot,
},
);
final String prospectiveId = prospective.first[0].toString();
final Map<String, dynamic> question = await testDb!.questionsDb.createQuestion(
assignedUserId: uid,
questionText: 'Guess',
correctAnswer: 4.0,
sourceTag: 'market_history:prospective',
metadata: <String, dynamic>{
'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: <String, dynamic>{'uid': uid},
);
final Map<String, dynamic> summary =
await GuessScoreStore.loadSummary(testDb!.connection, uid);
expect(summary['answersTotal'], 1);
expect(summary['answersCorrect'], 1);
expect(summary['total'], 2);
final Map<String, dynamic>? repaired =
await testDb!.userTradingStateDb.getGuessScore(uid);
expect(repaired, isNotNull);
expect((repaired!['answers_total'] as num).toInt(), 1);
});
}

View File

@ -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: <String, dynamic>{'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<QuestionAuditAsset> top = questionAuditTopHalfVolumeAssets(
<QuestionAuditAsset>[
asset('HIGH', 5000),
asset('MID', 3000),
asset('LOW', 1000),
],
);
expect(top, hasLength(2));
expect(top.map((QuestionAuditAsset a) => a.symbol).toList(),
<String>['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', () { group('compareUntil navigation', () {
final DateTime now = DateTime.utc(2026, 6, 2, 21); final DateTime now = DateTime.utc(2026, 6, 2, 21);
late DateTime defaultUntil; late DateTime defaultUntil;

View File

@ -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);
});
});
}

View File

@ -212,7 +212,7 @@ void main() {
'assets': <Map<String, dynamic>>[ 'assets': <Map<String, dynamic>>[
<String, dynamic>{ <String, dynamic>{
'symbol': 'AAA', 'symbol': 'AAA',
'priceDelta': 2.5, 'priceDelta': 25,
'volumeDelta': 50, 'volumeDelta': 50,
'olderSlot': <String, dynamic>{ 'olderSlot': <String, dynamic>{
'asOf': '2026-05-30T08:00:00Z', 'asOf': '2026-05-30T08:00:00Z',
@ -248,9 +248,11 @@ void main() {
expect(report.stepOlderCompareUntil, DateTime.utc(2026, 5, 30, 12)); expect(report.stepOlderCompareUntil, DateTime.utc(2026, 5, 30, 12));
expect(report.assets, hasLength(1)); expect(report.assets, hasLength(1));
expect(report.assets.single.symbol, 'AAA'); 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.volumeDelta, 50);
expect(report.assets.single.olderSlot.avgPrice, 10); 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.close, 12);
expect(report.assets.single.newerSlot.volumeUsd, 1875);
}); });
} }

View File

@ -10,7 +10,7 @@ import 'package:http/testing.dart';
QuestionAuditAsset _sampleAsset() { QuestionAuditAsset _sampleAsset() {
return QuestionAuditAsset( return QuestionAuditAsset(
symbol: 'AAA', symbol: 'AAA',
priceDelta: 2.5, priceDelta: 25,
volumeDelta: -40, volumeDelta: -40,
olderSlot: QuestionAuditBarSlot( olderSlot: QuestionAuditBarSlot(
asOf: DateTime.utc(2026, 5, 30, 8), asOf: DateTime.utc(2026, 5, 30, 8),
@ -20,6 +20,7 @@ QuestionAuditAsset _sampleAsset() {
close: 10, close: 10,
avgPrice: 10, avgPrice: 10,
volume: 100, volume: 100,
volumeUsd: 1000,
), ),
newerSlot: QuestionAuditBarSlot( newerSlot: QuestionAuditBarSlot(
asOf: DateTime.utc(2026, 5, 30, 12), asOf: DateTime.utc(2026, 5, 30, 12),
@ -29,6 +30,7 @@ QuestionAuditAsset _sampleAsset() {
close: 12.5, close: 12.5,
avgPrice: 12.5, avgPrice: 12.5,
volume: 60, volume: 60,
volumeUsd: 750,
), ),
); );
} }
@ -134,6 +136,20 @@ void main() {
expect(find.text('05/30 04:00 05/30 08:00 UTC'), findsOneWidget); 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(<QuestionAuditReport>[
_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 { testWidgets('tap expands slot detail panels', (WidgetTester tester) async {
await _pumpSheet( await _pumpSheet(
tester, tester,

View File

@ -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',
);
});
}

View File

@ -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);
});
}