cyberhybridhub/server/test/integration/market_data_db_test.dart
2026-05-31 11:17:12 -05:00

352 lines
9.1 KiB
Dart

@Tags(['integration', 'postgres'])
library;
import 'package:cyberhybridhub_server/trading/market_data_db.dart';
import 'package:cyberhybridhub_server/trading/market_history_four_hour_slot.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('insertSnapshot then latestForSymbol returns newest by as_of', () async {
if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
return;
}
final MarketDataDb db = testDb!.marketDataDb;
final DateTime older = DateTime.utc(2026, 5, 23, 10);
final DateTime newer = DateTime.utc(2026, 5, 23, 11);
await db.insertSnapshot(
symbol: 'SPY',
metric: 'last_trade',
price: 490,
asOf: older,
);
await db.insertSnapshot(
symbol: 'SPY',
metric: 'last_trade',
price: 492,
asOf: newer,
);
final MarketDataSnapshot? latest =
await db.latestForSymbol('SPY', 'last_trade');
expect(latest, isNotNull);
expect(latest!.price, 492);
expect(
latest.asOf.toUtc().millisecondsSinceEpoch,
greaterThan(older.toUtc().millisecondsSinceEpoch),
);
});
group('upsertSnapshot', () {
test('re-upsert updates price and raw without duplicating row', () async {
if (testDb == null) {
markTestSkipped(
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
);
return;
}
final MarketDataDb db = testDb!.marketDataDb;
final DateTime asOf = DateTime.utc(2026, 5, 23, 13);
await db.upsertSnapshot(
symbol: 'SPY',
metric: 'bar',
timeframe: '1Day',
asOf: asOf,
price: 500,
volume: 1000,
raw: <String, dynamic>{'c': 500, 'v': 1000},
);
await db.upsertSnapshot(
symbol: 'SPY',
metric: 'bar',
timeframe: '1Day',
asOf: asOf,
price: 505,
volume: 1100,
raw: <String, dynamic>{'c': 505, 'v': 1100},
);
final List<MarketDataSnapshot> rows = await db.barsForSymbol(
symbol: 'SPY',
timeframe: '1Day',
since: asOf.subtract(const Duration(seconds: 1)),
until: asOf.add(const Duration(seconds: 1)),
);
expect(rows, hasLength(1));
expect(rows.single.price, 505);
expect(rows.single.volume, 1100);
expect(rows.single.raw?['c'], 505);
expect(rows.single.raw?['v'], 1100);
});
});
group('barsForSymbol', () {
test('returns rows ordered by as_of ASC within [since, until)', () async {
if (testDb == null) {
markTestSkipped(
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
);
return;
}
final MarketDataDb db = testDb!.marketDataDb;
final DateTime t1 = DateTime.utc(2026, 5, 20);
final DateTime t2 = DateTime.utc(2026, 5, 21);
final DateTime t3 = DateTime.utc(2026, 5, 22);
for (final DateTime t in <DateTime>[t1, t2, t3]) {
await db.upsertSnapshot(
symbol: 'SPY',
metric: 'bar',
timeframe: '1Day',
asOf: t,
price: t.day.toDouble(),
);
}
final List<MarketDataSnapshot> rows = await db.barsForSymbol(
symbol: 'SPY',
timeframe: '1Day',
since: t1,
until: t3,
);
expect(rows, hasLength(2));
expect(rows.map((MarketDataSnapshot r) => r.asOf), <DateTime>[t1, t2]);
});
test('returns empty list when no rows match', () async {
if (testDb == null) {
markTestSkipped(
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
);
return;
}
final List<MarketDataSnapshot> rows =
await testDb!.marketDataDb.barsForSymbol(
symbol: 'NOPE',
timeframe: '1Day',
since: DateTime.utc(2026, 1, 1),
until: DateTime.utc(2026, 1, 2),
);
expect(rows, isEmpty);
});
});
test('latestSyncedAsOf returns newest as_of or null', () async {
if (testDb == null) {
markTestSkipped(
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
);
return;
}
final MarketDataDb db = testDb!.marketDataDb;
final DateTime older = DateTime.utc(2026, 5, 20);
final DateTime newer = DateTime.utc(2026, 5, 22);
expect(await db.latestSyncedAsOf('SPY', '1Day'), isNull);
await db.upsertSnapshot(
symbol: 'SPY',
metric: 'bar',
timeframe: '1Day',
asOf: older,
price: 490,
);
await db.upsertSnapshot(
symbol: 'SPY',
metric: 'bar',
timeframe: '1Day',
asOf: newer,
price: 500,
);
expect(await db.latestSyncedAsOf('SPY', '1Day'), newer);
});
test('symbolsWithBarForSlot matches canonical slot_start wire string', () async {
if (testDb == null) {
markTestSkipped(
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
);
return;
}
final MarketDataDb db = testDb!.marketDataDb;
const String timeframe = '4Hour';
final DateTime slotStart = DateTime.utc(2026, 5, 26, 8);
final String slotWire = MarketHistoryFourHourSlot.slotStartWire(slotStart);
await db.upsertSnapshot(
symbol: 'AAPL',
metric: 'bar',
timeframe: timeframe,
asOf: slotStart,
price: 186,
raw: <String, dynamic>{
'slot_start': slotWire,
't': slotWire,
},
);
final Set<String> synced = await db.symbolsWithBarForSlot(
symbols: <String>['AAPL', 'MSFT'],
slotStart: slotStart,
timeframe: timeframe,
);
expect(synced, <String>{'AAPL'});
});
test('symbolsWithBarForSlot falls back to as_of slot bucket for legacy rows', () async {
if (testDb == null) {
markTestSkipped(
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
);
return;
}
final MarketDataDb db = testDb!.marketDataDb;
const String timeframe = '4Hour';
final DateTime slotStart = DateTime.utc(2026, 5, 26, 8);
final DateTime barAt = slotStart.add(const Duration(hours: 1));
await db.upsertSnapshot(
symbol: 'AAPL',
metric: 'bar',
timeframe: timeframe,
asOf: barAt,
price: 186,
raw: <String, dynamic>{
// Different wire format than Dart's toIso8601String() — must still count.
'slot_start': '2026-05-26T08:00:00Z',
},
);
final Set<String> synced = await db.symbolsWithBarForSlot(
symbols: <String>['AAPL', 'MSFT'],
slotStart: slotStart,
timeframe: timeframe,
);
expect(synced, <String>{'AAPL'});
});
test('symbolsWithBarForSlot matches via slot_start bucket when wire differs', () async {
if (testDb == null) {
markTestSkipped(
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
);
return;
}
final MarketDataDb db = testDb!.marketDataDb;
const String timeframe = '4Hour';
final DateTime slotStart = DateTime.utc(2026, 5, 26, 8);
await db.upsertSnapshot(
symbol: 'AAPL',
metric: 'bar',
timeframe: timeframe,
asOf: slotStart.add(const Duration(hours: 4)),
price: 186,
raw: <String, dynamic>{
'slot_start': '2026-05-26T08:00:00.000Z',
},
);
final Set<String> synced = await db.symbolsWithBarForSlot(
symbols: <String>['AAPL'],
slotStart: slotStart,
timeframe: timeframe,
);
expect(synced, <String>{'AAPL'});
});
test('symbolsWithBarForSlot does not match the next slot boundary as prior slot',
() async {
if (testDb == null) {
markTestSkipped(
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
);
return;
}
final MarketDataDb db = testDb!.marketDataDb;
const String timeframe = '4Hour';
final DateTime slotStart = DateTime.utc(2026, 5, 26, 8);
await db.upsertSnapshot(
symbol: 'AAPL',
metric: 'bar',
timeframe: timeframe,
asOf: DateTime.utc(2026, 5, 26, 12),
price: 186,
);
final Set<String> synced = await db.symbolsWithBarForSlot(
symbols: <String>['AAPL'],
slotStart: slotStart,
timeframe: timeframe,
);
expect(synced, isEmpty);
});
test('symbolsWithBarForSlot counts no_data placeholder as synced', () async {
if (testDb == null) {
markTestSkipped(
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
);
return;
}
final MarketDataDb db = testDb!.marketDataDb;
const String timeframe = '4Hour';
final DateTime slotStart = DateTime.utc(2026, 5, 30, 20);
await db.upsertNoDataBarPlaceholder(
symbol: 'A',
slotStart: slotStart,
timeframe: timeframe,
checkedAt: DateTime.utc(2026, 5, 31),
);
final Set<String> synced = await db.symbolsWithBarForSlot(
symbols: <String>['A', 'B'],
slotStart: slotStart,
timeframe: timeframe,
);
expect(synced, <String>{'A'});
});
}