Skip to content

Commit c04ffdc

Browse files
authored
Merge pull request #105 from usherlabs/refactor/external-market-data-collector
Use external broker for market data collection
2 parents 7a83de5 + 35f5b2a commit c04ffdc

12 files changed

Lines changed: 253 additions & 466 deletions

.env.sample

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,9 @@ CEX_BROKER_ARCHIVE_FORWARDER_URL=http://localhost:8090/archive
3737
# the container filesystem is not durable across container replacement.
3838
CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH=./archive-loss.jsonl
3939
CEX_BROKER_DEPLOYMENT_ID=local-dev
40-
# OHLCV collector: required JSON array config and reconnect bootstrap coverage.
41-
# 1000 one-minute bars cover roughly 16 hours on every (re)subscription.
42-
CEX_BROKER_OHLCV_ARCHIVE_BOOTSTRAP_LIMIT=1000
43-
CEX_BROKER_OHLCV_COLLECTOR_CONFIG=./ohlcv-subscriptions.json
40+
# Market-data collector: external broker target and feed-intent JSON document.
41+
CEX_BROKER_URL=localhost:8086
42+
CEX_BROKER_MARKET_DATA_COLLECTOR_CONFIG=./market-data-subscriptions.json
4443
# ClickHouse (forwarder, candle-viewer, Python)
4544
CLICKHOUSE_HOST=localhost
4645
CLICKHOUSE_PORT=8123

.github/workflows/publish-ohlcv-collector.yml renamed to .github/workflows/publish-market-data-collector.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
# Publishes the OHLCV collector image (services/ohlcv-collector).
1+
# Publishes the multi-feed market-data collector image (services/ohlcv-collector).
22
# The collector is repo-runtime only — it is NOT part of the npm package
33
# (files: ["dist"]), so it ships as its own container image.
44
#
55
# Triggers: semver tags (alongside the broker publish) and manual dispatch
66
# (to publish from a branch without cutting an npm release).
77

8-
name: Publish OHLCV Collector
8+
name: Publish Market Data Collector
99

1010
on:
1111
push:
@@ -15,14 +15,14 @@ on:
1515
workflow_dispatch:
1616

1717
env:
18-
IMAGE_NAME: ghcr.io/usherlabs/cex-broker-ohlcv-collector
18+
IMAGE_NAME: ghcr.io/usherlabs/cex-broker-market-data-collector
1919

2020
permissions:
2121
contents: read
2222
packages: write
2323

2424
jobs:
25-
publish-ohlcv-collector:
25+
publish-market-data-collector:
2626
runs-on: ubuntu-latest
2727
steps:
2828
- name: Checkout repo

