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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user