import 'dart:math'; import 'package:postgres/postgres.dart'; import 'market_data_db.dart' show MarketDataDb; import 'market_history_bar_placeholder.dart'; import 'market_history_config.dart'; import 'market_history_session_slot.dart'; import 'tradable_assets_db.dart'; /// One symbol's rolling-window 4-hour bar summary for the guessing game. class WeeklyMover { WeeklyMover({ required this.symbol, required this.openClose, required this.currentClose, required this.days, }); final String symbol; final num openClose; final num currentClose; /// Number of 4-hour bars in the window (≥ [MarketHistoryConfig.minBarsForGuess]). final int days; } /// Read-side queries over 4-hour history bars for question rules. class MarketHistoryQuery { MarketHistoryQuery({ required Connection connection, TradableAssetsDb? tradableAssetsDb, }) : _connection = connection, _tradableAssetsDb = tradableAssetsDb ?? TradableAssetsDb(connection); final Connection _connection; final TradableAssetsDb _tradableAssetsDb; /// Symbols with ≥ [minBars] 4-hour closes in [`asOf` − window, `asOf`), whose /// newest bar is not older than [maxStalenessDays] before [asOf]. /// /// When [random] is set, eligible rows are sorted by symbol then shuffled for /// a stable pick order across runs with the same seed. Future> weeklyMovers({ required DateTime asOf, int minBars = MarketHistoryConfig.minBarsForGuess, int windowDays = MarketHistoryConfig.windowDays, int maxStalenessDays = 2, Random? random, }) async { final DateTime until = asOf.toUtc(); final DateTime since = MarketHistorySessionSlot.windowFirstSlotStart(until, windowDays); final DateTime freshSince = until.subtract(Duration(days: maxStalenessDays)); final List active = await _tradableAssetsDb.listActiveTradableSymbols(); if (active.isEmpty) { return []; } final Result rows = await _connection.execute( Sql.named( ''' WITH bars AS ( SELECT symbol, as_of, price FROM market_data_snapshots WHERE metric = 'bar' AND timeframe = @timeframe AND as_of >= @since AND as_of < @until AND symbol = ANY(@symbols) AND ${MarketHistoryBarPlaceholder.sqlExcludePlaceholders} AND price IS NOT NULL ), agg AS ( SELECT symbol, COUNT(*)::int AS bar_count, MIN(as_of) AS oldest_as_of, MAX(as_of) AS newest_as_of FROM bars GROUP BY symbol HAVING COUNT(*) >= @min_bars AND MAX(as_of) >= @fresh_since ) SELECT a.symbol, a.bar_count, ( SELECT b.price FROM bars b WHERE b.symbol = a.symbol AND b.as_of = a.oldest_as_of LIMIT 1 ) AS open_close, ( SELECT b.price FROM bars b WHERE b.symbol = a.symbol AND b.as_of = a.newest_as_of LIMIT 1 ) AS current_close FROM agg a ORDER BY a.symbol ASC ''', ), parameters: { 'since': since, 'until': until, 'symbols': active, 'timeframe': MarketHistoryConfig.barTimeframe, 'min_bars': minBars, 'fresh_since': freshSince, }, ); final List movers = []; for (final ResultRow row in rows) { final num? openClose = MarketDataDb.readOptionalNumeric(row[2]); final num? currentClose = MarketDataDb.readOptionalNumeric(row[3]); if (openClose == null || currentClose == null) { continue; } movers.add( WeeklyMover( symbol: row[0]! as String, openClose: openClose, currentClose: currentClose, days: (row[1]! as num).toInt(), ), ); } if (random != null && movers.length > 1) { movers.shuffle(random); } return movers; } }