feat(relayer): цена закрытия из истории на момент экспирации — окно выбора времени закрыто

Проблема: handleClose брал getPrice() (текущую цену), поэтому игрок мог
подождать выгодного момента и закрыть ставку тогда, когда ему надо
(эксплуатировалось вживую: ставка висела open >2 мин после экспирации 15с).

Решение (только шлюз, контракт не тронут):
- src/priceHistory.ts (новый) — локальная история цены на встроенном node:sqlite
  (Node 22, без новых зависимостей). Запись только при изменении цены
  (~300 строк/сутки вместо 92k тиков), ретеншен 24ч, prune одним DELETE.
- natsPrice: в PriceTick добавлено eventTime (время сделки у Binance) — источник
  истины по времени; ts не тронут.
- config: SMART_PRICE_HISTORY_PATH (/app/data/price-history.db),
  SMART_PRICE_RETENTION_HOURS (24).
- gateway: каждый тик пишется в историю; handleClose берёт
  history.priceAtOrBefore(expireTime*1000) — последняя цена НЕ ПОЗЖЕ экспирации.
  Отказ 400 'bet not expired yet' до экспирации, 400 на уже закрытую,
  503 если истории нет (не выдумываем цену).
- helm: env SMART_PRICE_HISTORY_PATH / SMART_PRICE_RETENTION_HOURS.
- check.ts: 10 проверок истории, включая ключевую «НЕ отдаёт цену позже момента».

Верификация: typecheck 0 ошибок; npm test 34/34; мутационная приёмка — подмена
<= на >=/ASC роняет 4 проверки, снятие дедупа и смена ретеншена роняют свои.
Живой стенд: exitPrice = цена на момент экспирации (9977000000), тогда как
текущая цена в момент клика была 9976000000 — взята история, не 'сейчас'.
This commit is contained in:
Caffeine
2026-09-13 14:46:47 +03:00
parent 5ab6c9def5
commit 0b136c6502
7 changed files with 302 additions and 4 deletions
+116
View File
@@ -0,0 +1,116 @@
/**
* Локальная история цены 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<DatabaseSync["prepare"]>;
private readonly stmtLastPrice: ReturnType<DatabaseSync["prepare"]>;
private readonly stmtAtOrBefore: ReturnType<DatabaseSync["prepare"]>;
private readonly stmtInsert: ReturnType<DatabaseSync["prepare"]>;
private readonly stmtPrune: ReturnType<DatabaseSync["prepare"]>;
private readonly stmtCount: ReturnType<DatabaseSync["prepare"]>;
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();
}
}