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[];
+}