318 lines
8.2 KiB
Dart
318 lines
8.2 KiB
Dart
@Tags(['integration', 'postgres'])
|
|
library;
|
|
|
|
import 'package:cyberhybridhub_server/trading/market_data_db.dart';
|
|
import 'package:cyberhybridhub_server/trading/market_history_session_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 = 'sessionHalf';
|
|
final DateTime slotStart = DateTime.utc(2026, 6, 2, 13, 30);
|
|
final String slotWire = MarketHistorySessionSlot.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 matches when as_of equals slot start', () async {
|
|
if (testDb == null) {
|
|
markTestSkipped(
|
|
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final MarketDataDb db = testDb!.marketDataDb;
|
|
const String timeframe = 'sessionHalf';
|
|
final DateTime slotStart = DateTime.utc(2026, 6, 2, 16, 45);
|
|
|
|
await db.upsertSnapshot(
|
|
symbol: 'AAPL',
|
|
metric: 'bar',
|
|
timeframe: timeframe,
|
|
asOf: slotStart,
|
|
price: 186,
|
|
raw: <String, dynamic>{
|
|
'slot_start': MarketHistorySessionSlot.slotStartWire(slotStart),
|
|
},
|
|
);
|
|
|
|
final Set<String> synced = await db.symbolsWithBarForSlot(
|
|
symbols: <String>['AAPL', 'MSFT'],
|
|
slotStart: slotStart,
|
|
timeframe: timeframe,
|
|
);
|
|
|
|
expect(synced, <String>{'AAPL'});
|
|
});
|
|
|
|
test('symbolsWithBarForSlot does not match a different session 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 = 'sessionHalf';
|
|
final DateTime morning = DateTime.utc(2026, 6, 2, 13, 30);
|
|
final DateTime afternoon = DateTime.utc(2026, 6, 2, 16, 45);
|
|
|
|
await db.upsertSnapshot(
|
|
symbol: 'AAPL',
|
|
metric: 'bar',
|
|
timeframe: timeframe,
|
|
asOf: afternoon,
|
|
price: 186,
|
|
);
|
|
|
|
final Set<String> synced = await db.symbolsWithBarForSlot(
|
|
symbols: <String>['AAPL'],
|
|
slotStart: morning,
|
|
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 = 'sessionHalf';
|
|
final DateTime slotStart = DateTime.utc(2026, 6, 2, 16, 45);
|
|
|
|
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'});
|
|
});
|
|
}
|