Good question flow
This commit is contained in:
parent
6615dc5d17
commit
d8f73a3c38
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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%';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
52
lib/models/guess_score_summary.dart
Normal file
52
lib/models/guess_score_summary.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
15
lib/models/question_submit_result.dart
Normal file
15
lib/models/question_submit_result.dart
Normal 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;
|
||||||
|
}
|
||||||
@ -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});
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
lib/utils/guess_slot_format.dart
Normal file
21
lib/utils/guess_slot_format.dart
Normal 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';
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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};
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
208
server/lib/trading/guess_score_store.dart
Normal file
208
server/lib/trading/guess_score_store.dart
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
277
server/lib/trading/market_history_prospective_questions.dart
Normal file
277
server/lib/trading/market_history_prospective_questions.dart
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
111
server/lib/trading/prospective_answer_scoring.dart
Normal file
111
server/lib/trading/prospective_answer_scoring.dart
Normal 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);
|
||||||
|
}
|
||||||
257
server/lib/trading/prospective_guess_assignments_db.dart
Normal file
257
server/lib/trading/prospective_guess_assignments_db.dart
Normal 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());
|
||||||
|
}
|
||||||
226
server/lib/trading/prospective_guess_selection.dart
Normal file
226
server/lib/trading/prospective_guess_selection.dart
Normal 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?';
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
@ -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);
|
||||||
20
server/migrations/013_market_history_prospective_answers.sql
Normal file
20
server/migrations/013_market_history_prospective_answers.sql
Normal 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);
|
||||||
@ -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);
|
||||||
14
server/migrations/015_prospective_assignments_per_symbol.sql
Normal file
14
server/migrations/015_prospective_assignments_per_symbol.sql
Normal 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);
|
||||||
@ -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);
|
||||||
@ -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 001–010.
|
/// Integration test Postgres: [cyberhybridhub_test] with migrations 001–016.
|
||||||
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,
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
114
server/test/trading/guess_score_store_test.dart
Normal file
114
server/test/trading/guess_score_store_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
56
server/test/trading/prospective_answer_scoring_test.dart
Normal file
56
server/test/trading/prospective_answer_scoring_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
14
test/guess_slot_format_test.dart
Normal file
14
test/guess_slot_format_test.dart
Normal 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
101
test/home_screen_score_test.dart
Normal file
101
test/home_screen_score_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user