Files
smart-updown-token/relayer/src/priceHistory.ts
T
Caffeine 0b136c6502 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 — взята история, не 'сейчас'.
2026-09-13 14:46:47 +03:00

117 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Локальная история цены 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();
}
}