docs/canonical-market-data-replay.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ CEX_BROKER_ARCHIVE_FORWARDER_URL=http://archive-forwarder:8090/archive
1818
CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH=/var/lib/cex-broker/archive-loss.jsonl
1919
```
2020

21-
`CEX_BROKER_ARCHIVE_SOURCE` is closed to `broker_read|broker_write` and defaults to `broker_write` for existing deployments. The writer stamps this immutable value into envelopes, rows, and loss records. It is never inferred from API-key presence. Production FIET-901 collector startup requires `broker_read` and a non-empty deployment-owned capture bundle. Development capture explicitly uses `CEX_BROKER_MARKET_CAPTURE_ENVIRONMENT=development` and generates a `development:<deployment>` bundle when none is supplied.
21+
`CEX_BROKER_ARCHIVE_SOURCE` is closed to `broker_read|broker_write` and defaults to `broker_write` for existing deployments. The writer stamps this immutable value into envelopes, rows, and loss records. It is never inferred from API-key presence. Production market-data collection requires the separately deployed broker to use `broker_read` and a non-empty deployment-owned capture bundle. Development capture explicitly uses `CEX_BROKER_MARKET_CAPTURE_ENVIRONMENT=development` and generates a `development:<deployment>` bundle when none is supplied.
2222

2323
Credential resolution uses the broker's established fixed precedence and requires no archive-specific credential configuration:
2424

@@ -52,8 +52,6 @@ Run it with `bun run start-market-data-collector`. The collector starts no loopb
5252

5353
Each entry has an independent reconnect supervisor and health state. OHLCV retains bootstrap/catch-up and stamps `broker_bootstrap_fetch_v1` separately from live capture. ORDERBOOK, TICKER, and TRADES record unrecoverable gaps after reconnect rather than synthesize missing events.
5454

55-
`CEX_BROKER_OHLCV_COLLECTOR_CONFIG` remains supported as an OHLCV-only array. Move those entries into the canonical document and add `feed: "OHLCV"`; archive identity stays on the broker. The `start-ohlcv-collector` command and existing service directory/image remain compatibility wrappers for both formats.
56-
5755
An external CCXT or Hummingbot fallback is an optional out-of-band producer of the shared capture contract, not a broker-collector implementation. It must declare its provider, versioned fallback source mode, reason, configured exchange, and configured pair. Cross-venue or cross-pair substitution is rejected.
5856

5957
## Capture and integrity contract

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"prepack": "bun run build",
3737
"start-broker": "bun run ./src/cli.ts",
3838
"start-archive-forwarder": "bun run services/archive-forwarder/index.ts",
39-
"start-ohlcv-collector": "bun run services/ohlcv-collector/index.ts",
39+
"start-market-data-collector": "bun run services/ohlcv-collector/index.ts",
4040
"start-archive-watch": "bun run examples/archive-watch-subscribe.ts",
4141
"start-candle-viewer": "bun run research/candle-viewer/server.ts",
4242
"dev:candle-viewer": "bunx nodemon --watch research/candle-viewer --ext ts,html --signal SIGTERM --exec \"bun run start-candle-viewer\"",
Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
# OHLCV collector: runs a loopback-only, keyless public broker and keeps one
2-
# Subscribe(OHLCV) stream alive for every configured pair. The broker forwards
3-
# captured candles to the separately deployed archive-forwarder.
1+
# Market-data collector: keeps one external Subscribe stream alive for every
2+
# configured feed intent against CEX_BROKER_URL.
43
#
54
# Build from the repository root:
6-
# docker build -f services/ohlcv-collector/Dockerfile -t cex-broker-ohlcv-collector .
5+
# docker build -f services/ohlcv-collector/Dockerfile -t cex-broker-market-data-collector .
76
#
8-
# Mount a JSON array of {"exchange","symbol","timeframe"} entries and set
9-
# CEX_BROKER_OHLCV_COLLECTOR_CONFIG to its container path. timeframe defaults to 1m.
7+
# Set CEX_BROKER_URL to the external broker target and
8+
# CEX_BROKER_MARKET_DATA_COLLECTOR_CONFIG to a JSON document containing
9+
# {"subscriptions":[...]} feed intents.
1010
FROM oven/bun:1.3
1111

1212
WORKDIR /app
@@ -18,11 +18,7 @@ RUN bun install --frozen-lockfile
1818
COPY src ./src
1919
COPY services/ohlcv-collector ./services/ohlcv-collector
2020

21-
# Every reconnect invokes the broker's OHLCV bootstrap. At 1m, 1000 bars provide
22-
# roughly 16 hours of gap coverage without adding a collector-side write/backfill path.
23-
ENV CEX_BROKER_OHLCV_ARCHIVE_BOOTSTRAP_LIMIT=1000
24-
2521
# The collector needs no elevated privileges; run as the base image's bun user.
2622
USER bun
2723

28-
CMD ["bun", "run", "services/ohlcv-collector/index.ts"]
24+
CMD ["bun", "run", "start-market-data-collector"]

services/ohlcv-collector/collector.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { SubscriptionType } from "../../src/helpers/constants";
33
import { log } from "../../src/helpers/logger";
44
import type { OtelMetrics } from "../../src/helpers/otel";
55
import { CEX_BROKER_PACKAGE_DEFINITION } from "../../src/proto-package-definition";
6-
import type { MarketDataSubscription, OhlcvSubscription } from "./config";
6+
import type { MarketDataSubscription } from "./config";
77

88
type SubscribeResponse = {
99
data: string;
@@ -20,16 +20,14 @@ type SubscribeClient = grpc.Client & {
2020

2121
type CollectorMetrics = Pick<OtelMetrics, "recordCounter">;
2222

23-
export type OhlcvCollectorOptions = {
23+
export type MarketDataCollectorOptions = {
2424
brokerUrl: string;
25-
subscriptions: Array<OhlcvSubscription | MarketDataSubscription>;
25+
subscriptions: MarketDataSubscription[];
2626
metrics?: CollectorMetrics;
2727
retry?: Partial<RetryPolicy>;
2828
};
2929

30-
type CollectorSubscription =
31-
| (OhlcvSubscription & { feed: "OHLCV"; bootstrapLimit?: number })
32-
| MarketDataSubscription;
30+
type CollectorSubscription = MarketDataSubscription;
3331

3432
export type CollectorFeedHealth = {
3533
state: "connecting" | "healthy" | "backoff" | "stopped";
@@ -113,20 +111,16 @@ function waitForDelay(delayMs: number, signal: AbortSignal): Promise<boolean> {
113111
});
114112
}
115113

116-
export class OhlcvCollector {
114+
export class MarketDataCollector {
117115
readonly #client: SubscribeClient;
118116
readonly #subscriptions: CollectorSubscription[];
119117
readonly #metrics?: CollectorMetrics;
120118
readonly #retry: RetryPolicy;
121119
readonly #health = new Map<string, CollectorFeedHealth>();
122120
#started = false;
123121

124-
constructor(options: OhlcvCollectorOptions) {
125-
this.#subscriptions = options.subscriptions.map((subscription) =>
126-
"feed" in subscription
127-
? subscription
128-
: { ...subscription, feed: "OHLCV" as const },
129-
);
122+
constructor(options: MarketDataCollectorOptions) {
123+
this.#subscriptions = options.subscriptions;
130124
this.#metrics = options.metrics;
131125
this.#retry = { ...DEFAULT_RETRY_POLICY, ...options.retry };
132126
this.#client = new grpcObject.cex_broker.cex_service(
@@ -161,7 +155,7 @@ export class OhlcvCollector {
161155

162156
async run(signal: AbortSignal): Promise<void> {
163157
if (this.#started) {
164-
throw new Error("OHLCV collector can only be started once");
158+
throw new Error("Market-data collector can only be started once");
165159
}
166160
this.#started = true;
167161
try {
@@ -370,5 +364,3 @@ export class OhlcvCollector {
370364
return Math.max(0, Math.round(baseDelay * jitter));
371365
}
372366
}
373-
374-
export const MarketDataCollector = OhlcvCollector;

services/ohlcv-collector/config.ts

Lines changed: 0 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { z } from "zod";
22

3-
export const OHLCV_COLLECTOR_CONFIG_ENV = "CEX_BROKER_OHLCV_COLLECTOR_CONFIG";
43
export const MARKET_DATA_COLLECTOR_CONFIG_ENV =
54
"CEX_BROKER_MARKET_DATA_COLLECTOR_CONFIG";
65

@@ -47,19 +46,10 @@ const marketSubscriptionSchema = z.discriminatedUnion("feed", [
4746

4847
const marketConfigSchema = z
4948
.object({
50-
captureBundleId: z.string().trim().optional(),
51-
environment: z.enum(["development", "production"]).default("production"),
5249
subscriptions: z.array(marketSubscriptionSchema).min(1),
5350
})
5451
.strict()
5552
.superRefine((config, context) => {
56-
if (config.environment === "production" && !config.captureBundleId) {
57-
context.addIssue({
58-
code: "custom",
59-
message: "captureBundleId is required in production",
60-
path: ["captureBundleId"],
61-
});
62-
}
6353
const seen = new Set<string>();
6454
for (const [index, subscription] of config.subscriptions.entries()) {
6555
const options =
@@ -133,74 +123,3 @@ export async function loadMarketDataCollectorConfig(
133123
throw error;
134124
}
135125
}
136-
137-
const subscriptionSchema = z
138-
.object({
139-
exchange: z
140-
.string()
141-
.trim()
142-
.min(1)
143-
.transform((value) => value.toLowerCase()),
144-
symbol: z.string().trim().min(1),
145-
timeframe: z.string().trim().min(1).default("1m"),
146-
})
147-
.strict();
148-
149-
const configSchema = z
150-
.array(subscriptionSchema)
151-
.min(1, "at least one OHLCV subscription is required")
152-
.superRefine((subscriptions, context) => {
153-
const seen = new Set<string>();
154-
for (const [index, subscription] of subscriptions.entries()) {
155-
const key = `${subscription.exchange}\u0000${subscription.symbol}\u0000${subscription.timeframe}`;
156-
if (seen.has(key)) {
157-
context.addIssue({
158-
code: "custom",
159-
message: "duplicate OHLCV subscription",
160-
path: [index],
161-
});
162-
}
163-
seen.add(key);
164-
}
165-
});
166-
167-
export type OhlcvSubscription = z.infer<typeof subscriptionSchema>;
168-
169-
export function parseOhlcvCollectorConfig(input: unknown): OhlcvSubscription[] {
170-
const result = configSchema.safeParse(input);
171-
if (!result.success) {
172-
throw new Error(
173-
`Invalid OHLCV collector config: ${formatZodIssues(result.error)}`,
174-
);
175-
}
176-
return result.data;
177-
}
178-
179-
export async function loadOhlcvCollectorConfig(
180-
configPath = process.env[OHLCV_COLLECTOR_CONFIG_ENV],
181-
): Promise<OhlcvSubscription[]> {
182-
const path = configPath?.trim();
183-
if (!path) {
184-
throw new Error(`${OHLCV_COLLECTOR_CONFIG_ENV} must point to a JSON file`);
185-
}
186-
187-
let contents: string;
188-
try {
189-
contents = await Bun.file(path).text();
190-
} catch (error) {
191-
throw new Error(`Failed to read OHLCV collector config at ${path}`, {
192-
cause: error,
193-
});
194-
}
195-
196-
let parsed: unknown;
197-
try {
198-
parsed = JSON.parse(contents);
199-
} catch (error) {
200-
throw new Error(`OHLCV collector config at ${path} is not valid JSON`, {
201-
cause: error,
202-
});
203-
}
204-
205-
return parseOhlcvCollectorConfig(parsed);
206-
}

0 commit comments

Comments
 (0)