fix: Stale-Event-Guard — Events älter 2h werden nur geloggt, nie gehandelt (Final-Review C1)

Erster Prod-Start: RSS liefert sofort die letzten ~100 Posts; ohne Guard würde
ein tagealter Coin-Post zum Tagespreis gekauft (nicht von der Event-Study gedeckt).
Gilt symmetrisch für on-chain (Downtime-Aufholjagd) und Truth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 09:54:58 +00:00
parent 5c72e4269c
commit 828ab274d6
3 changed files with 26 additions and 3 deletions

View File

@@ -29,7 +29,7 @@ export const positions = pgTable('positions', {
export const paperTrades = pgTable('paper_trades', { export const paperTrades = pgTable('paper_trades', {
id: serial('id').primaryKey(), id: serial('id').primaryKey(),
bot: text('bot').notNull().default('trend'), // 'trend' | 'grid' bot: text('bot').notNull().default('trend'), // 'trend' | 'grid' | 'trump'
pair: varchar('pair', { length: 16 }).notNull(), pair: varchar('pair', { length: 16 }).notNull(),
side: text('side').notNull(), side: text('side').notNull(),
entryTs: timestamp('entry_ts', { withTimezone: true }).notNull(), entryTs: timestamp('entry_ts', { withTimezone: true }).notNull(),

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { dedupeTruthEvents, passesNotional } from './poller'; import { consumedAtForInsert, dedupeTruthEvents, passesNotional, STALE_EVENT_MS } from './poller';
describe('passesNotional', () => { describe('passesNotional', () => {
test('amount × Preis gegen MIN_NOTIONAL_USD (50k)', () => { test('amount × Preis gegen MIN_NOTIONAL_USD (50k)', () => {
@@ -22,3 +22,12 @@ describe('dedupeTruthEvents', () => {
expect(dedupeTruthEvents(batch, existing).map((e) => e.url)).toEqual(['u2', 'u4']); expect(dedupeTruthEvents(batch, existing).map((e) => e.url)).toEqual(['u2', 'u4']);
}); });
}); });
describe('consumedAtForInsert', () => {
const NOW = 1_750_000_000_000;
test('frisches Event → null (handelbar), zu altes → sofort consumed (nur Log)', () => {
expect(consumedAtForInsert(NOW - 5 * 60_000, NOW)).toBeNull(); // 5 min alt
expect(consumedAtForInsert(NOW - STALE_EVENT_MS + 1, NOW)).toBeNull(); // knapp drunter
expect(consumedAtForInsert(NOW - STALE_EVENT_MS - 1, NOW)).toEqual(new Date(NOW)); // drüber → consumed
});
});

View File

@@ -10,11 +10,21 @@ import type { Pair } from '../types';
const M15 = 15 * 60 * 1000; const M15 = 15 * 60 * 1000;
/** Obergrenze Blöcke je Zyklus (~4 getLogs-Calls); Ethereum macht ~25 Blöcke/5min — reichlich Aufholpuffer. */ /** Obergrenze Blöcke je Zyklus (~4 getLogs-Calls); Ethereum macht ~25 Blöcke/5min — reichlich Aufholpuffer. */
const MAX_BLOCKS_PER_CYCLE = 20_000; const MAX_BLOCKS_PER_CYCLE = 20_000;
/** Events, die beim Einsammeln älter sind, werden nur geloggt (consumed), nie gehandelt —
* die Study misst Entries am Event-Open; ein Tage später entdecktes Event zum Tagespreis
* zu kaufen wäre eine andere (ungetestete) Strategie. Deckt v. a. den ersten Prod-Start ab:
* der RSS-Feed liefert sofort die letzten ~100 Posts, die dürfen keine Käufe auslösen. */
export const STALE_EVENT_MS = 2 * 3600_000;
export function passesNotional(amount: number, price: number | null): boolean { export function passesNotional(amount: number, price: number | null): boolean {
return price !== null && amount * price >= MIN_NOTIONAL_USD; return price !== null && amount * price >= MIN_NOTIONAL_USD;
} }
/** consumedAt-Wert für einen frischen Insert: jetzt (= nur loggen) wenn das Event zu alt ist, sonst null. */
export function consumedAtForInsert(eventTs: number, now: number): Date | null {
return now - eventTs > STALE_EVENT_MS ? new Date(now) : null;
}
/** existing: Coin → eventTs des jüngsten Truth-Events in der DB. Batch muss ts-aufsteigend sein. */ /** existing: Coin → eventTs des jüngsten Truth-Events in der DB. Batch muss ts-aufsteigend sein. */
export function dedupeTruthEvents( export function dedupeTruthEvents(
batch: { symbol: string; ts: number; url: string }[], batch: { symbol: string; ts: number; url: string }[],
@@ -69,6 +79,7 @@ export async function pollOnchain(): Promise<number> {
.values({ .values({
source: 'onchain', token: t.symbol, instrument: t.instrument, source: 'onchain', token: t.symbol, instrument: t.instrument,
eventTs: new Date(ts), ref: t.txHash, notionalUsd: t.amount * price!, eventTs: new Date(ts), ref: t.txHash, notionalUsd: t.amount * price!,
consumedAt: consumedAtForInsert(ts, Date.now()),
}) })
.onConflictDoNothing(); .onConflictDoNothing();
inserted++; inserted++;
@@ -103,7 +114,10 @@ export async function pollTruth(): Promise<number> {
const kw = COIN_KEYWORDS.find((c) => c.symbol === e.symbol)!; const kw = COIN_KEYWORDS.find((c) => c.symbol === e.symbol)!;
await db await db
.insert(trumpEvents) .insert(trumpEvents)
.values({ source: 'truth', token: e.symbol, instrument: kw.instrument, eventTs: new Date(e.ts), ref: e.url }) .values({
source: 'truth', token: e.symbol, instrument: kw.instrument, eventTs: new Date(e.ts), ref: e.url,
consumedAt: consumedAtForInsert(e.ts, Date.now()),
})
.onConflictDoNothing(); .onConflictDoNothing();
inserted++; inserted++;
} }