From b75a7fb81ba8534407ac77d727caf5617d06136e Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 25 Aug 2026 22:16:02 +0300 Subject: [PATCH 1/8] =?UTF-8?q?feat(collect-data):=20production=20DataColl?= =?UTF-8?q?ector=20=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=BE=D0=B9=20V2-=D0=BA=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Превращает composition, доказанную live в CHECKPOINT #1, в production-рантайм приложения и заменяет им V1-коллектор (900 строк composition + WS-обработчиков + очереди обогащения + ручного state machine в одном main.ts). Рантайм (`apps/collect-data/src/runtime/`): - `createDataCollector` — единственный composition root контура: ОДИН ExternalMessageBus, ОДИН ExternalMessageRecorder с двумя storage-политиками, PolymarketSource/CexSource V2, Discovery/Coordinator/Finalizer; - `DataCollector` — lifecycle и расписание: recorder-first старт с полным откатом частичного запуска, тик оркестрации владельцев состояния, идемпотентная best-effort лестница остановки в порядке CHECKPOINT; - `collectionLifecycle` — read-only проекция переходов рынка (DISCOVERED/COLLECTION_STARTED/FINALIZING/FINALIZED/DROPPED); - `processBootstrap` — DNS-обход под реальные V2-эндпоинты и ОДИН охраняемый путь остановки по сигналу; - `status()` — операционный снимок целиком из существующих getStats(). Bus остаётся явной точкой расширения: создаётся снаружи, передаётся фабрике, доступен независимым consumer-ам. Будущий Semantic Adapter подключится к нему, не завися от API коллектора. `main.ts` — тонкий bootstrap. Граница «внешний конфиг → V2» (включая формат `cex-config.json`) живёт в `DataCollectorConfig`; невалидная CEX-конфигурация теперь останавливает старт, а не оставляет прогон без половины датасета. Checkpoint переведён на ту же фабрику: второй копии composition в репозитории больше нет, verification-логика (PASS/FAIL, ring-буферы, exact-match, readback) осталась на своём слое. Тесты приложения (67): fan-out общего bus, отсутствие прямой связки source→recorder, наблюдаемость lifecycle, статус, старт (порядок/откат/ повторный) и остановка (идемпотентность/best-effort/таймеры). Co-Authored-By: Claude Opus 5 --- .../__tests__/DataCollector.lifecycle.test.ts | 236 +++++ .../__tests__/DataCollector.shutdown.test.ts | 218 ++++ .../__tests__/DataCollector.startup.test.ts | 166 +++ .../__tests__/DataCollector.status.test.ts | 166 +++ .../__tests__/DataCollectorConfig.test.ts | 184 ++++ .../__tests__/contour-fan-out.test.ts | 196 ++++ apps/collect-data/__tests__/helpers/fakes.ts | 443 ++++++++ .../__tests__/helpers/recordingFakes.ts | 107 ++ apps/collect-data/docs/collect-data.md | 184 +++- apps/collect-data/jest.config.js | 50 + apps/collect-data/package.json | 36 +- apps/collect-data/src/config.ts | 33 + apps/collect-data/src/main.ts | 950 ++---------------- .../collect-data/src/runtime/DataCollector.ts | 671 +++++++++++++ .../src/runtime/DataCollectorConfig.ts | 316 ++++++ .../src/runtime/collectionLifecycle.ts | 317 ++++++ .../src/runtime/createDataCollector.ts | 202 ++++ apps/collect-data/src/runtime/index.ts | 53 + .../src/runtime/processBootstrap.ts | 159 +++ apps/collect-data/tsconfig.build.json | 36 +- apps/collect-data/tsconfig.json | 12 +- docs/guides/data-collector-v2-cutover.md | 211 ++++ scripts/checkpoint-raw-live.mts | 473 +++++---- 23 files changed, 4281 insertions(+), 1138 deletions(-) create mode 100644 apps/collect-data/__tests__/DataCollector.lifecycle.test.ts create mode 100644 apps/collect-data/__tests__/DataCollector.shutdown.test.ts create mode 100644 apps/collect-data/__tests__/DataCollector.startup.test.ts create mode 100644 apps/collect-data/__tests__/DataCollector.status.test.ts create mode 100644 apps/collect-data/__tests__/DataCollectorConfig.test.ts create mode 100644 apps/collect-data/__tests__/contour-fan-out.test.ts create mode 100644 apps/collect-data/__tests__/helpers/fakes.ts create mode 100644 apps/collect-data/__tests__/helpers/recordingFakes.ts create mode 100644 apps/collect-data/jest.config.js create mode 100644 apps/collect-data/src/runtime/DataCollector.ts create mode 100644 apps/collect-data/src/runtime/DataCollectorConfig.ts create mode 100644 apps/collect-data/src/runtime/collectionLifecycle.ts create mode 100644 apps/collect-data/src/runtime/createDataCollector.ts create mode 100644 apps/collect-data/src/runtime/index.ts create mode 100644 apps/collect-data/src/runtime/processBootstrap.ts create mode 100644 docs/guides/data-collector-v2-cutover.md diff --git a/apps/collect-data/__tests__/DataCollector.lifecycle.test.ts b/apps/collect-data/__tests__/DataCollector.lifecycle.test.ts new file mode 100644 index 00000000..01a4fd8b --- /dev/null +++ b/apps/collect-data/__tests__/DataCollector.lifecycle.test.ts @@ -0,0 +1,236 @@ +/** + * Наблюдаемость collection lifecycle (MR-A PART 18/19/44). + * + * @remarks + * Проверяется наблюдаемое поведение проекции — какие переходы видит внешний + * наблюдатель, а не как именно они вычисляются. + */ +import { describe, expect, it } from '@jest/globals'; +import { asMarketId } from '@polymarket/ids'; +import { DataCollector } from '../src/runtime/DataCollector.js'; +import type { CollectionRuntimeConfig } from '../src/runtime/DataCollectorConfig.js'; +import type { CollectionLifecycleEvent } from '../src/runtime/collectionLifecycle.js'; +import { candidate, makeFakeContour, timestampOf } from './helpers/fakes.js'; + +const COLLECTION: CollectionRuntimeConfig = { + maxMarkets: 3, + discoveryRefreshMs: 30_000, + runtimeTickMs: 5_000, +}; + +const MARKET_A = asMarketId('0xaaa1')!; +const MARKET_B = asMarketId('0xbbb2')!; + +/** Рантайм + собранные события наблюдателя. */ +function makeObservedCollector(contour = makeFakeContour([])): { + collector: DataCollector; + contour: ReturnType; + events: CollectionLifecycleEvent[]; +} { + const collector = new DataCollector({ + components: contour.components, + collection: COLLECTION, + clock: contour.clock, + logger: contour.logger, + }); + const events: CollectionLifecycleEvent[] = []; + collector.onMarketLifecycle((event) => events.push(event)); + return { collector, contour, events }; +} + +/** Виды событий по рынку. */ +function kindsFor(events: readonly CollectionLifecycleEvent[], marketId: string): string[] { + return events.filter((event) => String(event.marketId) === marketId).map((event) => event.kind); +} + +describe('lifecycle — обнаружение кандидатов', () => { + it('объявляет DISCOVERED впервые увиденные рынки', async () => { + const { collector, contour, events } = makeObservedCollector(); + contour.discovery.candidates = [candidate('0xaaa1'), candidate('0xbbb2')]; + + await collector.tick(); + + expect(kindsFor(events, '0xaaa1')).toEqual(['DISCOVERED']); + expect(kindsFor(events, '0xbbb2')).toEqual(['DISCOVERED']); + expect(events[0]?.question).toBe('Question 0xaaa1'); + }); + + it('не повторяет DISCOVERED для того же рынка', async () => { + const { collector, contour, events } = makeObservedCollector(); + contour.discovery.candidates = [candidate('0xaaa1')]; + + await collector.tick(); + contour.clock.advance(COLLECTION.discoveryRefreshMs); + await collector.tick(); + + expect(kindsFor(events, '0xaaa1')).toEqual(['DISCOVERED']); + }); +}); + +describe('lifecycle — сессии сбора', () => { + it('объявляет COLLECTION_STARTED, когда сессия стала ACTIVE', async () => { + const { collector, contour, events } = makeObservedCollector(); + contour.coordinator.sessions = [ + { marketId: MARKET_A, state: 'ACTIVE', question: 'BTC up?', expiresAt: timestampOf(5_000) }, + ]; + + await collector.tick(); + + const started = events.find((event) => event.kind === 'COLLECTION_STARTED'); + expect(started?.question).toBe('BTC up?'); + expect(started?.expiresAtMs).toBe(5_000); + }); + + it('не объявляет отдельного события для промежуточного OPENING', async () => { + const { collector, contour, events } = makeObservedCollector(); + contour.coordinator.sessions = [{ marketId: MARKET_A, state: 'OPENING' }]; + + await collector.tick(); + expect(events).toHaveLength(0); + + contour.coordinator.sessions = [{ marketId: MARKET_A, state: 'ACTIVE' }]; + await collector.tick(); + + expect(kindsFor(events, '0xaaa1')).toEqual(['COLLECTION_STARTED']); + }); + + it('объявляет FINALIZING на переходе ACTIVE → FINALIZING', async () => { + const { collector, contour, events } = makeObservedCollector(); + contour.coordinator.sessions = [{ marketId: MARKET_A, state: 'ACTIVE' }]; + await collector.tick(); + + contour.coordinator.sessions = [{ marketId: MARKET_A, state: 'FINALIZING' }]; + await collector.tick(); + + expect(kindsFor(events, '0xaaa1')).toEqual(['COLLECTION_STARTED', 'FINALIZING']); + }); + + it('объявляет FINALIZED с исходом archived, когда финализация завершилась', async () => { + const { collector, contour, events } = makeObservedCollector(); + contour.coordinator.sessions = [{ marketId: MARKET_A, state: 'FINALIZING' }]; + await collector.tick(); + + contour.coordinator.sessions = []; + contour.finalizer.archivedTotal = 1; + await collector.tick(); + + const finalized = events.find((event) => event.kind === 'FINALIZED'); + expect(finalized?.outcome).toBe('archived'); + expect(String(finalized?.marketId)).toBe('0xaaa1'); + }); + + it('объявляет FINALIZED с исходом failed, когда архив не удался', async () => { + const { collector, contour, events } = makeObservedCollector(); + contour.coordinator.sessions = [{ marketId: MARKET_A, state: 'FINALIZING' }]; + await collector.tick(); + + contour.coordinator.sessions = []; + contour.finalizer.archiveFailures = 1; + await collector.tick(); + + expect(events.find((event) => event.kind === 'FINALIZED')?.outcome).toBe('failed'); + }); + + it('не выдумывает исход, когда финализацию покинули сразу несколько рынков', async () => { + const { collector, contour, events } = makeObservedCollector(); + contour.coordinator.sessions = [ + { marketId: MARKET_A, state: 'FINALIZING' }, + { marketId: MARKET_B, state: 'FINALIZING' }, + ]; + await collector.tick(); + + contour.coordinator.sessions = []; + contour.finalizer.archivedTotal = 2; + await collector.tick(); + + const finalized = events.filter((event) => event.kind === 'FINALIZED'); + expect(finalized).toHaveLength(2); + expect(finalized.every((event) => event.outcome === undefined)).toBe(true); + }); + + it('объявляет DROPPED, когда активная сессия исчезла без финализации', async () => { + const { collector, contour, events } = makeObservedCollector(); + contour.coordinator.sessions = [{ marketId: MARKET_A, state: 'ACTIVE' }]; + await collector.tick(); + + contour.coordinator.sessions = []; + await collector.tick(); + + const dropped = events.find((event) => event.kind === 'DROPPED'); + expect(dropped?.reason).toBe('source-failure'); + }); + + it('сессии, снятые остановкой, наблюдаемы как DROPPED c причиной shutdown', async () => { + const { collector, contour, events } = makeObservedCollector(); + await collector.start(); + contour.coordinator.sessions = [{ marketId: MARKET_A, state: 'ACTIVE' }]; + await collector.tick(); + + await collector.close(); + + expect(events.find((event) => event.kind === 'DROPPED')?.reason).toBe('shutdown'); + }); +}); + +describe('lifecycle — контракт наблюдателя', () => { + it('отписка прекращает доставку событий', async () => { + const { collector, contour, events } = makeObservedCollector(); + const seen: string[] = []; + const off = collector.onMarketLifecycle((event) => seen.push(event.kind)); + contour.coordinator.sessions = [{ marketId: MARKET_A, state: 'ACTIVE' }]; + await collector.tick(); + + off(); + contour.coordinator.sessions = [{ marketId: MARKET_A, state: 'FINALIZING' }]; + await collector.tick(); + + expect(seen).toEqual(['COLLECTION_STARTED']); + expect(events.map((event) => event.kind)).toEqual(['COLLECTION_STARTED', 'FINALIZING']); + }); + + it('исключение наблюдателя не срывает тик и не теряет остальных', async () => { + const { collector, contour, events } = makeObservedCollector(); + collector.onMarketLifecycle(() => { + throw new Error('observer exploded'); + }); + contour.coordinator.sessions = [{ marketId: MARKET_A, state: 'ACTIVE' }]; + + await expect(collector.tick()).resolves.toBeUndefined(); + + expect(events.map((event) => event.kind)).toEqual(['COLLECTION_STARTED']); + expect(contour.logger.byLevel('warn').some((line) => line.message.includes('listener'))).toBe( + true, + ); + }); + + it('накопительные счётчики отражают наблюдённые переходы', async () => { + const { collector, contour } = makeObservedCollector(); + contour.discovery.candidates = [candidate('0xaaa1')]; + contour.coordinator.sessions = [{ marketId: MARKET_A, state: 'ACTIVE' }]; + await collector.tick(); + + contour.coordinator.sessions = [{ marketId: MARKET_A, state: 'FINALIZING' }]; + await collector.tick(); + contour.coordinator.sessions = []; + contour.finalizer.archivedTotal = 1; + await collector.tick(); + + expect(collector.status().lifecycle).toEqual({ + discovered: 1, + collectionStarted: 1, + finalizing: 1, + finalized: 1, + archived: 1, + dropped: 0, + }); + }); + + it('отказ чтения кэша кандидатов не срывает тик', async () => { + const { collector, contour } = makeObservedCollector(); + contour.discovery.findRejection = new Error('gamma unavailable'); + + await expect(collector.tick()).resolves.toBeUndefined(); + + expect(contour.coordinator.fillCalls).toBe(1); + }); +}); diff --git a/apps/collect-data/__tests__/DataCollector.shutdown.test.ts b/apps/collect-data/__tests__/DataCollector.shutdown.test.ts new file mode 100644 index 00000000..7435f0b7 --- /dev/null +++ b/apps/collect-data/__tests__/DataCollector.shutdown.test.ts @@ -0,0 +1,218 @@ +/** + * Лестница остановки рантайма (MR-A PART 35/47). + * + * @remarks + * Порядок закрытия — доказанный контур CHECKPOINT #1: ingress глохнет + * раньше, чем дренируется bus, а recorder закрывается ПОСЛЕ дренажа — + * иначе последние сообщения были бы потеряны. + */ +import { describe, expect, it } from '@jest/globals'; +import { DataCollector } from '../src/runtime/DataCollector.js'; +import type { CollectionRuntimeConfig } from '../src/runtime/DataCollectorConfig.js'; +import { makeFakeContour } from './helpers/fakes.js'; + +const COLLECTION: CollectionRuntimeConfig = { + maxMarkets: 3, + discoveryRefreshMs: 30_000, + runtimeTickMs: 5_000, +}; + +/** Рантайм поверх fake-контура. */ +function makeCollector(contour = makeFakeContour(['binance'])): { + collector: DataCollector; + contour: ReturnType; +} { + const collector = new DataCollector({ + components: contour.components, + collection: COLLECTION, + clock: contour.clock, + logger: contour.logger, + }); + return { collector, contour }; +} + +/** Шаги остановки в порядке их появления в журнале. */ +function shutdownOrder(calls: readonly string[]): string[] { + const shutdownSteps = new Set([ + 'finalizer.close', + 'coordinator.close', + 'polymarketSource.close', + 'cexSource.close(binance)', + 'bus.drain', + 'recorder.close', + 'bus.close', + ]); + return calls.filter((call) => shutdownSteps.has(call)); +} + +describe('DataCollector.close() — штатная остановка', () => { + it('закрывает контур в доказанном порядке', async () => { + const { collector, contour } = makeCollector(); + await collector.start(); + + await collector.close(); + + expect(shutdownOrder(contour.log.calls)).toEqual([ + 'finalizer.close', + 'coordinator.close', + 'polymarketSource.close', + 'cexSource.close(binance)', + 'bus.drain', + 'recorder.close', + 'bus.close', + ]); + expect(collector.state).toBe('stopped'); + }); + + it('дренирует bus СТРОГО до закрытия recorder', async () => { + const { collector, contour } = makeCollector(); + await collector.start(); + + await collector.close(); + + expect(contour.log.indexOf('bus.drain')).toBeLessThan(contour.log.indexOf('recorder.close')); + expect(contour.log.indexOf('recorder.close')).toBeLessThan(contour.log.indexOf('bus.close')); + }); + + it('глушит ingress до дренажа очереди', async () => { + const { collector, contour } = makeCollector(); + await collector.start(); + + await collector.close(); + + expect(contour.log.indexOf('polymarketSource.close')).toBeLessThan( + contour.log.indexOf('bus.drain'), + ); + expect(contour.log.indexOf('cexSource.close(binance)')).toBeLessThan( + contour.log.indexOf('bus.drain'), + ); + }); +}); + +describe('DataCollector.close() — идемпотентность', () => { + it('повторный close() не запускает вторую лестницу', async () => { + const { collector, contour } = makeCollector(); + await collector.start(); + + await collector.close(); + await collector.close(); + await collector.close(); + + expect(contour.log.countOf('recorder.close')).toBe(1); + expect(contour.log.countOf('bus.close')).toBe(1); + expect(contour.log.countOf('finalizer.close')).toBe(1); + }); + + it('параллельные close() дожидаются одной и той же остановки', async () => { + const { collector, contour } = makeCollector(); + await collector.start(); + + await Promise.all([collector.close(), collector.close()]); + + expect(contour.log.countOf('bus.close')).toBe(1); + }); + + it('close() до старта — no-op, ресурсы не трогаются', async () => { + const { collector, contour } = makeCollector(); + + await collector.close(); + + expect(contour.log.calls).toEqual([]); + expect(collector.state).toBe('stopped'); + }); + + it('close() после неудавшегося старта не закрывает ресурсы повторно', async () => { + const contour = makeFakeContour(['binance']); + contour.cexSources[0]!.startError = new Error('binance down'); + const { collector } = makeCollector(contour); + await expect(collector.start()).rejects.toThrow('binance down'); + const callsAfterRollback = contour.log.calls.length; + + await collector.close(); + + expect(contour.log.calls.length).toBe(callsAfterRollback); + }); +}); + +describe('DataCollector.close() — best-effort', () => { + it('отказ одного шага не отменяет остальные', async () => { + const { collector, contour } = makeCollector(); + contour.finalizer.closeRejection = new Error('finalizer close failed'); + contour.polymarketSource.closeRejection = new Error('pm source close failed'); + await collector.start(); + + await expect(collector.close()).resolves.toBeUndefined(); + + // Все последующие шаги всё равно выполнены. + expect(contour.log.countOf('coordinator.close')).toBe(1); + expect(contour.log.countOf('cexSource.close(binance)')).toBe(1); + expect(contour.log.countOf('bus.drain')).toBe(1); + expect(contour.log.countOf('recorder.close')).toBe(1); + expect(contour.log.countOf('bus.close')).toBe(1); + }); + + it('отказы шагов наблюдаемы в логе', async () => { + const { collector, contour } = makeCollector(); + contour.recorder.closeRejection = new Error('recorder close failed'); + await collector.start(); + + await collector.close(); + + const failures = contour.logger + .byLevel('error') + .filter((line) => line.message.includes('Shutdown step failed')); + expect(failures).toHaveLength(1); + expect(failures[0]?.context?.['step']).toBe('recorder.close'); + }); + + it('отклонённый bus.drain не срывает закрытие recorder и bus', async () => { + const { collector, contour } = makeCollector(); + contour.bus.drainRejection = { message: 'drain timed out' } as never; + await collector.start(); + + await collector.close(); + + expect(contour.log.countOf('recorder.close')).toBe(1); + expect(contour.log.countOf('bus.close')).toBe(1); + }); + + it('отказ закрытия одной биржи не мешает закрыть остальные', async () => { + const contour = makeFakeContour(['binance', 'okx']); + contour.cexSources[0]!.closeRejection = new Error('binance close hung'); + const { collector } = makeCollector(contour); + await collector.start(); + + await collector.close(); + + expect(contour.log.countOf('cexSource.close(binance)')).toBe(1); + expect(contour.log.countOf('cexSource.close(okx)')).toBe(1); + expect(contour.log.countOf('bus.close')).toBe(1); + }); +}); + +describe('DataCollector.close() — таймеры и тики', () => { + it('останавливает runtime-цикл: после close() новых тиков нет', async () => { + const { collector, contour } = makeCollector(); + await collector.start(); + await collector.close(); + const fillCallsAfterClose = contour.coordinator.fillCalls; + + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(contour.coordinator.fillCalls).toBe(fillCallsAfterClose); + }); + + it('дожидается тика в полёте перед закрытием компонентов', async () => { + const { collector, contour } = makeCollector(); + await collector.start(); + let tickFinished = false; + const slowTick = collector.tick().then(() => { + tickFinished = true; + }); + + await Promise.all([collector.close(), slowTick]); + + expect(tickFinished).toBe(true); + expect(contour.log.countOf('bus.close')).toBe(1); + }); +}); diff --git a/apps/collect-data/__tests__/DataCollector.startup.test.ts b/apps/collect-data/__tests__/DataCollector.startup.test.ts new file mode 100644 index 00000000..f79610fe --- /dev/null +++ b/apps/collect-data/__tests__/DataCollector.startup.test.ts @@ -0,0 +1,166 @@ +/** + * Порядок и детерминизм запуска рантайма (MR-A PART 32/33/46). + * + * @remarks + * Проверяется наблюдаемое поведение, а не внутренности: recorder подписан + * ДО ingress, повторный старт явно отклоняется, а частичный отказ не + * оставляет за собой поднятых ресурсов. + */ +import { describe, expect, it } from '@jest/globals'; +import { DataCollector } from '../src/runtime/DataCollector.js'; +import type { CollectionRuntimeConfig } from '../src/runtime/DataCollectorConfig.js'; +import { makeFakeContour } from './helpers/fakes.js'; + +const COLLECTION: CollectionRuntimeConfig = { + maxMarkets: 3, + discoveryRefreshMs: 30_000, + runtimeTickMs: 5_000, +}; + +/** Рантайм поверх fake-контура. */ +function makeCollector(contour = makeFakeContour()): { + collector: DataCollector; + contour: ReturnType; +} { + const collector = new DataCollector({ + components: contour.components, + collection: COLLECTION, + clock: contour.clock, + logger: contour.logger, + }); + return { collector, contour }; +} + +describe('DataCollector.start() — порядок запуска', () => { + it('поднимает recorder ДО любого ingress', async () => { + const { collector, contour } = makeCollector(); + + await collector.start(); + + const recorderStart = contour.log.indexOf('recorder.start'); + expect(recorderStart).toBeGreaterThanOrEqual(0); + for (const source of contour.cexSources) { + expect(recorderStart).toBeLessThan(contour.log.indexOf(`cexSource.start(${source.exchangeId})`)); + } + + await collector.close(); + }); + + it('чистит незавершённые артефакты обеих политик ДО подписки recorder', async () => { + const { collector, contour } = makeCollector(); + + await collector.start(); + + expect(contour.log.indexOf('polymarketStorage.cleanup')).toBeLessThan( + contour.log.indexOf('recorder.start'), + ); + expect(contour.log.indexOf('cexStorage.cleanup')).toBeLessThan( + contour.log.indexOf('recorder.start'), + ); + + await collector.close(); + }); + + it('переводит рантайм в running и запускает все CEX-source-ы', async () => { + const { collector, contour } = makeCollector(); + + expect(collector.state).toBe('idle'); + await collector.start(); + + expect(collector.state).toBe('running'); + expect(contour.cexSources.every((source) => source.isRunning)).toBe(true); + expect(collector.status().startedAtMs).not.toBeNull(); + + await collector.close(); + }); +}); + +describe('DataCollector.start() — повторный вызов', () => { + it('отклоняет второй старт и не перезапускает компоненты', async () => { + const { collector, contour } = makeCollector(); + await collector.start(); + const callsAfterFirst = contour.log.calls.length; + + await expect(collector.start()).rejects.toThrow('already running'); + + expect(contour.log.calls.length).toBe(callsAfterFirst); + expect(contour.log.countOf('recorder.start')).toBe(1); + + await collector.close(); + }); + + it('отклоняет старт после остановки', async () => { + const { collector } = makeCollector(); + await collector.start(); + await collector.close(); + + await expect(collector.start()).rejects.toThrow('already stopped'); + }); +}); + +describe('DataCollector.start() — откат частичного запуска', () => { + it('закрывает уже поднятые ресурсы, если падает второй CEX-source', async () => { + const contour = makeFakeContour(['binance', 'okx', 'kraken']); + contour.cexSources[1]!.startError = new Error('okx transport unavailable'); + const { collector } = makeCollector(contour); + + await expect(collector.start()).rejects.toThrow('okx transport unavailable'); + + // Успевший подняться source закрыт, упавший и последующие — не запускались. + expect(contour.log.countOf('cexSource.close(binance)')).toBe(1); + expect(contour.log.countOf('cexSource.start(kraken)')).toBe(0); + expect(contour.log.countOf('cexSource.close(okx)')).toBe(0); + // Recorder и bus закрыты — контур не остался наполовину живым. + expect(contour.log.countOf('recorder.close')).toBe(1); + expect(contour.log.countOf('bus.close')).toBe(1); + expect(collector.state).toBe('stopped'); + }); + + it('откатывает в порядке, обратном подъёму (LIFO)', async () => { + const contour = makeFakeContour(['binance', 'okx']); + contour.cexSources[1]!.startError = new Error('okx down'); + const { collector } = makeCollector(contour); + + await expect(collector.start()).rejects.toThrow('okx down'); + + expect(contour.log.indexOf('cexSource.close(binance)')).toBeLessThan( + contour.log.indexOf('recorder.close'), + ); + expect(contour.log.indexOf('recorder.close')).toBeLessThan(contour.log.indexOf('bus.close')); + }); + + it('отказ startup cleanup не оставляет подписанного recorder', async () => { + const contour = makeFakeContour(); + contour.polymarketStorage.cleanupRejection = new Error('cleanup failed'); + const { collector } = makeCollector(contour); + + await expect(collector.start()).rejects.toThrow('cleanup failed'); + + expect(contour.log.countOf('recorder.start')).toBe(0); + expect(contour.log.countOf('cexSource.start(binance)')).toBe(0); + expect(collector.state).toBe('stopped'); + }); + + it('отказ recorder.start() закрывает bus и не запускает ingress', async () => { + const contour = makeFakeContour(); + contour.recorder.startError = new Error('recorder subscribe failed'); + const { collector } = makeCollector(contour); + + await expect(collector.start()).rejects.toThrow('recorder subscribe failed'); + + expect(contour.log.countOf('cexSource.start(binance)')).toBe(0); + expect(contour.log.countOf('bus.close')).toBe(1); + }); + + it('работает без единого CEX-source (CEX выключен конфигурацией)', async () => { + const contour = makeFakeContour([]); + const { collector } = makeCollector(contour); + + await collector.start(); + + expect(collector.state).toBe('running'); + expect(collector.status().sources.cex).toHaveLength(0); + + await collector.close(); + }); +}); diff --git a/apps/collect-data/__tests__/DataCollector.status.test.ts b/apps/collect-data/__tests__/DataCollector.status.test.ts new file mode 100644 index 00000000..ab9c4b30 --- /dev/null +++ b/apps/collect-data/__tests__/DataCollector.status.test.ts @@ -0,0 +1,166 @@ +/** + * Операционный статус рантайма (MR-A PART 20/45). + * + * @remarks + * Статус обязан быть read-only проекцией уже существующих `getStats()` + * компонентов: собственных метрик рантайм не заводит. + */ +import { describe, expect, it } from '@jest/globals'; +import { asMarketId } from '@polymarket/ids'; +import { DataCollector } from '../src/runtime/DataCollector.js'; +import type { CollectionRuntimeConfig } from '../src/runtime/DataCollectorConfig.js'; +import { makeFakeContour, timestampOf } from './helpers/fakes.js'; + +const COLLECTION: CollectionRuntimeConfig = { + maxMarkets: 3, + discoveryRefreshMs: 30_000, + runtimeTickMs: 5_000, +}; + +const MARKET_A = asMarketId('0xaaa1')!; + +/** Рантайм поверх fake-контура. */ +function makeCollector(contour = makeFakeContour(['binance', 'okx'])): { + collector: DataCollector; + contour: ReturnType; +} { + const collector = new DataCollector({ + components: contour.components, + collection: COLLECTION, + clock: contour.clock, + logger: contour.logger, + }); + return { collector, contour }; +} + +describe('DataCollector.status()', () => { + it('до старта: idle без времени работы', () => { + const { collector } = makeCollector(); + + const status = collector.status(); + + expect(status.state).toBe('idle'); + expect(status.startedAtMs).toBeNull(); + expect(status.uptimeMs).toBeNull(); + expect(status.collection.activeSessions).toBe(0); + expect(status.lifecycle.collectionStarted).toBe(0); + }); + + it('после старта: running, время работы растёт по часам', async () => { + const { collector, contour } = makeCollector(); + + await collector.start(); + contour.clock.advance(120_000); + const status = collector.status(); + + expect(status.state).toBe('running'); + expect(status.startedAtMs).not.toBeNull(); + expect(status.uptimeMs).toBe(120_000); + + await collector.close(); + }); + + it('отражает начатый сбор рынка', async () => { + const { collector, contour } = makeCollector(); + await collector.start(); + contour.coordinator.sessions = [ + { marketId: MARKET_A, state: 'ACTIVE', expiresAt: timestampOf(9_000) }, + ]; + + await collector.tick(); + const status = collector.status(); + + expect(status.collection.activeSessions).toBe(1); + expect(status.lifecycle.collectionStarted).toBe(1); + + await collector.close(); + }); + + it('отражает финализацию через счётчики финализатора', async () => { + const { collector, contour } = makeCollector(); + await collector.start(); + contour.finalizer.pendingFinalizations = 2; + contour.finalizer.archivedTotal = 5; + contour.finalizer.archiveFailures = 1; + + const status = collector.status(); + + expect(status.finalization).toEqual({ + pendingFinalizations: 2, + archivedTotal: 5, + archiveFailures: 1, + }); + + await collector.close(); + }); + + it('переиспользует статистику recorder, окон и bus без дублирования метрик', async () => { + const { collector } = makeCollector(); + await collector.start(); + + const status = collector.status(); + + expect(status.recorder.recordsWritten).toBe(7); + expect(status.recorderCex.cexRecordsAccepted).toBe(3); + expect(status.cexWindows.partitionsCompleted).toBe(4); + expect(status.bus.subscribedTypes).toBe(2); + + await collector.close(); + }); + + it('сообщает здоровье источников поимённо', async () => { + const { collector, contour } = makeCollector(); + await collector.start(); + contour.cexSources[1]!.hasFailed = true; + + const status = collector.status(); + + expect(status.sources.cex.map((source) => source.exchangeId)).toEqual(['binance', 'okx']); + expect(status.sources.cex[0]?.isRunning).toBe(true); + expect(status.sources.cex[1]?.hasFailed).toBe(true); + expect(status.sources.polymarket.isClosed).toBe(false); + + await collector.close(); + }); + + it('после остановки: stopped, источники закрыты', async () => { + const { collector } = makeCollector(); + await collector.start(); + + await collector.close(); + const status = collector.status(); + + expect(status.state).toBe('stopped'); + expect(status.sources.polymarket.isClosed).toBe(true); + expect(status.sources.cex.every((source) => !source.isRunning)).toBe(true); + }); +}); + +describe('DataCollector.drain()', () => { + it('дренирует незавершённые финализации по явному запросу', async () => { + const { collector, contour } = makeCollector(); + await collector.start(); + + await collector.drain(); + + expect(contour.finalizer.drainCalls).toBe(1); + await collector.close(); + }); + + it('НЕ вызывается штатной остановкой (сигнал не ждёт официальной резолюции)', async () => { + const { collector, contour } = makeCollector(); + await collector.start(); + + await collector.close(); + + expect(contour.finalizer.drainCalls).toBe(0); + }); + + it('на неработающем рантайме — no-op', async () => { + const { collector, contour } = makeCollector(); + + await collector.drain(); + + expect(contour.finalizer.drainCalls).toBe(0); + }); +}); diff --git a/apps/collect-data/__tests__/DataCollectorConfig.test.ts b/apps/collect-data/__tests__/DataCollectorConfig.test.ts new file mode 100644 index 00000000..21edc4e5 --- /dev/null +++ b/apps/collect-data/__tests__/DataCollectorConfig.test.ts @@ -0,0 +1,184 @@ +/** + * Boundary-конверсия внешней конфигурации в конфигурацию V2-рантайма + * (MR-A PART 24/25). + * + * @remarks + * Формат `cex-config.json` сохранён от legacy-коллектора, поэтому + * production-конфигурация не переписывается вместе с рантаймом — но legacy + * ТИПЫ внутрь V2 не проходят: конверсия имён полей выполняется на границе. + */ +import { describe, expect, it } from '@jest/globals'; +import { parseCexSourceConfigs, toDataCollectorConfig } from '../src/runtime/DataCollectorConfig.js'; +import type { CollectorConfig } from '../src/config.js'; + +/** Базовая внешняя конфигурация приложения. */ +function baseConfig(overrides: Partial = {}): CollectorConfig { + return { + dnsOverrideEnabled: false, + gammaApiBaseUrl: 'https://gamma-api.polymarket.com', + wsUrl: 'wss://ws-subscriptions-clob.polymarket.com/ws/market', + wsReconnectDelayMs: 3_000, + minTimeToExpiryHours: 0, + minSpread: 0, + minLiquidity: 50, + maxMarkets: 12, + requiredKeywords: ['up', 'down'], + anyOfKeywords: ['bitcoin', 'ethereum'], + excludedKeywords: [], + marketScanPauseMs: 30_000, + outputDir: './snapshots', + sourceSubDir: 'polymarket', + compression: 'gzip', + bufferSize: 100, + flushIntervalMs: 10_000, + cexConfig: null, + cexWindowMinutes: undefined, + cexBufferSize: 200, + cexFlushIntervalMs: 5_000, + ...overrides, + }; +} + +describe('parseCexSourceConfigs — legacy JSON → CexSourceConfig', () => { + it('переводит имена полей на словарь V2', () => { + const sources = parseCexSourceConfigs( + JSON.stringify({ + binance: { + type: 'spot', + symbols: ['BTC/USDT', 'ETH/USDT'], + orderbook: true, + trades: true, + obDepth: 10, + obMethod: 'watch', + restartIntervalMs: 900_000, + }, + }), + ); + + expect(sources).toEqual([ + { + exchangeId: 'binance', + marketType: 'spot', + symbols: ['BTC/USDT', 'ETH/USDT'], + watchOrderbook: true, + watchTrades: true, + orderbookDepth: 10, + orderbookMethod: 'watch', + restartIntervalMs: 900_000, + }, + ]); + }); + + it('ключ словаря служит биржей, явный exchangeId побеждает', () => { + const sources = parseCexSourceConfigs( + JSON.stringify({ + alias: { exchangeId: 'bybit', type: 'spot', symbols: ['BTC/USDT'], orderbook: true, trades: false }, + okx: { type: 'spot', symbols: ['ETH/USDT'], orderbook: false, trades: true }, + }), + ); + + expect(sources.map((source) => source.exchangeId)).toEqual(['bybit', 'okx']); + expect(sources[1]?.watchOrderbook).toBe(false); + expect(sources[1]?.watchTrades).toBe(true); + }); + + it('legacy futures отображается в canonical future', () => { + const sources = parseCexSourceConfigs( + JSON.stringify({ binance: { type: 'futures', symbols: ['BTC/USDT'], orderbook: true, trades: true } }), + ); + + expect(sources[0]?.marketType).toBe('future'); + }); + + it('опущенные поля не подставляются — дефолты остаются за пакетом source', () => { + const sources = parseCexSourceConfigs( + JSON.stringify({ kraken: { type: 'spot', symbols: ['BTC/USD'], orderbook: true, trades: true } }), + ); + + expect(sources[0]).not.toHaveProperty('orderbookDepth'); + expect(sources[0]).not.toHaveProperty('restartIntervalMs'); + expect(sources[0]).not.toHaveProperty('orderbookMethod'); + }); + + it('отвергает невалидную конфигурацию вместо тихой потери биржи', () => { + expect(() => parseCexSourceConfigs('{ not json')).toThrow('Invalid CEX config JSON'); + expect(() => parseCexSourceConfigs('[]')).toThrow('keyed by exchange id'); + expect(() => + parseCexSourceConfigs(JSON.stringify({ binance: { type: 'perp', symbols: ['BTC/USDT'] } })), + ).toThrow("type must be 'spot' | 'future' | 'swap'"); + expect(() => parseCexSourceConfigs(JSON.stringify({ binance: { type: 'spot', symbols: [] } }))).toThrow( + 'non-empty array of strings', + ); + expect(() => + parseCexSourceConfigs( + JSON.stringify({ binance: { type: 'spot', symbols: ['BTC/USDT'], obDepth: -1 } }), + ), + ).toThrow('obDepth must be a finite number > 0'); + expect(() => + parseCexSourceConfigs( + JSON.stringify({ binance: { type: 'spot', symbols: ['BTC/USDT'], obMethod: 'poll' } }), + ), + ).toThrow("obMethod must be 'watch' | 'fetch'"); + }); +}); + +describe('toDataCollectorConfig — внешняя конфигурация → рантайм', () => { + it('переносит фильтр discovery целиком', () => { + const config = toDataCollectorConfig(baseConfig()); + + expect(config.discovery.filter).toEqual({ + minTimeToExpiryHours: 0, + minSpread: 0, + minLiquidity: 50, + maxMarketsToReturn: 12, + requiredKeywords: ['up', 'down'], + anyOfKeywords: ['bitcoin', 'ethereum'], + excludedKeywords: [], + }); + }); + + it('обе storage-политики получают ОДИН корень датасетов', () => { + const config = toDataCollectorConfig(baseConfig({ outputDir: '/data/snapshots' })); + + expect(config.outputDir).toBe('/data/snapshots'); + expect(config.polymarket.sourceSubDir).toBe('polymarket'); + expect(config.cex.compression).toBe('gzip'); + }); + + it('без CEX-конфигурации source-ов нет (CEX выключен)', () => { + const config = toDataCollectorConfig(baseConfig({ cexConfig: null })); + + expect(config.cex.sources).toEqual([]); + }); + + it('невалидная CEX-конфигурация — отказ старта, а не тихая работа без CEX', () => { + expect(() => toDataCollectorConfig(baseConfig({ cexConfig: '{ broken' }))).toThrow( + 'Invalid CEX config JSON', + ); + }); + + it('дефолты финализации остаются за пакетом финализатора', () => { + const config = toDataCollectorConfig(baseConfig()); + + expect(config.finalization).toEqual({}); + }); + + it('размер окна не задан — дефолт принадлежит CexWindowRecorder', () => { + const config = toDataCollectorConfig(baseConfig()); + + expect(config.cex).not.toHaveProperty('windowMinutes'); + }); + + it('заданный размер окна пробрасывается', () => { + const config = toDataCollectorConfig(baseConfig({ cexWindowMinutes: 15 })); + + expect(config.cex.windowMinutes).toBe(15); + }); + + it('период обновления кандидатов берётся из конфигурации приложения', () => { + const config = toDataCollectorConfig(baseConfig({ marketScanPauseMs: 45_000 })); + + expect(config.collection.discoveryRefreshMs).toBe(45_000); + expect(config.collection.maxMarkets).toBe(12); + }); +}); diff --git a/apps/collect-data/__tests__/contour-fan-out.test.ts b/apps/collect-data/__tests__/contour-fan-out.test.ts new file mode 100644 index 00000000..c4abe11d --- /dev/null +++ b/apps/collect-data/__tests__/contour-fan-out.test.ts @@ -0,0 +1,196 @@ +/** + * Инварианты raw-контура коллектора (MR-A PART 11/12/29/30/31/42/43). + * + * @remarks + * Главный архитектурный тест этого MR. Он фиксирует, что recorder — ОДИН ИЗ + * consumer-ов общего bus, а не привилегированная цель прямых вызовов + * source-ов: + * + * ```text + * source.publish → ОДИН ExternalMessageBus + * ↙ ↘ + * Recorder Observer + * (сегодня — checkpoint, + * завтра — Semantic Adapter) + * ``` + * + * Именно эта развилка позволит будущему Semantic Adapter подключиться, не + * трогая ни source-пакеты, ни recorder, ни API коллектора. + */ +import { describe, expect, it } from '@jest/globals'; +import { ExternalMessageBus } from '@polymarket/external-message-bus'; +import { MessageMetadataGenerator } from '@polymarket/messages'; +import { LiveClock } from '@polymarket/time'; +import { ExternalMessageRecorder } from '@polymarket/external-message-recorder'; +import type { CexOrderbookPayload, CexTradePayload } from '@polymarket/cex-v2'; +import type { ContourMessage } from '../src/runtime/createDataCollector.js'; +import { CapturingLogger } from './helpers/fakes.js'; +import { FakeCexWindowStorage, FakePolymarketRecordingStorage } from './helpers/recordingFakes.js'; + +/** Собранный «мини-контур»: реальные bus и recorder, fake-хранилища. */ +function makeContour(): { + bus: ExternalMessageBus; + recorder: ExternalMessageRecorder; + cexStorage: FakeCexWindowStorage; + polymarketStorage: FakePolymarketRecordingStorage; + generator: MessageMetadataGenerator; +} { + const bus = new ExternalMessageBus(); + const polymarketStorage = new FakePolymarketRecordingStorage(); + const cexStorage = new FakeCexWindowStorage(); + const recorder = new ExternalMessageRecorder({ + bus, + storage: polymarketStorage, + logger: new CapturingLogger(), + cex: { bus, storage: cexStorage }, + }); + return { + bus, + recorder, + cexStorage, + polymarketStorage, + generator: new MessageMetadataGenerator({ clock: new LiveClock() }), + }; +} + +function orderbookPayload(): CexOrderbookPayload { + return { + exchangeId: 'binance', + marketType: 'spot', + symbol: 'BTC/USDT', + orderBook: { symbol: 'BTC/USDT', bids: [[100, 1]], asks: [[101, 1]], timestamp: 1 }, + }; +} + +function tradePayload(): CexTradePayload { + return { + exchangeId: 'okx', + marketType: 'spot', + symbol: 'ETH/USDT', + trade: { id: 't-1', price: 2_500, amount: 0.5, side: 'buy' }, + }; +} + +describe('общий bus — fan-out (PART 42)', () => { + it('одно сообщение source-а получают И recorder, И независимый наблюдатель', async () => { + const { bus, recorder, cexStorage, generator } = makeContour(); + const observed: CexOrderbookPayload[] = []; + bus.subscribe('CEX_ORDERBOOK', (message) => void observed.push(message.payload)); + recorder.start(); + + const payload = orderbookPayload(); + const published = await bus.publish({ + type: 'CEX_ORDERBOOK', + payload, + metadata: generator.nextRoot(), + }); + + expect(published.ok).toBe(true); + expect(cexStorage.writes).toHaveLength(1); + expect(observed).toHaveLength(1); + // Обе ветки видят ОДИН И ТОТ ЖЕ source-native payload — без копий и конверсий. + expect(cexStorage.writes[0]?.payload).toBe(payload); + expect(observed[0]).toBe(payload); + + await recorder.close(); + }); + + it('наблюдатель получает сообщения всех типов контура на ОДНОМ bus', async () => { + const { bus, recorder, generator } = makeContour(); + const kinds: string[] = []; + bus.subscribe('CEX_ORDERBOOK', () => void kinds.push('CEX_ORDERBOOK')); + bus.subscribe('CEX_TRADE', () => void kinds.push('CEX_TRADE')); + recorder.start(); + + await bus.publish({ + type: 'CEX_ORDERBOOK', + payload: orderbookPayload(), + metadata: generator.nextRoot(), + }); + await bus.publish({ type: 'CEX_TRADE', payload: tradePayload(), metadata: generator.nextRoot() }); + + expect(kinds).toEqual(['CEX_ORDERBOOK', 'CEX_TRADE']); + + await recorder.close(); + }); + + it('падение наблюдателя не лишает recorder его сообщения', async () => { + const { bus, recorder, cexStorage, generator } = makeContour(); + bus.subscribe('CEX_ORDERBOOK', () => { + throw new Error('observer exploded'); + }); + recorder.start(); + + await bus.publish({ + type: 'CEX_ORDERBOOK', + payload: orderbookPayload(), + metadata: generator.nextRoot(), + }); + + expect(cexStorage.writes).toHaveLength(1); + expect(bus.getStats().handlerErrorsTotal).toBeGreaterThan(0); + + await recorder.close(); + }); + + it('наблюдатель, подписавшийся позже recorder, всё равно получает поток', async () => { + const { bus, recorder, cexStorage, generator } = makeContour(); + recorder.start(); + const observed: unknown[] = []; + bus.subscribe('CEX_TRADE', (message) => void observed.push(message.payload)); + + await bus.publish({ type: 'CEX_TRADE', payload: tradePayload(), metadata: generator.nextRoot() }); + + expect(observed).toHaveLength(1); + expect(cexStorage.writes).toHaveLength(1); + + await recorder.close(); + }); +}); + +describe('recorder — consumer bus, а не прямая цель source (PART 43)', () => { + it('не получает ничего, пока не подписан на bus', async () => { + const { bus, recorder, cexStorage, generator } = makeContour(); + + // recorder.start() НЕ вызван — подписки нет. + await bus.publish({ + type: 'CEX_ORDERBOOK', + payload: orderbookPayload(), + metadata: generator.nextRoot(), + }); + + expect(cexStorage.writes).toHaveLength(0); + + await recorder.close(); + }); + + it('не получает сообщений, опубликованных мимо ЕГО bus', async () => { + const { recorder, cexStorage, generator } = makeContour(); + recorder.start(); + const foreignBus = new ExternalMessageBus(); + + await foreignBus.publish({ + type: 'CEX_ORDERBOOK', + payload: orderbookPayload(), + metadata: generator.nextRoot(), + }); + + expect(cexStorage.writes).toHaveLength(0); + + await recorder.close(); + await foreignBus.close(); + }); + + it('после закрытия recorder поток bus остаётся доступен другим consumer-ам', async () => { + const { bus, recorder, cexStorage, generator } = makeContour(); + const observed: unknown[] = []; + bus.subscribe('CEX_TRADE', (message) => void observed.push(message.payload)); + recorder.start(); + await recorder.close(); + + await bus.publish({ type: 'CEX_TRADE', payload: tradePayload(), metadata: generator.nextRoot() }); + + expect(observed).toHaveLength(1); + expect(cexStorage.writes).toHaveLength(0); + }); +}); diff --git a/apps/collect-data/__tests__/helpers/fakes.ts b/apps/collect-data/__tests__/helpers/fakes.ts new file mode 100644 index 00000000..3f5e4675 --- /dev/null +++ b/apps/collect-data/__tests__/helpers/fakes.ts @@ -0,0 +1,443 @@ +/** + * Тестовые fakes компонентов контура сбора. + * + * @remarks + * Все fakes — узкие структурные реализации портов `DataCollector`: они + * фиксируют вызовы и позволяют программировать отказы, не поднимая ни сети, + * ни CCXT, ни диска. Поведение самих компонентов проверяется тестами их + * собственных пакетов — здесь проверяется РАНТАЙМ: порядок запуска, откат, + * лестница остановки, проекция lifecycle и операционный статус. + */ +import type { ILogger } from '@polymarket/logger'; +import type { IClock } from '@polymarket/time'; +import type { MarketId } from '@polymarket/ids'; +import { asMarketId } from '@polymarket/ids'; +import type { MessageBusDrainError, MessageBusStats } from '@polymarket/message-bus'; +import type { Result } from '@polymarket/result'; +import { Err, Ok } from '@polymarket/result'; +import type { + CollectorBus, + CollectorCexSource, + CollectorCexSourceEntry, + CollectorCexStorage, + CollectorCoordinator, + CollectorDiscovery, + CollectorFinalizer, + CollectorPolymarketSource, + CollectorPolymarketStorage, + CollectorRecorder, + DataCollectorComponents, +} from '../../src/runtime/DataCollector.js'; + +/** Общий журнал вызовов: доказывает ПОРЯДОК запуска и остановки. */ +export class CallLog { + public readonly calls: string[] = []; + + public record(name: string): void { + this.calls.push(name); + } + + /** Индекс первого вызова (или -1). */ + public indexOf(name: string): number { + return this.calls.indexOf(name); + } + + /** Сколько раз вызывалось. */ + public countOf(name: string): number { + return this.calls.filter((call) => call === name).length; + } +} + +/** Управляемые часы: время двигает тест. */ +export class FakeClock implements IClock { + constructor(private _ms: number = 1_700_000_000_000) {} + + public now(): Date { + return new Date(this._ms); + } + + /** Двигает время вперёд. */ + public advance(ms: number): void { + this._ms += ms; + } +} + +/** Захваченная строка лога. */ +export interface LoggedLine { + readonly level: string; + readonly message: string; + readonly context: Record | undefined; +} + +/** Логгер, складывающий строки в массив. */ +export class CapturingLogger implements ILogger { + public readonly lines: LoggedLine[] = []; + + private _log(level: string, message: string, context?: Record): void { + this.lines.push({ level, message, context }); + } + + public trace(message: string, context?: Record): void { + this._log('trace', message, context); + } + public debug(message: string, context?: Record): void { + this._log('debug', message, context); + } + public info(message: string, context?: Record): void { + this._log('info', message, context); + } + public warn(message: string, context?: Record): void { + this._log('warn', message, context); + } + public error(message: string, context?: Record): void { + this._log('error', message, context); + } + public fatal(message: string, context?: Record): void { + this._log('fatal', message, context); + } + public child(): ILogger { + return this; + } + + /** Строки указанного уровня. */ + public byLevel(level: string): LoggedLine[] { + return this.lines.filter((line) => line.level === level); + } +} + +/** Fake общего bus: считает drain/close, умеет отклонять их. */ +export class FakeBus implements CollectorBus { + public drainRejection: MessageBusDrainError | undefined; + public closeRejection: MessageBusDrainError | undefined; + public stats: MessageBusStats = { + queueSize: 0, + subscribedTypes: 2, + dispatching: false, + closed: false, + publishedTotal: 0, + dispatchedTotal: 0, + handlerErrorsTotal: 0, + rejectedPublicationsTotal: 0, + }; + + constructor(private readonly _log: CallLog) {} + + public async drain(): Promise> { + this._log.record('bus.drain'); + return this.drainRejection !== undefined ? Err(this.drainRejection) : Ok(undefined); + } + + public async close(): Promise> { + this._log.record('bus.close'); + return this.closeRejection !== undefined ? Err(this.closeRejection) : Ok(undefined); + } + + public getStats(): MessageBusStats { + return this.stats; + } +} + +/** Fake recording-подписчика bus. */ +export class FakeRecorder implements CollectorRecorder { + public startError: Error | undefined; + public closeRejection: Error | undefined; + + constructor(private readonly _log: CallLog) {} + + public start(): void { + this._log.record('recorder.start'); + if (this.startError !== undefined) throw this.startError; + } + + public async close(): Promise { + this._log.record('recorder.close'); + if (this.closeRejection !== undefined) throw this.closeRejection; + } + + public getStats(): ReturnType { + return { + marketMessagesRouted: 0, + rtdsMessagesRouted: 0, + recordsWritten: 7, + recordsSkippedInactive: 0, + serializationFailures: 0, + registrationFailures: 0, + unroutedMarketMessages: 0, + unroutedRtdsMessages: 0, + handlerErrors: 0, + }; + } + + public getCexStats(): ReturnType { + return { + cexMessagesRouted: 0, + cexRecordsAccepted: 3, + cexRecordsDroppedInactive: 0, + cexWriteFailures: 0, + cexHandlerErrors: 0, + }; + } +} + +/** Fake storage-политики Polymarket. */ +export class FakePolymarketStorage implements CollectorPolymarketStorage { + public cleanupRejection: Error | undefined; + + constructor(private readonly _log: CallLog) {} + + public async cleanup(): Promise { + this._log.record('polymarketStorage.cleanup'); + if (this.cleanupRejection !== undefined) throw this.cleanupRejection; + } +} + +/** Fake storage-политики CEX-окон. */ +export class FakeCexStorage implements CollectorCexStorage { + public cleanupRejection: Error | undefined; + + constructor(private readonly _log: CallLog) {} + + public async cleanup(): Promise { + this._log.record('cexStorage.cleanup'); + if (this.cleanupRejection !== undefined) throw this.cleanupRejection; + } + + public getStats(): ReturnType { + return { + partitionsCompleted: 4, + rotationFailures: 0, + streamCloseFailures: 0, + compressionFailures: 0, + }; + } +} + +/** Fake Polymarket-source. */ +export class FakePolymarketSource implements CollectorPolymarketSource { + public hasFailed = false; + public isClosed = false; + public closeRejection: Error | undefined; + + constructor(private readonly _log: CallLog) {} + + public async close(): Promise { + this._log.record('polymarketSource.close'); + this.isClosed = true; + if (this.closeRejection !== undefined) throw this.closeRejection; + } +} + +/** Fake одного CEX-source. */ +export class FakeCexSource implements CollectorCexSource { + public hasFailed = false; + public isRunning = false; + public startError: Error | undefined; + public closeRejection: Error | undefined; + + constructor( + private readonly _log: CallLog, + public readonly exchangeId: string, + ) {} + + public start(): void { + this._log.record(`cexSource.start(${this.exchangeId})`); + if (this.startError !== undefined) throw this.startError; + this.isRunning = true; + } + + public async close(): Promise { + this._log.record(`cexSource.close(${this.exchangeId})`); + this.isRunning = false; + if (this.closeRejection !== undefined) throw this.closeRejection; + } + + public getStats(): ReturnType { + return { orderbookSnapshotFailures: 0, tradeSnapshotFailures: 0 }; + } +} + +/** Минимальный «Timestamp-подобный» объект для снимков. */ +export function timestampOf(ms: number): { toNumber(): number } { + return { toNumber: () => ms }; +} + +/** Кандидат discovery для проекции lifecycle. */ +export interface FakeCandidate { + readonly marketId: MarketId; + readonly question: string; + readonly expiresAt: { toNumber(): number }; +} + +/** Создаёт кандидата с заданным id. */ +export function candidate(id: string, expiresAtMs = 2_000_000): FakeCandidate { + return { + marketId: asMarketId(id)!, + question: `Question ${id}`, + expiresAt: timestampOf(expiresAtMs), + }; +} + +/** Fake Discovery: тест задаёт содержимое кэша кандидатов. */ +export class FakeDiscovery implements CollectorDiscovery { + public candidates: FakeCandidate[] = []; + public findRejection: Error | undefined; + + public async findCandidates(): Promise { + if (this.findRejection !== undefined) throw this.findRejection; + // Порт координатора типизирован полным PolymarketDiscoveredMarket; для + // проекции lifecycle достаточно identity/вопроса/истечения. + return this.candidates as unknown as never[]; + } +} + +/** Снимок сессии, которым управляет тест. */ +export interface FakeSession { + readonly marketId: MarketId; + state: 'OPENING' | 'ACTIVE' | 'FINALIZING'; + readonly question?: string; + readonly expiresAt?: { toNumber(): number }; +} + +/** Fake координатора: сессии и счётчики задаёт тест. */ +export class FakeCoordinator implements CollectorCoordinator { + public sessions: FakeSession[] = []; + public refreshCalls = 0; + public fillCalls = 0; + public closeRejection: Error | undefined; + + constructor(private readonly _log: CallLog) {} + + public async refreshCandidates(): Promise { + this.refreshCalls++; + this._log.record('coordinator.refreshCandidates'); + } + + public async fillSlots(): Promise { + this.fillCalls++; + this._log.record('coordinator.fillSlots'); + return 0; + } + + public listSessions(): never[] { + return this.sessions as unknown as never[]; + } + + public getStats(): ReturnType { + return { + activeSessions: this.sessions.filter((session) => session.state === 'ACTIVE').length, + openingSessions: this.sessions.filter((session) => session.state === 'OPENING').length, + finalizingSessions: this.sessions.filter((session) => session.state === 'FINALIZING').length, + rtdsFeeds: [], + }; + } + + public async close(): Promise { + this._log.record('coordinator.close'); + this.sessions = []; + if (this.closeRejection !== undefined) throw this.closeRejection; + } +} + +/** Fake финализатора: счётчики двигает тест. */ +export class FakeFinalizer implements CollectorFinalizer { + public pendingFinalizations = 0; + public archivedTotal = 0; + public archiveFailures = 0; + public runOnceCalls = 0; + public drainCalls = 0; + public closeRejection: Error | undefined; + + constructor(private readonly _log: CallLog) {} + + public async runOnce(): Promise { + this.runOnceCalls++; + this._log.record('finalizer.runOnce'); + } + + public async drain(): Promise { + this.drainCalls++; + this._log.record('finalizer.drain'); + } + + public getStats(): ReturnType { + return { + pendingFinalizations: this.pendingFinalizations, + archivedTotal: this.archivedTotal, + archiveFailures: this.archiveFailures, + }; + } + + public async close(): Promise { + this._log.record('finalizer.close'); + if (this.closeRejection !== undefined) throw this.closeRejection; + } +} + +/** Полный набор fake-компонентов одного теста. */ +export interface FakeContour { + readonly log: CallLog; + readonly logger: CapturingLogger; + readonly clock: FakeClock; + readonly bus: FakeBus; + readonly recorder: FakeRecorder; + readonly polymarketStorage: FakePolymarketStorage; + readonly cexStorage: FakeCexStorage; + readonly polymarketSource: FakePolymarketSource; + readonly cexSources: FakeCexSource[]; + readonly discovery: FakeDiscovery; + readonly coordinator: FakeCoordinator; + readonly finalizer: FakeFinalizer; + readonly components: DataCollectorComponents; +} + +/** + * Собирает полный fake-контур. + * + * @param exchangeIds - Биржи, для которых создаются fake CEX-source-ы + * @returns Все fakes + собранные `DataCollectorComponents` + */ +export function makeFakeContour(exchangeIds: readonly string[] = ['binance', 'okx']): FakeContour { + const log = new CallLog(); + const logger = new CapturingLogger(); + const clock = new FakeClock(); + const bus = new FakeBus(log); + const recorder = new FakeRecorder(log); + const polymarketStorage = new FakePolymarketStorage(log); + const cexStorage = new FakeCexStorage(log); + const polymarketSource = new FakePolymarketSource(log); + const cexSources = exchangeIds.map((exchangeId) => new FakeCexSource(log, exchangeId)); + const discovery = new FakeDiscovery(); + const coordinator = new FakeCoordinator(log); + const finalizer = new FakeFinalizer(log); + + const entries: CollectorCexSourceEntry[] = cexSources.map((source) => ({ + exchangeId: source.exchangeId, + source, + })); + + return { + log, + logger, + clock, + bus, + recorder, + polymarketStorage, + cexStorage, + polymarketSource, + cexSources, + discovery, + coordinator, + finalizer, + components: { + bus, + recorder, + polymarketStorage, + cexStorage, + polymarketSource, + cexSources: entries, + discovery, + coordinator, + finalizer, + }, + }; +} diff --git a/apps/collect-data/__tests__/helpers/recordingFakes.ts b/apps/collect-data/__tests__/helpers/recordingFakes.ts new file mode 100644 index 00000000..76356a5c --- /dev/null +++ b/apps/collect-data/__tests__/helpers/recordingFakes.ts @@ -0,0 +1,107 @@ +/** + * Fakes границы storage для тестов raw-контура. + * + * @remarks + * Узкие структурные реализации портов `ExternalMessageRecorder`: фиксируют + * записи, не трогая диск. Настоящая persistence проверяется тестами + * `@polymarket/data-collection` и `@polymarket/external-message-recorder`; + * здесь нужен только факт доставки сообщения в recorder. + */ +import type { MarketId } from '@polymarket/ids'; +import type { MarketMeta } from '@polymarket/ports'; +import type { CexWindowRecordOutcome, RecordOutcome } from '@polymarket/data-collection'; +import type { + CexRecordingStorage, + PolymarketRecordingStorage, +} from '@polymarket/external-message-recorder'; + +/** Захваченная запись в оконный storage. */ +export interface CexWindowWrite { + readonly exchangeId: string; + readonly symbol: string; + readonly marketType: string; + readonly stream: string; + readonly payload: unknown; +} + +/** Fake оконного CEX-storage. */ +export class FakeCexWindowStorage implements CexRecordingStorage { + public readonly writes: CexWindowWrite[] = []; + public startCalls = 0; + public closeCalls = 0; + + public start(): void { + this.startCalls++; + } + + public write( + exchangeId: string, + symbol: string, + marketType: string, + stream: 'orderbook' | 'trades', + payload: unknown, + ): CexWindowRecordOutcome { + this.writes.push({ exchangeId, symbol, marketType, stream, payload }); + return 'recorded'; + } + + public async flush(): Promise { + // Буферов нет — сброс не нужен. + } + + public async close(): Promise { + this.closeCalls++; + } +} + +/** Захваченная запись Polymarket-сессии. */ +export interface PolymarketWrite { + readonly marketId: MarketId; + readonly payload: unknown; +} + +/** Fake storage market-сессий. */ +export class FakePolymarketRecordingStorage implements PolymarketRecordingStorage { + public readonly registered: MarketMeta[] = []; + public readonly writes: PolymarketWrite[] = []; + public closeCalls = 0; + + public registerMarket(meta: MarketMeta): boolean { + this.registered.push(meta); + return true; + } + + public recordMarketEvent(marketId: MarketId, rawEvent: unknown): RecordOutcome { + this.writes.push({ marketId, payload: rawEvent }); + return 'recorded'; + } + + public async sealMarket(): Promise { + return true; + } + + public async updateMarketMeta(): Promise { + // Header в этих тестах не проверяется. + return true; + } + + public async readSealedPayloadLines(): Promise { + return undefined; + } + + public async finalizeMarket(): Promise { + // Архивация в этих тестах не проверяется. + } + + public async flush(): Promise { + // Буферов нет. + } + + public async cleanup(): Promise { + // Диска нет — чистить нечего. + } + + public async close(): Promise { + this.closeCalls++; + } +} diff --git a/apps/collect-data/docs/collect-data.md b/apps/collect-data/docs/collect-data.md index d48959d8..84e94d62 100644 --- a/apps/collect-data/docs/collect-data.md +++ b/apps/collect-data/docs/collect-data.md @@ -2,19 +2,25 @@ ## Обзор -Автономный сборщик рыночных данных Polymarket без торговой логики — подписывается на WS- -события выбранных рынков и пишет NDJSON-снапшоты на диск (для последующего реплея через -`@polymarket/backtesting`) — плюс набор CLI-инструментов анализа собранных CEX/Chainlink- -данных, используемых при калибровке crypto-сигналов стратегий. - -| Файл | Назначение | -|---|---| -| `main.ts` | Основной data-collector: дискавери рынков → WS-подписка → `DataRecorder` на диск | -| `config.ts` | `CollectorConfig` — загрузка настроек из `.env`, throw при отсутствии обязательной переменной | -| `analyzeChainlinkLeadLag.ts` | CLI: корреляционный анализ lead-lag CEX microprice vs Chainlink-цена BTC | -| `fitChainlinkLinearModels.ts` | CLI: Ridge-регрессия по тем же данным — веса бирж, горизонт предсказания | -| `checkCexSnapshot.ts` | CLI: валидация качества CEX JSONL.GZ снапшотов (gaps, crossed book, покрытие) | -| `backfillPolymarketMeta.ts` | CLI: перезаписывает meta-строку уже архивных снапшотов задним числом | +Автономный сборщик сырых рыночных данных без торговой логики: подписывается на +Polymarket (market-события + RTDS-цены) и публичные потоки CEX, публикует их в общий +`ExternalMessageBus` и пишет на диск payload-строки для последующего реплея через +`@polymarket/backtesting`. Плюс набор CLI-инструментов анализа собранных +CEX/Chainlink-данных, используемых при калибровке crypto-сигналов стратегий. + +| Файл | Назначение | +| ----------------------------- | ----------------------------------------------------------------- | +| `main.ts` | Тонкий bootstrap: конфиг → логгер → process-подготовка → рантайм | +| `config.ts` | `CollectorConfig` — загрузка настроек из `.env` | +| `runtime/DataCollector.ts` | Рантайм: composition, lifecycle источников, расписание, статус | +| `runtime/createDataCollector.ts` | Composition root контура (bus, recorder, sources, control plane) | +| `runtime/DataCollectorConfig.ts` | Конфигурация рантайма + граница «внешний конфиг → V2» | +| `runtime/collectionLifecycle.ts` | Read-only проекция lifecycle рынков | +| `runtime/processBootstrap.ts` | DNS-обход и единый охраняемый путь остановки | +| `analyzeChainlinkLeadLag.ts` | CLI: корреляционный анализ lead-lag CEX microprice vs Chainlink | +| `fitChainlinkLinearModels.ts` | CLI: Ridge-регрессия по тем же данным — веса бирж, горизонт | +| `checkCexSnapshot.ts` | CLI: валидация качества CEX JSONL.GZ снапшотов | +| `backfillPolymarketMeta.ts` | CLI: перезапись meta-строки уже архивных снапшотов задним числом | ```bash # Dev (hot-reload): @@ -24,38 +30,140 @@ npm run dev -w @polymarket/collect-data npm run build -w @polymarket/collect-data && npm start -w @polymarket/collect-data ``` -## `main.ts` — алгоритм сбора +## Контур сбора + +```text +PolymarketSource ─┐ +(market + RTDS) │ + ├──► ОДИН ExternalMessageBus ──┬──► ExternalMessageRecorder ──► JSONL +CexSource[] ──────┘ │ (PM-политика + CEX-политика) +(orderbook+trades) └──► любой другой consumer +``` + +Два жёстких инварианта процесса: **один bus** и **один recorder**. Отдельных +Polymarket/CEX/RTDS-шин не существует; под единственным recorder живут две +storage-политики — market-сессии и CEX-окна. + +Весь ingress идёт только `source → bus`. Recorder — обычный подписчик bus, а не +привилегированная цель прямых вызовов source-ов; на диск попадает исключительно +source-native `message.payload` без canonical runtime-metadata. + +Управление сбором: + +```text +PolymarketMarketDiscovery → MarketCollectionCoordinator → MarketFinalizer + «что доступно» «что записываем сейчас» «как закрываем» +``` + +`DataCollector` оркестрирует их тиком (`fillSlots` + `runOnce`), но собственного +рыночного состояния не держит. + +## Lifecycle рантайма + +### Старт + +```text +startup cleanup → recorder.start() → CEX sources → runtime loop +``` + +Recorder подписывается на bus ДО старта ingress — иначе первые сообщения ушли бы в +bus, не попав на диск. Отказ любого шага откатывает уже поднятые ресурсы в обратном +порядке и отклоняет `start()`: контур либо работает целиком, либо не оставляет за +собой ни открытых потоков, ни таймеров. + +### Остановка + +```text +runtime loop → finalizer → coordinator → PM source → CEX sources + → bus.drain() → recorder.close() → bus.close() +``` + +Сначала глохнет ingress, затем очередь bus дренируется В recorder, и только потом +закрывается сам recorder. `close()` идемпотентен и best-effort: отказ одного шага +логируется и не отменяет остальные. `process.exit()` не используется — процесс +обязан завершиться сам, иначе живой хэндл остался бы незамеченным. + +`SIGINT`, `SIGTERM` и фатальная ошибка сходятся в ОДИН охраняемый путь остановки: +повторный сигнал не запускает вторую параллельную остановку. + +### Опциональный drain + +`collector.drain()` дожидается официальных резолюций уже начатых финализаций. Штатная +остановка по сигналу его НЕ вызывает: ожидание измеряется десятками минут и не +укладывается в `kill_timeout` супервизора. Вызов уместен для контролируемых прогонов +(например, verification-runner checkpoint-а). + +## Наблюдаемость + +```typescript +const off = collector.onMarketLifecycle((event) => { + if (event.kind === 'FINALIZED') archived.push(String(event.marketId)); +}); +``` + +События: `DISCOVERED`, `COLLECTION_STARTED`, `FINALIZING`, `FINALIZED`, `DROPPED`. +Это operational-состояние сбора, а не domain-события и не trading lifecycle. + +`collector.status()` отдаёт снимок: состояние рантайма и время работы, сессии +координатора, финализации, статистику recorder/CEX-окон/bus и здоровье каждого +source поимённо. Все значения — существующие `getStats()` компонентов; собственных +метрик рантайм не заводит. `main.ts` печатает этот снимок раз в минуту вместе с +потреблением памяти. + +## Раскладка датасетов + +```text +{DATA_COLLECTION_OUTPUT_DIR}/ + {YYYY-MM-DD}/ + polymarket/{question}___{marketId}.jsonl[.gz] + {exchangeId}/{exchange}_{symbol}_{marketType}_{stream}_{dateET}_{startET}-{endET}_ET.jsonl[.gz] +``` + +`.jsonl` = незавершённый файл, `.jsonl.gz` = завершённый архив. Незавершённые остатки +предыдущего запуска удаляются при старте: восстановлению они не подлежат. + +Polymarket-архивы пишутся с `formatVersion: 2` — строки 2+ содержат source-native +события официального SDK. Первая строка (meta-header) определяет, каким парсером +читать остальные. + +## Конфигурация + +Настройки берутся из `.env` (см. файл в корне приложения). Ключевые: -1. Загрузить `.env` → `CollectorConfig`. -2. Инициализировать `DataRecorder` (`@polymarket/data-collection`), WS-адаптер, discovery - (`PolymarketMarketDiscoveryAdapter`). -3. Начальный discovery → зарегистрировать найденные рынки + подписаться на WS. -4. Периодическое пересканирование (`MARKET_SCAN_PAUSE_MS`) — находит новые рынки, регистрирует. -5. Периодическая проверка истечений (60с) — финализирует истёкшие рынки, отписывается от WS. -6. Graceful shutdown (SIGINT/SIGTERM) — останавливает feed, закрывает recorder, отключает WS. +| Переменная | Назначение | +| -------------------------------- | ------------------------------------------------- | +| `DATA_COLLECTION_OUTPUT_DIR` | Корень датасетов (общий для обеих политик) | +| `DATA_COLLECTION_MAX_MARKETS` | Максимум одновременных collection-сессий | +| `DATA_COLLECTION_COMPRESSION` | `none` \| `gzip` | +| `MARKET_SCAN_PAUSE_MS` | Период обновления кэша кандидатов | +| `MARKET_DISCOVERY_*` | Фильтр кандидатов (ключевые слова, пороги) | +| `CEX_CONFIG_FILE` / `CEX_CONFIG` | Конфигурация бирж; не задана — CEX выключен | +| `CEX_WINDOW_MINUTES` | Размер окна партиции (по умолчанию — 5) | +| `DNS_OVERRIDE_ENABLED` | Обход подменённого DNS провайдера | -Фильтрация кандидатов рынков — `MarketFilter`+`MarketScorer` из `@polymarket/market-discovery` -(та же логика, что использует `apps/bot` для выбора рынков к торговле — здесь без -последующей торговли, только для решения, какие WS-потоки писать на диск). +Формат `cex-config.json` сохранён от прежнего коллектора (ключ словаря = +`exchangeId`); конверсия имён полей в словарь `@polymarket/cex-v2` выполняется на +границе в `runtime/DataCollectorConfig.ts`. Невалидная CEX-конфигурация не даёт +процессу стартовать — прежний коллектор в этом случае молча продолжал БЕЗ CEX, и +прогон выглядел живым, хотя половина датасета не писалась. ## CLI-инструменты калибровки crypto-сигналов -`analyzeChainlinkLeadLag.ts`/`fitChainlinkLinearModels.ts` — оффлайн-анализ записанных CEX+ -Chainlink снапшотов для калибровки сигнала `cex_chainlink_lead_lag` в `CryptoSignalRegistry` -(`@polymarket/market-state`): первый оценивает предсказательную силу microprice отдельных -бирж простой корреляцией, второй строит Ridge-регрессионные модели (одно-venue/комбо-venue/ -aggregate) поверх тех же входных окон — получают веса бирж для weighted microprice и -устойчивость коэффициентов по времени (out-of-sample). Оба — оффлайн research-инструменты, -результат калибровки переносится в конфигурацию стратегии вручную, не автоматически. +`analyzeChainlinkLeadLag.ts`/`fitChainlinkLinearModels.ts` — оффлайн-анализ записанных +CEX+Chainlink снапшотов для калибровки сигнала `cex_chainlink_lead_lag` в +`CryptoSignalRegistry` (`@polymarket/market-state`): первый оценивает предсказательную +силу microprice отдельных бирж корреляцией, второй строит Ridge-регрессионные модели +поверх тех же входных окон. Результат калибровки переносится в конфигурацию стратегии +вручную. -`checkCexSnapshot.ts` — предварительная диагностика перед прогоном анализа: невалидный -JSON/отсутствующие поля, crossed orderbook, нарушение сортировки уровней, non-monotonic -timestamp, покрытие символа сделками (zero-trade файлы). +`checkCexSnapshot.ts` — предварительная диагностика перед прогоном анализа: +невалидный JSON, crossed orderbook, нарушение сортировки уровней, non-monotonic +timestamp, покрытие символа сделками. ## Ссылки -- `@polymarket/data-collection` (docs/data-collection.md) — запись снапшотов на диск -- `@polymarket/cex-market-data` (docs/cex-market-data.md) — источник CEX-данных -- `@polymarket/market-discovery` (docs/market-discovery.md) — `MarketFilter`/`MarketScorer` +- Cutover-обоснование: `docs/guides/data-collector-v2-cutover.md` +- CHECKPOINT #1: `docs/guides/checkpoint-1-raw-live.md` +- `@polymarket/data-collection`: `docs/architecture/data-collection.md` +- `@polymarket/market-discovery`: `docs/guides/multi-market.md` - ADR: `docs/architecture/boundary-contract.md` -- План миграции, Этап 11: `/Users/menvil/.claude/plans/synthetic-swimming-heron.md` diff --git a/apps/collect-data/jest.config.js b/apps/collect-data/jest.config.js new file mode 100644 index 00000000..a7683e2c --- /dev/null +++ b/apps/collect-data/jest.config.js @@ -0,0 +1,50 @@ +/** + * Jest-конфигурация приложения коллектора. + * + * @remarks + * Пакеты контура импортируются по именам пакетов (публичный API), поэтому + * имена маппятся на ИСХОДНИКИ — тесты не зависят от собранного `dist`. + */ +/** @type {import('jest').Config} */ +export default { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + moduleFileExtensions: ['ts', 'js', 'json'], + testMatch: ['**/__tests__/**/*.test.ts'], + transform: { + '^.+\\.ts$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.json' }], + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + '^@polymarket/logger$': '/../../packages/foundation/logger/src/index.ts', + '^@polymarket/ids$': '/../../packages/foundation/ids/src/index.ts', + '^@polymarket/result$': '/../../packages/foundation/result/src/index.ts', + '^@polymarket/errors$': '/../../packages/foundation/errors/src/index.ts', + '^@polymarket/errors/(.*)$': '/../../packages/foundation/errors/src/$1', + '^@polymarket/math$': '/../../packages/foundation/math/src/index.ts', + '^@polymarket/time$': '/../../packages/foundation/time/src/index.ts', + '^@polymarket/timestamp$': '/../../packages/foundation/timestamp/src/index.ts', + '^@polymarket/messages$': '/../../packages/foundation/messages/src/index.ts', + '^@polymarket/message-bus$': '/../../packages/foundation/message-bus/src/index.ts', + '^@polymarket/value-objects$': '/../../packages/domain/value-objects/src/index.ts', + '^@polymarket/ports$': '/../../packages/application/ports/src/index.ts', + '^@polymarket/market-discovery$': + '/../../packages/application/market-discovery/src/index.ts', + '^@polymarket/data-collection$': + '/../../packages/infrastructure/persistence/data-collection/src/index.ts', + '^@polymarket/external-messages$': + '/../../packages/infrastructure/external-messages/src/index.ts', + '^@polymarket/external-message-bus$': + '/../../packages/infrastructure/external-message-bus/src/index.ts', + '^@polymarket/external-message-recorder$': + '/../../packages/infrastructure/persistence/external-message-recorder/src/index.ts', + '^@polymarket/polymarket-v2$': + '/../../packages/infrastructure/polymarket-v2/src/index.ts', + '^@polymarket/collection-coordinator$': + '/../../packages/infrastructure/collection-coordinator/src/index.ts', + '^@polymarket/market-finalizer$': + '/../../packages/infrastructure/market-finalizer/src/index.ts', + '^@polymarket/cex-v2$': '/../../packages/infrastructure/cex-v2/src/index.ts', + }, +}; diff --git a/apps/collect-data/package.json b/apps/collect-data/package.json index 23a19e63..5533c405 100644 --- a/apps/collect-data/package.json +++ b/apps/collect-data/package.json @@ -13,18 +13,44 @@ "check:snapshot": "node --disable-warning=ExperimentalWarning src/checkCexSnapshot.ts", "backfill:polymarket": "node --import tsx --env-file=.env src/backfillPolymarketMeta.ts", "analyze:chainlink": "node --disable-warning=ExperimentalWarning src/analyzeChainlinkLeadLag.ts", - "fit:chainlink": "node --disable-warning=ExperimentalWarning src/fitChainlinkLinearModels.ts" + "fit:chainlink": "node --disable-warning=ExperimentalWarning src/fitChainlinkLinearModels.ts", + "test": "jest" }, "dependencies": { - "@polymarket/cex-market-data": "0.1.0", + "@polymarket/cex-v2": "0.1.0", + "@polymarket/client": "^0.6.0", + "@polymarket/collection-coordinator": "0.1.0", "@polymarket/data-collection": "0.1.0", "@polymarket/exchange": "0.1.0", + "@polymarket/external-message-bus": "0.1.0", + "@polymarket/external-message-recorder": "0.1.0", "@polymarket/ids": "0.1.0", "@polymarket/logger": "0.1.0", "@polymarket/market-discovery": "0.1.0", + "@polymarket/market-finalizer": "0.1.0", + "@polymarket/message-bus": "0.1.0", + "@polymarket/messages": "0.1.0", + "@polymarket/polymarket-v2": "0.1.0", "@polymarket/ports": "0.1.0", - "@polymarket/timestamp": "0.1.0", - "@polymarket/value-objects": "0.1.0" + "@polymarket/result": "^0.1.0", + "@polymarket/time": "0.1.0" }, - "devDependencies": {} + "devDependencies": { + "@jest/globals": "^29.7.0", + "@types/jest": "^29.5.0", + "@types/node": "^26.1.1", + "jest": "^29.7.0", + "ts-jest": "^29.1.0", + "typescript": "^5.3.0" + }, + "exports": { + ".": { + "types": "./dist/main.d.ts", + "import": "./dist/main.js" + }, + "./runtime": { + "types": "./dist/runtime/index.d.ts", + "import": "./dist/runtime/index.js" + } + } } diff --git a/apps/collect-data/src/config.ts b/apps/collect-data/src/config.ts index ecb22183..fc5b874f 100644 --- a/apps/collect-data/src/config.ts +++ b/apps/collect-data/src/config.ts @@ -82,6 +82,18 @@ export interface CollectorConfig { * @example `'{"binance":{"type":"spot","symbols":["BTC/USDT"],"orderbook":true,"trades":true}}'` */ readonly cexConfig: string | null; + + /** + * Размер окна CEX-партиции (минуты). + * Не задан — дефолт `CexWindowRecorder` (5 минут). + */ + readonly cexWindowMinutes: number | undefined; + + /** Записей в буфере CEX-окна до сброса на диск. */ + readonly cexBufferSize: number; + + /** Интервал периодического сброса буферов CEX-окон (мс). */ + readonly cexFlushIntervalMs: number; } /** @@ -109,6 +121,22 @@ export function loadConfig(): CollectorConfig { return n; } + /** + * Читает необязательное число без значения по умолчанию. + * + * @remarks + * Отличается от `optionalNumber` тем, что «не задано» остаётся `undefined` + * и дефолт применяет сам компонент — конфигурация приложения не обязана + * дублировать его константы. + */ + function optionalNumberOrUndefined(name: string): number | undefined { + const val = process.env[name]; + if (!val) return undefined; + const n = Number(val); + if (isNaN(n)) throw new Error(`Env var ${name} must be a number, got: ${val}`); + return n; + } + function parseKeywords(name: string): readonly string[] { const val = process.env[name]; if (!val || val.trim() === '') return []; @@ -158,5 +186,10 @@ export function loadConfig(): CollectorConfig { compression: parseCompression('DATA_COLLECTION_COMPRESSION'), bufferSize: optionalNumber('DATA_COLLECTION_BUFFER_SIZE', 100), flushIntervalMs: optionalNumber('DATA_COLLECTION_FLUSH_INTERVAL_MS', 10_000), + // Дефолты CEX-окон совпадают с дефолтами CexWindowRecorder: legacy-коллектор + // не передавал эти параметры вовсе, и поведение записи не меняется. + cexWindowMinutes: optionalNumberOrUndefined('CEX_WINDOW_MINUTES'), + cexBufferSize: optionalNumber('CEX_BUFFER_SIZE', 200), + cexFlushIntervalMs: optionalNumber('CEX_FLUSH_INTERVAL_MS', 5_000), }; } diff --git a/apps/collect-data/src/main.ts b/apps/collect-data/src/main.ts index 54165624..0aae7f33 100644 --- a/apps/collect-data/src/main.ts +++ b/apps/collect-data/src/main.ts @@ -1,907 +1,115 @@ /** - * Скрипт сбора рыночных данных Polymarket. + * Точка входа production-коллектора рыночных данных. * * @remarks - * Автономный data-collector без торговой логики. - * Подписывается на WS-события выбранных рынков и пишет снапшоты на диск. + * Тонкий bootstrap: загрузить конфигурацию → создать логгер/часы → + * применить process-требования → собрать контур → установить обработку + * сигналов → запустить. Ни рыночного состояния, ни WS-обработчиков, ни + * очередей обогащения, ни ротации файлов здесь нет — всем этим владеют + * компоненты контура (см. `runtime/`). * - * ### Алгоритм: - * 1. Загрузить конфигурацию из .env - * 2. Инициализировать DataRecorder, WS-адаптер, PolymarketMarketDiscoveryAdapter - * 3. Начальный дискавери → зарегистрировать рынки + подписаться на WS - * 4. Цикл пересканирования каждые `MARKET_SCAN_PAUSE_MS`: - * - Найти новые рынки → зарегистрировать + подписаться - * 5. Цикл проверки истечений каждые 60 сек: - * - Финализировать истёкшие рынки → отписаться от WS - * 6. Graceful shutdown (SIGINT / SIGTERM): - * - Остановить feed → закрыть recorder → отключить WS + * ```text + * loadConfig → logger/clock → processBootstrap → createDataCollector + * → installShutdownHandlers → collector.start() + * ``` + * + * ### Запуск * - * ### Запуск: * ```bash - * # Dev (с hot-reload): + * # Dev (hot-reload): * npm run dev * * # Production (после npm run build): * npm start * ``` */ - -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -- внутренняя Decimal-арифметика/парсинг границы после VO-типизированного публичного API, см. docs/architecture/boundary-contract.md, Решение 1 -import Decimal from 'decimal.js'; import { ColorConsoleLogger, LogLevel } from '@polymarket/logger'; import { LiveClock } from '@polymarket/time'; -import { MarketFilter, MarketScorer } from '@polymarket/market-discovery'; -import { DataRecorder, NDJSONFormatter, GzipCompressor } from '@polymarket/data-collection'; -import { PolymarketWebSocketManager } from '@polymarket/exchange/ws'; -import { PolymarketWsAdapter } from '@polymarket/exchange/ws'; -import { MarketDataFeedAdapter, PolymarketMarketDiscoveryAdapter, parseCryptoMeta } from '@polymarket/exchange/adapters'; -import { PolymarketMarketDataRestClient } from '@polymarket/exchange/rest'; -import { DnsOverride } from '@polymarket/exchange/dns'; -import { RtdsWebSocketClient } from '@polymarket/exchange/ws'; -import type { DiscoveredMarket } from '@polymarket/ports'; -import { asInstrumentId } from '@polymarket/ids'; -import type { MarketId } from '@polymarket/ids'; -import { Timestamp } from '@polymarket/timestamp'; import { loadConfig } from './config.js'; -import { CexCollectorService } from '@polymarket/cex-market-data'; -import type { CexCollectorConfig } from '@polymarket/cex-market-data'; - -// ─── Запуск ────────────────────────────────────────────────────────────────── - -const config = loadConfig(); - -const logLevelMap: Record = { +import { + applyProcessBootstrap, + createDataCollector, + installShutdownHandlers, + toDataCollectorConfig, +} from './runtime/index.js'; + +const LOG_LEVELS: Record = { TRACE: LogLevel.TRACE, DEBUG: LogLevel.DEBUG, - INFO: LogLevel.INFO, - WARN: LogLevel.WARN, + INFO: LogLevel.INFO, + WARN: LogLevel.WARN, ERROR: LogLevel.ERROR, FATAL: LogLevel.FATAL, }; -const logLevel = logLevelMap[process.env['LOG_LEVEL'] ?? 'INFO'] ?? LogLevel.INFO; -const clock = new LiveClock(); -const logger = new ColorConsoleLogger(clock, logLevel); - -logger.info('Starting Polymarket data collector', { - outputDir: config.outputDir, - maxMarkets: config.maxMarkets, - compression: config.compression, - wsUrl: config.wsUrl, - cexEnabled: config.cexConfig !== null, -}); - -// ─── CEX коллектор (опционально) ──────────────────────────────────────────── -let cexService: CexCollectorService | null = null; -if (config.cexConfig) { - try { - const exchanges = JSON.parse(config.cexConfig) as CexCollectorConfig['exchanges']; - const cexCollectorConfig: CexCollectorConfig = { - exchanges, - outputDir: config.outputDir, - compression: config.compression, - }; - cexService = new CexCollectorService(cexCollectorConfig, logger); - logger.info('CEX collector configured', { exchanges: Object.keys(exchanges) }); - // Чистим артефакты от предыдущего краш-запуска до старта - await cexService.cleanup(); - // Запускаем сразу — CEX независим от Polymarket инициализации - cexService.start(); - } catch (err) { - logger.error('Failed to parse CEX_CONFIG — CEX collector disabled', { - err: err instanceof Error ? err.message : String(err), - }); - } -} - -// ─── DNS Override ───────────────────────────────────────────────────────────── -// Опционально: патчим dns.lookup ДО любых сетевых вызовов. -// Включается через DNS_OVERRIDE_ENABLED=true в .env (для машин с заблокированным DNS). -// На серверах с нормальным DNS оставляем false — никаких изменений в поведении. -const dnsOverride = new DnsOverride(logger); -if (config.dnsOverrideEnabled) { - try { - await dnsOverride.install([ - 'gamma-api.polymarket.com', - 'clob.polymarket.com', - 'data-api.polymarket.com', - 'ws-subscriptions-clob.polymarket.com', - 'ws-live-data.polymarket.com', - ]); - } catch (err) { - logger.warn('DNS override install failed, continuing with system DNS', { - err: err instanceof Error ? err : new Error(String(err)), - }); - } -} else { - logger.debug('DNS override disabled (DNS_OVERRIDE_ENABLED != true)'); -} - -// ─── Зависимости ────────────────────────────────────────────────────────────── - -// DataRecorder: пишет сырые WS-события в NDJSON-файлы -const recorder = new DataRecorder( - { - outputDir: config.outputDir, - sourceSubDir: config.sourceSubDir, - bufferSize: config.bufferSize, - flushIntervalMs: config.flushIntervalMs, - compression: config.compression, - }, - new NDJSONFormatter(), - config.compression === 'gzip' ? new GzipCompressor() : null, - logger, -); - -// WS-транспорт -const wsManager = new PolymarketWebSocketManager( - { url: config.wsUrl, reconnectDelay: config.wsReconnectDelayMs }, - logger, +const config = loadConfig(); +const clock = new LiveClock(); +const logger = new ColorConsoleLogger( + clock, + LOG_LEVELS[process.env['LOG_LEVEL'] ?? 'INFO'] ?? LogLevel.INFO, ); -const ws = new PolymarketWsAdapter(wsManager, logger); -// Market feed: bookHandler = null (только WS lifecycle — reconnect, etc.) -const feed = new MarketDataFeedAdapter(ws, null, logger); +const runtimeConfig = toDataCollectorConfig(config); -// Записываем сырые WS-сообщения в оригинальном wire-формате (до DTO-маппинга) -ws.onRawMessage((tokenId, rawMsg) => { - const instrumentId = asInstrumentId(tokenId); - if (instrumentId === undefined) { - logger.debug('recordEvent: invalid tokenId, skipping', { tokenId }); - return; - } - recorder.recordEvent(instrumentId, rawMsg); +logger.info('Starting Polymarket data collector', { + outputDir: runtimeConfig.outputDir, + sourceSubDir: runtimeConfig.polymarket.sourceSubDir, + compression: runtimeConfig.polymarket.compression, + maxMarkets: runtimeConfig.collection.maxMarkets, + discoveryRefreshMs: runtimeConfig.collection.discoveryRefreshMs, + cexExchanges: runtimeConfig.cex.sources.map((source) => source.exchangeId), }); -// Market discovery: Gamma API → фильтрация → скоринг -const marketDataClient = new PolymarketMarketDataRestClient( - { baseUrl: config.gammaApiBaseUrl }, - logger, -); - -const filterConfig = { - minTimeToExpiryHours: config.minTimeToExpiryHours, - minSpread: config.minSpread, - minLiquidity: config.minLiquidity, - maxMarketsToReturn: config.maxMarkets, - requiredKeywords: config.requiredKeywords, - anyOfKeywords: config.anyOfKeywords, - excludedKeywords: config.excludedKeywords, -}; - -const discovery = new PolymarketMarketDiscoveryAdapter( - marketDataClient, - new MarketFilter(), - new MarketScorer(clock), - filterConfig, +const bootstrap = await applyProcessBootstrap({ + dnsOverrideEnabled: config.dnsOverrideEnabled, logger, -); - -// ─── RTDS (реал-тайм крипто-цены) ───────────────────────────────────────────── - -const rtdsClient = new RtdsWebSocketClient( - { url: 'wss://ws-live-data.polymarket.com' }, - logger, -); - -/** - * RTDS symbol → Set для маршрутизации записи цен. - * - * @remarks - * Set, а не одиночный tokenId: несколько рынков могут следить за одним символом - * одновременно (BTC 430-445 и BTC 435-440). Каждый получает свою копию crypto_price. - */ -const symbolToTokenIds = new Map>(); - -// RTDS → recorder wiring: записываем крипто-цены в тот же .jsonl файл -// source определяем по формату символа: 'btc/usd' → chainlink, 'btcusdt' → binance -rtdsClient.onPrice((symbol, price, ts) => { - const tokenIds = symbolToTokenIds.get(symbol); - if (!tokenIds || tokenIds.size === 0) return; - const source = symbol.includes('/') ? 'chainlink' : 'binance'; - for (const tokenId of tokenIds) { - const instrumentId = asInstrumentId(tokenId); - if (instrumentId === undefined) { - logger.debug('recordEvent: invalid tokenId, skipping', { tokenId }); - continue; - } - recorder.recordEvent(instrumentId, { t: 'crypto_price', symbol, price, ts, source }); - } -}); - -// ─── Состояние ──────────────────────────────────────────────────────────────── - -/** tokenId → DiscoveredMarket для O(1) проверки подписан ли уже */ -const subscribedTokens = new Map(); -/** marketId → DiscoveredMarket для проверки истечений */ -const subscribedMarkets = new Map(); -/** - * Рынки, постоянно закрытые как EXPIRED в рамках этой сессии. - * Предотвращает повторную регистрацию рынков которые Gamma API - * продолжает возвращать active=true даже после истечения. - */ -/** marketId → timestamp закрытия (ms). Очищается периодически — записи старше 24ч удаляются. */ -const closedMarkets = new Map(); -const CLOSED_MARKETS_TTL_MS = 24 * 60 * 60_000; // 24 часа - -/** - * Очередь отложенного обогащения meta: рынки ожидающие priceToBeat из API. - * - * @remarks - * При EXPIRED крипто-рынок добавляется сюда вместо немедленного finalize. - * Background таймер каждые 30 сек проверяет API — как только priceToBeat - * появятся оба, обновляет meta и финализирует. По таймауту — финализируем - * с тем что есть (бэктест fallback на Binance kline для недостающих). - * - * На каждом retry обновляем meta если появились новые данные. - * При shutdown — финализируем pending как EXPIRED (архивируем с данными). - */ -interface PendingEnrichment { - readonly marketId: MarketId; - readonly gammaMarketId: string; - readonly symbol: string; - readonly question: string; - readonly startedAt: number; - attempts: number; - /** Последнее известное состояние — чтобы обновлять meta только при изменениях */ - lastPriceToBeat: number | undefined; - lastFinalPrice: number | undefined; -} -const pendingEnrichment = new Map(); -let enrichmentRun: Promise | null = null; - -const ENRICHMENT_MAX_WAIT_MS = 15 * 60_000; // 15 минут -const ENRICHMENT_INTERVAL_MS = 30_000; // проверяем каждые 30 сек - -/** - * Проверяет pending enrichment очередь: re-fetch API, обновляет meta, финализирует. - * - * @remarks - * Вызывается по таймеру каждые 30 сек. Для каждого pending рынка: - * 1. Re-fetch Gamma API по slug - * 2. Всегда перезаписываем первую строку файла свежими данными API - * 3. Если ОБА поля (priceToBeat + finalPrice) есть → finalizeMarket (архив) - * 4. Если timeout → finalizeMarket с тем что есть (бэктест fallback) - */ -async function processEnrichmentQueue(): Promise { - if (isShuttingDown) return; - if (pendingEnrichment.size === 0) return; - - for (const [marketKey, pe] of [...pendingEnrichment]) { - if (isShuttingDown) return; - pe.attempts++; - const elapsed = Date.now() - pe.startedAt; - - try { - const updatedMarket = await marketDataClient.getMarketInfoById(pe.gammaMarketId); - if (isShuttingDown) return; - const updatedCrypto = parseCryptoMeta(updatedMarket as unknown as Record); - - // Всегда перезаписываем meta свежими данными API - await recorder.updateMarketMeta( - pe.marketId, - updatedMarket as unknown as Record, - ); - - pe.lastPriceToBeat = updatedCrypto?.priceToBeat; - pe.lastFinalPrice = updatedCrypto?.finalPrice; - - logger.debug('Enrichment meta updated', { - symbol: pe.symbol, - priceToBeat: pe.lastPriceToBeat, - finalPrice: pe.lastFinalPrice, - attempts: pe.attempts, - }); - - // Оба поля есть — финализируем (архивируем) - if (pe.lastPriceToBeat !== undefined && pe.lastFinalPrice !== undefined) { - await recorder.finalizeMarket(pe.marketId, 'EXPIRED'); - pendingEnrichment.delete(marketKey); - logger.info('Market fully enriched and finalized', { - question: pe.question, - symbol: pe.symbol, - priceToBeat: pe.lastPriceToBeat, - finalPrice: pe.lastFinalPrice, - attempts: pe.attempts, - elapsedMs: elapsed, - }); - continue; - } - } catch (err) { - logger.debug('Enrichment re-fetch failed (will retry)', { - gammaMarketId: pe.gammaMarketId, - attempt: pe.attempts, - err: err instanceof Error ? err.message : String(err), - }); - } - - // Timeout — финализируем с тем что есть - if (elapsed >= ENRICHMENT_MAX_WAIT_MS) { - await recorder.finalizeMarket(pe.marketId, 'EXPIRED'); - pendingEnrichment.delete(marketKey); - logger.warn('Market finalized on timeout (incomplete enrichment)', { - question: pe.question, - symbol: pe.symbol, - priceToBeat: pe.lastPriceToBeat, - finalPrice: pe.lastFinalPrice, - attempts: pe.attempts, - elapsedMs: elapsed, - }); - } - } -} - -function runEnrichmentQueueOnce(): void { - if (enrichmentRun) return; - enrichmentRun = processEnrichmentQueue() - .catch((err) => { - logger.warn('Enrichment queue pass failed', { - err: err instanceof Error ? err.message : String(err), - }); - }) - .finally(() => { - enrichmentRun = null; - }); -} - -async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { - return Promise.race([ - promise, - new Promise((_, reject) => { - setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); - }), - ]); -} - -// ─── Хелперы ────────────────────────────────────────────────────────────────── - -/** - * Регистрирует новый рынок: recorder + WS subscription. - * - * @param candidate - Кандидат из PolymarketMarketDiscoveryAdapter - * - * @remarks - * Пропускает рынки, которые: - * - уже подписаны (`subscribedTokens`) - * - закрыты как EXPIRED в этой сессии (`closedMarkets`) - * - уже истекли по `expiresAt` (Gamma API может возвращать active=true для истёкших рынков) - */ -async function openMarket(candidate: DiscoveredMarket): Promise { - const marketKey = String(candidate.marketId); - const upTokenId = String(candidate.instrumentId); - - if (subscribedTokens.has(upTokenId)) return; // уже подписаны - - // Рынок уже был закрыт как EXPIRED в этой сессии — не открываем снова. - // Gamma API может продолжать возвращать active=true для истёкших рынков. - if (closedMarkets.has(marketKey)) return; - - // Рынок уже истёк — пропускаем, чтобы не начинать запись данных которые - // не успеют накопиться до истечения. - if (candidate.expiresAt.toNumber() <= Date.now()) { - logger.debug('Skipping already-expired market', { - marketId: marketKey, - question: candidate.question, - expiresAt: new Date(candidate.expiresAt.toNumber()).toISOString(), - }); - return; - } - - // Все токены рынка (UP + DOWN если доступны, иначе только UP) - const allTokenIds = candidate.allTokenIds?.length - ? candidate.allTokenIds.map(String) - : [upTokenId]; - - // Помечаем токены ДО первого await — защита от race condition - // при повторном вызове scanAndSubscribe() во время ожидания подписки. - for (const tokenId of allTokenIds) { - subscribedTokens.set(tokenId, candidate); - } - subscribedMarkets.set(marketKey, candidate); - - // startsAt = момент вызова openMarket() — начинаем запись СЕЙЧАС при переключении на рынок - const startsAt = Timestamp.of(new Decimal(Date.now())); - - recorder.registerMarket({ - marketId: candidate.marketId, - question: candidate.question, - tokenIds: allTokenIds, - startsAt, - expiresAt: candidate.expiresAt, - rawMarket: candidate.rawMarket, - }); - - for (const tokenId of allTokenIds) { - await ws.subscribeToToken(tokenId); - } - - // Подписка на RTDS для крипто-рынков + запись strike price - const cryptoMeta = parseCryptoMeta(candidate.rawMarket); - if (cryptoMeta) { - // Подписываемся на ОБА topic (Binance + Chainlink) — RTDS может - // отправлять цены на любой из них в зависимости от символа - for (const sub of cryptoMeta.rtdsSubscriptions) { - rtdsClient.subscribe(sub.topic, sub.filter); - // Маппинг symbol → tokenIds для маршрутизации в recorder - let ids = symbolToTokenIds.get(sub.filter); - if (!ids) { ids = new Set(); symbolToTokenIds.set(sub.filter, ids); } - ids.add(allTokenIds[0]!); - } - - logger.info('RTDS subscriptions added for crypto market', { - subscriptions: cryptoMeta.rtdsSubscriptions.map((s) => `${s.topic}:${s.filter}`), - source: cryptoMeta.source, - }); - } - - logger.info('Market opened for collection', { - question: candidate.question, - marketId: marketKey, - tokenIds: allTokenIds, - expiresAt: new Date(candidate.expiresAt.toNumber()).toISOString(), - isCrypto: !!cryptoMeta, - }); -} - -/** - * Финализирует истёкший рынок: сбрасывает буфер, сжимает файл, отписывается от WS. - * - * @param candidate - Ранее зарегистрированный рынок - * @param reason - Причина закрытия - * - * @remarks - * При `reason === 'EXPIRED'` рынок добавляется в `closedMarkets` — - * постоянный blacklist для предотвращения повторной регистрации. - */ -async function closeMarket(candidate: DiscoveredMarket, reason: 'EXPIRED' | 'SHUTDOWN'): Promise { - const marketKey = String(candidate.marketId); - - const allTokenIds = candidate.allTokenIds?.length - ? candidate.allTokenIds.map(String) - : [String(candidate.instrumentId)]; - - for (const tokenId of allTokenIds) { - subscribedTokens.delete(tokenId); - await ws.unsubscribeFromToken(tokenId); - } - subscribedMarkets.delete(marketKey); - - // Для крипто-рынков: убираем RTDS подписки - const closeCryptoMeta = parseCryptoMeta(candidate.rawMarket); - if (closeCryptoMeta) { - for (const sub of closeCryptoMeta.rtdsSubscriptions) { - const ids = symbolToTokenIds.get(sub.filter); - if (ids) { - ids.delete(allTokenIds[0]!); - if (ids.size === 0) { - symbolToTokenIds.delete(sub.filter); - rtdsClient.unsubscribe(sub.topic, sub.filter); - } - } - } - } - - // Запоминаем рынок как постоянно закрытый при EXPIRED, - // чтобы scanAndSubscribe не открыл его снова (Gamma API может - // возвращать active=true ещё долго после истечения рынка). - if (reason === 'EXPIRED') { - closedMarkets.set(marketKey, Date.now()); - } - - if (reason === 'SHUTDOWN' || !closeCryptoMeta) { - // SHUTDOWN: файлы удалит recorder.close() — здесь только снимаем подписки. - // Не-крипто EXPIRED: финализируем сразу (нет нужды в обогащении). - if (reason !== 'SHUTDOWN') { - await recorder.finalizeMarket(candidate.marketId, reason); - } - logger.info('Market closed', { question: candidate.question, marketId: marketKey, reason }); - return; - } - - // Крипто-рынок EXPIRED — откладываем finalize до получения priceToBeat. - // Слот уже освобождён (subscribedMarkets.delete выше) → новые рынки открываются сразу. - const gammaMarketId = (candidate.rawMarket as Record)?.['id'] as string | undefined; - if (!gammaMarketId) { - await recorder.finalizeMarket(candidate.marketId, reason); - logger.info('Market closed (no Gamma market id for enrichment)', { marketId: marketKey }); - return; - } - - pendingEnrichment.set(marketKey, { - marketId: candidate.marketId, - gammaMarketId, - symbol: closeCryptoMeta.rtdsFilter, - question: candidate.question, - startedAt: Date.now(), - attempts: 0, - lastPriceToBeat: undefined, - lastFinalPrice: undefined, - }); - logger.info('Market expired, waiting for priceToBeat enrichment', { - question: candidate.question, - marketId: marketKey, - gammaMarketId, - }); -} - -// ─── Дискавери ──────────────────────────────────────────────────────────────── - -/** - * Обновляет кэш кандидатов из Gamma API. - * - * @remarks - * Независимый процесс — не знает о занятых/свободных слотах. - * Просто запрашивает рынки, фильтрует, сортирует, кладёт 30 лучших в кэш. - * Вызывается по таймеру (пауза 30с после завершения). - */ -async function refreshDiscoveryCache(): Promise { - logger.debug('Refreshing market discovery cache...'); - try { - await discovery.refresh(); - } catch (err) { - logger.error('Market discovery refresh failed', { - err: err instanceof Error ? err : new Error(String(err)), - }); - } -} - -/** - * Заполняет свободные слоты рынками из кэша дискавери. - * - * @remarks - * Независимый процесс — не запускает новое сканирование. - * Читает кэш, берёт первые N кандидатов которые ещё не подписаны. - * Вызывается при старте и после закрытия истёкших рынков. - */ -async function fillMarketSlots(): Promise { - const remaining = config.maxMarkets - subscribedMarkets.size; - if (remaining <= 0) return; - - let candidates: readonly DiscoveredMarket[]; - try { - candidates = await discovery.findCandidates(); - } catch (err) { - logger.error('Failed to read candidates from cache', { - err: err instanceof Error ? err : new Error(String(err)), - }); - return; - } - - const nowMs = Date.now(); - const MIN_TIME_TO_START_MS = 2 * 60_000; // минимум 2 минуты до начала рынка - - let opened = 0; - for (const candidate of candidates) { - if (opened >= remaining) break; - const tokenId = String(candidate.instrumentId); - const marketKey = String(candidate.marketId); - - if (subscribedTokens.has(tokenId)) continue; - if (closedMarkets.has(marketKey)) continue; - - const expiresAtMs = candidate.expiresAt.toNumber(); - if (expiresAtMs <= nowMs) continue; // уже истёк - - // Определяем длительность рынка из eventStartMs (если есть) - // Для крипто-рынков это обычно 5 или 15 минут - const durationMs = candidate.eventStartMs !== undefined - ? expiresAtMs - candidate.eventStartMs.toNumber() - : 15 * 60_000; // fallback: 15 минут - - // Вычисляем примерное время начала: expiresAt - duration - const estimatedStartMs = expiresAtMs - durationMs; - const timeToStartMs = estimatedStartMs - nowMs; - - // Пропускаем рынки, которые уже начались или начнутся слишком скоро - if (timeToStartMs < MIN_TIME_TO_START_MS) { - logger.debug('Skipping market (already started or starts too soon)', { - question: candidate.question, - expiresAt: new Date(expiresAtMs).toISOString(), - estimatedStart: new Date(estimatedStartMs).toISOString(), - timeToStartMin: (timeToStartMs / 60_000).toFixed(1), - }); - continue; - } - - await openMarket(candidate); - opened++; - } - - if (opened > 0) { - logger.info('Market slots filled from cache', { - opened, - total: subscribedMarkets.size, - maxMarkets: config.maxMarkets, - }); - } -} - -// ─── Проверка истечений ─────────────────────────────────────────────────────── - -/** - * Проверяет истёкшие рынки и финализирует их. - * - * @remarks - * Рынок считается истёкшим, когда `expiresAt <= Date.now()`. - * Вызывается каждые 60 сек. - */ -async function checkExpiredMarkets(): Promise { - const nowMs = Date.now(); - const expired: DiscoveredMarket[] = []; - - for (const candidate of subscribedMarkets.values()) { - if (candidate.expiresAt.toNumber() <= nowMs) { - expired.push(candidate); - } - } - - for (const candidate of expired) { - await closeMarket(candidate, 'EXPIRED'); - } - - if (expired.length > 0) { - logger.info('Expired markets finalized', { count: expired.length }); - } -} - -// ─── Graceful Shutdown ──────────────────────────────────────────────────────── - -let isShuttingDown = false; -let scanTimeoutId: ReturnType | null = null; -let expiryInterval: ReturnType | null = null; -let enrichmentInterval: ReturnType | null = null; -let closedMarketsCleanupInterval: ReturnType | null = null; -let memoryLogInterval: ReturnType | null = null; -let expiryRunInProgress = false; - -async function shutdown(signal: string, exitCode = 0): Promise { - if (isShuttingDown) return; - isShuttingDown = true; - - // Удерживаем event loop живым пока идёт async cleanup. - // Без этого Node.js может выйти сразу после очистки таймеров — - // до того как file I/O в finalizeMarket успеет выполниться. - const keepAlive = setInterval(() => {}, 500); - - logger.info(`Received ${signal}, shutting down...`); - - try { - if (scanTimeoutId) { clearTimeout(scanTimeoutId); scanTimeoutId = null; } - if (expiryInterval) { clearInterval(expiryInterval); expiryInterval = null; } - if (enrichmentInterval) { clearInterval(enrichmentInterval); enrichmentInterval = null; } - if (closedMarketsCleanupInterval) { clearInterval(closedMarketsCleanupInterval); closedMarketsCleanupInterval = null; } - if (memoryLogInterval) { clearInterval(memoryLogInterval); memoryLogInterval = null; } - - // Сначала останавливаем ingestion новых данных, чтобы не было новых записей - // в recorder пока завершаем pending enrichment и закрываем рынки. - feed.stop(); - - // Если enrichment-pass уже идёт в фоне, не конфликтуем с ним по тем же файлам. - if (enrichmentRun) { - try { - await withTimeout(enrichmentRun, 15_000, 'in-flight enrichment pass'); - } catch (err) { - logger.warn('Timed out waiting for in-flight enrichment pass during shutdown', { - err: err instanceof Error ? err.message : String(err), - }); - } - } - - // Pending enrichment рынки уже сняты с WS/RTDS, но их writer'ы остаются открыты. - // На shutdown не архивируем их по одному: этот path может зависнуть внутри - // finalizeMarket()/gzip и заблокировать весь shutdown. Вместо этого оставляем - // удаление на общий recorder.close(), который закроет stream'ы и удалит все - // незавершённые .jsonl файлы disk-scan'ом. - if (pendingEnrichment.size > 0) { - logger.info('Dropping pending enrichment on shutdown — incomplete files will be deleted by recorder.close()', { - count: pendingEnrichment.size, - }); - pendingEnrichment.clear(); - } - - // Финализируем все активные рынки (SHUTDOWN — не сжимаем) - for (const candidate of [...subscribedMarkets.values()]) { - try { - await closeMarket(candidate, 'SHUTDOWN'); - } catch (err) { - logger.warn('Error closing market on shutdown', { - marketId: String(candidate.marketId), - err: err instanceof Error ? err : new Error(String(err)), - }); - } - } - - const shutdownTasks: Array> = []; - - if (cexService) { - shutdownTasks.push((async () => { - try { - await withTimeout(cexService.stop(), 15_000, 'cexService.stop'); - logger.info('CEX service stop complete'); - } catch (err) { - logger.warn('Error stopping CEX service', { - err: err instanceof Error ? err.message : String(err), - }); - } - })()); - } - - shutdownTasks.push((async () => { - try { - await withTimeout(recorder.close(), 15_000, 'recorder.close'); - logger.info('Recorder close complete'); - } catch (err) { - logger.warn('Error closing recorder', { - err: err instanceof Error ? err.message : String(err), - }); - } - })()); - - await Promise.allSettled(shutdownTasks); - - try { - await ws.disconnect(); - } catch (err) { - logger.warn('Error disconnecting WS', { - err: err instanceof Error ? err.message : String(err), - }); - } - - rtdsClient.disconnect(); - dnsOverride.uninstall(); - } finally { - clearInterval(keepAlive); - logger.info('Shutdown complete'); - process.exit(exitCode); - } -} - -process.on('SIGINT', () => void shutdown('SIGINT', 0)); -process.on('SIGTERM', () => void shutdown('SIGTERM', 0)); - -// Подавляем автоматический process.exit(1) от unhandled rejections во время shutdown. -// CcxtSymbolWatcher now consumes late watch rejections internally; this remains -// as a defensive shutdown guard for other background tasks. -process.on('unhandledRejection', (reason) => { - logger.warn('Unhandled promise rejection (suppressed to allow clean shutdown)', { - reason: reason instanceof Error ? reason.message : String(reason), - }); }); -// Если процесс падает через uncaughtException, обычный SIGINT/SIGTERM shutdown -// не запускается и незавершённые .jsonl останутся до следующего старта. -// Пытаемся выполнить тот же cleanup best-effort, затем выходим с code=1. -process.on('uncaughtException', (error) => { - logger.error('Uncaught exception — attempting graceful shutdown', { - err: error, +const { collector } = createDataCollector({ config: runtimeConfig, logger, clock }); + +// Единственный охраняемый путь остановки устанавливается ДО start(): отказ +// запуска и сигнал во время него сходятся в тот же shutdown. +const stopped = installShutdownHandlers({ target: collector, bootstrap, logger }); + +// Периодический operational-снимок: одна строка вместо набора таймеров. +const statusInterval = setInterval(() => { + const status = collector.status(); + const memory = process.memoryUsage(); + logger.info('Collector status', { + uptimeMin: status.uptimeMs === null ? null : Math.round(status.uptimeMs / 60_000), + active: status.collection.activeSessions, + finalizing: status.collection.finalizingSessions, + archived: status.finalization.archivedTotal, + rtdsFeeds: status.collection.rtdsFeeds.length, + recordsWritten: status.recorder.recordsWritten, + cexRecords: status.recorderCex.cexRecordsAccepted, + cexPartitions: status.cexWindows.partitionsCompleted, + busQueue: status.bus.queueSize, + failedCexSources: status.sources.cex.filter((source) => source.hasFailed).length, + rssMb: Math.round(memory.rss / (1024 * 1024)), + heapUsedMb: Math.round(memory.heapUsed / (1024 * 1024)), }); - void shutdown('UNCAUGHT_EXCEPTION', 1); -}); - -// ─── Main ───────────────────────────────────────────────────────────────────── - -// Удаляем незавершённые файлы от предыдущего запуска -await recorder.cleanup(); - -// Начальный дискавери до подключения WS (чтобы подписки были готовы): -// сначала заполняем кэш, потом берём рынки из кэша. -await refreshDiscoveryCache(); -await fillMarketSlots(); +}, 60_000); +statusInterval.unref(); -// Подключаем WS и запускаем feed -// connect() может режектить при первоначальной ошибке TLS/сети — -// не падаем, BaseWebSocketTransport сам запустит авто-реконнект +let started = false; try { - await ws.connect(); -} catch (err) { - logger.warn('Initial WS connection failed, will retry automatically', { - err: err instanceof Error ? err : new Error(String(err)), + await collector.start(); + started = true; +} catch (error) { + // start() уже откатил всё, что успел поднять, — держать процесс живым + // нечем и незачем; зарегистрированные обработчики сигналов event loop + // не удерживают, поэтому процесс завершится сам. + logger.fatal('Data collector failed to start', { + error: error instanceof Error ? (error.stack ?? error.message) : String(error), }); + process.exitCode = 1; + clearInterval(statusInterval); + bootstrap.dispose(); } -feed.start(); - -// Подключаем RTDS для крипто-цен -try { - await rtdsClient.connect(); -} catch (err) { - logger.warn('Initial RTDS connection failed, crypto prices unavailable', { - err: err instanceof Error ? err.message : String(err), - }); -} - -logger.info('Feed started, collecting data', { - markets: subscribedMarkets.size, -}); -// Процесс 1: обновление кэша дискавери (пауза ПОСЛЕ завершения). -// Не знает о слотах — просто обновляет кэш каждые ~30с. -async function scheduleScanLoop(): Promise { - if (isShuttingDown) return; - await refreshDiscoveryCache(); - if (!isShuttingDown) { - scanTimeoutId = setTimeout(() => { void scheduleScanLoop(); }, config.marketScanPauseMs); - } +if (started) { + logger.info('Collector running. Press Ctrl+C to stop.'); + await stopped; + clearInterval(statusInterval); } -scanTimeoutId = setTimeout(() => { void scheduleScanLoop(); }, config.marketScanPauseMs); - -// Процесс 2: закрытие истёкших + заполнение слотов из кэша (каждые 5 сек). -// Не знает о сканировании — только читает кэш и управляет слотами. -expiryInterval = setInterval(() => { - if (expiryRunInProgress) { - logger.debug('Skipping expired markets check: previous run is still in progress'); - return; - } - - expiryRunInProgress = true; - void (async () => { - await checkExpiredMarkets(); - await fillMarketSlots(); - })() - .catch((err) => { - logger.warn('Market slot maintenance failed', { - err: err instanceof Error ? err : new Error(String(err)), - }); - }) - .finally(() => { - expiryRunInProgress = false; - }); -}, 5_000); - -// Background enrichment: проверяем priceToBeat каждые 30 сек -enrichmentInterval = setInterval(() => { - runEnrichmentQueueOnce(); -}, ENRICHMENT_INTERVAL_MS); - -// Периодический лог потребления памяти (каждую минуту). -// Помогает ловить утечки и видеть эффект плановых рестартов ccxt-инстансов. -// Дополнительно: по SIGUSR2 пишем heap snapshot в cwd для офлайн-анализа. -const MEMORY_LOG_INTERVAL_MS = 60_000; -const MB = 1024 * 1024; -memoryLogInterval = setInterval(() => { - const m = process.memoryUsage(); - logger.info('Memory usage', { - rssMb: +(m.rss / MB).toFixed(1), - heapUsedMb: +(m.heapUsed / MB).toFixed(1), - heapTotalMb: +(m.heapTotal / MB).toFixed(1), - externalMb: +(m.external / MB).toFixed(1), - arrayBuffersMb: +(m.arrayBuffers / MB).toFixed(1), - markets: subscribedMarkets.size, - pendingEnrich: pendingEnrichment.size, - }); -}, MEMORY_LOG_INTERVAL_MS); - -process.on('SIGUSR2', () => { - void (async () => { - try { - const { writeHeapSnapshot } = await import('v8'); - const file = `heap-${Date.now()}.heapsnapshot`; - writeHeapSnapshot(file); - logger.info('Heap snapshot written', { file }); - } catch (err) { - logger.warn('Failed to write heap snapshot', { - err: err instanceof Error ? err.message : String(err), - }); - } - })(); -}); - -// Очистка closedMarkets от записей старше 24ч (каждый час) -closedMarketsCleanupInterval = setInterval(() => { - const now = Date.now(); - let removed = 0; - for (const [key, closedAt] of closedMarkets) { - if (now - closedAt >= CLOSED_MARKETS_TTL_MS) { - closedMarkets.delete(key); - removed++; - } - } - if (removed > 0) { - logger.debug('Cleaned up stale closedMarkets entries', { removed, remaining: closedMarkets.size }); - } -}, 60 * 60_000); - -logger.info('Collector running. Press Ctrl+C to stop.', { - scanEveryMs: config.marketScanPauseMs, - expiryCheckMs: 5_000, - outputDir: config.outputDir, -}); diff --git a/apps/collect-data/src/runtime/DataCollector.ts b/apps/collect-data/src/runtime/DataCollector.ts new file mode 100644 index 00000000..26610fac --- /dev/null +++ b/apps/collect-data/src/runtime/DataCollector.ts @@ -0,0 +1,671 @@ +/** + * Production-рантайм сбора сырых рыночных данных. + * + * @remarks + * ### Контур + * + * ```text + * PolymarketSource ─┐ + * (market + RTDS) │ + * ├──► ОДИН ExternalMessageBus ──┬──► ExternalMessageRecorder ──► JSONL + * CexSource[] ──────┘ │ (PM-политика + CEX-политика) + * (orderbook+trades) └──► любой другой consumer + * (checkpoint-наблюдатель, + * будущий Semantic Adapter) + * ``` + * + * Composition/lifecycle/расписание/операционное состояние — ответственность + * этого класса. Semantic-конверсии, Domain-концептов, стратегий, риска, + * исполнения и позиций здесь нет и быть не может: `DataCollector` живёт + * строго ДО semantic boundary. + * + * ### Владение bus + * + * Bus передаётся снаружи (создаётся composition factory) и НЕ замурован + * внутри: любой consumer подписывается на него ДО `start()` и получает те же + * сообщения, что и recorder. Обратной зависимости нет — consumer знает про + * bus, а не про `DataCollector`. + * + * ### Порядок старта (recorder-first) + * + * ```text + * startup cleanup → recorder.start() → CEX sources → runtime loop + * ``` + * + * Ingress не начинается, пока recorder не подписан: иначе первые сообщения + * ушли бы в bus без записи. Отказ на любом шаге откатывает уже поднятые + * ресурсы и отклоняет `start()`. + * + * ### Порядок остановки + * + * ```text + * runtime loop → finalizer → coordinator → PM source → CEX sources + * → bus.drain() → recorder.close() → bus.close() + * ``` + * + * Сначала прекращается ingress, затем очередь bus дренируется В recorder, и + * только потом закрывается сам recorder — иначе последние сообщения были бы + * потеряны. Каждый шаг best-effort: отказ одного не отменяет остальные. + */ +import type { ILogger } from '@polymarket/logger'; +import type { IClock } from '@polymarket/time'; +import type { Result } from '@polymarket/result'; +import type { MessageBusDrainError, MessageBusStats } from '@polymarket/message-bus'; +import type { CexSource, CexSourceStats } from '@polymarket/cex-v2'; +import type { CexWindowRecorder, DataRecorder } from '@polymarket/data-collection'; +import type { CexWindowRecorderStats } from '@polymarket/data-collection'; +import type { PolymarketMarketDiscovery, PolymarketSource } from '@polymarket/polymarket-v2'; +import type { ExternalMessageRecorder } from '@polymarket/external-message-recorder'; +import type { + ExternalMessageRecorderCexStats, + ExternalMessageRecorderStats, +} from '@polymarket/external-message-recorder'; +import type { + CollectionCoordinatorStats, + MarketCollectionCoordinator, +} from '@polymarket/collection-coordinator'; +import type { MarketFinalizer, MarketFinalizerStats } from '@polymarket/market-finalizer'; +import type { CollectionRuntimeConfig } from './DataCollectorConfig.js'; +import type { + CollectionLifecycleCounts, + CollectionLifecycleEvent, + CollectionLifecycleListener, +} from './collectionLifecycle.js'; +import { CollectionLifecycleProjection } from './collectionLifecycle.js'; + +// ───────────────────────────── Порты компонентов ───────────────────────────── + +/** + * Порт общего bus, используемый рантаймом. + * + * @remarks + * Рантайм НЕ публикует в bus (это делают sources) и НЕ подписывается на него + * (это делают recorder и внешние consumer-ы) — только дренирует и закрывает + * его при остановке контура. Узкий порт делает это ограничение + * проверяемым типами: подписаться через него нельзя. + */ +export interface CollectorBus { + /** Дренирует очередь доставки в уже подписанных consumer-ов. */ + drain(): Promise>; + /** Закрывает контур доставки (после дренажа). */ + close(): Promise>; + /** Снимок очереди и ошибок обработчиков. */ + getStats(): MessageBusStats; +} + +/** Порт recording-подписчика bus. */ +export type CollectorRecorder = Pick< + ExternalMessageRecorder, + 'start' | 'close' | 'getStats' | 'getCexStats' +>; + +/** Порт storage-политики Polymarket (startup cleanup). */ +export type CollectorPolymarketStorage = Pick; + +/** Порт storage-политики CEX-окон (startup cleanup + статистика партиций). */ +export type CollectorCexStorage = Pick; + +/** Порт Polymarket-source (закрытие + health-сигнал). */ +export type CollectorPolymarketSource = Pick; + +/** Порт одного CEX-source. */ +export type CollectorCexSource = Pick< + CexSource, + 'start' | 'close' | 'getStats' | 'hasFailed' | 'isRunning' +>; + +/** + * CEX-source вместе со своей биржей. + * + * @remarks + * `CexSource` не публикует собственную конфигурацию, а операционный статус + * обязан называть биржу, а не индекс в массиве — поэтому идентификатор + * переносится рядом с source-ом на этапе сборки контура. + */ +export interface CollectorCexSourceEntry { + /** Идентификатор биржи в терминах CCXT (`binance`, `okx`, ...). */ + readonly exchangeId: string; + /** Сам source. */ + readonly source: CollectorCexSource; +} + +/** Порт Discovery (чтение кэша кандидатов для проекции lifecycle). */ +export type CollectorDiscovery = Pick; + +/** Порт координатора collection-сессий. */ +export type CollectorCoordinator = Pick< + MarketCollectionCoordinator, + 'refreshCandidates' | 'fillSlots' | 'listSessions' | 'getStats' | 'close' +>; + +/** Порт финализатора post-expiry датасетов. */ +export type CollectorFinalizer = Pick; + +/** + * Уже собранные компоненты контура, чьим lifecycle владеет рантайм. + * + * @remarks + * Разделение «сборка ↔ владение» намеренное: production-composition делает + * {@link createDataCollector}, а тесты подставляют fake-компоненты и + * проверяют поведение рантайма без сети, дисков и CCXT. + */ +export interface DataCollectorComponents { + readonly bus: CollectorBus; + readonly recorder: CollectorRecorder; + readonly polymarketStorage: CollectorPolymarketStorage; + readonly cexStorage: CollectorCexStorage; + readonly polymarketSource: CollectorPolymarketSource; + readonly cexSources: readonly CollectorCexSourceEntry[]; + readonly discovery: CollectorDiscovery; + readonly coordinator: CollectorCoordinator; + readonly finalizer: CollectorFinalizer; +} + +/** Зависимости {@link DataCollector}. */ +export interface DataCollectorDependencies { + /** Компоненты контура (см. {@link DataCollectorComponents}). */ + readonly components: DataCollectorComponents; + /** Параметры collection-цикла. */ + readonly collection: CollectionRuntimeConfig; + /** Источник времени (DI — детерминизм в тестах). */ + readonly clock: IClock; + /** Логгер (будет обёрнут в child с component-контекстом). */ + readonly logger: ILogger; +} + +// ──────────────────────────── Операционный статус ──────────────────────────── + +/** Состояние рантайма. */ +export type DataCollectorState = 'idle' | 'starting' | 'running' | 'stopping' | 'stopped'; + +/** Здоровье одного CEX-source. */ +export interface CexSourceHealth { + readonly exchangeId: string; + readonly isRunning: boolean; + readonly hasFailed: boolean; + readonly stats: CexSourceStats; +} + +/** + * Read-only снимок операционного состояния рантайма. + * + * @remarks + * Все поля — уже существующие `getStats()` компонентов; собственных метрик + * рантайм не заводит (MR-A PART 20 — переиспользовать, а не дублировать). + */ +export interface DataCollectorStatus { + readonly state: DataCollectorState; + /** Момент успешного `start()` (ms) либо `null`. */ + readonly startedAtMs: number | null; + /** Время работы с момента старта (ms) либо `null`. */ + readonly uptimeMs: number | null; + /** Сессии/слоты/RTDS-фиды координатора. */ + readonly collection: CollectionCoordinatorStats; + /** Pending/архивированные/отказавшие финализации. */ + readonly finalization: MarketFinalizerStats; + /** Маршрутизация и запись Polymarket-сообщений. */ + readonly recorder: ExternalMessageRecorderStats; + /** Маршрутизация и запись CEX-сообщений. */ + readonly recorderCex: ExternalMessageRecorderCexStats; + /** Завершённые/сбойные партиции CEX-окон. */ + readonly cexWindows: CexWindowRecorderStats; + /** Очередь и ошибки обработчиков общего bus. */ + readonly bus: MessageBusStats; + /** Здоровье ingress-source-ов. */ + readonly sources: { + readonly polymarket: { readonly hasFailed: boolean; readonly isClosed: boolean }; + readonly cex: readonly CexSourceHealth[]; + }; + /** Накопленные счётчики наблюдённых lifecycle-переходов. */ + readonly lifecycle: CollectionLifecycleCounts; +} + +/** Один шаг остановки контура. */ +interface ShutdownStep { + readonly name: string; + readonly run: () => Promise; +} + +/** + * Рантайм сбора: владеет composition, lifecycle источников и коллекции, + * расписанием и операционным состоянием. + * + * @example + * ```typescript + * const collector = createDataCollector({ config, logger, clock }); + * collector.onMarketLifecycle((event) => metrics.record(event)); + * await collector.start(); + * // ... + * await collector.close(); + * ``` + */ +export class DataCollector { + private readonly _components: DataCollectorComponents; + private readonly _collection: CollectionRuntimeConfig; + private readonly _clock: IClock; + private readonly _logger: ILogger; + private readonly _lifecycle = new CollectionLifecycleProjection(); + private readonly _listeners = new Set(); + + private _state: DataCollectorState = 'idle'; + private _startedAtMs: number | null = null; + private _tickTimer: ReturnType | null = null; + /** Текущий тик цикла — остановка дожидается его завершения. */ + private _tickInFlight: Promise | null = null; + private _lastRefreshMs = 0; + private _closePromise: Promise | null = null; + + /** + * Создаёт рантайм поверх уже собранных компонентов контура. + * + * @param deps - Зависимости (см. {@link DataCollectorDependencies}) + */ + constructor(deps: DataCollectorDependencies) { + this._components = deps.components; + this._collection = deps.collection; + this._clock = deps.clock; + this._logger = deps.logger.child({ component: 'DataCollector' }); + } + + /** Текущее состояние рантайма. */ + public get state(): DataCollectorState { + return this._state; + } + + /** + * Подписывает read-only наблюдателя collection lifecycle. + * + * @param listener - Обработчик события (исключения поглощаются и логируются) + * @returns Функция отписки + * + * @remarks + * Наблюдатель не влияет на сбор: он вызывается синхронно после того, как + * переход уже произошёл, а его отказ не прерывает тик рантайма. + * + * @example + * ```typescript + * const off = collector.onMarketLifecycle((event) => { + * if (event.kind === 'FINALIZED') archived.push(String(event.marketId)); + * }); + * ``` + */ + public onMarketLifecycle(listener: CollectionLifecycleListener): () => void { + this._listeners.add(listener); + return () => { + this._listeners.delete(listener); + }; + } + + /** + * Поднимает контур: startup cleanup → recorder → ingress → runtime loop. + * + * @returns Promise завершения запуска + * @throws {Error} При повторном старте, старте после остановки либо отказе + * любого шага запуска (уже поднятые ресурсы к этому моменту закрыты) + * + * @remarks + * Recorder подписывается на bus ДО старта источников — иначе первые + * сообщения пришли бы в bus без записи. Частичный отказ откатывается + * полностью: контур либо работает целиком, либо не оставляет за собой + * ни открытых потоков, ни таймеров. + */ + public async start(): Promise { + if (this._state !== 'idle') { + throw new Error(`DataCollector.start() rejected: already ${this._state}`); + } + this._state = 'starting'; + + /** Обратные шаги уже поднятых ресурсов (LIFO при откате). */ + const rollback: ShutdownStep[] = []; + try { + // 1. Startup cleanup: незавершённые артефакты прошлого запуска не + // подлежат восстановлению и должны исчезнуть ДО новых записей. + await this._components.polymarketStorage.cleanup(); + await this._components.cexStorage.cleanup(); + + // 2. Recorder-first: подписка на bus раньше любого ingress. + this._components.recorder.start(); + rollback.push({ + name: 'recorder.close', + run: async () => this._components.recorder.close(), + }); + + // 3. Ingress: CEX-потоки (Polymarket-подписки открывает координатор + // по мере выбора рынков). + for (const entry of this._components.cexSources) { + entry.source.start(); + rollback.push({ + name: `cexSource.close(${entry.exchangeId})`, + run: async () => entry.source.close(), + }); + } + + this._startedAtMs = this._nowMs(); + this._state = 'running'; + this._scheduleTick(0); + this._logger.info('Data collector started', { + cexSources: this._components.cexSources.length, + maxMarkets: this._collection.maxMarkets, + discoveryRefreshMs: this._collection.discoveryRefreshMs, + runtimeTickMs: this._collection.runtimeTickMs, + }); + } catch (error) { + this._logger.error('Data collector startup failed, rolling back', { + error: error instanceof Error ? error.message : String(error), + }); + for (const step of rollback.reverse()) { + await this._runStep(step); + } + // Bus закрывается вместе с откатом: контур не поднялся, и оставлять + // его открытым означало бы удерживать процесс живым без сбора. + await this._runStep({ + name: 'bus.close', + run: async () => { + await this._components.bus.close(); + }, + }); + this._state = 'stopped'; + throw error; + } + } + + /** + * Дожидается официальной резолюции уже начатых финализаций. + * + * @returns Promise завершения дренажа + * + * @remarks + * Опциональный graceful wind-down ПЕРЕД {@link DataCollector.close}: без + * него остановка обрывает окно ожидания официальной резолюции, и рынки + * архивируются как `timeout`. Штатный shutdown по сигналу его НЕ вызывает + * — ожидание измеряется десятками минут и не укладывается в + * `kill_timeout` супервизора; вызов уместен для контролируемых прогонов. + */ + public async drain(): Promise { + if (this._state !== 'running') { + return; + } + this._logger.info('Draining pending finalizations', { + finalizer: this._components.finalizer.getStats(), + }); + await this._components.finalizer.drain(); + this._logger.info('Finalization drain finished', { + finalizer: this._components.finalizer.getStats(), + }); + } + + /** + * Останавливает контур в порядке, обратном запуску. + * + * @returns Promise завершения остановки (идемпотентен) + * + * @remarks + * Идемпотентен: повторный вызов возвращает тот же Promise, а вызов до + * запуска — no-op. Ни один шаг не прерывает остальные: отказ логируется, и + * закрытие продолжается — иначе одна сбойная подсистема оставила бы + * открытыми потоки, таймеры и файловые дескрипторы остальных. + * `process.exit()` здесь не используется: он замаскировал бы дефекты + * lifecycle вместо их устранения. + */ + public async close(): Promise { + if (this._closePromise !== null) { + return this._closePromise; + } + // Ни разу не запускавшийся и уже остановленный рантайм закрывать нечем. + // Второй случай — не теоретический: неудавшийся start() уже откатил всё, + // что успел поднять, и повторная лестница закрыла бы ресурсы дважды. + if (this._state === 'idle' || this._state === 'stopped') { + this._state = 'stopped'; + return; + } + this._state = 'stopping'; + this._closePromise = this._close(); + return this._closePromise; + } + + /** + * Возвращает read-only снимок операционного состояния. + * + * @returns Текущее состояние рантайма и его компонентов + * + * @example + * ```typescript + * const status = collector.status(); + * logger.info('Collector status', { active: status.collection.activeSessions }); + * ``` + */ + public status(): DataCollectorStatus { + const nowMs = this._nowMs(); + return { + state: this._state, + startedAtMs: this._startedAtMs, + uptimeMs: this._startedAtMs === null ? null : nowMs - this._startedAtMs, + collection: this._components.coordinator.getStats(), + finalization: this._components.finalizer.getStats(), + recorder: this._components.recorder.getStats(), + recorderCex: this._components.recorder.getCexStats(), + cexWindows: this._components.cexStorage.getStats(), + bus: this._components.bus.getStats(), + sources: { + polymarket: { + hasFailed: this._components.polymarketSource.hasFailed, + isClosed: this._components.polymarketSource.isClosed, + }, + cex: this._components.cexSources.map((entry) => ({ + exchangeId: entry.exchangeId, + isRunning: entry.source.isRunning, + hasFailed: entry.source.hasFailed, + stats: entry.source.getStats(), + })), + }, + lifecycle: this._lifecycle.getCounts(), + }; + } + + /** + * Выполняет один тик runtime-цикла (публично — для детерминированных тестов). + * + * @returns Promise завершения тика + * + * @remarks + * Тик оркестрирует владельцев состояния и НЕ содержит собственного: + * ни карт подписок, ни blacklist-ов, ни очереди обогащения — всем этим + * владеют Discovery, Coordinator и Finalizer. + */ + public async tick(): Promise { + const nowMs = this._nowMs(); + if (nowMs - this._lastRefreshMs >= this._collection.discoveryRefreshMs) { + this._lastRefreshMs = nowMs; + await this._components.coordinator.refreshCandidates(); + await this._projectCandidates(); + } + await this._components.coordinator.fillSlots(); + await this._components.finalizer.runOnce(); + this._projectSessions(); + } + + // ───────────────────────────── Внутреннее ───────────────────────────── + + /** Текущее время в миллисекундах (`IClock` отдаёт `Date`). */ + private _nowMs(): number { + return this._clock.now().getTime(); + } + + /** Полная лестница остановки контура. */ + private async _close(): Promise { + this._logger.info('Data collector stopping', { status: this.status() }); + + if (this._tickTimer !== null) { + clearTimeout(this._tickTimer); + this._tickTimer = null; + } + // Тик мог быть в полёте: его шаги трогают те же координатор/финализатор, + // что и лестница закрытия, поэтому дожидаемся завершения. + if (this._tickInFlight !== null) { + await this._tickInFlight.catch(() => undefined); + this._tickInFlight = null; + } + + const cexSources = this._components.cexSources; + const steps: ShutdownStep[] = [ + { name: 'finalizer.close', run: async () => this._components.finalizer.close() }, + { name: 'coordinator.close', run: async () => this._components.coordinator.close() }, + { name: 'polymarketSource.close', run: async () => this._components.polymarketSource.close() }, + { + name: 'cexSources.close', + run: async () => { + const results = await Promise.allSettled( + cexSources.map(async (entry) => entry.source.close()), + ); + const failed = results.filter((result) => result.status === 'rejected'); + if (failed.length > 0) { + throw new Error(`${String(failed.length)} CEX source(s) failed to close`); + } + }, + }, + { + name: 'bus.drain', + run: async () => { + const drained = await this._components.bus.drain(); + if (!drained.ok) { + throw new Error(`bus.drain rejected: ${drained.error.message}`); + } + }, + }, + { name: 'recorder.close', run: async () => this._components.recorder.close() }, + { + name: 'bus.close', + run: async () => { + const closed = await this._components.bus.close(); + if (!closed.ok) { + throw new Error(`bus.close rejected: ${closed.error.message}`); + } + }, + }, + ]; + + for (const step of steps) { + await this._runStep(step); + } + + // Финальная проекция: сессии, снятые закрытием координатора, наблюдаемы + // как DROPPED — наблюдатель видит полный lifecycle, а не обрыв. + this._projectSessions(); + this._state = 'stopped'; + this._logger.info('Data collector stopped'); + } + + /** Выполняет шаг остановки, поглощая и логируя его отказ. */ + private async _runStep(step: ShutdownStep): Promise { + try { + await step.run(); + } catch (error) { + this._logger.error('Shutdown step failed, continuing', { + step: step.name, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** Планирует следующий тик (пауза ОТСЧИТЫВАЕТСЯ ПОСЛЕ завершения текущего). */ + private _scheduleTick(delayMs: number): void { + if (this._state !== 'running') { + return; + } + this._tickTimer = setTimeout(() => { + this._tickTimer = null; + if (this._state !== 'running') { + return; + } + const running = this.tick().catch((error: unknown) => { + this._logger.error('Runtime tick failed', { + error: error instanceof Error ? error.message : String(error), + }); + }); + this._tickInFlight = running; + void running.finally(() => { + if (this._tickInFlight === running) { + this._tickInFlight = null; + } + this._scheduleTick(this._collection.runtimeTickMs); + }); + }, delayMs); + this._tickTimer.unref?.(); + } + + /** Проецирует свежий candidate cache в события DISCOVERED. */ + private async _projectCandidates(): Promise { + if (this._listeners.size === 0) { + return; // никто не наблюдает — не читаем кэш + } + try { + const candidates = await this._components.discovery.findCandidates(); + this._emit(this._lifecycle.observeCandidates(candidates, this._nowMs())); + } catch (error) { + this._logger.debug('Lifecycle candidate projection skipped', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** Проецирует текущие сессии координатора в события переходов. */ + private _projectSessions(): void { + const events = this._lifecycle.observeSessions( + this._components.coordinator.listSessions(), + this._components.finalizer.getStats(), + this._nowMs(), + // Отбрасывание считается следствием остановки только когда она идёт: + // на `idle` рантайме (детерминированный `tick()` в тестах) причина — + // реконсиляция состояния, а не shutdown. + { shuttingDown: this._state === 'stopping' || this._state === 'stopped' }, + ); + this._emit(events); + } + + /** Рассылает события наблюдателям и пишет их в operational-лог. */ + private _emit(events: readonly CollectionLifecycleEvent[]): void { + for (const event of events) { + this._logMarketLifecycle(event); + for (const listener of this._listeners) { + try { + listener(event); + } catch (error) { + this._logger.warn('Lifecycle listener threw, ignoring', { + kind: event.kind, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + } + + /** Пишет lifecycle-переход в operational-лог. */ + private _logMarketLifecycle(event: CollectionLifecycleEvent): void { + const context: Record = { marketId: String(event.marketId) }; + if (event.question !== undefined) context['question'] = event.question; + if (event.expiresAtMs !== undefined) { + context['expiresAt'] = new Date(event.expiresAtMs).toISOString(); + } + if (event.outcome !== undefined) context['outcome'] = event.outcome; + if (event.reason !== undefined) context['reason'] = event.reason; + + switch (event.kind) { + case 'DISCOVERED': + this._logger.debug('Market discovered', context); + break; + case 'COLLECTION_STARTED': + this._logger.info('Market collection started', context); + break; + case 'FINALIZING': + this._logger.info('Market expired, finalizing', context); + break; + case 'FINALIZED': + this._logger.info('Market finalization finished', context); + break; + case 'DROPPED': + this._logger.warn('Market collection dropped', context); + break; + } + } + +} diff --git a/apps/collect-data/src/runtime/DataCollectorConfig.ts b/apps/collect-data/src/runtime/DataCollectorConfig.ts new file mode 100644 index 00000000..8eee7b54 --- /dev/null +++ b/apps/collect-data/src/runtime/DataCollectorConfig.ts @@ -0,0 +1,316 @@ +/** + * Конфигурация production-рантайма коллектора и boundary-конверсия из + * внешней (env/JSON) конфигурации приложения. + * + * @remarks + * Граница «внешняя конфигурация → V2-рантайм» живёт ЗДЕСЬ и только здесь: + * + * ```text + * .env + cex-config.json → toDataCollectorConfig() → DataCollectorConfig + * (CollectorConfig) (V2-компоненты) + * ``` + * + * Legacy-типы коллектора (`CexCollectorConfig`/`CexExchangeConfig`) внутрь + * рантайма НЕ протаскиваются: JSON-файл разбирается здесь и превращается в + * `CexSourceConfig[]` пакета `@polymarket/cex-v2`. + */ +import type { IMarketFilterConfig } from '@polymarket/ports'; +import type { CexMarketType, CexSourceConfig } from '@polymarket/cex-v2'; +import type { CollectorConfig } from '../config.js'; + +/** + * Параметры записи Polymarket-датасетов (market-session policy рекордера). + * + * @remarks + * Значения передаются в `DataRecorder` как есть; собственной file-логики у + * рантайма нет (PART 6 — новая третья реализация storage запрещена). + */ +export interface PolymarketRecordingConfig { + /** Поддиректория источника внутри date-папки (`{out}/{date}/{sourceSubDir}/`). */ + readonly sourceSubDir: string; + /** Событий в буфере до принудительного сброса. */ + readonly bufferSize: number; + /** Интервал периодического сброса буфера (мс). */ + readonly flushIntervalMs: number; + /** Сжатие датасета при финализации. */ + readonly compression: 'none' | 'gzip'; +} + +/** + * Параметры CEX-контура: набор source-ов + политика окон записи. + */ +export interface CexCollectionConfig { + /** Конфигурации V2-source-ов (одна на биржу × тип рынка). */ + readonly sources: readonly CexSourceConfig[]; + /** Размер окна партиции (минуты). Не задан — дефолт `CexWindowRecorder` (5). */ + readonly windowMinutes?: number; + /** Записей в буфере окна до сброса. */ + readonly bufferSize: number; + /** Интервал периодического сброса буферов окон (мс). */ + readonly flushIntervalMs: number; + /** Сжатие завершённой партиции. */ + readonly compression: 'none' | 'gzip'; +} + +/** + * Параметры collection-цикла (что и как часто открывать/закрывать). + */ +export interface CollectionRuntimeConfig { + /** Максимум одновременных collection-сессий. */ + readonly maxMarkets: number; + /** Минимальный запас до старта события, раньше которого рынок открывается (мс). */ + readonly minTimeToStartMs?: number; + /** Пауза между обновлениями candidate cache (мс). */ + readonly discoveryRefreshMs: number; + /** Пауза между тиками runtime-цикла (fillSlots + finalizer) (мс). */ + readonly runtimeTickMs: number; +} + +/** + * Параметры post-expiry финализации (пробрасываются в `MarketFinalizer`). + * + * @remarks + * Значения по умолчанию принадлежат самому финализатору — рантайм их НЕ + * переопределяет (MR-A: resolution hardening вне scope). + */ +export interface FinalizationRuntimeConfig { + /** Минимальная пауза между enrichment-попытками одного рынка (мс). */ + readonly enrichmentRetryMs?: number; + /** Потолок ожидания полного enrichment-а (мс). */ + readonly enrichmentMaxWaitMs?: number; +} + +/** + * Полная конфигурация production-рантайма коллектора. + */ +export interface DataCollectorConfig { + /** + * Корень датасетов, общий для обеих storage-политик. + * + * @remarks + * Раскладка (parity с legacy-коллектором): + * ```text + * {outputDir}/{YYYY-MM-DD}/{sourceSubDir}/{question}___{marketId}.jsonl[.gz] + * {outputDir}/{YYYY-MM-DD}/{exchangeId}/{exchange}_{symbol}_..._ET.jsonl[.gz] + * ``` + */ + readonly outputDir: string; + /** Политика записи Polymarket-сессий. */ + readonly polymarket: PolymarketRecordingConfig; + /** Конфигурация Discovery V2 (фильтр кандидатов). */ + readonly discovery: { readonly filter: IMarketFilterConfig }; + /** Параметры collection-цикла. */ + readonly collection: CollectionRuntimeConfig; + /** Параметры финализации. */ + readonly finalization: FinalizationRuntimeConfig; + /** Параметры CEX-контура (пустой `sources` — CEX выключен). */ + readonly cex: CexCollectionConfig; +} + +/** Дефолтная пауза тика runtime-цикла (мс). */ +const DEFAULT_RUNTIME_TICK_MS = 5_000; + +/** Legacy-описание одной биржи в `cex-config.json`. */ +interface CexConfigFileEntry { + readonly exchangeId?: unknown; + readonly type?: unknown; + readonly symbols?: unknown; + readonly orderbook?: unknown; + readonly trades?: unknown; + readonly obDepth?: unknown; + readonly restartIntervalMs?: unknown; + readonly obMethod?: unknown; +} + +/** + * Приводит legacy-тип рынка к canonical `CexMarketType`. + * + * @param raw - Значение поля `type` из внешней конфигурации + * @param exchangeKey - Ключ биржи (для сообщения об ошибке) + * @returns Тип рынка в терминах `@polymarket/cex-v2` + * @throws {Error} Если значение не является известным типом рынка + * + * @remarks + * Единственное расхождение словарей — legacy `'futures'` против canonical + * CCXT `'future'`; остальные значения совпадают. + */ +function toCexMarketType(raw: unknown, exchangeKey: string): CexMarketType { + if (raw === 'spot' || raw === 'swap') { + return raw; + } + if (raw === 'future' || raw === 'futures') { + return 'future'; + } + throw new Error( + `Invalid CEX config for '${exchangeKey}': type must be 'spot' | 'future' | 'swap', got ${JSON.stringify(raw)}`, + ); +} + +/** + * Читает опциональное положительное число из внешней конфигурации. + * + * @param raw - Сырое значение + * @param field - Имя поля (для сообщения об ошибке) + * @param exchangeKey - Ключ биржи (для сообщения об ошибке) + * @returns Число либо `undefined`, если поле не задано + * @throws {Error} Если значение задано, но не является конечным числом > 0 + */ +function optionalPositiveNumber( + raw: unknown, + field: string, + exchangeKey: string, +): number | undefined { + if (raw === undefined || raw === null) { + return undefined; + } + if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) { + throw new Error( + `Invalid CEX config for '${exchangeKey}': ${field} must be a finite number > 0, got ${JSON.stringify(raw)}`, + ); + } + return raw; +} + +/** + * Разбирает внешнюю CEX-конфигурацию в набор `CexSourceConfig`. + * + * @param json - Содержимое `cex-config.json` (или inline `CEX_CONFIG`) + * @returns Конфигурации V2-source-ов, по одной на биржу + * @throws {Error} При невалидном JSON или невалидном описании биржи + * + * @remarks + * Формат файла сохранён от legacy-коллектора (ключ словаря = `exchangeId`), + * чтобы production-конфигурация не переписывалась вместе с рантаймом; + * конверсия имён полей выполняется здесь: + * + * ```text + * { type, symbols, orderbook, trades, obDepth, obMethod, restartIntervalMs } + * ↓ + * { marketType, symbols, watchOrderbook, watchTrades, orderbookDepth, + * orderbookMethod, restartIntervalMs } + * ``` + * + * @example + * ```typescript + * const sources = parseCexSourceConfigs('{"binance":{"type":"spot",' + + * '"symbols":["BTC/USDT"],"orderbook":true,"trades":true}}'); + * // → [{ exchangeId: 'binance', marketType: 'spot', ... }] + * ``` + */ +export function parseCexSourceConfigs(json: string): readonly CexSourceConfig[] { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (error) { + throw new Error( + `Invalid CEX config JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Invalid CEX config: expected an object keyed by exchange id'); + } + + const sources: CexSourceConfig[] = []; + for (const [exchangeKey, rawEntry] of Object.entries(parsed as Record)) { + if (typeof rawEntry !== 'object' || rawEntry === null) { + throw new Error(`Invalid CEX config for '${exchangeKey}': expected an object`); + } + const entry = rawEntry as CexConfigFileEntry; + const symbols = entry.symbols; + if ( + !Array.isArray(symbols) || + symbols.length === 0 || + symbols.some((symbol) => typeof symbol !== 'string' || symbol.length === 0) + ) { + throw new Error( + `Invalid CEX config for '${exchangeKey}': symbols must be a non-empty array of strings`, + ); + } + if (entry.obMethod !== undefined && entry.obMethod !== 'watch' && entry.obMethod !== 'fetch') { + throw new Error( + `Invalid CEX config for '${exchangeKey}': obMethod must be 'watch' | 'fetch', got ${JSON.stringify(entry.obMethod)}`, + ); + } + const exchangeId = + typeof entry.exchangeId === 'string' && entry.exchangeId.length > 0 + ? entry.exchangeId + : exchangeKey; + + const orderbookDepth = optionalPositiveNumber(entry.obDepth, 'obDepth', exchangeKey); + const restartIntervalMs = optionalPositiveNumber( + entry.restartIntervalMs, + 'restartIntervalMs', + exchangeKey, + ); + + sources.push({ + exchangeId, + marketType: toCexMarketType(entry.type, exchangeKey), + symbols: symbols as readonly string[], + watchOrderbook: entry.orderbook === true, + watchTrades: entry.trades === true, + ...(orderbookDepth !== undefined ? { orderbookDepth } : {}), + ...(entry.obMethod !== undefined ? { orderbookMethod: entry.obMethod } : {}), + ...(restartIntervalMs !== undefined ? { restartIntervalMs } : {}), + }); + } + return sources; +} + +/** + * Превращает внешнюю конфигурацию приложения в конфигурацию V2-рантайма. + * + * @param config - Загруженная из окружения `CollectorConfig` + * @returns Конфигурация рантайма, готовая для `createDataCollector` + * @throws {Error} Если CEX-конфигурация задана, но невалидна + * + * @remarks + * Отказ разбора CEX-конфигурации — fail-fast: legacy-коллектор в этом случае + * молча продолжал БЕЗ CEX (`logger.error` + `cexService = null`), из-за чего + * прогон выглядел живым, но половина датасета не писалась. Здесь такая + * конфигурация не даёт процессу стартовать; чтобы выключить CEX сознательно, + * достаточно не задавать `CEX_CONFIG_FILE`/`CEX_CONFIG`. + * + * @example + * ```typescript + * const runtimeConfig = toDataCollectorConfig(loadConfig()); + * const collector = createDataCollector({ config: runtimeConfig, logger, clock }); + * ``` + */ +export function toDataCollectorConfig(config: CollectorConfig): DataCollectorConfig { + return { + outputDir: config.outputDir, + polymarket: { + sourceSubDir: config.sourceSubDir, + bufferSize: config.bufferSize, + flushIntervalMs: config.flushIntervalMs, + compression: config.compression, + }, + discovery: { + filter: { + minTimeToExpiryHours: config.minTimeToExpiryHours, + minSpread: config.minSpread, + minLiquidity: config.minLiquidity, + maxMarketsToReturn: config.maxMarkets, + requiredKeywords: config.requiredKeywords, + anyOfKeywords: config.anyOfKeywords, + excludedKeywords: config.excludedKeywords, + }, + }, + collection: { + maxMarkets: config.maxMarkets, + discoveryRefreshMs: config.marketScanPauseMs, + runtimeTickMs: DEFAULT_RUNTIME_TICK_MS, + }, + finalization: {}, + cex: { + sources: config.cexConfig === null ? [] : parseCexSourceConfigs(config.cexConfig), + bufferSize: config.cexBufferSize, + flushIntervalMs: config.cexFlushIntervalMs, + compression: config.compression, + ...(config.cexWindowMinutes !== undefined + ? { windowMinutes: config.cexWindowMinutes } + : {}), + }, + }; +} diff --git a/apps/collect-data/src/runtime/collectionLifecycle.ts b/apps/collect-data/src/runtime/collectionLifecycle.ts new file mode 100644 index 00000000..59eaf19f --- /dev/null +++ b/apps/collect-data/src/runtime/collectionLifecycle.ts @@ -0,0 +1,317 @@ +/** + * Read-only проекция collection lifecycle рынков. + * + * @remarks + * ### Почему проекция, а не события компонентов + * + * Collection-состоянием владеют `MarketCollectionCoordinator` (какие сессии + * открыты) и `MarketFinalizer` (какие сессии архивируются) — оба сознательно + * остаются collection-specific и НЕ превращаются в глобальный менеджер + * рынков. Поэтому наблюдаемость строится СНАРУЖИ: рантайм и так владеет + * тиком цикла, поэтому он же диффит публичные снимки компонентов и + * превращает переходы в события: + * + * ```text + * discovery.findCandidates() ──► DISCOVERED + * coordinator.listSessions() ─┬─► COLLECTION_STARTED (сессия стала ACTIVE) + * ├─► FINALIZING (expiry: ACTIVE → FINALIZING) + * ├─► FINALIZED (сессия ушла из FINALIZING) + * └─► DROPPED (сессия исчезла без финализации) + * ``` + * + * Это НЕ domain-события и НЕ trading lifecycle: здесь только + * operational-состояние сбора (MR-A PART 15 — контуры lifecycle не + * смешиваются). + * + * ### Ограничение гранулярности + * + * Проекция видит состояние в моменты тиков. Переход, целиком уместившийся + * МЕЖДУ двумя тиками, наблюдается как его итог: `OPENING` внутри одного + * `fillSlots()` не порождает отдельного события — рынок появляется сразу как + * `COLLECTION_STARTED`. Для рынков, живущих минуты, при тике в секундах это + * не теряет ни одного значимого перехода. + */ +import type { MarketId } from '@polymarket/ids'; + +/** Вид события collection lifecycle. */ +export type CollectionLifecycleKind = + | 'DISCOVERED' + | 'COLLECTION_STARTED' + | 'FINALIZING' + | 'FINALIZED' + | 'DROPPED'; + +/** Причина, по которой сессия исчезла, не пройдя финализацию. */ +export type CollectionDropReason = 'shutdown' | 'source-failure'; + +/** Итог завершённой финализации, когда он однозначно выводим. */ +export type CollectionFinalizedOutcome = 'archived' | 'failed'; + +/** + * Одно наблюдаемое событие collection lifecycle. + */ +export interface CollectionLifecycleEvent { + /** Вид перехода. */ + readonly kind: CollectionLifecycleKind; + /** Canonical id рынка (== Polymarket conditionId). */ + readonly marketId: MarketId; + /** Момент наблюдения перехода (ms). */ + readonly atMs: number; + /** Вопрос рынка, если известен на момент события. */ + readonly question?: string; + /** Истечение рынка (ms), если известно. */ + readonly expiresAtMs?: number; + /** + * Итог финализации (только `kind === 'FINALIZED'`). + * + * @remarks + * Выводится из дельты счётчиков финализатора и однозначен, пока за тик + * финализацию покидает один рынок; при одновременном завершении + * нескольких сессий поле не заполняется — авторитетные суммарные + * значения всегда доступны в `MarketFinalizerStats`. + */ + readonly outcome?: CollectionFinalizedOutcome; + /** Причина отбрасывания (только `kind === 'DROPPED'`). */ + readonly reason?: CollectionDropReason; +} + +/** Подписчик на события lifecycle (read-only наблюдение). */ +export type CollectionLifecycleListener = (event: CollectionLifecycleEvent) => void; + +/** Накопительные счётчики наблюдённых переходов. */ +export interface CollectionLifecycleCounts { + readonly discovered: number; + readonly collectionStarted: number; + readonly finalizing: number; + readonly finalized: number; + readonly archived: number; + readonly dropped: number; +} + +/** Минимальный снимок сессии, нужный проекции. */ +export interface LifecycleSessionSnapshot { + readonly marketId: MarketId; + readonly state: 'OPENING' | 'ACTIVE' | 'FINALIZING'; + readonly question?: string; + readonly expiresAt?: { toNumber(): number }; +} + +/** Минимальный снимок кандидата, нужный проекции. */ +export interface LifecycleCandidateSnapshot { + readonly marketId: MarketId; + readonly question: string; + readonly expiresAt: { toNumber(): number }; +} + +/** Счётчики финализатора, по которым выводится итог финализации. */ +export interface LifecycleFinalizerCounters { + readonly archivedTotal: number; + readonly archiveFailures: number; +} + +/** + * Предыдущее наблюдение одной сессии. + * + * @remarks + * Хранит и `marketId`: ключом карты служит `String(marketId)`, а событие об + * ИСЧЕЗНУВШЕЙ сессии выдаётся тогда, когда её снимка уже нет — восстановить + * typed-идентификатор из строки было бы невозможно без обратного каста. + */ +interface ObservedSession { + readonly marketId: MarketId; + readonly state: 'OPENING' | 'ACTIVE' | 'FINALIZING'; +} + +/** Время жизни записи о замеченном кандидате (сутки). */ +const DISCOVERED_TTL_MS = 24 * 60 * 60_000; + +/** + * Диффер публичных снимков коллекции в поток lifecycle-событий. + * + * @remarks + * Экземпляр хранит только предыдущее наблюдение (состояния сессий, дельты + * счётчиков финализатора и TTL-ограниченный набор уже замеченных + * кандидатов) — собственного «истинного» состояния рынка у него нет. + * + * @example + * ```typescript + * const projection = new CollectionLifecycleProjection(); + * for (const event of projection.observeCandidates(candidates, Date.now())) { + * logger.info('Market discovered', { marketId: String(event.marketId) }); + * } + * ``` + */ +export class CollectionLifecycleProjection { + /** Сессии на предыдущем наблюдении (`String(marketId)` → состояние + identity). */ + private readonly _sessionStates = new Map(); + /** Уже объявленные DISCOVERED рынки (`String(marketId)` → момент, ms). */ + private readonly _discovered = new Map(); + private _lastArchivedTotal = 0; + private _lastArchiveFailures = 0; + private _counts = { + discovered: 0, + collectionStarted: 0, + finalizing: 0, + finalized: 0, + archived: 0, + dropped: 0, + }; + + /** Накопленные счётчики наблюдённых переходов. */ + public getCounts(): CollectionLifecycleCounts { + return { ...this._counts }; + } + + /** + * Наблюдает свежий candidate cache и выдаёт события впервые увиденных рынков. + * + * @param candidates - Текущее содержимое кэша Discovery + * @param nowMs - Момент наблюдения (ms) + * @returns События `DISCOVERED` для новых рынков + * + * @remarks + * Набор уже объявленных рынков ограничен по TTL (сутки): рынок такого + * возраста давно вне окна discovery, поэтому повторного объявления не + * происходит, а память не растёт неограниченно. + */ + public observeCandidates( + candidates: readonly LifecycleCandidateSnapshot[], + nowMs: number, + ): readonly CollectionLifecycleEvent[] { + this._pruneDiscovered(nowMs); + const events: CollectionLifecycleEvent[] = []; + for (const candidate of candidates) { + const key = String(candidate.marketId); + if (this._discovered.has(key)) { + continue; + } + this._discovered.set(key, nowMs); + this._counts.discovered++; + events.push({ + kind: 'DISCOVERED', + marketId: candidate.marketId, + atMs: nowMs, + question: candidate.question, + expiresAtMs: candidate.expiresAt.toNumber(), + }); + } + return events; + } + + /** + * Наблюдает текущие сессии и выдаёт события их переходов. + * + * @param sessions - Снимок сессий координатора + * @param finalizer - Счётчики финализатора на тот же момент + * @param nowMs - Момент наблюдения (ms) + * @param options - Контекст наблюдения (идёт ли остановка рантайма) + * @returns События переходов, наблюдённые с прошлого вызова + * + * @remarks + * `OPENING` не порождает события: это внутренняя фаза открытия сессии, + * завершающаяся в пределах одного `fillSlots()`; наблюдаемым результатом + * является `ACTIVE` (`COLLECTION_STARTED`) либо полное отсутствие сессии + * (открытие откатилось — событие не выдаётся, поскольку сбор не начинался). + */ + public observeSessions( + sessions: readonly LifecycleSessionSnapshot[], + finalizer: LifecycleFinalizerCounters, + nowMs: number, + options: { readonly shuttingDown: boolean }, + ): readonly CollectionLifecycleEvent[] { + const events: CollectionLifecycleEvent[] = []; + const seen = new Set(); + + for (const session of sessions) { + const key = String(session.marketId); + seen.add(key); + const previous = this._sessionStates.get(key); + this._sessionStates.set(key, { marketId: session.marketId, state: session.state }); + if (previous?.state === session.state) { + continue; + } + if (session.state === 'ACTIVE') { + this._counts.collectionStarted++; + events.push({ + kind: 'COLLECTION_STARTED', + marketId: session.marketId, + atMs: nowMs, + ...(session.question !== undefined ? { question: session.question } : {}), + ...(session.expiresAt !== undefined + ? { expiresAtMs: session.expiresAt.toNumber() } + : {}), + }); + } else if (session.state === 'FINALIZING') { + this._counts.finalizing++; + events.push({ + kind: 'FINALIZING', + marketId: session.marketId, + atMs: nowMs, + ...(session.question !== undefined ? { question: session.question } : {}), + ...(session.expiresAt !== undefined + ? { expiresAtMs: session.expiresAt.toNumber() } + : {}), + }); + } + } + + // Исчезнувшие сессии: из FINALIZING — завершённая финализация, иначе — + // отброшенная сессия (реконсиляция отказа source / остановка рантайма). + const departed: Array<{ readonly key: string; readonly session: ObservedSession }> = []; + for (const [key, session] of this._sessionStates) { + if (!seen.has(key)) { + departed.push({ key, session }); + } + } + const archivedDelta = finalizer.archivedTotal - this._lastArchivedTotal; + const failuresDelta = finalizer.archiveFailures - this._lastArchiveFailures; + this._lastArchivedTotal = finalizer.archivedTotal; + this._lastArchiveFailures = finalizer.archiveFailures; + const finalizedDeparted = departed.filter((entry) => entry.session.state === 'FINALIZING'); + + for (const entry of departed) { + this._sessionStates.delete(entry.key); + const marketId = entry.session.marketId; + if (entry.session.state === 'FINALIZING') { + this._counts.finalized++; + // Итог однозначен, только когда финализацию покинул ровно один рынок. + const outcome: CollectionFinalizedOutcome | undefined = + finalizedDeparted.length !== 1 + ? undefined + : failuresDelta > 0 + ? 'failed' + : archivedDelta > 0 + ? 'archived' + : undefined; + if (outcome === 'archived') { + this._counts.archived++; + } + events.push({ + kind: 'FINALIZED', + marketId, + atMs: nowMs, + ...(outcome !== undefined ? { outcome } : {}), + }); + } else { + this._counts.dropped++; + events.push({ + kind: 'DROPPED', + marketId, + atMs: nowMs, + reason: options.shuttingDown ? 'shutdown' : 'source-failure', + }); + } + } + + return events; + } + + /** Удаляет записи о кандидатах старше TTL. */ + private _pruneDiscovered(nowMs: number): void { + for (const [key, seenAtMs] of this._discovered) { + if (nowMs - seenAtMs >= DISCOVERED_TTL_MS) { + this._discovered.delete(key); + } + } + } +} diff --git a/apps/collect-data/src/runtime/createDataCollector.ts b/apps/collect-data/src/runtime/createDataCollector.ts new file mode 100644 index 00000000..b277688b --- /dev/null +++ b/apps/collect-data/src/runtime/createDataCollector.ts @@ -0,0 +1,202 @@ +/** + * Composition root контура сбора: единственное место, где создаются bus, + * recorder, source-ы и control-plane компоненты. + * + * @remarks + * Фабрика существует, чтобы production-`main.ts` и verification-runner + * checkpoint-а поднимали ОДИН И ТОТ ЖЕ контур: + * + * ```text + * createDataCollector(...) + * ↑ ↑ + * production checkpoint + * main verification + * ``` + * + * Второй copy composition root в репозитории быть не должно: расхождение + * между «проверенным» и «работающим» контуром — именно тот класс дефектов, + * который verification обязан ловить, а не создавать. + */ +import { createPublicClient } from '@polymarket/client'; +import type { ILogger } from '@polymarket/logger'; +import type { IClock } from '@polymarket/time'; +import { LiveHighResolutionClock, MessageMetadataGenerator } from '@polymarket/messages'; +import { MarketFilter, MarketScorer } from '@polymarket/market-discovery'; +import { ExternalMessageBus } from '@polymarket/external-message-bus'; +import { PolymarketMarketDiscovery, PolymarketSource } from '@polymarket/polymarket-v2'; +import type { PolymarketExternalMessage } from '@polymarket/polymarket-v2'; +import { + CexWindowRecorder, + DataRecorder, + GzipCompressor, + NDJSONFormatter, +} from '@polymarket/data-collection'; +import { ExternalMessageRecorder } from '@polymarket/external-message-recorder'; +import { MarketCollectionCoordinator } from '@polymarket/collection-coordinator'; +import { MarketFinalizer } from '@polymarket/market-finalizer'; +import { CexSource } from '@polymarket/cex-v2'; +import type { CexExternalMessage } from '@polymarket/cex-v2'; +import type { DataCollectorConfig } from './DataCollectorConfig.js'; +import type { CollectorCexSourceEntry } from './DataCollector.js'; +import { DataCollector } from './DataCollector.js'; + +/** + * Union сообщений всех raw sources контура на ОДНОМ bus. + * + * @remarks + * Общий тип bus-а — это и есть raw-event boundary системы: будущий + * Semantic Adapter подписывается на него, а не на API коллектора. + */ +export type ContourMessage = PolymarketExternalMessage | CexExternalMessage; + +/** Общий bus внешнего контура коллектора. */ +export type ContourBus = ExternalMessageBus; + +/** Зависимости {@link createDataCollector}. */ +export interface CreateDataCollectorOptions { + /** Конфигурация рантайма (см. `DataCollectorConfig`). */ + readonly config: DataCollectorConfig; + /** Логгер приложения. */ + readonly logger: ILogger; + /** Источник времени. */ + readonly clock: IClock; + /** + * Общий raw bus. Если не передан — создаётся фабрикой. + * + * @remarks + * Передача снаружи — тот самый extension point: consumer (наблюдатель + * checkpoint-а, будущий Semantic Adapter) создаёт bus, подписывается на + * него и только потом отдаёт его коллектору. Обратной зависимости не + * возникает: consumer знает про bus, а не про `DataCollector`. + */ + readonly bus?: ContourBus; +} + +/** Результат сборки контура. */ +export interface CreatedDataCollector { + /** Рантайм сбора. */ + readonly collector: DataCollector; + /** + * Общий raw bus контура. + * + * @remarks + * Возвращается, чтобы consumer мог подписаться ДО `collector.start()` — + * recorder уже подписан, ingress ещё не начат. + */ + readonly bus: ContourBus; +} + +/** + * Собирает полный контур сбора и возвращает рантайм вместе с общим bus. + * + * @param options - Конфигурация, логгер, часы и (опционально) готовый bus + * @returns Рантайм и общий raw bus контура + * + * @remarks + * Инварианты сборки: + * - ОДИН `ExternalMessageBus` на процесс (отдельных PM/CEX/RTDS bus нет); + * - ОДИН `ExternalMessageRecorder` как общий recording-consumer, под + * которым живут ДВЕ storage-политики (market-сессии и CEX-окна); + * - весь ingress идёт `source → bus`; recorder — consumer bus, а не цель + * прямых вызовов source-ов; + * - обе storage-политики получают ОДИН `outputDir` (раскладка parity с + * legacy: `{date}/{sourceSubDir}/` для рынков, `{date}/{exchange}/` для бирж). + * + * @example + * ```typescript + * const { collector, bus } = createDataCollector({ config, logger, clock }); + * bus.subscribe('CEX_TRADE', (message) => observer.onTrade(message.payload)); + * await collector.start(); + * ``` + */ +export function createDataCollector(options: CreateDataCollectorOptions): CreatedDataCollector { + const { config, logger, clock } = options; + const bus: ContourBus = options.bus ?? new ExternalMessageBus(); + const client = createPublicClient(); + const metadataGenerator = new MessageMetadataGenerator({ + clock, + highResolutionClock: new LiveHighResolutionClock(), + }); + + // ── Storage-политики (общий корень датасетов) ───────────────────────── + const polymarketStorage = new DataRecorder( + { + outputDir: config.outputDir, + sourceSubDir: config.polymarket.sourceSubDir, + bufferSize: config.polymarket.bufferSize, + flushIntervalMs: config.polymarket.flushIntervalMs, + compression: config.polymarket.compression, + formatVersion: 2, + }, + new NDJSONFormatter(), + config.polymarket.compression === 'gzip' ? new GzipCompressor() : null, + logger, + ); + const cexStorage = new CexWindowRecorder( + { + outputDir: config.outputDir, + compression: config.cex.compression, + bufferSize: config.cex.bufferSize, + flushIntervalMs: config.cex.flushIntervalMs, + ...(config.cex.windowMinutes !== undefined + ? { windowMinutes: config.cex.windowMinutes } + : {}), + }, + logger, + ); + + // ── ОДИН recording-consumer общего bus ──────────────────────────────── + const recorder = new ExternalMessageRecorder({ + bus, + storage: polymarketStorage, + logger, + ...(config.cex.sources.length > 0 ? { cex: { bus, storage: cexStorage } } : {}), + }); + + // ── Polymarket: ingress + discovery + coordinator + finalizer ───────── + const polymarketSource = new PolymarketSource({ client, bus, metadataGenerator, logger }); + const discovery = new PolymarketMarketDiscovery( + { client, filter: new MarketFilter(), scorer: new MarketScorer(clock), clock, logger }, + { filter: config.discovery.filter }, + ); + const coordinator = new MarketCollectionCoordinator( + { discovery, source: polymarketSource, recorder, clock, logger }, + { + maxMarkets: config.collection.maxMarkets, + ...(config.collection.minTimeToStartMs !== undefined + ? { minTimeToStartMs: config.collection.minTimeToStartMs } + : {}), + }, + ); + const finalizer = new MarketFinalizer( + { coordinator, recorder, gamma: client, clock, logger }, + config.finalization, + ); + + // ── CEX: по одному source на биржу, на ТОМ ЖЕ bus ───────────────────── + const cexSources: readonly CollectorCexSourceEntry[] = config.cex.sources.map( + (sourceConfig) => ({ + exchangeId: sourceConfig.exchangeId, + source: new CexSource({ config: sourceConfig, bus, metadataGenerator, logger }), + }), + ); + + const collector = new DataCollector({ + components: { + bus, + recorder, + polymarketStorage, + cexStorage, + polymarketSource, + cexSources, + discovery, + coordinator, + finalizer, + }, + collection: config.collection, + clock, + logger, + }); + + return { collector, bus }; +} diff --git a/apps/collect-data/src/runtime/index.ts b/apps/collect-data/src/runtime/index.ts new file mode 100644 index 00000000..78542ade --- /dev/null +++ b/apps/collect-data/src/runtime/index.ts @@ -0,0 +1,53 @@ +/** + * Публичный API рантайма коллектора. + * + * @remarks + * Экспортируется для двух потребителей: production-`main.ts` и + * verification-runner CHECKPOINT-а, которые обязаны поднимать один и тот же + * контур. Semantic-слой этим API пользоваться НЕ должен: его точка входа — + * общий `ExternalMessageBus`, а не рантайм сбора. + */ +export { DataCollector } from './DataCollector.js'; +export type { + CexSourceHealth, + CollectorBus, + CollectorCexSource, + CollectorCexSourceEntry, + CollectorCexStorage, + CollectorCoordinator, + CollectorDiscovery, + CollectorFinalizer, + CollectorPolymarketSource, + CollectorPolymarketStorage, + CollectorRecorder, + DataCollectorComponents, + DataCollectorDependencies, + DataCollectorState, + DataCollectorStatus, +} from './DataCollector.js'; +export { createDataCollector } from './createDataCollector.js'; +export type { + ContourBus, + ContourMessage, + CreateDataCollectorOptions, + CreatedDataCollector, +} from './createDataCollector.js'; +export { parseCexSourceConfigs, toDataCollectorConfig } from './DataCollectorConfig.js'; +export type { + CexCollectionConfig, + CollectionRuntimeConfig, + DataCollectorConfig, + FinalizationRuntimeConfig, + PolymarketRecordingConfig, +} from './DataCollectorConfig.js'; +export { CollectionLifecycleProjection } from './collectionLifecycle.js'; +export type { + CollectionDropReason, + CollectionFinalizedOutcome, + CollectionLifecycleCounts, + CollectionLifecycleEvent, + CollectionLifecycleKind, + CollectionLifecycleListener, +} from './collectionLifecycle.js'; +export { applyProcessBootstrap, installShutdownHandlers } from './processBootstrap.js'; +export type { ProcessBootstrap, ShutdownTarget } from './processBootstrap.js'; diff --git a/apps/collect-data/src/runtime/processBootstrap.ts b/apps/collect-data/src/runtime/processBootstrap.ts new file mode 100644 index 00000000..4f181224 --- /dev/null +++ b/apps/collect-data/src/runtime/processBootstrap.ts @@ -0,0 +1,159 @@ +/** + * Process-level подготовка и завершение процесса коллектора. + * + * @remarks + * Всё, что относится к процессу, а не к контуру сбора: обход DNS и один + * охраняемый путь остановки по сигналу. Рантайм об этом не знает — + * `DataCollector` остаётся чистой composition без process-специфики. + */ +import type { ILogger } from '@polymarket/logger'; +import { DnsOverride } from '@polymarket/exchange/dns'; + +/** + * Хосты, которые контур V2 действительно резолвит. + * + * @remarks + * Список выведен из фактических вызовов V2-пути, а не скопирован из + * legacy-коллектора: + * - `gamma-api` — discovery (`listMarkets`) и enrichment (`fetchMarket`/`fetchEvent`); + * - `clob` — REST официального SDK; + * - `ws-subscriptions-clob` — market-подписки `PolymarketSource`; + * - `ws-live-data` — RTDS-фиды крипто-цен. + * + * `data-api.polymarket.com` из legacy-списка исключён сознательно: он + * обслуживает пользовательские позиции/сделки и в пути сбора не участвует. + * Хосты бирж не входят в список: CCXT резолвит их системным DNS, который + * для них не блокируется. + */ +const V2_COLLECTOR_HOSTS: readonly string[] = [ + 'gamma-api.polymarket.com', + 'clob.polymarket.com', + 'ws-subscriptions-clob.polymarket.com', + 'ws-live-data.polymarket.com', +]; + +/** Результат подготовки процесса. */ +export interface ProcessBootstrap { + /** Снимает установленные process-level патчи. */ + readonly dispose: () => void; +} + +/** + * Применяет process-level требования до любых сетевых вызовов. + * + * @param options - Признак включения DNS-обхода и логгер + * @returns Хэндл для снятия установленных патчей + * + * @remarks + * DNS-обход опционален (`DNS_OVERRIDE_ENABLED`) и нужен только на машинах с + * подменённым DNS провайдера; его отказ не мешает старту — процесс + * продолжает с системным DNS. + * + * @example + * ```typescript + * const bootstrap = await applyProcessBootstrap({ dnsOverrideEnabled: true, logger }); + * // ... работа ... + * bootstrap.dispose(); + * ``` + */ +export async function applyProcessBootstrap(options: { + readonly dnsOverrideEnabled: boolean; + readonly logger: ILogger; +}): Promise { + const { dnsOverrideEnabled, logger } = options; + if (!dnsOverrideEnabled) { + logger.debug('DNS override disabled (DNS_OVERRIDE_ENABLED != true)'); + return { dispose: () => undefined }; + } + + const dnsOverride = new DnsOverride(logger); + try { + await dnsOverride.install([...V2_COLLECTOR_HOSTS]); + logger.info('DNS override installed', { hosts: V2_COLLECTOR_HOSTS.length }); + } catch (error) { + logger.warn('DNS override install failed, continuing with system DNS', { + error: error instanceof Error ? error.message : String(error), + }); + } + return { + dispose: () => { + dnsOverride.uninstall(); + }, + }; +} + +/** Что останавливать по сигналу. */ +export interface ShutdownTarget { + /** Остановка контура (идемпотентна). */ + readonly close: () => Promise; +} + +/** + * Устанавливает единственный охраняемый путь остановки процесса. + * + * @param options - Цель остановки, process-хэндл и логгер + * @returns Promise, разрешающийся после завершения остановки + * + * @remarks + * Повторный сигнал НЕ запускает вторую параллельную остановку: `SIGINT`, + * `SIGTERM` и фатальная ошибка сходятся в один путь, который выполняется + * ровно один раз. `process.exit()` не вызывается — процесс обязан + * завершиться сам, когда не осталось живых хэндлов; принудительный выход + * замаскировал бы утечку таймера или незакрытый поток. + * + * @example + * ```typescript + * const stopped = installShutdownHandlers({ target: collector, bootstrap, logger }); + * await collector.start(); + * await stopped; + * ``` + */ +export function installShutdownHandlers(options: { + readonly target: ShutdownTarget; + readonly bootstrap: ProcessBootstrap; + readonly logger: ILogger; +}): Promise { + const { target, bootstrap, logger } = options; + let shutdownRun: Promise | null = null; + + const shutdown = (reason: string): Promise => { + if (shutdownRun !== null) { + logger.info('Shutdown already in progress, ignoring signal', { reason }); + return shutdownRun; + } + logger.info('Shutting down', { reason }); + shutdownRun = (async () => { + try { + await target.close(); + } catch (error) { + logger.error('Collector close failed', { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + bootstrap.dispose(); + logger.info('Shutdown complete'); + } + })(); + return shutdownRun; + }; + + return new Promise((resolve) => { + const finish = (reason: string): void => { + void shutdown(reason).then(resolve, resolve); + }; + process.once('SIGINT', () => finish('SIGINT')); + process.once('SIGTERM', () => finish('SIGTERM')); + process.on('uncaughtException', (error) => { + logger.fatal('Uncaught exception, shutting down', { error: error.stack ?? error.message }); + process.exitCode = 1; + finish('uncaughtException'); + }); + process.on('unhandledRejection', (reason) => { + logger.fatal('Unhandled rejection, shutting down', { + error: reason instanceof Error ? (reason.stack ?? reason.message) : String(reason), + }); + process.exitCode = 1; + finish('unhandledRejection'); + }); + }); +} diff --git a/apps/collect-data/tsconfig.build.json b/apps/collect-data/tsconfig.build.json index 7116ac96..4b55aeb7 100644 --- a/apps/collect-data/tsconfig.build.json +++ b/apps/collect-data/tsconfig.build.json @@ -23,13 +23,27 @@ "src/**/__tests__/**" ], "references": [ - { "path": "../../packages/foundation/timestamp/tsconfig.build.json" }, + { + "path": "../../packages/foundation/timestamp/tsconfig.build.json" + }, { "path": "../../packages/foundation/logger/tsconfig.build.json" }, { "path": "../../packages/foundation/ids/tsconfig.build.json" }, + { + "path": "../../packages/foundation/time/tsconfig.build.json" + }, + { + "path": "../../packages/foundation/result/tsconfig.build.json" + }, + { + "path": "../../packages/foundation/messages/tsconfig.build.json" + }, + { + "path": "../../packages/foundation/message-bus/tsconfig.build.json" + }, { "path": "../../packages/domain/value-objects/tsconfig.build.json" }, @@ -43,7 +57,25 @@ "path": "../../packages/infrastructure/persistence/data-collection/tsconfig.build.json" }, { - "path": "../../packages/infrastructure/cex-market-data/tsconfig.build.json" + "path": "../../packages/infrastructure/persistence/external-message-recorder/tsconfig.build.json" + }, + { + "path": "../../packages/infrastructure/external-messages/tsconfig.build.json" + }, + { + "path": "../../packages/infrastructure/external-message-bus/tsconfig.build.json" + }, + { + "path": "../../packages/infrastructure/polymarket-v2/tsconfig.build.json" + }, + { + "path": "../../packages/infrastructure/collection-coordinator/tsconfig.build.json" + }, + { + "path": "../../packages/infrastructure/market-finalizer/tsconfig.build.json" + }, + { + "path": "../../packages/infrastructure/cex-v2/tsconfig.build.json" }, { "path": "../../packages/infrastructure/polymarket/tsconfig.build.json" diff --git a/apps/collect-data/tsconfig.json b/apps/collect-data/tsconfig.json index 1ac1a649..bcb0f1ae 100644 --- a/apps/collect-data/tsconfig.json +++ b/apps/collect-data/tsconfig.json @@ -4,6 +4,17 @@ "outDir": "./dist", "allowImportingTsExtensions": true, "paths": { + "@polymarket/messages": ["../../packages/foundation/messages/src/index.ts"], + "@polymarket/message-bus": ["../../packages/foundation/message-bus/src/index.ts"], + "@polymarket/external-messages": ["../../packages/infrastructure/external-messages/src/index.ts"], + "@polymarket/external-message-bus": ["../../packages/infrastructure/external-message-bus/src/index.ts"], + "@polymarket/external-message-recorder": ["../../packages/infrastructure/persistence/external-message-recorder/src/index.ts"], + "@polymarket/polymarket-v2": ["../../packages/infrastructure/polymarket-v2/src/index.ts"], + "@polymarket/collection-coordinator": ["../../packages/infrastructure/collection-coordinator/src/index.ts"], + "@polymarket/market-finalizer": ["../../packages/infrastructure/market-finalizer/src/index.ts"], + "@polymarket/cex-v2": ["../../packages/infrastructure/cex-v2/src/index.ts"], + "@polymarket/timestamp": ["../../packages/foundation/timestamp/src/index.ts"], + "@polymarket/math": ["../../packages/foundation/math/src/index.ts"], "@polymarket/logger": ["../../packages/foundation/logger/src/index.ts"], "@polymarket/ids": ["../../packages/foundation/ids/src/index.ts"], "@polymarket/result": ["../../packages/foundation/result/src/index.ts"], @@ -12,7 +23,6 @@ "@polymarket/ports": ["../../packages/application/ports/src/index.ts"], "@polymarket/market-discovery": ["../../packages/application/market-discovery/src/index.ts"], "@polymarket/data-collection": ["../../packages/infrastructure/persistence/data-collection/src/index.ts"], - "@polymarket/cex-market-data": ["../../packages/infrastructure/cex-market-data/src/index.ts"], "@polymarket/exchange/ws": ["../../packages/infrastructure/polymarket/ws/index.ts"], "@polymarket/exchange/adapters": ["../../packages/infrastructure/polymarket/adapters/index.ts"], "@polymarket/exchange/rest": ["../../packages/infrastructure/polymarket/rest/index.ts"], diff --git a/docs/guides/data-collector-v2-cutover.md b/docs/guides/data-collector-v2-cutover.md new file mode 100644 index 00000000..f852d5a5 --- /dev/null +++ b/docs/guides/data-collector-v2-cutover.md @@ -0,0 +1,211 @@ +# Data Collector V2 — production cutover (MR-A) + +## Зачем это сделано + +К моменту MR-A в репозитории было ДВА контура сбора данных: + +- **V1** — `apps/collect-data/src/main.ts`: 900 строк, где composition, WS-обработчики, + карты подписок, очередь обогащения, blacklist истёкших рынков и лестница остановки + жили в одном файле, а source писал в recorder напрямую; +- **V2** — набор пакетов (`polymarket-v2`, `cex-v2`, `external-message-bus`, + `external-message-recorder`, `collection-coordinator`, `market-finalizer`), собранных + вместе только в verification-скрипте `scripts/checkpoint-raw-live.mts` и доказанных + live на CHECKPOINT #1. + +MR-A превращает уже доказанную composition в production-приложение и удаляет V1. +Постепенного рефакторинга V1 не было и не могло быть: это не улучшение старой +архитектуры, а замена её на уже проверенную. + +Главное, что этот MR обязан НЕ сломать, — возможность подключить к тому же потоку +сырых сообщений будущий Semantic Adapter, не трогая ни source-пакеты, ни recorder. + +## Итоговая архитектура + +```mermaid +flowchart TB + subgraph ingress["raw ingress"] + PM["PolymarketSource V2
market + RTDS"] + CEX["CexSource V2 × N
orderbook + trades"] + end + + BUS(["ОДИН ExternalMessageBus"]) + + subgraph consumers["consumers"] + REC["ExternalMessageRecorder"] + OBS["любой наблюдатель
(checkpoint сегодня,
Semantic Adapter завтра)"] + end + + subgraph storage["storage policies"] + PMW["DataRecorder
market-сессии"] + CEXW["CexWindowRecorder
5-мин окна"] + end + + PM --> BUS + CEX --> BUS + BUS --> REC + BUS --> OBS + REC --> PMW + REC --> CEXW + PMW --> JSONL1["{date}/polymarket/*.jsonl.gz"] + CEXW --> JSONL2["{date}/{exchange}/*.jsonl.gz"] +``` + +Control plane сбора живёт рядом с data plane, но не внутри него: + +```text +PolymarketMarketDiscovery → MarketCollectionCoordinator → MarketFinalizer + «что доступно» «что записываем сейчас» «как закрываем» +``` + +Оркестрирует их `DataCollector` — он владеет тиком цикла, но НЕ владеет их +состоянием: карт подписок, blacklist-ов и очередей обогащения у него нет. + +## Что из CHECKPOINT стало production, а что осталось verification + +Checkpoint был прототипом production composition, обёрнутым в verification-логику. +Разделение прошло по этой границе. + +| Часть checkpoint | Куда ушла | +| ------------------------------------------------------ | --------------------------------------------- | +| один `ExternalMessageBus` + `MessageMetadataGenerator` | `createDataCollector` | +| один `ExternalMessageRecorder` c двумя политиками | `createDataCollector` | +| `DataRecorder` + `CexWindowRecorder` | `createDataCollector` | +| `PolymarketSource` / `PolymarketMarketDiscovery` | `createDataCollector` | +| `MarketCollectionCoordinator` / `MarketFinalizer` | `createDataCollector` | +| `CexSource[]` по одной на биржу | `createDataCollector` | +| порядок старта (cleanup → recorder → ingress) | `DataCollector.start()` | +| цикл `refreshCandidates`/`fillSlots`/`runOnce` | `DataCollector.tick()` | +| лестница остановки контура | `DataCollector.close()` | +| `finalizer.drain()` перед остановкой | `DataCollector.drain()` (опционально) | +| PASS/FAIL, `report.json`, дедлайны прогона | остался в `scripts/checkpoint-raw-live.mts` | +| `CountingLogger`, ring-буферы, exact-match сэмплы | остался в checkpoint | +| gunzip/readback валидатор артефактов | остался в checkpoint | +| широкий discovery-фильтр, `CEX_PLAN`, изолированный вывод | остался в checkpoint как ЕГО конфигурация | + +После extraction checkpoint не поднимает вторую копию composition — он вызывает ту же +`createDataCollector`, что и production `main.ts`, и подписывается на общий bus как +обычный наблюдатель. Это и есть живое доказательство, что точка расширения работает. + +## V1 → V2: кто теперь владеет каждой ответственностью + +| Ответственность V1 | Статус | Новый владелец | +| ------------------------------- | ---------- | --------------------------------------------- | +| Polymarket WS-подписки | заменена | `PolymarketSource` (официальный SDK) | +| discovery рынков | заменена | `PolymarketMarketDiscovery` | +| карта подписанных рынков/токенов | заменена | `MarketCollectionCoordinator` | +| RTDS-подписки + ref-count | заменена | `MarketCollectionCoordinator` (shared feeds) | +| запись Polymarket-файлов | сохранена | `DataRecorder` (та же реализация) | +| CEX-потоки | заменена | `CexSource V2` | +| ротация CEX-файлов | заменена | `CexWindowRecorder` | +| обогащение meta после истечения | заменена | `MarketFinalizer` | +| blacklist `closedMarkets` + TTL | удалена | не нужна: координатор владеет lifecycle сессий | +| ручной scan истечений | удалена | не нужна: expiry-переход — у координатора | +| очередь `pendingEnrichment` | удалена | заменена на `MarketFinalizer` | +| прямая запись source → recorder | удалена | запрещена: только `source → bus → recorder` | +| DNS override | сохранена | `runtime/processBootstrap.ts` (список хостов пересобран под V2) | +| обработка сигналов | сохранена | `runtime/processBootstrap.ts` (один охраняемый путь) | +| лог потребления памяти | сохранена | объединён в периодический `collector.status()` | +| heap snapshot по `SIGUSR2` | удалена | отладочный инструмент, не операционная функция | +| `process.exit()` в shutdown | удалена | процесс завершается сам; выход маскировал бы утечку | + +## Аудит поведения файлов + +Раскладка датасетов сохранена от V1 — её ожидают уже собранные архивы, ридеры и +бэктест: + +```text +{outputDir}/{YYYY-MM-DD}/polymarket/{question}___{marketId}.jsonl[.gz] +{outputDir}/{YYYY-MM-DD}/{exchangeId}/{exchange}_{symbol}_{marketType}_{stream}_{dateET}_{startET}-{endET}_ET.jsonl[.gz] +``` + +| Поведение | V1 | V2 | Решение | +| ---------------------- | ----------------------------------- | ---------------------------------------- | ---------------- | +| date-директории | `{out}/{YYYY-MM-DD}/` | то же | parity | +| директория источника | `polymarket/` и `{exchange}/` | то же | parity | +| имя PM-файла | `{question}___{marketId}` | то же | parity | +| имя CEX-файла | без сегмента потока | **добавлен `{stream}`** | V2 лучше | +| буферизация | 100 / 10 c (PM), 200 / 5 c (CEX) | то же (настраивается) | parity | +| ротация CEX | окно 5 минут по ET-границам | то же + sweep «тихих» writer-ов | V2 лучше | +| gzip | при финализации/ротации | то же | parity | +| `.jsonl` = incomplete | да | да | parity | +| startup cleanup | обе политики чистят свои остатки | то же | parity | +| shutdown cleanup | PM удаляет незавершённые, CEX — свои| то же | parity | +| формат строк | wire-формат legacy WS | **`formatVersion: 2`**, source-native SDK | V2, осознанно | + +Два уточнения, которые стоит держать в голове: + +- **Разделение потоков в имени CEX-файла.** V2 пишет `orderbook` и `trades` в разные + партиции; V1 складывал их в один файл. Ридеры V2-архивов обязаны учитывать сегмент + `{stream}`. +- **Общий корень и startup cleanup.** Обе политики пишут в один `outputDir`, а + `CexWindowRecorder.cleanup()` обходит ВСЕ поддиректории date-папки, включая + `polymarket/`. Это безопасно ровно потому, что cleanup выполняется один раз при + старте, ДО первой записи, и удаляет тот же класс файлов (незавершённые `.jsonl`), + что и cleanup `DataRecorder`. Вызывать `cleanup()` на живом контуре нельзя. + +## Наблюдаемость collection lifecycle + +`DataCollector` даёт read-only поток переходов — не domain-события и не trading +lifecycle: + +| Событие | Когда | +| -------------------- | ------------------------------------------------ | +| `DISCOVERED` | рынок впервые появился в кэше кандидатов | +| `COLLECTION_STARTED` | сессия перешла в `ACTIVE` (запись пошла) | +| `FINALIZING` | рынок истёк, сессия перешла в `FINALIZING` | +| `FINALIZED` | финализация завершилась (при однозначности — с исходом) | +| `DROPPED` | сессия исчезла, не пройдя финализацию | + +```typescript +const off = collector.onMarketLifecycle((event) => { + if (event.kind === 'FINALIZED') archived.push(String(event.marketId)); +}); +``` + +Проекция строится диффом публичных снимков координатора и финализатора на каждом +тике. Так сделано сознательно: координатор остаётся collection-specific и не +превращается в глобальный менеджер рынков, а рантайм и так владеет тиком. Плата — +гранулярность тика: переход, целиком уместившийся между двумя тиками, наблюдается +как его итог. Для рынков, живущих минуты, при тике в секундах это не теряет ни +одного значимого перехода. + +`collector.status()` — снимок операционного состояния, целиком собранный из уже +существующих `getStats()` компонентов: сессии, финализации, recorder, CEX-окна, bus и +здоровье каждого source поимённо. + +## Точка расширения bus + +Bus создаётся composition factory и возвращается наружу; его можно и передать снаружи: + +```typescript +// Сегодня — checkpoint-наблюдатель: +const bus = new ExternalMessageBus(); +const { collector } = createDataCollector({ config, logger, clock, bus }); +bus.subscribe('CEX_TRADE', (message) => evidence.count(message.payload)); +await collector.start(); + +// Завтра — Semantic Adapter, тем же способом: +const bus = new ExternalMessageBus(); +semanticAdapter.attach(bus); // зависит от bus, НЕ от DataCollector +const { collector } = createDataCollector({ config, logger, clock, bus }); +await collector.start(); +``` + +Направление зависимости здесь принципиально: consumer знает про bus, а bus не знает +ни про кого. Поэтому Semantic Adapter никогда не будет вызывать +`DataCollector.getMessages()` — такого метода нет и не появится. + +Порядок подписки гарантирован конструкцией: recorder подписывается внутри +`collector.start()`, наблюдатели — до него, ingress стартует последним. + +## Что НЕ входило в MR-A + +TWAP-подписки и парсинг окон, пересмотр резолюции (60-минутный бюджет, fallback, +производный победитель), Semantic Adapter, `MarketUniverse`, dashboard и любые +изменения Application/Risk/Strategy/Execution. + +## Ссылки + +- CHECKPOINT #1: `docs/guides/checkpoint-1-raw-live.md` +- Приложение коллектора: `apps/collect-data/docs/collect-data.md` +- Запись снапшотов: `docs/architecture/data-collection.md` diff --git a/scripts/checkpoint-raw-live.mts b/scripts/checkpoint-raw-live.mts index 9d13fb80..ccf992e6 100644 --- a/scripts/checkpoint-raw-live.mts +++ b/scripts/checkpoint-raw-live.mts @@ -22,16 +22,32 @@ * FINALIZED .jsonl.gz завершённые .jsonl.gz партиции * ``` * - * Биржи и пары — подмножество production-конфига legacy-коллектора + * Контур поднимается production-фабрикой `createDataCollector` + * (`apps/collect-data/src/runtime`) — ТОЙ ЖЕ, что и production `main.ts`. + * Собственной composition у runner-а больше нет: расхождение между + * «проверенным» и «работающим» контуром — именно тот класс дефектов, + * который verification обязан ловить, а не создавать. + * + * ```text + * createDataCollector(...) + * ↑ ↑ + * production checkpoint + * main verification + * ``` + * + * Биржи и пары — подмножество production-конфига коллектора * (`apps/collect-data/cex-config.json`): binance, coinbase, kraken, - * cryptocom, okx, bybit; restartIntervalMs 15 минут — как в legacy + * cryptocom, okx, bybit; restartIntervalMs 15 минут — как в production * (плановый рестарт транспорта попадает в окно прогона и даёт живое * свидетельство restart+recovery). Окно CEX-партиций — production default * (5 минут), НЕ уменьшено. * - * Runner ТОЛЬКО композирует и наблюдает: собственных adapters нет, + * Runner ТОЛЬКО конфигурирует и наблюдает: собственных adapters нет, * payload не преобразуется, новая observability не строится (счётчики - * checkpoint-а — локальные подписки на том же bus + счётный logger-wrapper). + * checkpoint-а — независимые подписки на том же bus + счётный + * logger-wrapper + read-only lifecycle-наблюдатель рантайма). Именно + * независимая подписка на общий bus доказывает, что раздача сообщений не + * замурована в recorder: тем же способом позже подключится Semantic Adapter. * * Режимы (`CHECKPOINT_MODE`): * - `full` (default) — полный прогон до полного lifecycle Polymarket @@ -41,7 +57,7 @@ * процессе (teardown → повторный startup), короткий сбор, лёгкая валидация. * * Shutdown full-режима — graceful wind-down: перед закрытием контура - * выполняется `finalizer.drain()` — уже начатые финализации дожидаются + * выполняется `collector.drain()` — уже начатые финализации дожидаются * официальной резолюции (или полного 60-мин бюджета), опрос Gamma идёт * штатным 30-секундным cadence; SIGINT прерывает ожидание (аварийный * best-known путь close() сохранён). @@ -78,32 +94,20 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as zlib from 'node:zlib'; -import { createPublicClient } from '@polymarket/client'; import { ConsoleLogger, LogLevel } from '@polymarket/logger'; import type { ILogger } from '@polymarket/logger'; import { LiveClock } from '@polymarket/time'; -import { LiveHighResolutionClock, MessageMetadataGenerator } from '@polymarket/messages'; -import { MarketFilter, MarketScorer } from '@polymarket/market-discovery'; import { ExternalMessageBus } from '@polymarket/external-message-bus'; -import { PolymarketMarketDiscovery, PolymarketSource } from '@polymarket/polymarket-v2'; -import type { PolymarketExternalMessage } from '@polymarket/polymarket-v2'; -import { - CexWindowRecorder, - DataRecorder, - GzipCompressor, - NDJSONFormatter, -} from '@polymarket/data-collection'; -import { ExternalMessageRecorder } from '@polymarket/external-message-recorder'; -import { MarketCollectionCoordinator } from '@polymarket/collection-coordinator'; -import { MarketFinalizer } from '@polymarket/market-finalizer'; -import { CexSource } from '@polymarket/cex-v2'; -import type { CexExternalMessage, CexSourceConfig } from '@polymarket/cex-v2'; +import type { CexSourceConfig } from '@polymarket/cex-v2'; +import { createDataCollector } from '@polymarket/collect-data/runtime'; +import type { + ContourMessage, + DataCollectorConfig, + DataCollectorStatus, +} from '@polymarket/collect-data/runtime'; // ───────────────────────────── Конфигурация ───────────────────────────── -/** Union сообщений всех raw sources контура на ОДНОМ bus. */ -type ContourMessage = PolymarketExternalMessage | CexExternalMessage; - /** * CEX-план checkpoint: подмножество production-конфига legacy-коллектора * (`apps/collect-data/cex-config.json`), валидность символов подтверждена @@ -412,21 +416,66 @@ interface CompositionOptions { } /** - * Поднимает ПОЛНУЮ композицию контура (один bus, один recorder, все - * sources), собирает live-свидетельства и выполняет controlled shutdown. + * Конфигурация рантайма для checkpoint-прогона. + * + * @param runDir - Изолированный корень датасетов этого прогона + * @returns Конфигурация production-фабрики контура + * + * @remarks + * Это КОНФИГУРАЦИЯ verification-прогона, а НЕ production defaults: широкий + * discovery-фильтр, нулевые пороги, малый lead time и изолированный + * output нужны, чтобы полный lifecycle рынка уложился в окно прогона. + * Production-значения живут в `.env`/`cex-config.json` приложения. + */ +function checkpointConfig(runDir: string): DataCollectorConfig { + return { + outputDir: runDir, + polymarket: { + sourceSubDir: 'polymarket', + bufferSize: 200, + flushIntervalMs: 5_000, + compression: 'gzip', + }, + discovery: { + filter: { + minTimeToExpiryHours: 0, + minSpread: 0, + minLiquidity: 0, + maxMarketsToReturn: MAX_MARKETS * 3, + requiredKeywords: ['up or down'], + anyOfKeywords: ['bitcoin', 'ethereum', 'solana', 'xrp'], + excludedKeywords: [], + }, + }, + collection: { + maxMarkets: MAX_MARKETS, + minTimeToStartMs: 30_000, + discoveryRefreshMs: 30_000, + runtimeTickMs: 5_000, + }, + finalization: { enrichmentRetryMs: 30_000, enrichmentMaxWaitMs: 60 * 60_000 }, + cex: { + sources: CEX_PLAN, + bufferSize: 200, + flushIntervalMs: 2_000, + compression: 'gzip', + }, + }; +} + +/** + * Поднимает ПОЛНЫЙ контур production-фабрикой, собирает live-свидетельства + * независимыми подписками на общий bus и выполняет controlled shutdown. * * @param options - Параметры запуска (директории, длительности, режим) * @returns Свидетельства прогона для валидации и отчёта - * @throws {Error} При невозможности поднять композицию (fail-fast конфигурация) + * @throws {Error} При невозможности поднять контур (fail-fast конфигурация) */ async function runComposition(options: CompositionOptions): Promise { const logCounters = emptyLogCounters(); const clock = new LiveClock(); const logger: ILogger = new CountingLogger(new ConsoleLogger(clock, LogLevel.INFO), logCounters); - const pmDir = path.join(options.runDir, 'polymarket'); - const cexDir = path.join(options.runDir, 'cex'); - fs.mkdirSync(pmDir, { recursive: true }); - fs.mkdirSync(cexDir, { recursive: true }); + fs.mkdirSync(options.runDir, { recursive: true }); const evidence: CompositionEvidence = { runDir: options.runDir, @@ -455,72 +504,16 @@ async function runComposition(options: CompositionOptions): Promise spec.exchangeId), }); - // ── ONE bus / ONE recorder ──────────────────────────────────────────── - const client = createPublicClient(); + // ── Production-контур: ТА ЖЕ фабрика, что и у production main ───────── + // Bus создаётся ЗДЕСЬ и передаётся фабрике: именно так consumer получает + // возможность подписаться до старта ingress, не завися от коллектора. const bus = new ExternalMessageBus(); - const metadataGenerator = new MessageMetadataGenerator({ - clock, - highResolutionClock: new LiveHighResolutionClock(), - }); - const pmStorage = new DataRecorder( - { - outputDir: pmDir, - sourceSubDir: 'polymarket', - bufferSize: 200, - flushIntervalMs: 5_000, - compression: 'gzip', - formatVersion: 2, - }, - new NDJSONFormatter(), - new GzipCompressor(), + const { collector } = createDataCollector({ + config: checkpointConfig(options.runDir), logger, - ); - const cexStorage = new CexWindowRecorder( - { - outputDir: cexDir, - compression: 'gzip', - bufferSize: 200, - flushIntervalMs: 2_000, - }, - logger, - ); - await cexStorage.cleanup(); - const recorder = new ExternalMessageRecorder({ + clock, bus, - storage: pmStorage, - logger, - cex: { bus, storage: cexStorage }, }); - recorder.start(); - - // ── Polymarket contour (source + discovery + coordinator + finalizer) ─ - const pmSource = new PolymarketSource({ client, bus, metadataGenerator, logger }); - const discovery = new PolymarketMarketDiscovery( - { client, filter: new MarketFilter(), scorer: new MarketScorer(clock), clock, logger }, - { - filter: { - minTimeToExpiryHours: 0, - minSpread: 0, - minLiquidity: 0, - maxMarketsToReturn: MAX_MARKETS * 3, - requiredKeywords: ['up or down'], - anyOfKeywords: ['bitcoin', 'ethereum', 'solana', 'xrp'], - }, - }, - ); - const coordinator = new MarketCollectionCoordinator( - { discovery, source: pmSource, recorder, clock, logger }, - { maxMarkets: MAX_MARKETS, minTimeToStartMs: 30_000 }, - ); - const finalizer = new MarketFinalizer( - { coordinator, recorder, gamma: client, clock, logger }, - { enrichmentRetryMs: 30_000, enrichmentMaxWaitMs: 60 * 60_000 }, - ); - - // ── CEX sources (по одной на биржу) на ТОМ ЖЕ bus ───────────────────── - const cexSources = CEX_PLAN.map( - (config) => new CexSource({ config, bus, metadataGenerator, logger }), - ); // ── Checkpoint-счётчики: независимые подписки на ТОМ ЖЕ bus ─────────── const busTotals = new Map(); @@ -625,15 +618,6 @@ async function runComposition(options: CompositionOptions): Promise(); - let lastRefreshMs = 0; - let lastStatusMs = 0; - let pipelineError: unknown; - /** Замораживает ring-снимки рынка, покинувшего ACTIVE (для exact-match валидации). */ const freezeMarket = (marketId: string): void => { if (evidence.frozenMarketRings[marketId] !== undefined) return; @@ -643,33 +627,82 @@ async function runComposition(options: CompositionOptions): Promise { - const out: string[] = []; - const walk = (dir: string): void => { - if (!fs.existsSync(dir)) return; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith('.jsonl.gz')) out.push(full); + // ── Lifecycle-наблюдатель рантайма ──────────────────────────────────── + // Момент выхода рынка из ACTIVE (seal → архив) сообщает сам рантайм — + // это же служит живым доказательством, что collection lifecycle + // наблюдаем снаружи (MR-A PART 18/19), а не выводится опросом. + const observedLifecycle: string[] = []; + collector.onMarketLifecycle((event) => { + observedLifecycle.push(`${event.kind}:${String(event.marketId)}`); + if (event.kind === 'FINALIZING' || event.kind === 'DROPPED') { + freezeMarket(String(event.marketId)); + } + }); + + // ── Старт live-сбора (recorder-first обеспечивает сам рантайм) ───────── + await collector.start(); + + const startedMs = Date.now(); + let lastStatusMs = 0; + let pipelineError: unknown; + + /** + * Разбирает раскладку датасетов прогона. + * + * @returns Пути завершённых архивов Polymarket и партиций CEX по биржам + * + * @remarks + * Раскладка — production (обе политики пишут в ОДИН корень): + * ```text + * {runDir}/{YYYY-MM-DD}/polymarket/{question}___{marketId}.jsonl[.gz] + * {runDir}/{YYYY-MM-DD}/{exchangeId}/{exchange}_{symbol}_..._ET.jsonl[.gz] + * ``` + * Классификация идёт по имени родительской директории — то же правило, + * по которому датасеты читает бэктест. + */ + const scanDatasets = (): { + pmArchives: string[]; + cexPartitionsByExchange: Map; + } => { + const pmArchives: string[] = []; + const cexPartitionsByExchange = new Map(); + const exchangeIds = new Set(CEX_PLAN.map((spec) => spec.exchangeId)); + if (!fs.existsSync(options.runDir)) { + return { pmArchives, cexPartitionsByExchange }; + } + for (const dateEntry of fs.readdirSync(options.runDir, { withFileTypes: true })) { + if (!dateEntry.isDirectory() || !/^\d{4}-\d{2}-\d{2}$/.test(dateEntry.name)) continue; + const dateDir = path.join(options.runDir, dateEntry.name); + for (const sourceEntry of fs.readdirSync(dateDir, { withFileTypes: true })) { + if (!sourceEntry.isDirectory()) continue; + const sourceDir = path.join(dateDir, sourceEntry.name); + const archives = fs + .readdirSync(sourceDir) + .filter((name) => name.endsWith('.jsonl.gz')) + .map((name) => path.join(sourceDir, name)); + if (sourceEntry.name === 'polymarket') { + pmArchives.push(...archives); + } else if (exchangeIds.has(sourceEntry.name)) { + const existing = cexPartitionsByExchange.get(sourceEntry.name) ?? []; + existing.push(...archives); + cexPartitionsByExchange.set(sourceEntry.name, existing); + } } - }; - walk(pmDir); - return out.sort(); + } + pmArchives.sort(); + return { pmArchives, cexPartitionsByExchange }; }; + /** Список .jsonl.gz архивов Polymarket текущего прогона. */ + const listPmArchives = (): string[] => scanDatasets().pmArchives; + /** Есть ли завершённая gzip-партиция у КАЖДОЙ биржи. */ - const everyExchangeHasCompletedPartition = (): boolean => - CEX_PLAN.every((spec) => { - const root = cexDir; - if (!fs.existsSync(root)) return false; - for (const dateDir of fs.readdirSync(root)) { - const exchangeDir = path.join(root, dateDir, spec.exchangeId); - if (!fs.existsSync(exchangeDir)) continue; - if (fs.readdirSync(exchangeDir).some((name) => name.endsWith('.jsonl.gz'))) return true; - } - return false; - }); + const everyExchangeHasCompletedPartition = (): boolean => { + const { cexPartitionsByExchange } = scanDatasets(); + return CEX_PLAN.every( + (spec) => (cexPartitionsByExchange.get(spec.exchangeId) ?? []).length > 0, + ); + }; /** * Статус финализации архива с кэшем разбора. @@ -714,26 +747,11 @@ async function runComposition(options: CompositionOptions): Promise= 30_000) { - lastRefreshMs = Date.now(); - await coordinator.refreshCandidates(); - } - await coordinator.fillSlots(); - await finalizer.runOnce(); - - // Freeze ring-снимков рынков, покинувших ACTIVE (seal → архив) - const nowActive = new Set(); - for (const session of coordinator.listSessions()) { - const key = String(session.marketId); - if (session.state === 'ACTIVE') nowActive.add(key); - } - for (const key of knownActive) { - if (!nowActive.has(key)) freezeMarket(key); - } - for (const key of nowActive) knownActive.add(key); - // Окно захвата CEX-сэмплов: после ~60% MIN (full) / после 30s (short) const captureFromMs = options.requireFullLifecycle ? options.minMs * 0.6 : 30_000; capturingCexSamples = Date.now() - startedMs >= captureFromMs; @@ -743,12 +761,7 @@ async function runComposition(options: CompositionOptions): Promise { drainSettled = true; }, (error: unknown) => { drainSettled = true; const message = error instanceof Error ? error.message : String(error); - evidence.shutdownStepFailures.push(`finalizer.drain: ${message}`); + evidence.shutdownStepFailures.push(`collector.drain: ${message}`); logger.error('CHECKPOINT finalization drain failed', { error: message }); }, ); @@ -804,59 +816,47 @@ async function runComposition(options: CompositionOptions): Promise ({ - exchange: CEX_PLAN[index]!.exchangeId, - stats: source.getStats(), + coordinator: finalStatus.collection, + finalizer: finalStatus.finalization, + recorder: finalStatus.recorder, + recorderCex: finalStatus.recorderCex, + windows: finalStatus.cexWindows, + bus: finalStatus.bus, + lifecycle: finalStatus.lifecycle, + cexSources: finalStatus.sources.cex.map((source) => ({ + exchange: source.exchangeId, + stats: source.stats, hasFailed: source.hasFailed, })), + lifecycleEvents: observedLifecycle, }; - // ── Controlled shutdown в порядке контура ───────────────────────────── - const cleanupStep = async (step: string, run: () => Promise): Promise => { - try { - await run(); - } catch (error) { - evidence.shutdownStepFailures.push( - `${step}: ${error instanceof Error ? error.message : String(error)}`, - ); - logger.error('CHECKPOINT cleanup step failed', { - step, - error: error instanceof Error ? error.message : String(error), - }); - } - }; - await cleanupStep('finalizer.close', async () => finalizer.close()); - await cleanupStep('coordinator.close', async () => coordinator.close()); - await cleanupStep('pmSource.close', async () => pmSource.close()); - await cleanupStep('cexSources.close', async () => { - await Promise.all(cexSources.map(async (source) => source.close())); - }); - await cleanupStep('bus.drain', async () => { - const drained = await bus.drain(); - if (!drained.ok) throw new Error(`bus.drain rejected: ${drained.error.message}`); - }); - await cleanupStep('recorder.close', async () => recorder.close()); - await cleanupStep('bus.close', async () => { - const closed = await bus.close(); - if (!closed.ok) throw new Error(`bus.close rejected: ${closed.error.message}`); - }); + // ── Controlled shutdown (лестница принадлежит рантайму) ─────────────── + try { + await collector.close(); + } catch (error) { + evidence.shutdownStepFailures.push( + `collector.close: ${error instanceof Error ? error.message : String(error)}`, + ); + } for (const dispose of disposers) dispose(); - for (const [index, source] of cexSources.entries()) { - if (source.hasFailed) evidence.cexSourcesFailed.push(CEX_PLAN[index]!.exchangeId); + // Отказы отдельных шагов рантайм логирует и продолжает закрытие — снимаем + // их из счётного logger-wrapper, чтобы вердикт учитывал каждый. + for (const line of logCounters.errorSamples) { + if (line.startsWith('Shutdown step failed')) evidence.shutdownStepFailures.push(line); + } + + for (const source of finalStatus.sources.cex) { + if (source.hasFailed) evidence.cexSourcesFailed.push(source.exchangeId); } evidence.finishedAtIso = new Date().toISOString(); @@ -1028,25 +1028,56 @@ function validateArtifacts( samplesTotal: 0, }, }; - const pmRoot = path.join(runDir, 'polymarket'); - const cexRoot = path.join(runDir, 'cex'); + // Раскладка — production: обе политики пишут в ОДИН корень датасетов, а + // источник определяется именем директории внутри date-папки + // (`polymarket` против `{exchangeId}`) — то же правило, по которому + // датасеты читает бэктест. + const cexExchangeIds = new Set(CEX_PLAN.map((spec) => spec.exchangeId)); - // ── Polymarket ──────────────────────────────────────────────────────── - const walk = (dir: string, onFile: (file: string) => void): void => { - if (!fs.existsSync(dir)) return; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) walk(full, onFile); - else onFile(full); + /** + * Обходит все файлы датасетов прогона, разделяя их по источнику. + * + * @param onPolymarket - Обработчик файла market-сессии + * @param onCex - Обработчик файла CEX-партиции (вместе с биржей) + */ + const walkDatasets = ( + onPolymarket: (file: string) => void, + onCex: (file: string, exchangeId: string) => void, + ): void => { + if (!fs.existsSync(runDir)) return; + for (const dateEntry of fs.readdirSync(runDir, { withFileTypes: true })) { + if (!dateEntry.isDirectory() || !/^\d{4}-\d{2}-\d{2}$/.test(dateEntry.name)) continue; + const dateDir = path.join(runDir, dateEntry.name); + for (const sourceEntry of fs.readdirSync(dateDir, { withFileTypes: true })) { + if (!sourceEntry.isDirectory()) continue; + const sourceDir = path.join(dateDir, sourceEntry.name); + const isPolymarket = sourceEntry.name === 'polymarket'; + const isCex = cexExchangeIds.has(sourceEntry.name); + if (!isPolymarket && !isCex) continue; + for (const fileEntry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + if (!fileEntry.isFile()) continue; + const full = path.join(sourceDir, fileEntry.name); + if (isPolymarket) onPolymarket(full); + else onCex(full, sourceEntry.name); + } + } } }; - walk(pmRoot, (file) => { + const pmFiles: string[] = []; + const cexFiles: Array<{ readonly file: string; readonly exchangeId: string }> = []; + walkDatasets( + (file) => pmFiles.push(file), + (file, exchangeId) => cexFiles.push({ file, exchangeId }), + ); + + // ── Polymarket ──────────────────────────────────────────────────────── + for (const file of pmFiles) { if (file.endsWith('.jsonl') && !file.endsWith('.jsonl.gz')) { report.pmIncompleteFiles.push(file); - return; + continue; } - if (!file.endsWith('.jsonl.gz')) return; + if (!file.endsWith('.jsonl.gz')) continue; let header: PmArchiveHeader; let lines: string[]; try { @@ -1057,7 +1088,7 @@ function validateArtifacts( report.violations.push( `PM archive unreadable: ${file}: ${error instanceof Error ? error.message : String(error)}`, ); - return; + continue; } if (header.marketId !== header.conditionId) { report.violations.push( @@ -1146,7 +1177,7 @@ function validateArtifacts( exactRtdsSampleMatched, sizeBytes: fs.statSync(file).size, }); - }); + } if (report.pmIncompleteFiles.length > 0) { report.violations.push( @@ -1231,15 +1262,15 @@ function validateArtifacts( report.cex.samplesTotal += samples.length; } - walk(cexRoot, (file) => { + for (const { file, exchangeId } of cexFiles) { const name = path.basename(file); if (name.endsWith('.jsonl') && !name.endsWith('.jsonl.gz')) { report.cex.incompleteFiles.push(file); - return; + continue; } - if (!name.endsWith('.jsonl.gz')) return; + if (!name.endsWith('.jsonl.gz')) continue; report.cex.gzFiles++; - const dirExchange = path.basename(path.dirname(file)); + const dirExchange = exchangeId; report.cex.perExchangeFiles[dirExchange] = (report.cex.perExchangeFiles[dirExchange] ?? 0) + 1; // {exchange}_{symbol}_{marketType}_{stream}_{dateET}_{startET}-{endET}_ET.jsonl.gz const parts = name.replace(/\.jsonl\.gz$/, '').split('_'); @@ -1258,12 +1289,12 @@ function validateArtifacts( report.violations.push( `CEX gzip unreadable: ${file}: ${error instanceof Error ? error.message : String(error)}`, ); - return; + continue; } const lines = text.trimEnd().split('\n').filter((line) => line.length > 0); if (lines.length === 0) { report.violations.push(`empty completed CEX partition: ${file}`); - return; + continue; } for (const line of lines) { report.cex.totalLines++; @@ -1307,7 +1338,7 @@ function validateArtifacts( sampleFound.set(sampleKey, (sampleFound.get(sampleKey) ?? 0) + 1); } } - }); + } for (const found of sampleFound.values()) report.cex.samplesMatched += found; if (report.cex.parseErrors > 0) { From d53101e93f440e4122e3acf22f019d3530b1c0df Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 25 Aug 2026 22:36:11 +0300 Subject: [PATCH 2/8] =?UTF-8?q?docs(collect-data):=20cutover-=D0=BE=D0=B1?= =?UTF-8?q?=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5,=20?= =?UTF-8?q?=D0=B0=D1=83=D0=B4=D0=B8=D1=82=20=D0=BF=D0=BE=D0=B2=D0=B5=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B8=20legacy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фиксирует, что именно из проверенной CHECKPOINT-композиции стало production рантаймом, а что осталось verification-слоем; таблица переноса ответственностей V1 → V2; аудит раскладки/именования/ротации/cleanup с явными отличиями (сегмент {stream} в имени CEX-партиции, formatVersion 2, общий корень и пересечение startup-cleanup двух политик). Аудит collector-legacy: ни один из классов V1-контура не оказался zero-consumer — все они остаются в живом употреблении у apps/bot, поэтому удалена только зависимость приложения коллектора от @polymarket/cex-market-data. Co-Authored-By: Claude Opus 5 --- apps/collect-data/ecosystem.config.cjs | 6 ++--- apps/collect-data/src/checkCexSnapshot.ts | 3 ++- docs/guides/data-collector-v2-cutover.md | 32 +++++++++++++++++++++-- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/collect-data/ecosystem.config.cjs b/apps/collect-data/ecosystem.config.cjs index 1788245e..c5ac37dc 100644 --- a/apps/collect-data/ecosystem.config.cjs +++ b/apps/collect-data/ecosystem.config.cjs @@ -8,9 +8,9 @@ * pm2 stop polymarket-collector * * Почему fork, а не cluster: - * Сервис держит глобальное состояние (подписки WS, открытые файлы ротатора, - * in-flight enrichment). Cluster-режим породил бы N копий, которые подписались - * бы на одни и те же рынки и писали бы в одни и те же файлы. + * Сервис держит глобальное состояние (подписки источников, открытые окна + * записи, идущие финализации). Cluster-режим породил бы N копий, которые + * подписались бы на одни и те же рынки и писали бы в одни и те же файлы. */ module.exports = { apps: [ diff --git a/apps/collect-data/src/checkCexSnapshot.ts b/apps/collect-data/src/checkCexSnapshot.ts index da9e7091..4f833297 100644 --- a/apps/collect-data/src/checkCexSnapshot.ts +++ b/apps/collect-data/src/checkCexSnapshot.ts @@ -3,7 +3,8 @@ * * @remarks * ### Назначение - * Проверяет качество данных в снапшотах CEX-бирж, собранных `CexCollectorService`. + * Проверяет качество данных в снапшотах CEX-бирж, собранных коллектором + * (`CexSource` → общий bus → `CexWindowRecorder`). * Используется для: * - Обнаружения проблем после сбора данных (gap'ы, frozen book, crossed book). * - Оценки покрытия символа сделками (zero-trade files). diff --git a/docs/guides/data-collector-v2-cutover.md b/docs/guides/data-collector-v2-cutover.md index f852d5a5..2525a38a 100644 --- a/docs/guides/data-collector-v2-cutover.md +++ b/docs/guides/data-collector-v2-cutover.md @@ -114,7 +114,7 @@ Checkpoint был прототипом production composition, обёрнуты бэктест: ```text -{outputDir}/{YYYY-MM-DD}/polymarket/{question}___{marketId}.jsonl[.gz] +{outputDir}/{YYYY-MM-DD}/polymarket/polymarket_{question}___{marketId}.jsonl[.gz] {outputDir}/{YYYY-MM-DD}/{exchangeId}/{exchange}_{symbol}_{marketType}_{stream}_{dateET}_{startET}-{endET}_ET.jsonl[.gz] ``` @@ -122,7 +122,7 @@ Checkpoint был прототипом production composition, обёрнуты | ---------------------- | ----------------------------------- | ---------------------------------------- | ---------------- | | date-директории | `{out}/{YYYY-MM-DD}/` | то же | parity | | директория источника | `polymarket/` и `{exchange}/` | то же | parity | -| имя PM-файла | `{question}___{marketId}` | то же | parity | +| имя PM-файла | строит `DataRecorder` | тот же `DataRecorder` | parity побайтово | | имя CEX-файла | без сегмента потока | **добавлен `{stream}`** | V2 лучше | | буферизация | 100 / 10 c (PM), 200 / 5 c (CEX) | то же (настраивается) | parity | | ротация CEX | окно 5 минут по ET-границам | то же + sweep «тихих» writer-ов | V2 лучше | @@ -137,6 +137,10 @@ Checkpoint был прототипом production composition, обёрнуты - **Разделение потоков в имени CEX-файла.** V2 пишет `orderbook` и `trades` в разные партиции; V1 складывал их в один файл. Ридеры V2-архивов обязаны учитывать сегмент `{stream}`. +- **Имя PM-файла не менялось этим MR.** Его целиком строит `DataRecorder`, который + V1 и V2 используют один и тот же, — включая префикс `polymarket_` и вставку года + (`_-_` → `_-2026_`). Архивы марта 2026 в `apps/collect-data/snapshots/` выглядят + иначе просто потому, что предшествуют этому изменению именования. - **Общий корень и startup cleanup.** Обе политики пишут в один `outputDir`, а `CexWindowRecorder.cleanup()` обходит ВСЕ поддиректории date-папки, включая `polymarket/`. Это безопасно ровно потому, что cleanup выполняется один раз при @@ -198,6 +202,30 @@ await collector.start(); Порядок подписки гарантирован конструкцией: recorder подписывается внутри `collector.start()`, наблюдатели — до него, ingress стартует последним. +## Аудит collector-legacy после удаления V1 + +После cutover проверено, остались ли у collector-специфичных классов потребители. +Результат: **ни один из них не оказался zero-consumer** — все они продолжают +использоваться торговым приложением `apps/bot`, которое MR-A не трогает. + +| Компонент | Решение | Оставшиеся потребители | +| --------------------------------- | -------- | ---------------------------------------------------- | +| `PolymarketWebSocketManager` | оставлен | `apps/bot/src/main.ts` | +| `PolymarketWsAdapter` | оставлен | `apps/bot`: `main.ts`, `MarketRotation`, `buildLiveInfra` | +| `MarketDataFeedAdapter` | оставлен | `apps/bot/src/main.ts` | +| `PolymarketMarketDiscoveryAdapter` | оставлен | `apps/bot`: `main.ts`, `MarketRotation`, 3 скрипта | +| `CexCollectorService` | оставлен | `apps/bot`: `main.ts`, `buildRecording` | +| `CcxtExchangeWatcher` | оставлен | внутренний для `CexCollectorService` | +| `CcxtSymbolWatcher` | оставлен | внутренний для `CexCollectorService` | +| `CexFileRotator` | оставлен | внутренний для `CexCollectorService` | +| `DnsOverride` | оставлен | коллектор (`processBootstrap`), `apps/bot`, CLI-скрипты | + +Единственное, что удалось убрать безопасно, — зависимость приложения коллектора от +`@polymarket/cex-market-data`: после cutover ни один его файл этот пакет не +импортирует, поэтому он исключён из `package.json` и `tsconfig`. Сам пакет остаётся +в репозитории: он смешивает collector-legacy с живой функциональностью бота, и +удалять его целиком было бы поломкой торгового приложения. + ## Что НЕ входило в MR-A TWAP-подписки и парсинг окон, пересмотр резолюции (60-минутный бюджет, fallback, From f2c87f51305c06f5b6f41f9b57eb17f134de705a Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 25 Aug 2026 23:29:16 +0300 Subject: [PATCH 3/8] =?UTF-8?q?docs(collect-data):=20=D0=B7=D0=B0=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B1=D0=BB=D1=8E=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BE=20=D1=81=D0=BE=D0=BA=D0=B5=D1=82=D0=B0=D1=85=20=D0=BE?= =?UTF-8?q?=D1=84=D0=B8=D1=86=D0=B8=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Длинный прогон не завершает процесс сам: у createPublicClient() нет метода закрытия, и его keep-alive пул к gamma-api не освобождается ничем. Короткий режим checkpoint (две композиции подряд в одном процессе) заканчивается тем же состоянием, что и прогоны до этого MR, и выходит с кодом 0 — всё, чем владеет контур, освобождается. process.exit() сознательно не добавлен: он скрыл бы и будущие утечки. Co-Authored-By: Claude Opus 5 --- docs/guides/data-collector-v2-cutover.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/guides/data-collector-v2-cutover.md b/docs/guides/data-collector-v2-cutover.md index 2525a38a..6f0cbbf0 100644 --- a/docs/guides/data-collector-v2-cutover.md +++ b/docs/guides/data-collector-v2-cutover.md @@ -202,6 +202,28 @@ await collector.start(); Порядок подписки гарантирован конструкцией: recorder подписывается внутри `collector.start()`, наблюдатели — до него, ingress стартует последним. +## Известное ограничение: сокеты официального SDK + +На длинном прогоне (31.6 минуты, из них 10 минут дренажа финализаций) процесс не +завершился сам в течение 60 секунд после остановки контура: остались 7 +`TCPSocketWrap`. Разбор: + +- `createPublicClient()` официального SDK не имеет НИКАКОГО метода закрытия — ни + `close`, ни `dispose`, ни `Symbol.asyncDispose`. Его пул keep-alive соединений к + `gamma-api`/`clob` не может быть освобождён ни коллектором, ни verification-runner-ом; +- чем дольше идёт финализация, тем больше опросов Gamma и тем больше соединений в + пуле: прогоны на 17 минут с тремя финализациями завершались чисто, прогон на 31 + минуту с девятью — нет; +- всё, чем владеет сам контур, освобождается: короткий режим checkpoint поднимает и + гасит ДВЕ полные композиции подряд в одном процессе, и обе заканчиваются состоянием + `{FSReqPromise: 1}` — ровно тем же, что и прогоны до этого MR, — а процесс выходит + с кодом 0. + +Маскировать это `process.exit()` сознательно не стали: принудительный выход скрыл бы +не только чужой пул сокетов, но и любую будущую утечку таймера или потока. Правильное +место для решения — метод закрытия в SDK-клиенте либо собственный HTTP-агент под +контролем приложения; и то и другое выходит за границы MR-A. + ## Аудит collector-legacy после удаления V1 После cutover проверено, остались ли у collector-специфичных классов потребители. From e7c28e770d2af41ebc743f744eb3b2f727038d28 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 25 Aug 2026 23:58:06 +0300 Subject: [PATCH 4/8] =?UTF-8?q?fix(collect-data):=20kill=5Ftimeout=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=20=D1=84=D0=B0=D0=BA=D1=82=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D1=83=D1=8E=20=D0=B4=D0=BB=D0=B8=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замер на production-масштабе (100 рынков, 6 бирж, ~1.4M записей): от SIGTERM до «Shutdown complete» — 79 секунд. Прежних 30 секунд не хватало, и SIGKILL от pm2 приходил ПОСРЕДИ финализации, теряя текущие окна записи. Прежний коллектор эту проблему прятал за process.exit() в конце shutdown. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 +++ apps/collect-data/ecosystem.config.cjs | 14 ++++++++++++-- docs/guides/data-collector-v2-cutover.md | 7 +++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index f6e5ac1b..07a72fe0 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,9 @@ src_bundle.md # Bot data (recordings, journals, paper/live data) apps/bot/data/ +# Collector datasets (live-прогоны и smoke пишут сюда собранные архивы) +apps/collect-data/data/ + # Checkpoint verification runs (raw live collection artifacts) /data/ diff --git a/apps/collect-data/ecosystem.config.cjs b/apps/collect-data/ecosystem.config.cjs index c5ac37dc..5bd21309 100644 --- a/apps/collect-data/ecosystem.config.cjs +++ b/apps/collect-data/ecosystem.config.cjs @@ -37,8 +37,18 @@ module.exports = { max_restarts: 10, min_uptime: '60s', - // Graceful shutdown — даём finalize, close recorder и т.п. успеть. - kill_timeout: 30000, + // Graceful shutdown: коллектор гасит источники, дренирует общий bus в + // recorder, закрывает окна записи и снимает сессии. На production-масштабе + // (100 рынков, 6 бирж, ~1.4M записей) замерено 79 секунд до «Shutdown + // complete», поэтому прежних 30 секунд не хватало — SIGKILL приходил + // ПОСРЕДИ финализации и терял текущие окна. + // + // После завершения работы процесс не выходит сам: keep-alive пул + // официального SDK-клиента закрыть нечем (у него нет метода закрытия), и + // финальный SIGKILL от pm2 штатен — к этому моменту все архивы уже на + // диске. Маскировать это process.exit() нельзя: он скрыл бы и настоящие + // утечки таймеров/потоков. + kill_timeout: 180000, wait_ready: false, watch: false, diff --git a/docs/guides/data-collector-v2-cutover.md b/docs/guides/data-collector-v2-cutover.md index 6f0cbbf0..8e474ae9 100644 --- a/docs/guides/data-collector-v2-cutover.md +++ b/docs/guides/data-collector-v2-cutover.md @@ -224,6 +224,13 @@ await collector.start(); место для решения — метод закрытия в SDK-клиенте либо собственный HTTP-агент под контролем приложения; и то и другое выходит за границы MR-A. +Операционное следствие: под pm2 процесс завершает финальный `SIGKILL` по +`kill_timeout`. Это штатно — к тому моменту вся работа сделана, — но таймаут обязан +превышать длительность самой остановки. На production-масштабе (100 рынков, 6 бирж, +~1.4M записей) от `SIGTERM` до «Shutdown complete» замерено **79 секунд**, поэтому +прежних 30 секунд не хватало: SIGKILL приходил посреди финализации и терял текущие +окна записи. `kill_timeout` поднят до 180 секунд. + ## Аудит collector-legacy после удаления V1 После cutover проверено, остались ли у collector-специфичных классов потребители. From 655b90716dc933b1fcfb41b08c4ab8b4ffee67a1 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 26 Aug 2026 01:12:58 +0300 Subject: [PATCH 5/8] =?UTF-8?q?fix(collect-data):=20SDK-=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=20=E2=80=94=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=B8=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D0=B5=20shared=20realtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Клиент создавался фабрикой, использовался source/discovery/finalizer и не принадлежал никому: `PolymarketSource.close()` снимает СВОИ подписки, но shared websocket-соединения держит сам клиент, и закрыть их было некому. Теперь клиент — явная часть DataCollectorComponents (инъецируемая, как bus), а его `closeSubscriptions()` стоит в лестнице остановки сразу ПОСЛЕ закрытия source: раньше это рвало бы соединение под работающим итератором. Первичное объяснение зависания процесса («keep-alive пул Gamma, закрыть нечем») оказалось НЕВЕРНЫМ — метод закрытия у клиента есть, а сокеты gamma-api в замерах освобождаются сами. Диагностика на production-масштабе (100 рынков, 6 бирж, форсированные плановые рестарты CCXT) не оставляет ни одного сокета и завершает процесс сама; исходное зависание на 31-минутном прогоне воспроизвести не удалось, поэтому шаг добавлен как закрытие реального разрыва во владении, а не как доказанное лекарство. Заодно: - start() и close() сериализованы: сигнал во время запуска дожидается подъёма, иначе остановка гасила бы источники, которые запуск продолжает поднимать; - tick() регистрирует себя сам — остановка ждёт ЛЮБОЙ идущий тик, а не только запущенный планировщиком; - эндпоинты из конфигурации приложения доходят до клиента через форк окружения SDK (раньше GAMMA_API_BASE_URL/POLYMARKET_WS_URL молча игнорировались); - CEX-конфигурация требует явных булевых флагов потоков и хотя бы одного включённого; - отказ конверсии конфигурации логируется через logger, а не голым stack trace; - watchdog checkpoint-а печатает servername каждого оставшегося сокета — «какой именно ресурс» вместо «TCPSocketWrap»; - убран корневой export пакета (импорт корня запускал бы main.ts); - jest-алиасы выводятся из tsconfig, ESM включён по-настоящему. Co-Authored-By: Claude Opus 5 --- .../__tests__/DataCollector.shutdown.test.ts | 120 ++++++++++++++-- .../__tests__/DataCollector.startup.test.ts | 18 +-- .../__tests__/DataCollectorConfig.test.ts | 30 +++- .../__tests__/contour-fan-out.test.ts | 22 ++- apps/collect-data/__tests__/helpers/fakes.ts | 132 ++++++++++++++---- apps/collect-data/docs/collect-data.md | 15 +- apps/collect-data/ecosystem.config.cjs | 19 ++- apps/collect-data/jest.config.js | 50 +++---- apps/collect-data/package.json | 6 +- apps/collect-data/src/main.ts | 13 +- .../collect-data/src/runtime/DataCollector.ts | 129 ++++++++++++++--- .../src/runtime/DataCollectorConfig.ts | 73 +++++++++- .../src/runtime/createDataCollector.ts | 33 ++++- docs/guides/data-collector-v2-cutover.md | 87 ++++++++---- scripts/checkpoint-raw-live.mts | 35 +++++ 15 files changed, 629 insertions(+), 153 deletions(-) diff --git a/apps/collect-data/__tests__/DataCollector.shutdown.test.ts b/apps/collect-data/__tests__/DataCollector.shutdown.test.ts index 7435f0b7..d073ca09 100644 --- a/apps/collect-data/__tests__/DataCollector.shutdown.test.ts +++ b/apps/collect-data/__tests__/DataCollector.shutdown.test.ts @@ -37,6 +37,7 @@ function shutdownOrder(calls: readonly string[]): string[] { 'finalizer.close', 'coordinator.close', 'polymarketSource.close', + 'polymarketClient.closeSubscriptions', 'cexSource.close(binance)', 'bus.drain', 'recorder.close', @@ -56,6 +57,7 @@ describe('DataCollector.close() — штатная остановка', () => { 'finalizer.close', 'coordinator.close', 'polymarketSource.close', + 'polymarketClient.closeSubscriptions', 'cexSource.close(binance)', 'bus.drain', 'recorder.close', @@ -70,8 +72,8 @@ describe('DataCollector.close() — штатная остановка', () => { await collector.close(); - expect(contour.log.indexOf('bus.drain')).toBeLessThan(contour.log.indexOf('recorder.close')); - expect(contour.log.indexOf('recorder.close')).toBeLessThan(contour.log.indexOf('bus.close')); + expect(contour.log.orderOf('bus.drain')).toBeLessThan(contour.log.orderOf('recorder.close')); + expect(contour.log.orderOf('recorder.close')).toBeLessThan(contour.log.orderOf('bus.close')); }); it('глушит ingress до дренажа очереди', async () => { @@ -80,11 +82,11 @@ describe('DataCollector.close() — штатная остановка', () => { await collector.close(); - expect(contour.log.indexOf('polymarketSource.close')).toBeLessThan( - contour.log.indexOf('bus.drain'), + expect(contour.log.orderOf('polymarketSource.close')).toBeLessThan( + contour.log.orderOf('bus.drain'), ); - expect(contour.log.indexOf('cexSource.close(binance)')).toBeLessThan( - contour.log.indexOf('bus.drain'), + expect(contour.log.orderOf('cexSource.close(binance)')).toBeLessThan( + contour.log.orderOf('bus.drain'), ); }); }); @@ -134,6 +136,81 @@ describe('DataCollector.close() — идемпотентность', () => { }); }); +describe('DataCollector.close() — ресурсы SDK-клиента', () => { + it('закрывает shared realtime клиента после снятия подписок source', async () => { + const { collector, contour } = makeCollector(); + await collector.start(); + + await collector.close(); + + // Порядок принципиален: сначала source снимает СВОИ подписки, и только + // потом закрывается общий транспорт, которым владеет клиент. + expect(contour.log.orderOf('polymarketSource.close')).toBeLessThan( + contour.log.orderOf('polymarketClient.closeSubscriptions'), + ); + expect(contour.log.countOf('polymarketClient.closeSubscriptions')).toBe(1); + }); + + it('отказ client-level cleanup не срывает остальную лестницу', async () => { + const { collector, contour } = makeCollector(); + contour.polymarketClient.closeRejection = new Error('closeSubscriptions failed'); + await collector.start(); + + await collector.close(); + + expect(contour.log.countOf('bus.drain')).toBe(1); + expect(contour.log.countOf('recorder.close')).toBe(1); + expect(contour.log.countOf('bus.close')).toBe(1); + }); +}); + +describe('DataCollector — гонка запуска и остановки', () => { + it('остановка во время запуска не гасит контур на подъёме', async () => { + const contour = makeFakeContour(['binance']); + let releaseCleanup: (() => void) | undefined; + contour.polymarketStorage.cleanupGate = new Promise((resolve) => { + releaseCleanup = resolve; + }); + const { collector } = makeCollector(contour); + + const starting = collector.start(); + await Promise.resolve(); + // Сигнал пришёл, пока startup cleanup ещё не завершился. + const closing = collector.close(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Лестница остановки НЕ начата: запуск ещё идёт. + expect(contour.log.indexOf('bus.close')).toBe(-1); + + releaseCleanup?.(); + await Promise.all([starting, closing]); + + // Контур поднялся целиком и погашен целиком — без «повисшего» ingress. + expect(contour.log.orderOf('cexSource.start(binance)')).toBeLessThan( + contour.log.orderOf('cexSource.close(binance)'), + ); + expect(contour.log.countOf('bus.close')).toBe(1); + expect(collector.state).toBe('stopped'); + }); + + it('остановка после провалившегося запуска не закрывает ресурсы повторно', async () => { + const contour = makeFakeContour(['binance']); + contour.recorder.startError = new Error('recorder subscribe failed'); + const { collector } = makeCollector(contour); + + const starting = collector.start(); + const closing = collector.close(); + await expect(starting).rejects.toThrow('recorder subscribe failed'); + await closing; + + // Recorder не поднялся — закрывать его нечего; bus закрыт РОВНО один раз, + // хотя остановку запросили параллельно откату запуска. + expect(contour.log.countOf('recorder.close')).toBe(0); + expect(contour.log.countOf('bus.close')).toBe(1); + expect(collector.state).toBe('stopped'); + }); +}); + describe('DataCollector.close() — best-effort', () => { it('отказ одного шага не отменяет остальные', async () => { const { collector, contour } = makeCollector(); @@ -202,17 +279,30 @@ describe('DataCollector.close() — таймеры и тики', () => { expect(contour.coordinator.fillCalls).toBe(fillCallsAfterClose); }); - it('дожидается тика в полёте перед закрытием компонентов', async () => { + it('не закрывает контур, пока тик в полёте не завершился', async () => { const { collector, contour } = makeCollector(); await collector.start(); - let tickFinished = false; - const slowTick = collector.tick().then(() => { - tickFinished = true; - }); - - await Promise.all([collector.close(), slowTick]); - - expect(tickFinished).toBe(true); + contour.coordinator.block(); + const slowTick = collector.tick(); + // Ждём, пока тик реально войдёт в fillSlots и подвиснет там. + await Promise.resolve(); + await Promise.resolve(); + expect(contour.log.indexOf('coordinator.fillSlots.start')).toBeGreaterThanOrEqual(0); + + const closing = collector.close(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Тик держит остановку: контур ещё цел. + expect(contour.log.indexOf('coordinator.fillSlots')).toBe(-1); + expect(contour.log.indexOf('bus.close')).toBe(-1); + + contour.coordinator.release(); + await Promise.all([closing, slowTick]); + + // Завершившийся тик предшествует закрытию контура. + expect(contour.log.orderOf('coordinator.fillSlots')).toBeLessThan( + contour.log.orderOf('bus.close'), + ); expect(contour.log.countOf('bus.close')).toBe(1); }); }); diff --git a/apps/collect-data/__tests__/DataCollector.startup.test.ts b/apps/collect-data/__tests__/DataCollector.startup.test.ts index f79610fe..32a07801 100644 --- a/apps/collect-data/__tests__/DataCollector.startup.test.ts +++ b/apps/collect-data/__tests__/DataCollector.startup.test.ts @@ -37,10 +37,10 @@ describe('DataCollector.start() — порядок запуска', () => { await collector.start(); - const recorderStart = contour.log.indexOf('recorder.start'); + const recorderStart = contour.log.orderOf('recorder.start'); expect(recorderStart).toBeGreaterThanOrEqual(0); for (const source of contour.cexSources) { - expect(recorderStart).toBeLessThan(contour.log.indexOf(`cexSource.start(${source.exchangeId})`)); + expect(recorderStart).toBeLessThan(contour.log.orderOf(`cexSource.start(${source.exchangeId})`)); } await collector.close(); @@ -51,11 +51,11 @@ describe('DataCollector.start() — порядок запуска', () => { await collector.start(); - expect(contour.log.indexOf('polymarketStorage.cleanup')).toBeLessThan( - contour.log.indexOf('recorder.start'), + expect(contour.log.orderOf('polymarketStorage.cleanup')).toBeLessThan( + contour.log.orderOf('recorder.start'), ); - expect(contour.log.indexOf('cexStorage.cleanup')).toBeLessThan( - contour.log.indexOf('recorder.start'), + expect(contour.log.orderOf('cexStorage.cleanup')).toBeLessThan( + contour.log.orderOf('recorder.start'), ); await collector.close(); @@ -123,10 +123,10 @@ describe('DataCollector.start() — откат частичного запуск await expect(collector.start()).rejects.toThrow('okx down'); - expect(contour.log.indexOf('cexSource.close(binance)')).toBeLessThan( - contour.log.indexOf('recorder.close'), + expect(contour.log.orderOf('cexSource.close(binance)')).toBeLessThan( + contour.log.orderOf('recorder.close'), ); - expect(contour.log.indexOf('recorder.close')).toBeLessThan(contour.log.indexOf('bus.close')); + expect(contour.log.orderOf('recorder.close')).toBeLessThan(contour.log.orderOf('bus.close')); }); it('отказ startup cleanup не оставляет подписанного recorder', async () => { diff --git a/apps/collect-data/__tests__/DataCollectorConfig.test.ts b/apps/collect-data/__tests__/DataCollectorConfig.test.ts index 21edc4e5..b586ea89 100644 --- a/apps/collect-data/__tests__/DataCollectorConfig.test.ts +++ b/apps/collect-data/__tests__/DataCollectorConfig.test.ts @@ -100,6 +100,22 @@ describe('parseCexSourceConfigs — legacy JSON → CexSourceConfig', () => { expect(sources[0]).not.toHaveProperty('orderbookMethod'); }); + it('требует явные булевы флаги потоков', () => { + expect(() => + parseCexSourceConfigs(JSON.stringify({ binance: { type: 'spot', symbols: ['BTC/USDT'] } })), + ).toThrow('orderbook and trades must be booleans'); + }); + + it('отвергает биржу без единого включённого потока', () => { + expect(() => + parseCexSourceConfigs( + JSON.stringify({ + binance: { type: 'spot', symbols: ['BTC/USDT'], orderbook: false, trades: false }, + }), + ), + ).toThrow('at least one of orderbook/trades must be true'); + }); + it('отвергает невалидную конфигурацию вместо тихой потери биржи', () => { expect(() => parseCexSourceConfigs('{ not json')).toThrow('Invalid CEX config JSON'); expect(() => parseCexSourceConfigs('[]')).toThrow('keyed by exchange id'); @@ -111,12 +127,22 @@ describe('parseCexSourceConfigs — legacy JSON → CexSourceConfig', () => { ); expect(() => parseCexSourceConfigs( - JSON.stringify({ binance: { type: 'spot', symbols: ['BTC/USDT'], obDepth: -1 } }), + JSON.stringify({ + binance: { type: 'spot', symbols: ['BTC/USDT'], orderbook: true, trades: true, obDepth: -1 }, + }), ), ).toThrow('obDepth must be a finite number > 0'); expect(() => parseCexSourceConfigs( - JSON.stringify({ binance: { type: 'spot', symbols: ['BTC/USDT'], obMethod: 'poll' } }), + JSON.stringify({ + binance: { + type: 'spot', + symbols: ['BTC/USDT'], + orderbook: true, + trades: true, + obMethod: 'poll', + }, + }), ), ).toThrow("obMethod must be 'watch' | 'fetch'"); }); diff --git a/apps/collect-data/__tests__/contour-fan-out.test.ts b/apps/collect-data/__tests__/contour-fan-out.test.ts index c4abe11d..aae0977d 100644 --- a/apps/collect-data/__tests__/contour-fan-out.test.ts +++ b/apps/collect-data/__tests__/contour-fan-out.test.ts @@ -17,7 +17,7 @@ * Именно эта развилка позволит будущему Semantic Adapter подключиться, не * трогая ни source-пакеты, ни recorder, ни API коллектора. */ -import { describe, expect, it } from '@jest/globals'; +import { afterEach, describe, expect, it } from '@jest/globals'; import { ExternalMessageBus } from '@polymarket/external-message-bus'; import { MessageMetadataGenerator } from '@polymarket/messages'; import { LiveClock } from '@polymarket/time'; @@ -27,6 +27,21 @@ import type { ContourMessage } from '../src/runtime/createDataCollector.js'; import { CapturingLogger } from './helpers/fakes.js'; import { FakeCexWindowStorage, FakePolymarketRecordingStorage } from './helpers/recordingFakes.js'; +/** Все bus, созданные тестом: закрываются в teardown (без утечек между тестами). */ +const openBuses: ExternalMessageBus[] = []; + +afterEach(async () => { + // Bus держит очередь доставки и подписки; незакрытый экземпляр пережил бы + // тест и мог бы получить сообщения соседнего. + await Promise.all(openBuses.splice(0).map(async (bus) => bus.close())); +}); + +/** Регистрирует bus для гарантированного закрытия. */ +function trackBus(bus: ExternalMessageBus): ExternalMessageBus { + openBuses.push(bus); + return bus; +} + /** Собранный «мини-контур»: реальные bus и recorder, fake-хранилища. */ function makeContour(): { bus: ExternalMessageBus; @@ -35,7 +50,7 @@ function makeContour(): { polymarketStorage: FakePolymarketRecordingStorage; generator: MessageMetadataGenerator; } { - const bus = new ExternalMessageBus(); + const bus = trackBus(new ExternalMessageBus()); const polymarketStorage = new FakePolymarketRecordingStorage(); const cexStorage = new FakeCexWindowStorage(); const recorder = new ExternalMessageRecorder({ @@ -167,7 +182,7 @@ describe('recorder — consumer bus, а не прямая цель source (PART it('не получает сообщений, опубликованных мимо ЕГО bus', async () => { const { recorder, cexStorage, generator } = makeContour(); recorder.start(); - const foreignBus = new ExternalMessageBus(); + const foreignBus = trackBus(new ExternalMessageBus()); await foreignBus.publish({ type: 'CEX_ORDERBOOK', @@ -178,7 +193,6 @@ describe('recorder — consumer bus, а не прямая цель source (PART expect(cexStorage.writes).toHaveLength(0); await recorder.close(); - await foreignBus.close(); }); it('после закрытия recorder поток bus остаётся доступен другим consumer-ам', async () => { diff --git a/apps/collect-data/__tests__/helpers/fakes.ts b/apps/collect-data/__tests__/helpers/fakes.ts index 3f5e4675..f43f7cd3 100644 --- a/apps/collect-data/__tests__/helpers/fakes.ts +++ b/apps/collect-data/__tests__/helpers/fakes.ts @@ -10,8 +10,10 @@ */ import type { ILogger } from '@polymarket/logger'; import type { IClock } from '@polymarket/time'; -import type { MarketId } from '@polymarket/ids'; import { asMarketId } from '@polymarket/ids'; +import type { CollectionSessionSnapshot } from '@polymarket/collection-coordinator'; +import type { PolymarketDiscoveredMarket } from '@polymarket/polymarket-v2'; +import type { Timestamp } from '@polymarket/timestamp'; import type { MessageBusDrainError, MessageBusStats } from '@polymarket/message-bus'; import type { Result } from '@polymarket/result'; import { Err, Ok } from '@polymarket/result'; @@ -23,6 +25,7 @@ import type { CollectorCoordinator, CollectorDiscovery, CollectorFinalizer, + CollectorPolymarketClient, CollectorPolymarketSource, CollectorPolymarketStorage, CollectorRecorder, @@ -37,7 +40,23 @@ export class CallLog { this.calls.push(name); } - /** Индекс первого вызова (или -1). */ + /** + * Индекс первого вызова; отсутствие вызова — ошибка теста. + * + * @remarks + * Обычный `indexOf` вернул бы -1, и сравнение порядка (`a < b`) прошло бы + * ВАКУУМНО для шага, которого вообще не было. Порядковые проверки обязаны + * падать именно в этом случае. + */ + public orderOf(name: string): number { + const index = this.calls.indexOf(name); + if (index === -1) { + throw new Error(`Expected call '${name}' was never made. Calls: ${this.calls.join(' → ')}`); + } + return index; + } + + /** Индекс первого вызова (или -1) — для проверок «вызова не было». */ public indexOf(name: string): number { return this.calls.indexOf(name); } @@ -182,11 +201,16 @@ export class FakeRecorder implements CollectorRecorder { /** Fake storage-политики Polymarket. */ export class FakePolymarketStorage implements CollectorPolymarketStorage { public cleanupRejection: Error | undefined; + /** Пока задан — `cleanup()` не завершается (запуск наблюдаемо «в полёте»). */ + public cleanupGate: Promise | undefined; constructor(private readonly _log: CallLog) {} public async cleanup(): Promise { this._log.record('polymarketStorage.cleanup'); + if (this.cleanupGate !== undefined) { + await this.cleanupGate; + } if (this.cleanupRejection !== undefined) throw this.cleanupRejection; } } @@ -227,6 +251,18 @@ export class FakePolymarketSource implements CollectorPolymarketSource { } } +/** Fake SDK-клиента: считает client-level cleanup, умеет его провалить. */ +export class FakePolymarketClient implements CollectorPolymarketClient { + public closeRejection: Error | undefined; + + constructor(private readonly _log: CallLog) {} + + public async closeSubscriptions(): Promise { + this._log.record('polymarketClient.closeSubscriptions'); + if (this.closeRejection !== undefined) throw this.closeRejection; + } +} + /** Fake одного CEX-source. */ export class FakeCexSource implements CollectorCexSource { public hasFailed = false; @@ -256,17 +292,30 @@ export class FakeCexSource implements CollectorCexSource { } } -/** Минимальный «Timestamp-подобный» объект для снимков. */ -export function timestampOf(ms: number): { toNumber(): number } { - return { toNumber: () => ms }; +/** + * Минимальный Timestamp для снимков. + * + * @remarks + * Рантайм вызывает у него только `toNumber()`; конструировать настоящий VO + * ради этого не нужно, но тип обязан быть настоящим — иначе расширение + * контракта снимка прошло бы мимо тестов. + */ +export function timestampOf(ms: number): Timestamp { + return { toNumber: () => ms } as Timestamp; } -/** Кандидат discovery для проекции lifecycle. */ -export interface FakeCandidate { - readonly marketId: MarketId; - readonly question: string; - readonly expiresAt: { toNumber(): number }; -} +/** + * Кандидат discovery в объёме, который читает рантайм. + * + * @remarks + * Проекция реального `PolymarketDiscoveredMarket` по РЕАЛЬНЫМ полям: если + * рантайм начнёт читать что-то ещё или контракт изменится, тест перестанет + * компилироваться — в отличие от `never[]`, который скрывал бы расхождение. + */ +export type FakeCandidate = Pick< + PolymarketDiscoveredMarket, + 'marketId' | 'question' | 'expiresAt' +>; /** Создаёт кандидата с заданным id. */ export function candidate(id: string, expiresAtMs = 2_000_000): FakeCandidate { @@ -282,21 +331,25 @@ export class FakeDiscovery implements CollectorDiscovery { public candidates: FakeCandidate[] = []; public findRejection: Error | undefined; - public async findCandidates(): Promise { + public async findCandidates(): Promise { if (this.findRejection !== undefined) throw this.findRejection; - // Порт координатора типизирован полным PolymarketDiscoveredMarket; для - // проекции lifecycle достаточно identity/вопроса/истечения. - return this.candidates as unknown as never[]; + return this.candidates; } } -/** Снимок сессии, которым управляет тест. */ -export interface FakeSession { - readonly marketId: MarketId; - state: 'OPENING' | 'ACTIVE' | 'FINALIZING'; - readonly question?: string; - readonly expiresAt?: { toNumber(): number }; -} +/** + * Снимок сессии в объёме, который читает рантайм. + * + * @remarks + * Проекция реального `CollectionSessionSnapshot`; `state` сделан изменяемым, + * чтобы тест мог двигать сессию по lifecycle. + */ +export type FakeSession = Pick< + CollectionSessionSnapshot, + 'marketId' | 'question' | 'expiresAt' +> & { + state: CollectionSessionSnapshot['state']; +}; /** Fake координатора: сессии и счётчики задаёт тест. */ export class FakeCoordinator implements CollectorCoordinator { @@ -304,9 +357,32 @@ export class FakeCoordinator implements CollectorCoordinator { public refreshCalls = 0; public fillCalls = 0; public closeRejection: Error | undefined; + /** Пока задан — `fillSlots()` не завершается (тик наблюдаемо «в полёте»). */ + private _gate: Promise | undefined; + private _openGate: (() => void) | undefined; constructor(private readonly _log: CallLog) {} + /** + * Подвешивает следующий `fillSlots()` до вызова {@link FakeCoordinator.release}. + * + * @remarks + * Позволяет проверить, что остановка ДОЖИДАЕТСЯ тика, а не просто + * оказывается позже него по случайному стечению планировщика. + */ + public block(): void { + this._gate = new Promise((resolve) => { + this._openGate = resolve; + }); + } + + /** Отпускает подвешенный `fillSlots()`. */ + public release(): void { + this._openGate?.(); + this._gate = undefined; + this._openGate = undefined; + } + public async refreshCandidates(): Promise { this.refreshCalls++; this._log.record('coordinator.refreshCandidates'); @@ -314,12 +390,16 @@ export class FakeCoordinator implements CollectorCoordinator { public async fillSlots(): Promise { this.fillCalls++; + this._log.record('coordinator.fillSlots.start'); + if (this._gate !== undefined) { + await this._gate; + } this._log.record('coordinator.fillSlots'); return 0; } - public listSessions(): never[] { - return this.sessions as unknown as never[]; + public listSessions(): FakeSession[] { + return this.sessions; } public getStats(): ReturnType { @@ -383,6 +463,7 @@ export interface FakeContour { readonly polymarketStorage: FakePolymarketStorage; readonly cexStorage: FakeCexStorage; readonly polymarketSource: FakePolymarketSource; + readonly polymarketClient: FakePolymarketClient; readonly cexSources: FakeCexSource[]; readonly discovery: FakeDiscovery; readonly coordinator: FakeCoordinator; @@ -405,6 +486,7 @@ export function makeFakeContour(exchangeIds: readonly string[] = ['binance', 'ok const polymarketStorage = new FakePolymarketStorage(log); const cexStorage = new FakeCexStorage(log); const polymarketSource = new FakePolymarketSource(log); + const polymarketClient = new FakePolymarketClient(log); const cexSources = exchangeIds.map((exchangeId) => new FakeCexSource(log, exchangeId)); const discovery = new FakeDiscovery(); const coordinator = new FakeCoordinator(log); @@ -424,6 +506,7 @@ export function makeFakeContour(exchangeIds: readonly string[] = ['binance', 'ok polymarketStorage, cexStorage, polymarketSource, + polymarketClient, cexSources, discovery, coordinator, @@ -434,6 +517,7 @@ export function makeFakeContour(exchangeIds: readonly string[] = ['binance', 'ok polymarketStorage, cexStorage, polymarketSource, + polymarketClient, cexSources: entries, discovery, coordinator, diff --git a/apps/collect-data/docs/collect-data.md b/apps/collect-data/docs/collect-data.md index 84e94d62..adf44a61 100644 --- a/apps/collect-data/docs/collect-data.md +++ b/apps/collect-data/docs/collect-data.md @@ -74,8 +74,8 @@ bus, не попав на диск. Отказ любого шага откат ### Остановка ```text -runtime loop → finalizer → coordinator → PM source → CEX sources - → bus.drain() → recorder.close() → bus.close() +runtime loop → finalizer → coordinator → PM source → SDK client realtime + → CEX sources → bus.drain() → recorder.close() → bus.close() ``` Сначала глохнет ingress, затем очередь bus дренируется В recorder, и только потом @@ -83,6 +83,15 @@ runtime loop → finalizer → coordinator → PM source → CEX sources логируется и не отменяет остальные. `process.exit()` не используется — процесс обязан завершиться сам, иначе живой хэндл остался бы незамеченным. +Отдельный шаг — `closeSubscriptions()` официального SDK-клиента. Клиент общий для +source, discovery и финализатора, и shared websocket-соединения принадлежат ему, а не +подпискам: снятие подписок источником их не закрывает. Шаг идёт сразу ПОСЛЕ закрытия +source — раньше это рвало бы соединение под работающим итератором. + +Запуск и остановка сериализованы: сигнал, пришедший во время `start()`, дожидается +завершения подъёма и только потом гасит контур — иначе остановка «закрыла» бы +источники, которые запуск продолжает поднимать. + `SIGINT`, `SIGTERM` и фатальная ошибка сходятся в ОДИН охраняемый путь остановки: повторный сигнал не запускает вторую параллельную остановку. @@ -115,7 +124,7 @@ source поимённо. Все значения — существующие `g ```text {DATA_COLLECTION_OUTPUT_DIR}/ {YYYY-MM-DD}/ - polymarket/{question}___{marketId}.jsonl[.gz] + polymarket/polymarket_{question}___{marketId}.jsonl[.gz] {exchangeId}/{exchange}_{symbol}_{marketType}_{stream}_{dateET}_{startET}-{endET}_ET.jsonl[.gz] ``` diff --git a/apps/collect-data/ecosystem.config.cjs b/apps/collect-data/ecosystem.config.cjs index 5bd21309..3430bcf9 100644 --- a/apps/collect-data/ecosystem.config.cjs +++ b/apps/collect-data/ecosystem.config.cjs @@ -37,17 +37,16 @@ module.exports = { max_restarts: 10, min_uptime: '60s', - // Graceful shutdown: коллектор гасит источники, дренирует общий bus в - // recorder, закрывает окна записи и снимает сессии. На production-масштабе - // (100 рынков, 6 бирж, ~1.4M записей) замерено 79 секунд до «Shutdown - // complete», поэтому прежних 30 секунд не хватало — SIGKILL приходил - // ПОСРЕДИ финализации и терял текущие окна. + // Graceful shutdown: коллектор гасит источники, закрывает shared realtime + // SDK-клиента, дренирует общий bus в recorder и закрывает окна записи. + // На production-масштабе (100 рынков, 6 бирж, ~1.4M записей) от SIGTERM до + // «Shutdown complete» замерено 79 секунд, поэтому прежних 30 секунд не + // хватало — SIGKILL приходил ПОСРЕДИ финализации и терял текущие окна. // - // После завершения работы процесс не выходит сам: keep-alive пул - // официального SDK-клиента закрыть нечем (у него нет метода закрытия), и - // финальный SIGKILL от pm2 штатен — к этому моменту все архивы уже на - // диске. Маскировать это process.exit() нельзя: он скрыл бы и настоящие - // утечки таймеров/потоков. + // Это АВАРИЙНАЯ страховка, а не часть штатного пути: после «Shutdown + // complete» процесс обязан завершиться сам. Если он этого не делает — + // остался незакрытый ресурс, и лечить нужно его, а не таймаут; + // process.exit() такую утечку только спрятал бы. kill_timeout: 180000, wait_ready: false, diff --git a/apps/collect-data/jest.config.js b/apps/collect-data/jest.config.js index a7683e2c..6242bfdd 100644 --- a/apps/collect-data/jest.config.js +++ b/apps/collect-data/jest.config.js @@ -2,9 +2,24 @@ * Jest-конфигурация приложения коллектора. * * @remarks - * Пакеты контура импортируются по именам пакетов (публичный API), поэтому - * имена маппятся на ИСХОДНИКИ — тесты не зависят от собранного `dist`. + * Алиасы пакетов НЕ дублируются вручную: они выводятся из + * `tsconfig.json → compilerOptions.paths`, который и так обязан быть + * актуальным для компиляции. Ручная копия расходилась бы с ним молча. + * + * ESM включён по-настоящему (`--experimental-vm-modules` в npm-скрипте): + * рантайм импортирует официальный SDK как ESM-пакет из `node_modules`, и + * подменять его моком значило бы тестировать не тот код, который работает. */ +import { readFileSync } from 'node:fs'; +import { pathsToModuleNameMapper } from 'ts-jest'; + +const tsconfig = JSON.parse( + readFileSync(new URL('./tsconfig.json', import.meta.url), 'utf8').replace( + /^\s*\/\/.*$/gm, + '', + ), +); + /** @type {import('jest').Config} */ export default { preset: 'ts-jest/presets/default-esm', @@ -16,35 +31,8 @@ export default { '^.+\\.ts$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.json' }], }, moduleNameMapper: { + // Относительные ESM-импорты с расширением `.js` → исходники TypeScript. '^(\\.{1,2}/.*)\\.js$': '$1', - '^@polymarket/logger$': '/../../packages/foundation/logger/src/index.ts', - '^@polymarket/ids$': '/../../packages/foundation/ids/src/index.ts', - '^@polymarket/result$': '/../../packages/foundation/result/src/index.ts', - '^@polymarket/errors$': '/../../packages/foundation/errors/src/index.ts', - '^@polymarket/errors/(.*)$': '/../../packages/foundation/errors/src/$1', - '^@polymarket/math$': '/../../packages/foundation/math/src/index.ts', - '^@polymarket/time$': '/../../packages/foundation/time/src/index.ts', - '^@polymarket/timestamp$': '/../../packages/foundation/timestamp/src/index.ts', - '^@polymarket/messages$': '/../../packages/foundation/messages/src/index.ts', - '^@polymarket/message-bus$': '/../../packages/foundation/message-bus/src/index.ts', - '^@polymarket/value-objects$': '/../../packages/domain/value-objects/src/index.ts', - '^@polymarket/ports$': '/../../packages/application/ports/src/index.ts', - '^@polymarket/market-discovery$': - '/../../packages/application/market-discovery/src/index.ts', - '^@polymarket/data-collection$': - '/../../packages/infrastructure/persistence/data-collection/src/index.ts', - '^@polymarket/external-messages$': - '/../../packages/infrastructure/external-messages/src/index.ts', - '^@polymarket/external-message-bus$': - '/../../packages/infrastructure/external-message-bus/src/index.ts', - '^@polymarket/external-message-recorder$': - '/../../packages/infrastructure/persistence/external-message-recorder/src/index.ts', - '^@polymarket/polymarket-v2$': - '/../../packages/infrastructure/polymarket-v2/src/index.ts', - '^@polymarket/collection-coordinator$': - '/../../packages/infrastructure/collection-coordinator/src/index.ts', - '^@polymarket/market-finalizer$': - '/../../packages/infrastructure/market-finalizer/src/index.ts', - '^@polymarket/cex-v2$': '/../../packages/infrastructure/cex-v2/src/index.ts', + ...pathsToModuleNameMapper(tsconfig.compilerOptions.paths, { prefix: '/' }), }, }; diff --git a/apps/collect-data/package.json b/apps/collect-data/package.json index 5533c405..2f958935 100644 --- a/apps/collect-data/package.json +++ b/apps/collect-data/package.json @@ -14,7 +14,7 @@ "backfill:polymarket": "node --import tsx --env-file=.env src/backfillPolymarketMeta.ts", "analyze:chainlink": "node --disable-warning=ExperimentalWarning src/analyzeChainlinkLeadLag.ts", "fit:chainlink": "node --disable-warning=ExperimentalWarning src/fitChainlinkLinearModels.ts", - "test": "jest" + "test": "node --experimental-vm-modules ../../node_modules/.bin/jest" }, "dependencies": { "@polymarket/cex-v2": "0.1.0", @@ -44,10 +44,6 @@ "typescript": "^5.3.0" }, "exports": { - ".": { - "types": "./dist/main.d.ts", - "import": "./dist/main.js" - }, "./runtime": { "types": "./dist/runtime/index.d.ts", "import": "./dist/runtime/index.js" diff --git a/apps/collect-data/src/main.ts b/apps/collect-data/src/main.ts index 0aae7f33..032df42c 100644 --- a/apps/collect-data/src/main.ts +++ b/apps/collect-data/src/main.ts @@ -49,7 +49,18 @@ const logger = new ColorConsoleLogger( LOG_LEVELS[process.env['LOG_LEVEL'] ?? 'INFO'] ?? LogLevel.INFO, ); -const runtimeConfig = toDataCollectorConfig(config); +// Конверсия валидирует внешнюю конфигурацию (в т.ч. `cex-config.json`) и +// падает на невалидной. Без этого перехвата отказ ушёл бы в stderr голым +// stack trace-ом мимо логгера — в production-логах его было бы не найти. +let runtimeConfig; +try { + runtimeConfig = toDataCollectorConfig(config); +} catch (error) { + logger.fatal('Invalid collector configuration', { + error: error instanceof Error ? (error.stack ?? error.message) : String(error), + }); + process.exit(1); +} logger.info('Starting Polymarket data collector', { outputDir: runtimeConfig.outputDir, diff --git a/apps/collect-data/src/runtime/DataCollector.ts b/apps/collect-data/src/runtime/DataCollector.ts index 26610fac..334721e7 100644 --- a/apps/collect-data/src/runtime/DataCollector.ts +++ b/apps/collect-data/src/runtime/DataCollector.ts @@ -51,10 +51,11 @@ import type { ILogger } from '@polymarket/logger'; import type { IClock } from '@polymarket/time'; import type { Result } from '@polymarket/result'; import type { MessageBusDrainError, MessageBusStats } from '@polymarket/message-bus'; +import type { createPublicClient } from '@polymarket/client'; import type { CexSource, CexSourceStats } from '@polymarket/cex-v2'; import type { CexWindowRecorder, DataRecorder } from '@polymarket/data-collection'; import type { CexWindowRecorderStats } from '@polymarket/data-collection'; -import type { PolymarketMarketDiscovery, PolymarketSource } from '@polymarket/polymarket-v2'; +import type { PolymarketDiscoveredMarket, PolymarketSource } from '@polymarket/polymarket-v2'; import type { ExternalMessageRecorder } from '@polymarket/external-message-recorder'; import type { ExternalMessageRecorderCexStats, @@ -108,6 +109,21 @@ export type CollectorCexStorage = Pick; +/** + * Порт официального SDK-клиента в части ЕГО собственных ресурсов. + * + * @remarks + * Клиент разделяется между source, discovery и finalizer, но принадлежит не им, + * а контуру: shared realtime-соединения открывает и держит сам клиент, и + * закрыть их может только он. Без этого порта закрытие source снимало бы + * только его подписки, а транспорт под ними оставался бы жив — процесс не + * завершался бы сам после остановки. + */ +export type CollectorPolymarketClient = Pick< + ReturnType, + 'closeSubscriptions' +>; + /** Порт одного CEX-source. */ export type CollectorCexSource = Pick< CexSource, @@ -129,8 +145,33 @@ export interface CollectorCexSourceEntry { readonly source: CollectorCexSource; } -/** Порт Discovery (чтение кэша кандидатов для проекции lifecycle). */ -export type CollectorDiscovery = Pick; +/** + * Кандидат Discovery в объёме, который читает рантайм. + * + * @remarks + * Рантайм не участвует в отборе рынков (это работа координатора) и берёт из + * кандидата только identity/вопрос/истечение — ровно то, что уходит в событие + * `DISCOVERED`. Порт объявляет именно этот минимум: требовать полный + * `PolymarketDiscoveredMarket` значило бы заставлять любую альтернативную + * реализацию (и тесты) выдумывать восемь полей, которые никто не читает. + */ +export type CollectorCandidate = Pick< + PolymarketDiscoveredMarket, + 'marketId' | 'question' | 'expiresAt' +>; + +/** + * Порт Discovery (чтение кэша кандидатов для проекции lifecycle). + * + * @remarks + * Реальный `PolymarketMarketDiscovery` удовлетворяет порту без адаптеров: + * его `findCandidates()` возвращает более широкий тип, что для возвращаемого + * значения допустимо. + */ +export interface CollectorDiscovery { + /** Текущее содержимое кэша кандидатов. */ + findCandidates(): Promise; +} /** Порт координатора collection-сессий. */ export type CollectorCoordinator = Pick< @@ -155,6 +196,8 @@ export interface DataCollectorComponents { readonly polymarketStorage: CollectorPolymarketStorage; readonly cexStorage: CollectorCexStorage; readonly polymarketSource: CollectorPolymarketSource; + /** SDK-клиент, разделяемый source/discovery/finalizer (его realtime — за контуром). */ + readonly polymarketClient: CollectorPolymarketClient; readonly cexSources: readonly CollectorCexSourceEntry[]; readonly discovery: CollectorDiscovery; readonly coordinator: CollectorCoordinator; @@ -254,6 +297,8 @@ export class DataCollector { private _tickInFlight: Promise | null = null; private _lastRefreshMs = 0; private _closePromise: Promise | null = null; + /** Незавершённый запуск (остановка ждёт его, чтобы не гасить контур на подъёме). */ + private _startPromise: Promise | null = null; /** * Создаёт рантайм поверх уже собранных компонентов контура. @@ -314,7 +359,15 @@ export class DataCollector { throw new Error(`DataCollector.start() rejected: already ${this._state}`); } this._state = 'starting'; + // Запуск асинхронен, а остановка может прийти в любой момент — например, + // сигнал во время startup cleanup. Чтобы обе операции не работали с одними + // компонентами одновременно, close() дожидается ЭТОГО promise (см. close()). + this._startPromise = this._start(); + return this._startPromise; + } + /** Тело запуска (сериализовано с остановкой через `_startPromise`). */ + private async _start(): Promise { /** Обратные шаги уже поднятых ресурсов (LIFO при откате). */ const rollback: ShutdownStep[] = []; try { @@ -342,6 +395,8 @@ export class DataCollector { this._startedAtMs = this._nowMs(); this._state = 'running'; + // Цикл запускается ПОСЛЕДНИМ и только после перехода в running: тик, + // стартовавший раньше, работал бы с ещё не поднятым контуром. this._scheduleTick(0); this._logger.info('Data collector started', { cexSources: this._components.cexSources.length, @@ -418,11 +473,32 @@ export class DataCollector { this._state = 'stopped'; return; } - this._state = 'stopping'; - this._closePromise = this._close(); + this._closePromise = this._closeAfterStart(); return this._closePromise; } + /** + * Дожидается незавершённого запуска и только затем гасит контур. + * + * @remarks + * Без этого ожидания сигнал, пришедший во время `start()`, запустил бы + * лестницу остановки ПАРАЛЛЕЛЬНО подъёму: запуск продолжил бы поднимать + * source-ы уже после того, как остановка их «закрыла», и процесс остался бы + * с работающим ingress без recorder-а. + */ + private async _closeAfterStart(): Promise { + if (this._startPromise !== null) { + // Отказ запуска уже обработан внутри start(): он откатил поднятое и + // перевёл рантайм в stopped — здесь важен только факт завершения. + await this._startPromise.catch(() => undefined); + } + if (this._state === 'stopped') { + return; // запуск не удался и полностью откатился + } + this._state = 'stopping'; + return this._close(); + } + /** * Возвращает read-only снимок операционного состояния. * @@ -473,6 +549,21 @@ export class DataCollector { * владеют Discovery, Coordinator и Finalizer. */ public async tick(): Promise { + // Тик регистрирует себя САМ, а не планировщик: остановка обязана дожидаться + // любого идущего тика — и запущенного циклом, и вызванного напрямую. + const running = this._tick(); + this._tickInFlight = running; + try { + await running; + } finally { + if (this._tickInFlight === running) { + this._tickInFlight = null; + } + } + } + + /** Тело одного тика. */ + private async _tick(): Promise { const nowMs = this._nowMs(); if (nowMs - this._lastRefreshMs >= this._collection.discoveryRefreshMs) { this._lastRefreshMs = nowMs; @@ -511,6 +602,13 @@ export class DataCollector { { name: 'finalizer.close', run: async () => this._components.finalizer.close() }, { name: 'coordinator.close', run: async () => this._components.coordinator.close() }, { name: 'polymarketSource.close', run: async () => this._components.polymarketSource.close() }, + // Подписки source сняты — теперь можно закрыть shared realtime-транспорт, + // которым владеет сам клиент. Раньше нельзя: он общий для source, + // discovery и finalizer, а координатор к этому моменту уже закрыт. + { + name: 'polymarketClient.closeSubscriptions', + run: async () => this._components.polymarketClient.closeSubscriptions(), + }, { name: 'cexSources.close', run: async () => { @@ -577,18 +675,17 @@ export class DataCollector { if (this._state !== 'running') { return; } - const running = this.tick().catch((error: unknown) => { - this._logger.error('Runtime tick failed', { - error: error instanceof Error ? error.message : String(error), + // Регистрацию in-flight делает сам tick(); здесь остаётся только + // проглотить отказ и запланировать следующий проход. + void this.tick() + .catch((error: unknown) => { + this._logger.error('Runtime tick failed', { + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + this._scheduleTick(this._collection.runtimeTickMs); }); - }); - this._tickInFlight = running; - void running.finally(() => { - if (this._tickInFlight === running) { - this._tickInFlight = null; - } - this._scheduleTick(this._collection.runtimeTickMs); - }); }, delayMs); this._tickTimer.unref?.(); } diff --git a/apps/collect-data/src/runtime/DataCollectorConfig.ts b/apps/collect-data/src/runtime/DataCollectorConfig.ts index 8eee7b54..d88fbb9e 100644 --- a/apps/collect-data/src/runtime/DataCollectorConfig.ts +++ b/apps/collect-data/src/runtime/DataCollectorConfig.ts @@ -14,6 +14,8 @@ * рантайма НЕ протаскиваются: JSON-файл разбирается здесь и превращается в * `CexSourceConfig[]` пакета `@polymarket/cex-v2`. */ +import { forkEnvironmentConfig } from '@polymarket/client'; +import type { EnvironmentConfig } from '@polymarket/client'; import type { IMarketFilterConfig } from '@polymarket/ports'; import type { CexMarketType, CexSourceConfig } from '@polymarket/cex-v2'; import type { CollectorConfig } from '../config.js'; @@ -34,6 +36,15 @@ export interface PolymarketRecordingConfig { readonly flushIntervalMs: number; /** Сжатие датасета при финализации. */ readonly compression: 'none' | 'gzip'; + /** + * Окружение официального SDK. Не задано — production по умолчанию. + * + * @remarks + * Собирается из `GAMMA_API_BASE_URL`/`POLYMARKET_WS_URL`, если они заданы: + * иначе конфигурация приложения объявляла бы эндпоинты, которые контур + * молча игнорирует. + */ + readonly environment?: EnvironmentConfig; } /** @@ -216,6 +227,10 @@ export function parseCexSourceConfigs(json: string): readonly CexSourceConfig[] throw new Error(`Invalid CEX config for '${exchangeKey}': expected an object`); } const entry = rawEntry as CexConfigFileEntry; + // Порядок проверок = порядок «фундаментальности» поля: сначала тип рынка, + // затем набор символов, затем потоки, затем необязательные параметры — + // так сообщение указывает на первую по-настоящему сломанную вещь. + const marketType = toCexMarketType(entry.type, exchangeKey); const symbols = entry.symbols; if ( !Array.isArray(symbols) || @@ -243,12 +258,25 @@ export function parseCexSourceConfigs(json: string): readonly CexSourceConfig[] exchangeKey, ); + if (typeof entry.orderbook !== 'boolean' || typeof entry.trades !== 'boolean') { + throw new Error( + `Invalid CEX config for '${exchangeKey}': orderbook and trades must be booleans`, + ); + } + // Биржа без единого потока — почти наверняка опечатка в конфигурации: + // source поднимется, соединится и не запишет ни строки. + if (!entry.orderbook && !entry.trades) { + throw new Error( + `Invalid CEX config for '${exchangeKey}': at least one of orderbook/trades must be true`, + ); + } + sources.push({ exchangeId, - marketType: toCexMarketType(entry.type, exchangeKey), + marketType, symbols: symbols as readonly string[], - watchOrderbook: entry.orderbook === true, - watchTrades: entry.trades === true, + watchOrderbook: entry.orderbook, + watchTrades: entry.trades, ...(orderbookDepth !== undefined ? { orderbookDepth } : {}), ...(entry.obMethod !== undefined ? { orderbookMethod: entry.obMethod } : {}), ...(restartIntervalMs !== undefined ? { restartIntervalMs } : {}), @@ -257,6 +285,43 @@ export function parseCexSourceConfigs(json: string): readonly CexSourceConfig[] return sources; } +/** Production-значения эндпоинтов, при которых форк окружения не нужен. */ +const DEFAULT_GAMMA_BASE_URL = 'https://gamma-api.polymarket.com'; +const DEFAULT_MARKET_WS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market'; + +/** + * Собирает окружение SDK из заданных приложением эндпоинтов. + * + * @param config - Внешняя конфигурация приложения + * @returns Форк production-окружения либо `undefined`, если переопределений нет + * + * @remarks + * Пока эндпоинты совпадают с production, форк не создаётся — клиент работает + * на штатной конфигурации SDK. Как только `GAMMA_API_BASE_URL` или + * `POLYMARKET_WS_URL` отличаются, они ДОЛЖНЫ дойти до клиента: иначе + * конфигурация приложения обещает переключение окружения, которого на самом + * деле не происходит. + * + * `POLYMARKET_WS_URL` указывает на market-канал CLOB, поэтому переопределяется + * именно `clob.market`. + * + * `forkEnvironmentConfig` помечен в SDK как `@experimental` («signature may + * change without notice»), поэтому вызов локализован в одной функции: при + * смене контракта чинить придётся только её. + */ +function toEnvironmentConfig(config: CollectorConfig): EnvironmentConfig | undefined { + const gammaOverridden = config.gammaApiBaseUrl !== DEFAULT_GAMMA_BASE_URL; + const wsOverridden = config.wsUrl !== DEFAULT_MARKET_WS_URL; + if (!gammaOverridden && !wsOverridden) { + return undefined; + } + return forkEnvironmentConfig({ + name: 'collect-data', + ...(gammaOverridden ? { gamma: { rest: config.gammaApiBaseUrl } } : {}), + ...(wsOverridden ? { clob: { market: { ws: config.wsUrl } } } : {}), + }); +} + /** * Превращает внешнюю конфигурацию приложения в конфигурацию V2-рантайма. * @@ -278,6 +343,7 @@ export function parseCexSourceConfigs(json: string): readonly CexSourceConfig[] * ``` */ export function toDataCollectorConfig(config: CollectorConfig): DataCollectorConfig { + const environment = toEnvironmentConfig(config); return { outputDir: config.outputDir, polymarket: { @@ -285,6 +351,7 @@ export function toDataCollectorConfig(config: CollectorConfig): DataCollectorCon bufferSize: config.bufferSize, flushIntervalMs: config.flushIntervalMs, compression: config.compression, + ...(environment !== undefined ? { environment } : {}), }, discovery: { filter: { diff --git a/apps/collect-data/src/runtime/createDataCollector.ts b/apps/collect-data/src/runtime/createDataCollector.ts index b277688b..2566d295 100644 --- a/apps/collect-data/src/runtime/createDataCollector.ts +++ b/apps/collect-data/src/runtime/createDataCollector.ts @@ -52,6 +52,9 @@ export type ContourMessage = PolymarketExternalMessage | CexExternalMessage; /** Общий bus внешнего контура коллектора. */ export type ContourBus = ExternalMessageBus; +/** Официальный SDK-клиент, разделяемый компонентами Polymarket-контура. */ +export type ContourPolymarketClient = ReturnType; + /** Зависимости {@link createDataCollector}. */ export interface CreateDataCollectorOptions { /** Конфигурация рантайма (см. `DataCollectorConfig`). */ @@ -70,6 +73,17 @@ export interface CreateDataCollectorOptions { * возникает: consumer знает про bus, а не про `DataCollector`. */ readonly bus?: ContourBus; + /** + * Общий SDK-клиент. Если не передан — создаётся фабрикой по + * `config.polymarket.environment`. + * + * @remarks + * Клиент — разделяемый ресурс source/discovery/finalizer, и его realtime + * закрывает рантайм (`closeSubscriptions` в лестнице остановки). Инъекция + * нужна диагностике и тестам, которым важно держать ссылку на тот же + * экземпляр. + */ + readonly client?: ContourPolymarketClient; } /** Результат сборки контура. */ @@ -84,6 +98,14 @@ export interface CreatedDataCollector { * recorder уже подписан, ingress ещё не начат. */ readonly bus: ContourBus; + /** + * Общий SDK-клиент контура. + * + * @remarks + * Возвращается для диагностики; закрывать его самостоятельно не нужно — + * это делает `collector.close()`. + */ + readonly client: ContourPolymarketClient; } /** @@ -112,7 +134,13 @@ export interface CreatedDataCollector { export function createDataCollector(options: CreateDataCollectorOptions): CreatedDataCollector { const { config, logger, clock } = options; const bus: ContourBus = options.bus ?? new ExternalMessageBus(); - const client = createPublicClient(); + const client: ContourPolymarketClient = + options.client ?? + createPublicClient( + config.polymarket.environment !== undefined + ? { environment: config.polymarket.environment } + : {}, + ); const metadataGenerator = new MessageMetadataGenerator({ clock, highResolutionClock: new LiveHighResolutionClock(), @@ -188,6 +216,7 @@ export function createDataCollector(options: CreateDataCollectorOptions): Create polymarketStorage, cexStorage, polymarketSource, + polymarketClient: client, cexSources, discovery, coordinator, @@ -198,5 +227,5 @@ export function createDataCollector(options: CreateDataCollectorOptions): Create logger, }); - return { collector, bus }; + return { collector, bus, client }; } diff --git a/docs/guides/data-collector-v2-cutover.md b/docs/guides/data-collector-v2-cutover.md index 8e474ae9..7ef97c8f 100644 --- a/docs/guides/data-collector-v2-cutover.md +++ b/docs/guides/data-collector-v2-cutover.md @@ -202,34 +202,65 @@ await collector.start(); Порядок подписки гарантирован конструкцией: recorder подписывается внутри `collector.start()`, наблюдатели — до него, ingress стартует последним. -## Известное ограничение: сокеты официального SDK - -На длинном прогоне (31.6 минуты, из них 10 минут дренажа финализаций) процесс не -завершился сам в течение 60 секунд после остановки контура: остались 7 -`TCPSocketWrap`. Разбор: - -- `createPublicClient()` официального SDK не имеет НИКАКОГО метода закрытия — ни - `close`, ни `dispose`, ни `Symbol.asyncDispose`. Его пул keep-alive соединений к - `gamma-api`/`clob` не может быть освобождён ни коллектором, ни verification-runner-ом; -- чем дольше идёт финализация, тем больше опросов Gamma и тем больше соединений в - пуле: прогоны на 17 минут с тремя финализациями завершались чисто, прогон на 31 - минуту с девятью — нет; -- всё, чем владеет сам контур, освобождается: короткий режим checkpoint поднимает и - гасит ДВЕ полные композиции подряд в одном процессе, и обе заканчиваются состоянием - `{FSReqPromise: 1}` — ровно тем же, что и прогоны до этого MR, — а процесс выходит - с кодом 0. - -Маскировать это `process.exit()` сознательно не стали: принудительный выход скрыл бы -не только чужой пул сокетов, но и любую будущую утечку таймера или потока. Правильное -место для решения — метод закрытия в SDK-клиенте либо собственный HTTP-агент под -контролем приложения; и то и другое выходит за границы MR-A. - -Операционное следствие: под pm2 процесс завершает финальный `SIGKILL` по -`kill_timeout`. Это штатно — к тому моменту вся работа сделана, — но таймаут обязан -превышать длительность самой остановки. На production-масштабе (100 рынков, 6 бирж, -~1.4M записей) от `SIGTERM` до «Shutdown complete» замерено **79 секунд**, поэтому -прежних 30 секунд не хватало: SIGKILL приходил посреди финализации и терял текущие -окна записи. `kill_timeout` поднят до 180 секунд. +## Владение SDK-клиентом и завершение процесса + +Официальный SDK-клиент создаётся один на процесс и разделяется тремя компонентами: + +```text +createPublicClient() + ├──► PolymarketSource (subscribe) + ├──► PolymarketMarketDiscovery (listMarkets/fetchEvent) + └──► MarketFinalizer (fetchMarket/fetchEvent) +``` + +Ключевой момент: **подписки и транспорт под ними принадлежат разным владельцам**. +`PolymarketSource.close()` снимает СВОИ подписки, но shared websocket-соединения +держит сам клиент — по документации SDK, `closeSubscriptions()` «ends active +subscription iterators and closes shared websocket connections». Пока клиент не +входил в состав компонентов рантайма, закрыть их было некому: ресурс создавался +фабрикой, использовался тремя компонентами и не принадлежал никому. + +Поэтому клиент — явная часть `DataCollectorComponents`, а его realtime закрывается +в лестнице остановки сразу после source, который этими подписками пользовался: + +```text +… → coordinator.close → polymarketSource.close + → polymarketClient.closeSubscriptions ← закрытие shared realtime + → cexSources.close → bus.drain → recorder.close → bus.close +``` + +Порядок обязателен: закрывать общий транспорт раньше, чем source снял свои подписки, +означало бы рвать соединение под работающим итератором. + +### Что показали замеры + +Диагностика (`process._getActiveHandles()` с `servername` каждого сокета) на +production-масштабе — 100 рынков, 6 бирж, 12 RTDS-фидов, ~108 тыс. сообщений: + +| Прогон | Осталось после остановки | +| ------------------------------------------------- | ------------------------ | +| 5 мин, 100 рынков, 6 бирж | 0 сокетов, выход сам | +| 5 мин, принудительные плановые рестарты CCXT (45 c) | 0 сокетов, выход сам | +| checkpoint full 18 мин, 3 архива | `{FSReqCallback: 1}`, exit 0 | + +До остановки в этих прогонах живы ровно ожидаемые сокеты — по два на биржу, +`ws-subscriptions-clob`, `ws-live-data` и (в момент опроса) `gamma-api`; после +остановки не остаётся ни одного. + +Важно зафиксировать честно: **исходное «зависание» наблюдалось однократно** — на +прогоне 31.6 минуты с девятью архивами и десятиминутным дренажом. Первичное +объяснение («keep-alive пул Gamma, который нечем закрыть») оказалось НЕВЕРНЫМ: +метод закрытия у клиента есть, а сокеты `gamma-api` в замерах освобождаются сами. +Воспроизвести зависание на более коротких прогонах не удалось, поэтому +`closeSubscriptions()` добавлен как закрытие реального разрыва во владении, а не как +доказанное лекарство от того конкретного случая. + +`process.exit()` для маскировки по-прежнему не используется: он скрыл бы и утечку +таймера, и незакрытый поток. `kill_timeout` в pm2 остаётся аварийной страховкой, а не +частью штатного пути остановки; его значение подобрано под фактическую длительность +самой остановки — на production-масштабе от `SIGTERM` до «Shutdown complete» +замерено **79 секунд**, поэтому прежних 30 секунд не хватало и SIGKILL приходил +посреди финализации. ## Аудит collector-legacy после удаления V1 diff --git a/scripts/checkpoint-raw-live.mts b/scripts/checkpoint-raw-live.mts index ccf992e6..fd817408 100644 --- a/scripts/checkpoint-raw-live.mts +++ b/scripts/checkpoint-raw-live.mts @@ -1554,11 +1554,46 @@ function armExitWatchdog(): void { const watchdog = setTimeout(() => { console.error('CHECKPOINT: process did not exit naturally within 60s after main()'); console.error('Active resources:', process.getActiveResourcesInfo()); + console.error('Active handles:', describeActiveHandles()); process.exit(3); }, 60_000); watchdog.unref(); } +/** + * Описывает удерживающие процесс handle-ы с деталями сокетов. + * + * @returns Человекочитаемые строки по каждому активному handle + * + * @remarks + * `getActiveResourcesInfo()` отдаёт только типы (`'TCPSocketWrap'`), по которым + * невозможно понять ВЛАДЕЛЬЦА утечки. Для сокетов решает `servername`: он прямо + * называет хост (`ws-subscriptions-clob`, `gamma-api`, `stream.binance.com`), а + * значит и подсистему, чей ресурс не закрыт. Используются недокументированные + * `process._getActiveHandles/_getActiveRequests` — это диагностика runner-а, не + * production-код, и их отсутствие не ломает вердикт. + */ +function describeActiveHandles(): readonly string[] { + const handles = + (process as unknown as { _getActiveHandles?: () => unknown[] })._getActiveHandles?.() ?? []; + const described = handles.map((handle) => { + const entry = handle as Record; + const kind = (entry['constructor'] as { name?: string } | undefined)?.name ?? 'unknown'; + if (kind !== 'Socket' && kind !== 'TLSSocket') { + return kind; + } + const servername = entry['servername']; + return ( + `${kind} servername=${String(servername)} ` + + `remote=${String(entry['remoteAddress'])}:${String(entry['remotePort'])} ` + + `destroyed=${String(entry['destroyed'])}` + ); + }); + const requests = + (process as unknown as { _getActiveRequests?: () => unknown[] })._getActiveRequests?.() ?? []; + return [...described, `activeRequests=${String(requests.length)}`]; +} + void main().then( (code) => { process.exitCode = code; From 6123920519747106bda8812b65788dd984eceeae Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 26 Aug 2026 01:51:30 +0300 Subject: [PATCH 6/8] =?UTF-8?q?docs(collect-data):=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BD=D0=B0=20=D0=B4=D0=BB=D0=B8=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прогон 36.3 мин / 9 архивов / 1 478 982 сообщения / 288 партиций с дренажом превосходит исходный отказавший (31.6 мин / 9 архивов / 1 240 382 / 240) по каждому параметру и завершается сам: activeResourcesAfterShutdown = {FSReqPromise: 1} — базовое состояние до этого MR, watchdog не срабатывал, код выхода 0. Co-Authored-By: Claude Opus 5 --- docs/guides/data-collector-v2-cutover.md | 33 ++++++++++++++---------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/docs/guides/data-collector-v2-cutover.md b/docs/guides/data-collector-v2-cutover.md index 7ef97c8f..d941e47d 100644 --- a/docs/guides/data-collector-v2-cutover.md +++ b/docs/guides/data-collector-v2-cutover.md @@ -237,23 +237,28 @@ subscription iterators and closes shared websocket connections». Пока кл Диагностика (`process._getActiveHandles()` с `servername` каждого сокета) на production-масштабе — 100 рынков, 6 бирж, 12 RTDS-фидов, ~108 тыс. сообщений: -| Прогон | Осталось после остановки | -| ------------------------------------------------- | ------------------------ | -| 5 мин, 100 рынков, 6 бирж | 0 сокетов, выход сам | -| 5 мин, принудительные плановые рестарты CCXT (45 c) | 0 сокетов, выход сам | -| checkpoint full 18 мин, 3 архива | `{FSReqCallback: 1}`, exit 0 | - -До остановки в этих прогонах живы ровно ожидаемые сокеты — по два на биржу, +| Прогон | Осталось после остановки | Выход | +| --------------------------------------------------- | ------------------------------ | ------ | +| 5 мин, 100 рынков, 6 бирж | 0 сокетов | сам | +| 5 мин, принудительные плановые рестарты CCXT (45 c) | 0 сокетов | сам | +| checkpoint full 18 мин, 3 архива | `{FSReqCallback: 1}` | код 0 | +| **checkpoint full 36.3 мин, 9 архивов, дренаж** | **`{FSReqPromise: 1}`** | код 0 | +| _(до исправления)_ checkpoint full 31.6 мин, 9 архивов | _7 `TCPSocketWrap`_ | _код 3_ | + +До остановки живы ровно ожидаемые сокеты — по два на биржу, `ws-subscriptions-clob`, `ws-live-data` и (в момент опроса) `gamma-api`; после остановки не остаётся ни одного. -Важно зафиксировать честно: **исходное «зависание» наблюдалось однократно** — на -прогоне 31.6 минуты с девятью архивами и десятиминутным дренажом. Первичное -объяснение («keep-alive пул Gamma, который нечем закрыть») оказалось НЕВЕРНЫМ: -метод закрытия у клиента есть, а сокеты `gamma-api` в замерах освобождаются сами. -Воспроизвести зависание на более коротких прогонах не удалось, поэтому -`closeSubscriptions()` добавлен как закрытие реального разрыва во владении, а не как -доказанное лекарство от того конкретного случая. +Последний прогон превосходит исходный отказавший по КАЖДОМУ параметру — длительность +36.3 против 31.6 минуты, 1 478 982 сообщения против 1 240 382, 288 CEX-партиций +против 240, те же девять архивов (все `complete`) с дренажом финализаций — и +завершается сам, без срабатывания watchdog. Остаточное состояние совпадает с +базовым, каким оно было до этого MR. + +Первичное объяснение («keep-alive пул Gamma, который нечем закрыть») оказалось +НЕВЕРНЫМ и здесь исправлено: метод закрытия у клиента есть (`closeSubscriptions`), а +сокеты `gamma-api` в замерах освобождаются сами. Настоящей причиной был разрыв во +владении — shared realtime клиента не закрывал никто. `process.exit()` для маскировки по-прежнему не используется: он скрыл бы и утечку таймера, и незакрытый поток. `kill_timeout` в pm2 остаётся аварийной страховкой, а не From da52d7a73c38267b4aff55e8d75434d8f954738d Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 26 Aug 2026 15:28:57 +0300 Subject: [PATCH 7/8] =?UTF-8?q?fix(collect-data):=20=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B4=D0=BE=D0=B6=D0=B8?= =?UTF-8?q?=D0=B4=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=92=D0=A1=D0=95=D0=A5?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D1=8E?= =?UTF-8?q?=D1=89=D0=B8=D1=85=D1=81=D1=8F=20=D1=82=D0=B8=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_tickInFlight` хранил ссылку на один тик, и второй затирал первый. Планировщик запускает следующий проход только после завершения предыдущего, но `tick()` публичен: прямой вызов может перекрыться с проходом цикла. В этом случае остановка дожидалась только последнего тика и закрывала координатор с финализатором прямо под первым, ещё работающим. Воспроизведено тестом (первый тик подвешен в fillSlots, второй проходит насквозь и завершается раньше): до исправления `bus.close` выполнялся, пока первый тик был жив. Теперь тики хранятся множеством, а `close()` дренирует его циклом — новых проходов планировщик к этому моменту уже не создаёт. Заодно по ревью: - документация больше не утверждает «один клиент на процесс»: граница владения — композиция, каждый `createDataCollector` без явного `client` создаёт свой (короткий режим checkpoint поднимает две композиции — клиентов там два); - диагностика watchdog-а пишется через `fs.writeSync(2, ...)`: запись в stderr асинхронна при pipe, и следующий `process.exit(3)` её обрывал. Проверено эмпирически — через pipe `console.error` терял вывод ЦЕЛИКОМ. Co-Authored-By: Claude Opus 5 --- .../__tests__/DataCollector.shutdown.test.ts | 32 +++++++++++++++++++ apps/collect-data/__tests__/helpers/fakes.ts | 11 +++++++ .../collect-data/src/runtime/DataCollector.ts | 30 ++++++++++------- docs/guides/data-collector-v2-cutover.md | 10 +++++- scripts/checkpoint-raw-live.mts | 14 ++++++-- 5 files changed, 82 insertions(+), 15 deletions(-) diff --git a/apps/collect-data/__tests__/DataCollector.shutdown.test.ts b/apps/collect-data/__tests__/DataCollector.shutdown.test.ts index d073ca09..565e146a 100644 --- a/apps/collect-data/__tests__/DataCollector.shutdown.test.ts +++ b/apps/collect-data/__tests__/DataCollector.shutdown.test.ts @@ -305,4 +305,36 @@ describe('DataCollector.close() — таймеры и тики', () => { ); expect(contour.log.countOf('bus.close')).toBe(1); }); + + it('дожидается ВСЕХ перекрывающихся тиков, а не только последнего', async () => { + const { collector, contour } = makeCollector(); + await collector.start(); + + // Первый тик подвисает в fillSlots. + contour.coordinator.block(); + const firstTick = collector.tick(); + await Promise.resolve(); + await Promise.resolve(); + expect(contour.log.indexOf('coordinator.fillSlots.start')).toBeGreaterThanOrEqual(0); + + // Второй тик перекрывает первый и УСПЕВАЕТ завершиться раньше него. + contour.coordinator.unblockFuture(); + await collector.tick(); + + const closing = collector.close(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Первый тик всё ещё работает с координатором/финализатором — закрывать их + // нельзя. Хранение одной ссылки на «текущий тик» здесь давало bus.close. + expect(contour.log.indexOf('bus.close')).toBe(-1); + + contour.coordinator.release(); + await Promise.all([closing, firstTick]); + + expect(contour.log.countOf('coordinator.fillSlots')).toBe(2); + // Оба тика завершились до закрытия контура. + expect(contour.log.calls.lastIndexOf('coordinator.fillSlots')).toBeLessThan( + contour.log.orderOf('bus.close'), + ); + }); }); diff --git a/apps/collect-data/__tests__/helpers/fakes.ts b/apps/collect-data/__tests__/helpers/fakes.ts index f43f7cd3..e8062bca 100644 --- a/apps/collect-data/__tests__/helpers/fakes.ts +++ b/apps/collect-data/__tests__/helpers/fakes.ts @@ -376,6 +376,17 @@ export class FakeCoordinator implements CollectorCoordinator { }); } + /** + * Снимает блокировку для БУДУЩИХ вызовов, не отпуская уже подвешенный. + * + * @remarks + * Нужно для проверки ПЕРЕКРЫВАЮЩИХСЯ тиков: первый остаётся в полёте, а + * второй проходит насквозь и завершается раньше него. + */ + public unblockFuture(): void { + this._gate = undefined; + } + /** Отпускает подвешенный `fillSlots()`. */ public release(): void { this._openGate?.(); diff --git a/apps/collect-data/src/runtime/DataCollector.ts b/apps/collect-data/src/runtime/DataCollector.ts index 334721e7..93004b22 100644 --- a/apps/collect-data/src/runtime/DataCollector.ts +++ b/apps/collect-data/src/runtime/DataCollector.ts @@ -293,8 +293,17 @@ export class DataCollector { private _state: DataCollectorState = 'idle'; private _startedAtMs: number | null = null; private _tickTimer: ReturnType | null = null; - /** Текущий тик цикла — остановка дожидается его завершения. */ - private _tickInFlight: Promise | null = null; + /** + * ВСЕ идущие сейчас тики — остановка дожидается каждого. + * + * @remarks + * Именно множество, а не «текущий тик»: планировщик запускает следующий проход + * только после завершения предыдущего, но `tick()` публичен, и прямой вызов + * может перекрыться с проходом цикла. При хранении одной ссылки второй тик + * затирал бы первый, и остановка закрывала бы координатор с финализатором + * прямо под работающим тиком. + */ + private readonly _activeTicks = new Set>(); private _lastRefreshMs = 0; private _closePromise: Promise | null = null; /** Незавершённый запуск (остановка ждёт его, чтобы не гасить контур на подъёме). */ @@ -552,13 +561,11 @@ export class DataCollector { // Тик регистрирует себя САМ, а не планировщик: остановка обязана дожидаться // любого идущего тика — и запущенного циклом, и вызванного напрямую. const running = this._tick(); - this._tickInFlight = running; + this._activeTicks.add(running); try { await running; } finally { - if (this._tickInFlight === running) { - this._tickInFlight = null; - } + this._activeTicks.delete(running); } } @@ -590,11 +597,12 @@ export class DataCollector { clearTimeout(this._tickTimer); this._tickTimer = null; } - // Тик мог быть в полёте: его шаги трогают те же координатор/финализатор, - // что и лестница закрытия, поэтому дожидаемся завершения. - if (this._tickInFlight !== null) { - await this._tickInFlight.catch(() => undefined); - this._tickInFlight = null; + // Тики могли остаться в полёте: их шаги трогают те же координатор и + // финализатор, что и лестница закрытия. Цикл — на случай, если тик успел + // стартовать, пока мы дожидались предыдущих; новых проходов планировщик уже + // не создаёт (состояние больше не `running`), поэтому ожидание конечно. + while (this._activeTicks.size > 0) { + await Promise.allSettled([...this._activeTicks]); } const cexSources = this._components.cexSources; diff --git a/docs/guides/data-collector-v2-cutover.md b/docs/guides/data-collector-v2-cutover.md index d941e47d..3ce986d6 100644 --- a/docs/guides/data-collector-v2-cutover.md +++ b/docs/guides/data-collector-v2-cutover.md @@ -204,7 +204,8 @@ await collector.start(); ## Владение SDK-клиентом и завершение процесса -Официальный SDK-клиент создаётся один на процесс и разделяется тремя компонентами: +Официальный SDK-клиент создаётся ОДИН НА КОМПОЗИЦИЮ и разделяется тремя её +компонентами: ```text createPublicClient() @@ -213,6 +214,13 @@ createPublicClient() └──► MarketFinalizer (fetchMarket/fetchEvent) ``` +Границей владения служит именно композиция, а не процесс: каждый вызов +`createDataCollector` без явного `options.client` создаёт СВОЙ клиент. В +production это одно и то же (процесс поднимает один контур), но короткий режим +checkpoint поднимает две композиции подряд — и клиентов там два, каждый со своим +закрытием. Передать общий клиент снаружи можно тем же способом, что и bus: +`createDataCollector({ ..., client })`. + Ключевой момент: **подписки и транспорт под ними принадлежат разным владельцам**. `PolymarketSource.close()` снимает СВОИ подписки, но shared websocket-соединения держит сам клиент — по документации SDK, `closeSubscriptions()` «ends active diff --git a/scripts/checkpoint-raw-live.mts b/scripts/checkpoint-raw-live.mts index fd817408..909a96a6 100644 --- a/scripts/checkpoint-raw-live.mts +++ b/scripts/checkpoint-raw-live.mts @@ -1552,9 +1552,17 @@ async function main(): Promise { */ function armExitWatchdog(): void { const watchdog = setTimeout(() => { - console.error('CHECKPOINT: process did not exit naturally within 60s after main()'); - console.error('Active resources:', process.getActiveResourcesInfo()); - console.error('Active handles:', describeActiveHandles()); + // fs.writeSync, а НЕ console.error: запись в stderr асинхронна, когда он + // подключён к pipe (CI, `| tee`), и следующий за ней process.exit() обрывает + // её на полуслове. Потерять здесь можно ровно ту диагностику, ради которой + // watchdog и существует. + const lines = [ + 'CHECKPOINT: process did not exit naturally within 60s after main()', + `Active resources: ${JSON.stringify(process.getActiveResourcesInfo())}`, + `Active handles: ${JSON.stringify(describeActiveHandles(), null, 2)}`, + '', + ]; + fs.writeSync(2, lines.join('\n')); process.exit(3); }, 60_000); watchdog.unref(); From 5caf4d473f779822dbb3da9c4f988742d861a8ef Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Wed, 26 Aug 2026 16:12:49 +0300 Subject: [PATCH 8/8] =?UTF-8?q?fix(data-collection):=20=D1=83=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=82=D1=8C=20=D0=B3=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D1=83=20=D0=B2=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=D1=85=20=D1=80?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D1=86=D0=B8=D0=B8=20CEX-=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GzipCompressor.compressFile` сначала переименовывает временный файл в `.gz` и только потом удаляет исходный `.jsonl` — это ДВА события. Тесты ждали появления `.gz`, а утверждали про удаление `.jsonl`, из-за чего ловили середину ротации: в листинге оказывались оба файла, и `expect(open).toHaveLength(1)` падал. Окно измерено на реальном компрессоре: оба файла сосуществуют в 4 из 40 ротаций. Под параллельной нагрузкой полного прогона монорепы этого хватало, чтобы сьют падал примерно раз через раз. Обе площадки переведены на `waitForCompletedPartitions`, которая ждёт фактического инварианта («архив есть И исходник удалён»). Проверено: на состоянии середины ротации старый предикат пропускал дальше, новый продолжает ждать. 12 прогонов сьюта подряд — без падений. Найдено при верификации MR-A: гонка существовала до этой ветки и к V2-контуру отношения не имеет. Co-Authored-By: Claude Opus 5 --- .../__tests__/CexWindowRecorder.test.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/infrastructure/persistence/data-collection/__tests__/CexWindowRecorder.test.ts b/packages/infrastructure/persistence/data-collection/__tests__/CexWindowRecorder.test.ts index fc631b77..ccb990b6 100644 --- a/packages/infrastructure/persistence/data-collection/__tests__/CexWindowRecorder.test.ts +++ b/packages/infrastructure/persistence/data-collection/__tests__/CexWindowRecorder.test.ts @@ -53,6 +53,28 @@ function listFiles(root: string): string[] { return out.sort(); } +/** + * Ждёт ЗАВЕРШЁННЫХ партиций: `.jsonl.gz` появился И исходный `.jsonl` удалён. + * + * @remarks + * Это ДВА разных события: `GzipCompressor` сначала переименовывает временный + * файл в `.gz` и только потом удаляет исходник. Ожидание одного лишь `.gz` + * ловит середину ротации, и в листинге оказываются оба файла сразу — под + * параллельной нагрузкой (полный прогон монорепы) это давало флейк. + */ +async function waitForCompletedPartitions( + root: string, + count: number, + timeoutMs = 3_000, +): Promise { + await waitFor(() => { + const files = listFiles(root); + const archives = files.filter((file) => file.endsWith('.jsonl.gz')); + if (archives.length !== count) return false; + return archives.every((archive) => !files.includes(archive.replace(/\.gz$/, ''))); + }, timeoutMs); +} + function gunzipLines(filePath: string): string[] { const raw = zlib.gunzipSync(fs.readFileSync(filePath)).toString('utf8'); return raw.split('\n').filter((line) => line.length > 0); @@ -148,7 +170,7 @@ describe('CexWindowRecorder (инъецированное время)', () => { expect(recorder.write('binance', 'BTC/USDT', 'spot', 'orderbook', { w: 2 })).toBe('recorded'); // Старая партиция завершена: .jsonl.gz появился, .jsonl удалён - await waitFor(() => listFiles(dir).some((file) => file.endsWith('.jsonl.gz'))); + await waitForCompletedPartitions(dir, 1); const files = listFiles(dir); const gz = files.find((file) => file.endsWith('.jsonl.gz'))!; const open = files.filter((file) => file.endsWith('.jsonl') && !file.endsWith('.jsonl.gz')); @@ -230,7 +252,7 @@ describe('CexWindowRecorder (инъецированное время)', () => { recorder.write('binance', 'BTC/USDT', 'spot', 'trades', { w: 1 }); now = firstWindow + windowMs + 1_000; recorder.write('binance', 'BTC/USDT', 'spot', 'trades', { w: 2 }); - await waitFor(() => listFiles(dir).filter((f) => f.endsWith('.jsonl.gz')).length === 1); + await waitForCompletedPartitions(dir, 1); await recorder.flush(); const files = listFiles(dir);