diff --git a/.env-example b/.env-example index 2778510..3db267c 100644 --- a/.env-example +++ b/.env-example @@ -60,4 +60,19 @@ OPEN_METRICS_ENABLED=false # Protect OpenMetrics API endpoint with Bearer authentication # Set to "none" to disable authentication (not recommended for production) # Default: none -OPEN_METRICS_AUTH_TOKEN=none \ No newline at end of file +OPEN_METRICS_AUTH_TOKEN=none + +# Enable/disable history recording +HISTORY_ENABLED=true + +# ClickHouse Connection +CLICKHOUSE_HOST=clickhouse +CLICKHOUSE_PORT=8123 +CLICKHOUSE_DATABASE=rabbitforex +CLICKHOUSE_USERNAME=rabbitforex_user +CLICKHOUSE_PASSWORD=rabbitforex_password + +# ClickHouse Performance Settings +CLICKHOUSE_COMPRESSION=true +CLICKHOUSE_MAX_CONNECTIONS=10 +CLICKHOUSE_TIMEOUT=30000 \ No newline at end of file diff --git a/README.md b/README.md index a675ad8..5b50727 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ A high-performance foreign exchange (Forex), precious metals, stocks, and crypto - 🌍 Multi-currency Support - Convert between 150+ currencies with accurate cross-rates - 🥇 Metals - Gold, Silver, Palladium, Copper - 📊 Wise - Reliable source for exchange rates +- 📜 Historical Data - Store and query historical prices with ClickHouse (raw, hourly, daily aggregates) - 🔄 Smart Price Aggregation - Combines multiple crypto exchanges for optimal pricing with outlier detection - 🐳 Docker Ready - Easy deployment with Docker and Docker Compose -- 🏥 Health Checks - Built-in monitoring and health endpoints +- 🥐 Health Checks - Built-in monitoring and health endpoints - 🔄 Auto-restart - Automatic recovery on failures - 📈 Smart Caching - Efficient cache control headers for optimal performance @@ -92,6 +93,21 @@ OPEN_METRICS_ENABLED=false # Set to "none" to disable authentication (not recommended for production) # Default: none OPEN_METRICS_AUTH_TOKEN=none + +# Enable/disable history recording +HISTORY_ENABLED=true + +# ClickHouse Connection +CLICKHOUSE_HOST=clickhouse +CLICKHOUSE_PORT=8123 +CLICKHOUSE_DATABASE=rabbitforex +CLICKHOUSE_USERNAME=rabbitforex_user +CLICKHOUSE_PASSWORD=rabbitforex_password + +# ClickHouse Performance Settings +CLICKHOUSE_COMPRESSION=true +CLICKHOUSE_MAX_CONNECTIONS=10 +CLICKHOUSE_TIMEOUT=30000 ``` ### Running with Docker Compose @@ -135,7 +151,8 @@ Health Check and Statistics "cryptoCount": 2886, "stockCount": 23, "totalAssetCount": 3075, - "updateInterval": "30s" + "updateInterval": "30s", + "historyEnabled": true }, "httpStats": { "pendingRequests": 1 @@ -213,6 +230,80 @@ Example: `/v1/rates/EUR` - Euro as base } ``` +### GET `/v1/rates/history/:symbol` + +Get raw price history for a currency (last 24 hours) + +Example: `/v1/rates/history/EUR` + +```json +{ + "symbol": "EUR", + "base": "USD", + "resolution": "raw", + "data": [ + { + "timestamp": "2025-11-07T06:30:00.000Z", + "price": 1.0892 + }, + { + "timestamp": "2025-11-07T06:30:30.000Z", + "price": 1.0895 + } + ] +} +``` + +### GET `/v1/rates/history/:symbol/hourly` + +Get hourly aggregated price history for a currency (last 90 days) + +Example: `/v1/rates/history/EUR/hourly` + +```json +{ + "symbol": "EUR", + "base": "USD", + "resolution": "hourly", + "data": [ + { + "timestamp": "2025-11-07T06:00:00Z", + "avg": 1.0898, + "min": 1.0885, + "max": 1.0912, + "open": 1.0892, + "close": 1.0905, + "sampleCount": 120 + } + ] +} +``` + +### GET `/v1/rates/history/:symbol/daily` + +Get daily aggregated price history for a currency (all time) + +Example: `/v1/rates/history/EUR/daily` + +```json +{ + "symbol": "EUR", + "base": "USD", + "resolution": "daily", + "data": [ + { + "timestamp": "2025-11-07", + "avg": 1.09, + "min": 1.085, + "max": 1.095, + "open": 1.0875, + "close": 1.092, + "sampleCount": 2880 + } + ] +} +``` + ### GET `/v1/metals/rates` Get all metal rates with USD as base (default) @@ -274,6 +365,76 @@ Example: `/v1/metals/rates/EUR` - Euro as base } ``` +### GET `/v1/metals/history/:symbol` + +Get raw price history for a metal (last 24 hours) + +Example: `/v1/metals/history/GOLD` + +```json +{ + "symbol": "GOLD", + "base": "USD", + "resolution": "raw", + "data": [ + { + "timestamp": "2025-11-07T06:30:00.000Z", + "price": 4332.32 + } + ] +} +``` + +### GET `/v1/metals/history/:symbol/hourly` + +Get hourly aggregated price history for a metal (last 90 days) + +Example: `/v1/metals/history/GOLD/hourly` + +```json +{ + "symbol": "GOLD", + "base": "USD", + "resolution": "hourly", + "data": [ + { + "timestamp": "2025-11-07T06:00:00Z", + "avg": 4332.32, + "min": 4246.65, + "max": 4374.73, + "open": 4314.41, + "close": 4353.63, + "sampleCount": 120 + } + ] +} +``` + +### GET `/v1/metals/history/:symbol/daily` + +Get daily aggregated price history for a metal (all time) + +Example: `/v1/metals/history/GOLD/daily` + +```json +{ + "symbol": "GOLD", + "base": "USD", + "resolution": "daily", + "data": [ + { + "timestamp": "2025-11-07", + "avg": 4332.32, + "min": 4246.65, + "max": 4374.73, + "open": 4314.41, + "close": 4353.63, + "sampleCount": 2880 + } + ] +} +``` + ### GET `/v1/crypto/rates` Get all cryptocurrency rates with USD as base (default) @@ -339,6 +500,80 @@ Example: `/v1/crypto/rates/EUR` - Euro as base for crypto rates } ``` +### GET `/v1/crypto/history/:symbol` + +Get raw price history for a cryptocurrency (last 24 hours) + +Example: `/v1/crypto/history/BTC` + +```json +{ + "symbol": "BTC", + "base": "USD", + "resolution": "raw", + "data": [ + { + "timestamp": "2025-11-07T06:30:00.000Z", + "price": 97500.1234 + }, + { + "timestamp": "2025-11-07T06:30:30.000Z", + "price": 97520.5678 + } + ] +} +``` + +### GET `/v1/crypto/history/:symbol/hourly` + +Get hourly aggregated price history for a cryptocurrency (last 90 days) + +Example: `/v1/crypto/history/BTC/hourly` + +```json +{ + "symbol": "BTC", + "base": "USD", + "resolution": "hourly", + "data": [ + { + "timestamp": "2025-11-07T06:00:00Z", + "avg": 97500.0, + "min": 96000.0, + "max": 99000.0, + "open": 96500.0, + "close": 98000.0, + "sampleCount": 120 + } + ] +} +``` + +### GET `/v1/crypto/history/:symbol/daily` + +Get daily aggregated price history for a cryptocurrency (all time) + +Example: `/v1/crypto/history/BTC/daily` + +```json +{ + "symbol": "BTC", + "base": "USD", + "resolution": "daily", + "data": [ + { + "timestamp": "2025-11-07", + "avg": 97500.0, + "min": 95000.0, + "max": 100000.0, + "open": 96000.0, + "close": 99000.0, + "sampleCount": 2880 + } + ] +} +``` + ### GET `/v1/stocks/rates` Get all stock rates with USD as base (default) @@ -411,6 +646,80 @@ Example: `/v1/stocks/rates/EUR` - Euro as base for stock rates } ``` +### GET `/v1/stocks/history/:symbol` + +Get raw price history for a stock (last 24 hours) + +Example: `/v1/stocks/history/NET` + +```json +{ + "symbol": "NET", + "base": "USD", + "resolution": "raw", + "data": [ + { + "timestamp": "2025-11-07T06:30:00.000Z", + "price": 196.1853 + }, + { + "timestamp": "2025-11-07T06:30:30.000Z", + "price": 198.9521 + } + ] +} +``` + +### GET `/v1/stocks/history/:symbol/hourly` + +Get hourly aggregated price history for a stock (last 90 days) + +Example: `/v1/stocks/history/NET/hourly` + +```json +{ + "symbol": "NET", + "base": "USD", + "resolution": "hourly", + "data": [ + { + "timestamp": "2025-11-07T06:00:00Z", + "avg": 197.1243, + "min": 184.5493, + "max": 210.4347, + "open": 186.9825, + "close": 205.7362, + "sampleCount": 120 + } + ] +} +``` + +### GET `/v1/stocks/history/:symbol/daily` + +Get daily aggregated price history for a stock (all time) + +Example: `/v1/stocks/history/NET/daily` + +```json +{ + "symbol": "NET", + "base": "USD", + "resolution": "daily", + "data": [ + { + "timestamp": "2025-11-07", + "avg": 197.1243, + "min": 184.5493, + "max": 210.4347, + "open": 186.9825, + "close": 205.7362, + "sampleCount": 2880 + } + ] +} +``` + ### GET `/v1/assets` Get lists of all supported currencies, metals and cryptocurrencies @@ -430,6 +739,42 @@ Get lists of all supported currencies, metals and cryptocurrencies } ``` +## Historical Data + +The API supports historical price data storage and retrieval using ClickHouse. When `HISTORY_ENABLED=true`, prices are recorded and aggregated at multiple resolutions. + +### Data Retention + +| Resolution | Endpoint Suffix | Data Retention | Cache TTL | +| ---------- | --------------- | -------------- | ---------- | +| Raw | (none) | 1 day | 30 seconds | +| Hourly | `/hourly` | 90 days | 5 minutes | +| Daily | `/daily` | Forever | 1 hour | + +### Aggregation + +An aggregation job runs every 10 minutes to compute: + +- **Hourly aggregates**: From raw data after each hour completes +- **Daily aggregates**: From hourly data after each day completes + +### Response Fields + +**Raw data** includes: + +- `price` - The price at that moment +- `timestamp` - ISO 8601 timestamp + +**Aggregated data** (hourly/daily) includes: + +- `timestamp` - Period start time +- `avg` - Average price in the period +- `min` - Minimum price in the period +- `max` - Maximum price in the period +- `open` - Opening price (first price) +- `close` - Closing price (last price) +- `sampleCount` - Number of data points + ## Supported Assets ### Currencies (150+) diff --git a/bun.lock b/bun.lock index 8ad24ef..92e002c 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "rabbitforexapi", "dependencies": { + "@clickhouse/client": "^1.15.0", "@rabbit-company/openmetrics-client": "^2.0.1", "@rabbit-company/web": "^0.16.0", "@rabbit-company/web-middleware": "^0.16.0", @@ -18,6 +19,10 @@ }, }, "packages": { + "@clickhouse/client": ["@clickhouse/client@1.15.0", "", { "dependencies": { "@clickhouse/client-common": "1.15.0" } }, "sha512-QmW+p4c/r0oa3X6Un6lcBs4GZtJEQUdvf//x8GeqM5ru6m4oIUg3WwvermP3HE31kpEGoFOQfKbMN5ooR5gvNw=="], + + "@clickhouse/client-common": ["@clickhouse/client-common@1.15.0", "", {}, "sha512-/1BXaNNsBzH2w5ALWiH6M9zS+cJwlM9uMnMj9e8ETwvDjJzO+nIYlyxzp8Prc+9pEDZ5iAilZ4F8c0YVCzzNaA=="], + "@rabbit-company/logger": ["@rabbit-company/logger@5.6.0", "", { "peerDependencies": { "typescript": "^5.8.3" } }, "sha512-lVfA2aq+iMUPI7g771LOqfinDy2IU5gY9m/X4OWO1q5ohwKi4fnj2q4sBWVe0NuF3+xUEVTYjYHWTHYYqOzHEw=="], "@rabbit-company/openmetrics-client": ["@rabbit-company/openmetrics-client@2.0.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-JJr8reWZwTr5vvq1txc2jEgDkLKJ/QAref8W7C7pP7fdmy1qltpu9Ujrbqariq/pJb3qTj0y89krlYZSgh/YvA=="], diff --git a/clickhouse/low-resources.xml b/clickhouse/low-resources.xml new file mode 100644 index 0000000..0a196e0 --- /dev/null +++ b/clickhouse/low-resources.xml @@ -0,0 +1,41 @@ + + + + + 6442450944 + 0.75 + + + 524288000 + + + + + 1 + + 8192 + + 1 + + 0 + + 0 + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 6b475da..eac4875 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -26,9 +26,61 @@ services: - PROXY - OPEN_METRICS_ENABLED - OPEN_METRICS_AUTH_TOKEN + - HISTORY_ENABLED + - CLICKHOUSE_HOST + - CLICKHOUSE_PORT + - CLICKHOUSE_DATABASE + - CLICKHOUSE_USERNAME + - CLICKHOUSE_PASSWORD + - CLICKHOUSE_COMPRESSION + - CLICKHOUSE_MAX_CONNECTIONS + - CLICKHOUSE_TIMEOUT + networks: + - internal-network + depends_on: + clickhouse: + condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000"] interval: 10s timeout: 3s retries: 3 start_period: 5s + + clickhouse: + image: clickhouse/clickhouse-server:latest-alpine + container_name: rabbitforexapi-clickhouse + restart: unless-stopped + expose: + - "8123" + - "9000" + volumes: + - clickhouse_data:/var/lib/clickhouse + # This makes ClickHouse consume less resources, which is useful for small setups. + # https://clickhouse.com/docs/en/operations/tips#using-less-than-16gb-of-ram + - ./clickhouse/low-resources.xml:/etc/clickhouse-server/config.d/low-resources.xml:ro + environment: + - CLICKHOUSE_DB=rabbitforex + - CLICKHOUSE_USER=rabbitforex_user + - CLICKHOUSE_PASSWORD=rabbitforex_password + - CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 + networks: + - internal-network + ulimits: + nofile: + soft: 262144 + hard: 262144 + healthcheck: + test: ["CMD", "clickhouse-client", "--user", "rabbitforex_user", "--password", "rabbitforex_password", "--host", "localhost", "--query", "SELECT 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + +networks: + internal-network: + driver: bridge + +volumes: + clickhouse_data: + driver: local diff --git a/docker-compose.yml b/docker-compose.yml index 990cb48..31921c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,9 +25,61 @@ services: - PROXY - OPEN_METRICS_ENABLED - OPEN_METRICS_AUTH_TOKEN + - HISTORY_ENABLED + - CLICKHOUSE_HOST + - CLICKHOUSE_PORT + - CLICKHOUSE_DATABASE + - CLICKHOUSE_USERNAME + - CLICKHOUSE_PASSWORD + - CLICKHOUSE_COMPRESSION + - CLICKHOUSE_MAX_CONNECTIONS + - CLICKHOUSE_TIMEOUT + networks: + - internal-network + depends_on: + clickhouse: + condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000"] interval: 10s timeout: 3s retries: 3 start_period: 5s + + clickhouse: + image: clickhouse/clickhouse-server:latest-alpine + container_name: rabbitforexapi-clickhouse + restart: unless-stopped + expose: + - "8123" + - "9000" + volumes: + - clickhouse_data:/var/lib/clickhouse + # This makes ClickHouse consume less resources, which is useful for small setups. + # https://clickhouse.com/docs/en/operations/tips#using-less-than-16gb-of-ram + - ./clickhouse/low-resources.xml:/etc/clickhouse-server/config.d/low-resources.xml:ro + environment: + - CLICKHOUSE_DB=rabbitforex + - CLICKHOUSE_USER=rabbitforex_user + - CLICKHOUSE_PASSWORD=rabbitforex_password + - CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 + networks: + - internal-network + ulimits: + nofile: + soft: 262144 + hard: 262144 + healthcheck: + test: ["CMD", "clickhouse-client", "--user", "rabbitforex_user", "--password", "rabbitforex_password", "--host", "localhost", "--query", "SELECT 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + +networks: + internal-network: + driver: bridge + +volumes: + clickhouse_data: + driver: local diff --git a/package.json b/package.json index 2beb329..f580e7f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rabbitforexapi", - "version": "4.1.0", + "version": "5.0.0", "module": "src/index.ts", "type": "module", "private": true, @@ -14,6 +14,7 @@ "typescript": "^5" }, "dependencies": { + "@clickhouse/client": "^1.15.0", "@rabbit-company/openmetrics-client": "^2.0.1", "@rabbit-company/web": "^0.16.0", "@rabbit-company/web-middleware": "^0.16.0" diff --git a/src/aggregation.ts b/src/aggregation.ts new file mode 100644 index 0000000..1677757 --- /dev/null +++ b/src/aggregation.ts @@ -0,0 +1,171 @@ +import type { ClickHouseWrapper } from "./clickhouse"; +import { Logger } from "./logger"; + +export class AggregationJob { + private clickhouse: ClickHouseWrapper; + private intervalId: NodeJS.Timeout | null = null; + private readonly INTERVAL_MS = 10 * 60 * 1000; + private isRunning: boolean = false; + + constructor(clickhouse: ClickHouseWrapper) { + this.clickhouse = clickhouse; + } + + start(): void { + if (this.intervalId) return; + + Logger.info("[AggregationJob] Starting aggregation job (runs every 10 minutes)"); + + this.runAggregation(); + + this.intervalId = setInterval(() => { + this.runAggregation(); + }, this.INTERVAL_MS); + } + + stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + Logger.info("[AggregationJob] Stopped"); + } + + private async runAggregation(): Promise { + if (this.isRunning) { + Logger.debug("[AggregationJob] Previous run still in progress, skipping"); + return; + } + + this.isRunning = true; + + try { + Logger.debug("[AggregationJob] Running aggregation..."); + await this.aggregateHourly(); + await this.aggregateDaily(); + Logger.debug("[AggregationJob] Aggregation completed"); + } catch (error: any) { + Logger.error("[AggregationJob] Aggregation failed:", error); + } finally { + this.isRunning = false; + } + } + + /** + * Find completed hours missing from prices_hourly and aggregate from prices_raw + */ + private async aggregateHourly(): Promise { + // Find hours that: + // 1. Exist in prices_raw + // 2. Are completed (hour < current hour) + // 3. Don't exist in prices_hourly (or have different sample counts) + const query = ` + INSERT INTO prices_hourly ( + symbol, asset_type, hour, + price_min, price_max, price_avg, + price_open, price_close, sample_count + ) + SELECT + symbol, + asset_type, + hour, + price_min, + price_max, + price_avg, + price_open, + price_close, + total_samples AS sample_count + FROM ( + -- Aggregate all completed hours from raw data + SELECT + symbol, + asset_type, + toStartOfHour(timestamp) AS hour, + min(price_usd) AS price_min, + max(price_usd) AS price_max, + avg(price_usd) AS price_avg, + argMin(price_usd, timestamp) AS price_open, + argMax(price_usd, timestamp) AS price_close, + count() AS total_samples + FROM prices_raw + WHERE toStartOfHour(timestamp) < toStartOfHour(now()) + GROUP BY symbol, asset_type, toStartOfHour(timestamp) + ) AS r + LEFT JOIN ( + -- Get existing hourly aggregates with their sample counts + SELECT + symbol AS h_symbol, + asset_type AS h_asset_type, + hour AS h_hour, + sum(sample_count) AS existing_samples + FROM prices_hourly + GROUP BY symbol, asset_type, hour + ) AS h ON r.symbol = h.h_symbol + AND r.asset_type = h.h_asset_type + AND r.hour = h.h_hour + -- Only insert if missing or sample count differs (means we have more data) + WHERE h.h_symbol IS NULL OR h.existing_samples < r.total_samples + `; + + await this.clickhouse.command(query); + } + + /** + * Find completed days missing from prices_daily and aggregate from prices_hourly + */ + private async aggregateDaily(): Promise { + // Find days that: + // 1. Have complete hourly data (24 hours or day has passed) + // 2. Are completed (date < today) + // 3. Don't exist in prices_daily (or have different sample counts) + const query = ` + INSERT INTO prices_daily ( + symbol, asset_type, date, + price_min, price_max, price_avg, + price_open, price_close, sample_count + ) + SELECT + symbol, + asset_type, + date, + price_min, + price_max, + price_avg, + price_open, + price_close, + total_samples AS sample_count + FROM ( + -- Aggregate all completed days from hourly data + SELECT + symbol, + asset_type, + toDate(hour) AS date, + min(price_min) AS price_min, + max(price_max) AS price_max, + sum(price_avg * sample_count) / sum(sample_count) AS price_avg, + argMin(price_open, hour) AS price_open, + argMax(price_close, hour) AS price_close, + sum(sample_count) AS total_samples + FROM prices_hourly + WHERE toDate(hour) < today() + GROUP BY symbol, asset_type, toDate(hour) + ) AS h + LEFT JOIN ( + -- Get existing daily aggregates with their sample counts + SELECT + symbol AS d_symbol, + asset_type AS d_asset_type, + date AS d_date, + sum(sample_count) AS existing_samples + FROM prices_daily + GROUP BY symbol, asset_type, date + ) AS d ON h.symbol = d.d_symbol + AND h.asset_type = d.d_asset_type + AND h.date = d.d_date + -- Only insert if missing or sample count differs + WHERE d.d_symbol IS NULL OR d.existing_samples < h.total_samples + `; + + await this.clickhouse.command(query); + } +} diff --git a/src/clickhouse.ts b/src/clickhouse.ts new file mode 100644 index 0000000..cc2c3f3 --- /dev/null +++ b/src/clickhouse.ts @@ -0,0 +1,424 @@ +import { createClient, type ClickHouseClient, type ClickHouseSettings } from "@clickhouse/client"; +import { Logger } from "./logger"; +import type { AssetType } from "./types"; + +export interface ClickHouseConfig { + host: string; + port: number; + database: string; + username: string; + password: string; + compression?: boolean; + maxOpenConnections?: number; + requestTimeout?: number; +} + +export class ClickHouseWrapper { + private client: ClickHouseClient; + private config: ClickHouseConfig; + private isConnected: boolean = false; + + constructor(config?: Partial) { + this.config = { + host: process.env.CLICKHOUSE_HOST || "localhost", + port: parseInt(process.env.CLICKHOUSE_PORT || "8123") || 8123, + database: process.env.CLICKHOUSE_DATABASE || "rabbitforex", + username: process.env.CLICKHOUSE_USERNAME || "default", + password: process.env.CLICKHOUSE_PASSWORD || "", + compression: process.env.CLICKHOUSE_COMPRESSION === "true", + maxOpenConnections: parseInt(process.env.CLICKHOUSE_MAX_CONNECTIONS || "10") || 10, + requestTimeout: parseInt(process.env.CLICKHOUSE_TIMEOUT || "30000") || 30000, + ...config, + }; + + this.client = createClient({ + url: `http://${this.config.host}:${this.config.port}`, + username: this.config.username, + password: this.config.password, + database: this.config.database, + compression: { + request: this.config.compression, + response: this.config.compression, + }, + max_open_connections: this.config.maxOpenConnections, + request_timeout: this.config.requestTimeout, + clickhouse_settings: { + async_insert: 1, + wait_for_async_insert: 0, + } as ClickHouseSettings, + }); + } + + async initialize(): Promise { + try { + const res = await this.client.ping(); + if (!res.success) throw res.error; + + this.isConnected = true; + Logger.info("[ClickHouse] Connected successfully"); + + await this.runMigrations(); + } catch (error: any) { + Logger.error("[ClickHouse] Failed to connect:", error); + throw error; + } + } + + private async runMigrations(): Promise { + Logger.info("[ClickHouse] Running migrations..."); + + await this.client.command({ + query: `CREATE DATABASE IF NOT EXISTS ${this.config.database}`, + }); + + // Raw prices table - stores all incoming prices (kept for 1 day) + await this.client.command({ + query: ` + CREATE TABLE IF NOT EXISTS ${this.config.database}.prices_raw ( + symbol LowCardinality(String), + asset_type Enum8('currency' = 1, 'metal' = 2, 'crypto' = 3, 'stock' = 4), + price_usd Float64, + timestamp DateTime64(3, 'UTC'), + + INDEX idx_symbol symbol TYPE bloom_filter GRANULARITY 4, + INDEX idx_asset_type asset_type TYPE minmax GRANULARITY 1 + ) + ENGINE = ReplacingMergeTree() + PARTITION BY toYYYYMMDD(timestamp) + ORDER BY (asset_type, symbol, timestamp) + TTL toDateTime(timestamp) + INTERVAL 1 DAY + SETTINGS index_granularity = 8192 + `, + }); + + // Hourly aggregated prices - kept for 90 days + // Populated by aggregation job from prices_raw after each hour completes + await this.client.command({ + query: ` + CREATE TABLE IF NOT EXISTS ${this.config.database}.prices_hourly ( + symbol LowCardinality(String), + asset_type Enum8('currency' = 1, 'metal' = 2, 'crypto' = 3, 'stock' = 4), + hour DateTime('UTC'), + price_min Float64, + price_max Float64, + price_avg Float64, + price_open Float64, + price_close Float64, + sample_count UInt32, + + INDEX idx_symbol symbol TYPE bloom_filter GRANULARITY 4, + INDEX idx_asset_type asset_type TYPE minmax GRANULARITY 1 + ) + ENGINE = ReplacingMergeTree(sample_count) + PARTITION BY toYYYYMM(hour) + ORDER BY (asset_type, symbol, hour) + TTL hour + INTERVAL 90 DAY + SETTINGS index_granularity = 8192 + `, + }); + + // Daily aggregated prices - kept indefinitely + // Populated by aggregation job from prices_hourly + await this.client.command({ + query: ` + CREATE TABLE IF NOT EXISTS ${this.config.database}.prices_daily ( + symbol LowCardinality(String), + asset_type Enum8('currency' = 1, 'metal' = 2, 'crypto' = 3, 'stock' = 4), + date Date, + price_min Float64, + price_max Float64, + price_avg Float64, + price_open Float64, + price_close Float64, + sample_count UInt32, + + INDEX idx_symbol symbol TYPE bloom_filter GRANULARITY 4, + INDEX idx_asset_type asset_type TYPE minmax GRANULARITY 1 + ) + ENGINE = ReplacingMergeTree(sample_count) + PARTITION BY toYear(date) + ORDER BY (asset_type, symbol, date) + SETTINGS index_granularity = 8192 + `, + }); + + Logger.info("[ClickHouse] Migrations completed successfully"); + } + + async insertPrices( + prices: Array<{ + symbol: string; + assetType: AssetType; + priceUsd: number; + timestamp: Date; + }> + ): Promise { + if (prices.length === 0) return; + + const assetTypeMap = { + currency: 1, + metal: 2, + crypto: 3, + stock: 4, + }; + + try { + await this.client.insert({ + table: "prices_raw", + values: prices.map((p) => ({ + symbol: p.symbol, + asset_type: assetTypeMap[p.assetType], + price_usd: p.priceUsd, + timestamp: p.timestamp.toISOString().replace("T", " ").replace("Z", ""), + })), + format: "JSONEachRow", + }); + + Logger.debug(`[ClickHouse] Inserted ${prices.length} price records`); + } catch (error: any) { + Logger.error("[ClickHouse] Failed to insert prices:", error); + throw error; + } + } + + /** + * Get ALL raw prices for a symbol (entire raw table - last 24h due to TTL) + */ + async getAllRawPrices( + symbol: string, + assetType: AssetType + ): Promise< + Array<{ + symbol: string; + price_usd: number; + timestamp: string; + }> + > { + const assetTypeMap = { currency: 1, metal: 2, crypto: 3, stock: 4 }; + + const query = ` + SELECT + symbol, + price_usd, + formatDateTime(timestamp, '%Y-%m-%dT%H:%i:%s.000Z') as timestamp + FROM prices_raw + WHERE symbol = '${symbol}' AND asset_type = ${assetTypeMap[assetType]} + ORDER BY timestamp ASC + `; + + const result = await this.client.query({ + query, + format: "JSONEachRow", + }); + + return result.json(); + } + + /** + * Get ALL hourly prices for a symbol (last 90 days) + * Reads from prices_hourly table (populated by aggregation job) + * Falls back to prices_raw for current incomplete hour + */ + async getAllHourlyPrices( + symbol: string, + assetType: AssetType + ): Promise< + Array<{ + symbol: string; + hour: string; + price_min: number; + price_max: number; + price_avg: number; + price_open: number; + price_close: number; + sample_count: number; + }> + > { + const assetTypeMap = { currency: 1, metal: 2, crypto: 3, stock: 4 }; + + // Query completed hours from prices_hourly (clean, aggregated data) + // Plus current incomplete hour from prices_raw + const query = ` + SELECT + symbol, + formatDateTime(hour, '%Y-%m-%dT%H:00:00Z') as hour, + price_min, + price_max, + price_avg, + price_open, + price_close, + sample_count + FROM ( + -- Completed hours from hourly table + SELECT + symbol, + hour, + price_min, + price_max, + price_avg, + price_open, + price_close, + sample_count + FROM prices_hourly FINAL + WHERE symbol = '${symbol}' AND asset_type = ${assetTypeMap[assetType]} + + UNION ALL + + -- Current incomplete hour from raw table + SELECT + symbol, + toStartOfHour(timestamp) AS hour, + min(price_usd) AS price_min, + max(price_usd) AS price_max, + avg(price_usd) AS price_avg, + argMin(price_usd, timestamp) AS price_open, + argMax(price_usd, timestamp) AS price_close, + count() AS sample_count + FROM prices_raw + WHERE symbol = '${symbol}' + AND asset_type = ${assetTypeMap[assetType]} + AND toStartOfHour(timestamp) = toStartOfHour(now()) + GROUP BY symbol, toStartOfHour(timestamp) + ) + ORDER BY hour ASC + `; + + const result = await this.client.query({ + query, + format: "JSONEachRow", + }); + + return result.json(); + } + + /** + * Get ALL daily prices for a symbol (all time) + * Reads from prices_daily table (populated by aggregation job) + * Falls back to prices_hourly for current incomplete day + */ + async getAllDailyPrices( + symbol: string, + assetType: AssetType + ): Promise< + Array<{ + symbol: string; + date: string; + price_min: number; + price_max: number; + price_avg: number; + price_open: number; + price_close: number; + sample_count: number; + }> + > { + const assetTypeMap = { currency: 1, metal: 2, crypto: 3, stock: 4 }; + + // Query completed days from prices_daily (clean, aggregated data) + // Plus current incomplete day from prices_hourly + prices_raw + const query = ` + SELECT + symbol, + date, + price_min, + price_max, + price_avg, + price_open, + price_close, + sample_count + FROM ( + -- Completed days from daily table + SELECT + symbol, + toString(date) as date, + price_min, + price_max, + price_avg, + price_open, + price_close, + sample_count + FROM prices_daily FINAL + WHERE symbol = '${symbol}' AND asset_type = ${assetTypeMap[assetType]} + + UNION ALL + + -- Current incomplete day aggregated from hourly + current hour from raw + SELECT + symbol, + toString(toDate(hour)) as date, + min(price_min) AS price_min, + max(price_max) AS price_max, + sum(price_avg * samples) / sum(samples) AS price_avg, + argMin(price_open, hour) AS price_open, + argMax(price_close, hour) AS price_close, + sum(samples) AS sample_count + FROM ( + -- Today's completed hours from hourly table + SELECT + symbol, + hour, + price_min, + price_max, + price_avg, + price_open, + price_close, + sample_count AS samples + FROM prices_hourly FINAL + WHERE symbol = '${symbol}' + AND asset_type = ${assetTypeMap[assetType]} + AND toDate(hour) = today() + + UNION ALL + + -- Current incomplete hour from raw + SELECT + symbol, + toStartOfHour(timestamp) AS hour, + min(price_usd) AS price_min, + max(price_usd) AS price_max, + avg(price_usd) AS price_avg, + argMin(price_usd, timestamp) AS price_open, + argMax(price_usd, timestamp) AS price_close, + count() AS samples + FROM prices_raw + WHERE symbol = '${symbol}' + AND asset_type = ${assetTypeMap[assetType]} + AND toStartOfHour(timestamp) = toStartOfHour(now()) + GROUP BY symbol, toStartOfHour(timestamp) + ) + GROUP BY symbol, toDate(hour) + HAVING toDate(hour) = today() + ) + ORDER BY date ASC + `; + + const result = await this.client.query({ + query, + format: "JSONEachRow", + }); + + return result.json(); + } + + async close(): Promise { + await this.client.close(); + this.isConnected = false; + Logger.info("[ClickHouse] Connection closed"); + } + + /** + * Execute a command (INSERT, ALTER, etc.) + */ + async command(query: string): Promise { + try { + await this.client.command({ query }); + return true; + } catch (error: any) { + Logger.error("[ClickHouse] Command failed:", error); + throw error; + } + } + + isReady(): boolean { + return this.isConnected; + } +} diff --git a/src/exchange.ts b/src/exchange.ts index 2ac98da..fd4931d 100644 --- a/src/exchange.ts +++ b/src/exchange.ts @@ -2,6 +2,7 @@ import { CryptoExchange } from "./crypto"; import { Logger } from "./logger"; import { MetalExchange } from "./metals"; import { StockExchange } from "./stock"; +import { historyService } from "./history"; import type { ExchangeRates, MetalData, StockData } from "./types"; interface WiseRate { @@ -54,6 +55,9 @@ export class Exchange { await this.stockExchange.initialize(); await this.metalExchange.initialize(); await this.updateRates(); + + await historyService.initialize(this); + this.startPeriodicUpdate(); } @@ -64,6 +68,10 @@ export class Exchange { await this.updateCryptoRates(); await this.updateStockRates(); this.lastUpdate = new Date(); + + if (historyService.isEnabled()) { + await historyService.recordCurrentPrices(); + } } catch (err: any) { Logger.error("[Exchange] Failed to update exchange rates:", err); throw err; @@ -357,7 +365,7 @@ export class Exchange { }, this.updateInterval * 1000); } - stop(): void { + async stop(): Promise { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; @@ -365,6 +373,8 @@ export class Exchange { this.cryptoExchange.stop(); this.stockExchange.stop(); this.metalExchange.stop(); + + await historyService.stop(); } convert(amount: number, from: string, to: string): number | undefined { diff --git a/src/history.ts b/src/history.ts new file mode 100644 index 0000000..1e4f856 --- /dev/null +++ b/src/history.ts @@ -0,0 +1,281 @@ +import { ClickHouseWrapper } from "./clickhouse"; +import { AggregationJob } from "./aggregation"; +import { Logger } from "./logger"; +import type { Exchange } from "./exchange"; +import type { AggregatedPriceRecord, AssetType, HistoryResponse, PricePoint, RawPriceRecord } from "./types"; + +export class HistoryService { + private clickhouse: ClickHouseWrapper; + private aggregationJob: AggregationJob; + private exchange: Exchange | null = null; + private recordingEnabled: boolean = false; + private batchBuffer: PricePoint[] = []; + private batchTimeout: NodeJS.Timeout | null = null; + private readonly BATCH_SIZE = 1000; + private readonly BATCH_INTERVAL = 5000; + + constructor() { + this.clickhouse = new ClickHouseWrapper(); + this.aggregationJob = new AggregationJob(this.clickhouse); + } + + async initialize(exchange?: Exchange): Promise { + this.exchange = exchange || null; + + const historyEnabled = process.env.HISTORY_ENABLED === "true"; + if (!historyEnabled) { + Logger.info("[HistoryService] History recording is disabled"); + return; + } + + try { + await this.clickhouse.initialize(); + this.recordingEnabled = true; + + this.aggregationJob.start(); + + Logger.info("[HistoryService] History service initialized"); + } catch (error: any) { + Logger.error("[HistoryService] Failed to initialize:", error); + // Don't throw - allow the app to continue without history + this.recordingEnabled = false; + } + } + + setExchange(exchange: Exchange): void { + this.exchange = exchange; + } + + isEnabled(): boolean { + return this.recordingEnabled; + } + + /** + * Record current prices from all asset types + */ + async recordCurrentPrices(): Promise { + if (!this.recordingEnabled || !this.exchange) return; + + const timestamp = new Date(); + const prices: PricePoint[] = []; + + try { + // Record currency rates (USD is base, so we record 1/rate for each currency) + const forexRates = this.exchange.getForexRates("USD"); + for (const [symbol, rate] of Object.entries(forexRates)) { + if (symbol === "USD") continue; + + prices.push({ + symbol, + assetType: "currency", + priceUsd: 1 / rate, + timestamp, + }); + } + + prices.push({ + symbol: "USD", + assetType: "currency", + priceUsd: 1, + timestamp, + }); + + const metalRates = this.exchange.getMetalRates("USD"); + for (const metal of this.exchange.getSupportedMetals()) { + const usdRate = metalRates[metal]; + if (usdRate && usdRate > 0) { + prices.push({ + symbol: metal, + assetType: "metal", + priceUsd: 1 / usdRate, + timestamp, + }); + } + } + + const cryptoRates = this.exchange.getCryptoRates("USD"); + for (const crypto of this.exchange.getSupportedCryptocurrencies()) { + const usdRate = cryptoRates[crypto]; + if (usdRate && usdRate > 0) { + prices.push({ + symbol: crypto, + assetType: "crypto", + priceUsd: 1 / usdRate, + timestamp, + }); + } + } + + const stockRates = this.exchange.getStockRates("USD"); + for (const stock of this.exchange.getSupportedStocks()) { + const usdRate = stockRates[stock]; + if (usdRate && usdRate > 0) { + prices.push({ + symbol: stock, + assetType: "stock", + priceUsd: 1 / usdRate, + timestamp, + }); + } + } + + this.addToBatch(prices); + + Logger.debug(`[HistoryService] Queued ${prices.length} price records`); + } catch (error: any) { + Logger.error("[HistoryService] Failed to record prices:", error); + } + } + + private addToBatch(prices: PricePoint[]): void { + this.batchBuffer.push(...prices); + + if (this.batchBuffer.length >= this.BATCH_SIZE) { + this.flushBatch(); + return; + } + + if (!this.batchTimeout) { + this.batchTimeout = setTimeout(() => { + this.flushBatch(); + }, this.BATCH_INTERVAL); + } + } + + private async flushBatch(): Promise { + if (this.batchTimeout) { + clearTimeout(this.batchTimeout); + this.batchTimeout = null; + } + + if (this.batchBuffer.length === 0) return; + + const batch = this.batchBuffer.splice(0); + + try { + await this.clickhouse.insertPrices(batch); + Logger.debug(`[HistoryService] Flushed ${batch.length} records to ClickHouse`); + } catch (error: any) { + Logger.error("[HistoryService] Failed to flush batch:", error); + // Re-add failed items to buffer for retry + this.batchBuffer.unshift(...batch); + } + } + + /** + * Get conversion rate from USD to target currency + */ + private getConversionRate(base: string): number { + if (base === "USD" || !this.exchange) return 1; + + const forexRates = this.exchange.getForexRates("USD"); + const rate = forexRates[base]; + if (rate && rate > 0) return rate; + + return 1; + } + + /** + * Get raw prices for a symbol (last 24 hours - all data from raw table) + */ + async getRawHistory(symbol: string, assetType: AssetType, base: string = "USD"): Promise { + if (!this.recordingEnabled) { + throw new Error("History service is not enabled"); + } + + const conversionRate = this.getConversionRate(base); + const rawData = await this.clickhouse.getAllRawPrices(symbol, assetType); + + const data: RawPriceRecord[] = rawData.map((r) => ({ + timestamp: r.timestamp, + price: this.roundPrice(r.price_usd * conversionRate), + })); + + return { + symbol, + base, + resolution: "raw", + data, + }; + } + + /** + * Get hourly prices for a symbol (last 90 days - all data from hourly table) + */ + async getHourlyHistory(symbol: string, assetType: AssetType, base: string = "USD"): Promise { + if (!this.recordingEnabled) { + throw new Error("History service is not enabled"); + } + + const conversionRate = this.getConversionRate(base); + const hourlyData = await this.clickhouse.getAllHourlyPrices(symbol, assetType); + + const data: AggregatedPriceRecord[] = hourlyData.map((r) => ({ + timestamp: r.hour, + avg: this.roundPrice(r.price_avg * conversionRate), + min: this.roundPrice(r.price_min * conversionRate), + max: this.roundPrice(r.price_max * conversionRate), + open: this.roundPrice(r.price_open * conversionRate), + close: this.roundPrice(r.price_close * conversionRate), + sampleCount: r.sample_count, + })); + + return { + symbol, + base, + resolution: "hourly", + data, + }; + } + + /** + * Get daily prices for a symbol (all time - all data from daily table) + */ + async getDailyHistory(symbol: string, assetType: AssetType, base: string = "USD"): Promise { + if (!this.recordingEnabled) { + throw new Error("History service is not enabled"); + } + + const conversionRate = this.getConversionRate(base); + const dailyData = await this.clickhouse.getAllDailyPrices(symbol, assetType); + + const data: AggregatedPriceRecord[] = dailyData.map((r) => ({ + timestamp: r.date, + avg: this.roundPrice(r.price_avg * conversionRate), + min: this.roundPrice(r.price_min * conversionRate), + max: this.roundPrice(r.price_max * conversionRate), + open: this.roundPrice(r.price_open * conversionRate), + close: this.roundPrice(r.price_close * conversionRate), + sampleCount: r.sample_count, + })); + + return { + symbol, + base, + resolution: "daily", + data, + }; + } + + private roundPrice(price: number): number { + if (price >= 1) { + return Math.round(price * 10000) / 10000; + } else if (price >= 0.0001) { + return Math.round(price * 100000000) / 100000000; + } else { + return price; + } + } + + async stop(): Promise { + this.aggregationJob.stop(); + await this.flushBatch(); + + if (this.recordingEnabled) { + await this.clickhouse.close(); + } + Logger.info("[HistoryService] Stopped"); + } +} + +export const historyService = new HistoryService(); diff --git a/src/index.ts b/src/index.ts index 6680626..25e3530 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import pkg from "../package.json"; import { openapi } from "./openapi"; import { httpRequests, registry } from "./metrics"; import { bearerAuth } from "@rabbit-company/web-middleware/bearer-auth"; +import { historyService } from "./history"; const host = process.env.SERVER_HOST || "0.0.0.0"; const port = parseInt(process.env.SERVER_PORT || "3000") || 3000; @@ -24,6 +25,10 @@ const cacheControl = [ `stale-if-error=31536000`, ].join(", "); +const rawCacheControl = `public, max-age=${updateInterval}, s-maxage=${updateInterval}, stale-while-revalidate=${updateInterval * 10}, stale-if-error=86400`; +const hourlyCacheControl = "public, max-age=300, s-maxage=300, stale-while-revalidate=3600, stale-if-error=86400"; +const dailyCacheControl = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400, stale-if-error=604800"; + Logger.setLevel(parseInt(process.env.LOGGER_LEVEL || "3") || 3); const exchange = new Exchange(); @@ -67,6 +72,7 @@ app.get("/", (c) => { stockCount: exchange.getSupportedStocks().length, totalAssetCount: exchange.getSupportedAssets().length, updateInterval: `${updateInterval}s`, + historyEnabled: historyService.isEnabled(), }, httpStats: { pendingRequests: server.pendingRequests, @@ -103,7 +109,7 @@ if (openMetricsEnabled) { app.get("/openapi.json", (c) => { httpRequests.labels({ endpoint: "/openapi.json" }).inc(); - return c.json(openapi, 200, { "Cache-Control": "public, max-age=3600 s-maxage=3600 stale-while-revalidate=36000 stale-if-error=31536000" }); + return c.json(openapi, 200, { "Cache-Control": "public, max-age=3600, s-maxage=3600, stale-while-revalidate=36000, stale-if-error=31536000" }); }); app.get("/v1/assets", (c) => { @@ -127,6 +133,8 @@ app.get("/v1/assets", (c) => { ); }); +// LIVE RATES ENDPOINTS + app.get("/v1/rates", (c) => { httpRequests.labels({ endpoint: "/v1/rates" }).inc(); @@ -269,24 +277,270 @@ app.get("/v1/stocks/rates/:base", (c) => { ); }); +// HISTORY ENDPOINTS + +// Currency history - raw (last 24h) +app.get("/v1/rates/history/:symbol", async (c) => { + httpRequests.labels({ endpoint: "/v1/rates/history/:symbol" }).inc(); + + if (!historyService.isEnabled()) { + return c.json({ error: "History service is not enabled" }, 503); + } + + try { + const symbol = c.params["symbol"]!.toUpperCase(); + const result = await historyService.getRawHistory(symbol, "currency", "USD"); + return c.json(result, 200, { "Cache-Control": rawCacheControl }); + } catch (error: any) { + Logger.error("[History] Error:", error); + return c.json({ error: error.message }, 500); + } +}); + +// Currency history - hourly (last 90 days) +app.get("/v1/rates/history/:symbol/hourly", async (c) => { + httpRequests.labels({ endpoint: "/v1/rates/history/:symbol/hourly" }).inc(); + + if (!historyService.isEnabled()) { + return c.json({ error: "History service is not enabled" }, 503); + } + + try { + const symbol = c.params["symbol"]!.toUpperCase(); + const result = await historyService.getHourlyHistory(symbol, "currency", "USD"); + return c.json(result, 200, { "Cache-Control": hourlyCacheControl }); + } catch (error: any) { + Logger.error("[History] Error:", error); + return c.json({ error: error.message }, 500); + } +}); + +// Currency history - daily (all time) +app.get("/v1/rates/history/:symbol/daily", async (c) => { + httpRequests.labels({ endpoint: "/v1/rates/history/:symbol/daily" }).inc(); + + if (!historyService.isEnabled()) { + return c.json({ error: "History service is not enabled" }, 503); + } + + try { + const symbol = c.params["symbol"]!.toUpperCase(); + const result = await historyService.getDailyHistory(symbol, "currency", "USD"); + return c.json(result, 200, { "Cache-Control": dailyCacheControl }); + } catch (error: any) { + Logger.error("[History] Error:", error); + return c.json({ error: error.message }, 500); + } +}); + +// Metal history - raw (last 24h) +app.get("/v1/metals/history/:symbol", async (c) => { + httpRequests.labels({ endpoint: "/v1/metals/history/:symbol" }).inc(); + + if (!historyService.isEnabled()) { + return c.json({ error: "History service is not enabled" }, 503); + } + + try { + const symbol = c.params["symbol"]!.toUpperCase(); + const result = await historyService.getRawHistory(symbol, "metal", "USD"); + return c.json(result, 200, { "Cache-Control": rawCacheControl }); + } catch (error: any) { + Logger.error("[History] Error:", error); + return c.json({ error: error.message }, 500); + } +}); + +// Metal history - hourly (last 90 days) +app.get("/v1/metals/history/:symbol/hourly", async (c) => { + httpRequests.labels({ endpoint: "/v1/metals/history/:symbol/hourly" }).inc(); + + if (!historyService.isEnabled()) { + return c.json({ error: "History service is not enabled" }, 503); + } + + try { + const symbol = c.params["symbol"]!.toUpperCase(); + const result = await historyService.getHourlyHistory(symbol, "metal", "USD"); + return c.json(result, 200, { "Cache-Control": hourlyCacheControl }); + } catch (error: any) { + Logger.error("[History] Error:", error); + return c.json({ error: error.message }, 500); + } +}); + +// Metal history - daily (all time) +app.get("/v1/metals/history/:symbol/daily", async (c) => { + httpRequests.labels({ endpoint: "/v1/metals/history/:symbol/daily" }).inc(); + + if (!historyService.isEnabled()) { + return c.json({ error: "History service is not enabled" }, 503); + } + + try { + const symbol = c.params["symbol"]!.toUpperCase(); + const result = await historyService.getDailyHistory(symbol, "metal", "USD"); + return c.json(result, 200, { "Cache-Control": dailyCacheControl }); + } catch (error: any) { + Logger.error("[History] Error:", error); + return c.json({ error: error.message }, 500); + } +}); + +// Crypto history - raw (last 24h) +app.get("/v1/crypto/history/:symbol", async (c) => { + httpRequests.labels({ endpoint: "/v1/crypto/history/:symbol" }).inc(); + + if (!historyService.isEnabled()) { + return c.json({ error: "History service is not enabled" }, 503); + } + + try { + const symbol = c.params["symbol"]!.toUpperCase(); + const result = await historyService.getRawHistory(symbol, "crypto", "USD"); + return c.json(result, 200, { "Cache-Control": rawCacheControl }); + } catch (error: any) { + Logger.error("[History] Error:", error); + return c.json({ error: error.message }, 500); + } +}); + +// Crypto history - hourly (last 90 days) +app.get("/v1/crypto/history/:symbol/hourly", async (c) => { + httpRequests.labels({ endpoint: "/v1/crypto/history/:symbol/hourly" }).inc(); + + if (!historyService.isEnabled()) { + return c.json({ error: "History service is not enabled" }, 503); + } + + try { + const symbol = c.params["symbol"]!.toUpperCase(); + const result = await historyService.getHourlyHistory(symbol, "crypto", "USD"); + return c.json(result, 200, { "Cache-Control": hourlyCacheControl }); + } catch (error: any) { + Logger.error("[History] Error:", error); + return c.json({ error: error.message }, 500); + } +}); + +// Crypto history - daily (all time) +app.get("/v1/crypto/history/:symbol/daily", async (c) => { + httpRequests.labels({ endpoint: "/v1/crypto/history/:symbol/daily" }).inc(); + + if (!historyService.isEnabled()) { + return c.json({ error: "History service is not enabled" }, 503); + } + + try { + const symbol = c.params["symbol"]!.toUpperCase(); + const result = await historyService.getDailyHistory(symbol, "crypto", "USD"); + return c.json(result, 200, { "Cache-Control": dailyCacheControl }); + } catch (error: any) { + Logger.error("[History] Error:", error); + return c.json({ error: error.message }, 500); + } +}); + +// Stock history - raw (last 24h) +app.get("/v1/stocks/history/:symbol", async (c) => { + httpRequests.labels({ endpoint: "/v1/stocks/history/:symbol" }).inc(); + + if (!historyService.isEnabled()) { + return c.json({ error: "History service is not enabled" }, 503); + } + + try { + const symbol = c.params["symbol"]!.toUpperCase(); + const result = await historyService.getRawHistory(symbol, "stock", "USD"); + return c.json(result, 200, { "Cache-Control": rawCacheControl }); + } catch (error: any) { + Logger.error("[History] Error:", error); + return c.json({ error: error.message }, 500); + } +}); + +// Stock history - hourly (last 90 days) +app.get("/v1/stocks/history/:symbol/hourly", async (c) => { + httpRequests.labels({ endpoint: "/v1/stocks/history/:symbol/hourly" }).inc(); + + if (!historyService.isEnabled()) { + return c.json({ error: "History service is not enabled" }, 503); + } + + try { + const symbol = c.params["symbol"]!.toUpperCase(); + const result = await historyService.getHourlyHistory(symbol, "stock", "USD"); + return c.json(result, 200, { "Cache-Control": hourlyCacheControl }); + } catch (error: any) { + Logger.error("[History] Error:", error); + return c.json({ error: error.message }, 500); + } +}); + +// Stock history - daily (all time) +app.get("/v1/stocks/history/:symbol/daily", async (c) => { + httpRequests.labels({ endpoint: "/v1/stocks/history/:symbol/daily" }).inc(); + + if (!historyService.isEnabled()) { + return c.json({ error: "History service is not enabled" }, 503); + } + + try { + const symbol = c.params["symbol"]!.toUpperCase(); + const result = await historyService.getDailyHistory(symbol, "stock", "USD"); + return c.json(result, 200, { "Cache-Control": dailyCacheControl }); + } catch (error: any) { + Logger.error("[History] Error:", error); + return c.json({ error: error.message }, 500); + } +}); + export const server = await app.listen({ hostname: host, port: port, }); +// Graceful shutdown +process.on("SIGTERM", async () => { + Logger.info("Received SIGTERM, shutting down gracefully..."); + await exchange.stop(); + server.stop(); + process.exit(0); +}); + +process.on("SIGINT", async () => { + Logger.info("Received SIGINT, shutting down gracefully..."); + await exchange.stop(); + server.stop(); + process.exit(0); +}); + Logger.info("RabbitForexAPI started successfully"); Logger.info(`Server running on http://${host}:${port}`); Logger.info(`Exchange rates updates every ${updateInterval}s`); +Logger.info(`History recording: ${historyService.isEnabled() ? "enabled" : "disabled"}`); Logger.info("Available endpoints:"); -Logger.info(" GET / - Health check and stats"); -Logger.info(" GET /metrics - OpenMetrics format"); -Logger.info(" GET /openapi.json - OpenAPI specification"); -Logger.info(" GET /v1/assets - List all supported currencies, metals, stocks and cryptocurrencies"); -Logger.info(" GET /v1/rates - Exchange rates for USD (default)"); -Logger.info(" GET /v1/rates/:asset - Exchange rates for specified asset"); -Logger.info(" GET /v1/metals/rates - Metal rates for USD (default)"); -Logger.info(" GET /v1/metals/rates/:asset - Metal rates for specified asset"); -Logger.info(" GET /v1/crypto/rates - Cryptocurrency rates for USD (default)"); -Logger.info(" GET /v1/crypto/rates/:asset - Cryptocurrency rates for specified asset"); -Logger.info(" GET /v1/stocks/rates - Stock rates for USD (default)"); -Logger.info(" GET /v1/stocks/rates/:asset - Stock rates for specified asset"); +Logger.info(" GET / - Health check and stats"); +Logger.info(" GET /metrics - OpenMetrics format"); +Logger.info(" GET /openapi.json - OpenAPI specification"); +Logger.info(" GET /v1/assets - List all supported assets"); +Logger.info(" GET /v1/rates - Currency rates (USD base)"); +Logger.info(" GET /v1/rates/:base - Currency rates (custom base)"); +Logger.info(" GET /v1/rates/history/:symbol - Currency history (raw, last 24h)"); +Logger.info(" GET /v1/rates/history/:symbol/hourly - Currency history (hourly, last 90d)"); +Logger.info(" GET /v1/rates/history/:symbol/daily - Currency history (daily, all time)"); +Logger.info(" GET /v1/metals/rates - Metal rates (USD base)"); +Logger.info(" GET /v1/metals/rates/:base - Metal rates (custom base)"); +Logger.info(" GET /v1/metals/history/:symbol - Metal history (raw, last 24h)"); +Logger.info(" GET /v1/metals/history/:symbol/hourly - Metal history (hourly, last 90d)"); +Logger.info(" GET /v1/metals/history/:symbol/daily - Metal history (daily, all time)"); +Logger.info(" GET /v1/crypto/rates - Crypto rates (USD base)"); +Logger.info(" GET /v1/crypto/rates/:base - Crypto rates (custom base)"); +Logger.info(" GET /v1/crypto/history/:symbol - Crypto history (raw, last 24h)"); +Logger.info(" GET /v1/crypto/history/:symbol/hourly - Crypto history (hourly, last 90d)"); +Logger.info(" GET /v1/crypto/history/:symbol/daily - Crypto history (daily, all time)"); +Logger.info(" GET /v1/stocks/rates - Stock rates (USD base)"); +Logger.info(" GET /v1/stocks/rates/:base - Stock rates (custom base)"); +Logger.info(" GET /v1/stocks/history/:symbol - Stock history (raw, last 24h)"); +Logger.info(" GET /v1/stocks/history/:symbol/hourly - Stock history (hourly, last 90d)"); +Logger.info(" GET /v1/stocks/history/:symbol/daily - Stock history (daily, all time)"); diff --git a/src/openapi.ts b/src/openapi.ts index 8b49041..6c992d0 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -4,7 +4,7 @@ export const openapi = { openapi: "3.1.1", info: { title: "RabbitForexAPI", - description: "Foreign exchange (Forex), precious metals, stocks and cryptocurrency API", + description: "Foreign exchange (Forex), precious metals, stocks and cryptocurrency API with historical data", version: pkg.version, contact: { name: "Rabbit Company", @@ -16,283 +16,249 @@ export const openapi = { url: "https://github.com/Rabbit-Company/RabbitForexAPI/blob/main/LICENSE", }, }, - servers: [ - { - url: "https://forex.rabbitmonitor.com", - }, - ], + servers: [{ url: "https://forex.rabbitmonitor.com" }], tags: [ - { - name: "Health", - description: "Health check and statistics endpoints", - }, - { - name: "Rates", - description: "Exchange rates endpoints", - }, - { - name: "Metals", - description: "Precious metals prices and exchange rates endpoints", - }, - { - name: "Crypto", - description: "Cryptocurrency exchange rates endpoints", - }, - { - name: "Stocks", - description: "Stock prices and exchange rates endpoints", - }, - { - name: "Assets", - description: "Supported currencies, metals, stocks and cryptocurrencies information", - }, + { name: "Health", description: "Health check and statistics endpoints" }, + { name: "Rates", description: "Live currency exchange rates" }, + { name: "Metals", description: "Live precious metals prices" }, + { name: "Crypto", description: "Live cryptocurrency prices" }, + { name: "Stocks", description: "Live stock prices" }, + { name: "Assets", description: "Supported assets information" }, + { name: "History", description: "Historical price data" }, ], paths: { "/": { get: { tags: ["Health"], summary: "Health check and statistics", - description: "Returns health status and API statistics", operationId: "getHealth", - responses: { - "200": { - description: "Successful response", - content: { - "application/json": { - schema: { - $ref: "#/components/schemas/HealthResponse", - }, - }, - }, - }, - }, + responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/HealthResponse" } } } } }, }, }, + "/v1/assets": { + get: { + tags: ["Assets"], + summary: "Get all supported assets", + operationId: "getAssets", + responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/AssetsResponse" } } } } }, + }, + }, + // Currency endpoints "/v1/rates": { get: { tags: ["Rates"], - summary: "Get all exchange rates with USD as base", - description: "Returns all exchange rates with USD as the base currency", + summary: "Get currency rates with USD as base", operationId: "getAllRates", + responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/RatesResponse" } } } } }, + }, + }, + "/v1/rates/{base}": { + get: { + tags: ["Rates"], + summary: "Get currency rates with specified base", + operationId: "getRatesByBase", + parameters: [{ name: "base", in: "path", required: true, schema: { type: "string", example: "EUR" } }], + responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/RatesResponse" } } } } }, + }, + }, + "/v1/rates/history/{symbol}": { + get: { + tags: ["History"], + summary: "Get currency raw history (last 24 hours)", + description: "Returns all raw price data points from the last 24 hours", + operationId: "getCurrencyRawHistory", + parameters: [{ name: "symbol", in: "path", required: true, schema: { type: "string", example: "EUR" } }], responses: { - "200": { - description: "Successful response", - content: { - "application/json": { - schema: { - $ref: "#/components/schemas/RatesResponse", - }, - }, - }, - }, + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/RawHistoryResponse" } } } }, + "503": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, }, }, }, - "/v1/rates/{asset}": { + "/v1/rates/history/{symbol}/hourly": { get: { - tags: ["Rates"], - summary: "Get all exchange rates with specified asset as base", - description: "Returns all exchange rates with the specified currency or metal as base", - operationId: "getRatesByAsset", - parameters: [ - { - name: "asset", - in: "path", - required: true, - description: "Currency code (e.g., USD, EUR, JPY) or metal code (e.g., GOLD, SILVER)", - schema: { - type: "string", - example: "EUR", - }, - }, - ], + tags: ["History"], + summary: "Get currency hourly history (last 90 days)", + description: "Returns hourly aggregated price data from the last 90 days", + operationId: "getCurrencyHourlyHistory", + parameters: [{ name: "symbol", in: "path", required: true, schema: { type: "string", example: "EUR" } }], responses: { - "200": { - description: "Successful response", - content: { - "application/json": { - schema: { - $ref: "#/components/schemas/RatesResponse", - }, - }, - }, - }, + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/AggregatedHistoryResponse" } } } }, + "503": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, }, }, }, + "/v1/rates/history/{symbol}/daily": { + get: { + tags: ["History"], + summary: "Get currency daily history (all time)", + description: "Returns daily aggregated price data from all available history", + operationId: "getCurrencyDailyHistory", + parameters: [{ name: "symbol", in: "path", required: true, schema: { type: "string", example: "EUR" } }], + responses: { + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/AggregatedHistoryResponse" } } } }, + "503": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, + }, + }, + }, + // Metal endpoints "/v1/metals/rates": { get: { tags: ["Metals"], - summary: "Get all metal rates with USD as base", - description: "Returns all metal exchange rates with USD as the base currency", + summary: "Get metal rates with USD as base", operationId: "getAllMetalRates", + responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/MetalRatesResponse" } } } } }, + }, + }, + "/v1/metals/rates/{base}": { + get: { + tags: ["Metals"], + summary: "Get metal rates with specified base", + operationId: "getMetalRatesByBase", + parameters: [{ name: "base", in: "path", required: true, schema: { type: "string", example: "GOLD" } }], + responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/MetalRatesResponse" } } } } }, + }, + }, + "/v1/metals/history/{symbol}": { + get: { + tags: ["History"], + summary: "Get metal raw history (last 24 hours)", + operationId: "getMetalRawHistory", + parameters: [{ name: "symbol", in: "path", required: true, schema: { type: "string", example: "GOLD" } }], responses: { - "200": { - description: "Successful response", - content: { - "application/json": { - schema: { - $ref: "#/components/schemas/MetalRatesResponse", - }, - }, - }, - }, + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/RawHistoryResponse" } } } }, + "503": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, }, }, }, - "/v1/metals/rates/{asset}": { + "/v1/metals/history/{symbol}/hourly": { get: { - tags: ["Metals"], - summary: "Get all metal rates with specified asset as base", - description: "Returns all metal exchange rates with the specified currency or metal as base", - operationId: "getMetalRatesByAsset", - parameters: [ - { - name: "asset", - in: "path", - required: true, - description: "Currency code (e.g., USD, EUR, JPY) or metal code (e.g., GOLD, SILVER)", - schema: { - type: "string", - example: "GOLD", - }, - }, - ], + tags: ["History"], + summary: "Get metal hourly history (last 90 days)", + operationId: "getMetalHourlyHistory", + parameters: [{ name: "symbol", in: "path", required: true, schema: { type: "string", example: "GOLD" } }], responses: { - "200": { - description: "Successful response", - content: { - "application/json": { - schema: { - $ref: "#/components/schemas/MetalRatesResponse", - }, - }, - }, - }, + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/AggregatedHistoryResponse" } } } }, + "503": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, + }, + }, + }, + "/v1/metals/history/{symbol}/daily": { + get: { + tags: ["History"], + summary: "Get metal daily history (all time)", + operationId: "getMetalDailyHistory", + parameters: [{ name: "symbol", in: "path", required: true, schema: { type: "string", example: "GOLD" } }], + responses: { + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/AggregatedHistoryResponse" } } } }, + "503": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, }, }, }, + // Crypto endpoints "/v1/crypto/rates": { get: { tags: ["Crypto"], - summary: "Get all cryptocurrency rates with USD as base", - description: "Returns all cryptocurrency exchange rates with USD as the base currency", + summary: "Get crypto rates with USD as base", operationId: "getAllCryptoRates", + responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/CryptoRatesResponse" } } } } }, + }, + }, + "/v1/crypto/rates/{base}": { + get: { + tags: ["Crypto"], + summary: "Get crypto rates with specified base", + operationId: "getCryptoRatesByBase", + parameters: [{ name: "base", in: "path", required: true, schema: { type: "string", example: "BTC" } }], + responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/CryptoRatesResponse" } } } } }, + }, + }, + "/v1/crypto/history/{symbol}": { + get: { + tags: ["History"], + summary: "Get crypto raw history (last 24 hours)", + operationId: "getCryptoRawHistory", + parameters: [{ name: "symbol", in: "path", required: true, schema: { type: "string", example: "BTC" } }], responses: { - "200": { - description: "Successful response", - content: { - "application/json": { - schema: { - $ref: "#/components/schemas/CryptoRatesResponse", - }, - }, - }, - }, + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/RawHistoryResponse" } } } }, + "503": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, }, }, }, - "/v1/crypto/rates/{asset}": { + "/v1/crypto/history/{symbol}/hourly": { get: { - tags: ["Crypto"], - summary: "Get all cryptocurrency rates with specified asset as base", - description: "Returns all cryptocurrency exchange rates with the specified currency or cryptocurrency as base", - operationId: "getCryptoRatesByAsset", - parameters: [ - { - name: "asset", - in: "path", - required: true, - description: "Currency code (e.g., USD, EUR) or cryptocurrency code (e.g., BTC, ETH)", - schema: { - type: "string", - example: "EUR", - }, - }, - ], + tags: ["History"], + summary: "Get crypto hourly history (last 90 days)", + operationId: "getCryptoHourlyHistory", + parameters: [{ name: "symbol", in: "path", required: true, schema: { type: "string", example: "BTC" } }], responses: { - "200": { - description: "Successful response", - content: { - "application/json": { - schema: { - $ref: "#/components/schemas/CryptoRatesResponse", - }, - }, - }, - }, + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/AggregatedHistoryResponse" } } } }, + "503": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, + }, + }, + }, + "/v1/crypto/history/{symbol}/daily": { + get: { + tags: ["History"], + summary: "Get crypto daily history (all time)", + operationId: "getCryptoDailyHistory", + parameters: [{ name: "symbol", in: "path", required: true, schema: { type: "string", example: "BTC" } }], + responses: { + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/AggregatedHistoryResponse" } } } }, + "503": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, }, }, }, + // Stock endpoints "/v1/stocks/rates": { get: { tags: ["Stocks"], - summary: "Get all stock rates with USD as base", - description: "Returns all stock exchange rates with USD as the base currency", + summary: "Get stock rates with USD as base", operationId: "getAllStockRates", + responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/StockRatesResponse" } } } } }, + }, + }, + "/v1/stocks/rates/{base}": { + get: { + tags: ["Stocks"], + summary: "Get stock rates with specified base", + operationId: "getStockRatesByBase", + parameters: [{ name: "base", in: "path", required: true, schema: { type: "string", example: "MSFT" } }], + responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/StockRatesResponse" } } } } }, + }, + }, + "/v1/stocks/history/{symbol}": { + get: { + tags: ["History"], + summary: "Get stock raw history (last 24 hours)", + operationId: "getStockRawHistory", + parameters: [{ name: "symbol", in: "path", required: true, schema: { type: "string", example: "MSFT" } }], responses: { - "200": { - description: "Successful response", - content: { - "application/json": { - schema: { - $ref: "#/components/schemas/StockRatesResponse", - }, - }, - }, - }, + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/RawHistoryResponse" } } } }, + "503": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, }, }, }, - "/v1/stocks/rates/{asset}": { + "/v1/stocks/history/{symbol}/hourly": { get: { - tags: ["Stocks"], - summary: "Get all stock rates with specified asset as base", - description: "Returns all stock exchange rates with the specified currency or stock as base", - operationId: "getStockRatesByAsset", - parameters: [ - { - name: "asset", - in: "path", - required: true, - description: "Currency code (e.g., USD, EUR) or stock symbol (e.g., MSFT, NET)", - schema: { - type: "string", - example: "EUR", - }, - }, - ], + tags: ["History"], + summary: "Get stock hourly history (last 90 days)", + operationId: "getStockHourlyHistory", + parameters: [{ name: "symbol", in: "path", required: true, schema: { type: "string", example: "MSFT" } }], responses: { - "200": { - description: "Successful response", - content: { - "application/json": { - schema: { - $ref: "#/components/schemas/StockRatesResponse", - }, - }, - }, - }, + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/AggregatedHistoryResponse" } } } }, + "503": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, }, }, }, - "/v1/assets": { + "/v1/stocks/history/{symbol}/daily": { get: { - tags: ["Assets"], - summary: "Get lists of all supported currencies, metals, stocks and cryptocurrencies", - description: "Returns all supported currency, metal, stock, and cryptocurrency codes", - operationId: "getAssets", + tags: ["History"], + summary: "Get stock daily history (all time)", + operationId: "getStockDailyHistory", + parameters: [{ name: "symbol", in: "path", required: true, schema: { type: "string", example: "MSFT" } }], responses: { - "200": { - description: "Successful response", - content: { - "application/json": { - schema: { - $ref: "#/components/schemas/AssetsResponse", - }, - }, - }, - }, + "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/AggregatedHistoryResponse" } } } }, + "503": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, }, }, }, @@ -302,489 +268,138 @@ export const openapi = { HealthResponse: { type: "object", properties: { - program: { - type: "string", - example: "RabbitForexAPI", - }, - version: { - type: "string", - example: pkg.version, - }, - sourceCode: { - type: "string", - example: "https://github.com/Rabbit-Company/RabbitForexAPI", - }, + program: { type: "string", example: "RabbitForexAPI" }, + version: { type: "string", example: pkg.version }, + sourceCode: { type: "string" }, monitorStats: { - $ref: "#/components/schemas/MonitorStats", - }, - httpStats: { - $ref: "#/components/schemas/HttpStats", - }, - lastUpdate: { - type: "string", - format: "date-time", - example: "2025-11-07T07:07:54.995Z", - }, - }, - required: ["program", "version", "sourceCode", "monitorStats", "httpStats", "lastUpdate"], - }, - MonitorStats: { - type: "object", - properties: { - currencyCount: { - type: "integer", - example: 162, - }, - metalCount: { - type: "integer", - example: 4, - }, - cryptoCount: { - type: "integer", - example: 2885, - }, - stockCount: { - type: "integer", - example: 23, - }, - totalAssetCount: { - type: "integer", - example: 3075, - }, - updateInterval: { - type: "string", - example: "60s", + type: "object", + properties: { + currencyCount: { type: "integer", example: 162 }, + metalCount: { type: "integer", example: 4 }, + cryptoCount: { type: "integer", example: 2500 }, + stockCount: { type: "integer", example: 25 }, + totalAssetCount: { type: "integer", example: 2691 }, + updateInterval: { type: "string", example: "30s" }, + historyEnabled: { type: "boolean", example: true }, + }, }, + httpStats: { type: "object", properties: { pendingRequests: { type: "integer" } } }, + lastUpdate: { type: "string", format: "date-time" }, }, - required: ["currencyCount", "metalCount", "cryptoCount", "stockCount", "totalAssetCount", "updateInterval"], }, - HttpStats: { + AssetsResponse: { type: "object", properties: { - pendingRequests: { - type: "integer", - example: 1, + currencies: { type: "array", items: { type: "string" }, example: ["USD", "EUR", "GBP"] }, + metals: { type: "array", items: { type: "string" }, example: ["GOLD", "SILVER"] }, + cryptocurrencies: { type: "array", items: { type: "string" }, example: ["BTC", "ETH"] }, + stocks: { type: "array", items: { type: "string" }, example: ["MSFT", "AAPL"] }, + timestamps: { + type: "object", + properties: { + currency: { type: "string", format: "date-time", nullable: true }, + metal: { type: "string", format: "date-time", nullable: true }, + crypto: { type: "string", format: "date-time", nullable: true }, + stock: { type: "string", format: "date-time", nullable: true }, + }, }, }, - required: ["pendingRequests"], }, RatesResponse: { type: "object", properties: { - base: { - type: "string", - description: "Base currency or metal code", - example: "USD", - }, - rates: { - type: "object", - additionalProperties: { - type: "number", - }, - description: "Exchange rates from base to target assets", - example: { - USD: 1, - EUR: 0.86702, - JPY: 153.4793, - GBP: 0.7624, - CHF: 0.80776, - }, - }, - timestamps: { - $ref: "#/components/schemas/CurrencyTimestamps", - }, + base: { type: "string", example: "USD" }, + rates: { type: "object", additionalProperties: { type: "number" }, example: { EUR: 0.92, GBP: 0.79 } }, + timestamps: { type: "object", properties: { currency: { type: "string", format: "date-time", nullable: true } } }, }, - required: ["base", "rates", "timestamps"], }, MetalRatesResponse: { type: "object", properties: { - base: { - type: "string", - description: "Base currency or metal code", - example: "USD", - }, - rates: { + base: { type: "string", example: "USD" }, + rates: { type: "object", additionalProperties: { type: "number" } }, + timestamps: { type: "object", - additionalProperties: { - type: "number", - }, - description: "Metal exchange rates from base to target assets", - example: { - GOLD: 0.0077614, - SILVER: 0.63833, - PLATINUM: 0.020007, - COPPER: 93.4827, + properties: { + currency: { type: "string", format: "date-time", nullable: true }, + metal: { type: "string", format: "date-time", nullable: true }, }, }, - timestamps: { - $ref: "#/components/schemas/MetalTimestamps", - }, }, - required: ["base", "rates", "timestamps"], }, CryptoRatesResponse: { type: "object", properties: { - base: { - type: "string", - description: "Base currency, metal, or cryptocurrency code", - example: "USD", - }, - rates: { + base: { type: "string", example: "USD" }, + rates: { type: "object", additionalProperties: { type: "number" } }, + timestamps: { type: "object", - additionalProperties: { - type: "number", - }, - description: "Cryptocurrency exchange rates from base to target assets", - example: { - BTC: 0.0000098082, - ETH: 0.00029232, - SOL: 0.0062949, - ADA: 1.7876, - XRP: 0.4379, - DOT: 0.32144, + properties: { + currency: { type: "string", format: "date-time", nullable: true }, + crypto: { type: "string", format: "date-time", nullable: true }, }, }, - timestamps: { - $ref: "#/components/schemas/CryptoTimestamps", - }, }, - required: ["base", "rates", "timestamps"], }, StockRatesResponse: { type: "object", properties: { - base: { - type: "string", - description: "Base currency or stock symbol", - example: "USD", - }, - rates: { + base: { type: "string", example: "USD" }, + rates: { type: "object", additionalProperties: { type: "number" } }, + timestamps: { type: "object", - additionalProperties: { - type: "number", - }, - description: "Stock exchange rates from base to target assets", - example: { - VOW3d: 0.01227, - NET: 0.004275, - MSFT: 0.0020088, - ASMLa: 0.0013276, - V: 0.0029717, - UBNT: 0.0016181, + properties: { + currency: { type: "string", format: "date-time", nullable: true }, + stock: { type: "string", format: "date-time", nullable: true }, }, }, - timestamps: { - $ref: "#/components/schemas/StockTimestamps", - }, }, - required: ["base", "rates", "timestamps"], }, - AssetsResponse: { + RawHistoryResponse: { type: "object", properties: { - currencies: { - type: "array", - items: { - type: "string", - }, - description: "List of supported currency codes", - example: ["AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "EUR", "USD", "GBP", "JPY", "CHF", "CAD"], - }, - metals: { - type: "array", - items: { - type: "string", - }, - description: "List of supported metal codes", - example: ["GOLD", "SILVER", "PALLADIUM", "COPPER"], - }, - cryptocurrencies: { - type: "array", - items: { - type: "string", - }, - description: "List of supported cryptocurrency codes", - example: ["BTC", "ETH", "SOL", "ADA", "XRP", "DOT", "DOGE", "AVAX", "LINK"], - }, - stocks: { + symbol: { type: "string", example: "BTC" }, + base: { type: "string", example: "USD" }, + resolution: { type: "string", enum: ["raw"], example: "raw" }, + data: { type: "array", items: { - type: "string", + type: "object", + properties: { + timestamp: { type: "string", format: "date-time" }, + price: { type: "number", example: 97500.1234 }, + }, }, - description: "List of supported stock symbols", - example: ["VOW3d", "NET", "MSFT", "ASMLa", "V", "UBNT", "SMSDl", "FB"], - }, - timestamps: { - $ref: "#/components/schemas/AssetTimestamps", - }, - }, - required: ["currencies", "metals", "cryptocurrencies", "stocks", "timestamps"], - }, - CurrencyTimestamps: { - type: "object", - properties: { - currency: { - type: "string", - format: "date-time", - nullable: true, - description: "Last currency data update timestamp", - example: "2025-11-07T07:06:10.544Z", - }, - }, - required: ["currency"], - }, - MetalTimestamps: { - type: "object", - properties: { - currency: { - type: "string", - format: "date-time", - nullable: true, - description: "Last currency data update timestamp", - example: "2025-11-07T07:06:10.544Z", - }, - metal: { - type: "string", - format: "date-time", - nullable: true, - description: "Last metal data update timestamp", - example: "2025-11-07T07:06:07.016Z", }, }, - required: ["currency", "metal"], }, - CryptoTimestamps: { + AggregatedHistoryResponse: { type: "object", properties: { - currency: { - type: "string", - format: "date-time", - nullable: true, - description: "Last currency data update timestamp", - example: "2025-11-07T07:06:10.544Z", - }, - crypto: { - type: "string", - format: "date-time", - nullable: true, - description: "Last cryptocurrency data update timestamp", - example: "2025-11-07T07:06:05.123Z", - }, - }, - required: ["currency", "crypto"], - }, - StockTimestamps: { - type: "object", - properties: { - currency: { - type: "string", - format: "date-time", - nullable: true, - description: "Last currency data update timestamp", - example: "2025-11-07T07:06:10.544Z", - }, - stock: { - type: "string", - format: "date-time", - nullable: true, - description: "Last stock data update timestamp", - example: "2025-11-07T07:06:05.123Z", + symbol: { type: "string", example: "BTC" }, + base: { type: "string", example: "USD" }, + resolution: { type: "string", enum: ["hourly", "daily"], example: "hourly" }, + data: { + type: "array", + items: { + type: "object", + properties: { + timestamp: { type: "string", example: "2024-01-15T12:00:00Z" }, + avg: { type: "number", example: 97500 }, + min: { type: "number", example: 96000 }, + max: { type: "number", example: 99000 }, + open: { type: "number", example: 96500 }, + close: { type: "number", example: 98000 }, + sampleCount: { type: "integer", example: 120 }, + }, + }, }, }, - required: ["currency", "stock"], }, - AssetTimestamps: { + ErrorResponse: { type: "object", properties: { - currency: { - type: "string", - format: "date-time", - nullable: true, - description: "Last currency data update timestamp", - example: "2025-11-07T07:06:10.544Z", - }, - metal: { - type: "string", - format: "date-time", - nullable: true, - description: "Last metal data update timestamp", - example: "2025-11-07T07:06:07.016Z", - }, - crypto: { - type: "string", - format: "date-time", - nullable: true, - description: "Last cryptocurrency data update timestamp", - example: "2025-11-07T07:06:05.123Z", - }, - stock: { - type: "string", - format: "date-time", - nullable: true, - description: "Last stock data update timestamp", - example: "2025-11-07T07:06:05.123Z", - }, - }, - required: ["currency", "metal", "crypto", "stock"], - }, - }, - responses: {}, - parameters: { - AssetParameter: { - name: "asset", - in: "path", - required: true, - description: "Currency or metal code", - schema: { - type: "string", - example: "EUR", - }, - }, - CryptoAssetParameter: { - name: "asset", - in: "path", - required: true, - description: "Currency, metal, or cryptocurrency code", - schema: { - type: "string", - example: "BTC", - }, - }, - StockAssetParameter: { - name: "asset", - in: "path", - required: true, - description: "Currency or stock symbol", - schema: { - type: "string", - example: "MSFT", - }, - }, - }, - examples: { - USDBaseRates: { - summary: "USD base rates example", - value: { - base: "USD", - rates: { - USD: 1, - EUR: 0.86702, - JPY: 153.4793, - GBP: 0.7624, - CHF: 0.80776, - }, - timestamps: { - currency: "2025-11-07T07:06:10.544Z", - }, - }, - }, - GoldBaseRates: { - summary: "Gold base rates example", - value: { - base: "GOLD", - rates: { - USD: 128.8432, - EUR: 111.7092, - JPY: 19774.7612, - GBP: 98.2304, - GOLD: 1, - SILVER: 82.2438, - PLATINUM: 2.5778, - }, - timestamps: { - currency: "2025-11-07T07:06:10.544Z", - metal: "2025-11-07T07:06:07.016Z", - }, - }, - }, - USDCryptoRates: { - summary: "USD base cryptocurrency rates example", - value: { - base: "USD", - rates: { - USD: 1, - EUR: 0.86702, - BTC: 0.000015, - ETH: 0.00023, - SOL: 0.0056, - ADA: 1.2345, - XRP: 2.5678, - DOT: 0.089, - }, - timestamps: { - currency: "2025-11-07T07:06:10.544Z", - crypto: "2025-11-07T07:06:05.123Z", - }, - }, - }, - USDStockRates: { - summary: "USD base stock rates example", - value: { - base: "USD", - rates: { - VOW3d: 0.01227, - NET: 0.004275, - MSFT: 0.0020088, - ASMLa: 0.0013276, - V: 0.0029717, - UBNT: 0.0016181, - }, - timestamps: { - currency: "2025-11-07T07:06:10.544Z", - stock: "2025-11-07T07:06:05.123Z", - }, - }, - }, - BTCCryptoRates: { - summary: "BTC base cryptocurrency rates example", - value: { - base: "BTC", - rates: { - USD: 65000, - EUR: 56355, - BTC: 1, - ETH: 15.333, - SOL: 373.333, - ADA: 82233.333, - }, - timestamps: { - currency: "2025-11-07T07:06:10.544Z", - crypto: "2025-11-07T07:06:05.123Z", - }, - }, - }, - NETStockRates: { - summary: "NET base stock rates example", - value: { - base: "NET", - rates: { - USD: 233.92, - EUR: 202.75, - NET: 1, - MSFT: 0.47, - V: 0.695, - }, - timestamps: { - currency: "2025-11-07T07:06:10.544Z", - stock: "2025-11-07T07:06:05.123Z", - }, - }, - }, - AssetsList: { - summary: "Supported assets example", - value: { - currencies: ["AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "EUR", "USD", "GBP", "JPY", "CHF", "CAD"], - metals: ["GOLD", "SILVER", "PALLADIUM", "COPPER"], - cryptocurrencies: ["BTC", "ETH", "SOL", "ADA", "XRP", "DOT", "DOGE", "AVAX", "LINK"], - stocks: ["VOW3d", "NET", "MSFT", "ASMLa", "V", "UBNT", "SMSDl", "FB"], - timestamps: { - currency: "2025-11-07T07:06:10.544Z", - metal: "2025-11-07T07:06:07.016Z", - crypto: "2025-11-07T07:06:05.123Z", - stock: "2025-11-07T07:06:05.123Z", - }, + error: { type: "string", example: "History service is not enabled" }, }, }, }, diff --git a/src/types.ts b/src/types.ts index 3d5d71f..9007d7e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,3 +63,35 @@ export interface MetalRatesResponse { currency: string | null; }; } + +export type AssetType = "currency" | "metal" | "crypto" | "stock"; +export type Resolution = "raw" | "hourly" | "daily"; + +export interface PricePoint { + symbol: string; + assetType: AssetType; + priceUsd: number; + timestamp: Date; +} + +export interface RawPriceRecord { + timestamp: string; + price: number; +} + +export interface AggregatedPriceRecord { + timestamp: string; + avg: number; + min: number; + max: number; + open: number; + close: number; + sampleCount: number; +} + +export interface HistoryResponse { + symbol: string; + base: string; + resolution: Resolution; + data: RawPriceRecord[] | AggregatedPriceRecord[]; +}