From 5025de78b90687d52425fbf251b6a06c4d127528 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Tue, 5 May 2026 03:14:01 -0400 Subject: [PATCH] Support single-token Alpaca auth --- .beads/issues.jsonl | 1 + .env.example | 1 + README.md | 7 +++-- deployment/docker/.env.example | 1 + .../ingest-equities/src/adapters/alpaca.ts | 31 +++++++++++++------ services/ingest-equities/src/index.ts | 11 +++++++ .../ingest-options/src/adapters/alpaca.ts | 21 +++++++++---- services/ingest-options/src/index.ts | 10 ++++-- 8 files changed, 63 insertions(+), 20 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1176d76..a11730d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -4,6 +4,7 @@ {"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-h47","title":"Support single-token Alpaca auth","description":"Support single-token Alpaca authentication across ingest adapters using ALPACA_API_KEY with fallback to ALPACA_KEY_ID/ALPACA_SECRET_KEY, and document env usage.\n","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-05T07:12:22Z","created_by":"dirtydishes","updated_at":"2026-05-05T07:13:54Z","started_at":"2026-05-05T07:12:25Z","closed_at":"2026-05-05T07:13:54Z","close_reason":"Added ALPACA_API_KEY support with key-pair fallback","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-neu","title":"Add Alpha Vantage event calendar provider","description":"Add an Alpha Vantage earnings-calendar provider to services/refdata that fetches CSV, normalizes entries, writes the JSON cache consumed by compute, and documents the required env variables.\n","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-05T07:00:31Z","created_by":"dirtydishes","updated_at":"2026-05-05T07:02:30Z","started_at":"2026-05-05T07:00:37Z","closed_at":"2026-05-05T07:02:30Z","close_reason":"Added Alpha Vantage event-calendar provider","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-b6d","title":"Finish smart-money event-calendar enrichment","description":"Finish the smart-money event-calendar provider layer in services/refdata and connect days-to-event / expiry-after-event enrichment into compute using timestamp-available data only.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-04T21:35:26Z","created_by":"dirtydishes","updated_at":"2026-05-04T23:21:09Z","started_at":"2026-05-04T23:18:29Z","closed_at":"2026-05-04T23:21:09Z","close_reason":"Completed event-calendar provider and compute enrichment","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-e60","title":"Add smart-money replay evaluation harness","description":"Add replay-style live-vs-batch consistency tests plus evaluation utilities for parent-event precision/recall, calibration, abstention rate, and economic sanity checks.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-04T21:35:25Z","created_by":"dirtydishes","updated_at":"2026-05-05T06:08:08Z","started_at":"2026-05-05T06:07:22Z","closed_at":"2026-05-05T06:08:08Z","close_reason":"Completed smart-money replay consistency harness and evaluation utilities.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.env.example b/.env.example index d7d7f6c..90f4c9b 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ REDIS_URL=redis://127.0.0.1:6379 # Options ingest OPTIONS_INGEST_ADAPTER=synthetic +ALPACA_API_KEY= ALPACA_KEY_ID= ALPACA_SECRET_KEY= ALPACA_REST_URL=https://data.alpaca.markets diff --git a/README.md b/README.md index 986f562..02cdc62 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,9 @@ Synthetic profile intent: | Variable | Default | What it controls | | --- | --- | --- | -| `ALPACA_KEY_ID` | empty | Alpaca API key for options/equities adapters. Required when `*_INGEST_ADAPTER=alpaca`. | -| `ALPACA_SECRET_KEY` | empty | Alpaca API secret for options/equities adapters. Required when `*_INGEST_ADAPTER=alpaca`. | +| `ALPACA_API_KEY` | empty | Single-token Alpaca API auth for options/equities adapters. Use this when your account provides one API key value. | +| `ALPACA_KEY_ID` | empty | Alpaca key-pair auth key id (legacy/auth-pair mode). | +| `ALPACA_SECRET_KEY` | empty | Alpaca key-pair auth secret (legacy/auth-pair mode). | | `ALPACA_REST_URL` | `https://data.alpaca.markets` | Alpaca REST base URL for contract discovery/reference calls. | | `ALPACA_WS_BASE_URL` | `wss://stream.data.alpaca.markets/v1beta1` (options), `wss://stream.data.alpaca.markets` (equities) | Alpaca websocket base URL. | | `ALPACA_FEED` | `indicative` | Options feed tier for Alpaca options (`indicative` or `opra`). | @@ -161,6 +162,8 @@ Synthetic profile intent: | `ALPACA_MAX_QUOTES` | `200` | Upper bound on selected Alpaca options contracts/quotes per cycle. | | `ALPACA_EQUITIES_FEED` | `iex` | Alpaca equities feed (`iex` free tier, `sip` paid consolidated feed). | +For Alpaca adapters, configure either `ALPACA_API_KEY` or the `ALPACA_KEY_ID` + `ALPACA_SECRET_KEY` pair. + ### Databento replay adapter configuration | Variable | Default | What it controls | diff --git a/deployment/docker/.env.example b/deployment/docker/.env.example index ea6ba5c..0cced99 100644 --- a/deployment/docker/.env.example +++ b/deployment/docker/.env.example @@ -20,6 +20,7 @@ NEXT_PUBLIC_NBBO_MAX_AGE_MS=1000 # Options ingest OPTIONS_INGEST_ADAPTER=synthetic +ALPACA_API_KEY= ALPACA_KEY_ID= ALPACA_SECRET_KEY= ALPACA_REST_URL=https://data.alpaca.markets diff --git a/services/ingest-equities/src/adapters/alpaca.ts b/services/ingest-equities/src/adapters/alpaca.ts index 97b7205..2ff77c1 100644 --- a/services/ingest-equities/src/adapters/alpaca.ts +++ b/services/ingest-equities/src/adapters/alpaca.ts @@ -6,6 +6,7 @@ import WebSocket from "ws"; export type AlpacaEquitiesFeed = "iex" | "sip"; export type AlpacaEquitiesAdapterConfig = { + apiKey: string; keyId: string; secretKey: string; restUrl: string; @@ -63,10 +64,18 @@ const normalizeSymbols = (symbols: string[]): string[] => { return result; }; -const buildHeaders = (config: AlpacaEquitiesAdapterConfig): Record => ({ - "APCA-API-KEY-ID": config.keyId, - "APCA-API-SECRET-KEY": config.secretKey -}); +const buildHeaders = (config: AlpacaEquitiesAdapterConfig): Record => { + if (config.apiKey) { + return { + Authorization: `Bearer ${config.apiKey}` + }; + } + + return { + "APCA-API-KEY-ID": config.keyId, + "APCA-API-SECRET-KEY": config.secretKey + }; +}; const parseTimestamp = (value: string): number => { const parsed = Date.parse(value); @@ -184,8 +193,10 @@ export const createAlpacaEquitiesAdapter = ( return { name: "alpaca", start: async (handlers: EquityIngestHandlers) => { - if (!config.keyId || !config.secretKey) { - throw new Error("Alpaca equities adapter requires ALPACA_KEY_ID and ALPACA_SECRET_KEY."); + if (!config.apiKey && (!config.keyId || !config.secretKey)) { + throw new Error( + "Alpaca equities adapter requires ALPACA_API_KEY or ALPACA_KEY_ID and ALPACA_SECRET_KEY." + ); } const symbols = normalizeSymbols(config.symbols); @@ -195,7 +206,9 @@ export const createAlpacaEquitiesAdapter = ( const exchangeNameMap = await fetchExchangeMeta(config); const wsUrl = buildWsUrl(config.wsBaseUrl, config.feed); - const ws = new WebSocket(wsUrl); + const ws = new WebSocket(wsUrl, { + headers: buildHeaders(config) + }); let seq = 0; let stopped = false; @@ -205,8 +218,8 @@ export const createAlpacaEquitiesAdapter = ( ws.send( JSON.stringify({ action: "auth", - key: config.keyId, - secret: config.secretKey + key: config.apiKey || config.keyId, + secret: config.apiKey ? "" : config.secretKey }) ); }); diff --git a/services/ingest-equities/src/index.ts b/services/ingest-equities/src/index.ts index 588d855..9579ce0 100644 --- a/services/ingest-equities/src/index.ts +++ b/services/ingest-equities/src/index.ts @@ -41,6 +41,7 @@ const envSchema = z.object({ SYNTHETIC_EQUITIES_MODE: z.string().default(""), // Alpaca (equities) + ALPACA_API_KEY: z.string().default(""), ALPACA_KEY_ID: z.string().default(""), ALPACA_SECRET_KEY: z.string().default(""), ALPACA_REST_URL: z.string().default("https://data.alpaca.markets"), @@ -167,7 +168,17 @@ const selectAdapter = (name: string): EquityIngestAdapter => { } if (name === "alpaca") { + const hasApiKey = Boolean(env.ALPACA_API_KEY); + const hasKeyPair = Boolean(env.ALPACA_KEY_ID && env.ALPACA_SECRET_KEY); + if (!hasApiKey && !hasKeyPair) { + logger.warn("alpaca credentials missing; set ALPACA_API_KEY or ALPACA_KEY_ID and ALPACA_SECRET_KEY"); + throw new Error( + "ALPACA_API_KEY or ALPACA_KEY_ID and ALPACA_SECRET_KEY are required for the alpaca adapter." + ); + } + return createAlpacaEquitiesAdapter({ + apiKey: env.ALPACA_API_KEY, keyId: env.ALPACA_KEY_ID, secretKey: env.ALPACA_SECRET_KEY, restUrl: env.ALPACA_REST_URL, diff --git a/services/ingest-options/src/adapters/alpaca.ts b/services/ingest-options/src/adapters/alpaca.ts index 756f3f3..b137cab 100644 --- a/services/ingest-options/src/adapters/alpaca.ts +++ b/services/ingest-options/src/adapters/alpaca.ts @@ -6,6 +6,7 @@ import WebSocket from "ws"; type AlpacaFeed = "indicative" | "opra"; type AlpacaOptionsAdapterConfig = { + apiKey: string; keyId: string; secretKey: string; restUrl: string; @@ -148,10 +149,18 @@ const normalizeUnderlyings = (value: string[]): string[] => { return result; }; -const buildHeaders = (config: AlpacaOptionsAdapterConfig): Record => ({ - "APCA-API-KEY-ID": config.keyId, - "APCA-API-SECRET-KEY": config.secretKey -}); +const buildHeaders = (config: AlpacaOptionsAdapterConfig): Record => { + if (config.apiKey) { + return { + Authorization: `Bearer ${config.apiKey}` + }; + } + + return { + "APCA-API-KEY-ID": config.keyId, + "APCA-API-SECRET-KEY": config.secretKey + }; +}; const fetchJson = async ( url: URL, @@ -398,8 +407,8 @@ export const createAlpacaOptionsAdapter = ( return { name: "alpaca", start: async (handlers: OptionIngestHandlers) => { - if (!config.keyId || !config.secretKey) { - throw new Error("Alpaca adapter requires ALPACA_KEY_ID and ALPACA_SECRET_KEY."); + if (!config.apiKey && (!config.keyId || !config.secretKey)) { + throw new Error("Alpaca adapter requires ALPACA_API_KEY or ALPACA_KEY_ID and ALPACA_SECRET_KEY."); } const underlyings = normalizeUnderlyings(config.underlyings); diff --git a/services/ingest-options/src/index.ts b/services/ingest-options/src/index.ts index a5fe14c..7b3bb66 100644 --- a/services/ingest-options/src/index.ts +++ b/services/ingest-options/src/index.ts @@ -49,6 +49,7 @@ const envSchema = z.object({ CLICKHOUSE_URL: z.string().default("http://127.0.0.1:8123"), CLICKHOUSE_DATABASE: z.string().default("default"), OPTIONS_INGEST_ADAPTER: z.string().min(1).default("synthetic"), + ALPACA_API_KEY: z.string().default(""), ALPACA_KEY_ID: z.string().default(""), ALPACA_SECRET_KEY: z.string().default(""), ALPACA_REST_URL: z.string().default("https://data.alpaca.markets"), @@ -229,14 +230,17 @@ const selectAdapter = (name: string): OptionIngestAdapter => { } if (name === "alpaca") { - if (!env.ALPACA_KEY_ID || !env.ALPACA_SECRET_KEY) { - logger.warn("alpaca credentials missing; set ALPACA_KEY_ID and ALPACA_SECRET_KEY"); - throw new Error("ALPACA_KEY_ID and ALPACA_SECRET_KEY are required for the alpaca adapter."); + const hasApiKey = Boolean(env.ALPACA_API_KEY); + const hasKeyPair = Boolean(env.ALPACA_KEY_ID && env.ALPACA_SECRET_KEY); + if (!hasApiKey && !hasKeyPair) { + logger.warn("alpaca credentials missing; set ALPACA_API_KEY or ALPACA_KEY_ID and ALPACA_SECRET_KEY"); + throw new Error("ALPACA_API_KEY or ALPACA_KEY_ID and ALPACA_SECRET_KEY are required for the alpaca adapter."); } const underlyings = env.ALPACA_UNDERLYINGS.split(",").map((symbol) => symbol.trim()); return createAlpacaOptionsAdapter({ + apiKey: env.ALPACA_API_KEY, keyId: env.ALPACA_KEY_ID, secretKey: env.ALPACA_SECRET_KEY, restUrl: env.ALPACA_REST_URL,