diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 78eeedf..64cf52c 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -29,7 +29,7 @@ export const positions = pgTable('positions', { export const paperTrades = pgTable('paper_trades', { 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(), side: text('side').notNull(), entryTs: timestamp('entry_ts', { withTimezone: true }).notNull(), diff --git a/src/server/signals/poller.test.ts b/src/server/signals/poller.test.ts index 881ab54..8163136 100644 --- a/src/server/signals/poller.test.ts +++ b/src/server/signals/poller.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { dedupeTruthEvents, passesNotional } from './poller'; +import { consumedAtForInsert, dedupeTruthEvents, passesNotional, STALE_EVENT_MS } from './poller'; describe('passesNotional', () => { 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']); }); }); + +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 + }); +}); diff --git a/src/server/signals/poller.ts b/src/server/signals/poller.ts index dd93056..b411568 100644 --- a/src/server/signals/poller.ts +++ b/src/server/signals/poller.ts @@ -10,11 +10,21 @@ import type { Pair } from '../types'; const M15 = 15 * 60 * 1000; /** Obergrenze Blöcke je Zyklus (~4 getLogs-Calls); Ethereum macht ~25 Blöcke/5min — reichlich Aufholpuffer. */ 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 { 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. */ export function dedupeTruthEvents( batch: { symbol: string; ts: number; url: string }[], @@ -69,6 +79,7 @@ export async function pollOnchain(): Promise { .values({ source: 'onchain', token: t.symbol, instrument: t.instrument, eventTs: new Date(ts), ref: t.txHash, notionalUsd: t.amount * price!, + consumedAt: consumedAtForInsert(ts, Date.now()), }) .onConflictDoNothing(); inserted++; @@ -103,7 +114,10 @@ export async function pollTruth(): Promise { const kw = COIN_KEYWORDS.find((c) => c.symbol === e.symbol)!; await db .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(); inserted++; }