no idea- added loki and other stuff to market-data-gateway, also added config lib
This commit is contained in:
parent
b957fb99aa
commit
1b71fc87ab
72 changed files with 6178 additions and 153 deletions
53
libs/config/.env.example
Normal file
53
libs/config/.env.example
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Base environment variables for Stock Bot
|
||||
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Database configuration
|
||||
DRAGONFLY_HOST=localhost
|
||||
DRAGONFLY_PORT=6379
|
||||
DRAGONFLY_PASSWORD=
|
||||
DRAGONFLY_MAX_RETRIES_PER_REQUEST=3
|
||||
|
||||
TIMESCALE_HOST=localhost
|
||||
TIMESCALE_PORT=5432
|
||||
TIMESCALE_DB=stockbot
|
||||
TIMESCALE_USER=postgres
|
||||
TIMESCALE_PASSWORD=postgres
|
||||
|
||||
# Data providers
|
||||
DEFAULT_DATA_PROVIDER=alpaca
|
||||
ALPACA_API_KEY=your_alpaca_key_here
|
||||
ALPACA_API_SECRET=your_alpaca_secret_here
|
||||
POLYGON_API_KEY=your_polygon_key_here
|
||||
|
||||
# Risk parameters
|
||||
RISK_MAX_DRAWDOWN=0.05
|
||||
RISK_MAX_POSITION_SIZE=0.1
|
||||
RISK_MAX_LEVERAGE=1.5
|
||||
RISK_STOP_LOSS_DEFAULT=0.02
|
||||
RISK_TAKE_PROFIT_DEFAULT=0.05
|
||||
|
||||
# Market Data Gateway
|
||||
SERVICE_PORT=4000
|
||||
WEBSOCKET_ENABLED=true
|
||||
WEBSOCKET_PATH=/ws/market-data
|
||||
WEBSOCKET_HEARTBEAT_INTERVAL=30000
|
||||
THROTTLING_MAX_REQUESTS=300
|
||||
THROTTLING_MAX_CONNECTIONS=5
|
||||
CACHING_ENABLED=true
|
||||
CACHING_TTL_SECONDS=60
|
||||
|
||||
# Risk Guardian
|
||||
RISK_CHECKS_PRE_TRADE=true
|
||||
RISK_CHECKS_PORTFOLIO=true
|
||||
RISK_CHECKS_LEVERAGE=true
|
||||
RISK_CHECKS_CONCENTRATION=true
|
||||
ALERTING_ENABLED=true
|
||||
ALERTING_CRITICAL_THRESHOLD=0.8
|
||||
ALERTING_WARNING_THRESHOLD=0.6
|
||||
WATCHDOG_ENABLED=true
|
||||
WATCHDOG_CHECK_INTERVAL=60
|
||||
28
libs/config/.env.production.example
Normal file
28
libs/config/.env.production.example
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Production environment variables for Stock Bot
|
||||
|
||||
# Environment
|
||||
NODE_ENV=production
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Database configuration (use environment-specific values)
|
||||
DRAGONFLY_HOST=dragonfly.production
|
||||
DRAGONFLY_PORT=6379
|
||||
DRAGONFLY_MAX_RETRIES_PER_REQUEST=5
|
||||
|
||||
TIMESCALE_HOST=timescale.production
|
||||
TIMESCALE_PORT=5432
|
||||
TIMESCALE_DB=stockbot_prod
|
||||
|
||||
# Risk parameters (more conservative for production)
|
||||
RISK_MAX_DRAWDOWN=0.03
|
||||
RISK_MAX_POSITION_SIZE=0.05
|
||||
RISK_MAX_LEVERAGE=1.0
|
||||
|
||||
# Service settings
|
||||
WEBSOCKET_HEARTBEAT_INTERVAL=15000
|
||||
THROTTLING_MAX_REQUESTS=500
|
||||
THROTTLING_MAX_CONNECTIONS=20
|
||||
CACHING_ENABLED=true
|
||||
CACHING_TTL_SECONDS=30
|
||||
103
libs/config/README.md
Normal file
103
libs/config/README.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# @stock-bot/config
|
||||
|
||||
A configuration management library for the Stock Bot trading platform.
|
||||
|
||||
## Overview
|
||||
|
||||
This library provides a centralized way to manage configurations across all Stock Bot microservices and components. It includes:
|
||||
|
||||
- Environment-based configuration loading
|
||||
- Strong TypeScript typing and validation using Zod
|
||||
- Default configurations for services
|
||||
- Environment variable parsing helpers
|
||||
- Service-specific configuration modules
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { databaseConfig, dataProviderConfigs, riskConfig } from '@stock-bot/config';
|
||||
|
||||
// Access database configuration
|
||||
const dragonflyHost = databaseConfig.dragonfly.host;
|
||||
|
||||
// Access data provider configuration
|
||||
const alpacaApiKey = dataProviderConfigs.providers.find(p => p.name === 'alpaca')?.apiKey;
|
||||
|
||||
// Access risk configuration
|
||||
const maxPositionSize = riskConfig.maxPositionSize;
|
||||
```
|
||||
|
||||
### Service-Specific Configuration
|
||||
|
||||
```typescript
|
||||
import { marketDataGatewayConfig, riskGuardianConfig } from '@stock-bot/config';
|
||||
|
||||
// Access Market Data Gateway configuration
|
||||
const websocketPath = marketDataGatewayConfig.websocket.path;
|
||||
|
||||
// Access Risk Guardian configuration
|
||||
const preTradeValidation = riskGuardianConfig.riskChecks.preTradeValidation;
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The library automatically loads environment variables from `.env` files. You can create environment-specific files:
|
||||
|
||||
- `.env` - Base environment variables
|
||||
- `.env.development` - Development-specific variables
|
||||
- `.env.production` - Production-specific variables
|
||||
- `.env.local` - Local overrides (not to be committed to git)
|
||||
|
||||
## Configuration Modules
|
||||
|
||||
### Core Configuration
|
||||
|
||||
- `Environment` - Enum for different environments
|
||||
- `loadEnvVariables()` - Load environment variables from .env files
|
||||
- `getEnvironment()` - Get the current environment
|
||||
- `validateConfig()` - Validate configuration with Zod schema
|
||||
|
||||
### Database Configuration
|
||||
|
||||
- `databaseConfig` - Database connection settings (Dragonfly, TimescaleDB)
|
||||
|
||||
### Data Provider Configuration
|
||||
|
||||
- `dataProviderConfigs` - Settings for market data providers
|
||||
|
||||
### Risk Configuration
|
||||
|
||||
- `riskConfig` - Risk management parameters (max drawdown, position size, etc.)
|
||||
|
||||
### Service-Specific Configuration
|
||||
|
||||
- `marketDataGatewayConfig` - Configs for the Market Data Gateway service
|
||||
- `riskGuardianConfig` - Configs for the Risk Guardian service
|
||||
|
||||
## Extending
|
||||
|
||||
To add a new service configuration:
|
||||
|
||||
1. Create a new file in `src/services/`
|
||||
2. Define a Zod schema for validation
|
||||
3. Create loading and default configuration functions
|
||||
4. Export from `src/services/index.ts`
|
||||
5. The new configuration will be automatically available from the main package
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Run tests
|
||||
bun test
|
||||
|
||||
# Type check
|
||||
bun run type-check
|
||||
|
||||
# Lint
|
||||
bun run lint
|
||||
```
|
||||
37
libs/config/package.json
Normal file
37
libs/config/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "@stock-bot/config",
|
||||
"version": "1.0.0",
|
||||
"description": "Configuration management library for Stock Bot platform",
|
||||
"main": "src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "bun test",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.3.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"typescript": "^5.3.0",
|
||||
"eslint": "^8.56.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"bun-types": "^1.2.15"
|
||||
},
|
||||
"keywords": [
|
||||
"configuration",
|
||||
"settings",
|
||||
"env",
|
||||
"stock-bot"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
24
libs/config/setup.bat
Normal file
24
libs/config/setup.bat
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
@echo off
|
||||
echo Building @stock-bot/config library...
|
||||
|
||||
cd /d g:\repos\stock-bot
|
||||
echo Installing dependencies...
|
||||
bun install
|
||||
|
||||
echo Running type check...
|
||||
cd /d g:\repos\stock-bot\libs\config
|
||||
bun run type-check
|
||||
|
||||
echo Running tests...
|
||||
bun test
|
||||
|
||||
echo Setting up example configuration...
|
||||
copy .env.example .env
|
||||
|
||||
echo Running example to display configuration...
|
||||
bun run src/example.ts
|
||||
|
||||
echo.
|
||||
echo Configuration library setup complete!
|
||||
echo.
|
||||
echo You can now import @stock-bot/config in your services.
|
||||
113
libs/config/src/core.test.ts
Normal file
113
libs/config/src/core.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Tests for the configuration library
|
||||
*/
|
||||
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
|
||||
import {
|
||||
getEnvironment,
|
||||
Environment,
|
||||
validateConfig,
|
||||
ConfigurationError,
|
||||
loadEnvVariables,
|
||||
getEnvVar,
|
||||
getNumericEnvVar,
|
||||
getBooleanEnvVar
|
||||
} from './core';
|
||||
|
||||
import { databaseConfigSchema } from './types';
|
||||
|
||||
describe('Core configuration', () => {
|
||||
// Save original environment variables
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
// Setup test environment variables
|
||||
beforeAll(() => {
|
||||
process.env.NODE_ENV = 'testing';
|
||||
process.env.TEST_STRING = 'test-value';
|
||||
process.env.TEST_NUMBER = '42';
|
||||
process.env.TEST_BOOL_TRUE = 'true';
|
||||
process.env.TEST_BOOL_FALSE = 'false';
|
||||
});
|
||||
|
||||
// Restore original environment variables
|
||||
afterAll(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
test('getEnvironment returns correct environment', () => {
|
||||
expect(getEnvironment()).toBe(Environment.Testing);
|
||||
|
||||
// Test different environments
|
||||
process.env.NODE_ENV = 'development';
|
||||
expect(getEnvironment()).toBe(Environment.Development);
|
||||
|
||||
process.env.NODE_ENV = 'production';
|
||||
expect(getEnvironment()).toBe(Environment.Production);
|
||||
|
||||
process.env.NODE_ENV = 'staging';
|
||||
expect(getEnvironment()).toBe(Environment.Staging);
|
||||
|
||||
// Test default environment
|
||||
process.env.NODE_ENV = 'unknown';
|
||||
expect(getEnvironment()).toBe(Environment.Development);
|
||||
});
|
||||
|
||||
test('getEnvVar retrieves environment variables', () => {
|
||||
expect(getEnvVar('TEST_STRING')).toBe('test-value');
|
||||
expect(getEnvVar('NON_EXISTENT')).toBeUndefined();
|
||||
expect(getEnvVar('NON_EXISTENT', false)).toBeUndefined();
|
||||
|
||||
// Test required variables
|
||||
expect(() => getEnvVar('NON_EXISTENT', true)).toThrow(ConfigurationError);
|
||||
});
|
||||
|
||||
test('getNumericEnvVar converts to number', () => {
|
||||
expect(getNumericEnvVar('TEST_NUMBER')).toBe(42);
|
||||
expect(getNumericEnvVar('NON_EXISTENT', 100)).toBe(100);
|
||||
|
||||
// Test invalid number
|
||||
process.env.INVALID_NUMBER = 'not-a-number';
|
||||
expect(() => getNumericEnvVar('INVALID_NUMBER')).toThrow(ConfigurationError);
|
||||
});
|
||||
|
||||
test('getBooleanEnvVar converts to boolean', () => {
|
||||
expect(getBooleanEnvVar('TEST_BOOL_TRUE')).toBe(true);
|
||||
expect(getBooleanEnvVar('TEST_BOOL_FALSE')).toBe(false);
|
||||
expect(getBooleanEnvVar('NON_EXISTENT', true)).toBe(true);
|
||||
});
|
||||
|
||||
test('validateConfig validates against schema', () => {
|
||||
// Valid config
|
||||
const validConfig = {
|
||||
dragonfly: {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
maxRetriesPerRequest: 3
|
||||
},
|
||||
timescaleDB: {
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
database: 'stockbot',
|
||||
user: 'postgres'
|
||||
}
|
||||
};
|
||||
|
||||
expect(() => validateConfig(validConfig, databaseConfigSchema)).not.toThrow();
|
||||
|
||||
// Invalid config (missing required field)
|
||||
const invalidConfig = {
|
||||
dragonfly: {
|
||||
host: 'localhost',
|
||||
// missing port
|
||||
maxRetriesPerRequest: 3
|
||||
},
|
||||
timescaleDB: {
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
database: 'stockbot',
|
||||
user: 'postgres'
|
||||
}
|
||||
};
|
||||
|
||||
expect(() => validateConfig(invalidConfig, databaseConfigSchema)).toThrow(ConfigurationError);
|
||||
});
|
||||
});
|
||||
162
libs/config/src/core.ts
Normal file
162
libs/config/src/core.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* Core configuration module for the Stock Bot platform
|
||||
*/
|
||||
import { config as dotenvConfig } from 'dotenv';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import { Environment } from './types';
|
||||
|
||||
/**
|
||||
* Represents an error related to configuration validation
|
||||
*/
|
||||
export class ConfigurationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads environment variables from .env files based on the current environment
|
||||
*/
|
||||
export function loadEnvVariables(envOverride?: string): void {
|
||||
const env = envOverride || process.env.NODE_ENV || 'development';
|
||||
|
||||
// Order of loading:
|
||||
// 1. .env (base environment variables)
|
||||
// 2. .env.{environment} (environment-specific variables)
|
||||
// 3. .env.local (local overrides, not to be committed)
|
||||
|
||||
const envFiles = [
|
||||
'.env',
|
||||
`.env.${env}`,
|
||||
'.env.local'
|
||||
];
|
||||
|
||||
for (const file of envFiles) {
|
||||
dotenvConfig({ path: path.resolve(process.cwd(), file) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current environment from process.env.NODE_ENV
|
||||
*/
|
||||
export function getEnvironment(): Environment {
|
||||
const env = process.env.NODE_ENV?.toLowerCase() || 'development';
|
||||
|
||||
switch (env) {
|
||||
case 'development':
|
||||
return Environment.Development;
|
||||
case 'testing':
|
||||
return Environment.Testing;
|
||||
case 'staging':
|
||||
return Environment.Staging;
|
||||
case 'production':
|
||||
return Environment.Production;
|
||||
default:
|
||||
return Environment.Development;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates configuration using Zod schema
|
||||
*/
|
||||
export function validateConfig<T>(config: unknown, schema: z.ZodSchema<T>): T {
|
||||
try {
|
||||
return schema.parse(config);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const issues = error.issues.map(issue =>
|
||||
`${issue.path.join('.')}: ${issue.message}`
|
||||
).join('\n');
|
||||
|
||||
throw new ConfigurationError(`Configuration validation failed:\n${issues}`);
|
||||
}
|
||||
throw new ConfigurationError('Invalid configuration');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an environment variable with validation
|
||||
*/
|
||||
export function getEnvVar(key: string, required: boolean = false): string | undefined {
|
||||
const value = process.env[key];
|
||||
|
||||
if (required && (value === undefined || value === '')) {
|
||||
throw new ConfigurationError(`Required environment variable ${key} is missing`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a numeric environment variable with validation
|
||||
*/
|
||||
export function getNumericEnvVar(key: string, defaultValue?: number): number {
|
||||
const value = process.env[key];
|
||||
if (value === undefined || value === '') {
|
||||
if (defaultValue !== undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
throw new ConfigurationError(`Required numeric environment variable ${key} is missing`);
|
||||
}
|
||||
|
||||
const numValue = Number(value);
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
throw new ConfigurationError(`Environment variable ${key} is not a valid number`);
|
||||
}
|
||||
|
||||
return numValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a boolean environment variable with validation
|
||||
*/
|
||||
export function getBooleanEnvVar(key: string, defaultValue?: boolean): boolean {
|
||||
const value = process.env[key];
|
||||
if (value === undefined || value === '') {
|
||||
if (defaultValue !== undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
throw new ConfigurationError(`Required boolean environment variable ${key} is missing`);
|
||||
}
|
||||
|
||||
return value.toLowerCase() === 'true' || value === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a typed dynamic configuration loader for a specific service
|
||||
*/
|
||||
export function createConfigLoader<T>(
|
||||
serviceName: string,
|
||||
schema: z.ZodSchema<T>,
|
||||
defaultConfig: Partial<T> = {}
|
||||
): () => T {
|
||||
return (): T => {
|
||||
try {
|
||||
loadEnvVariables();
|
||||
const configEnvVar = `${serviceName.toUpperCase()}_CONFIG`;
|
||||
let config = { ...defaultConfig } as unknown as T;
|
||||
|
||||
// Try to load JSON from environment variable if available
|
||||
const configJson = process.env[configEnvVar];
|
||||
if (configJson) {
|
||||
try {
|
||||
const parsedConfig = JSON.parse(configJson);
|
||||
config = { ...config, ...parsedConfig };
|
||||
} catch (error) {
|
||||
throw new ConfigurationError(`Invalid JSON in ${configEnvVar} environment variable`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and return the config
|
||||
return validateConfig(config, schema);
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ConfigurationError(`Failed to load configuration for service ${serviceName}: ${error}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
73
libs/config/src/data-providers.ts
Normal file
73
libs/config/src/data-providers.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Data provider configurations for market data
|
||||
*/
|
||||
import { getEnvVar, validateConfig } from './core';
|
||||
import { dataProvidersConfigSchema, DataProvidersConfig, DataProviderConfig } from './types';
|
||||
|
||||
/**
|
||||
* Default data provider configurations
|
||||
*/
|
||||
const defaultDataProviders: DataProviderConfig[] = [
|
||||
{
|
||||
name: 'alpaca',
|
||||
type: 'rest',
|
||||
baseUrl: 'https://data.alpaca.markets/v1beta1',
|
||||
apiKey: '',
|
||||
apiSecret: '',
|
||||
rateLimits: {
|
||||
maxRequestsPerMinute: 200
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'polygon',
|
||||
type: 'rest',
|
||||
baseUrl: 'https://api.polygon.io/v2',
|
||||
apiKey: '',
|
||||
rateLimits: {
|
||||
maxRequestsPerMinute: 5
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'alpaca-websocket',
|
||||
type: 'websocket',
|
||||
wsUrl: 'wss://stream.data.alpaca.markets/v2/iex',
|
||||
apiKey: '',
|
||||
apiSecret: ''
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Load data provider configurations from environment variables
|
||||
*/
|
||||
export function loadDataProviderConfigs(): DataProvidersConfig {
|
||||
// Get provider specific environment variables
|
||||
const providers = defaultDataProviders.map(provider => {
|
||||
const nameUpper = provider.name.toUpperCase().replace('-', '_');
|
||||
|
||||
const updatedProvider: DataProviderConfig = {
|
||||
...provider,
|
||||
apiKey: getEnvVar(`${nameUpper}_API_KEY`) || provider.apiKey || '',
|
||||
};
|
||||
|
||||
if (provider.apiSecret !== undefined) {
|
||||
updatedProvider.apiSecret = getEnvVar(`${nameUpper}_API_SECRET`) || provider.apiSecret || '';
|
||||
}
|
||||
|
||||
return updatedProvider;
|
||||
});
|
||||
|
||||
// Load default provider from environment
|
||||
const defaultProvider = getEnvVar('DEFAULT_DATA_PROVIDER') || 'alpaca';
|
||||
|
||||
const config: DataProvidersConfig = {
|
||||
providers,
|
||||
defaultProvider
|
||||
};
|
||||
|
||||
return validateConfig(config, dataProvidersConfigSchema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton data provider configurations
|
||||
*/
|
||||
export const dataProviderConfigs = loadDataProviderConfigs();
|
||||
52
libs/config/src/database.ts
Normal file
52
libs/config/src/database.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Database configuration for Stock Bot services
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { getEnvVar, getNumericEnvVar, validateConfig } from './core';
|
||||
import { databaseConfigSchema, DatabaseConfig } from './types';
|
||||
|
||||
/**
|
||||
* Default database configuration
|
||||
*/
|
||||
const defaultDatabaseConfig: DatabaseConfig = {
|
||||
dragonfly: {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
maxRetriesPerRequest: 3
|
||||
},
|
||||
timescaleDB: {
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
database: 'stockbot',
|
||||
user: 'postgres'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load database configuration from environment variables
|
||||
*/
|
||||
export function loadDatabaseConfig(): DatabaseConfig {
|
||||
const config: DatabaseConfig = {
|
||||
dragonfly: {
|
||||
host: getEnvVar('DRAGONFLY_HOST') || defaultDatabaseConfig.dragonfly.host,
|
||||
port: getNumericEnvVar('DRAGONFLY_PORT', defaultDatabaseConfig.dragonfly.port),
|
||||
password: getEnvVar('DRAGONFLY_PASSWORD'),
|
||||
maxRetriesPerRequest: getNumericEnvVar('DRAGONFLY_MAX_RETRIES_PER_REQUEST',
|
||||
defaultDatabaseConfig.dragonfly.maxRetriesPerRequest)
|
||||
},
|
||||
timescaleDB: {
|
||||
host: getEnvVar('TIMESCALE_HOST') || defaultDatabaseConfig.timescaleDB.host,
|
||||
port: getNumericEnvVar('TIMESCALE_PORT', defaultDatabaseConfig.timescaleDB.port),
|
||||
database: getEnvVar('TIMESCALE_DB') || defaultDatabaseConfig.timescaleDB.database,
|
||||
user: getEnvVar('TIMESCALE_USER') || defaultDatabaseConfig.timescaleDB.user,
|
||||
password: getEnvVar('TIMESCALE_PASSWORD')
|
||||
}
|
||||
};
|
||||
|
||||
return validateConfig(config, databaseConfigSchema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton database configuration
|
||||
*/
|
||||
export const databaseConfig = loadDatabaseConfig();
|
||||
73
libs/config/src/example.ts
Normal file
73
libs/config/src/example.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Example usage of the @stock-bot/config library
|
||||
*/
|
||||
import {
|
||||
databaseConfig,
|
||||
dataProviderConfigs,
|
||||
riskConfig,
|
||||
Environment,
|
||||
getEnvironment,
|
||||
marketDataGatewayConfig,
|
||||
riskGuardianConfig,
|
||||
ConfigurationError,
|
||||
validateConfig
|
||||
} from './index';
|
||||
|
||||
/**
|
||||
* Display current configuration values
|
||||
*/
|
||||
export function printCurrentConfig(): void {
|
||||
console.log('\n=== Stock Bot Configuration ===');
|
||||
|
||||
console.log('\nEnvironment:', getEnvironment());
|
||||
|
||||
console.log('\n--- Database Config ---');
|
||||
console.log('Dragonfly Host:', databaseConfig.dragonfly.host);
|
||||
console.log('Dragonfly Port:', databaseConfig.dragonfly.port);
|
||||
console.log('TimescaleDB Host:', databaseConfig.timescaleDB.host);
|
||||
console.log('TimescaleDB Database:', databaseConfig.timescaleDB.database);
|
||||
|
||||
console.log('\n--- Data Provider Config ---');
|
||||
console.log('Default Provider:', dataProviderConfigs.defaultProvider);
|
||||
console.log('Providers:');
|
||||
dataProviderConfigs.providers.forEach(provider => {
|
||||
console.log(` - ${provider.name} (${provider.type})`);
|
||||
if (provider.baseUrl) console.log(` URL: ${provider.baseUrl}`);
|
||||
if (provider.wsUrl) console.log(` WebSocket: ${provider.wsUrl}`);
|
||||
});
|
||||
|
||||
console.log('\n--- Risk Config ---');
|
||||
console.log('Max Drawdown:', riskConfig.maxDrawdown * 100, '%');
|
||||
console.log('Max Position Size:', riskConfig.maxPositionSize * 100, '%');
|
||||
console.log('Max Leverage:', riskConfig.maxLeverage, 'x');
|
||||
console.log('Default Stop Loss:', riskConfig.stopLossDefault * 100, '%');
|
||||
console.log('Default Take Profit:', riskConfig.takeProfitDefault * 100, '%');
|
||||
|
||||
console.log('\n--- Market Data Gateway Config ---');
|
||||
console.log('Service Port:', marketDataGatewayConfig.service.port);
|
||||
console.log('WebSocket Enabled:', marketDataGatewayConfig.websocket.enabled);
|
||||
console.log('WebSocket Path:', marketDataGatewayConfig.websocket.path);
|
||||
console.log('Caching Enabled:', marketDataGatewayConfig.caching.enabled);
|
||||
console.log('Caching TTL:', marketDataGatewayConfig.caching.ttlSeconds, 'seconds');
|
||||
|
||||
console.log('\n--- Risk Guardian Config ---');
|
||||
console.log('Service Port:', riskGuardianConfig.service.port);
|
||||
console.log('Pre-Trade Validation:', riskGuardianConfig.riskChecks.preTradeValidation);
|
||||
console.log('Portfolio Validation:', riskGuardianConfig.riskChecks.portfolioValidation);
|
||||
console.log('Alerting Enabled:', riskGuardianConfig.alerting.enabled);
|
||||
console.log('Critical Threshold:', riskGuardianConfig.alerting.criticalThreshold * 100, '%');
|
||||
}
|
||||
|
||||
// Execute example if this file is run directly
|
||||
if (require.main === module) {
|
||||
try {
|
||||
printCurrentConfig();
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigurationError) {
|
||||
console.error('Configuration Error:', error.message);
|
||||
} else {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
24
libs/config/src/index.ts
Normal file
24
libs/config/src/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* @stock-bot/config
|
||||
*
|
||||
* Configuration management library for Stock Bot platform
|
||||
*/
|
||||
|
||||
// Core configuration functionality
|
||||
export * from './core';
|
||||
export * from './types';
|
||||
|
||||
// Database configurations
|
||||
export * from './database';
|
||||
|
||||
// Data provider configurations
|
||||
export * from './data-providers';
|
||||
|
||||
// Risk management configurations
|
||||
export * from './risk';
|
||||
|
||||
// Logging configurations
|
||||
export * from './logging';
|
||||
|
||||
// Service-specific configurations
|
||||
export * from './services';
|
||||
75
libs/config/src/logging.ts
Normal file
75
libs/config/src/logging.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Loki logging configuration for Stock Bot platform
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { getEnvVar, getNumericEnvVar, getBooleanEnvVar } from './core';
|
||||
|
||||
/**
|
||||
* Loki configuration schema
|
||||
*/
|
||||
export const lokiConfigSchema = z.object({
|
||||
host: z.string().default('localhost'),
|
||||
port: z.number().default(3100),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
retentionDays: z.number().default(30),
|
||||
labels: z.record(z.string()).default({}),
|
||||
batchSize: z.number().default(100),
|
||||
flushIntervalMs: z.number().default(5000)
|
||||
});
|
||||
|
||||
export type LokiConfig = z.infer<typeof lokiConfigSchema>;
|
||||
|
||||
/**
|
||||
* Logging configuration schema
|
||||
*/
|
||||
export const loggingConfigSchema = z.object({
|
||||
level: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||
console: z.boolean().default(true),
|
||||
loki: lokiConfigSchema
|
||||
});
|
||||
|
||||
export type LoggingConfig = z.infer<typeof loggingConfigSchema>;
|
||||
|
||||
/**
|
||||
* Parse labels from environment variable string
|
||||
* Format: key1=value1,key2=value2
|
||||
*/
|
||||
function parseLabels(labelsStr?: string): Record<string, string> {
|
||||
if (!labelsStr) return {};
|
||||
|
||||
const labels: Record<string, string> = {};
|
||||
labelsStr.split(',').forEach(labelPair => {
|
||||
const [key, value] = labelPair.trim().split('=');
|
||||
if (key && value) {
|
||||
labels[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load logging configuration from environment variables
|
||||
*/
|
||||
export function loadLoggingConfig(): LoggingConfig {
|
||||
return {
|
||||
level: (getEnvVar('LOG_LEVEL') || 'info') as 'debug' | 'info' | 'warn' | 'error',
|
||||
console: getBooleanEnvVar('LOG_CONSOLE', true),
|
||||
loki: {
|
||||
host: getEnvVar('LOKI_HOST') || 'localhost',
|
||||
port: getNumericEnvVar('LOKI_PORT', 3100),
|
||||
username: getEnvVar('LOKI_USERNAME'),
|
||||
password: getEnvVar('LOKI_PASSWORD'),
|
||||
retentionDays: getNumericEnvVar('LOKI_RETENTION_DAYS', 30),
|
||||
labels: parseLabels(getEnvVar('LOKI_LABELS')),
|
||||
batchSize: getNumericEnvVar('LOKI_BATCH_SIZE', 100),
|
||||
flushIntervalMs: getNumericEnvVar('LOKI_FLUSH_INTERVAL_MS', 5000)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton logging configuration
|
||||
*/
|
||||
export const loggingConfig = loadLoggingConfig();
|
||||
36
libs/config/src/risk.ts
Normal file
36
libs/config/src/risk.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Risk management configuration for trading operations
|
||||
*/
|
||||
import { getNumericEnvVar, validateConfig } from './core';
|
||||
import { riskConfigSchema, RiskConfig } from './types';
|
||||
|
||||
/**
|
||||
* Default risk configuration
|
||||
*/
|
||||
const defaultRiskConfig: RiskConfig = {
|
||||
maxDrawdown: 0.05,
|
||||
maxPositionSize: 0.1,
|
||||
maxLeverage: 1,
|
||||
stopLossDefault: 0.02,
|
||||
takeProfitDefault: 0.05
|
||||
};
|
||||
|
||||
/**
|
||||
* Load risk configuration from environment variables
|
||||
*/
|
||||
export function loadRiskConfig(): RiskConfig {
|
||||
const config: RiskConfig = {
|
||||
maxDrawdown: getNumericEnvVar('RISK_MAX_DRAWDOWN', defaultRiskConfig.maxDrawdown),
|
||||
maxPositionSize: getNumericEnvVar('RISK_MAX_POSITION_SIZE', defaultRiskConfig.maxPositionSize),
|
||||
maxLeverage: getNumericEnvVar('RISK_MAX_LEVERAGE', defaultRiskConfig.maxLeverage),
|
||||
stopLossDefault: getNumericEnvVar('RISK_STOP_LOSS_DEFAULT', defaultRiskConfig.stopLossDefault),
|
||||
takeProfitDefault: getNumericEnvVar('RISK_TAKE_PROFIT_DEFAULT', defaultRiskConfig.takeProfitDefault)
|
||||
};
|
||||
|
||||
return validateConfig(config, riskConfigSchema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton risk configuration
|
||||
*/
|
||||
export const riskConfig = loadRiskConfig();
|
||||
5
libs/config/src/services/index.ts
Normal file
5
libs/config/src/services/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Export all service-specific configurations
|
||||
*/
|
||||
export * from './market-data-gateway';
|
||||
export * from './risk-guardian';
|
||||
106
libs/config/src/services/market-data-gateway.ts
Normal file
106
libs/config/src/services/market-data-gateway.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Market Data Gateway service configuration
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { getEnvVar, getNumericEnvVar, getBooleanEnvVar, createConfigLoader } from './core';
|
||||
import { Environment, BaseConfig } from './types';
|
||||
import { getEnvironment } from './core';
|
||||
|
||||
/**
|
||||
* Market Data Gateway specific configuration schema
|
||||
*/
|
||||
export const marketDataGatewayConfigSchema = z.object({
|
||||
environment: z.nativeEnum(Environment),
|
||||
logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||
service: z.object({
|
||||
name: z.string().default('market-data-gateway'),
|
||||
version: z.string().default('1.0.0'),
|
||||
port: z.number().default(4000)
|
||||
}),
|
||||
websocket: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
path: z.string().default('/ws/market-data'),
|
||||
heartbeatInterval: z.number().default(30000)
|
||||
}),
|
||||
throttling: z.object({
|
||||
maxRequestsPerMinute: z.number().default(300),
|
||||
maxConnectionsPerIP: z.number().default(5)
|
||||
}),
|
||||
caching: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
ttlSeconds: z.number().default(60)
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Market Data Gateway configuration type
|
||||
*/
|
||||
export type MarketDataGatewayConfig = z.infer<typeof marketDataGatewayConfigSchema>;
|
||||
|
||||
/**
|
||||
* Default Market Data Gateway configuration
|
||||
*/
|
||||
const defaultConfig: Partial<MarketDataGatewayConfig> = {
|
||||
environment: getEnvironment(),
|
||||
logLevel: 'info',
|
||||
service: {
|
||||
name: 'market-data-gateway',
|
||||
version: '1.0.0',
|
||||
port: 4000
|
||||
},
|
||||
websocket: {
|
||||
enabled: true,
|
||||
path: '/ws/market-data',
|
||||
heartbeatInterval: 30000 // 30 seconds
|
||||
},
|
||||
throttling: {
|
||||
maxRequestsPerMinute: 300,
|
||||
maxConnectionsPerIP: 5
|
||||
},
|
||||
caching: {
|
||||
enabled: true,
|
||||
ttlSeconds: 60
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load Market Data Gateway configuration
|
||||
*/
|
||||
export function loadMarketDataGatewayConfig(): MarketDataGatewayConfig {
|
||||
return {
|
||||
environment: getEnvironment(),
|
||||
logLevel: (getEnvVar('LOG_LEVEL') || defaultConfig.logLevel) as 'debug' | 'info' | 'warn' | 'error',
|
||||
service: {
|
||||
name: getEnvVar('SERVICE_NAME') || defaultConfig.service!.name,
|
||||
version: getEnvVar('SERVICE_VERSION') || defaultConfig.service!.version,
|
||||
port: getNumericEnvVar('SERVICE_PORT', defaultConfig.service!.port)
|
||||
},
|
||||
websocket: {
|
||||
enabled: getBooleanEnvVar('WEBSOCKET_ENABLED', defaultConfig.websocket!.enabled),
|
||||
path: getEnvVar('WEBSOCKET_PATH') || defaultConfig.websocket!.path,
|
||||
heartbeatInterval: getNumericEnvVar('WEBSOCKET_HEARTBEAT_INTERVAL', defaultConfig.websocket!.heartbeatInterval)
|
||||
},
|
||||
throttling: {
|
||||
maxRequestsPerMinute: getNumericEnvVar('THROTTLING_MAX_REQUESTS', defaultConfig.throttling!.maxRequestsPerMinute),
|
||||
maxConnectionsPerIP: getNumericEnvVar('THROTTLING_MAX_CONNECTIONS', defaultConfig.throttling!.maxConnectionsPerIP)
|
||||
},
|
||||
caching: {
|
||||
enabled: getBooleanEnvVar('CACHING_ENABLED', defaultConfig.caching!.enabled),
|
||||
ttlSeconds: getNumericEnvVar('CACHING_TTL_SECONDS', defaultConfig.caching!.ttlSeconds)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dynamic configuration loader for the Market Data Gateway
|
||||
*/
|
||||
export const createMarketDataGatewayConfig = createConfigLoader<MarketDataGatewayConfig>(
|
||||
'market-data-gateway',
|
||||
marketDataGatewayConfigSchema,
|
||||
defaultConfig
|
||||
);
|
||||
|
||||
/**
|
||||
* Singleton Market Data Gateway configuration
|
||||
*/
|
||||
export const marketDataGatewayConfig = loadMarketDataGatewayConfig();
|
||||
112
libs/config/src/services/risk-guardian.ts
Normal file
112
libs/config/src/services/risk-guardian.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* Risk Guardian service configuration
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { getEnvVar, getNumericEnvVar, getBooleanEnvVar, createConfigLoader } from '../core';
|
||||
import { Environment, BaseConfig } from '../types';
|
||||
import { getEnvironment } from '../core';
|
||||
|
||||
/**
|
||||
* Risk Guardian specific configuration schema
|
||||
*/
|
||||
export const riskGuardianConfigSchema = z.object({
|
||||
environment: z.nativeEnum(Environment),
|
||||
logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||
service: z.object({
|
||||
name: z.string().default('risk-guardian'),
|
||||
version: z.string().default('1.0.0'),
|
||||
port: z.number().default(4001)
|
||||
}),
|
||||
riskChecks: z.object({
|
||||
preTradeValidation: z.boolean().default(true),
|
||||
portfolioValidation: z.boolean().default(true),
|
||||
leverageValidation: z.boolean().default(true),
|
||||
concentrationValidation: z.boolean().default(true)
|
||||
}),
|
||||
alerting: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
criticalThreshold: z.number().default(0.8),
|
||||
warningThreshold: z.number().default(0.6)
|
||||
}),
|
||||
watchdog: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
checkIntervalSeconds: z.number().default(60)
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Risk Guardian configuration type
|
||||
*/
|
||||
export type RiskGuardianConfig = z.infer<typeof riskGuardianConfigSchema>;
|
||||
|
||||
/**
|
||||
* Default Risk Guardian configuration
|
||||
*/
|
||||
const defaultConfig: Partial<RiskGuardianConfig> = {
|
||||
environment: getEnvironment(),
|
||||
logLevel: 'info',
|
||||
service: {
|
||||
name: 'risk-guardian',
|
||||
version: '1.0.0',
|
||||
port: 4001
|
||||
},
|
||||
riskChecks: {
|
||||
preTradeValidation: true,
|
||||
portfolioValidation: true,
|
||||
leverageValidation: true,
|
||||
concentrationValidation: true
|
||||
},
|
||||
alerting: {
|
||||
enabled: true,
|
||||
criticalThreshold: 0.8,
|
||||
warningThreshold: 0.6
|
||||
},
|
||||
watchdog: {
|
||||
enabled: true,
|
||||
checkIntervalSeconds: 60
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Load Risk Guardian configuration
|
||||
*/
|
||||
export function loadRiskGuardianConfig(): RiskGuardianConfig {
|
||||
return {
|
||||
environment: getEnvironment(),
|
||||
logLevel: (getEnvVar('LOG_LEVEL') || defaultConfig.logLevel) as 'debug' | 'info' | 'warn' | 'error',
|
||||
service: {
|
||||
name: getEnvVar('SERVICE_NAME') || defaultConfig.service!.name,
|
||||
version: getEnvVar('SERVICE_VERSION') || defaultConfig.service!.version,
|
||||
port: getNumericEnvVar('SERVICE_PORT', defaultConfig.service!.port)
|
||||
},
|
||||
riskChecks: {
|
||||
preTradeValidation: getBooleanEnvVar('RISK_CHECKS_PRE_TRADE', defaultConfig.riskChecks!.preTradeValidation),
|
||||
portfolioValidation: getBooleanEnvVar('RISK_CHECKS_PORTFOLIO', defaultConfig.riskChecks!.portfolioValidation),
|
||||
leverageValidation: getBooleanEnvVar('RISK_CHECKS_LEVERAGE', defaultConfig.riskChecks!.leverageValidation),
|
||||
concentrationValidation: getBooleanEnvVar('RISK_CHECKS_CONCENTRATION', defaultConfig.riskChecks!.concentrationValidation)
|
||||
},
|
||||
alerting: {
|
||||
enabled: getBooleanEnvVar('ALERTING_ENABLED', defaultConfig.alerting!.enabled),
|
||||
criticalThreshold: getNumericEnvVar('ALERTING_CRITICAL_THRESHOLD', defaultConfig.alerting!.criticalThreshold),
|
||||
warningThreshold: getNumericEnvVar('ALERTING_WARNING_THRESHOLD', defaultConfig.alerting!.warningThreshold)
|
||||
},
|
||||
watchdog: {
|
||||
enabled: getBooleanEnvVar('WATCHDOG_ENABLED', defaultConfig.watchdog!.enabled),
|
||||
checkIntervalSeconds: getNumericEnvVar('WATCHDOG_CHECK_INTERVAL', defaultConfig.watchdog!.checkIntervalSeconds)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dynamic configuration loader for the Risk Guardian
|
||||
*/
|
||||
export const createRiskGuardianConfig = createConfigLoader<RiskGuardianConfig>(
|
||||
'risk-guardian',
|
||||
riskGuardianConfigSchema,
|
||||
defaultConfig
|
||||
);
|
||||
|
||||
/**
|
||||
* Singleton Risk Guardian configuration
|
||||
*/
|
||||
export const riskGuardianConfig = loadRiskGuardianConfig();
|
||||
87
libs/config/src/types.ts
Normal file
87
libs/config/src/types.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Configuration type definitions for the Stock Bot platform
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Environment enum for different deployment environments
|
||||
*/
|
||||
export enum Environment {
|
||||
Development = 'development',
|
||||
Testing = 'testing',
|
||||
Staging = 'staging',
|
||||
Production = 'production'
|
||||
}
|
||||
|
||||
/**
|
||||
* Common configuration interface for all service configs
|
||||
*/
|
||||
export interface BaseConfig {
|
||||
environment: Environment;
|
||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||
service: {
|
||||
name: string;
|
||||
version: string;
|
||||
port: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Database configuration schema
|
||||
*/
|
||||
export const databaseConfigSchema = z.object({
|
||||
dragonfly: z.object({
|
||||
host: z.string().default('localhost'),
|
||||
port: z.number().default(6379),
|
||||
password: z.string().optional(),
|
||||
maxRetriesPerRequest: z.number().default(3)
|
||||
}),
|
||||
timescaleDB: z.object({
|
||||
host: z.string().default('localhost'),
|
||||
port: z.number().default(5432),
|
||||
database: z.string().default('stockbot'),
|
||||
user: z.string().default('postgres'),
|
||||
password: z.string().optional()
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Data provider configuration schema
|
||||
*/
|
||||
export const dataProviderSchema = z.object({
|
||||
name: z.string(),
|
||||
type: z.enum(['rest', 'websocket', 'file']),
|
||||
baseUrl: z.string().url().optional(),
|
||||
wsUrl: z.string().url().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
apiSecret: z.string().optional(),
|
||||
refreshInterval: z.number().optional(),
|
||||
rateLimits: z.object({
|
||||
maxRequestsPerMinute: z.number().optional(),
|
||||
maxRequestsPerSecond: z.number().optional()
|
||||
}).optional()
|
||||
});
|
||||
|
||||
export const dataProvidersConfigSchema = z.object({
|
||||
providers: z.array(dataProviderSchema),
|
||||
defaultProvider: z.string()
|
||||
});
|
||||
|
||||
/**
|
||||
* Risk management configuration schema
|
||||
*/
|
||||
export const riskConfigSchema = z.object({
|
||||
maxDrawdown: z.number().default(0.05),
|
||||
maxPositionSize: z.number().default(0.1),
|
||||
maxLeverage: z.number().default(1),
|
||||
stopLossDefault: z.number().default(0.02),
|
||||
takeProfitDefault: z.number().default(0.05)
|
||||
});
|
||||
|
||||
/**
|
||||
* Type definitions based on schemas
|
||||
*/
|
||||
export type DatabaseConfig = z.infer<typeof databaseConfigSchema>;
|
||||
export type DataProviderConfig = z.infer<typeof dataProviderSchema>;
|
||||
export type DataProvidersConfig = z.infer<typeof dataProvidersConfigSchema>;
|
||||
export type RiskConfig = z.infer<typeof riskConfigSchema>;
|
||||
10
libs/config/tsconfig.json
Normal file
10
libs/config/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
187
libs/http-client/README.md
Normal file
187
libs/http-client/README.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# @stock-bot/http-client
|
||||
|
||||
High-performance HTTP client for Stock Bot microservices built on Bun's native fetch API.
|
||||
|
||||
## Features
|
||||
|
||||
- **Ultra-fast performance** - Built on Bun's native fetch implementation
|
||||
- **Connection pooling** - Efficiently manages connections to prevent overwhelming servers
|
||||
- **Automatic retries** - Handles transient network errors with configurable retry strategies
|
||||
- **Timeout management** - Prevents requests from hanging indefinitely
|
||||
- **Streaming support** - Efficient handling of large responses
|
||||
- **TypeScript support** - Full type safety for all operations
|
||||
- **Metrics & monitoring** - Built-in performance statistics
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @stock-bot/http-client
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { BunHttpClient } from '@stock-bot/http-client';
|
||||
|
||||
// Create a client
|
||||
const client = new BunHttpClient({
|
||||
baseURL: 'https://api.example.com',
|
||||
timeout: 5000,
|
||||
retries: 3
|
||||
});
|
||||
|
||||
// Make requests
|
||||
async function fetchData() {
|
||||
try {
|
||||
// GET request
|
||||
const response = await client.get('/users');
|
||||
console.log(response.data);
|
||||
|
||||
// POST request with data
|
||||
const createResponse = await client.post('/users', {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com'
|
||||
});
|
||||
console.log(createResponse.data);
|
||||
} catch (error) {
|
||||
console.error('Request failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Close when done
|
||||
await client.close();
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
```typescript
|
||||
const client = new BunHttpClient({
|
||||
baseURL: 'https://api.example.com',
|
||||
timeout: 10000,
|
||||
retries: 3,
|
||||
retryDelay: 1000,
|
||||
maxConcurrency: 20,
|
||||
keepAlive: true,
|
||||
headers: {
|
||||
'User-Agent': 'StockBot/1.0',
|
||||
'Authorization': 'Bearer token'
|
||||
},
|
||||
validateStatus: (status) => status >= 200 && status < 300
|
||||
});
|
||||
```
|
||||
|
||||
## Connection Pooling
|
||||
|
||||
The HTTP client automatically manages connection pooling with smart limits:
|
||||
|
||||
```typescript
|
||||
// Get connection statistics
|
||||
const stats = client.getStats();
|
||||
console.log(`Active connections: ${stats.activeConnections}`);
|
||||
console.log(`Success rate: ${stats.successfulRequests / (stats.successfulRequests + stats.failedRequests)}`);
|
||||
console.log(`Average response time: ${stats.averageResponseTime}ms`);
|
||||
|
||||
// Health check
|
||||
const health = await client.healthCheck();
|
||||
if (health.healthy) {
|
||||
console.log('HTTP client is healthy');
|
||||
} else {
|
||||
console.log('HTTP client is degraded:', health.details);
|
||||
}
|
||||
```
|
||||
|
||||
## Event Handling
|
||||
|
||||
```typescript
|
||||
// Listen for specific events
|
||||
client.on('response', ({ host, response }) => {
|
||||
console.log(`Response from ${host}: ${response.status}`);
|
||||
});
|
||||
|
||||
client.on('error', ({ host, error }) => {
|
||||
console.log(`Error from ${host}: ${error.message}`);
|
||||
});
|
||||
|
||||
client.on('retryAttempt', (data) => {
|
||||
console.log(`Retrying request (${data.attempt}/${data.config.retries}): ${data.error.message}`);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### BunHttpClient
|
||||
|
||||
Main HTTP client class with connection pooling and retry support.
|
||||
|
||||
#### Methods
|
||||
|
||||
- `request(config)`: Make a request with full configuration options
|
||||
- `get(url, config?)`: Make a GET request
|
||||
- `post(url, data?, config?)`: Make a POST request with data
|
||||
- `put(url, data?, config?)`: Make a PUT request with data
|
||||
- `patch(url, data?, config?)`: Make a PATCH request with data
|
||||
- `delete(url, config?)`: Make a DELETE request
|
||||
- `head(url, config?)`: Make a HEAD request
|
||||
- `options(url, config?)`: Make an OPTIONS request
|
||||
- `getStats()`: Get connection statistics
|
||||
- `healthCheck()`: Check health of the client
|
||||
- `close()`: Close all connections
|
||||
- `setBaseURL(url)`: Update the base URL
|
||||
- `setDefaultHeaders(headers)`: Update default headers
|
||||
- `setTimeout(timeout)`: Update default timeout
|
||||
- `create(config)`: Create a new instance with different config
|
||||
|
||||
### Request Configuration
|
||||
|
||||
```typescript
|
||||
interface RequestConfig {
|
||||
url: string;
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
validateStatus?: (status: number) => boolean;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
```
|
||||
|
||||
### Response Object
|
||||
|
||||
```typescript
|
||||
interface HttpResponse<T = any> {
|
||||
data: T;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
config: RequestConfig;
|
||||
timing: {
|
||||
start: number;
|
||||
end: number;
|
||||
duration: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const response = await client.get('/resource-that-might-fail');
|
||||
processData(response.data);
|
||||
} catch (error) {
|
||||
if (error instanceof TimeoutError) {
|
||||
console.log('Request timed out');
|
||||
} else if (error instanceof RetryExhaustedError) {
|
||||
console.log(`Request failed after ${error.config.retries} retries`);
|
||||
} else if (error instanceof HttpClientError) {
|
||||
console.log(`HTTP error: ${error.status} - ${error.message}`);
|
||||
} else {
|
||||
console.log('Unexpected error', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
91
libs/http-client/examples/basic-usage.ts
Normal file
91
libs/http-client/examples/basic-usage.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Example usage of the @stock-bot/http-client library
|
||||
|
||||
import { BunHttpClient } from '../src';
|
||||
|
||||
async function main() {
|
||||
// Create a client instance
|
||||
const client = new BunHttpClient({
|
||||
baseURL: 'https://api.polygon.io',
|
||||
timeout: 10000,
|
||||
retries: 2,
|
||||
retryDelay: 500,
|
||||
headers: {
|
||||
'X-API-Key': process.env.POLYGON_API_KEY || 'demo'
|
||||
}
|
||||
});
|
||||
|
||||
// Add event listeners for monitoring
|
||||
client.on('response', ({host, response}) => {
|
||||
console.log(`📦 Response from ${host}: ${response.status} (${response.timing.duration.toFixed(2)}ms)`);
|
||||
});
|
||||
|
||||
client.on('error', ({host, error}) => {
|
||||
console.error(`❌ Error from ${host}: ${error.message}`);
|
||||
});
|
||||
|
||||
client.on('retryAttempt', ({attempt, config, delay}) => {
|
||||
console.warn(`⚠️ Retry ${attempt}/${config.retries} for ${config.url} in ${delay}ms`);
|
||||
});
|
||||
|
||||
try {
|
||||
console.log('Fetching market data...');
|
||||
|
||||
// Make a GET request
|
||||
const tickerResponse = await client.get('/v3/reference/tickers', {
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Found ${tickerResponse.data.results.length} tickers`);
|
||||
console.log(`First ticker: ${JSON.stringify(tickerResponse.data.results[0], null, 2)}`);
|
||||
|
||||
// Make a request that will fail
|
||||
try {
|
||||
await client.get('/non-existent-endpoint');
|
||||
} catch (error) {
|
||||
console.log('Expected error caught:', error.message);
|
||||
}
|
||||
|
||||
// Multiple parallel requests
|
||||
console.log('Making parallel requests...');
|
||||
const [aaplData, msftData, amznData] = await Promise.all([
|
||||
client.get('/v2/aggs/ticker/AAPL/range/1/day/2023-01-01/2023-01-15'),
|
||||
client.get('/v2/aggs/ticker/MSFT/range/1/day/2023-01-01/2023-01-15'),
|
||||
client.get('/v2/aggs/ticker/AMZN/range/1/day/2023-01-01/2023-01-15')
|
||||
]);
|
||||
|
||||
console.log('Parallel requests completed:');
|
||||
console.log(`- AAPL: ${aaplData.status}, data points: ${aaplData.data.results?.length || 0}`);
|
||||
console.log(`- MSFT: ${msftData.status}, data points: ${msftData.data.results?.length || 0}`);
|
||||
console.log(`- AMZN: ${amznData.status}, data points: ${amznData.data.results?.length || 0}`);
|
||||
|
||||
// Get client statistics
|
||||
const stats = client.getStats();
|
||||
console.log('\nClient Stats:');
|
||||
console.log(`- Active connections: ${stats.activeConnections}`);
|
||||
console.log(`- Total connections: ${stats.totalConnections}`);
|
||||
console.log(`- Successful requests: ${stats.successfulRequests}`);
|
||||
console.log(`- Failed requests: ${stats.failedRequests}`);
|
||||
console.log(`- Average response time: ${stats.averageResponseTime.toFixed(2)}ms`);
|
||||
console.log(`- Requests per second: ${stats.requestsPerSecond.toFixed(2)}`);
|
||||
|
||||
// Health check
|
||||
const health = await client.healthCheck();
|
||||
console.log(`\nClient health: ${health.healthy ? 'HEALTHY' : 'DEGRADED'}`);
|
||||
console.log(`Health details: ${JSON.stringify(health.details, null, 2)}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in example:', error);
|
||||
} finally {
|
||||
// Always close the client when done to clean up resources
|
||||
await client.close();
|
||||
console.log('HTTP client closed');
|
||||
}
|
||||
}
|
||||
|
||||
// Run the example
|
||||
main().catch(err => {
|
||||
console.error('Example failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
34
libs/http-client/package.json
Normal file
34
libs/http-client/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "@stock-bot/http-client",
|
||||
"version": "1.0.0",
|
||||
"description": "High-performance HTTP client for Stock Bot using Bun's native fetch",
|
||||
"main": "src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "bun test",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"typescript": "^5.3.0",
|
||||
"eslint": "^8.56.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"bun-types": "^1.2.15"
|
||||
},
|
||||
"keywords": [
|
||||
"http-client",
|
||||
"fetch",
|
||||
"bun",
|
||||
"performance",
|
||||
"connection-pooling"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
182
libs/http-client/src/BunHttpClient.test.ts
Normal file
182
libs/http-client/src/BunHttpClient.test.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||
import { BunHttpClient, HttpClientError, TimeoutError } from "../src";
|
||||
|
||||
// Mock GlobalFetch to avoid making real network requests
|
||||
const mockFetchSuccess = mock(() =>
|
||||
Promise.resolve(new Response(
|
||||
JSON.stringify({ result: "success" }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
))
|
||||
);
|
||||
|
||||
const mockFetchFailure = mock(() =>
|
||||
Promise.resolve(new Response(
|
||||
JSON.stringify({ error: "Not found" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } }
|
||||
))
|
||||
);
|
||||
|
||||
const mockFetchTimeout = mock(() => {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
const error = new Error("Timeout");
|
||||
error.name = "AbortError";
|
||||
reject(error);
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BunHttpClient", () => {
|
||||
let client: BunHttpClient;
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh client for each test
|
||||
client = new BunHttpClient({
|
||||
baseURL: "https://api.example.com",
|
||||
timeout: 1000,
|
||||
retries: 1
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup after each test
|
||||
await client.close();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("should make successful GET requests", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
const response = await client.get("/users");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data).toEqual({ result: "success" });
|
||||
expect(mockFetchSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should handle failed requests", async () => {
|
||||
global.fetch = mockFetchFailure;
|
||||
|
||||
try {
|
||||
await client.get("/missing");
|
||||
expect("Should have thrown").toBe("But didn't");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(HttpClientError);
|
||||
expect(error.status).toBe(404);
|
||||
}
|
||||
|
||||
expect(mockFetchFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should handle request timeouts", async () => {
|
||||
global.fetch = mockFetchTimeout;
|
||||
|
||||
try {
|
||||
await client.get("/slow");
|
||||
expect("Should have thrown").toBe("But didn't");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(TimeoutError);
|
||||
}
|
||||
});
|
||||
|
||||
test("should build full URLs properly", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
await client.get("/users/123");
|
||||
expect(mockFetchSuccess).toHaveBeenCalledWith(
|
||||
"https://api.example.com/users/123",
|
||||
expect.objectContaining({
|
||||
method: "GET"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should make POST requests with body", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
const data = { name: "John", email: "john@example.com" };
|
||||
await client.post("/users", data);
|
||||
|
||||
expect(mockFetchSuccess).toHaveBeenCalledWith(
|
||||
"https://api.example.com/users",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should provide convenience methods for all HTTP verbs", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
await client.get("/users");
|
||||
await client.post("/users", { name: "Test" });
|
||||
await client.put("/users/1", { name: "Updated" });
|
||||
await client.patch("/users/1", { status: "active" });
|
||||
await client.delete("/users/1");
|
||||
await client.head("/users");
|
||||
await client.options("/users");
|
||||
|
||||
expect(mockFetchSuccess).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
test("should merge config options correctly", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
await client.get("/users", {
|
||||
headers: { "X-Custom": "Value" },
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
expect(mockFetchSuccess).toHaveBeenCalledWith(
|
||||
"https://api.example.com/users",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"X-Custom": "Value"
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle absolute URLs", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
await client.get("https://other-api.com/endpoint");
|
||||
|
||||
expect(mockFetchSuccess).toHaveBeenCalledWith(
|
||||
"https://other-api.com/endpoint",
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
test("should update configuration", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
client.setBaseURL("https://new-api.com");
|
||||
client.setDefaultHeaders({ "Authorization": "Bearer token" });
|
||||
client.setTimeout(2000);
|
||||
|
||||
await client.get("/resource");
|
||||
|
||||
expect(mockFetchSuccess).toHaveBeenCalledWith(
|
||||
"https://new-api.com/resource",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Authorization": "Bearer token"
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should get connection stats", async () => {
|
||||
global.fetch = mockFetchSuccess;
|
||||
|
||||
await client.get("/users");
|
||||
const stats = client.getStats();
|
||||
|
||||
expect(stats).toHaveProperty("successfulRequests", 1);
|
||||
expect(stats).toHaveProperty("activeConnections");
|
||||
expect(stats).toHaveProperty("averageResponseTime");
|
||||
});
|
||||
});
|
||||
199
libs/http-client/src/BunHttpClient.ts
Normal file
199
libs/http-client/src/BunHttpClient.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { EventEmitter } from 'eventemitter3';
|
||||
import type {
|
||||
HttpClientConfig,
|
||||
RequestConfig,
|
||||
HttpResponse,
|
||||
ConnectionStats,
|
||||
HttpClientError,
|
||||
TimeoutError
|
||||
} from './types';
|
||||
import { ConnectionPool } from './ConnectionPool';
|
||||
import { RetryHandler } from './RetryHandler';
|
||||
|
||||
export class BunHttpClient extends EventEmitter {
|
||||
private connectionPool: ConnectionPool;
|
||||
private retryHandler: RetryHandler;
|
||||
private defaultConfig: Required<HttpClientConfig>;
|
||||
|
||||
constructor(config: HttpClientConfig = {}) {
|
||||
super();
|
||||
|
||||
this.defaultConfig = {
|
||||
baseURL: '',
|
||||
timeout: 30000,
|
||||
headers: {},
|
||||
retries: 3,
|
||||
retryDelay: 1000,
|
||||
maxConcurrency: 10,
|
||||
keepAlive: true,
|
||||
validateStatus: (status: number) => status < 400,
|
||||
...config
|
||||
};
|
||||
|
||||
this.connectionPool = new ConnectionPool({
|
||||
maxConnections: this.defaultConfig.maxConcurrency,
|
||||
maxConnectionsPerHost: Math.ceil(this.defaultConfig.maxConcurrency / 4),
|
||||
keepAlive: this.defaultConfig.keepAlive,
|
||||
maxIdleTime: 60000,
|
||||
connectionTimeout: this.defaultConfig.timeout
|
||||
});
|
||||
|
||||
this.retryHandler = new RetryHandler({
|
||||
maxRetries: this.defaultConfig.retries,
|
||||
baseDelay: this.defaultConfig.retryDelay,
|
||||
maxDelay: 30000,
|
||||
exponentialBackoff: true
|
||||
});
|
||||
|
||||
// Forward events from connection pool and retry handler
|
||||
this.connectionPool.on('response', (data) => this.emit('response', data));
|
||||
this.connectionPool.on('error', (data) => this.emit('error', data));
|
||||
this.retryHandler.on('retryAttempt', (data) => this.emit('retryAttempt', data));
|
||||
this.retryHandler.on('retrySuccess', (data) => this.emit('retrySuccess', data));
|
||||
this.retryHandler.on('retryExhausted', (data) => this.emit('retryExhausted', data));
|
||||
}
|
||||
|
||||
async request<T = any>(config: RequestConfig): Promise<HttpResponse<T>> {
|
||||
const fullConfig = this.mergeConfig(config);
|
||||
|
||||
return this.retryHandler.execute(async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// Add timing metadata
|
||||
fullConfig.metadata = {
|
||||
...fullConfig.metadata,
|
||||
startTime
|
||||
};
|
||||
|
||||
const response = await this.connectionPool.request(fullConfig);
|
||||
return response as HttpResponse<T>;
|
||||
|
||||
} catch (error: any) {
|
||||
// Convert fetch errors to our error types
|
||||
if (error.name === 'AbortError') {
|
||||
throw new TimeoutError(fullConfig, fullConfig.timeout || this.defaultConfig.timeout);
|
||||
}
|
||||
|
||||
// Re-throw as HttpClientError if not already
|
||||
if (!(error instanceof HttpClientError)) {
|
||||
const httpError = new HttpClientError(
|
||||
error.message || 'Request failed',
|
||||
error.code,
|
||||
error.status,
|
||||
error.response,
|
||||
fullConfig
|
||||
);
|
||||
throw httpError;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}, fullConfig);
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
async get<T = any>(url: string, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'GET' });
|
||||
}
|
||||
|
||||
async post<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'POST', body: data });
|
||||
}
|
||||
|
||||
async put<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'PUT', body: data });
|
||||
}
|
||||
|
||||
async patch<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'PATCH', body: data });
|
||||
}
|
||||
|
||||
async delete<T = any>(url: string, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'DELETE' });
|
||||
}
|
||||
|
||||
async head<T = any>(url: string, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'HEAD' });
|
||||
}
|
||||
|
||||
async options<T = any>(url: string, config?: Partial<RequestConfig>): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, url, method: 'OPTIONS' });
|
||||
}
|
||||
|
||||
private mergeConfig(config: RequestConfig): RequestConfig {
|
||||
return {
|
||||
timeout: this.defaultConfig.timeout,
|
||||
retries: this.defaultConfig.retries,
|
||||
headers: { ...this.defaultConfig.headers, ...config.headers },
|
||||
validateStatus: this.defaultConfig.validateStatus,
|
||||
url: this.buildUrl(config.url),
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
private buildUrl(url: string): string {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (this.defaultConfig.baseURL) {
|
||||
const baseURL = this.defaultConfig.baseURL.replace(/\/$/, '');
|
||||
const path = url.replace(/^\//, '');
|
||||
return `${baseURL}/${path}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
// Configuration methods
|
||||
setBaseURL(baseURL: string): void {
|
||||
this.defaultConfig.baseURL = baseURL;
|
||||
}
|
||||
|
||||
setDefaultHeaders(headers: Record<string, string>): void {
|
||||
this.defaultConfig.headers = { ...this.defaultConfig.headers, ...headers };
|
||||
}
|
||||
|
||||
setTimeout(timeout: number): void {
|
||||
this.defaultConfig.timeout = timeout;
|
||||
}
|
||||
|
||||
setMaxConcurrency(maxConcurrency: number): void {
|
||||
this.defaultConfig.maxConcurrency = maxConcurrency;
|
||||
}
|
||||
|
||||
// Statistics and monitoring
|
||||
getStats(): ConnectionStats {
|
||||
return this.connectionPool.getStats();
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<{ healthy: boolean; details: any }> {
|
||||
return this.connectionPool.healthCheck();
|
||||
}
|
||||
|
||||
// Lifecycle management
|
||||
async close(): Promise<void> {
|
||||
await this.connectionPool.close();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
// Create a new instance with different configuration
|
||||
create(config: HttpClientConfig): BunHttpClient {
|
||||
const mergedConfig = { ...this.defaultConfig, ...config };
|
||||
return new BunHttpClient(mergedConfig);
|
||||
}
|
||||
|
||||
// Interceptor-like functionality through events
|
||||
onRequest(handler: (config: RequestConfig) => RequestConfig | Promise<RequestConfig>): void {
|
||||
this.on('beforeRequest', handler);
|
||||
}
|
||||
|
||||
onResponse(handler: (response: HttpResponse) => HttpResponse | Promise<HttpResponse>): void {
|
||||
this.on('afterResponse', handler);
|
||||
}
|
||||
|
||||
onError(handler: (error: any) => void): void {
|
||||
this.on('requestError', handler);
|
||||
}
|
||||
}
|
||||
331
libs/http-client/src/ConnectionPool.ts
Normal file
331
libs/http-client/src/ConnectionPool.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import { EventEmitter } from 'eventemitter3';
|
||||
import type {
|
||||
ConnectionPoolConfig,
|
||||
ConnectionStats,
|
||||
QueuedRequest,
|
||||
RequestConfig
|
||||
} from './types';
|
||||
|
||||
export class ConnectionPool extends EventEmitter {
|
||||
private activeConnections = new Map<string, number>();
|
||||
private requestQueue: QueuedRequest[] = [];
|
||||
private stats = {
|
||||
totalConnections: 0,
|
||||
activeRequests: 0,
|
||||
successfulRequests: 0,
|
||||
failedRequests: 0,
|
||||
totalResponseTime: 0,
|
||||
requestCount: 0,
|
||||
startTime: Date.now()
|
||||
};
|
||||
private isProcessingQueue = false;
|
||||
private queueProcessor?: NodeJS.Timeout;
|
||||
|
||||
constructor(private config: ConnectionPoolConfig) {
|
||||
super();
|
||||
this.startQueueProcessor();
|
||||
}
|
||||
|
||||
async request(requestConfig: RequestConfig): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const host = this.extractHost(requestConfig.url);
|
||||
const queuedRequest: QueuedRequest = {
|
||||
id: this.generateRequestId(),
|
||||
config: requestConfig,
|
||||
resolve,
|
||||
reject,
|
||||
timestamp: Date.now(),
|
||||
retryCount: 0,
|
||||
host
|
||||
};
|
||||
|
||||
this.requestQueue.push(queuedRequest);
|
||||
this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isProcessingQueue || this.requestQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessingQueue = true;
|
||||
|
||||
while (this.requestQueue.length > 0) {
|
||||
const request = this.requestQueue.shift()!;
|
||||
|
||||
try {
|
||||
const currentConnections = this.activeConnections.get(request.host) || 0;
|
||||
|
||||
// Check per-host connection limits
|
||||
if (currentConnections >= this.config.maxConnectionsPerHost) {
|
||||
this.requestQueue.unshift(request);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check global connection limit
|
||||
const totalActive = Array.from(this.activeConnections.values())
|
||||
.reduce((sum, count) => sum + count, 0);
|
||||
|
||||
if (totalActive >= this.config.maxConnections) {
|
||||
this.requestQueue.unshift(request);
|
||||
break;
|
||||
}
|
||||
|
||||
// Execute the request
|
||||
this.executeRequest(request);
|
||||
|
||||
} catch (error) {
|
||||
request.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
this.isProcessingQueue = false;
|
||||
}
|
||||
|
||||
private async executeRequest(request: QueuedRequest): Promise<void> {
|
||||
const { host, config } = request;
|
||||
|
||||
// Increment active connections
|
||||
this.activeConnections.set(host, (this.activeConnections.get(host) || 0) + 1);
|
||||
this.stats.activeRequests++;
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// Build the full URL
|
||||
const url = this.buildUrl(config.url, config);
|
||||
|
||||
// Create abort controller for timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = config.timeout ? setTimeout(() => {
|
||||
controller.abort();
|
||||
}, config.timeout) : undefined;
|
||||
|
||||
// Make the fetch request
|
||||
const response = await fetch(url, {
|
||||
method: config.method || 'GET',
|
||||
headers: this.buildHeaders(config.headers),
|
||||
body: this.buildBody(config.body),
|
||||
signal: controller.signal,
|
||||
// Bun-specific optimizations
|
||||
keepalive: this.config.keepAlive,
|
||||
});
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// Check if response is considered successful
|
||||
const isSuccess = config.validateStatus
|
||||
? config.validateStatus(response.status)
|
||||
: response.status < 400;
|
||||
|
||||
if (!isSuccess) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Parse response data
|
||||
const data = await this.parseResponse(response);
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// Update stats
|
||||
this.updateStats(true, duration);
|
||||
|
||||
// Build response object
|
||||
const httpResponse = {
|
||||
data,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: this.parseHeaders(response.headers),
|
||||
config,
|
||||
timing: {
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
duration
|
||||
}
|
||||
};
|
||||
|
||||
this.emit('response', { host, response: httpResponse });
|
||||
request.resolve(httpResponse);
|
||||
|
||||
} catch (error: any) {
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
this.updateStats(false, duration);
|
||||
this.emit('error', { host, error, config });
|
||||
request.reject(error);
|
||||
|
||||
} finally {
|
||||
// Decrement active connections
|
||||
this.activeConnections.set(host, Math.max(0, (this.activeConnections.get(host) || 0) - 1));
|
||||
this.stats.activeRequests = Math.max(0, this.stats.activeRequests - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private buildUrl(url: string, config: RequestConfig): string {
|
||||
// If URL is already absolute, return as-is
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// If no base URL in config, assume it's a relative URL that needs a protocol
|
||||
if (!url.startsWith('/')) {
|
||||
url = '/' + url;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
private buildHeaders(headers?: Record<string, string>): HeadersInit {
|
||||
return {
|
||||
'User-Agent': 'StockBot-HttpClient/1.0',
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
};
|
||||
}
|
||||
|
||||
private buildBody(body: any): BodyInit | undefined {
|
||||
if (!body) return undefined;
|
||||
if (typeof body === 'string') return body;
|
||||
if (body instanceof FormData || body instanceof Blob) return body;
|
||||
if (body instanceof ArrayBuffer || body instanceof Uint8Array) return body;
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
private async parseResponse(response: Response): Promise<any> {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
if (contentType.includes('text/')) {
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
return await response.arrayBuffer();
|
||||
}
|
||||
|
||||
private parseHeaders(headers: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private extractHost(url: string): string {
|
||||
try {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.host;
|
||||
}
|
||||
return 'default';
|
||||
} catch {
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
private generateRequestId(): string {
|
||||
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
private updateStats(success: boolean, responseTime: number): void {
|
||||
this.stats.requestCount++;
|
||||
this.stats.totalResponseTime += responseTime;
|
||||
|
||||
if (success) {
|
||||
this.stats.successfulRequests++;
|
||||
} else {
|
||||
this.stats.failedRequests++;
|
||||
}
|
||||
}
|
||||
|
||||
private startQueueProcessor(): void {
|
||||
this.queueProcessor = setInterval(() => {
|
||||
if (this.requestQueue.length > 0) {
|
||||
this.processQueue();
|
||||
}
|
||||
}, 10); // Process queue every 10ms for better responsiveness
|
||||
}
|
||||
|
||||
getStats(): ConnectionStats {
|
||||
const totalActive = Array.from(this.activeConnections.values())
|
||||
.reduce((sum, count) => sum + count, 0);
|
||||
|
||||
const averageResponseTime = this.stats.requestCount > 0
|
||||
? this.stats.totalResponseTime / this.stats.requestCount
|
||||
: 0;
|
||||
|
||||
const utilization = this.config.maxConnections > 0
|
||||
? totalActive / this.config.maxConnections
|
||||
: 0;
|
||||
|
||||
const elapsedTimeSeconds = (Date.now() - this.stats.startTime) / 1000;
|
||||
const requestsPerSecond = elapsedTimeSeconds > 0
|
||||
? this.stats.requestCount / elapsedTimeSeconds
|
||||
: 0;
|
||||
|
||||
return {
|
||||
activeConnections: totalActive,
|
||||
totalConnections: this.stats.totalConnections,
|
||||
successfulRequests: this.stats.successfulRequests,
|
||||
failedRequests: this.stats.failedRequests,
|
||||
averageResponseTime,
|
||||
connectionPoolUtilization: utilization,
|
||||
requestsPerSecond
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Stop queue processor
|
||||
if (this.queueProcessor) {
|
||||
clearInterval(this.queueProcessor);
|
||||
this.queueProcessor = undefined;
|
||||
}
|
||||
|
||||
// Wait for pending requests to complete (with timeout)
|
||||
const timeout = 30000; // 30 seconds
|
||||
const startTime = Date.now();
|
||||
|
||||
while (this.requestQueue.length > 0 && Date.now() - startTime < timeout) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// Reject remaining requests
|
||||
while (this.requestQueue.length > 0) {
|
||||
const request = this.requestQueue.shift()!;
|
||||
request.reject(new Error('Connection pool closing'));
|
||||
}
|
||||
|
||||
// Clear connections
|
||||
this.activeConnections.clear();
|
||||
this.removeAllListeners();
|
||||
|
||||
this.emit('closed');
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<{ healthy: boolean; details: any }> {
|
||||
const stats = this.getStats();
|
||||
const queueSize = this.requestQueue.length;
|
||||
|
||||
const healthy =
|
||||
stats.connectionPoolUtilization < 0.9 && // Less than 90% utilization
|
||||
queueSize < 100 && // Queue not too large
|
||||
stats.averageResponseTime < 5000; // Average response time under 5 seconds
|
||||
|
||||
return {
|
||||
healthy,
|
||||
details: {
|
||||
stats,
|
||||
queueSize,
|
||||
activeHosts: Array.from(this.activeConnections.keys()),
|
||||
config: this.config
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
131
libs/http-client/src/RetryHandler.ts
Normal file
131
libs/http-client/src/RetryHandler.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { EventEmitter } from 'eventemitter3';
|
||||
import type {
|
||||
RetryConfig,
|
||||
RequestConfig,
|
||||
HttpResponse,
|
||||
HttpClientError,
|
||||
TimeoutError,
|
||||
RetryExhaustedError
|
||||
} from './types';
|
||||
|
||||
export class RetryHandler extends EventEmitter {
|
||||
private config: Required<RetryConfig>;
|
||||
|
||||
constructor(config: Partial<RetryConfig> = {}) {
|
||||
super();
|
||||
this.config = {
|
||||
maxRetries: 3,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 30000,
|
||||
exponentialBackoff: true,
|
||||
retryCondition: this.defaultRetryCondition,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
async execute<T>(
|
||||
operation: () => Promise<HttpResponse<T>>,
|
||||
requestConfig: RequestConfig
|
||||
): Promise<HttpResponse<T>> {
|
||||
let lastError: any;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt <= this.config.maxRetries) {
|
||||
try {
|
||||
const result = await operation();
|
||||
|
||||
if (attempt > 0) {
|
||||
this.emit('retrySuccess', {
|
||||
requestConfig,
|
||||
attempt,
|
||||
result
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
attempt++;
|
||||
|
||||
// Check if we should retry
|
||||
if (
|
||||
attempt > this.config.maxRetries ||
|
||||
!this.config.retryCondition(error)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate delay
|
||||
const delay = this.calculateDelay(attempt);
|
||||
|
||||
this.emit('retryAttempt', {
|
||||
requestConfig,
|
||||
attempt,
|
||||
error,
|
||||
delay
|
||||
});
|
||||
|
||||
// Wait before retry
|
||||
await this.delay(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
const finalError = new RetryExhaustedError(requestConfig, attempt, lastError);
|
||||
this.emit('retryExhausted', {
|
||||
requestConfig,
|
||||
attempts: attempt,
|
||||
finalError
|
||||
});
|
||||
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
private calculateDelay(attempt: number): number {
|
||||
if (!this.config.exponentialBackoff) {
|
||||
return this.config.baseDelay;
|
||||
}
|
||||
|
||||
// Exponential backoff with jitter
|
||||
const exponentialDelay = this.config.baseDelay * Math.pow(2, attempt - 1);
|
||||
const jitter = Math.random() * 0.1 * exponentialDelay; // 10% jitter
|
||||
const totalDelay = Math.min(exponentialDelay + jitter, this.config.maxDelay);
|
||||
|
||||
return Math.floor(totalDelay);
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private defaultRetryCondition(error: any): boolean {
|
||||
// Network errors
|
||||
if (error.code === 'ECONNRESET' ||
|
||||
error.code === 'ECONNREFUSED' ||
|
||||
error.code === 'ETIMEDOUT' ||
|
||||
error.code === 'ENOTFOUND') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HTTP status codes that should be retried
|
||||
const retryableStatuses = [408, 429, 500, 502, 503, 504];
|
||||
if (error.status && retryableStatuses.includes(error.status)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Timeout errors
|
||||
if (error instanceof TimeoutError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
updateConfig(config: Partial<RetryConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
getConfig(): RetryConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
}
|
||||
25
libs/http-client/src/index.ts
Normal file
25
libs/http-client/src/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Main exports
|
||||
export { BunHttpClient } from './BunHttpClient';
|
||||
export { ConnectionPool } from './ConnectionPool';
|
||||
export { RetryHandler } from './RetryHandler';
|
||||
|
||||
// Type exports
|
||||
export type {
|
||||
HttpClientConfig,
|
||||
RequestConfig,
|
||||
HttpResponse,
|
||||
ConnectionPoolConfig,
|
||||
ConnectionStats,
|
||||
QueuedRequest,
|
||||
RetryConfig
|
||||
} from './types';
|
||||
|
||||
// Error exports
|
||||
export {
|
||||
HttpClientError,
|
||||
TimeoutError,
|
||||
RetryExhaustedError
|
||||
} from './types';
|
||||
|
||||
// Default export for convenience
|
||||
export { BunHttpClient as default } from './BunHttpClient';
|
||||
104
libs/http-client/src/types.ts
Normal file
104
libs/http-client/src/types.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// Type definitions for the HTTP client
|
||||
export interface HttpClientConfig {
|
||||
baseURL?: string;
|
||||
timeout?: number;
|
||||
headers?: Record<string, string>;
|
||||
retries?: number;
|
||||
retryDelay?: number;
|
||||
maxConcurrency?: number;
|
||||
keepAlive?: boolean;
|
||||
validateStatus?: (status: number) => boolean;
|
||||
}
|
||||
|
||||
export interface RequestConfig {
|
||||
url: string;
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
metadata?: Record<string, any>;
|
||||
validateStatus?: (status: number) => boolean;
|
||||
}
|
||||
|
||||
export interface HttpResponse<T = any> {
|
||||
data: T;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
config: RequestConfig;
|
||||
timing: {
|
||||
start: number;
|
||||
end: number;
|
||||
duration: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConnectionPoolConfig {
|
||||
maxConnections: number;
|
||||
maxConnectionsPerHost: number;
|
||||
keepAlive: boolean;
|
||||
maxIdleTime: number;
|
||||
connectionTimeout: number;
|
||||
}
|
||||
|
||||
export interface ConnectionStats {
|
||||
activeConnections: number;
|
||||
totalConnections: number;
|
||||
successfulRequests: number;
|
||||
failedRequests: number;
|
||||
averageResponseTime: number;
|
||||
connectionPoolUtilization: number;
|
||||
requestsPerSecond: number;
|
||||
}
|
||||
|
||||
export interface QueuedRequest {
|
||||
id: string;
|
||||
config: RequestConfig;
|
||||
resolve: (value: HttpResponse) => void;
|
||||
reject: (error: any) => void;
|
||||
timestamp: number;
|
||||
retryCount: number;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export interface RetryConfig {
|
||||
maxRetries: number;
|
||||
baseDelay: number;
|
||||
maxDelay: number;
|
||||
exponentialBackoff: boolean;
|
||||
retryCondition?: (error: any) => boolean;
|
||||
}
|
||||
|
||||
export class HttpClientError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code?: string,
|
||||
public status?: number,
|
||||
public response?: any,
|
||||
public config?: RequestConfig
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HttpClientError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeoutError extends HttpClientError {
|
||||
constructor(config: RequestConfig, timeout: number) {
|
||||
super(`Request timeout after ${timeout}ms`, 'TIMEOUT', undefined, undefined, config);
|
||||
this.name = 'TimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RetryExhaustedError extends HttpClientError {
|
||||
constructor(config: RequestConfig, attempts: number, lastError: any) {
|
||||
super(
|
||||
`Request failed after ${attempts} attempts: ${lastError.message}`,
|
||||
'RETRY_EXHAUSTED',
|
||||
lastError.status,
|
||||
lastError.response,
|
||||
config
|
||||
);
|
||||
this.name = 'RetryExhaustedError';
|
||||
}
|
||||
}
|
||||
12
libs/http-client/tsconfig.json
Normal file
12
libs/http-client/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "**/*.test.ts"]
|
||||
}
|
||||
|
|
@ -9,9 +9,9 @@
|
|||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
}, "dependencies": {
|
||||
"@stock-bot/shared-types": "workspace:*",
|
||||
"@stock-bot/config": "workspace:*",
|
||||
"date-fns": "^2.30.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './dateUtils';
|
||||
export * from './financialUtils';
|
||||
export * from './logger';
|
||||
export * from './lokiClient';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
/**
|
||||
* Logger utility with consistent formatting and log levels
|
||||
* Supports console and Loki logging
|
||||
*/
|
||||
import { loggingConfig } from '@stock-bot/config';
|
||||
import { LokiClient } from './lokiClient';
|
||||
|
||||
// Singleton Loki client
|
||||
let lokiClient: LokiClient | null = null;
|
||||
|
||||
function getLokiClient(): LokiClient {
|
||||
if (!lokiClient) {
|
||||
lokiClient = new LokiClient(loggingConfig);
|
||||
}
|
||||
return lokiClient;
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
constructor(private serviceName: string, private level: LogLevel = LogLevel.INFO) {}
|
||||
|
||||
|
|
@ -26,22 +40,51 @@ export class Logger {
|
|||
const timestamp = new Date().toISOString();
|
||||
const levelStr = LogLevel[level].padEnd(5);
|
||||
|
||||
const logMessage = `[${timestamp}] [${levelStr}] [${this.serviceName}] ${message}`;
|
||||
const formattedArgs = args.length ? this.formatArgs(args) : '';
|
||||
const fullMessage = `${message}${formattedArgs}`;
|
||||
const logMessage = `[${timestamp}] [${levelStr}] [${this.serviceName}] ${fullMessage}`;
|
||||
|
||||
switch (level) {
|
||||
case LogLevel.ERROR:
|
||||
console.error(logMessage, ...args);
|
||||
break;
|
||||
case LogLevel.WARN:
|
||||
console.warn(logMessage, ...args);
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
console.info(logMessage, ...args);
|
||||
break;
|
||||
case LogLevel.DEBUG:
|
||||
default:
|
||||
console.debug(logMessage, ...args);
|
||||
break;
|
||||
// Console logging
|
||||
if (loggingConfig.console) {
|
||||
switch (level) {
|
||||
case LogLevel.ERROR:
|
||||
console.error(logMessage);
|
||||
break;
|
||||
case LogLevel.WARN:
|
||||
console.warn(logMessage);
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
console.info(logMessage);
|
||||
break;
|
||||
case LogLevel.DEBUG:
|
||||
default:
|
||||
console.debug(logMessage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Loki logging
|
||||
try {
|
||||
const loki = getLokiClient();
|
||||
loki.log(LogLevel[level].toLowerCase(), fullMessage, this.serviceName);
|
||||
} catch (error) {
|
||||
console.error('Failed to send log to Loki:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private formatArgs(args: any[]): string {
|
||||
try {
|
||||
return args.map(arg => {
|
||||
if (arg instanceof Error) {
|
||||
return ` ${arg.message}\n${arg.stack}`;
|
||||
} else if (typeof arg === 'object') {
|
||||
return ` ${JSON.stringify(arg)}`;
|
||||
} else {
|
||||
return ` ${arg}`;
|
||||
}
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
return ` [Error formatting log arguments: ${error}]`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
86
libs/utils/src/lokiClient.ts
Normal file
86
libs/utils/src/lokiClient.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Loki client for sending logs to Grafana Loki
|
||||
*/
|
||||
import { LoggingConfig } from '@stock-bot/config';
|
||||
|
||||
export class LokiClient {
|
||||
private batchQueue: any[] = [];
|
||||
private flushInterval: NodeJS.Timeout;
|
||||
private lokiUrl: string;
|
||||
private authHeader?: string;
|
||||
|
||||
constructor(private config: LoggingConfig) {
|
||||
const { host, port, username, password } = config.loki;
|
||||
|
||||
this.lokiUrl = `http://${host}:${port}/loki/api/v1/push`;
|
||||
|
||||
if (username && password) {
|
||||
const authString = Buffer.from(`${username}:${password}`).toString('base64');
|
||||
this.authHeader = `Basic ${authString}`;
|
||||
}
|
||||
|
||||
this.flushInterval = setInterval(
|
||||
() => this.flush(),
|
||||
config.loki.flushIntervalMs
|
||||
);
|
||||
}
|
||||
|
||||
async log(level: string, message: string, serviceName: string, labels: Record<string, string> = {}) {
|
||||
const timestamp = Date.now() * 1000000; // Loki expects nanoseconds
|
||||
|
||||
this.batchQueue.push({
|
||||
streams: [{
|
||||
stream: {
|
||||
level,
|
||||
service: serviceName,
|
||||
...this.config.loki.labels,
|
||||
...labels,
|
||||
},
|
||||
values: [[`${timestamp}`, message]],
|
||||
}],
|
||||
});
|
||||
|
||||
if (this.batchQueue.length >= this.config.loki.batchSize) {
|
||||
await this.flush();
|
||||
}
|
||||
}
|
||||
|
||||
private async flush() {
|
||||
if (this.batchQueue.length === 0) return;
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this.authHeader) {
|
||||
headers['Authorization'] = this.authHeader;
|
||||
}
|
||||
|
||||
const response = await fetch(this.lokiUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
streams: this.batchQueue.flatMap(batch => batch.streams),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to send logs to Loki: ${response.status} ${response.statusText}`);
|
||||
const text = await response.text();
|
||||
if (text) {
|
||||
console.error(text);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending logs to Loki:', error);
|
||||
} finally {
|
||||
this.batchQueue = [];
|
||||
}
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
clearInterval(this.flushInterval);
|
||||
return this.flush();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue