Initial Commit
This commit is contained in:
87
packages/money/finance/currency-provider-kraken/README.md
Normal file
87
packages/money/finance/currency-provider-kraken/README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# @suaveplan/currency-provider-kraken - Universal Kraken public-ticker rate provider for @suaveplan/currency. Wraps Kraken's free no-API-key /0/public/Ticker endpoint into a CurrencyProvider with the slim core's shared httpRequest helper, typed CurrencyProviderError (HTTP_ERROR / PARSE_ERROR / UPSTREAM_ERROR / UNKNOWN_CURRENCY), and the 12-symbol BTC/ETH/SOL/USDT/DOT/ADA/LINK/AVAX/ATOM/LTC/DOGE/XRP coverage matched against Kraken's non-standard pair names.
|
||||
|
||||
Universal Kraken public-ticker rate provider for `@suaveplan/currency`.
|
||||
Wraps Kraken's free, no-API-key `/0/public/Ticker` endpoint into a
|
||||
`CurrencyProvider` so a `CurrencyConverter` can price the major crypto
|
||||
pairs against USD without callers ever seeing Kraken's non-standard pair
|
||||
names (`XXBTZUSD`, `XETHZUSD`, `XDGUSD`, …).
|
||||
|
||||
Concrete sibling of the slim `@suaveplan/currency-providers` core — uses
|
||||
the shared `httpRequest()` helper, raises the shared `CurrencyProviderError`
|
||||
codes (`HTTP_ERROR`, `PARSE_ERROR`, `UPSTREAM_ERROR`, `UNKNOWN_CURRENCY`),
|
||||
and accepts the shared `NoKeyConfig` (optional `baseUrl`, optional injectable
|
||||
`FetchLike`) plus an optional `extraSymbols` map for caller-supplied
|
||||
extensions.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [provider](./src/provider/provider.md) — `KrakenProvider` is the universal `CurrencyProvider` implementation for Kraken's free `/0/public/Ticker` endpoint.…
|
||||
- [provider](./src/provider/provider.md) — `KrakenProvider` is the universal `CurrencyProvider` implementation for Kraken's free `/0/public/Ticker` endpoint.…
|
||||
- [symbols](./src/symbols/symbols.md) — The Kraken public ticker addresses pairs by non-standard names — `XXBTZUSD` for BTC/USD, `XETHZUSD` for ETH/USD, `XDGUS…
|
||||
- [symbols](./src/symbols/symbols.md) — The Kraken public ticker addresses pairs by non-standard names — `XXBTZUSD` for BTC/USD, `XETHZUSD` for ETH/USD, `XDGUS…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/currency-provider-kraken @suaveplan/currency @suaveplan/currency-providers
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { CurrencyConverter } from "@suaveplan/currency";
|
||||
import { KrakenProvider } from "@suaveplan/currency-provider-kraken";
|
||||
|
||||
const provider = new KrakenProvider();
|
||||
const converter = new CurrencyConverter({ provider });
|
||||
|
||||
const btcPerUsd = await converter.getRate("USD", "BTC"); // e.g. 0.0000149
|
||||
const ethPerUsd = await converter.getRate("USD", "ETH"); // e.g. 0.0002857
|
||||
```
|
||||
|
||||
Or register through the slim core's registry:
|
||||
|
||||
```ts
|
||||
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
|
||||
import { KrakenProvider } from "@suaveplan/currency-provider-kraken";
|
||||
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
registry.register(new KrakenProvider());
|
||||
|
||||
const provider = registry.get("kraken");
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- No API key — Kraken's public endpoint is free (per-IP rate-limited)
|
||||
- 12 high-volume symbols out of the box: BTC, ETH, SOL, USDT, DOT, ADA,
|
||||
LINK, AVAX, ATOM, LTC, DOGE, XRP
|
||||
- Optional `extraSymbols` for newly-listed pairs without forking
|
||||
- Reciprocal-of-USD rate model — `rates[X] = 1 / lastTradeClosedPriceUsd`
|
||||
- Single-round-trip batch fetch (Kraken supports a comma-separated `pair=`
|
||||
query)
|
||||
- Uses the slim core's `httpRequest()` so HTTP errors, timeouts, and caller
|
||||
`AbortSignal` propagation are uniform across providers
|
||||
- Maps wire-level failure modes to typed `CurrencyProviderError`:
|
||||
`HTTP_ERROR`, `UPSTREAM_ERROR`, `PARSE_ERROR`, `UNKNOWN_CURRENCY`
|
||||
- Universal — no `node:`/`bun:`/DOM imports; runs unchanged under
|
||||
Node ≥ 18, Bun, modern browsers (subject to Kraken's CORS policy), Web
|
||||
Workers
|
||||
|
||||
## Modules
|
||||
|
||||
- [`src/provider/provider.md`](./src/provider/provider.md) —
|
||||
`KrakenProvider` class, `KrakenProviderConfig`, `DEFAULT_KRAKEN_BASE_URL`
|
||||
- [`src/symbols/symbols.md`](./src/symbols/symbols.md) —
|
||||
`KRAKEN_SYMBOL_MAP`, `KRAKEN_PAIR_TO_SYMBOL`, `mergeSymbolMap`
|
||||
|
||||
## Status
|
||||
|
||||
`0.1.0` (pre-graduation). native sibling of the slim
|
||||
`@suaveplan/currency-providers` core landed by
|
||||
the design document. See
|
||||
the design proposal for
|
||||
the migration parity table and source mapping.
|
||||
|
||||
74
packages/money/finance/currency-provider-kraken/package.json
Normal file
74
packages/money/finance/currency-provider-kraken/package.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "@suaveplan/currency-provider-kraken",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal Kraken public-ticker rate provider for @suaveplan/currency. Wraps Kraken's free no-API-key /0/public/Ticker endpoint into a CurrencyProvider with the slim core's shared httpRequest helper, typed CurrencyProviderError (HTTP_ERROR / PARSE_ERROR / UPSTREAM_ERROR / UNKNOWN_CURRENCY), and the 12-symbol BTC/ETH/SOL/USDT/DOT/ADA/LINK/AVAX/ATOM/LTC/DOGE/XRP coverage matched against Kraken's non-standard pair names.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./symbols/symbols": {
|
||||
"import": {
|
||||
"types": "./dist/symbols/symbols.d.ts",
|
||||
"default": "./dist/symbols/symbols.js"
|
||||
}
|
||||
},
|
||||
"./provider/provider": {
|
||||
"import": {
|
||||
"types": "./dist/provider/provider.d.ts",
|
||||
"default": "./dist/provider/provider.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/currency": "workspace:^",
|
||||
"@suaveplan/currency-providers": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
11
packages/money/finance/currency-provider-kraken/src/index.ts
Normal file
11
packages/money/finance/currency-provider-kraken/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
DEFAULT_KRAKEN_BASE_URL,
|
||||
KrakenProvider,
|
||||
type KrakenProviderConfig,
|
||||
} from "./provider/provider.js";
|
||||
export {
|
||||
KRAKEN_PAIR_TO_SYMBOL,
|
||||
KRAKEN_SYMBOL_MAP,
|
||||
type MergedSymbolMaps,
|
||||
mergeSymbolMap,
|
||||
} from "./symbols/symbols.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { KrakenProviderConfig } from "./provider.js";
|
||||
export {
|
||||
DEFAULT_KRAKEN_BASE_URL,
|
||||
KrakenProvider,
|
||||
} from "./provider.js";
|
||||
@@ -0,0 +1,130 @@
|
||||
# `provider` — KrakenProvider class
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`KrakenProvider` is the universal `CurrencyProvider` implementation
|
||||
for Kraken's free `/0/public/Ticker` endpoint. It fetches real-time
|
||||
last-trade-closed prices for a fixed set of high-volume crypto pairs and
|
||||
returns them as reciprocal-of-USD rates, so a downstream `CurrencyConverter`
|
||||
can translate `USD → BTC` (and friends) without callers ever seeing
|
||||
Kraken's non-standard pair names.
|
||||
|
||||
The class is the canonical sibling-package surface defined by the slim
|
||||
`@suaveplan/currency-providers` core: it wraps a single upstream endpoint,
|
||||
maps every documented failure mode to a typed `CurrencyProviderError`, and
|
||||
exposes nothing that consumers can't already obtain from the shared core
|
||||
helpers.
|
||||
|
||||
## Features
|
||||
|
||||
- No API key required — Kraken's public endpoint is free and rate-limited
|
||||
per IP
|
||||
- 12 high-volume symbols out of the box (BTC, ETH, SOL, USDT, DOT, ADA,
|
||||
LINK, AVAX, ATOM, LTC, DOGE, XRP); extensible via constructor
|
||||
`extraSymbols`
|
||||
- Reciprocal-of-USD rate model — `1 USD = (1 / krakenLastTradePrice)` of the
|
||||
target crypto, matching the shape every `CurrencyConverter` consumer expects
|
||||
- Uses the slim core's `httpRequest()` helper so HTTP failures, timeouts,
|
||||
and caller abort-signal propagation are handled uniformly
|
||||
- Maps the four wire-level failure modes to typed `CurrencyProviderError`:
|
||||
`HTTP_ERROR` (non-2xx), `UPSTREAM_ERROR` (Kraken `error[]` array),
|
||||
`PARSE_ERROR` (malformed JSON / shape mismatch), `UNKNOWN_CURRENCY` (all
|
||||
requested targets unmapped)
|
||||
- Universal — no `node:`/`bun:`/DOM imports; runs unchanged under
|
||||
Node ≥ 18, Bun, modern browsers (subject to Kraken's CORS), Web Workers
|
||||
- Optional injectable `FetchLike` (defaults to `globalThis.fetch`) for
|
||||
recording tests, proxying, or staging URLs
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { KrakenProvider } from "@suaveplan/currency-provider-kraken/provider/provider";
|
||||
|
||||
const provider = new KrakenProvider();
|
||||
const result = await provider.fetchRates({ base: "USD" });
|
||||
|
||||
console.log(result.rates.BTC); // e.g. 0.0000149 (≈ 1 / 67000)
|
||||
console.log(result.rates.ETH); // e.g. 0.0002857 (≈ 1 / 3500)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { CurrencyConverter } from "@suaveplan/currency";
|
||||
import { CurrencyProviderRegistry } from "@suaveplan/currency-providers";
|
||||
import { KrakenProvider } from "@suaveplan/currency-provider-kraken/provider/provider";
|
||||
|
||||
// Use the slim core's registry — wire Kraken plus any other sibling
|
||||
// providers your app supports. The Kraken instance extends coverage with
|
||||
// a newly-listed pair via `extraSymbols`, and reuses a custom fetch
|
||||
// closure that adds a proxy header.
|
||||
const registry = new CurrencyProviderRegistry();
|
||||
registry.register(
|
||||
new KrakenProvider({
|
||||
baseUrl: "https://kraken.proxy.example.com/0/public",
|
||||
fetch: async (url, init) =>
|
||||
fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers ?? {}),
|
||||
"x-proxy-token": process.env.PROXY_TOKEN ?? "",
|
||||
},
|
||||
}),
|
||||
extraSymbols: { MATIC: "MATICUSD" },
|
||||
}),
|
||||
);
|
||||
|
||||
const converter = new CurrencyConverter({
|
||||
provider: registry.get("kraken"),
|
||||
});
|
||||
const btcRate = await converter.getRate("USD", "BTC");
|
||||
const maticRate = await converter.getRate("USD", "MATIC");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Kind | Description |
|
||||
|---|---|---|
|
||||
| `DEFAULT_KRAKEN_BASE_URL` | `string` | `"https://api.kraken.com/0/public"` — overridable via `config.baseUrl`. |
|
||||
| `KrakenProviderConfig` | `interface` extends `NoKeyConfig` | Optional `baseUrl`, optional `fetch` (`FetchLike`), optional `extraSymbols` (`Record<string, string>`). |
|
||||
| `KrakenProvider` | `class implements CurrencyProvider` | Fixed `name = "kraken"`, `supportsCrypto = true`, `supportsFiat = false`. Constructor takes an optional `KrakenProviderConfig`. |
|
||||
| `KrakenProvider.fetchRates(options?)` | `(RateFetchOptions?) => Promise<RateFetchResult>` | Issues one `GET /Ticker?pair=<csv>` via the injected fetch. Returns `{ base, rates, timestamp }`. Throws `CurrencyProviderError` on every documented failure path. |
|
||||
|
||||
### Error codes raised
|
||||
|
||||
| Code | When |
|
||||
|---|---|
|
||||
| `CURRENCY_PROVIDER.HTTP_ERROR` | Non-2xx response (raised by the shared `httpRequest`). Carries `status`. |
|
||||
| `CURRENCY_PROVIDER.UPSTREAM_ERROR` | Kraken response carries a non-empty `error[]` array. Carries `upstreamMessage`. |
|
||||
| `CURRENCY_PROVIDER.PARSE_ERROR` | Body fails JSON-parse, OR the parsed body does not match the `{ error: string[], result: object }` shape. Carries `cause` for the JSON-parse case. |
|
||||
| `CURRENCY_PROVIDER.UNKNOWN_CURRENCY` | Caller supplied `options.targets` but none of the requested symbols resolved to a mapped Kraken pair. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The pair list sent in the `pair=` query is the value list of the merged
|
||||
symbol map — Kraken supports a comma-separated batch so the whole supported
|
||||
set is fetched in a single round trip. This is more efficient than one
|
||||
request per symbol AND matches what Kraken's documentation recommends.
|
||||
- The rate model is `1 / Number.parseFloat(ticker.c[0])` where `c[0]` is
|
||||
Kraken's "last trade closed" price. Entries whose price does not parse
|
||||
to a positive finite number are skipped (not raised) — Kraken occasionally
|
||||
returns `"0.00"` for freshly-listed pairs. If the entire mapped set drops,
|
||||
`options.targets` filtering will subsequently raise `UNKNOWN_CURRENCY`
|
||||
on the caller's behalf.
|
||||
- `options.signal` is forwarded into `httpRequest()` so the caller's
|
||||
`AbortController` composes with any future timeout the helper applies.
|
||||
- The provider does **not** retry, cache, or rate-limit. Those concerns
|
||||
belong in wrapper providers (the slim core's `## Out of scope` defers
|
||||
them) — keeping this class focused on Kraken's wire protocol keeps the
|
||||
failure surface auditable.
|
||||
- All HTTP error mapping flows through the shared `httpRequest()` helper
|
||||
exported from `@suaveplan/currency-providers/http/http`, so the response
|
||||
is never read on a non-2xx path — Kraken's body on a 5xx is typically
|
||||
HTML and would otherwise raise a confusing `PARSE_ERROR`.
|
||||
- Tier purity: no `node:` imports, no DOM imports, no `setTimeout`. The
|
||||
helper uses `AbortSignal.timeout` for timeouts (universal in Node 17.3+,
|
||||
Bun, modern browsers).
|
||||
@@ -0,0 +1,420 @@
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "@suaveplan/currency-providers/error-codes/error-codes";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
import { DEFAULT_KRAKEN_BASE_URL, KrakenProvider } from "./provider.js";
|
||||
|
||||
interface FetchCall {
|
||||
url: string;
|
||||
init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
interface Recorder {
|
||||
fetch: FetchLike;
|
||||
calls: FetchCall[];
|
||||
}
|
||||
|
||||
function recordingFetch(
|
||||
handler: (call: FetchCall) => Promise<Response> | Response
|
||||
): Recorder {
|
||||
const calls: FetchCall[] = [];
|
||||
const fetchImpl: FetchLike = async (
|
||||
url: string | URL | Request,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
const call: FetchCall = { url: String(url), init };
|
||||
calls.push(call);
|
||||
return await handler(call);
|
||||
};
|
||||
return { fetch: fetchImpl, calls };
|
||||
}
|
||||
|
||||
function ok(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const SAMPLE_BODY = {
|
||||
error: [],
|
||||
result: {
|
||||
XXBTZUSD: { c: ["67000.00", "0.1"] },
|
||||
XETHZUSD: { c: ["3500.00", "1.0"] },
|
||||
SOLUSD: { c: ["150.00", "5.0"] },
|
||||
},
|
||||
};
|
||||
|
||||
describe("KrakenProvider — identity & defaults", () => {
|
||||
it("exposes the canonical kraken identity", { timeout: 5000 }, () => {
|
||||
const provider = new KrakenProvider();
|
||||
expect(provider.name).toBe("kraken");
|
||||
expect(provider.supportsCrypto).toBe(true);
|
||||
expect(provider.supportsFiat).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to globalThis.fetch and DEFAULT_KRAKEN_BASE_URL", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Replace `globalThis.fetch` with a recording closure scoped to this test.
|
||||
const original = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = (async (
|
||||
url: string | URL | Request,
|
||||
_init?: RequestInit
|
||||
) => {
|
||||
calls.push(String(url));
|
||||
return ok(SAMPLE_BODY);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const provider = new KrakenProvider();
|
||||
const result = await provider.fetchRates();
|
||||
expect(result.base).toBe("USD");
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]?.startsWith(DEFAULT_KRAKEN_BASE_URL)).toBe(true);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("honours custom baseUrl and fetch", { timeout: 5000 }, async () => {
|
||||
const { fetch, calls } = recordingFetch(() => ok(SAMPLE_BODY));
|
||||
const provider = new KrakenProvider({
|
||||
fetch,
|
||||
baseUrl: "https://custom.kraken.example",
|
||||
});
|
||||
await provider.fetchRates();
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0]?.url.startsWith("https://custom.kraken.example")).toBe(
|
||||
true
|
||||
);
|
||||
expect(calls[0]?.url).toContain("/Ticker?pair=");
|
||||
});
|
||||
|
||||
it("defaults options.base to USD", { timeout: 5000 }, async () => {
|
||||
const { fetch } = recordingFetch(() => ok(SAMPLE_BODY));
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
const result = await provider.fetchRates();
|
||||
expect(result.base).toBe("USD");
|
||||
});
|
||||
|
||||
it("forwards options.base verbatim", { timeout: 5000 }, async () => {
|
||||
const { fetch } = recordingFetch(() => ok(SAMPLE_BODY));
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
const result = await provider.fetchRates({ base: "USDT" });
|
||||
expect(result.base).toBe("USDT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("KrakenProvider — rate mapping", () => {
|
||||
it("maps positive prices to reciprocal-of-USD rates", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() => ok(SAMPLE_BODY));
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
const result = await provider.fetchRates();
|
||||
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 10);
|
||||
expect(result.rates.ETH).toBeCloseTo(1 / 3500, 10);
|
||||
expect(result.rates.SOL).toBeCloseTo(1 / 150, 10);
|
||||
expect(typeof result.timestamp).toBe("number");
|
||||
});
|
||||
|
||||
it("skips zero or non-finite prices without raising", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
ok({
|
||||
error: [],
|
||||
result: {
|
||||
XXBTZUSD: { c: ["0.00", "0"] },
|
||||
XETHZUSD: { c: ["3500.00", "1.0"] },
|
||||
SOLUSD: { c: ["not-a-number", "5.0"] },
|
||||
},
|
||||
})
|
||||
);
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
const result = await provider.fetchRates();
|
||||
expect(result.rates.ETH).toBeCloseTo(1 / 3500, 10);
|
||||
expect(result.rates.BTC).toBeUndefined();
|
||||
expect(result.rates.SOL).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips ticker entries missing c[0]", { timeout: 5000 }, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
ok({
|
||||
error: [],
|
||||
result: {
|
||||
XXBTZUSD: { c: [] as unknown as [string, string] },
|
||||
XETHZUSD: { c: ["3500.00", "1.0"] },
|
||||
},
|
||||
})
|
||||
);
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
const result = await provider.fetchRates();
|
||||
expect(result.rates.ETH).toBeCloseTo(1 / 3500, 10);
|
||||
expect(result.rates.BTC).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips unknown Kraken pairs", { timeout: 5000 }, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
ok({
|
||||
error: [],
|
||||
result: {
|
||||
XXBTZUSD: { c: ["67000.00", "0.1"] },
|
||||
UNKNOWNPAIR: { c: ["1.00", "1.0"] },
|
||||
},
|
||||
})
|
||||
);
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
const result = await provider.fetchRates();
|
||||
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 10);
|
||||
expect(Object.keys(result.rates)).toEqual(["BTC"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KrakenProvider — targets filtering", () => {
|
||||
it("filters by options.targets", { timeout: 5000 }, async () => {
|
||||
const { fetch } = recordingFetch(() => ok(SAMPLE_BODY));
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
const result = await provider.fetchRates({ targets: ["BTC"] });
|
||||
expect(Object.keys(result.rates)).toEqual(["BTC"]);
|
||||
expect(result.rates.BTC).toBeCloseTo(1 / 67000, 10);
|
||||
});
|
||||
|
||||
it("returns mapped subset when some targets are unmapped", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() => ok(SAMPLE_BODY));
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
const result = await provider.fetchRates({
|
||||
targets: ["BTC", "DOGECOIN-FAKE"],
|
||||
});
|
||||
expect(Object.keys(result.rates)).toEqual(["BTC"]);
|
||||
});
|
||||
|
||||
it("raises UNKNOWN_CURRENCY when every requested target is unmapped", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() => ok(SAMPLE_BODY));
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
try {
|
||||
await provider.fetchRates({ targets: ["FAKE1", "FAKE2"] });
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY);
|
||||
expect(err.providerName).toBe("kraken");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an empty targets list as a no-op (no UNKNOWN_CURRENCY)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() => ok(SAMPLE_BODY));
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
const result = await provider.fetchRates({ targets: [] });
|
||||
expect(Object.keys(result.rates)).toEqual([]);
|
||||
});
|
||||
|
||||
it("extends the symbol map via extraSymbols", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
ok({
|
||||
error: [],
|
||||
result: {
|
||||
MATICUSD: { c: ["0.50", "10.0"] },
|
||||
},
|
||||
})
|
||||
);
|
||||
const provider = new KrakenProvider({
|
||||
fetch,
|
||||
extraSymbols: { MATIC: "MATICUSD" },
|
||||
});
|
||||
const result = await provider.fetchRates({ targets: ["MATIC"] });
|
||||
expect(result.rates.MATIC).toBeCloseTo(1 / 0.5, 10);
|
||||
// MATICUSD made it into the pair= query.
|
||||
expect(calls[0]?.url).toContain("MATICUSD");
|
||||
});
|
||||
});
|
||||
|
||||
describe("KrakenProvider — failure modes", () => {
|
||||
it("raises HTTP_ERROR on non-2xx response", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(
|
||||
() => new Response("", { status: 503, statusText: "Down" })
|
||||
);
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
try {
|
||||
await provider.fetchRates();
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.HTTP_ERROR);
|
||||
expect(err.status).toBe(503);
|
||||
expect(err.providerName).toBe("kraken");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps a non-empty error array to UPSTREAM_ERROR", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
ok({
|
||||
error: ["EGeneral:Too many requests"],
|
||||
result: {},
|
||||
})
|
||||
);
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
try {
|
||||
await provider.fetchRates();
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR);
|
||||
expect(err.upstreamMessage).toBe("EGeneral:Too many requests");
|
||||
expect(err.message).toContain("Too many requests");
|
||||
expect(err.providerName).toBe("kraken");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a placeholder when error array is non-empty but the first entry is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Synthesise an array with an explicit `undefined`-hole to exercise
|
||||
// the `?? "unknown upstream error"` fallback. JSON.stringify drops
|
||||
// holes to `null`, then JSON.parse rehydrates them as `null`, which
|
||||
// is `!== undefined` — so we use a fetch that returns the parsed
|
||||
// object directly by wrapping the body in a Response whose .json()
|
||||
// resolves to the array we want.
|
||||
const fetchImpl: FetchLike = async () =>
|
||||
new Response('{"error":[null],"result":{}}', {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const provider = new KrakenProvider({ fetch: fetchImpl });
|
||||
try {
|
||||
await provider.fetchRates();
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR);
|
||||
// The first element was JSON `null`, so the fallback "unknown
|
||||
// upstream error" string is used.
|
||||
expect(err.upstreamMessage).toBe("unknown upstream error");
|
||||
}
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR on malformed JSON", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(
|
||||
() =>
|
||||
new Response("not-json", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
try {
|
||||
await provider.fetchRates();
|
||||
expect.fail("fetchRates should have rejected");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CurrencyProviderError);
|
||||
const err = error as CurrencyProviderError;
|
||||
expect(err.code).toBe(CURRENCY_PROVIDER_CODES.PARSE_ERROR);
|
||||
expect(err.providerName).toBe("kraken");
|
||||
expect(err.cause).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when the body is JSON null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() => ok(null));
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
await expect(provider.fetchRates()).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when error is not an array", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
ok({ error: "boom", result: {} })
|
||||
);
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
await expect(provider.fetchRates()).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when result is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() => ok({ error: [] }));
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
await expect(provider.fetchRates()).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("raises PARSE_ERROR when result is null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { fetch } = recordingFetch(() => ok({ error: [], result: null }));
|
||||
const provider = new KrakenProvider({ fetch });
|
||||
await expect(provider.fetchRates()).rejects.toMatchObject({
|
||||
code: CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("KrakenProvider — abort propagation", () => {
|
||||
it("forwards options.signal to the underlying fetch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const seenSignals: (AbortSignal | undefined)[] = [];
|
||||
const fetchImpl: FetchLike = async (
|
||||
_url: string | URL | Request,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
seenSignals.push(init?.signal ?? undefined);
|
||||
return ok(SAMPLE_BODY);
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const provider = new KrakenProvider({ fetch: fetchImpl });
|
||||
await provider.fetchRates({ signal: controller.signal });
|
||||
expect(seenSignals.length).toBe(1);
|
||||
expect(seenSignals[0]).toBeDefined();
|
||||
});
|
||||
|
||||
it("propagates a caller-aborted signal", { timeout: 5000 }, async () => {
|
||||
const fetchImpl: FetchLike = async (
|
||||
_url: string | URL | Request,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(
|
||||
init?.signal?.reason ??
|
||||
new DOMException("aborted", "AbortError")
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const provider = new KrakenProvider({ fetch: fetchImpl });
|
||||
const promise = provider.fetchRates({ signal: controller.signal });
|
||||
controller.abort(new Error("caller cancelled"));
|
||||
await expect(promise).rejects.toThrow(/caller cancelled/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import type {
|
||||
CurrencyProvider,
|
||||
RateFetchOptions,
|
||||
RateFetchResult,
|
||||
} from "@suaveplan/currency";
|
||||
import {
|
||||
CURRENCY_PROVIDER_CODES,
|
||||
CurrencyProviderError,
|
||||
} from "@suaveplan/currency-providers/error-codes/error-codes";
|
||||
import { httpRequest } from "@suaveplan/currency-providers/http/http";
|
||||
import type { NoKeyConfig } from "@suaveplan/currency-providers/types/configs";
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
import { mergeSymbolMap } from "../symbols/symbols.js";
|
||||
|
||||
/**
|
||||
* Public Kraken ticker base URL. Override via
|
||||
* {@link KrakenProviderConfig.baseUrl} when wiring to a staging mirror or
|
||||
* recording fixture.
|
||||
*/
|
||||
export const DEFAULT_KRAKEN_BASE_URL = "https://api.kraken.com/0/public";
|
||||
|
||||
/** Stable provider identifier carried on every raised error. */
|
||||
const PROVIDER_NAME = "kraken";
|
||||
|
||||
/**
|
||||
* Configuration accepted by {@link KrakenProvider}. Extends the slim core's
|
||||
* {@link NoKeyConfig} (optional `baseUrl`, optional injectable `FetchLike`)
|
||||
* with an optional `extraSymbols` record so callers can extend symbol
|
||||
* coverage at construction time without forking the package.
|
||||
*/
|
||||
export interface KrakenProviderConfig extends NoKeyConfig {
|
||||
/**
|
||||
* Additional Suaveplan-symbol → Kraken-pair entries merged on top of
|
||||
* the canonical `KRAKEN_SYMBOL_MAP`. Useful for newly-listed pairs or
|
||||
* private-label overrides.
|
||||
*/
|
||||
readonly extraSymbols?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/** Shape of a single Kraken ticker entry — only `c` is used downstream. */
|
||||
interface KrakenTicker {
|
||||
readonly c: readonly [string, string];
|
||||
}
|
||||
|
||||
/** Shape of the Kraken `/0/public/Ticker` JSON response. */
|
||||
interface KrakenResponse {
|
||||
readonly error: readonly string[];
|
||||
readonly result: Readonly<Record<string, KrakenTicker>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kraken public-ticker rate provider.
|
||||
*
|
||||
* Wraps `GET /0/public/Ticker?pair=<csv>` — Kraken's free, no-API-key
|
||||
* real-time price endpoint — into the `CurrencyProvider` contract. Returns
|
||||
* reciprocal-of-USD rates (`1 / lastTradeClosedPrice`) keyed by the
|
||||
* Suaveplan-standard symbol. Maps Kraken's wire-level failure modes to the
|
||||
* shared `CurrencyProviderError` codes:
|
||||
*
|
||||
* - non-2xx response → `HTTP_ERROR` (raised by the shared `httpRequest`)
|
||||
* - non-empty `error[]` array → `UPSTREAM_ERROR` (with `upstreamMessage`)
|
||||
* - unparsable / shape-violating body → `PARSE_ERROR`
|
||||
* - all requested targets unmapped → `UNKNOWN_CURRENCY`
|
||||
*/
|
||||
export class KrakenProvider implements CurrencyProvider {
|
||||
public readonly name = PROVIDER_NAME;
|
||||
public readonly supportsCrypto = true;
|
||||
public readonly supportsFiat = false;
|
||||
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl: FetchLike;
|
||||
private readonly forwardMap: Readonly<Record<string, string>>;
|
||||
private readonly reverseMap: Readonly<Record<string, string>>;
|
||||
|
||||
public constructor(config: KrakenProviderConfig = {}) {
|
||||
this.baseUrl = config.baseUrl ?? DEFAULT_KRAKEN_BASE_URL;
|
||||
this.fetchImpl = config.fetch ?? globalThis.fetch;
|
||||
const merged = mergeSymbolMap(config.extraSymbols);
|
||||
this.forwardMap = merged.forward;
|
||||
this.reverseMap = merged.reverse;
|
||||
}
|
||||
|
||||
public async fetchRates(
|
||||
options?: RateFetchOptions
|
||||
): Promise<RateFetchResult> {
|
||||
const base = options?.base ?? "USD";
|
||||
const pairs = Object.values(this.forwardMap).join(",");
|
||||
const url = `${this.baseUrl}/Ticker?pair=${pairs}`;
|
||||
|
||||
const response = await httpRequest({
|
||||
url,
|
||||
fetch: this.fetchImpl,
|
||||
providerName: PROVIDER_NAME,
|
||||
...(options?.signal !== undefined
|
||||
? { signal: options.signal }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const data = await parseKrakenBody(response);
|
||||
assertNoUpstreamError(data);
|
||||
|
||||
const rates = buildRates(data.result, this.reverseMap);
|
||||
const filtered = filterByTargets(rates, options?.targets);
|
||||
|
||||
return { base, rates: filtered, timestamp: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
async function parseKrakenBody(response: Response): Promise<KrakenResponse> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await response.json();
|
||||
} catch (cause) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
`${PROVIDER_NAME} response was not valid JSON`,
|
||||
{ providerName: PROVIDER_NAME, cause }
|
||||
);
|
||||
}
|
||||
if (
|
||||
raw === null ||
|
||||
typeof raw !== "object" ||
|
||||
!Array.isArray((raw as { error?: unknown }).error) ||
|
||||
typeof (raw as { result?: unknown }).result !== "object" ||
|
||||
(raw as { result?: unknown }).result === null
|
||||
) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.PARSE_ERROR,
|
||||
`${PROVIDER_NAME} response did not match the expected { error, result } shape`,
|
||||
{ providerName: PROVIDER_NAME }
|
||||
);
|
||||
}
|
||||
return raw as KrakenResponse;
|
||||
}
|
||||
|
||||
function assertNoUpstreamError(data: KrakenResponse): void {
|
||||
if (data.error.length === 0) return;
|
||||
const first = data.error[0] ?? "unknown upstream error";
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR,
|
||||
`${PROVIDER_NAME} upstream error: ${first}`,
|
||||
{ providerName: PROVIDER_NAME, upstreamMessage: first }
|
||||
);
|
||||
}
|
||||
|
||||
function buildRates(
|
||||
result: Readonly<Record<string, KrakenTicker>>,
|
||||
reverseMap: Readonly<Record<string, string>>
|
||||
): Record<string, number> {
|
||||
const rates: Record<string, number> = {};
|
||||
for (const [pair, ticker] of Object.entries(result)) {
|
||||
const symbol = reverseMap[pair];
|
||||
if (symbol === undefined) continue;
|
||||
const raw = ticker.c?.[0];
|
||||
if (typeof raw !== "string") continue;
|
||||
const price = Number.parseFloat(raw);
|
||||
if (!Number.isFinite(price) || price <= 0) continue;
|
||||
rates[symbol] = 1 / price;
|
||||
}
|
||||
return rates;
|
||||
}
|
||||
|
||||
function filterByTargets(
|
||||
rates: Record<string, number>,
|
||||
targets: readonly string[] | undefined
|
||||
): Record<string, number> {
|
||||
if (targets === undefined) return rates;
|
||||
const filtered: Record<string, number> = {};
|
||||
for (const t of targets) {
|
||||
const value = rates[t];
|
||||
if (value !== undefined) filtered[t] = value;
|
||||
}
|
||||
if (targets.length > 0 && Object.keys(filtered).length === 0) {
|
||||
throw new CurrencyProviderError(
|
||||
CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY,
|
||||
`${PROVIDER_NAME} could not price any of the requested targets: ${targets.join(", ")}`,
|
||||
{ providerName: PROVIDER_NAME }
|
||||
);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { MergedSymbolMaps } from "./symbols.js";
|
||||
export {
|
||||
KRAKEN_PAIR_TO_SYMBOL,
|
||||
KRAKEN_SYMBOL_MAP,
|
||||
mergeSymbolMap,
|
||||
} from "./symbols.js";
|
||||
@@ -0,0 +1,90 @@
|
||||
# `symbols` — Kraken symbol-mapping table
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The Kraken public ticker addresses pairs by non-standard names — `XXBTZUSD`
|
||||
for BTC/USD, `XETHZUSD` for ETH/USD, `XDGUSD` for DOGE/USD — using the
|
||||
asset-prefix convention (`X` for native crypto assets, `Z` for fiat) inherited
|
||||
from its early-2014 instrument schema. Every consumer of the `KrakenProvider`
|
||||
wants to address rates by the Suaveplan-standard symbol (`BTC`, `ETH`,
|
||||
`DOGE`), so the provider needs a stable bidirectional lookup table that
|
||||
normalises both directions in O(1).
|
||||
|
||||
This module owns that table. It exports the frozen forward map
|
||||
(`KRAKEN_SYMBOL_MAP`), the derived frozen reverse map
|
||||
(`KRAKEN_PAIR_TO_SYMBOL`), and a `mergeSymbolMap` helper for callers who
|
||||
need to extend coverage without forking the package.
|
||||
|
||||
## Features
|
||||
|
||||
- 12 canonical Suaveplan symbols mapped to their Kraken pair names: BTC,
|
||||
ETH, SOL, USDT, DOT, ADA, LINK, AVAX, ATOM, LTC, DOGE, XRP
|
||||
- Forward + reverse maps both `Object.freeze`-d for identity stability and
|
||||
cheap memoised lookups in the hot path
|
||||
- `mergeSymbolMap(extras)` for caller-supplied extensions — returns a fresh
|
||||
forward + reverse pair without mutating the canonical maps
|
||||
- Zero runtime dependencies, universal
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
KRAKEN_PAIR_TO_SYMBOL,
|
||||
KRAKEN_SYMBOL_MAP,
|
||||
} from "@suaveplan/currency-provider-kraken/symbols/symbols";
|
||||
|
||||
console.log(KRAKEN_SYMBOL_MAP.BTC); // "XXBTZUSD"
|
||||
console.log(KRAKEN_PAIR_TO_SYMBOL.XXBTZUSD); // "BTC"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { mergeSymbolMap } from "@suaveplan/currency-provider-kraken/symbols/symbols";
|
||||
|
||||
// Extend with a pair Kraken added after this release shipped.
|
||||
const { forward, reverse } = mergeSymbolMap({
|
||||
MATIC: "MATICUSD",
|
||||
APT: "APTUSD",
|
||||
});
|
||||
|
||||
console.log(forward.MATIC); // "MATICUSD"
|
||||
console.log(reverse.MATICUSD); // "MATIC"
|
||||
|
||||
// Original maps are unchanged — `mergeSymbolMap` never mutates them.
|
||||
console.log("MATIC" in KRAKEN_SYMBOL_MAP); // false
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Kind | Description |
|
||||
|---|---|---|
|
||||
| `KRAKEN_SYMBOL_MAP` | `Readonly<Record<string, string>>` | Frozen forward map: 12 Suaveplan symbols → Kraken pair name. |
|
||||
| `KRAKEN_PAIR_TO_SYMBOL` | `Readonly<Record<string, string>>` | Frozen reverse map derived at module load from the forward map. |
|
||||
| `MergedSymbolMaps` | `interface` | Shape returned by `mergeSymbolMap` — `{ forward, reverse }`, both `Readonly<Record<string, string>>`. |
|
||||
| `mergeSymbolMap(extras?)` | `(Readonly<Record<string, string>> \| undefined) => MergedSymbolMaps` | Returns the canonical maps when `extras` is empty/undefined; otherwise returns fresh forward + reverse maps with the extras merged on top. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The Kraken instrument schema mixes legacy `X<base>Z<quote>` pairs (`XXBTZUSD`)
|
||||
with flat `<base>USD` pairs (`SOLUSD`, `DOTUSD`) added after the schema
|
||||
redesign. The map captures both forms so callers don't have to.
|
||||
- The forward map is intentionally small. Kraken lists hundreds of pairs;
|
||||
the 12 included here are the high-volume subset that match the upstream
|
||||
9-provider bundle's coverage. Callers needing more symbols use the
|
||||
`extraSymbols` constructor field on `KrakenProvider` (which calls
|
||||
`mergeSymbolMap` internally) or call `mergeSymbolMap` directly when they
|
||||
want the maps for their own logic.
|
||||
- `mergeSymbolMap` is a hot-path-aware fast path: when `extras` is empty or
|
||||
undefined, it returns the canonical maps by reference without allocating.
|
||||
- Both canonical maps are frozen, so `Object.assign(KRAKEN_SYMBOL_MAP, …)`
|
||||
silently fails in non-strict mode and throws in strict mode — making
|
||||
accidental mutation impossible.
|
||||
- Determinism: iteration order of `Object.entries(KRAKEN_SYMBOL_MAP)` is
|
||||
property-insertion order per the ES2015 spec, so derived structures
|
||||
(including the reverse map and merged outputs) have deterministic ordering
|
||||
for golden-file tests.
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
KRAKEN_PAIR_TO_SYMBOL,
|
||||
KRAKEN_SYMBOL_MAP,
|
||||
mergeSymbolMap,
|
||||
} from "./symbols.js";
|
||||
|
||||
const REQUIRED_SYMBOLS = [
|
||||
"BTC",
|
||||
"ETH",
|
||||
"SOL",
|
||||
"USDT",
|
||||
"DOT",
|
||||
"ADA",
|
||||
"LINK",
|
||||
"AVAX",
|
||||
"ATOM",
|
||||
"LTC",
|
||||
"DOGE",
|
||||
"XRP",
|
||||
] as const;
|
||||
|
||||
describe("KRAKEN_SYMBOL_MAP", () => {
|
||||
it("exports a frozen 12-entry symbol map", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(KRAKEN_SYMBOL_MAP)).toBe(true);
|
||||
const keys = Object.keys(KRAKEN_SYMBOL_MAP);
|
||||
expect(keys.length).toBe(REQUIRED_SYMBOLS.length);
|
||||
for (const sym of REQUIRED_SYMBOLS) {
|
||||
expect(KRAKEN_SYMBOL_MAP[sym]).toBeDefined();
|
||||
expect(typeof KRAKEN_SYMBOL_MAP[sym]).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps the well-known pairs to their canonical Kraken names", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(KRAKEN_SYMBOL_MAP.BTC).toBe("XXBTZUSD");
|
||||
expect(KRAKEN_SYMBOL_MAP.ETH).toBe("XETHZUSD");
|
||||
expect(KRAKEN_SYMBOL_MAP.DOGE).toBe("XDGUSD");
|
||||
expect(KRAKEN_SYMBOL_MAP.SOL).toBe("SOLUSD");
|
||||
});
|
||||
|
||||
it("reverse map is consistent with the forward map", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Object.isFrozen(KRAKEN_PAIR_TO_SYMBOL)).toBe(true);
|
||||
for (const [symbol, pair] of Object.entries(KRAKEN_SYMBOL_MAP)) {
|
||||
expect(KRAKEN_PAIR_TO_SYMBOL[pair]).toBe(symbol);
|
||||
}
|
||||
// And every reverse-map entry exists in the forward map.
|
||||
for (const [pair, symbol] of Object.entries(KRAKEN_PAIR_TO_SYMBOL)) {
|
||||
expect(KRAKEN_SYMBOL_MAP[symbol]).toBe(pair);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeSymbolMap", () => {
|
||||
it("returns canonical maps by reference when no extras are supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { forward, reverse } = mergeSymbolMap();
|
||||
expect(forward).toBe(KRAKEN_SYMBOL_MAP);
|
||||
expect(reverse).toBe(KRAKEN_PAIR_TO_SYMBOL);
|
||||
});
|
||||
|
||||
it("returns canonical maps by reference when extras is an empty object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { forward, reverse } = mergeSymbolMap({});
|
||||
expect(forward).toBe(KRAKEN_SYMBOL_MAP);
|
||||
expect(reverse).toBe(KRAKEN_PAIR_TO_SYMBOL);
|
||||
});
|
||||
|
||||
it("merges extras into a fresh forward map without mutating the canonical map", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { forward, reverse } = mergeSymbolMap({
|
||||
MATIC: "MATICUSD",
|
||||
APT: "APTUSD",
|
||||
});
|
||||
|
||||
expect(forward).not.toBe(KRAKEN_SYMBOL_MAP);
|
||||
expect(forward.MATIC).toBe("MATICUSD");
|
||||
expect(forward.APT).toBe("APTUSD");
|
||||
expect(forward.BTC).toBe("XXBTZUSD");
|
||||
expect(reverse.MATICUSD).toBe("MATIC");
|
||||
expect(reverse.APTUSD).toBe("APT");
|
||||
expect(reverse.XXBTZUSD).toBe("BTC");
|
||||
|
||||
// Canonical maps untouched.
|
||||
expect("MATIC" in KRAKEN_SYMBOL_MAP).toBe(false);
|
||||
expect("MATICUSD" in KRAKEN_PAIR_TO_SYMBOL).toBe(false);
|
||||
});
|
||||
|
||||
it("lets extras override a canonical mapping", { timeout: 5000 }, () => {
|
||||
const { forward, reverse } = mergeSymbolMap({ BTC: "XBTUSDT" });
|
||||
expect(forward.BTC).toBe("XBTUSDT");
|
||||
expect(reverse.XBTUSDT).toBe("BTC");
|
||||
// The legacy XXBTZUSD pair no longer reverse-maps to BTC because BTC
|
||||
// has been redirected — but the legacy entry still points back to
|
||||
// BTC since both forward entries had value BTC ... actually,
|
||||
// because BTC is overridden, the reverse map only has XBTUSDT for BTC.
|
||||
expect(reverse.XXBTZUSD).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Canonical mapping from Suaveplan-standard cryptocurrency symbol to
|
||||
* Kraken's non-standard ticker pair name. Kraken uses asset-prefixed names
|
||||
* (`XXBT` for BTC, `XETH` for ETH, `XDG` for DOGE) plus a `Z` separator
|
||||
* for the quote (`ZUSD` for USD) on legacy pairs, and a flat `<base>USD`
|
||||
* for newer listings. This table normalises both forms so callers always
|
||||
* see the standard symbol.
|
||||
*
|
||||
* The map is `Object.freeze`-d so callers can rely on identity stability
|
||||
* for memoised lookups.
|
||||
*/
|
||||
export const KRAKEN_SYMBOL_MAP: Readonly<Record<string, string>> =
|
||||
Object.freeze({
|
||||
BTC: "XXBTZUSD",
|
||||
ETH: "XETHZUSD",
|
||||
SOL: "SOLUSD",
|
||||
USDT: "USDTZUSD",
|
||||
DOT: "DOTUSD",
|
||||
ADA: "ADAUSD",
|
||||
LINK: "LINKUSD",
|
||||
AVAX: "AVAXUSD",
|
||||
ATOM: "ATOMUSD",
|
||||
LTC: "XLTCZUSD",
|
||||
DOGE: "XDGUSD",
|
||||
XRP: "XXRPZUSD",
|
||||
});
|
||||
|
||||
/**
|
||||
* Reverse of {@link KRAKEN_SYMBOL_MAP} — Kraken pair name → Suaveplan
|
||||
* symbol. Used when iterating a Kraken `result` payload to recover the
|
||||
* standard symbol in O(1). Derived at module load from
|
||||
* `KRAKEN_SYMBOL_MAP`; frozen so identity remains stable.
|
||||
*/
|
||||
export const KRAKEN_PAIR_TO_SYMBOL: Readonly<Record<string, string>> =
|
||||
Object.freeze(
|
||||
Object.fromEntries(
|
||||
Object.entries(KRAKEN_SYMBOL_MAP).map(([symbol, pair]) => [
|
||||
pair,
|
||||
symbol,
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Merge a caller-supplied `extraSymbols` record on top of
|
||||
* {@link KRAKEN_SYMBOL_MAP}, producing a new forward map + matching
|
||||
* reverse map. Used by `KrakenProvider` when the caller wires the
|
||||
* constructor's optional `extraSymbols` field.
|
||||
*
|
||||
* The returned maps are NOT frozen — the provider keeps them private and
|
||||
* never exposes them to callers, so freezing would just cost cycles. If
|
||||
* a future caller needs to expose them, freeze at the call site.
|
||||
*/
|
||||
export interface MergedSymbolMaps {
|
||||
readonly forward: Readonly<Record<string, string>>;
|
||||
readonly reverse: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export function mergeSymbolMap(
|
||||
extra?: Readonly<Record<string, string>>
|
||||
): MergedSymbolMaps {
|
||||
if (extra === undefined || Object.keys(extra).length === 0) {
|
||||
return { forward: KRAKEN_SYMBOL_MAP, reverse: KRAKEN_PAIR_TO_SYMBOL };
|
||||
}
|
||||
const forward: Record<string, string> = { ...KRAKEN_SYMBOL_MAP, ...extra };
|
||||
const reverse: Record<string, string> = {};
|
||||
for (const [symbol, pair] of Object.entries(forward)) {
|
||||
reverse[pair] = symbol;
|
||||
}
|
||||
return { forward, reverse };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = join(__dirname, "..");
|
||||
|
||||
function findFiles(
|
||||
dir: string,
|
||||
predicate: (path: string) => boolean
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
if (entry === "node_modules" || entry === "dist") continue;
|
||||
out.push(...findFiles(full, predicate));
|
||||
} else if (predicate(full)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("dep-graph invariants", () => {
|
||||
it("src/index.ts has no export * statements", { timeout: 5000 }, () => {
|
||||
const src = readFileSync(join(PKG_ROOT, "src", "index.ts"), "utf8");
|
||||
expect(/^\s*export\s+(type\s+)?\*\s+from/m.test(src)).toBe(false);
|
||||
});
|
||||
|
||||
it("feature barrels have no export * statements", { timeout: 5000 }, () => {
|
||||
const barrels = findFiles(join(PKG_ROOT, "src"), (p) =>
|
||||
p.endsWith(`${"/"}index.ts`)
|
||||
);
|
||||
const offenders: string[] = [];
|
||||
for (const path of barrels) {
|
||||
if (path === join(PKG_ROOT, "src", "index.ts")) continue;
|
||||
const text = readFileSync(path, "utf8");
|
||||
if (/^\s*export\s+(type\s+)?\*\s+from/m.test(text)) {
|
||||
offenders.push(path);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("package.json declares one subpath export per feature module", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
|
||||
) as { exports: Record<string, unknown> };
|
||||
const subpaths = Object.keys(pkg.exports).filter(
|
||||
(k) => k.startsWith("./") && k !== "./package.json"
|
||||
);
|
||||
const required = ["./symbols/symbols", "./provider/provider"];
|
||||
for (const r of required) {
|
||||
expect(subpaths).toContain(r);
|
||||
}
|
||||
// Root `.` + exactly two feature subpaths.
|
||||
expect(subpaths.length).toBe(required.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
// Polyfills for test environment — none required. This package is
|
||||
// universal and uses only `globalThis.fetch` (overridable via FetchLike
|
||||
// injection), `JSON.parse`, `Number.parseFloat`, `Number.isFinite`, `Map`,
|
||||
// and `Object.freeze` — all available in every supported runtime (Node 18+,
|
||||
// Bun, modern browsers, Web Workers). This file exists to give vitest a
|
||||
// stable setup file path mirroring the sibling-package layout.
|
||||
export { };
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/currency-provider-kraken",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/currency",
|
||||
"@suaveplan/currency-providers",
|
||||
"@suaveplan/currency-providers/error-codes/error-codes",
|
||||
"@suaveplan/currency-providers/http/http",
|
||||
"@suaveplan/currency-providers/types/configs",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user