/** * Локальная история цены SOL (SQLite, node:sqlite). * * Используется шлюзом, чтобы при `close_bet` подставлять в транзакцию * **зафиксированную заранее** цену на момент `expire_time` ставки, а не * текущую с биржи. Это закрывает окно «подождать выгодного момента и * кликнуть позже»: цена фиксируется историей, момент клика больше * ничего не значит. * * Схема (одна таблица, PK = `ts` = unix-миллисекунды сделки): * * ts INTEGER PRIMARY KEY -- время СДЕЛКИ (eventTime Binance), unix ms * price INTEGER NOT NULL -- цена × 1e8, целое (без float) * * Запись идёт **только при изменении цены** (≈300 строк/сутки вместо * 92k тиков), ретеншен по умолчанию — 24 часа, чистится периодически * `DELETE FROM price_history WHERE ts < ?` одним запросом. */ import { DatabaseSync } from "node:sqlite"; /** Ретеншен по умолчанию: 24 часа (в миллисекундах). */ export const DEFAULT_RETENTION_MS: number = 24 * 60 * 60 * 1000; export class PriceHistory { private readonly db: DatabaseSync; private readonly stmtLast: ReturnType; private readonly stmtLastPrice: ReturnType; private readonly stmtAtOrBefore: ReturnType; private readonly stmtInsert: ReturnType; private readonly stmtPrune: ReturnType; private readonly stmtCount: ReturnType; constructor(filePath: string) { this.db = new DatabaseSync(filePath); // WAL: параллельные читатели не блокируют пишущего тик-цикл. this.db.exec("PRAGMA journal_mode=WAL"); // PK(ts) уже индекс для `WHERE ts <= ?` и `ORDER BY ts DESC LIMIT 1`, // отдельный индекс не нужен. this.db.exec(` CREATE TABLE IF NOT EXISTS price_history ( ts INTEGER PRIMARY KEY, price INTEGER NOT NULL ) `); this.stmtLast = this.db.prepare("SELECT ts FROM price_history ORDER BY ts DESC LIMIT 1"); this.stmtLastPrice = this.db.prepare( "SELECT price FROM price_history ORDER BY ts DESC LIMIT 1" ); this.stmtAtOrBefore = this.db.prepare( "SELECT price FROM price_history WHERE ts <= ? ORDER BY ts DESC LIMIT 1" ); this.stmtInsert = this.db.prepare( "INSERT OR REPLACE INTO price_history (ts, price) VALUES (?, ?)" ); this.stmtPrune = this.db.prepare("DELETE FROM price_history WHERE ts < ?"); this.stmtCount = this.db.prepare("SELECT COUNT(*) AS c FROM price_history"); } /** * Записать точку. Пишем ТОЛЬКО если цена отличается от последней записанной * — иначе возвращаем false и в БД ничего не добавляем (≈300 строк/сутки * вместо 92k тиков). Первая запись всегда пишется. `tsMs` пишется как * есть (PK; INSERT OR REPLACE на случай коллизии `ts`). * * Возвращает true, если строка реально добавлена. */ record(tsMs: number, priceInt: number): boolean { if (!Number.isFinite(tsMs) || !Number.isFinite(priceInt)) return false; const prev = this.stmtLastPrice.get() as { price: number } | undefined; if (prev !== undefined && prev.price === priceInt) return false; this.stmtInsert.run(tsMs, priceInt); return true; } /** * Последняя цена, записанная НЕ ПОЗЖЕ `tsMs` (ts <= tsMs). * Или null, если такой нет. * * Именно этот метод определяет честность расчёта: цена ПОЗЖЕ момента * не должна возвращаться никогда. Поэтому `ORDER BY ts DESC` + `LIMIT 1` * под условием `ts <= ?` — никаких «ближайших». */ priceAtOrBefore(tsMs: number): number | null { if (!Number.isFinite(tsMs)) return null; const row = this.stmtAtOrBefore.get(tsMs) as { price: number } | undefined; return row !== undefined ? row.price : null; } /** Unix-ms последней записи, или null если истории нет. */ lastTs(): number | null { const row = this.stmtLast.get() as { ts: number } | undefined; return row !== undefined ? row.ts : null; } /** * Удалить записи старше `(nowMs - retentionMs)`. Одним запросом * `DELETE FROM price_history WHERE ts < ?`. Возвращает число удалённых строк. */ prune(nowMs: number, retentionMs: number = DEFAULT_RETENTION_MS): number { if (!Number.isFinite(nowMs) || !Number.isFinite(retentionMs)) return 0; const cutoff = nowMs - retentionMs; const res = this.stmtPrune.run(cutoff); return Number(res.changes); } /** Сколько строк в таблице (для диагностики/тестов). */ count(): number { const row = this.stmtCount.get() as { c: number } | undefined; return row !== undefined ? Number(row.c) : 0; } /** Закрыть БД. */ close(): void { this.db.close(); } }