139 lines
4.0 KiB
Dart
139 lines
4.0 KiB
Dart
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 '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<List<WeeklyMover>> 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 =
|
||
until.subtract(Duration(days: windowDays));
|
||
final DateTime freshSince =
|
||
until.subtract(Duration(days: maxStalenessDays));
|
||
|
||
final List<String> active =
|
||
await _tradableAssetsDb.listActiveTradableSymbols();
|
||
if (active.isEmpty) {
|
||
return <WeeklyMover>[];
|
||
}
|
||
|
||
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: <String, dynamic>{
|
||
'since': since,
|
||
'until': until,
|
||
'symbols': active,
|
||
'timeframe': MarketHistoryConfig.barTimeframe,
|
||
'min_bars': minBars,
|
||
'fresh_since': freshSince,
|
||
},
|
||
);
|
||
|
||
final List<WeeklyMover> movers = <WeeklyMover>[];
|
||
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;
|
||
}
|
||
}
|