feat: Add complete Market Data OHLCV module
- Create market-data module with types, service, controller, and routes
- Implement getOHLCV endpoint: GET /api/v1/market-data/ohlcv/:symbol/:timeframe
- Implement getHistoricalData endpoint: GET /api/v1/market-data/historical/:symbol
- Add Redis caching with 60s TTL
- Support 5m and 15m timeframes from market_data schema
- Query PostgreSQL tables: market_data.ohlcv_5m, market_data.ohlcv_15m
- Validate parameters and return { data, count, cached } response
- Follow existing module patterns (ml, trading, notifications)
Resolves: GAP-P1-001
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ad51d5d5a8
commit
3295f255ee
@ -37,6 +37,7 @@ import { llmRouter } from './modules/llm/llm.routes.js';
|
|||||||
import { portfolioRouter } from './modules/portfolio/portfolio.routes.js';
|
import { portfolioRouter } from './modules/portfolio/portfolio.routes.js';
|
||||||
import { agentsRouter } from './modules/agents/agents.routes.js';
|
import { agentsRouter } from './modules/agents/agents.routes.js';
|
||||||
import { notificationRouter } from './modules/notifications/notification.routes.js';
|
import { notificationRouter } from './modules/notifications/notification.routes.js';
|
||||||
|
import { marketDataRouter } from './modules/market-data/index.js';
|
||||||
|
|
||||||
// Service clients for health checks
|
// Service clients for health checks
|
||||||
import { tradingAgentsClient, mlEngineClient, llmAgentClient } from './shared/clients/index.js';
|
import { tradingAgentsClient, mlEngineClient, llmAgentClient } from './shared/clients/index.js';
|
||||||
@ -154,6 +155,7 @@ apiRouter.use('/llm', llmRouter);
|
|||||||
apiRouter.use('/portfolio', portfolioRouter);
|
apiRouter.use('/portfolio', portfolioRouter);
|
||||||
apiRouter.use('/agents', agentsRouter);
|
apiRouter.use('/agents', agentsRouter);
|
||||||
apiRouter.use('/notifications', notificationRouter);
|
apiRouter.use('/notifications', notificationRouter);
|
||||||
|
apiRouter.use('/market-data', marketDataRouter);
|
||||||
|
|
||||||
// Mount API router
|
// Mount API router
|
||||||
app.use('/api/v1', apiRouter);
|
app.use('/api/v1', apiRouter);
|
||||||
|
|||||||
158
src/modules/market-data/controllers/market-data.controller.ts
Normal file
158
src/modules/market-data/controllers/market-data.controller.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Market Data Controller
|
||||||
|
* Handles OHLCV data endpoints
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { marketDataService } from '../services/marketData.service';
|
||||||
|
import { Timeframe } from '../types/market-data.types';
|
||||||
|
import { logger } from '../../../shared/utils/logger';
|
||||||
|
|
||||||
|
export async function getOHLCV(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { symbol, timeframe } = req.params;
|
||||||
|
const { limit } = req.query;
|
||||||
|
|
||||||
|
if (!symbol || !timeframe) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Missing required parameters: symbol and timeframe',
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTimeframes: Timeframe[] = ['5m', '15m', '1h', '4h', '1d'];
|
||||||
|
if (!validTimeframes.includes(timeframe as Timeframe)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: `Invalid timeframe. Must be one of: ${validTimeframes.join(', ')}`,
|
||||||
|
code: 'INVALID_TIMEFRAME',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveLimit = limit ? parseInt(limit as string, 10) : undefined;
|
||||||
|
|
||||||
|
const data = await marketDataService.getOHLCV(
|
||||||
|
symbol,
|
||||||
|
timeframe as Timeframe,
|
||||||
|
effectiveLimit
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
count: data.length,
|
||||||
|
cached: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in getOHLCV', { error: (error as Error).message });
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHistoricalData(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { symbol } = req.params;
|
||||||
|
const { timeframe, from, to } = req.query;
|
||||||
|
|
||||||
|
if (!symbol || !timeframe || !from || !to) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Missing required parameters: symbol, timeframe, from, to',
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTimeframes: Timeframe[] = ['5m', '15m', '1h', '4h', '1d'];
|
||||||
|
if (!validTimeframes.includes(timeframe as Timeframe)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: `Invalid timeframe. Must be one of: ${validTimeframes.join(', ')}`,
|
||||||
|
code: 'INVALID_TIMEFRAME',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromDate = new Date(from as string);
|
||||||
|
const toDate = new Date(to as string);
|
||||||
|
|
||||||
|
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Invalid date format for from or to parameters',
|
||||||
|
code: 'INVALID_DATE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromDate >= toDate) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'from date must be before to date',
|
||||||
|
code: 'INVALID_DATE_RANGE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await marketDataService.getHistoricalData(
|
||||||
|
symbol,
|
||||||
|
timeframe as Timeframe,
|
||||||
|
fromDate,
|
||||||
|
toDate
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
count: data.length,
|
||||||
|
cached: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in getHistoricalData', { error: (error as Error).message });
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAvailableSymbols(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const symbols = await marketDataService.getAvailableSymbols();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: symbols,
|
||||||
|
count: symbols.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in getAvailableSymbols', { error: (error as Error).message });
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function healthCheck(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const health = await marketDataService.healthCheck();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
...health,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in healthCheck', { error: (error as Error).message });
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/modules/market-data/index.ts
Normal file
8
src/modules/market-data/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Market Data Module
|
||||||
|
* Exports market data service, routes, and types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { marketDataService } from './services/marketData.service';
|
||||||
|
export { marketDataRouter } from './market-data.routes';
|
||||||
|
export * from './types/market-data.types';
|
||||||
24
src/modules/market-data/market-data.routes.ts
Normal file
24
src/modules/market-data/market-data.routes.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Market Data Routes
|
||||||
|
* Defines REST endpoints for OHLCV market data
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import {
|
||||||
|
getOHLCV,
|
||||||
|
getHistoricalData,
|
||||||
|
getAvailableSymbols,
|
||||||
|
healthCheck,
|
||||||
|
} from './controllers/market-data.controller';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/health', healthCheck);
|
||||||
|
|
||||||
|
router.get('/symbols', getAvailableSymbols);
|
||||||
|
|
||||||
|
router.get('/ohlcv/:symbol/:timeframe', getOHLCV);
|
||||||
|
|
||||||
|
router.get('/historical/:symbol', getHistoricalData);
|
||||||
|
|
||||||
|
export { router as marketDataRouter };
|
||||||
233
src/modules/market-data/services/marketData.service.ts
Normal file
233
src/modules/market-data/services/marketData.service.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* Market Data Service
|
||||||
|
* Provides OHLCV data from PostgreSQL with Redis caching
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '../../../shared/database';
|
||||||
|
import { redis } from '../../../shared/redis';
|
||||||
|
import { logger } from '../../../shared/utils/logger';
|
||||||
|
import {
|
||||||
|
OHLCV,
|
||||||
|
Timeframe,
|
||||||
|
CandleQueryOptions,
|
||||||
|
HistoricalDataOptions,
|
||||||
|
OhlcvDataRow,
|
||||||
|
TickerRow,
|
||||||
|
} from '../types/market-data.types';
|
||||||
|
|
||||||
|
class MarketDataService {
|
||||||
|
private readonly CACHE_TTL = 60;
|
||||||
|
private readonly DEFAULT_LIMIT = 100;
|
||||||
|
|
||||||
|
async getOHLCV(symbol: string, timeframe: Timeframe, limit?: number): Promise<OHLCV[]> {
|
||||||
|
const cacheKey = `market-data:ohlcv:${symbol}:${timeframe}:${limit || this.DEFAULT_LIMIT}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redisClient = await redis.getClient();
|
||||||
|
const cached = await redisClient.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
logger.debug('Market data cache hit', { symbol, timeframe });
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Redis get failed, proceeding without cache', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await this.fetchOHLCVFromDB(symbol, timeframe, limit);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redisClient = await redis.getClient();
|
||||||
|
await redisClient.setex(cacheKey, this.CACHE_TTL, JSON.stringify(data));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Redis set failed', { error: (error as Error).message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistoricalData(symbol: string, timeframe: Timeframe, from: Date, to: Date): Promise<OHLCV[]> {
|
||||||
|
const cacheKey = `market-data:historical:${symbol}:${timeframe}:${from.getTime()}:${to.getTime()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redisClient = await redis.getClient();
|
||||||
|
const cached = await redisClient.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
logger.debug('Historical data cache hit', { symbol, timeframe, from, to });
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Redis get failed, proceeding without cache', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await this.fetchHistoricalFromDB(symbol, timeframe, from, to);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redisClient = await redis.getClient();
|
||||||
|
await redisClient.setex(cacheKey, this.CACHE_TTL, JSON.stringify(data));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Redis set failed', { error: (error as Error).message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchOHLCVFromDB(symbol: string, timeframe: Timeframe, limit?: number): Promise<OHLCV[]> {
|
||||||
|
const tableName = this.getTableName(timeframe);
|
||||||
|
const effectiveLimit = limit || this.DEFAULT_LIMIT;
|
||||||
|
|
||||||
|
const tickerResult = await db.query<TickerRow>(
|
||||||
|
'SELECT id, symbol FROM market_data.tickers WHERE symbol = $1 AND is_active = true',
|
||||||
|
[symbol.toUpperCase()]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tickerResult.rows.length === 0) {
|
||||||
|
logger.warn('Ticker not found', { symbol });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickerId = tickerResult.rows[0].id;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
timestamp,
|
||||||
|
open,
|
||||||
|
high,
|
||||||
|
low,
|
||||||
|
close,
|
||||||
|
volume,
|
||||||
|
vwap
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE ticker_id = $1
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query<OhlcvDataRow>(query, [tickerId, effectiveLimit]);
|
||||||
|
|
||||||
|
return result.rows.map((row) => this.transformOHLCV(row, symbol));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchHistoricalFromDB(
|
||||||
|
symbol: string,
|
||||||
|
timeframe: Timeframe,
|
||||||
|
from: Date,
|
||||||
|
to: Date
|
||||||
|
): Promise<OHLCV[]> {
|
||||||
|
const tableName = this.getTableName(timeframe);
|
||||||
|
|
||||||
|
const tickerResult = await db.query<TickerRow>(
|
||||||
|
'SELECT id, symbol FROM market_data.tickers WHERE symbol = $1 AND is_active = true',
|
||||||
|
[symbol.toUpperCase()]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tickerResult.rows.length === 0) {
|
||||||
|
logger.warn('Ticker not found', { symbol });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickerId = tickerResult.rows[0].id;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
timestamp,
|
||||||
|
open,
|
||||||
|
high,
|
||||||
|
low,
|
||||||
|
close,
|
||||||
|
volume,
|
||||||
|
vwap
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE ticker_id = $1
|
||||||
|
AND timestamp >= $2
|
||||||
|
AND timestamp <= $3
|
||||||
|
ORDER BY timestamp ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query<OhlcvDataRow>(query, [tickerId, from, to]);
|
||||||
|
|
||||||
|
return result.rows.map((row) => this.transformOHLCV(row, symbol));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTableName(timeframe: Timeframe): string {
|
||||||
|
switch (timeframe) {
|
||||||
|
case '5m':
|
||||||
|
return 'market_data.ohlcv_5m';
|
||||||
|
case '15m':
|
||||||
|
return 'market_data.ohlcv_15m';
|
||||||
|
case '1h':
|
||||||
|
case '4h':
|
||||||
|
case '1d':
|
||||||
|
throw new Error(`Timeframe ${timeframe} not yet implemented. Use 5m or 15m.`);
|
||||||
|
default:
|
||||||
|
throw new Error(`Invalid timeframe: ${timeframe}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private transformOHLCV(row: OhlcvDataRow, symbol: string): OHLCV {
|
||||||
|
return {
|
||||||
|
timestamp: row.timestamp,
|
||||||
|
open: parseFloat(row.open),
|
||||||
|
high: parseFloat(row.high),
|
||||||
|
low: parseFloat(row.low),
|
||||||
|
close: parseFloat(row.close),
|
||||||
|
volume: parseFloat(row.volume),
|
||||||
|
symbol,
|
||||||
|
vwap: row.vwap ? parseFloat(row.vwap) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableSymbols(): Promise<string[]> {
|
||||||
|
const cacheKey = 'market-data:symbols';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redisClient = await redis.getClient();
|
||||||
|
const cached = await redisClient.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Redis get failed', { error: (error as Error).message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query<TickerRow>(
|
||||||
|
'SELECT symbol FROM market_data.tickers WHERE is_active = true ORDER BY symbol'
|
||||||
|
);
|
||||||
|
|
||||||
|
const symbols = result.rows.map((row) => row.symbol);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redisClient = await redis.getClient();
|
||||||
|
await redisClient.setex(cacheKey, 300, JSON.stringify(symbols));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Redis set failed', { error: (error as Error).message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return symbols;
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<{ status: string; message: string }> {
|
||||||
|
try {
|
||||||
|
const result = await db.query('SELECT COUNT(*) as count FROM market_data.tickers');
|
||||||
|
const count = parseInt(result.rows[0].count, 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'healthy',
|
||||||
|
message: `Market data service operational. ${count} tickers available.`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'unhealthy',
|
||||||
|
message: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const marketDataService = new MarketDataService();
|
||||||
56
src/modules/market-data/types/market-data.types.ts
Normal file
56
src/modules/market-data/types/market-data.types.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Market Data Module Types
|
||||||
|
* OHLCV data types for the market data module
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Timeframe = '5m' | '15m' | '1h' | '4h' | '1d';
|
||||||
|
|
||||||
|
export interface OHLCV {
|
||||||
|
timestamp: Date;
|
||||||
|
open: number;
|
||||||
|
high: number;
|
||||||
|
low: number;
|
||||||
|
close: number;
|
||||||
|
volume: number;
|
||||||
|
symbol: string;
|
||||||
|
vwap?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandleQueryOptions {
|
||||||
|
symbol: string;
|
||||||
|
timeframe: Timeframe;
|
||||||
|
limit?: number;
|
||||||
|
from?: Date;
|
||||||
|
to?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoricalDataOptions {
|
||||||
|
symbol: string;
|
||||||
|
timeframe: Timeframe;
|
||||||
|
from: Date;
|
||||||
|
to: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OhlcvDataRow {
|
||||||
|
id: string;
|
||||||
|
ticker_id: number;
|
||||||
|
timestamp: Date;
|
||||||
|
open: string;
|
||||||
|
high: string;
|
||||||
|
low: string;
|
||||||
|
close: string;
|
||||||
|
volume: string;
|
||||||
|
vwap: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TickerRow {
|
||||||
|
id: number;
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
asset_type: string;
|
||||||
|
exchange: string | null;
|
||||||
|
base_currency: string;
|
||||||
|
quote_currency: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user