running prettier for cleanup

This commit is contained in:
Boki 2025-06-11 10:13:25 -04:00
parent fe7733aeb5
commit d85cd58acd
151 changed files with 29158 additions and 27966 deletions

32
.githooks/pre-commit Executable file
View file

@ -0,0 +1,32 @@
#!/bin/bash
# Pre-commit hook to run Prettier
echo "Running Prettier format check..."
# Check if prettier is available
if ! command -v prettier &> /dev/null; then
echo "Prettier not found. Please install it with: bun add -d prettier"
exit 1
fi
# Run prettier check on staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(ts|js|json)$')
if [[ -n "$STAGED_FILES" ]]; then
echo "Checking format for staged files..."
# Check if files are formatted
npx prettier --check $STAGED_FILES
if [[ $? -ne 0 ]]; then
echo ""
echo "❌ Some files are not formatted correctly."
echo "Please run 'npm run format' or 'bun run format' to fix formatting issues."
echo "Or run 'npx prettier --write $STAGED_FILES' to format just the staged files."
exit 1
fi
echo "✅ All staged files are properly formatted."
fi
exit 0

110
.prettierignore Normal file
View file

@ -0,0 +1,110 @@
# Dependencies
node_modules/
**/node_modules/
# Build outputs
dist/
build/
**/dist/
**/build/
.next/
**/.next/
# Cache directories
.turbo/
**/.turbo/
.cache/
**/.cache/
# Environment files
.env
.env.local
.env.production
.env.staging
**/.env*
# Lock files
package-lock.json
yarn.lock
bun.lockb
pnpm-lock.yaml
bun.lock
# Logs
*.log
logs/
**/logs/
# Database files
*.db
*.sqlite
*.sqlite3
# Temporary files
*.tmp
*.temp
.DS_Store
Thumbs.db
# IDE/Editor files
.vscode/settings.json
.idea/
*.swp
*.swo
# Angular specific
**/.angular/
# Test coverage
coverage/
**/coverage/
# Generated documentation
docs/generated/
**/docs/generated/
# Docker
Dockerfile*
docker-compose*.yml
# Scripts (might have different formatting requirements)
scripts/
**/scripts/
# Configuration files that should maintain their format
*.md
*.yml
*.yaml
*.toml
!package.json
!tsconfig*.json
!.prettierrc
# Git files
.gitignore
.dockerignore
# Binary and special files
*.ico
*.png
*.jpg
*.jpeg
*.gif
*.svg
*.woff
*.woff2
*.ttf
*.eot
# SQL files
*.sql
# Shell scripts
*.sh
*.bat
*.ps1
# Config files that need special formatting
bunfig.toml
angular.json
turbo.json

26
.prettierrc Normal file
View file

@ -0,0 +1,26 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"bracketSpacing": true,
"bracketSameLine": false,
"quoteProps": "as-needed",
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
"importOrder": [
"^(node:.*|fs|path|crypto|url|os|util|events|stream|buffer|child_process|cluster|http|https|net|tls|dgram|dns|readline|repl|vm|zlib|querystring|punycode|assert|timers|constants)$",
"<THIRD_PARTY_MODULES>",
"^@stock-bot/(.*)$",
"^@/(.*)$",
"^\\.\\.(?!/?$)",
"^\\.\\./?$",
"^\\./(?=.*/)(?!/?$)",
"^\\.(?!/?$)",
"^\\./?$"
],
"importOrderParserPlugins": ["typescript", "decorators-legacy"]
}

21
.vscode/settings.json vendored
View file

@ -20,5 +20,24 @@
"yaml.validate": true, "yaml.validate": true,
"yaml.completion": true, "yaml.completion": true,
"yaml.hover": true, "yaml.hover": true,
"yaml.format.enable": true "yaml.format.enable": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
} }

View file

@ -1,16 +1,19 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core'; import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router'; import {
import { provideHttpClient } from '@angular/common/http'; ApplicationConfig,
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; provideBrowserGlobalErrorListeners,
provideZonelessChangeDetection,
import { routes } from './app.routes'; } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
export const appConfig: ApplicationConfig = { import { provideRouter } from '@angular/router';
providers: [ import { routes } from './app.routes';
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(), export const appConfig: ApplicationConfig = {
provideRouter(routes), providers: [
provideHttpClient(), provideBrowserGlobalErrorListeners(),
provideAnimationsAsync() provideZonelessChangeDetection(),
] provideRouter(routes),
}; provideHttpClient(),
provideAnimationsAsync(),
],
};

View file

@ -1,18 +1,18 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { DashboardComponent } from './pages/dashboard/dashboard.component'; import { DashboardComponent } from './pages/dashboard/dashboard.component';
import { MarketDataComponent } from './pages/market-data/market-data.component'; import { MarketDataComponent } from './pages/market-data/market-data.component';
import { PortfolioComponent } from './pages/portfolio/portfolio.component'; import { PortfolioComponent } from './pages/portfolio/portfolio.component';
import { StrategiesComponent } from './pages/strategies/strategies.component'; import { RiskManagementComponent } from './pages/risk-management/risk-management.component';
import { RiskManagementComponent } from './pages/risk-management/risk-management.component'; import { SettingsComponent } from './pages/settings/settings.component';
import { SettingsComponent } from './pages/settings/settings.component'; import { StrategiesComponent } from './pages/strategies/strategies.component';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' }, { path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent }, { path: 'dashboard', component: DashboardComponent },
{ path: 'market-data', component: MarketDataComponent }, { path: 'market-data', component: MarketDataComponent },
{ path: 'portfolio', component: PortfolioComponent }, { path: 'portfolio', component: PortfolioComponent },
{ path: 'strategies', component: StrategiesComponent }, { path: 'strategies', component: StrategiesComponent },
{ path: 'risk-management', component: RiskManagementComponent }, { path: 'risk-management', component: RiskManagementComponent },
{ path: 'settings', component: SettingsComponent }, { path: 'settings', component: SettingsComponent },
{ path: '**', redirectTo: '/dashboard' } { path: '**', redirectTo: '/dashboard' },
]; ];

View file

@ -1,25 +1,25 @@
import { provideZonelessChangeDetection } from '@angular/core'; import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { App } from './app'; import { App } from './app';
describe('App', () => { describe('App', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: [provideZonelessChangeDetection()] providers: [provideZonelessChangeDetection()],
}).compileComponents(); }).compileComponents();
}); });
it('should create the app', () => { it('should create the app', () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance; const app = fixture.componentInstance;
expect(app).toBeTruthy(); expect(app).toBeTruthy();
}); });
it('should render title', () => { it('should render title', () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, trading-dashboard'); expect(compiled.querySelector('h1')?.textContent).toContain('Hello, trading-dashboard');
}); });
}); });

View file

@ -1,40 +1,40 @@
import { Component, signal } from '@angular/core'; import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router'; import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { MatButtonModule } from '@angular/material/button';
import { MatSidenavModule } from '@angular/material/sidenav'; import { MatChipsModule } from '@angular/material/chips';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button'; import { MatSidenavModule } from '@angular/material/sidenav';
import { MatIconModule } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar';
import { MatChipsModule } from '@angular/material/chips'; import { RouterOutlet } from '@angular/router';
import { SidebarComponent } from './components/sidebar/sidebar.component'; import { NotificationsComponent } from './components/notifications/notifications';
import { NotificationsComponent } from './components/notifications/notifications'; import { SidebarComponent } from './components/sidebar/sidebar.component';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [ imports: [
RouterOutlet, RouterOutlet,
CommonModule, CommonModule,
MatSidenavModule, MatSidenavModule,
MatToolbarModule, MatToolbarModule,
MatButtonModule, MatButtonModule,
MatIconModule, MatIconModule,
MatChipsModule, MatChipsModule,
SidebarComponent, SidebarComponent,
NotificationsComponent NotificationsComponent,
], ],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.css' styleUrl: './app.css',
}) })
export class App { export class App {
protected title = 'Trading Dashboard'; protected title = 'Trading Dashboard';
protected sidenavOpened = signal(true); protected sidenavOpened = signal(true);
toggleSidenav() { toggleSidenav() {
this.sidenavOpened.set(!this.sidenavOpened()); this.sidenavOpened.set(!this.sidenavOpened());
} }
onNavigationClick(route: string) { onNavigationClick(route: string) {
// Handle navigation if needed // Handle navigation if needed
console.log('Navigating to:', route); console.log('Navigating to:', route);
} }
} }

View file

@ -1,86 +1,94 @@
import { Component, inject } from '@angular/core'; import { CommonModule } from '@angular/common';
import { CommonModule } from '@angular/common'; import { Component, inject } from '@angular/core';
import { MatIconModule } from '@angular/material/icon'; import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatBadgeModule } from '@angular/material/badge'; import { MatDividerModule } from '@angular/material/divider';
import { MatMenuModule } from '@angular/material/menu'; import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list'; import { MatListModule } from '@angular/material/list';
import { MatDividerModule } from '@angular/material/divider'; import { MatMenuModule } from '@angular/material/menu';
import { NotificationService, Notification } from '../../services/notification.service'; import { Notification, NotificationService } from '../../services/notification.service';
@Component({ @Component({
selector: 'app-notifications', selector: 'app-notifications',
imports: [ imports: [
CommonModule, CommonModule,
MatIconModule, MatIconModule,
MatButtonModule, MatButtonModule,
MatBadgeModule, MatBadgeModule,
MatMenuModule, MatMenuModule,
MatListModule, MatListModule,
MatDividerModule MatDividerModule,
], ],
templateUrl: './notifications.html', templateUrl: './notifications.html',
styleUrl: './notifications.css' styleUrl: './notifications.css',
}) })
export class NotificationsComponent { export class NotificationsComponent {
private notificationService = inject(NotificationService); private notificationService = inject(NotificationService);
get notifications() { get notifications() {
return this.notificationService.notifications(); return this.notificationService.notifications();
} }
get unreadCount() { get unreadCount() {
return this.notificationService.unreadCount(); return this.notificationService.unreadCount();
} }
markAsRead(notification: Notification) { markAsRead(notification: Notification) {
this.notificationService.markAsRead(notification.id); this.notificationService.markAsRead(notification.id);
} }
markAllAsRead() { markAllAsRead() {
this.notificationService.markAllAsRead(); this.notificationService.markAllAsRead();
} }
clearNotification(notification: Notification) { clearNotification(notification: Notification) {
this.notificationService.clearNotification(notification.id); this.notificationService.clearNotification(notification.id);
} }
clearAll() { clearAll() {
this.notificationService.clearAllNotifications(); this.notificationService.clearAllNotifications();
} }
getNotificationIcon(type: string): string { getNotificationIcon(type: string): string {
switch (type) { switch (type) {
case 'error': return 'error'; case 'error':
case 'warning': return 'warning'; return 'error';
case 'success': return 'check_circle'; case 'warning':
case 'info': return 'warning';
default: return 'info'; case 'success':
} return 'check_circle';
} case 'info':
default:
getNotificationColor(type: string): string { return 'info';
switch (type) { }
case 'error': return 'text-red-600'; }
case 'warning': return 'text-yellow-600';
case 'success': return 'text-green-600'; getNotificationColor(type: string): string {
case 'info': switch (type) {
default: return 'text-blue-600'; case 'error':
} return 'text-red-600';
} case 'warning':
return 'text-yellow-600';
formatTime(timestamp: Date): string { case 'success':
const now = new Date(); return 'text-green-600';
const diff = now.getTime() - timestamp.getTime(); case 'info':
const minutes = Math.floor(diff / 60000); default:
return 'text-blue-600';
if (minutes < 1) return 'Just now'; }
if (minutes < 60) return `${minutes}m ago`; }
const hours = Math.floor(minutes / 60); formatTime(timestamp: Date): string {
if (hours < 24) return `${hours}h ago`; const now = new Date();
const diff = now.getTime() - timestamp.getTime();
const days = Math.floor(hours / 24); const minutes = Math.floor(diff / 60000);
return `${days}d ago`;
} if (minutes < 1) return 'Just now';
} if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
}

View file

@ -1,61 +1,56 @@
import { Component, input, output } from '@angular/core'; import { CommonModule } from '@angular/common';
import { CommonModule } from '@angular/common'; import { Component, input, output } from '@angular/core';
import { MatSidenavModule } from '@angular/material/sidenav'; import { MatButtonModule } from '@angular/material/button';
import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon';
import { MatIconModule } from '@angular/material/icon'; import { MatSidenavModule } from '@angular/material/sidenav';
import { Router, NavigationEnd } from '@angular/router'; import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs/operators'; import { filter } from 'rxjs/operators';
export interface NavigationItem { export interface NavigationItem {
label: string; label: string;
icon: string; icon: string;
route: string; route: string;
active?: boolean; active?: boolean;
} }
@Component({ @Component({
selector: 'app-sidebar', selector: 'app-sidebar',
standalone: true, standalone: true,
imports: [ imports: [CommonModule, MatSidenavModule, MatButtonModule, MatIconModule],
CommonModule, templateUrl: './sidebar.component.html',
MatSidenavModule, styleUrl: './sidebar.component.css',
MatButtonModule, })
MatIconModule export class SidebarComponent {
], opened = input<boolean>(true);
templateUrl: './sidebar.component.html', navigationItemClick = output<string>();
styleUrl: './sidebar.component.css'
}) protected navigationItems: NavigationItem[] = [
export class SidebarComponent { { label: 'Dashboard', icon: 'dashboard', route: '/dashboard', active: true },
opened = input<boolean>(true); { label: 'Market Data', icon: 'trending_up', route: '/market-data' },
navigationItemClick = output<string>(); { label: 'Portfolio', icon: 'account_balance_wallet', route: '/portfolio' },
{ label: 'Strategies', icon: 'psychology', route: '/strategies' },
protected navigationItems: NavigationItem[] = [ { label: 'Risk Management', icon: 'security', route: '/risk-management' },
{ label: 'Dashboard', icon: 'dashboard', route: '/dashboard', active: true }, { label: 'Settings', icon: 'settings', route: '/settings' },
{ label: 'Market Data', icon: 'trending_up', route: '/market-data' }, ];
{ label: 'Portfolio', icon: 'account_balance_wallet', route: '/portfolio' },
{ label: 'Strategies', icon: 'psychology', route: '/strategies' }, constructor(private router: Router) {
{ label: 'Risk Management', icon: 'security', route: '/risk-management' }, // Listen to route changes to update active state
{ label: 'Settings', icon: 'settings', route: '/settings' } this.router.events
]; .pipe(filter(event => event instanceof NavigationEnd))
.subscribe((event: NavigationEnd) => {
constructor(private router: Router) { this.updateActiveRoute(event.urlAfterRedirects);
// Listen to route changes to update active state });
this.router.events.pipe( }
filter(event => event instanceof NavigationEnd)
).subscribe((event: NavigationEnd) => { onNavigationClick(route: string) {
this.updateActiveRoute(event.urlAfterRedirects); this.navigationItemClick.emit(route);
}); this.router.navigate([route]);
} this.updateActiveRoute(route);
}
onNavigationClick(route: string) {
this.navigationItemClick.emit(route); private updateActiveRoute(currentRoute: string) {
this.router.navigate([route]); this.navigationItems.forEach(item => {
this.updateActiveRoute(route); item.active = item.route === currentRoute;
} });
}
private updateActiveRoute(currentRoute: string) { }
this.navigationItems.forEach(item => {
item.active = item.route === currentRoute;
});
}
}

View file

@ -1,44 +1,44 @@
import { Component, signal } from '@angular/core'; import { CommonModule } from '@angular/common';
import { CommonModule } from '@angular/common'; import { Component, signal } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs'; import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon';
import { MatIconModule } from '@angular/material/icon'; import { MatTableModule } from '@angular/material/table';
import { MatTableModule } from '@angular/material/table'; import { MatTabsModule } from '@angular/material/tabs';
export interface MarketDataItem { export interface MarketDataItem {
symbol: string; symbol: string;
price: number; price: number;
change: number; change: number;
changePercent: number; changePercent: number;
} }
@Component({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
MatCardModule, MatCardModule,
MatTabsModule, MatTabsModule,
MatButtonModule, MatButtonModule,
MatIconModule, MatIconModule,
MatTableModule MatTableModule,
], ],
templateUrl: './dashboard.component.html', templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.css' styleUrl: './dashboard.component.css',
}) })
export class DashboardComponent { export class DashboardComponent {
// Mock data for the dashboard // Mock data for the dashboard
protected marketData = signal<MarketDataItem[]>([ protected marketData = signal<MarketDataItem[]>([
{ symbol: 'AAPL', price: 192.53, change: 2.41, changePercent: 1.27 }, { symbol: 'AAPL', price: 192.53, change: 2.41, changePercent: 1.27 },
{ symbol: 'GOOGL', price: 138.21, change: -1.82, changePercent: -1.30 }, { symbol: 'GOOGL', price: 138.21, change: -1.82, changePercent: -1.3 },
{ symbol: 'MSFT', price: 378.85, change: 4.12, changePercent: 1.10 }, { symbol: 'MSFT', price: 378.85, change: 4.12, changePercent: 1.1 },
{ symbol: 'TSLA', price: 248.42, change: -3.21, changePercent: -1.28 }, { symbol: 'TSLA', price: 248.42, change: -3.21, changePercent: -1.28 },
]); ]);
protected portfolioValue = signal(125420.50); protected portfolioValue = signal(125420.5);
protected dayChange = signal(2341.20); protected dayChange = signal(2341.2);
protected dayChangePercent = signal(1.90); protected dayChangePercent = signal(1.9);
protected displayedColumns: string[] = ['symbol', 'price', 'change', 'changePercent']; protected displayedColumns: string[] = ['symbol', 'price', 'change', 'changePercent'];
} }

View file

@ -1,198 +1,205 @@
import { Component, signal, OnInit, OnDestroy, inject } from '@angular/core'; import { CommonModule } from '@angular/common';
import { CommonModule } from '@angular/common'; import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatButtonModule } from '@angular/material/button';
import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatTableModule } from '@angular/material/table'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTabsModule } from '@angular/material/tabs'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTableModule } from '@angular/material/table';
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs';
import { ApiService } from '../../services/api.service'; import { interval, Subscription } from 'rxjs';
import { WebSocketService } from '../../services/websocket.service'; import { ApiService } from '../../services/api.service';
import { interval, Subscription } from 'rxjs'; import { WebSocketService } from '../../services/websocket.service';
export interface ExtendedMarketData { export interface ExtendedMarketData {
symbol: string; symbol: string;
price: number; price: number;
change: number; change: number;
changePercent: number; changePercent: number;
volume: number; volume: number;
marketCap: string; marketCap: string;
high52Week: number; high52Week: number;
low52Week: number; low52Week: number;
} }
@Component({ @Component({
selector: 'app-market-data', selector: 'app-market-data',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
MatCardModule, MatCardModule,
MatButtonModule, MatButtonModule,
MatIconModule, MatIconModule,
MatTableModule, MatTableModule,
MatTabsModule, MatTabsModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
MatSnackBarModule MatSnackBarModule,
], ],
templateUrl: './market-data.component.html', templateUrl: './market-data.component.html',
styleUrl: './market-data.component.css' styleUrl: './market-data.component.css',
}) })
export class MarketDataComponent implements OnInit, OnDestroy { export class MarketDataComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService); private apiService = inject(ApiService);
private webSocketService = inject(WebSocketService); private webSocketService = inject(WebSocketService);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
private subscriptions: Subscription[] = []; private subscriptions: Subscription[] = [];
protected marketData = signal<ExtendedMarketData[]>([]); protected marketData = signal<ExtendedMarketData[]>([]);
protected currentTime = signal<string>(new Date().toLocaleTimeString()); protected currentTime = signal<string>(new Date().toLocaleTimeString());
protected isLoading = signal<boolean>(true); protected isLoading = signal<boolean>(true);
protected error = signal<string | null>(null); protected error = signal<string | null>(null);
protected displayedColumns: string[] = ['symbol', 'price', 'change', 'changePercent', 'volume', 'marketCap']; protected displayedColumns: string[] = [
ngOnInit() { 'symbol',
// Update time every second 'price',
const timeSubscription = interval(1000).subscribe(() => { 'change',
this.currentTime.set(new Date().toLocaleTimeString()); 'changePercent',
}); 'volume',
this.subscriptions.push(timeSubscription); 'marketCap',
];
// Load initial market data ngOnInit() {
this.loadMarketData(); // Update time every second
const timeSubscription = interval(1000).subscribe(() => {
// Subscribe to real-time market data updates this.currentTime.set(new Date().toLocaleTimeString());
const wsSubscription = this.webSocketService.getMarketDataUpdates().subscribe({ });
next: (update) => { this.subscriptions.push(timeSubscription);
this.updateMarketData(update);
}, // Load initial market data
error: (err) => { this.loadMarketData();
console.error('WebSocket market data error:', err);
} // Subscribe to real-time market data updates
}); const wsSubscription = this.webSocketService.getMarketDataUpdates().subscribe({
this.subscriptions.push(wsSubscription); next: update => {
this.updateMarketData(update);
// Fallback: Refresh market data every 30 seconds if WebSocket fails },
const dataSubscription = interval(30000).subscribe(() => { error: err => {
if (!this.webSocketService.isConnected()) { console.error('WebSocket market data error:', err);
this.loadMarketData(); },
} });
}); this.subscriptions.push(wsSubscription);
this.subscriptions.push(dataSubscription);
} // Fallback: Refresh market data every 30 seconds if WebSocket fails
const dataSubscription = interval(30000).subscribe(() => {
ngOnDestroy() { if (!this.webSocketService.isConnected()) {
this.subscriptions.forEach(sub => sub.unsubscribe()); this.loadMarketData();
} }
private loadMarketData() { });
this.apiService.getMarketData().subscribe({ this.subscriptions.push(dataSubscription);
next: (response) => { }
// Convert MarketData to ExtendedMarketData with mock extended properties
const extendedData: ExtendedMarketData[] = response.data.map(item => ({ ngOnDestroy() {
...item, this.subscriptions.forEach(sub => sub.unsubscribe());
marketCap: this.getMockMarketCap(item.symbol), }
high52Week: item.price * 1.3, // Mock 52-week high (30% above current) private loadMarketData() {
low52Week: item.price * 0.7 // Mock 52-week low (30% below current) this.apiService.getMarketData().subscribe({
})); next: response => {
// Convert MarketData to ExtendedMarketData with mock extended properties
this.marketData.set(extendedData); const extendedData: ExtendedMarketData[] = response.data.map(item => ({
this.isLoading.set(false); ...item,
this.error.set(null); marketCap: this.getMockMarketCap(item.symbol),
}, high52Week: item.price * 1.3, // Mock 52-week high (30% above current)
error: (err) => { low52Week: item.price * 0.7, // Mock 52-week low (30% below current)
console.error('Failed to load market data:', err); }));
this.error.set('Failed to load market data');
this.isLoading.set(false); this.marketData.set(extendedData);
this.snackBar.open('Failed to load market data', 'Dismiss', { duration: 5000 }); this.isLoading.set(false);
this.error.set(null);
// Use mock data as fallback },
this.marketData.set(this.getMockData()); error: err => {
} console.error('Failed to load market data:', err);
}); this.error.set('Failed to load market data');
} this.isLoading.set(false);
this.snackBar.open('Failed to load market data', 'Dismiss', { duration: 5000 });
private getMockMarketCap(symbol: string): string {
const marketCaps: { [key: string]: string } = { // Use mock data as fallback
'AAPL': '2.98T', this.marketData.set(this.getMockData());
'GOOGL': '1.78T', },
'MSFT': '3.08T', });
'TSLA': '789.2B', }
'AMZN': '1.59T'
}; private getMockMarketCap(symbol: string): string {
return marketCaps[symbol] || '1.00T'; const marketCaps: { [key: string]: string } = {
} AAPL: '2.98T',
GOOGL: '1.78T',
private getMockData(): ExtendedMarketData[] { MSFT: '3.08T',
return [ TSLA: '789.2B',
{ AMZN: '1.59T',
symbol: 'AAPL', };
price: 192.53, return marketCaps[symbol] || '1.00T';
change: 2.41, }
changePercent: 1.27,
volume: 45230000, private getMockData(): ExtendedMarketData[] {
marketCap: '2.98T', return [
high52Week: 199.62, {
low52Week: 164.08 symbol: 'AAPL',
}, price: 192.53,
{ change: 2.41,
symbol: 'GOOGL', changePercent: 1.27,
price: 2847.56, volume: 45230000,
change: -12.34, marketCap: '2.98T',
changePercent: -0.43, high52Week: 199.62,
volume: 12450000, low52Week: 164.08,
marketCap: '1.78T', },
high52Week: 3030.93, {
low52Week: 2193.62 symbol: 'GOOGL',
}, price: 2847.56,
{ change: -12.34,
symbol: 'MSFT', changePercent: -0.43,
price: 415.26, volume: 12450000,
change: 8.73, marketCap: '1.78T',
changePercent: 2.15, high52Week: 3030.93,
volume: 23180000, low52Week: 2193.62,
marketCap: '3.08T', },
high52Week: 468.35, {
low52Week: 309.45 symbol: 'MSFT',
}, price: 415.26,
{ change: 8.73,
symbol: 'TSLA', changePercent: 2.15,
price: 248.50, volume: 23180000,
change: -5.21, marketCap: '3.08T',
changePercent: -2.05, high52Week: 468.35,
volume: 89760000, low52Week: 309.45,
marketCap: '789.2B', },
high52Week: 299.29, {
low52Week: 152.37 symbol: 'TSLA',
}, price: 248.5,
{ change: -5.21,
symbol: 'AMZN', changePercent: -2.05,
price: 152.74, volume: 89760000,
change: 3.18, marketCap: '789.2B',
changePercent: 2.12, high52Week: 299.29,
volume: 34520000, low52Week: 152.37,
marketCap: '1.59T', },
high52Week: 170.17, {
low52Week: 118.35 symbol: 'AMZN',
} price: 152.74,
]; change: 3.18,
} changePercent: 2.12,
refreshData() { volume: 34520000,
this.isLoading.set(true); marketCap: '1.59T',
this.loadMarketData(); high52Week: 170.17,
} low52Week: 118.35,
},
private updateMarketData(update: any) { ];
const currentData = this.marketData(); }
const updatedData = currentData.map(item => { refreshData() {
if (item.symbol === update.symbol) { this.isLoading.set(true);
return { this.loadMarketData();
...item, }
price: update.price,
change: update.change, private updateMarketData(update: any) {
changePercent: update.changePercent, const currentData = this.marketData();
volume: update.volume const updatedData = currentData.map(item => {
}; if (item.symbol === update.symbol) {
} return {
return item; ...item,
}); price: update.price,
this.marketData.set(updatedData); change: update.change,
} changePercent: update.changePercent,
} volume: update.volume,
};
}
return item;
});
this.marketData.set(updatedData);
}
}

View file

@ -1,159 +1,168 @@
import { Component, signal, OnInit, OnDestroy, inject } from '@angular/core'; import { CommonModule } from '@angular/common';
import { CommonModule } from '@angular/common'; import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon';
import { MatTableModule } from '@angular/material/table'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar'; import { MatTableModule } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { ApiService } from '../../services/api.service'; import { interval, Subscription } from 'rxjs';
import { interval, Subscription } from 'rxjs'; import { ApiService } from '../../services/api.service';
export interface Position { export interface Position {
symbol: string; symbol: string;
quantity: number; quantity: number;
avgPrice: number; avgPrice: number;
currentPrice: number; currentPrice: number;
marketValue: number; marketValue: number;
unrealizedPnL: number; unrealizedPnL: number;
unrealizedPnLPercent: number; unrealizedPnLPercent: number;
dayChange: number; dayChange: number;
dayChangePercent: number; dayChangePercent: number;
} }
export interface PortfolioSummary { export interface PortfolioSummary {
totalValue: number; totalValue: number;
totalCost: number; totalCost: number;
totalPnL: number; totalPnL: number;
totalPnLPercent: number; totalPnLPercent: number;
dayChange: number; dayChange: number;
dayChangePercent: number; dayChangePercent: number;
cash: number; cash: number;
positionsCount: number; positionsCount: number;
} }
@Component({ @Component({
selector: 'app-portfolio', selector: 'app-portfolio',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
MatCardModule, MatCardModule,
MatIconModule, MatIconModule,
MatButtonModule, MatButtonModule,
MatTableModule, MatTableModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
MatSnackBarModule, MatSnackBarModule,
MatTabsModule MatTabsModule,
], ],
templateUrl: './portfolio.component.html', templateUrl: './portfolio.component.html',
styleUrl: './portfolio.component.css' styleUrl: './portfolio.component.css',
}) })
export class PortfolioComponent implements OnInit, OnDestroy { export class PortfolioComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService); private apiService = inject(ApiService);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
private subscriptions: Subscription[] = []; private subscriptions: Subscription[] = [];
protected portfolioSummary = signal<PortfolioSummary>({ protected portfolioSummary = signal<PortfolioSummary>({
totalValue: 0, totalValue: 0,
totalCost: 0, totalCost: 0,
totalPnL: 0, totalPnL: 0,
totalPnLPercent: 0, totalPnLPercent: 0,
dayChange: 0, dayChange: 0,
dayChangePercent: 0, dayChangePercent: 0,
cash: 0, cash: 0,
positionsCount: 0 positionsCount: 0,
}); });
protected positions = signal<Position[]>([]); protected positions = signal<Position[]>([]);
protected isLoading = signal<boolean>(true); protected isLoading = signal<boolean>(true);
protected error = signal<string | null>(null); protected error = signal<string | null>(null);
protected displayedColumns = ['symbol', 'quantity', 'avgPrice', 'currentPrice', 'marketValue', 'unrealizedPnL', 'dayChange']; protected displayedColumns = [
'symbol',
ngOnInit() { 'quantity',
this.loadPortfolioData(); 'avgPrice',
'currentPrice',
// Refresh portfolio data every 30 seconds 'marketValue',
const portfolioSubscription = interval(30000).subscribe(() => { 'unrealizedPnL',
this.loadPortfolioData(); 'dayChange',
}); ];
this.subscriptions.push(portfolioSubscription);
} ngOnInit() {
this.loadPortfolioData();
ngOnDestroy() {
this.subscriptions.forEach(sub => sub.unsubscribe()); // Refresh portfolio data every 30 seconds
} const portfolioSubscription = interval(30000).subscribe(() => {
this.loadPortfolioData();
private loadPortfolioData() { });
// Since we don't have a portfolio endpoint yet, let's create mock data this.subscriptions.push(portfolioSubscription);
// In a real implementation, this would call this.apiService.getPortfolio() }
setTimeout(() => { ngOnDestroy() {
const mockPositions: Position[] = [ this.subscriptions.forEach(sub => sub.unsubscribe());
{ }
symbol: 'AAPL',
quantity: 100, private loadPortfolioData() {
avgPrice: 180.50, // Since we don't have a portfolio endpoint yet, let's create mock data
currentPrice: 192.53, // In a real implementation, this would call this.apiService.getPortfolio()
marketValue: 19253,
unrealizedPnL: 1203, setTimeout(() => {
unrealizedPnLPercent: 6.67, const mockPositions: Position[] = [
dayChange: 241, {
dayChangePercent: 1.27 symbol: 'AAPL',
}, quantity: 100,
{ avgPrice: 180.5,
symbol: 'MSFT', currentPrice: 192.53,
quantity: 50, marketValue: 19253,
avgPrice: 400.00, unrealizedPnL: 1203,
currentPrice: 415.26, unrealizedPnLPercent: 6.67,
marketValue: 20763, dayChange: 241,
unrealizedPnL: 763, dayChangePercent: 1.27,
unrealizedPnLPercent: 3.82, },
dayChange: 436.50, {
dayChangePercent: 2.15 symbol: 'MSFT',
}, quantity: 50,
{ avgPrice: 400.0,
symbol: 'GOOGL', currentPrice: 415.26,
quantity: 10, marketValue: 20763,
avgPrice: 2900.00, unrealizedPnL: 763,
currentPrice: 2847.56, unrealizedPnLPercent: 3.82,
marketValue: 28475.60, dayChange: 436.5,
unrealizedPnL: -524.40, dayChangePercent: 2.15,
unrealizedPnLPercent: -1.81, },
dayChange: -123.40, {
dayChangePercent: -0.43 symbol: 'GOOGL',
} quantity: 10,
]; avgPrice: 2900.0,
currentPrice: 2847.56,
const summary: PortfolioSummary = { marketValue: 28475.6,
totalValue: mockPositions.reduce((sum, pos) => sum + pos.marketValue, 0) + 25000, // + cash unrealizedPnL: -524.4,
totalCost: mockPositions.reduce((sum, pos) => sum + (pos.avgPrice * pos.quantity), 0), unrealizedPnLPercent: -1.81,
totalPnL: mockPositions.reduce((sum, pos) => sum + pos.unrealizedPnL, 0), dayChange: -123.4,
totalPnLPercent: 0, dayChangePercent: -0.43,
dayChange: mockPositions.reduce((sum, pos) => sum + pos.dayChange, 0), },
dayChangePercent: 0, ];
cash: 25000,
positionsCount: mockPositions.length const summary: PortfolioSummary = {
}; totalValue: mockPositions.reduce((sum, pos) => sum + pos.marketValue, 0) + 25000, // + cash
totalCost: mockPositions.reduce((sum, pos) => sum + pos.avgPrice * pos.quantity, 0),
summary.totalPnLPercent = (summary.totalPnL / summary.totalCost) * 100; totalPnL: mockPositions.reduce((sum, pos) => sum + pos.unrealizedPnL, 0),
summary.dayChangePercent = (summary.dayChange / (summary.totalValue - summary.dayChange)) * 100; totalPnLPercent: 0,
dayChange: mockPositions.reduce((sum, pos) => sum + pos.dayChange, 0),
this.positions.set(mockPositions); dayChangePercent: 0,
this.portfolioSummary.set(summary); cash: 25000,
this.isLoading.set(false); positionsCount: mockPositions.length,
this.error.set(null); };
}, 1000);
} summary.totalPnLPercent = (summary.totalPnL / summary.totalCost) * 100;
summary.dayChangePercent =
refreshData() { (summary.dayChange / (summary.totalValue - summary.dayChange)) * 100;
this.isLoading.set(true);
this.loadPortfolioData(); this.positions.set(mockPositions);
} this.portfolioSummary.set(summary);
this.isLoading.set(false);
getPnLColor(value: number): string { this.error.set(null);
if (value > 0) return 'text-green-600'; }, 1000);
if (value < 0) return 'text-red-600'; }
return 'text-gray-600';
} refreshData() {
} this.isLoading.set(true);
this.loadPortfolioData();
}
getPnLColor(value: number): string {
if (value > 0) return 'text-green-600';
if (value < 0) return 'text-red-600';
return 'text-gray-600';
}
}

View file

@ -1,135 +1,139 @@
import { Component, signal, OnInit, OnDestroy, inject } from '@angular/core'; import { CommonModule } from '@angular/common';
import { CommonModule } from '@angular/common'; import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button';
import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card';
import { MatTableModule } from '@angular/material/table'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { MatTableModule } from '@angular/material/table';
import { ApiService, RiskThresholds, RiskEvaluation } from '../../services/api.service'; import { interval, Subscription } from 'rxjs';
import { interval, Subscription } from 'rxjs'; import { ApiService, RiskEvaluation, RiskThresholds } from '../../services/api.service';
@Component({ @Component({
selector: 'app-risk-management', selector: 'app-risk-management',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
MatCardModule, MatCardModule,
MatIconModule, MatIconModule,
MatButtonModule, MatButtonModule,
MatTableModule, MatTableModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
MatSnackBarModule, MatSnackBarModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
ReactiveFormsModule ReactiveFormsModule,
], ],
templateUrl: './risk-management.component.html', templateUrl: './risk-management.component.html',
styleUrl: './risk-management.component.css' styleUrl: './risk-management.component.css',
}) })
export class RiskManagementComponent implements OnInit, OnDestroy { export class RiskManagementComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService); private apiService = inject(ApiService);
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
private subscriptions: Subscription[] = []; private subscriptions: Subscription[] = [];
protected riskThresholds = signal<RiskThresholds | null>(null); protected riskThresholds = signal<RiskThresholds | null>(null);
protected riskHistory = signal<RiskEvaluation[]>([]); protected riskHistory = signal<RiskEvaluation[]>([]);
protected isLoading = signal<boolean>(true); protected isLoading = signal<boolean>(true);
protected isSaving = signal<boolean>(false); protected isSaving = signal<boolean>(false);
protected error = signal<string | null>(null); protected error = signal<string | null>(null);
protected thresholdsForm: FormGroup; protected thresholdsForm: FormGroup;
protected displayedColumns = ['symbol', 'positionValue', 'riskLevel', 'violations', 'timestamp']; protected displayedColumns = ['symbol', 'positionValue', 'riskLevel', 'violations', 'timestamp'];
constructor() { constructor() {
this.thresholdsForm = this.fb.group({ this.thresholdsForm = this.fb.group({
maxPositionSize: [0, [Validators.required, Validators.min(0)]], maxPositionSize: [0, [Validators.required, Validators.min(0)]],
maxDailyLoss: [0, [Validators.required, Validators.min(0)]], maxDailyLoss: [0, [Validators.required, Validators.min(0)]],
maxPortfolioRisk: [0, [Validators.required, Validators.min(0), Validators.max(1)]], maxPortfolioRisk: [0, [Validators.required, Validators.min(0), Validators.max(1)]],
volatilityLimit: [0, [Validators.required, Validators.min(0), Validators.max(1)]] volatilityLimit: [0, [Validators.required, Validators.min(0), Validators.max(1)]],
}); });
} }
ngOnInit() { ngOnInit() {
this.loadRiskThresholds(); this.loadRiskThresholds();
this.loadRiskHistory(); this.loadRiskHistory();
// Refresh risk history every 30 seconds // Refresh risk history every 30 seconds
const historySubscription = interval(30000).subscribe(() => { const historySubscription = interval(30000).subscribe(() => {
this.loadRiskHistory(); this.loadRiskHistory();
}); });
this.subscriptions.push(historySubscription); this.subscriptions.push(historySubscription);
} }
ngOnDestroy() { ngOnDestroy() {
this.subscriptions.forEach(sub => sub.unsubscribe()); this.subscriptions.forEach(sub => sub.unsubscribe());
} }
private loadRiskThresholds() { private loadRiskThresholds() {
this.apiService.getRiskThresholds().subscribe({ this.apiService.getRiskThresholds().subscribe({
next: (response) => { next: response => {
this.riskThresholds.set(response.data); this.riskThresholds.set(response.data);
this.thresholdsForm.patchValue(response.data); this.thresholdsForm.patchValue(response.data);
this.isLoading.set(false); this.isLoading.set(false);
this.error.set(null); this.error.set(null);
}, },
error: (err) => { error: err => {
console.error('Failed to load risk thresholds:', err); console.error('Failed to load risk thresholds:', err);
this.error.set('Failed to load risk thresholds'); this.error.set('Failed to load risk thresholds');
this.isLoading.set(false); this.isLoading.set(false);
this.snackBar.open('Failed to load risk thresholds', 'Dismiss', { duration: 5000 }); this.snackBar.open('Failed to load risk thresholds', 'Dismiss', { duration: 5000 });
} },
}); });
} }
private loadRiskHistory() { private loadRiskHistory() {
this.apiService.getRiskHistory().subscribe({ this.apiService.getRiskHistory().subscribe({
next: (response) => { next: response => {
this.riskHistory.set(response.data); this.riskHistory.set(response.data);
}, },
error: (err) => { error: err => {
console.error('Failed to load risk history:', err); console.error('Failed to load risk history:', err);
this.snackBar.open('Failed to load risk history', 'Dismiss', { duration: 3000 }); this.snackBar.open('Failed to load risk history', 'Dismiss', { duration: 3000 });
} },
}); });
} }
saveThresholds() { saveThresholds() {
if (this.thresholdsForm.valid) { if (this.thresholdsForm.valid) {
this.isSaving.set(true); this.isSaving.set(true);
const thresholds = this.thresholdsForm.value as RiskThresholds; const thresholds = this.thresholdsForm.value as RiskThresholds;
this.apiService.updateRiskThresholds(thresholds).subscribe({ this.apiService.updateRiskThresholds(thresholds).subscribe({
next: (response) => { next: response => {
this.riskThresholds.set(response.data); this.riskThresholds.set(response.data);
this.isSaving.set(false); this.isSaving.set(false);
this.snackBar.open('Risk thresholds updated successfully', 'Dismiss', { duration: 3000 }); this.snackBar.open('Risk thresholds updated successfully', 'Dismiss', { duration: 3000 });
}, },
error: (err) => { error: err => {
console.error('Failed to save risk thresholds:', err); console.error('Failed to save risk thresholds:', err);
this.isSaving.set(false); this.isSaving.set(false);
this.snackBar.open('Failed to save risk thresholds', 'Dismiss', { duration: 5000 }); this.snackBar.open('Failed to save risk thresholds', 'Dismiss', { duration: 5000 });
} },
}); });
} }
} }
refreshData() { refreshData() {
this.isLoading.set(true); this.isLoading.set(true);
this.loadRiskThresholds(); this.loadRiskThresholds();
this.loadRiskHistory(); this.loadRiskHistory();
} }
getRiskLevelColor(level: string): string { getRiskLevelColor(level: string): string {
switch (level) { switch (level) {
case 'LOW': return 'text-green-600'; case 'LOW':
case 'MEDIUM': return 'text-yellow-600'; return 'text-green-600';
case 'HIGH': return 'text-red-600'; case 'MEDIUM':
default: return 'text-gray-600'; return 'text-yellow-600';
} case 'HIGH':
} return 'text-red-600';
} default:
return 'text-gray-600';
}
}
}

View file

@ -1,13 +1,13 @@
import { Component } from '@angular/core'; import { CommonModule } from '@angular/common';
import { CommonModule } from '@angular/common'; import { Component } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
standalone: true, standalone: true,
imports: [CommonModule, MatCardModule, MatIconModule], imports: [CommonModule, MatCardModule, MatIconModule],
templateUrl: './settings.component.html', templateUrl: './settings.component.html',
styleUrl: './settings.component.css' styleUrl: './settings.component.css',
}) })
export class SettingsComponent {} export class SettingsComponent {}

View file

@ -1,165 +1,165 @@
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { CommonModule } from '@angular/common';
import { CommonModule } from '@angular/common'; import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { BacktestResult } from '../../../services/strategy.service'; import { Chart, ChartOptions } from 'chart.js/auto';
import { Chart, ChartOptions } from 'chart.js/auto'; import { BacktestResult } from '../../../services/strategy.service';
@Component({ @Component({
selector: 'app-drawdown-chart', selector: 'app-drawdown-chart',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
template: ` template: `
<div class="drawdown-chart-container"> <div class="drawdown-chart-container">
<canvas #drawdownChart></canvas> <canvas #drawdownChart></canvas>
</div> </div>
`, `,
styles: ` styles: `
.drawdown-chart-container { .drawdown-chart-container {
width: 100%; width: 100%;
height: 300px; height: 300px;
margin-bottom: 20px; margin-bottom: 20px;
} }
` `,
}) })
export class DrawdownChartComponent implements OnChanges { export class DrawdownChartComponent implements OnChanges {
@Input() backtestResult?: BacktestResult; @Input() backtestResult?: BacktestResult;
private chart?: Chart; private chart?: Chart;
private chartElement?: HTMLCanvasElement; private chartElement?: HTMLCanvasElement;
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes['backtestResult'] && this.backtestResult) { if (changes['backtestResult'] && this.backtestResult) {
this.renderChart(); this.renderChart();
} }
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.chartElement = document.querySelector('canvas') as HTMLCanvasElement; this.chartElement = document.querySelector('canvas') as HTMLCanvasElement;
if (this.backtestResult) { if (this.backtestResult) {
this.renderChart(); this.renderChart();
} }
} }
private renderChart(): void { private renderChart(): void {
if (!this.chartElement || !this.backtestResult) return; if (!this.chartElement || !this.backtestResult) return;
// Clean up previous chart if it exists // Clean up previous chart if it exists
if (this.chart) { if (this.chart) {
this.chart.destroy(); this.chart.destroy();
} }
// Calculate drawdown series from daily returns // Calculate drawdown series from daily returns
const drawdownData = this.calculateDrawdownSeries(this.backtestResult); const drawdownData = this.calculateDrawdownSeries(this.backtestResult);
// Create chart // Create chart
this.chart = new Chart(this.chartElement, { this.chart = new Chart(this.chartElement, {
type: 'line', type: 'line',
data: { data: {
labels: drawdownData.dates.map(date => this.formatDate(date)), labels: drawdownData.dates.map(date => this.formatDate(date)),
datasets: [ datasets: [
{ {
label: 'Drawdown', label: 'Drawdown',
data: drawdownData.drawdowns, data: drawdownData.drawdowns,
borderColor: 'rgba(255, 99, 132, 1)', borderColor: 'rgba(255, 99, 132, 1)',
backgroundColor: 'rgba(255, 99, 132, 0.2)', backgroundColor: 'rgba(255, 99, 132, 0.2)',
fill: true, fill: true,
tension: 0.3, tension: 0.3,
borderWidth: 2 borderWidth: 2,
} },
] ],
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
scales: { scales: {
x: { x: {
ticks: { ticks: {
maxTicksLimit: 12, maxTicksLimit: 12,
maxRotation: 0, maxRotation: 0,
minRotation: 0 minRotation: 0,
}, },
grid: { grid: {
display: false display: false,
} },
}, },
y: { y: {
ticks: { ticks: {
callback: function(value) { callback: function (value) {
return (value * 100).toFixed(1) + '%'; return (value * 100).toFixed(1) + '%';
} },
}, },
grid: { grid: {
color: 'rgba(200, 200, 200, 0.2)' color: 'rgba(200, 200, 200, 0.2)',
}, },
min: -0.05, // Show at least 5% drawdown for context min: -0.05, // Show at least 5% drawdown for context
suggestedMax: 0.01 suggestedMax: 0.01,
} },
}, },
plugins: { plugins: {
tooltip: { tooltip: {
mode: 'index', mode: 'index',
intersect: false, intersect: false,
callbacks: { callbacks: {
label: function(context) { label: function (context) {
let label = context.dataset.label || ''; let label = context.dataset.label || '';
if (label) { if (label) {
label += ': '; label += ': ';
} }
if (context.parsed.y !== null) { if (context.parsed.y !== null) {
label += (context.parsed.y * 100).toFixed(2) + '%'; label += (context.parsed.y * 100).toFixed(2) + '%';
} }
return label; return label;
} },
} },
}, },
legend: { legend: {
position: 'top', position: 'top',
} },
} },
} as ChartOptions } as ChartOptions,
}); });
} }
private calculateDrawdownSeries(result: BacktestResult): { private calculateDrawdownSeries(result: BacktestResult): {
dates: Date[]; dates: Date[];
drawdowns: number[]; drawdowns: number[];
} { } {
const dates: Date[] = []; const dates: Date[] = [];
const drawdowns: number[] = []; const drawdowns: number[] = [];
// Sort daily returns by date // Sort daily returns by date
const sortedReturns = [...result.dailyReturns].sort( const sortedReturns = [...result.dailyReturns].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
); );
// Calculate equity curve // Calculate equity curve
let equity = 1; let equity = 1;
const equityCurve: number[] = []; const equityCurve: number[] = [];
for (const daily of sortedReturns) { for (const daily of sortedReturns) {
equity *= (1 + daily.return); equity *= 1 + daily.return;
equityCurve.push(equity); equityCurve.push(equity);
dates.push(new Date(daily.date)); dates.push(new Date(daily.date));
} }
// Calculate running maximum (high water mark) // Calculate running maximum (high water mark)
let hwm = equityCurve[0]; let hwm = equityCurve[0];
for (let i = 0; i < equityCurve.length; i++) { for (let i = 0; i < equityCurve.length; i++) {
// Update high water mark // Update high water mark
hwm = Math.max(hwm, equityCurve[i]); hwm = Math.max(hwm, equityCurve[i]);
// Calculate drawdown as percentage from high water mark // Calculate drawdown as percentage from high water mark
const drawdown = (equityCurve[i] / hwm) - 1; const drawdown = equityCurve[i] / hwm - 1;
drawdowns.push(drawdown); drawdowns.push(drawdown);
} }
return { dates, drawdowns }; return { dates, drawdowns };
} }
private formatDate(date: Date): string { private formatDate(date: Date): string {
return new Date(date).toLocaleDateString('en-US', { return new Date(date).toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric' year: 'numeric',
}); });
} }
} }

View file

@ -1,171 +1,173 @@
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { CommonModule } from '@angular/common';
import { CommonModule } from '@angular/common'; import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { BacktestResult } from '../../../services/strategy.service'; import { Chart, ChartOptions } from 'chart.js/auto';
import { Chart, ChartOptions } from 'chart.js/auto'; import { BacktestResult } from '../../../services/strategy.service';
@Component({ @Component({
selector: 'app-equity-chart', selector: 'app-equity-chart',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
template: ` template: `
<div class="equity-chart-container"> <div class="equity-chart-container">
<canvas #equityChart></canvas> <canvas #equityChart></canvas>
</div> </div>
`, `,
styles: ` styles: `
.equity-chart-container { .equity-chart-container {
width: 100%; width: 100%;
height: 400px; height: 400px;
margin-bottom: 20px; margin-bottom: 20px;
} }
` `,
}) })
export class EquityChartComponent implements OnChanges { export class EquityChartComponent implements OnChanges {
@Input() backtestResult?: BacktestResult; @Input() backtestResult?: BacktestResult;
private chart?: Chart; private chart?: Chart;
private chartElement?: HTMLCanvasElement; private chartElement?: HTMLCanvasElement;
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes['backtestResult'] && this.backtestResult) { if (changes['backtestResult'] && this.backtestResult) {
this.renderChart(); this.renderChart();
} }
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.chartElement = document.querySelector('canvas') as HTMLCanvasElement; this.chartElement = document.querySelector('canvas') as HTMLCanvasElement;
if (this.backtestResult) { if (this.backtestResult) {
this.renderChart(); this.renderChart();
} }
} }
private renderChart(): void { private renderChart(): void {
if (!this.chartElement || !this.backtestResult) return; if (!this.chartElement || !this.backtestResult) return;
// Clean up previous chart if it exists // Clean up previous chart if it exists
if (this.chart) { if (this.chart) {
this.chart.destroy(); this.chart.destroy();
} }
// Prepare data // Prepare data
const equityCurve = this.calculateEquityCurve(this.backtestResult); const equityCurve = this.calculateEquityCurve(this.backtestResult);
// Create chart // Create chart
this.chart = new Chart(this.chartElement, { this.chart = new Chart(this.chartElement, {
type: 'line', type: 'line',
data: { data: {
labels: equityCurve.dates.map(date => this.formatDate(date)), labels: equityCurve.dates.map(date => this.formatDate(date)),
datasets: [ datasets: [
{ {
label: 'Portfolio Value', label: 'Portfolio Value',
data: equityCurve.values, data: equityCurve.values,
borderColor: 'rgba(75, 192, 192, 1)', borderColor: 'rgba(75, 192, 192, 1)',
backgroundColor: 'rgba(75, 192, 192, 0.2)', backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.3, tension: 0.3,
borderWidth: 2, borderWidth: 2,
fill: true fill: true,
}, },
{ {
label: 'Benchmark', label: 'Benchmark',
data: equityCurve.benchmark, data: equityCurve.benchmark,
borderColor: 'rgba(153, 102, 255, 0.5)', borderColor: 'rgba(153, 102, 255, 0.5)',
backgroundColor: 'rgba(153, 102, 255, 0.1)', backgroundColor: 'rgba(153, 102, 255, 0.1)',
borderDash: [5, 5], borderDash: [5, 5],
tension: 0.3, tension: 0.3,
borderWidth: 1, borderWidth: 1,
fill: false fill: false,
} },
] ],
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
scales: { scales: {
x: { x: {
ticks: { ticks: {
maxTicksLimit: 12, maxTicksLimit: 12,
maxRotation: 0, maxRotation: 0,
minRotation: 0 minRotation: 0,
}, },
grid: { grid: {
display: false display: false,
} },
}, },
y: { y: {
ticks: { ticks: {
callback: function(value) { callback: function (value) {
return '$' + value.toLocaleString(); return '$' + value.toLocaleString();
} },
}, },
grid: { grid: {
color: 'rgba(200, 200, 200, 0.2)' color: 'rgba(200, 200, 200, 0.2)',
} },
} },
}, },
plugins: { plugins: {
tooltip: { tooltip: {
mode: 'index', mode: 'index',
intersect: false, intersect: false,
callbacks: { callbacks: {
label: function(context) { label: function (context) {
let label = context.dataset.label || ''; let label = context.dataset.label || '';
if (label) { if (label) {
label += ': '; label += ': ';
} }
if (context.parsed.y !== null) { if (context.parsed.y !== null) {
label += new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }) label += new Intl.NumberFormat('en-US', {
.format(context.parsed.y); style: 'currency',
} currency: 'USD',
return label; }).format(context.parsed.y);
} }
} return label;
}, },
legend: { },
position: 'top', },
} legend: {
} position: 'top',
} as ChartOptions },
}); },
} } as ChartOptions,
});
private calculateEquityCurve(result: BacktestResult): { }
dates: Date[];
values: number[]; private calculateEquityCurve(result: BacktestResult): {
benchmark: number[]; dates: Date[];
} { values: number[];
const initialValue = result.initialCapital; benchmark: number[];
const dates: Date[] = []; } {
const values: number[] = []; const initialValue = result.initialCapital;
const benchmark: number[] = []; const dates: Date[] = [];
const values: number[] = [];
// Sort daily returns by date const benchmark: number[] = [];
const sortedReturns = [...result.dailyReturns].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() // Sort daily returns by date
); const sortedReturns = [...result.dailyReturns].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
// Calculate cumulative portfolio values );
let portfolioValue = initialValue;
let benchmarkValue = initialValue; // Calculate cumulative portfolio values
let portfolioValue = initialValue;
for (const daily of sortedReturns) { let benchmarkValue = initialValue;
const date = new Date(daily.date);
portfolioValue = portfolioValue * (1 + daily.return); for (const daily of sortedReturns) {
// Simple benchmark (e.g., assuming 8% annualized return for a market index) const date = new Date(daily.date);
benchmarkValue = benchmarkValue * (1 + 0.08 / 365); portfolioValue = portfolioValue * (1 + daily.return);
// Simple benchmark (e.g., assuming 8% annualized return for a market index)
dates.push(date); benchmarkValue = benchmarkValue * (1 + 0.08 / 365);
values.push(portfolioValue);
benchmark.push(benchmarkValue); dates.push(date);
} values.push(portfolioValue);
benchmark.push(benchmarkValue);
return { dates, values, benchmark }; }
}
return { dates, values, benchmark };
private formatDate(date: Date): string { }
return new Date(date).toLocaleDateString('en-US', {
month: 'short', private formatDate(date: Date): string {
day: 'numeric', return new Date(date).toLocaleDateString('en-US', {
year: 'numeric' month: 'short',
}); day: 'numeric',
} year: 'numeric',
} });
}
}

View file

@ -1,258 +1,304 @@
import { Component, Input } from '@angular/core'; import { CommonModule } from '@angular/common';
import { CommonModule } from '@angular/common'; import { Component, Input } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatGridListModule } from '@angular/material/grid-list'; import { MatDividerModule } from '@angular/material/divider';
import { MatDividerModule } from '@angular/material/divider'; import { MatGridListModule } from '@angular/material/grid-list';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { BacktestResult } from '../../../services/strategy.service'; import { BacktestResult } from '../../../services/strategy.service';
@Component({ @Component({
selector: 'app-performance-metrics', selector: 'app-performance-metrics',
standalone: true, standalone: true,
imports: [ imports: [CommonModule, MatCardModule, MatGridListModule, MatDividerModule, MatTooltipModule],
CommonModule, template: `
MatCardModule, <mat-card class="metrics-card">
MatGridListModule, <mat-card-header>
MatDividerModule, <mat-card-title>Performance Metrics</mat-card-title>
MatTooltipModule </mat-card-header>
], <mat-card-content>
template: ` <div class="metrics-grid">
<mat-card class="metrics-card"> <div class="metric-group">
<mat-card-header> <h3>Returns</h3>
<mat-card-title>Performance Metrics</mat-card-title> <div class="metrics-row">
</mat-card-header> <div class="metric">
<mat-card-content> <div class="metric-name" matTooltip="Total return over the backtest period">
<div class="metrics-grid"> Total Return
<div class="metric-group"> </div>
<h3>Returns</h3> <div
<div class="metrics-row"> class="metric-value"
<div class="metric"> [ngClass]="getReturnClass(backtestResult?.totalReturn || 0)"
<div class="metric-name" matTooltip="Total return over the backtest period">Total Return</div> >
<div class="metric-value" [ngClass]="getReturnClass(backtestResult?.totalReturn || 0)"> {{ formatPercent(backtestResult?.totalReturn || 0) }}
{{formatPercent(backtestResult?.totalReturn || 0)}} </div>
</div> </div>
</div> <div class="metric">
<div class="metric"> <div
<div class="metric-name" matTooltip="Annualized return (adjusted for the backtest duration)">Annualized Return</div> class="metric-name"
<div class="metric-value" [ngClass]="getReturnClass(backtestResult?.annualizedReturn || 0)"> matTooltip="Annualized return (adjusted for the backtest duration)"
{{formatPercent(backtestResult?.annualizedReturn || 0)}} >
</div> Annualized Return
</div> </div>
<div class="metric"> <div
<div class="metric-name" matTooltip="Compound Annual Growth Rate">CAGR</div> class="metric-value"
<div class="metric-value" [ngClass]="getReturnClass(backtestResult?.cagr || 0)"> [ngClass]="getReturnClass(backtestResult?.annualizedReturn || 0)"
{{formatPercent(backtestResult?.cagr || 0)}} >
</div> {{ formatPercent(backtestResult?.annualizedReturn || 0) }}
</div> </div>
</div> </div>
</div> <div class="metric">
<div class="metric-name" matTooltip="Compound Annual Growth Rate">CAGR</div>
<mat-divider></mat-divider> <div class="metric-value" [ngClass]="getReturnClass(backtestResult?.cagr || 0)">
{{ formatPercent(backtestResult?.cagr || 0) }}
<div class="metric-group"> </div>
<h3>Risk Metrics</h3> </div>
<div class="metrics-row"> </div>
<div class="metric"> </div>
<div class="metric-name" matTooltip="Maximum peak-to-valley drawdown">Max Drawdown</div>
<div class="metric-value negative"> <mat-divider></mat-divider>
{{formatPercent(backtestResult?.maxDrawdown || 0)}}
</div> <div class="metric-group">
</div> <h3>Risk Metrics</h3>
<div class="metric"> <div class="metrics-row">
<div class="metric-name" matTooltip="Number of days in the worst drawdown">Max DD Duration</div> <div class="metric">
<div class="metric-value"> <div class="metric-name" matTooltip="Maximum peak-to-valley drawdown">
{{formatDays(backtestResult?.maxDrawdownDuration || 0)}} Max Drawdown
</div> </div>
</div> <div class="metric-value negative">
<div class="metric"> {{ formatPercent(backtestResult?.maxDrawdown || 0) }}
<div class="metric-name" matTooltip="Annualized standard deviation of returns">Volatility</div> </div>
<div class="metric-value"> </div>
{{formatPercent(backtestResult?.volatility || 0)}} <div class="metric">
</div> <div class="metric-name" matTooltip="Number of days in the worst drawdown">
</div> Max DD Duration
<div class="metric"> </div>
<div class="metric-name" matTooltip="Square root of the sum of the squares of drawdowns">Ulcer Index</div> <div class="metric-value">
<div class="metric-value"> {{ formatDays(backtestResult?.maxDrawdownDuration || 0) }}
{{(backtestResult?.ulcerIndex || 0).toFixed(4)}} </div>
</div> </div>
</div> <div class="metric">
</div> <div class="metric-name" matTooltip="Annualized standard deviation of returns">
</div> Volatility
</div>
<mat-divider></mat-divider> <div class="metric-value">
{{ formatPercent(backtestResult?.volatility || 0) }}
<div class="metric-group"> </div>
<h3>Risk-Adjusted Returns</h3> </div>
<div class="metrics-row"> <div class="metric">
<div class="metric"> <div
<div class="metric-name" matTooltip="Excess return per unit of risk">Sharpe Ratio</div> class="metric-name"
<div class="metric-value" [ngClass]="getRatioClass(backtestResult?.sharpeRatio || 0)"> matTooltip="Square root of the sum of the squares of drawdowns"
{{(backtestResult?.sharpeRatio || 0).toFixed(2)}} >
</div> Ulcer Index
</div> </div>
<div class="metric"> <div class="metric-value">
<div class="metric-name" matTooltip="Return per unit of downside risk">Sortino Ratio</div> {{ (backtestResult?.ulcerIndex || 0).toFixed(4) }}
<div class="metric-value" [ngClass]="getRatioClass(backtestResult?.sortinoRatio || 0)"> </div>
{{(backtestResult?.sortinoRatio || 0).toFixed(2)}} </div>
</div> </div>
</div> </div>
<div class="metric">
<div class="metric-name" matTooltip="Return per unit of max drawdown">Calmar Ratio</div> <mat-divider></mat-divider>
<div class="metric-value" [ngClass]="getRatioClass(backtestResult?.calmarRatio || 0)">
{{(backtestResult?.calmarRatio || 0).toFixed(2)}} <div class="metric-group">
</div> <h3>Risk-Adjusted Returns</h3>
</div> <div class="metrics-row">
<div class="metric"> <div class="metric">
<div class="metric-name" matTooltip="Probability-weighted ratio of gains vs. losses">Omega Ratio</div> <div class="metric-name" matTooltip="Excess return per unit of risk">
<div class="metric-value" [ngClass]="getRatioClass(backtestResult?.omegaRatio || 0)"> Sharpe Ratio
{{(backtestResult?.omegaRatio || 0).toFixed(2)}} </div>
</div> <div
</div> class="metric-value"
</div> [ngClass]="getRatioClass(backtestResult?.sharpeRatio || 0)"
</div> >
{{ (backtestResult?.sharpeRatio || 0).toFixed(2) }}
<mat-divider></mat-divider> </div>
</div>
<div class="metric-group"> <div class="metric">
<h3>Trade Statistics</h3> <div class="metric-name" matTooltip="Return per unit of downside risk">
<div class="metrics-row"> Sortino Ratio
<div class="metric"> </div>
<div class="metric-name" matTooltip="Total number of trades">Total Trades</div> <div
<div class="metric-value"> class="metric-value"
{{backtestResult?.totalTrades || 0}} [ngClass]="getRatioClass(backtestResult?.sortinoRatio || 0)"
</div> >
</div> {{ (backtestResult?.sortinoRatio || 0).toFixed(2) }}
<div class="metric"> </div>
<div class="metric-name" matTooltip="Percentage of winning trades">Win Rate</div> </div>
<div class="metric-value" [ngClass]="getWinRateClass(backtestResult?.winRate || 0)"> <div class="metric">
{{formatPercent(backtestResult?.winRate || 0)}} <div class="metric-name" matTooltip="Return per unit of max drawdown">
</div> Calmar Ratio
</div> </div>
<div class="metric"> <div
<div class="metric-name" matTooltip="Average profit of winning trades">Avg Win</div> class="metric-value"
<div class="metric-value positive"> [ngClass]="getRatioClass(backtestResult?.calmarRatio || 0)"
{{formatPercent(backtestResult?.averageWinningTrade || 0)}} >
</div> {{ (backtestResult?.calmarRatio || 0).toFixed(2) }}
</div> </div>
<div class="metric"> </div>
<div class="metric-name" matTooltip="Average loss of losing trades">Avg Loss</div> <div class="metric">
<div class="metric-value negative"> <div
{{formatPercent(backtestResult?.averageLosingTrade || 0)}} class="metric-name"
</div> matTooltip="Probability-weighted ratio of gains vs. losses"
</div> >
<div class="metric"> Omega Ratio
<div class="metric-name" matTooltip="Ratio of total gains to total losses">Profit Factor</div> </div>
<div class="metric-value" [ngClass]="getProfitFactorClass(backtestResult?.profitFactor || 0)"> <div
{{(backtestResult?.profitFactor || 0).toFixed(2)}} class="metric-value"
</div> [ngClass]="getRatioClass(backtestResult?.omegaRatio || 0)"
</div> >
</div> {{ (backtestResult?.omegaRatio || 0).toFixed(2) }}
</div> </div>
</div> </div>
</mat-card-content> </div>
</mat-card> </div>
`,
styles: ` <mat-divider></mat-divider>
.metrics-card {
margin-bottom: 20px; <div class="metric-group">
} <h3>Trade Statistics</h3>
<div class="metrics-row">
.metrics-grid { <div class="metric">
display: flex; <div class="metric-name" matTooltip="Total number of trades">Total Trades</div>
flex-direction: column; <div class="metric-value">
gap: 16px; {{ backtestResult?.totalTrades || 0 }}
} </div>
</div>
.metric-group { <div class="metric">
padding: 10px 0; <div class="metric-name" matTooltip="Percentage of winning trades">Win Rate</div>
} <div class="metric-value" [ngClass]="getWinRateClass(backtestResult?.winRate || 0)">
{{ formatPercent(backtestResult?.winRate || 0) }}
.metric-group h3 { </div>
margin-top: 0; </div>
margin-bottom: 16px; <div class="metric">
font-size: 16px; <div class="metric-name" matTooltip="Average profit of winning trades">Avg Win</div>
font-weight: 500; <div class="metric-value positive">
color: #555; {{ formatPercent(backtestResult?.averageWinningTrade || 0) }}
} </div>
</div>
.metrics-row { <div class="metric">
display: flex; <div class="metric-name" matTooltip="Average loss of losing trades">Avg Loss</div>
flex-wrap: wrap; <div class="metric-value negative">
gap: 24px; {{ formatPercent(backtestResult?.averageLosingTrade || 0) }}
} </div>
</div>
.metric { <div class="metric">
min-width: 120px; <div class="metric-name" matTooltip="Ratio of total gains to total losses">
margin-bottom: 16px; Profit Factor
} </div>
<div
.metric-name { class="metric-value"
font-size: 12px; [ngClass]="getProfitFactorClass(backtestResult?.profitFactor || 0)"
color: #666; >
margin-bottom: 4px; {{ (backtestResult?.profitFactor || 0).toFixed(2) }}
} </div>
</div>
.metric-value { </div>
font-size: 16px; </div>
font-weight: 500; </div>
} </mat-card-content>
</mat-card>
.positive { `,
color: #4CAF50; styles: `
} .metrics-card {
margin-bottom: 20px;
.negative { }
color: #F44336;
} .metrics-grid {
display: flex;
.neutral { flex-direction: column;
color: #FFA000; gap: 16px;
} }
mat-divider { .metric-group {
margin: 8px 0; padding: 10px 0;
} }
`
}) .metric-group h3 {
export class PerformanceMetricsComponent { margin-top: 0;
@Input() backtestResult?: BacktestResult; margin-bottom: 16px;
font-size: 16px;
// Formatting helpers font-weight: 500;
formatPercent(value: number): string { color: #555;
return new Intl.NumberFormat('en-US', { }
style: 'percent',
minimumFractionDigits: 2, .metrics-row {
maximumFractionDigits: 2 display: flex;
}).format(value); flex-wrap: wrap;
} gap: 24px;
}
formatDays(days: number): string {
return `${days} days`; .metric {
} min-width: 120px;
margin-bottom: 16px;
// Conditional classes }
getReturnClass(value: number): string {
if (value > 0) return 'positive'; .metric-name {
if (value < 0) return 'negative'; font-size: 12px;
return ''; color: #666;
} margin-bottom: 4px;
}
getRatioClass(value: number): string {
if (value >= 1.5) return 'positive'; .metric-value {
if (value >= 1) return 'neutral'; font-size: 16px;
if (value < 0) return 'negative'; font-weight: 500;
return ''; }
}
.positive {
getWinRateClass(value: number): string { color: #4caf50;
if (value >= 0.55) return 'positive'; }
if (value >= 0.45) return 'neutral';
return 'negative'; .negative {
} color: #f44336;
}
getProfitFactorClass(value: number): string {
if (value >= 1.5) return 'positive'; .neutral {
if (value >= 1) return 'neutral'; color: #ffa000;
return 'negative'; }
}
} mat-divider {
margin: 8px 0;
}
`,
})
export class PerformanceMetricsComponent {
@Input() backtestResult?: BacktestResult;
// Formatting helpers
formatPercent(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
}
formatDays(days: number): string {
return `${days} days`;
}
// Conditional classes
getReturnClass(value: number): string {
if (value > 0) return 'positive';
if (value < 0) return 'negative';
return '';
}
getRatioClass(value: number): string {
if (value >= 1.5) return 'positive';
if (value >= 1) return 'neutral';
if (value < 0) return 'negative';
return '';
}
getWinRateClass(value: number): string {
if (value >= 0.55) return 'positive';
if (value >= 0.45) return 'neutral';
return 'negative';
}
getProfitFactorClass(value: number): string {
if (value >= 1.5) return 'positive';
if (value >= 1) return 'neutral';
return 'negative';
}
}

View file

@ -1,221 +1,259 @@
import { Component, Input } from '@angular/core'; import { CommonModule } from '@angular/common';
import { CommonModule } from '@angular/common'; import { Component, Input } from '@angular/core';
import { MatTableModule } from '@angular/material/table'; import { MatCardModule } from '@angular/material/card';
import { MatSortModule, Sort } from '@angular/material/sort'; import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatCardModule } from '@angular/material/card'; import { MatSortModule, Sort } from '@angular/material/sort';
import { MatIconModule } from '@angular/material/icon'; import { MatTableModule } from '@angular/material/table';
import { BacktestResult } from '../../../services/strategy.service'; import { BacktestResult } from '../../../services/strategy.service';
@Component({ @Component({
selector: 'app-trades-table', selector: 'app-trades-table',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
MatTableModule, MatTableModule,
MatSortModule, MatSortModule,
MatPaginatorModule, MatPaginatorModule,
MatCardModule, MatCardModule,
MatIconModule MatIconModule,
], ],
template: ` template: `
<mat-card class="trades-card"> <mat-card class="trades-card">
<mat-card-header> <mat-card-header>
<mat-card-title>Trades</mat-card-title> <mat-card-title>Trades</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<table mat-table [dataSource]="displayedTrades" matSort (matSortChange)="sortData($event)" class="trades-table"> <table
mat-table
<!-- Symbol Column --> [dataSource]="displayedTrades"
<ng-container matColumnDef="symbol"> matSort
<th mat-header-cell *matHeaderCellDef mat-sort-header> Symbol </th> (matSortChange)="sortData($event)"
<td mat-cell *matCellDef="let trade"> {{trade.symbol}} </td> class="trades-table"
</ng-container> >
<!-- Symbol Column -->
<!-- Entry Date Column --> <ng-container matColumnDef="symbol">
<ng-container matColumnDef="entryTime"> <th mat-header-cell *matHeaderCellDef mat-sort-header>Symbol</th>
<th mat-header-cell *matHeaderCellDef mat-sort-header> Entry Time </th> <td mat-cell *matCellDef="let trade">{{ trade.symbol }}</td>
<td mat-cell *matCellDef="let trade"> {{formatDate(trade.entryTime)}} </td> </ng-container>
</ng-container>
<!-- Entry Date Column -->
<!-- Entry Price Column --> <ng-container matColumnDef="entryTime">
<ng-container matColumnDef="entryPrice"> <th mat-header-cell *matHeaderCellDef mat-sort-header>Entry Time</th>
<th mat-header-cell *matHeaderCellDef mat-sort-header> Entry Price </th> <td mat-cell *matCellDef="let trade">{{ formatDate(trade.entryTime) }}</td>
<td mat-cell *matCellDef="let trade"> {{formatCurrency(trade.entryPrice)}} </td> </ng-container>
</ng-container>
<!-- Entry Price Column -->
<!-- Exit Date Column --> <ng-container matColumnDef="entryPrice">
<ng-container matColumnDef="exitTime"> <th mat-header-cell *matHeaderCellDef mat-sort-header>Entry Price</th>
<th mat-header-cell *matHeaderCellDef mat-sort-header> Exit Time </th> <td mat-cell *matCellDef="let trade">{{ formatCurrency(trade.entryPrice) }}</td>
<td mat-cell *matCellDef="let trade"> {{formatDate(trade.exitTime)}} </td> </ng-container>
</ng-container>
<!-- Exit Date Column -->
<!-- Exit Price Column --> <ng-container matColumnDef="exitTime">
<ng-container matColumnDef="exitPrice"> <th mat-header-cell *matHeaderCellDef mat-sort-header>Exit Time</th>
<th mat-header-cell *matHeaderCellDef mat-sort-header> Exit Price </th> <td mat-cell *matCellDef="let trade">{{ formatDate(trade.exitTime) }}</td>
<td mat-cell *matCellDef="let trade"> {{formatCurrency(trade.exitPrice)}} </td> </ng-container>
</ng-container>
<!-- Exit Price Column -->
<!-- Quantity Column --> <ng-container matColumnDef="exitPrice">
<ng-container matColumnDef="quantity"> <th mat-header-cell *matHeaderCellDef mat-sort-header>Exit Price</th>
<th mat-header-cell *matHeaderCellDef mat-sort-header> Quantity </th> <td mat-cell *matCellDef="let trade">{{ formatCurrency(trade.exitPrice) }}</td>
<td mat-cell *matCellDef="let trade"> {{trade.quantity}} </td> </ng-container>
</ng-container>
<!-- Quantity Column -->
<!-- P&L Column --> <ng-container matColumnDef="quantity">
<ng-container matColumnDef="pnl"> <th mat-header-cell *matHeaderCellDef mat-sort-header>Quantity</th>
<th mat-header-cell *matHeaderCellDef mat-sort-header> P&L </th> <td mat-cell *matCellDef="let trade">{{ trade.quantity }}</td>
<td mat-cell *matCellDef="let trade" </ng-container>
[ngClass]="{'positive': trade.pnl > 0, 'negative': trade.pnl < 0}">
{{formatCurrency(trade.pnl)}} <!-- P&L Column -->
</td> <ng-container matColumnDef="pnl">
</ng-container> <th mat-header-cell *matHeaderCellDef mat-sort-header>P&L</th>
<td
<!-- P&L Percent Column --> mat-cell
<ng-container matColumnDef="pnlPercent"> *matCellDef="let trade"
<th mat-header-cell *matHeaderCellDef mat-sort-header> P&L % </th> [ngClass]="{ positive: trade.pnl > 0, negative: trade.pnl < 0 }"
<td mat-cell *matCellDef="let trade" >
[ngClass]="{'positive': trade.pnlPercent > 0, 'negative': trade.pnlPercent < 0}"> {{ formatCurrency(trade.pnl) }}
{{formatPercent(trade.pnlPercent)}} </td>
</td> </ng-container>
</ng-container>
<!-- P&L Percent Column -->
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <ng-container matColumnDef="pnlPercent">
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr> <th mat-header-cell *matHeaderCellDef mat-sort-header>P&L %</th>
</table> <td
mat-cell
<mat-paginator *matCellDef="let trade"
[length]="totalTrades" [ngClass]="{ positive: trade.pnlPercent > 0, negative: trade.pnlPercent < 0 }"
[pageSize]="pageSize" >
[pageSizeOptions]="[5, 10, 25, 50]" {{ formatPercent(trade.pnlPercent) }}
(page)="pageChange($event)" </td>
aria-label="Select page"> </ng-container>
</mat-paginator>
</mat-card-content> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
</mat-card> <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
`, </table>
styles: `
.trades-card { <mat-paginator
margin-bottom: 20px; [length]="totalTrades"
} [pageSize]="pageSize"
[pageSizeOptions]="[5, 10, 25, 50]"
.trades-table { (page)="pageChange($event)"
width: 100%; aria-label="Select page"
border-collapse: collapse; >
} </mat-paginator>
</mat-card-content>
.mat-column-pnl, .mat-column-pnlPercent { </mat-card>
text-align: right; `,
font-weight: 500; styles: `
} .trades-card {
margin-bottom: 20px;
.positive { }
color: #4CAF50;
} .trades-table {
width: 100%;
.negative { border-collapse: collapse;
color: #F44336; }
}
.mat-column-pnl,
.mat-mdc-row:hover { .mat-column-pnlPercent {
background-color: rgba(0, 0, 0, 0.04); text-align: right;
} font-weight: 500;
` }
})
export class TradesTableComponent { .positive {
@Input() set backtestResult(value: BacktestResult | undefined) { color: #4caf50;
if (value) { }
this._backtestResult = value;
this.updateDisplayedTrades(); .negative {
} color: #f44336;
} }
get backtestResult(): BacktestResult | undefined { .mat-mdc-row:hover {
return this._backtestResult; background-color: rgba(0, 0, 0, 0.04);
} }
`,
private _backtestResult?: BacktestResult; })
export class TradesTableComponent {
// Table configuration @Input() set backtestResult(value: BacktestResult | undefined) {
displayedColumns: string[] = [ if (value) {
'symbol', 'entryTime', 'entryPrice', 'exitTime', this._backtestResult = value;
'exitPrice', 'quantity', 'pnl', 'pnlPercent' this.updateDisplayedTrades();
]; }
}
// Pagination
pageSize = 10; get backtestResult(): BacktestResult | undefined {
currentPage = 0; return this._backtestResult;
displayedTrades: any[] = []; }
get totalTrades(): number { private _backtestResult?: BacktestResult;
return this._backtestResult?.trades.length || 0;
} // Table configuration
displayedColumns: string[] = [
// Sort the trades 'symbol',
sortData(sort: Sort): void { 'entryTime',
if (!sort.active || sort.direction === '') { 'entryPrice',
this.updateDisplayedTrades(); 'exitTime',
return; 'exitPrice',
} 'quantity',
'pnl',
const data = this._backtestResult?.trades.slice() || []; 'pnlPercent',
];
this.displayedTrades = data.sort((a, b) => {
const isAsc = sort.direction === 'asc'; // Pagination
switch (sort.active) { pageSize = 10;
case 'symbol': return this.compare(a.symbol, b.symbol, isAsc); currentPage = 0;
case 'entryTime': return this.compare(new Date(a.entryTime).getTime(), new Date(b.entryTime).getTime(), isAsc); displayedTrades: any[] = [];
case 'entryPrice': return this.compare(a.entryPrice, b.entryPrice, isAsc);
case 'exitTime': return this.compare(new Date(a.exitTime).getTime(), new Date(b.exitTime).getTime(), isAsc); get totalTrades(): number {
case 'exitPrice': return this.compare(a.exitPrice, b.exitPrice, isAsc); return this._backtestResult?.trades.length || 0;
case 'quantity': return this.compare(a.quantity, b.quantity, isAsc); }
case 'pnl': return this.compare(a.pnl, b.pnl, isAsc);
case 'pnlPercent': return this.compare(a.pnlPercent, b.pnlPercent, isAsc); // Sort the trades
default: return 0; sortData(sort: Sort): void {
} if (!sort.active || sort.direction === '') {
}).slice(this.currentPage * this.pageSize, (this.currentPage + 1) * this.pageSize); this.updateDisplayedTrades();
} return;
}
// Handle page changes
pageChange(event: PageEvent): void { const data = this._backtestResult?.trades.slice() || [];
this.pageSize = event.pageSize;
this.currentPage = event.pageIndex; this.displayedTrades = data
this.updateDisplayedTrades(); .sort((a, b) => {
} const isAsc = sort.direction === 'asc';
switch (sort.active) {
// Update displayed trades based on current page and page size case 'symbol':
updateDisplayedTrades(): void { return this.compare(a.symbol, b.symbol, isAsc);
if (this._backtestResult) { case 'entryTime':
this.displayedTrades = this._backtestResult.trades.slice( return this.compare(
this.currentPage * this.pageSize, new Date(a.entryTime).getTime(),
(this.currentPage + 1) * this.pageSize new Date(b.entryTime).getTime(),
); isAsc
} else { );
this.displayedTrades = []; case 'entryPrice':
} return this.compare(a.entryPrice, b.entryPrice, isAsc);
} case 'exitTime':
return this.compare(
// Helper methods for formatting new Date(a.exitTime).getTime(),
formatDate(date: Date | string): string { new Date(b.exitTime).getTime(),
return new Date(date).toLocaleString(); isAsc
} );
case 'exitPrice':
formatCurrency(value: number): string { return this.compare(a.exitPrice, b.exitPrice, isAsc);
return new Intl.NumberFormat('en-US', { case 'quantity':
style: 'currency', return this.compare(a.quantity, b.quantity, isAsc);
currency: 'USD', case 'pnl':
}).format(value); return this.compare(a.pnl, b.pnl, isAsc);
} case 'pnlPercent':
return this.compare(a.pnlPercent, b.pnlPercent, isAsc);
formatPercent(value: number): string { default:
return new Intl.NumberFormat('en-US', { return 0;
style: 'percent', }
minimumFractionDigits: 2, })
maximumFractionDigits: 2 .slice(this.currentPage * this.pageSize, (this.currentPage + 1) * this.pageSize);
}).format(value); }
}
// Handle page changes
private compare(a: number | string, b: number | string, isAsc: boolean): number { pageChange(event: PageEvent): void {
return (a < b ? -1 : 1) * (isAsc ? 1 : -1); this.pageSize = event.pageSize;
} this.currentPage = event.pageIndex;
} this.updateDisplayedTrades();
}
// Update displayed trades based on current page and page size
updateDisplayedTrades(): void {
if (this._backtestResult) {
this.displayedTrades = this._backtestResult.trades.slice(
this.currentPage * this.pageSize,
(this.currentPage + 1) * this.pageSize
);
} else {
this.displayedTrades = [];
}
}
// Helper methods for formatting
formatDate(date: Date | string): string {
return new Date(date).toLocaleString();
}
formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(value);
}
formatPercent(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
}
private compare(a: number | string, b: number | string, isAsc: boolean): number {
return (a < b ? -1 : 1) * (isAsc ? 1 : -1);
}
}

View file

@ -1,185 +1,193 @@
import { Component, Inject, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common';
import { CommonModule } from '@angular/common'; import { Component, Inject, OnInit } from '@angular/core';
import { import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
FormBuilder, import { MatButtonModule } from '@angular/material/button';
FormGroup, import { MatChipsModule } from '@angular/material/chips';
ReactiveFormsModule, import { MatNativeDateModule } from '@angular/material/core';
Validators import { MatDatepickerModule } from '@angular/material/datepicker';
} from '@angular/forms'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input';
import { MatInputModule } from '@angular/material/input'; import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatNativeDateModule } from '@angular/material/core'; import { MatTabsModule } from '@angular/material/tabs';
import { MatProgressBarModule } from '@angular/material/progress-bar'; import {
import { MatTabsModule } from '@angular/material/tabs'; BacktestRequest,
import { MatChipsModule } from '@angular/material/chips'; BacktestResult,
import { MatIconModule } from '@angular/material/icon'; StrategyService,
import { MatSlideToggleModule } from '@angular/material/slide-toggle'; TradingStrategy,
import { } from '../../../services/strategy.service';
BacktestRequest,
BacktestResult, @Component({
StrategyService, selector: 'app-backtest-dialog',
TradingStrategy standalone: true,
} from '../../../services/strategy.service'; imports: [
CommonModule,
@Component({ ReactiveFormsModule,
selector: 'app-backtest-dialog', MatButtonModule,
standalone: true, MatDialogModule,
imports: [ MatFormFieldModule,
CommonModule, MatInputModule,
ReactiveFormsModule, MatSelectModule,
MatButtonModule, MatDatepickerModule,
MatDialogModule, MatNativeDateModule,
MatFormFieldModule, MatProgressBarModule,
MatInputModule, MatTabsModule,
MatSelectModule, MatChipsModule,
MatDatepickerModule, MatIconModule,
MatNativeDateModule, MatSlideToggleModule,
MatProgressBarModule, ],
MatTabsModule, templateUrl: './backtest-dialog.component.html',
MatChipsModule, styleUrl: './backtest-dialog.component.css',
MatIconModule, })
MatSlideToggleModule export class BacktestDialogComponent implements OnInit {
], backtestForm: FormGroup;
templateUrl: './backtest-dialog.component.html', strategyTypes: string[] = [];
styleUrl: './backtest-dialog.component.css' availableSymbols: string[] = [
}) 'AAPL',
export class BacktestDialogComponent implements OnInit { 'MSFT',
backtestForm: FormGroup; 'GOOGL',
strategyTypes: string[] = []; 'AMZN',
availableSymbols: string[] = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA', 'META', 'NVDA', 'SPY', 'QQQ']; 'TSLA',
selectedSymbols: string[] = []; 'META',
parameters: Record<string, any> = {}; 'NVDA',
isRunning: boolean = false; 'SPY',
backtestResult: BacktestResult | null = null; 'QQQ',
];
constructor( selectedSymbols: string[] = [];
private fb: FormBuilder, parameters: Record<string, any> = {};
private strategyService: StrategyService, isRunning: boolean = false;
@Inject(MAT_DIALOG_DATA) public data: TradingStrategy | null, backtestResult: BacktestResult | null = null;
private dialogRef: MatDialogRef<BacktestDialogComponent>
) { constructor(
// Initialize form with defaults private fb: FormBuilder,
this.backtestForm = this.fb.group({ private strategyService: StrategyService,
strategyType: ['', [Validators.required]], @Inject(MAT_DIALOG_DATA) public data: TradingStrategy | null,
startDate: [new Date(new Date().setFullYear(new Date().getFullYear() - 1)), [Validators.required]], private dialogRef: MatDialogRef<BacktestDialogComponent>
endDate: [new Date(), [Validators.required]], ) {
initialCapital: [100000, [Validators.required, Validators.min(1000)]], // Initialize form with defaults
dataResolution: ['1d', [Validators.required]], this.backtestForm = this.fb.group({
commission: [0.001, [Validators.required, Validators.min(0), Validators.max(0.1)]], strategyType: ['', [Validators.required]],
slippage: [0.0005, [Validators.required, Validators.min(0), Validators.max(0.1)]], startDate: [
mode: ['event', [Validators.required]] new Date(new Date().setFullYear(new Date().getFullYear() - 1)),
}); [Validators.required],
],
// If strategy is provided, pre-populate the form endDate: [new Date(), [Validators.required]],
if (data) { initialCapital: [100000, [Validators.required, Validators.min(1000)]],
this.selectedSymbols = [...data.symbols]; dataResolution: ['1d', [Validators.required]],
this.backtestForm.patchValue({ commission: [0.001, [Validators.required, Validators.min(0), Validators.max(0.1)]],
strategyType: data.type slippage: [0.0005, [Validators.required, Validators.min(0), Validators.max(0.1)]],
}); mode: ['event', [Validators.required]],
this.parameters = {...data.parameters}; });
}
} // If strategy is provided, pre-populate the form
if (data) {
ngOnInit(): void { this.selectedSymbols = [...data.symbols];
this.loadStrategyTypes(); this.backtestForm.patchValue({
} strategyType: data.type,
});
loadStrategyTypes(): void { this.parameters = { ...data.parameters };
this.strategyService.getStrategyTypes().subscribe({ }
next: (response) => { }
if (response.success) {
this.strategyTypes = response.data; ngOnInit(): void {
this.loadStrategyTypes();
// If strategy is provided, load its parameters }
if (this.data) {
this.onStrategyTypeChange(this.data.type); loadStrategyTypes(): void {
} this.strategyService.getStrategyTypes().subscribe({
} next: response => {
}, if (response.success) {
error: (error) => { this.strategyTypes = response.data;
console.error('Error loading strategy types:', error);
this.strategyTypes = ['MOVING_AVERAGE_CROSSOVER', 'MEAN_REVERSION', 'CUSTOM']; // If strategy is provided, load its parameters
} if (this.data) {
}); this.onStrategyTypeChange(this.data.type);
} }
}
onStrategyTypeChange(type: string): void { },
// Get default parameters for this strategy type error: error => {
this.strategyService.getStrategyParameters(type).subscribe({ console.error('Error loading strategy types:', error);
next: (response) => { this.strategyTypes = ['MOVING_AVERAGE_CROSSOVER', 'MEAN_REVERSION', 'CUSTOM'];
if (response.success) { },
// If strategy is provided, merge default with existing });
if (this.data) { }
this.parameters = {
...response.data, onStrategyTypeChange(type: string): void {
...this.data.parameters // Get default parameters for this strategy type
}; this.strategyService.getStrategyParameters(type).subscribe({
} else { next: response => {
this.parameters = response.data; if (response.success) {
} // If strategy is provided, merge default with existing
} if (this.data) {
}, this.parameters = {
error: (error) => { ...response.data,
console.error('Error loading parameters:', error); ...this.data.parameters,
this.parameters = {}; };
} } else {
}); this.parameters = response.data;
} }
}
addSymbol(symbol: string): void { },
if (!symbol || this.selectedSymbols.includes(symbol)) return; error: error => {
this.selectedSymbols.push(symbol); console.error('Error loading parameters:', error);
} this.parameters = {};
},
removeSymbol(symbol: string): void { });
this.selectedSymbols = this.selectedSymbols.filter(s => s !== symbol); }
}
addSymbol(symbol: string): void {
updateParameter(key: string, value: any): void { if (!symbol || this.selectedSymbols.includes(symbol)) return;
this.parameters[key] = value; this.selectedSymbols.push(symbol);
} }
onSubmit(): void { removeSymbol(symbol: string): void {
if (this.backtestForm.invalid || this.selectedSymbols.length === 0) { this.selectedSymbols = this.selectedSymbols.filter(s => s !== symbol);
return; }
}
updateParameter(key: string, value: any): void {
const formValue = this.backtestForm.value; this.parameters[key] = value;
}
const backtestRequest: BacktestRequest = {
strategyType: formValue.strategyType, onSubmit(): void {
strategyParams: this.parameters, if (this.backtestForm.invalid || this.selectedSymbols.length === 0) {
symbols: this.selectedSymbols, return;
startDate: formValue.startDate, }
endDate: formValue.endDate,
initialCapital: formValue.initialCapital, const formValue = this.backtestForm.value;
dataResolution: formValue.dataResolution,
commission: formValue.commission, const backtestRequest: BacktestRequest = {
slippage: formValue.slippage, strategyType: formValue.strategyType,
mode: formValue.mode strategyParams: this.parameters,
}; symbols: this.selectedSymbols,
startDate: formValue.startDate,
this.isRunning = true; endDate: formValue.endDate,
initialCapital: formValue.initialCapital,
this.strategyService.runBacktest(backtestRequest).subscribe({ dataResolution: formValue.dataResolution,
next: (response) => { commission: formValue.commission,
this.isRunning = false; slippage: formValue.slippage,
if (response.success) { mode: formValue.mode,
this.backtestResult = response.data; };
}
}, this.isRunning = true;
error: (error) => {
this.isRunning = false; this.strategyService.runBacktest(backtestRequest).subscribe({
console.error('Backtest error:', error); next: response => {
} this.isRunning = false;
}); if (response.success) {
} this.backtestResult = response.data;
}
close(): void { },
this.dialogRef.close(this.backtestResult); error: error => {
} this.isRunning = false;
} console.error('Backtest error:', error);
},
});
}
close(): void {
this.dialogRef.close(this.backtestResult);
}
}

View file

@ -1,178 +1,180 @@
import { Component, Inject, OnInit } from '@angular/core'; import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import { Component, Inject, OnInit } from '@angular/core';
FormBuilder, import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
FormGroup, import { MatAutocompleteModule } from '@angular/material/autocomplete';
ReactiveFormsModule, import { MatButtonModule } from '@angular/material/button';
Validators import { MatChipsModule } from '@angular/material/chips';
} from '@angular/forms'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input';
import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select';
import { MatSelectModule } from '@angular/material/select'; import { StrategyService, TradingStrategy } from '../../../services/strategy.service';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon'; @Component({
import { COMMA, ENTER } from '@angular/cdk/keycodes'; selector: 'app-strategy-dialog',
import { MatAutocompleteModule } from '@angular/material/autocomplete'; standalone: true,
import { imports: [
StrategyService, CommonModule,
TradingStrategy ReactiveFormsModule,
} from '../../../services/strategy.service'; MatButtonModule,
MatDialogModule,
@Component({ MatFormFieldModule,
selector: 'app-strategy-dialog', MatInputModule,
standalone: true, MatSelectModule,
imports: [ MatChipsModule,
CommonModule, MatIconModule,
ReactiveFormsModule, MatAutocompleteModule,
MatButtonModule, ],
MatDialogModule, templateUrl: './strategy-dialog.component.html',
MatFormFieldModule, styleUrl: './strategy-dialog.component.css',
MatInputModule, })
MatSelectModule, export class StrategyDialogComponent implements OnInit {
MatChipsModule, strategyForm: FormGroup;
MatIconModule, isEditMode: boolean = false;
MatAutocompleteModule strategyTypes: string[] = [];
], availableSymbols: string[] = [
templateUrl: './strategy-dialog.component.html', 'AAPL',
styleUrl: './strategy-dialog.component.css' 'MSFT',
}) 'GOOGL',
export class StrategyDialogComponent implements OnInit { 'AMZN',
strategyForm: FormGroup; 'TSLA',
isEditMode: boolean = false; 'META',
strategyTypes: string[] = []; 'NVDA',
availableSymbols: string[] = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA', 'META', 'NVDA', 'SPY', 'QQQ']; 'SPY',
selectedSymbols: string[] = []; 'QQQ',
separatorKeysCodes: number[] = [ENTER, COMMA]; ];
parameters: Record<string, any> = {}; selectedSymbols: string[] = [];
separatorKeysCodes: number[] = [ENTER, COMMA];
constructor( parameters: Record<string, any> = {};
private fb: FormBuilder,
private strategyService: StrategyService, constructor(
@Inject(MAT_DIALOG_DATA) public data: TradingStrategy | null, private fb: FormBuilder,
private dialogRef: MatDialogRef<StrategyDialogComponent> private strategyService: StrategyService,
) { @Inject(MAT_DIALOG_DATA) public data: TradingStrategy | null,
this.isEditMode = !!data; private dialogRef: MatDialogRef<StrategyDialogComponent>
) {
this.strategyForm = this.fb.group({ this.isEditMode = !!data;
name: ['', [Validators.required]],
description: [''], this.strategyForm = this.fb.group({
type: ['', [Validators.required]], name: ['', [Validators.required]],
// Dynamic parameters will be added based on strategy type description: [''],
}); type: ['', [Validators.required]],
// Dynamic parameters will be added based on strategy type
if (this.isEditMode && data) { });
this.selectedSymbols = [...data.symbols];
this.strategyForm.patchValue({ if (this.isEditMode && data) {
name: data.name, this.selectedSymbols = [...data.symbols];
description: data.description, this.strategyForm.patchValue({
type: data.type name: data.name,
}); description: data.description,
this.parameters = {...data.parameters}; type: data.type,
} });
} this.parameters = { ...data.parameters };
}
ngOnInit(): void { }
// In a real implementation, fetch available strategy types from the API
this.loadStrategyTypes(); ngOnInit(): void {
} // In a real implementation, fetch available strategy types from the API
this.loadStrategyTypes();
loadStrategyTypes(): void { }
// In a real implementation, this would call the API
this.strategyService.getStrategyTypes().subscribe({ loadStrategyTypes(): void {
next: (response) => { // In a real implementation, this would call the API
if (response.success) { this.strategyService.getStrategyTypes().subscribe({
this.strategyTypes = response.data; next: response => {
if (response.success) {
// If editing, load parameters this.strategyTypes = response.data;
if (this.isEditMode && this.data) {
this.onStrategyTypeChange(this.data.type); // If editing, load parameters
} if (this.isEditMode && this.data) {
} this.onStrategyTypeChange(this.data.type);
}, }
error: (error) => { }
console.error('Error loading strategy types:', error); },
// Fallback to hardcoded types error: error => {
this.strategyTypes = ['MOVING_AVERAGE_CROSSOVER', 'MEAN_REVERSION', 'CUSTOM']; console.error('Error loading strategy types:', error);
} // Fallback to hardcoded types
}); this.strategyTypes = ['MOVING_AVERAGE_CROSSOVER', 'MEAN_REVERSION', 'CUSTOM'];
} },
});
onStrategyTypeChange(type: string): void { }
// Get default parameters for this strategy type
this.strategyService.getStrategyParameters(type).subscribe({ onStrategyTypeChange(type: string): void {
next: (response) => { // Get default parameters for this strategy type
if (response.success) { this.strategyService.getStrategyParameters(type).subscribe({
// If editing, merge default with existing next: response => {
if (this.isEditMode && this.data) { if (response.success) {
this.parameters = { // If editing, merge default with existing
...response.data, if (this.isEditMode && this.data) {
...this.data.parameters this.parameters = {
}; ...response.data,
} else { ...this.data.parameters,
this.parameters = response.data; };
} } else {
} this.parameters = response.data;
}, }
error: (error) => { }
console.error('Error loading parameters:', error); },
// Fallback to empty parameters error: error => {
this.parameters = {}; console.error('Error loading parameters:', error);
} // Fallback to empty parameters
}); this.parameters = {};
} },
});
addSymbol(symbol: string): void { }
if (!symbol || this.selectedSymbols.includes(symbol)) return;
this.selectedSymbols.push(symbol); addSymbol(symbol: string): void {
} if (!symbol || this.selectedSymbols.includes(symbol)) return;
this.selectedSymbols.push(symbol);
removeSymbol(symbol: string): void { }
this.selectedSymbols = this.selectedSymbols.filter(s => s !== symbol);
} removeSymbol(symbol: string): void {
this.selectedSymbols = this.selectedSymbols.filter(s => s !== symbol);
onSubmit(): void { }
if (this.strategyForm.invalid || this.selectedSymbols.length === 0) {
return; onSubmit(): void {
} if (this.strategyForm.invalid || this.selectedSymbols.length === 0) {
return;
const formValue = this.strategyForm.value; }
const strategy: Partial<TradingStrategy> = { const formValue = this.strategyForm.value;
name: formValue.name,
description: formValue.description, const strategy: Partial<TradingStrategy> = {
type: formValue.type, name: formValue.name,
symbols: this.selectedSymbols, description: formValue.description,
parameters: this.parameters, type: formValue.type,
}; symbols: this.selectedSymbols,
parameters: this.parameters,
if (this.isEditMode && this.data) { };
this.strategyService.updateStrategy(this.data.id, strategy).subscribe({
next: (response) => { if (this.isEditMode && this.data) {
if (response.success) { this.strategyService.updateStrategy(this.data.id, strategy).subscribe({
this.dialogRef.close(true); next: response => {
} if (response.success) {
}, this.dialogRef.close(true);
error: (error) => { }
console.error('Error updating strategy:', error); },
} error: error => {
}); console.error('Error updating strategy:', error);
} else { },
this.strategyService.createStrategy(strategy).subscribe({ });
next: (response) => { } else {
if (response.success) { this.strategyService.createStrategy(strategy).subscribe({
this.dialogRef.close(true); next: response => {
} if (response.success) {
}, this.dialogRef.close(true);
error: (error) => { }
console.error('Error creating strategy:', error); },
} error: error => {
}); console.error('Error creating strategy:', error);
} },
} });
}
updateParameter(key: string, value: any): void { }
this.parameters[key] = value;
} updateParameter(key: string, value: any): void {
} this.parameters[key] = value;
}
}

View file

@ -1,148 +1,154 @@
import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common';
import { CommonModule } from '@angular/common'; import { Component, OnInit } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button';
import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card';
import { MatTabsModule } from '@angular/material/tabs'; import { MatChipsModule } from '@angular/material/chips';
import { MatTableModule } from '@angular/material/table'; import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatSortModule } from '@angular/material/sort'; import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule } from '@angular/material/paginator'; import { MatMenuModule } from '@angular/material/menu';
import { MatDialogModule, MatDialog } from '@angular/material/dialog'; import { MatPaginatorModule } from '@angular/material/paginator';
import { MatMenuModule } from '@angular/material/menu'; import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatChipsModule } from '@angular/material/chips'; import { MatSortModule } from '@angular/material/sort';
import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTableModule } from '@angular/material/table';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatTabsModule } from '@angular/material/tabs';
import { StrategyService, TradingStrategy } from '../../services/strategy.service'; import { StrategyService, TradingStrategy } from '../../services/strategy.service';
import { WebSocketService } from '../../services/websocket.service'; import { WebSocketService } from '../../services/websocket.service';
import { StrategyDialogComponent } from './dialogs/strategy-dialog.component'; import { BacktestDialogComponent } from './dialogs/backtest-dialog.component';
import { BacktestDialogComponent } from './dialogs/backtest-dialog.component'; import { StrategyDialogComponent } from './dialogs/strategy-dialog.component';
import { StrategyDetailsComponent } from './strategy-details/strategy-details.component'; import { StrategyDetailsComponent } from './strategy-details/strategy-details.component';
@Component({ @Component({
selector: 'app-strategies', selector: 'app-strategies',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
MatCardModule, MatCardModule,
MatIconModule, MatIconModule,
MatButtonModule, MatButtonModule,
MatTabsModule, MatTabsModule,
MatTableModule, MatTableModule,
MatSortModule, MatSortModule,
MatPaginatorModule, MatPaginatorModule,
MatDialogModule, MatDialogModule,
MatMenuModule, MatMenuModule,
MatChipsModule, MatChipsModule,
MatProgressBarModule, MatProgressBarModule,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
StrategyDetailsComponent StrategyDetailsComponent,
], ],
templateUrl: './strategies.component.html', templateUrl: './strategies.component.html',
styleUrl: './strategies.component.css' styleUrl: './strategies.component.css',
}) })
export class StrategiesComponent implements OnInit { export class StrategiesComponent implements OnInit {
strategies: TradingStrategy[] = []; strategies: TradingStrategy[] = [];
displayedColumns: string[] = ['name', 'type', 'symbols', 'status', 'performance', 'actions']; displayedColumns: string[] = ['name', 'type', 'symbols', 'status', 'performance', 'actions'];
selectedStrategy: TradingStrategy | null = null; selectedStrategy: TradingStrategy | null = null;
isLoading = false; isLoading = false;
constructor( constructor(
private strategyService: StrategyService, private strategyService: StrategyService,
private webSocketService: WebSocketService, private webSocketService: WebSocketService,
private dialog: MatDialog private dialog: MatDialog
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.loadStrategies(); this.loadStrategies();
this.listenForStrategyUpdates(); this.listenForStrategyUpdates();
} }
loadStrategies(): void { loadStrategies(): void {
this.isLoading = true; this.isLoading = true;
this.strategyService.getStrategies().subscribe({ this.strategyService.getStrategies().subscribe({
next: (response) => { next: response => {
if (response.success) { if (response.success) {
this.strategies = response.data; this.strategies = response.data;
} }
this.isLoading = false; this.isLoading = false;
}, },
error: (error) => { error: error => {
console.error('Error loading strategies:', error); console.error('Error loading strategies:', error);
this.isLoading = false; this.isLoading = false;
} },
}); });
} }
listenForStrategyUpdates(): void { listenForStrategyUpdates(): void {
this.webSocketService.messages.subscribe(message => { this.webSocketService.messages.subscribe(message => {
if (message.type === 'STRATEGY_CREATED' || if (
message.type === 'STRATEGY_UPDATED' || message.type === 'STRATEGY_CREATED' ||
message.type === 'STRATEGY_STATUS_CHANGED') { message.type === 'STRATEGY_UPDATED' ||
// Refresh the strategy list when changes occur message.type === 'STRATEGY_STATUS_CHANGED'
this.loadStrategies(); ) {
} // Refresh the strategy list when changes occur
}); this.loadStrategies();
} }
});
getStatusColor(status: string): string { }
switch (status) {
case 'ACTIVE': return 'green'; getStatusColor(status: string): string {
case 'PAUSED': return 'orange'; switch (status) {
case 'ERROR': return 'red'; case 'ACTIVE':
default: return 'gray'; return 'green';
} case 'PAUSED':
} return 'orange';
case 'ERROR':
openStrategyDialog(strategy?: TradingStrategy): void { return 'red';
const dialogRef = this.dialog.open(StrategyDialogComponent, { default:
width: '600px', return 'gray';
data: strategy || null }
}); }
dialogRef.afterClosed().subscribe(result => { openStrategyDialog(strategy?: TradingStrategy): void {
if (result) { const dialogRef = this.dialog.open(StrategyDialogComponent, {
this.loadStrategies(); width: '600px',
} data: strategy || null,
}); });
}
dialogRef.afterClosed().subscribe(result => {
openBacktestDialog(strategy?: TradingStrategy): void { if (result) {
const dialogRef = this.dialog.open(BacktestDialogComponent, { this.loadStrategies();
width: '800px', }
data: strategy || null });
}); }
dialogRef.afterClosed().subscribe(result => { openBacktestDialog(strategy?: TradingStrategy): void {
if (result) { const dialogRef = this.dialog.open(BacktestDialogComponent, {
// Handle backtest result if needed width: '800px',
} data: strategy || null,
}); });
}
dialogRef.afterClosed().subscribe(result => {
toggleStrategyStatus(strategy: TradingStrategy): void { if (result) {
this.isLoading = true; // Handle backtest result if needed
}
if (strategy.status === 'ACTIVE') { });
this.strategyService.pauseStrategy(strategy.id).subscribe({ }
next: () => this.loadStrategies(),
error: (error) => { toggleStrategyStatus(strategy: TradingStrategy): void {
console.error('Error pausing strategy:', error); this.isLoading = true;
this.isLoading = false;
} if (strategy.status === 'ACTIVE') {
}); this.strategyService.pauseStrategy(strategy.id).subscribe({
} else { next: () => this.loadStrategies(),
this.strategyService.startStrategy(strategy.id).subscribe({ error: error => {
next: () => this.loadStrategies(), console.error('Error pausing strategy:', error);
error: (error) => { this.isLoading = false;
console.error('Error starting strategy:', error); },
this.isLoading = false; });
} } else {
}); this.strategyService.startStrategy(strategy.id).subscribe({
} next: () => this.loadStrategies(),
} error: error => {
console.error('Error starting strategy:', error);
viewStrategyDetails(strategy: TradingStrategy): void { this.isLoading = false;
this.selectedStrategy = strategy; },
} });
} }
}
viewStrategyDetails(strategy: TradingStrategy): void {
this.selectedStrategy = strategy;
}
}

View file

@ -4,122 +4,144 @@
<mat-card class="flex-1 p-4"> <mat-card class="flex-1 p-4">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
<h2 class="text-xl font-bold">{{strategy.name}}</h2> <h2 class="text-xl font-bold">{{ strategy.name }}</h2>
<p class="text-gray-600 text-sm">{{strategy.description}}</p> <p class="text-gray-600 text-sm">{{ strategy.description }}</p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button mat-raised-button color="primary" class="mr-2" (click)="openBacktestDialog()"> <button mat-raised-button color="primary" class="mr-2" (click)="openBacktestDialog()">
Run Backtest Run Backtest
</button> </button>
<span class="px-3 py-1 rounded-full text-xs font-semibold" <span
[style.background-color]="getStatusColor(strategy.status)" class="px-3 py-1 rounded-full text-xs font-semibold"
style="color: white;"> [style.background-color]="getStatusColor(strategy.status)"
{{strategy.status}} style="color: white"
>
{{ strategy.status }}
</span> </span>
</div> </div>
</div> </div>
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<h3 class="font-semibold text-sm text-gray-600">Type</h3> <h3 class="font-semibold text-sm text-gray-600">Type</h3>
<p>{{strategy.type}}</p> <p>{{ strategy.type }}</p>
</div> </div>
<div> <div>
<h3 class="font-semibold text-sm text-gray-600">Created</h3> <h3 class="font-semibold text-sm text-gray-600">Created</h3>
<p>{{strategy.createdAt | date:'medium'}}</p> <p>{{ strategy.createdAt | date: 'medium' }}</p>
</div> </div>
<div> <div>
<h3 class="font-semibold text-sm text-gray-600">Last Updated</h3> <h3 class="font-semibold text-sm text-gray-600">Last Updated</h3>
<p>{{strategy.updatedAt | date:'medium'}}</p> <p>{{ strategy.updatedAt | date: 'medium' }}</p>
</div> </div>
<div> <div>
<h3 class="font-semibold text-sm text-gray-600">Symbols</h3> <h3 class="font-semibold text-sm text-gray-600">Symbols</h3>
<div class="flex flex-wrap gap-1 mt-1"> <div class="flex flex-wrap gap-1 mt-1">
<mat-chip *ngFor="let symbol of strategy.symbols">{{symbol}}</mat-chip> <mat-chip *ngFor="let symbol of strategy.symbols">{{ symbol }}</mat-chip>
</div> </div>
</div> </div>
</div> </div>
</mat-card> </mat-card>
<!-- Performance Summary Card --> <!-- Performance Summary Card -->
<mat-card class="md:w-1/3 p-4"> <mat-card class="md:w-1/3 p-4">
<h3 class="text-lg font-bold mb-3">Performance</h3> <h3 class="text-lg font-bold mb-3">Performance</h3>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
<p class="text-sm text-gray-600">Return</p> <p class="text-sm text-gray-600">Return</p>
<p class="text-xl font-semibold" <p
[ngClass]="{'text-green-600': performance.totalReturn >= 0, 'text-red-600': performance.totalReturn < 0}"> class="text-xl font-semibold"
{{performance.totalReturn | percent:'1.2-2'}} [ngClass]="{
'text-green-600': performance.totalReturn >= 0,
'text-red-600': performance.totalReturn < 0,
}"
>
{{ performance.totalReturn | percent: '1.2-2' }}
</p> </p>
</div> </div>
<div> <div>
<p class="text-sm text-gray-600">Win Rate</p> <p class="text-sm text-gray-600">Win Rate</p>
<p class="text-xl font-semibold">{{performance.winRate | percent:'1.0-0'}}</p> <p class="text-xl font-semibold">{{ performance.winRate | percent: '1.0-0' }}</p>
</div> </div>
<div> <div>
<p class="text-sm text-gray-600">Sharpe Ratio</p> <p class="text-sm text-gray-600">Sharpe Ratio</p>
<p class="text-xl font-semibold">{{performance.sharpeRatio | number:'1.2-2'}}</p> <p class="text-xl font-semibold">{{ performance.sharpeRatio | number: '1.2-2' }}</p>
</div> </div>
<div> <div>
<p class="text-sm text-gray-600">Max Drawdown</p> <p class="text-sm text-gray-600">Max Drawdown</p>
<p class="text-xl font-semibold text-red-600">{{performance.maxDrawdown | percent:'1.2-2'}}</p> <p class="text-xl font-semibold text-red-600">
{{ performance.maxDrawdown | percent: '1.2-2' }}
</p>
</div> </div>
<div> <div>
<p class="text-sm text-gray-600">Total Trades</p> <p class="text-sm text-gray-600">Total Trades</p>
<p class="text-xl font-semibold">{{performance.totalTrades}}</p> <p class="text-xl font-semibold">{{ performance.totalTrades }}</p>
</div> </div>
<div> <div>
<p class="text-sm text-gray-600">Sortino Ratio</p> <p class="text-sm text-gray-600">Sortino Ratio</p>
<p class="text-xl font-semibold">{{performance.sortinoRatio | number:'1.2-2'}}</p> <p class="text-xl font-semibold">{{ performance.sortinoRatio | number: '1.2-2' }}</p>
</div> </div>
</div> </div>
<mat-divider class="my-4"></mat-divider> <mat-divider class="my-4"></mat-divider>
<div class="flex justify-between mt-2"> <div class="flex justify-between mt-2">
<button mat-button color="primary" *ngIf="strategy.status !== 'ACTIVE'" (click)="activateStrategy()"> <button
mat-button
color="primary"
*ngIf="strategy.status !== 'ACTIVE'"
(click)="activateStrategy()"
>
<mat-icon>play_arrow</mat-icon> Start <mat-icon>play_arrow</mat-icon> Start
</button> </button>
<button mat-button color="accent" *ngIf="strategy.status === 'ACTIVE'" (click)="pauseStrategy()"> <button
mat-button
color="accent"
*ngIf="strategy.status === 'ACTIVE'"
(click)="pauseStrategy()"
>
<mat-icon>pause</mat-icon> Pause <mat-icon>pause</mat-icon> Pause
</button> </button>
<button mat-button color="warn" *ngIf="strategy.status === 'ACTIVE'" (click)="stopStrategy()"> <button
mat-button
color="warn"
*ngIf="strategy.status === 'ACTIVE'"
(click)="stopStrategy()"
>
<mat-icon>stop</mat-icon> Stop <mat-icon>stop</mat-icon> Stop
</button> </button>
<button mat-button (click)="openEditDialog()"> <button mat-button (click)="openEditDialog()"><mat-icon>edit</mat-icon> Edit</button>
<mat-icon>edit</mat-icon> Edit
</button>
</div> </div>
</mat-card> </mat-card>
</div> </div>
<!-- Parameters Card --> <!-- Parameters Card -->
<mat-card class="p-4"> <mat-card class="p-4">
<h3 class="text-lg font-bold mb-3">Strategy Parameters</h3> <h3 class="text-lg font-bold mb-3">Strategy Parameters</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4"> <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div *ngFor="let param of strategy.parameters | keyvalue"> <div *ngFor="let param of strategy.parameters | keyvalue">
<p class="text-sm text-gray-600">{{param.key}}</p> <p class="text-sm text-gray-600">{{ param.key }}</p>
<p class="font-semibold">{{param.value}}</p> <p class="font-semibold">{{ param.value }}</p>
</div> </div>
</div> </div>
</mat-card> </mat-card>
<!-- Backtest Results Section (only shown when a backtest has been run) --> <!-- Backtest Results Section (only shown when a backtest has been run) -->
<div *ngIf="backtestResult" class="backtest-results space-y-6"> <div *ngIf="backtestResult" class="backtest-results space-y-6">
<h2 class="text-xl font-bold">Backtest Results</h2> <h2 class="text-xl font-bold">Backtest Results</h2>
<!-- Performance Metrics Component --> <!-- Performance Metrics Component -->
<app-performance-metrics [backtestResult]="backtestResult"></app-performance-metrics> <app-performance-metrics [backtestResult]="backtestResult"></app-performance-metrics>
<!-- Equity Chart Component --> <!-- Equity Chart Component -->
<app-equity-chart [backtestResult]="backtestResult"></app-equity-chart> <app-equity-chart [backtestResult]="backtestResult"></app-equity-chart>
<!-- Drawdown Chart Component --> <!-- Drawdown Chart Component -->
<app-drawdown-chart [backtestResult]="backtestResult"></app-drawdown-chart> <app-drawdown-chart [backtestResult]="backtestResult"></app-drawdown-chart>
<!-- Trades Table Component --> <!-- Trades Table Component -->
<app-trades-table [backtestResult]="backtestResult"></app-trades-table> <app-trades-table [backtestResult]="backtestResult"></app-trades-table>
</div> </div>
<!-- Tabs for Signals/Trades --> <!-- Tabs for Signals/Trades -->
<mat-card class="p-0"> <mat-card class="p-0">
<mat-tab-group> <mat-tab-group>
@ -140,18 +162,20 @@
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let signal of signals"> <tr *ngFor="let signal of signals">
<td class="py-2">{{signal.timestamp | date:'short'}}</td> <td class="py-2">{{ signal.timestamp | date: 'short' }}</td>
<td class="py-2">{{signal.symbol}}</td> <td class="py-2">{{ signal.symbol }}</td>
<td class="py-2"> <td class="py-2">
<span class="px-2 py-1 rounded text-xs font-semibold" <span
[style.background-color]="getSignalColor(signal.action)" class="px-2 py-1 rounded text-xs font-semibold"
style="color: white;"> [style.background-color]="getSignalColor(signal.action)"
{{signal.action}} style="color: white"
>
{{ signal.action }}
</span> </span>
</td> </td>
<td class="py-2">${{signal.price | number:'1.2-2'}}</td> <td class="py-2">${{ signal.price | number: '1.2-2' }}</td>
<td class="py-2">{{signal.quantity}}</td> <td class="py-2">{{ signal.quantity }}</td>
<td class="py-2">{{signal.confidence | percent:'1.0-0'}}</td> <td class="py-2">{{ signal.confidence | percent: '1.0-0' }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -161,7 +185,7 @@
</ng-template> </ng-template>
</div> </div>
</mat-tab> </mat-tab>
<!-- Trades Tab --> <!-- Trades Tab -->
<mat-tab label="Recent Trades"> <mat-tab label="Recent Trades">
<div class="p-4"> <div class="p-4">
@ -179,19 +203,30 @@
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let trade of trades"> <tr *ngFor="let trade of trades">
<td class="py-2">{{trade.symbol}}</td> <td class="py-2">{{ trade.symbol }}</td>
<td class="py-2"> <td class="py-2">
${{trade.entryPrice | number:'1.2-2'}} @ {{trade.entryTime | date:'short'}} ${{ trade.entryPrice | number: '1.2-2' }} &#64;
{{ trade.entryTime | date: 'short' }}
</td> </td>
<td class="py-2"> <td class="py-2">
${{trade.exitPrice | number:'1.2-2'}} @ {{trade.exitTime | date:'short'}} ${{ trade.exitPrice | number: '1.2-2' }} &#64;
{{ trade.exitTime | date: 'short' }}
</td> </td>
<td class="py-2">{{trade.quantity}}</td> <td class="py-2">{{ trade.quantity }}</td>
<td class="py-2" [ngClass]="{'text-green-600': trade.pnl >= 0, 'text-red-600': trade.pnl < 0}"> <td
${{trade.pnl | number:'1.2-2'}} class="py-2"
[ngClass]="{ 'text-green-600': trade.pnl >= 0, 'text-red-600': trade.pnl < 0 }"
>
${{ trade.pnl | number: '1.2-2' }}
</td> </td>
<td class="py-2" [ngClass]="{'text-green-600': trade.pnlPercent >= 0, 'text-red-600': trade.pnlPercent < 0}"> <td
{{trade.pnlPercent | number:'1.2-2'}}% class="py-2"
[ngClass]="{
'text-green-600': trade.pnlPercent >= 0,
'text-red-600': trade.pnlPercent < 0,
}"
>
{{ trade.pnlPercent | number: '1.2-2' }}%
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -208,7 +243,7 @@
<mat-card class="p-6 flex items-center" *ngIf="!strategy"> <mat-card class="p-6 flex items-center" *ngIf="!strategy">
<div class="text-center text-gray-500 w-full"> <div class="text-center text-gray-500 w-full">
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">psychology</mat-icon> <mat-icon style="font-size: 4rem; width: 4rem; height: 4rem">psychology</mat-icon>
<p class="mb-4">No strategy selected</p> <p class="mb-4">No strategy selected</p>
</div> </div>
</mat-card> </mat-card>

View file

@ -1,381 +1,389 @@
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { CommonModule } from '@angular/common';
import { CommonModule } from '@angular/common'; import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs'; import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon'; import { MatChipsModule } from '@angular/material/chips';
import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog';
import { MatTableModule } from '@angular/material/table'; import { MatDividerModule } from '@angular/material/divider';
import { MatChipsModule } from '@angular/material/chips'; import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatDividerModule } from '@angular/material/divider'; import { MatTableModule } from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog'; import { MatTabsModule } from '@angular/material/tabs';
import { BacktestResult, TradingStrategy, StrategyService } from '../../../services/strategy.service'; import {
import { WebSocketService } from '../../../services/websocket.service'; BacktestResult,
import { EquityChartComponent } from '../components/equity-chart.component'; StrategyService,
import { DrawdownChartComponent } from '../components/drawdown-chart.component'; TradingStrategy,
import { TradesTableComponent } from '../components/trades-table.component'; } from '../../../services/strategy.service';
import { PerformanceMetricsComponent } from '../components/performance-metrics.component'; import { WebSocketService } from '../../../services/websocket.service';
import { StrategyDialogComponent } from '../dialogs/strategy-dialog.component'; import { DrawdownChartComponent } from '../components/drawdown-chart.component';
import { BacktestDialogComponent } from '../dialogs/backtest-dialog.component'; import { EquityChartComponent } from '../components/equity-chart.component';
import { PerformanceMetricsComponent } from '../components/performance-metrics.component';
@Component({ import { TradesTableComponent } from '../components/trades-table.component';
selector: 'app-strategy-details', import { BacktestDialogComponent } from '../dialogs/backtest-dialog.component';
standalone: true, import { StrategyDialogComponent } from '../dialogs/strategy-dialog.component';
imports: [
CommonModule, @Component({
MatCardModule, selector: 'app-strategy-details',
MatTabsModule, standalone: true,
MatIconModule, imports: [
MatButtonModule, CommonModule,
MatTableModule, MatCardModule,
MatChipsModule, MatTabsModule,
MatProgressBarModule, MatIconModule,
MatDividerModule, MatButtonModule,
EquityChartComponent, MatTableModule,
DrawdownChartComponent, MatChipsModule,
TradesTableComponent, MatProgressBarModule,
PerformanceMetricsComponent MatDividerModule,
], EquityChartComponent,
templateUrl: './strategy-details.component.html', DrawdownChartComponent,
styleUrl: './strategy-details.component.css' TradesTableComponent,
}) PerformanceMetricsComponent,
export class StrategyDetailsComponent implements OnChanges { ],
@Input() strategy: TradingStrategy | null = null; templateUrl: './strategy-details.component.html',
styleUrl: './strategy-details.component.css',
signals: any[] = []; })
trades: any[] = []; export class StrategyDetailsComponent implements OnChanges {
performance: any = {}; @Input() strategy: TradingStrategy | null = null;
isLoadingSignals = false;
isLoadingTrades = false; signals: any[] = [];
backtestResult: BacktestResult | undefined; trades: any[] = [];
performance: any = {};
constructor( isLoadingSignals = false;
private strategyService: StrategyService, isLoadingTrades = false;
private webSocketService: WebSocketService, backtestResult: BacktestResult | undefined;
private dialog: MatDialog
) {} constructor(
private strategyService: StrategyService,
ngOnChanges(changes: SimpleChanges): void { private webSocketService: WebSocketService,
if (changes['strategy'] && this.strategy) { private dialog: MatDialog
this.loadStrategyData(); ) {}
this.listenForUpdates();
} ngOnChanges(changes: SimpleChanges): void {
} if (changes['strategy'] && this.strategy) {
this.loadStrategyData();
loadStrategyData(): void { this.listenForUpdates();
if (!this.strategy) return; }
}
// In a real implementation, these would call API methods to fetch the data
this.loadSignals(); loadStrategyData(): void {
this.loadTrades(); if (!this.strategy) return;
this.loadPerformance();
} // In a real implementation, these would call API methods to fetch the data
loadSignals(): void { this.loadSignals();
if (!this.strategy) return; this.loadTrades();
this.loadPerformance();
this.isLoadingSignals = true; }
loadSignals(): void {
// First check if we can get real signals from the API if (!this.strategy) return;
this.strategyService.getStrategySignals(this.strategy.id)
.subscribe({ this.isLoadingSignals = true;
next: (response) => {
if (response.success && response.data && response.data.length > 0) { // First check if we can get real signals from the API
this.signals = response.data; this.strategyService.getStrategySignals(this.strategy.id).subscribe({
} else { next: response => {
// Fallback to mock data if no real signals available if (response.success && response.data && response.data.length > 0) {
this.signals = this.generateMockSignals(); this.signals = response.data;
} } else {
this.isLoadingSignals = false; // Fallback to mock data if no real signals available
}, this.signals = this.generateMockSignals();
error: (error) => { }
console.error('Error loading signals', error); this.isLoadingSignals = false;
// Fallback to mock data on error },
this.signals = this.generateMockSignals(); error: error => {
this.isLoadingSignals = false; console.error('Error loading signals', error);
} // Fallback to mock data on error
}); this.signals = this.generateMockSignals();
} this.isLoadingSignals = false;
},
loadTrades(): void { });
if (!this.strategy) return; }
this.isLoadingTrades = true; loadTrades(): void {
if (!this.strategy) return;
// First check if we can get real trades from the API
this.strategyService.getStrategyTrades(this.strategy.id) this.isLoadingTrades = true;
.subscribe({
next: (response) => { // First check if we can get real trades from the API
if (response.success && response.data && response.data.length > 0) { this.strategyService.getStrategyTrades(this.strategy.id).subscribe({
this.trades = response.data; next: response => {
} else { if (response.success && response.data && response.data.length > 0) {
// Fallback to mock data if no real trades available this.trades = response.data;
this.trades = this.generateMockTrades(); } else {
} // Fallback to mock data if no real trades available
this.isLoadingTrades = false; this.trades = this.generateMockTrades();
}, }
error: (error) => { this.isLoadingTrades = false;
console.error('Error loading trades', error); },
// Fallback to mock data on error error: error => {
this.trades = this.generateMockTrades(); console.error('Error loading trades', error);
this.isLoadingTrades = false; // Fallback to mock data on error
} this.trades = this.generateMockTrades();
}); this.isLoadingTrades = false;
} },
});
loadPerformance(): void { }
// This would be an API call in a real implementation
this.performance = { loadPerformance(): void {
totalReturn: this.strategy?.performance.totalReturn || 0, // This would be an API call in a real implementation
winRate: this.strategy?.performance.winRate || 0, this.performance = {
sharpeRatio: this.strategy?.performance.sharpeRatio || 0, totalReturn: this.strategy?.performance.totalReturn || 0,
maxDrawdown: this.strategy?.performance.maxDrawdown || 0, winRate: this.strategy?.performance.winRate || 0,
totalTrades: this.strategy?.performance.totalTrades || 0, sharpeRatio: this.strategy?.performance.sharpeRatio || 0,
// Additional metrics that would come from the API maxDrawdown: this.strategy?.performance.maxDrawdown || 0,
dailyReturn: 0.0012, totalTrades: this.strategy?.performance.totalTrades || 0,
volatility: 0.008, // Additional metrics that would come from the API
sortinoRatio: 1.2, dailyReturn: 0.0012,
calmarRatio: 0.7 volatility: 0.008,
}; sortinoRatio: 1.2,
} calmarRatio: 0.7,
listenForUpdates(): void { };
if (!this.strategy) return; }
listenForUpdates(): void {
// Subscribe to strategy signals if (!this.strategy) return;
this.webSocketService.getStrategySignals(this.strategy.id)
.subscribe((signal: any) => { // Subscribe to strategy signals
// Add the new signal to the top of the list this.webSocketService.getStrategySignals(this.strategy.id).subscribe((signal: any) => {
this.signals = [signal, ...this.signals.slice(0, 9)]; // Keep only the latest 10 signals // Add the new signal to the top of the list
}); this.signals = [signal, ...this.signals.slice(0, 9)]; // Keep only the latest 10 signals
});
// Subscribe to strategy trades
this.webSocketService.getStrategyTrades(this.strategy.id) // Subscribe to strategy trades
.subscribe((trade: any) => { this.webSocketService.getStrategyTrades(this.strategy.id).subscribe((trade: any) => {
// Add the new trade to the top of the list // Add the new trade to the top of the list
this.trades = [trade, ...this.trades.slice(0, 9)]; // Keep only the latest 10 trades this.trades = [trade, ...this.trades.slice(0, 9)]; // Keep only the latest 10 trades
// Update performance metrics // Update performance metrics
this.updatePerformanceMetrics(); this.updatePerformanceMetrics();
}); });
// Subscribe to strategy status updates // Subscribe to strategy status updates
this.webSocketService.getStrategyUpdates() this.webSocketService.getStrategyUpdates().subscribe((update: any) => {
.subscribe((update: any) => { if (update.strategyId === this.strategy?.id) {
if (update.strategyId === this.strategy?.id) { // Update strategy status if changed
// Update strategy status if changed if (update.status && this.strategy && this.strategy.status !== update.status) {
if (update.status && this.strategy && this.strategy.status !== update.status) { this.strategy.status = update.status;
this.strategy.status = update.status; }
}
// Update other fields if present
// Update other fields if present if (update.performance && this.strategy) {
if (update.performance && this.strategy) { this.strategy.performance = {
this.strategy.performance = { ...this.strategy.performance,
...this.strategy.performance, ...update.performance,
...update.performance };
}; this.performance = {
this.performance = { ...this.performance,
...this.performance, ...update.performance,
...update.performance };
}; }
} }
} });
});
console.log('WebSocket listeners for strategy updates initialized');
console.log('WebSocket listeners for strategy updates initialized'); }
}
/**
/** * Update performance metrics when new trades come in
* Update performance metrics when new trades come in */
*/ private updatePerformanceMetrics(): void {
private updatePerformanceMetrics(): void { if (!this.strategy || this.trades.length === 0) return;
if (!this.strategy || this.trades.length === 0) return;
// Calculate basic metrics
// Calculate basic metrics const winningTrades = this.trades.filter(t => t.pnl > 0);
const winningTrades = this.trades.filter(t => t.pnl > 0); const losingTrades = this.trades.filter(t => t.pnl < 0);
const losingTrades = this.trades.filter(t => t.pnl < 0);
const totalPnl = this.trades.reduce((sum, trade) => sum + trade.pnl, 0);
const totalPnl = this.trades.reduce((sum, trade) => sum + trade.pnl, 0); const winRate = winningTrades.length / this.trades.length;
const winRate = winningTrades.length / this.trades.length;
// Update performance data
// Update performance data const currentPerformance = this.performance || {};
const currentPerformance = this.performance || {}; this.performance = {
this.performance = { ...currentPerformance,
...currentPerformance, totalTrades: this.trades.length,
totalTrades: this.trades.length, winRate: winRate,
winRate: winRate, totalReturn: (currentPerformance.totalReturn || 0) + totalPnl / 10000, // Approximate
totalReturn: (currentPerformance.totalReturn || 0) + (totalPnl / 10000) // Approximate };
};
// Update strategy performance as well
// Update strategy performance as well if (this.strategy && this.strategy.performance) {
if (this.strategy && this.strategy.performance) { this.strategy.performance = {
this.strategy.performance = { ...this.strategy.performance,
...this.strategy.performance, totalTrades: this.trades.length,
totalTrades: this.trades.length, winRate: winRate,
winRate: winRate };
}; }
} }
}
getStatusColor(status: string): string {
getStatusColor(status: string): string { switch (status) {
switch (status) { case 'ACTIVE':
case 'ACTIVE': return 'green'; return 'green';
case 'PAUSED': return 'orange'; case 'PAUSED':
case 'ERROR': return 'red'; return 'orange';
default: return 'gray'; case 'ERROR':
} return 'red';
} default:
return 'gray';
getSignalColor(action: string): string { }
switch (action) { }
case 'BUY': return 'green';
case 'SELL': return 'red'; getSignalColor(action: string): string {
default: return 'gray'; switch (action) {
} case 'BUY':
} return 'green';
case 'SELL':
/** return 'red';
* Open the backtest dialog to run a backtest for this strategy default:
*/ return 'gray';
openBacktestDialog(): void { }
if (!this.strategy) return; }
const dialogRef = this.dialog.open(BacktestDialogComponent, { /**
width: '800px', * Open the backtest dialog to run a backtest for this strategy
data: this.strategy */
}); openBacktestDialog(): void {
if (!this.strategy) return;
dialogRef.afterClosed().subscribe(result => {
if (result) { const dialogRef = this.dialog.open(BacktestDialogComponent, {
// Store the backtest result for visualization width: '800px',
this.backtestResult = result; data: this.strategy,
} });
});
} dialogRef.afterClosed().subscribe(result => {
if (result) {
/** // Store the backtest result for visualization
* Open the strategy edit dialog this.backtestResult = result;
*/ }
openEditDialog(): void { });
if (!this.strategy) return; }
const dialogRef = this.dialog.open(StrategyDialogComponent, { /**
width: '600px', * Open the strategy edit dialog
data: this.strategy */
}); openEditDialog(): void {
if (!this.strategy) return;
dialogRef.afterClosed().subscribe(result => {
if (result) { const dialogRef = this.dialog.open(StrategyDialogComponent, {
// Refresh strategy data after edit width: '600px',
this.loadStrategyData(); data: this.strategy,
} });
});
} dialogRef.afterClosed().subscribe(result => {
if (result) {
/** // Refresh strategy data after edit
* Start the strategy this.loadStrategyData();
*/ }
activateStrategy(): void { });
if (!this.strategy) return; }
this.strategyService.startStrategy(this.strategy.id).subscribe({ /**
next: (response) => { * Start the strategy
if (response.success) { */
this.strategy!.status = 'ACTIVE'; activateStrategy(): void {
} if (!this.strategy) return;
},
error: (error) => { this.strategyService.startStrategy(this.strategy.id).subscribe({
console.error('Error starting strategy:', error); next: response => {
} if (response.success) {
}); this.strategy!.status = 'ACTIVE';
} }
},
/** error: error => {
* Pause the strategy console.error('Error starting strategy:', error);
*/ },
pauseStrategy(): void { });
if (!this.strategy) return; }
this.strategyService.pauseStrategy(this.strategy.id).subscribe({ /**
next: (response) => { * Pause the strategy
if (response.success) { */
this.strategy!.status = 'PAUSED'; pauseStrategy(): void {
} if (!this.strategy) return;
},
error: (error) => { this.strategyService.pauseStrategy(this.strategy.id).subscribe({
console.error('Error pausing strategy:', error); next: response => {
} if (response.success) {
}); this.strategy!.status = 'PAUSED';
} }
},
/** error: error => {
* Stop the strategy console.error('Error pausing strategy:', error);
*/ },
stopStrategy(): void { });
if (!this.strategy) return; }
this.strategyService.stopStrategy(this.strategy.id).subscribe({ /**
next: (response) => { * Stop the strategy
if (response.success) { */
this.strategy!.status = 'INACTIVE'; stopStrategy(): void {
} if (!this.strategy) return;
},
error: (error) => { this.strategyService.stopStrategy(this.strategy.id).subscribe({
console.error('Error stopping strategy:', error); next: response => {
} if (response.success) {
}); this.strategy!.status = 'INACTIVE';
} }
},
// Methods to generate mock data error: error => {
private generateMockSignals(): any[] { console.error('Error stopping strategy:', error);
if (!this.strategy) return []; },
});
const signals = []; }
const actions = ['BUY', 'SELL', 'HOLD'];
const now = new Date(); // Methods to generate mock data
private generateMockSignals(): any[] {
for (let i = 0; i < 10; i++) { if (!this.strategy) return [];
const symbol = this.strategy.symbols[Math.floor(Math.random() * this.strategy.symbols.length)];
const action = actions[Math.floor(Math.random() * actions.length)]; const signals = [];
const actions = ['BUY', 'SELL', 'HOLD'];
signals.push({ const now = new Date();
id: `sig_${i}`,
symbol, for (let i = 0; i < 10; i++) {
action, const symbol =
confidence: 0.7 + Math.random() * 0.3, this.strategy.symbols[Math.floor(Math.random() * this.strategy.symbols.length)];
price: 100 + Math.random() * 50, const action = actions[Math.floor(Math.random() * actions.length)];
timestamp: new Date(now.getTime() - i * 1000 * 60 * 30), // 30 min intervals
quantity: Math.floor(10 + Math.random() * 90) signals.push({
}); id: `sig_${i}`,
} symbol,
action,
return signals; confidence: 0.7 + Math.random() * 0.3,
} price: 100 + Math.random() * 50,
timestamp: new Date(now.getTime() - i * 1000 * 60 * 30), // 30 min intervals
private generateMockTrades(): any[] { quantity: Math.floor(10 + Math.random() * 90),
if (!this.strategy) return []; });
}
const trades = [];
const now = new Date(); return signals;
}
for (let i = 0; i < 10; i++) {
const symbol = this.strategy.symbols[Math.floor(Math.random() * this.strategy.symbols.length)]; private generateMockTrades(): any[] {
const entryPrice = 100 + Math.random() * 50; if (!this.strategy) return [];
const exitPrice = entryPrice * (1 + (Math.random() * 0.1 - 0.05)); // -5% to +5%
const quantity = Math.floor(10 + Math.random() * 90); const trades = [];
const pnl = (exitPrice - entryPrice) * quantity; const now = new Date();
trades.push({ for (let i = 0; i < 10; i++) {
id: `trade_${i}`, const symbol =
symbol, this.strategy.symbols[Math.floor(Math.random() * this.strategy.symbols.length)];
entryPrice, const entryPrice = 100 + Math.random() * 50;
entryTime: new Date(now.getTime() - (i + 5) * 1000 * 60 * 60), // Hourly intervals const exitPrice = entryPrice * (1 + (Math.random() * 0.1 - 0.05)); // -5% to +5%
exitPrice, const quantity = Math.floor(10 + Math.random() * 90);
exitTime: new Date(now.getTime() - i * 1000 * 60 * 60), const pnl = (exitPrice - entryPrice) * quantity;
quantity,
pnl, trades.push({
pnlPercent: ((exitPrice - entryPrice) / entryPrice) * 100 id: `trade_${i}`,
}); symbol,
} entryPrice,
entryTime: new Date(now.getTime() - (i + 5) * 1000 * 60 * 60), // Hourly intervals
return trades; exitPrice,
} exitTime: new Date(now.getTime() - i * 1000 * 60 * 60),
} quantity,
pnl,
pnlPercent: ((exitPrice - entryPrice) / entryPrice) * 100,
});
}
return trades;
}
}

View file

@ -1,98 +1,104 @@
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http';
import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
export interface RiskThresholds { export interface RiskThresholds {
maxPositionSize: number; maxPositionSize: number;
maxDailyLoss: number; maxDailyLoss: number;
maxPortfolioRisk: number; maxPortfolioRisk: number;
volatilityLimit: number; volatilityLimit: number;
} }
export interface RiskEvaluation { export interface RiskEvaluation {
symbol: string; symbol: string;
positionValue: number; positionValue: number;
positionRisk: number; positionRisk: number;
violations: string[]; violations: string[];
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH'; riskLevel: 'LOW' | 'MEDIUM' | 'HIGH';
} }
export interface MarketData { export interface MarketData {
symbol: string; symbol: string;
price: number; price: number;
change: number; change: number;
changePercent: number; changePercent: number;
volume: number; volume: number;
timestamp: string; timestamp: string;
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class ApiService { export class ApiService {
private readonly baseUrls = { private readonly baseUrls = {
riskGuardian: 'http://localhost:3002', riskGuardian: 'http://localhost:3002',
strategyOrchestrator: 'http://localhost:3003', strategyOrchestrator: 'http://localhost:3003',
marketDataGateway: 'http://localhost:3001' marketDataGateway: 'http://localhost:3001',
}; };
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
// Risk Guardian API // Risk Guardian API
getRiskThresholds(): Observable<{ success: boolean; data: RiskThresholds }> { getRiskThresholds(): Observable<{ success: boolean; data: RiskThresholds }> {
return this.http.get<{ success: boolean; data: RiskThresholds }>( return this.http.get<{ success: boolean; data: RiskThresholds }>(
`${this.baseUrls.riskGuardian}/api/risk/thresholds` `${this.baseUrls.riskGuardian}/api/risk/thresholds`
); );
} }
updateRiskThresholds(thresholds: RiskThresholds): Observable<{ success: boolean; data: RiskThresholds }> { updateRiskThresholds(
return this.http.put<{ success: boolean; data: RiskThresholds }>( thresholds: RiskThresholds
`${this.baseUrls.riskGuardian}/api/risk/thresholds`, ): Observable<{ success: boolean; data: RiskThresholds }> {
thresholds return this.http.put<{ success: boolean; data: RiskThresholds }>(
); `${this.baseUrls.riskGuardian}/api/risk/thresholds`,
} thresholds
);
evaluateRisk(params: { }
symbol: string;
quantity: number; evaluateRisk(params: {
price: number; symbol: string;
portfolioValue: number; quantity: number;
}): Observable<{ success: boolean; data: RiskEvaluation }> { price: number;
return this.http.post<{ success: boolean; data: RiskEvaluation }>( portfolioValue: number;
`${this.baseUrls.riskGuardian}/api/risk/evaluate`, }): Observable<{ success: boolean; data: RiskEvaluation }> {
params return this.http.post<{ success: boolean; data: RiskEvaluation }>(
); `${this.baseUrls.riskGuardian}/api/risk/evaluate`,
} params
);
getRiskHistory(): Observable<{ success: boolean; data: RiskEvaluation[] }> { }
return this.http.get<{ success: boolean; data: RiskEvaluation[] }>(
`${this.baseUrls.riskGuardian}/api/risk/history` getRiskHistory(): Observable<{ success: boolean; data: RiskEvaluation[] }> {
); return this.http.get<{ success: boolean; data: RiskEvaluation[] }>(
} `${this.baseUrls.riskGuardian}/api/risk/history`
);
// Strategy Orchestrator API }
getStrategies(): Observable<{ success: boolean; data: any[] }> {
return this.http.get<{ success: boolean; data: any[] }>( // Strategy Orchestrator API
`${this.baseUrls.strategyOrchestrator}/api/strategies` getStrategies(): Observable<{ success: boolean; data: any[] }> {
); return this.http.get<{ success: boolean; data: any[] }>(
} `${this.baseUrls.strategyOrchestrator}/api/strategies`
);
createStrategy(strategy: any): Observable<{ success: boolean; data: any }> { }
return this.http.post<{ success: boolean; data: any }>(
`${this.baseUrls.strategyOrchestrator}/api/strategies`, createStrategy(strategy: any): Observable<{ success: boolean; data: any }> {
strategy return this.http.post<{ success: boolean; data: any }>(
); `${this.baseUrls.strategyOrchestrator}/api/strategies`,
} strategy
// Market Data Gateway API );
getMarketData(symbols: string[] = ['AAPL', 'GOOGL', 'MSFT', 'TSLA', 'AMZN']): Observable<{ success: boolean; data: MarketData[] }> { }
const symbolsParam = symbols.join(','); // Market Data Gateway API
return this.http.get<{ success: boolean; data: MarketData[] }>( getMarketData(
`${this.baseUrls.marketDataGateway}/api/market-data?symbols=${symbolsParam}` symbols: string[] = ['AAPL', 'GOOGL', 'MSFT', 'TSLA', 'AMZN']
); ): Observable<{ success: boolean; data: MarketData[] }> {
} const symbolsParam = symbols.join(',');
return this.http.get<{ success: boolean; data: MarketData[] }>(
// Health checks `${this.baseUrls.marketDataGateway}/api/market-data?symbols=${symbolsParam}`
checkServiceHealth(service: 'riskGuardian' | 'strategyOrchestrator' | 'marketDataGateway'): Observable<any> { );
return this.http.get(`${this.baseUrls[service]}/health`); }
}
} // Health checks
checkServiceHealth(
service: 'riskGuardian' | 'strategyOrchestrator' | 'marketDataGateway'
): Observable<any> {
return this.http.get(`${this.baseUrls[service]}/health`);
}
}

View file

@ -1,193 +1,191 @@
import { Injectable, signal, inject } from '@angular/core'; import { inject, Injectable, signal } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { WebSocketService, RiskAlert } from './websocket.service'; import { Subscription } from 'rxjs';
import { Subscription } from 'rxjs'; import { RiskAlert, WebSocketService } from './websocket.service';
export interface Notification { export interface Notification {
id: string; id: string;
type: 'info' | 'warning' | 'error' | 'success'; type: 'info' | 'warning' | 'error' | 'success';
title: string; title: string;
message: string; message: string;
timestamp: Date; timestamp: Date;
read: boolean; read: boolean;
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class NotificationService { export class NotificationService {
private snackBar = inject(MatSnackBar); private snackBar = inject(MatSnackBar);
private webSocketService = inject(WebSocketService); private webSocketService = inject(WebSocketService);
private riskAlertsSubscription?: Subscription; private riskAlertsSubscription?: Subscription;
// Reactive state // Reactive state
public notifications = signal<Notification[]>([]); public notifications = signal<Notification[]>([]);
public unreadCount = signal<number>(0); public unreadCount = signal<number>(0);
constructor() { constructor() {
this.initializeRiskAlerts(); this.initializeRiskAlerts();
} }
private initializeRiskAlerts() { private initializeRiskAlerts() {
// Subscribe to risk alerts from WebSocket // Subscribe to risk alerts from WebSocket
this.riskAlertsSubscription = this.webSocketService.getRiskAlerts().subscribe({ this.riskAlertsSubscription = this.webSocketService.getRiskAlerts().subscribe({
next: (alert: RiskAlert) => { next: (alert: RiskAlert) => {
this.handleRiskAlert(alert); this.handleRiskAlert(alert);
}, },
error: (err) => { error: err => {
console.error('Risk alert subscription error:', err); console.error('Risk alert subscription error:', err);
} },
}); });
} }
private handleRiskAlert(alert: RiskAlert) { private handleRiskAlert(alert: RiskAlert) {
const notification: Notification = { const notification: Notification = {
id: alert.id, id: alert.id,
type: this.mapSeverityToType(alert.severity), type: this.mapSeverityToType(alert.severity),
title: `Risk Alert: ${alert.symbol}`, title: `Risk Alert: ${alert.symbol}`,
message: alert.message, message: alert.message,
timestamp: new Date(alert.timestamp), timestamp: new Date(alert.timestamp),
read: false read: false,
}; };
this.addNotification(notification); this.addNotification(notification);
this.showSnackBarAlert(notification); this.showSnackBarAlert(notification);
} }
private mapSeverityToType(severity: string): 'info' | 'warning' | 'error' | 'success' { private mapSeverityToType(severity: string): 'info' | 'warning' | 'error' | 'success' {
switch (severity) { switch (severity) {
case 'HIGH': return 'error'; case 'HIGH':
case 'MEDIUM': return 'warning'; return 'error';
case 'LOW': return 'info'; case 'MEDIUM':
default: return 'info'; return 'warning';
} case 'LOW':
} return 'info';
default:
private showSnackBarAlert(notification: Notification) { return 'info';
const actionText = notification.type === 'error' ? 'Review' : 'Dismiss'; }
const duration = notification.type === 'error' ? 10000 : 5000; }
this.snackBar.open( private showSnackBarAlert(notification: Notification) {
`${notification.title}: ${notification.message}`, const actionText = notification.type === 'error' ? 'Review' : 'Dismiss';
actionText, const duration = notification.type === 'error' ? 10000 : 5000;
{
duration, this.snackBar.open(`${notification.title}: ${notification.message}`, actionText, {
panelClass: [`snack-${notification.type}`] duration,
} panelClass: [`snack-${notification.type}`],
); });
} }
// Public methods // Public methods
addNotification(notification: Notification) { addNotification(notification: Notification) {
const current = this.notifications(); const current = this.notifications();
const updated = [notification, ...current].slice(0, 50); // Keep only latest 50 const updated = [notification, ...current].slice(0, 50); // Keep only latest 50
this.notifications.set(updated); this.notifications.set(updated);
this.updateUnreadCount(); this.updateUnreadCount();
} }
markAsRead(notificationId: string) { markAsRead(notificationId: string) {
const current = this.notifications(); const current = this.notifications();
const updated = current.map(n => const updated = current.map(n => (n.id === notificationId ? { ...n, read: true } : n));
n.id === notificationId ? { ...n, read: true } : n this.notifications.set(updated);
); this.updateUnreadCount();
this.notifications.set(updated); }
this.updateUnreadCount();
} markAllAsRead() {
const current = this.notifications();
markAllAsRead() { const updated = current.map(n => ({ ...n, read: true }));
const current = this.notifications(); this.notifications.set(updated);
const updated = current.map(n => ({ ...n, read: true })); this.updateUnreadCount();
this.notifications.set(updated); }
this.updateUnreadCount();
} clearNotification(notificationId: string) {
const current = this.notifications();
clearNotification(notificationId: string) { const updated = current.filter(n => n.id !== notificationId);
const current = this.notifications(); this.notifications.set(updated);
const updated = current.filter(n => n.id !== notificationId); this.updateUnreadCount();
this.notifications.set(updated); }
this.updateUnreadCount();
} clearAllNotifications() {
this.notifications.set([]);
clearAllNotifications() { this.unreadCount.set(0);
this.notifications.set([]); }
this.unreadCount.set(0);
} private updateUnreadCount() {
const unread = this.notifications().filter(n => !n.read).length;
private updateUnreadCount() { this.unreadCount.set(unread);
const unread = this.notifications().filter(n => !n.read).length; }
this.unreadCount.set(unread);
} // Manual notification methods
showSuccess(title: string, message: string) {
// Manual notification methods const notification: Notification = {
showSuccess(title: string, message: string) { id: this.generateId(),
const notification: Notification = { type: 'success',
id: this.generateId(), title,
type: 'success', message,
title, timestamp: new Date(),
message, read: false,
timestamp: new Date(), };
read: false this.addNotification(notification);
}; this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
this.addNotification(notification); duration: 3000,
this.snackBar.open(`${title}: ${message}`, 'Dismiss', { panelClass: ['snack-success'],
duration: 3000, });
panelClass: ['snack-success'] }
});
} showError(title: string, message: string) {
const notification: Notification = {
showError(title: string, message: string) { id: this.generateId(),
const notification: Notification = { type: 'error',
id: this.generateId(), title,
type: 'error', message,
title, timestamp: new Date(),
message, read: false,
timestamp: new Date(), };
read: false this.addNotification(notification);
}; this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
this.addNotification(notification); duration: 8000,
this.snackBar.open(`${title}: ${message}`, 'Dismiss', { panelClass: ['snack-error'],
duration: 8000, });
panelClass: ['snack-error'] }
});
} showWarning(title: string, message: string) {
const notification: Notification = {
showWarning(title: string, message: string) { id: this.generateId(),
const notification: Notification = { type: 'warning',
id: this.generateId(), title,
type: 'warning', message,
title, timestamp: new Date(),
message, read: false,
timestamp: new Date(), };
read: false this.addNotification(notification);
}; this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
this.addNotification(notification); duration: 5000,
this.snackBar.open(`${title}: ${message}`, 'Dismiss', { panelClass: ['snack-warning'],
duration: 5000, });
panelClass: ['snack-warning'] }
});
} showInfo(title: string, message: string) {
const notification: Notification = {
showInfo(title: string, message: string) { id: this.generateId(),
const notification: Notification = { type: 'info',
id: this.generateId(), title,
type: 'info', message,
title, timestamp: new Date(),
message, read: false,
timestamp: new Date(), };
read: false this.addNotification(notification);
}; this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
this.addNotification(notification); duration: 4000,
this.snackBar.open(`${title}: ${message}`, 'Dismiss', { panelClass: ['snack-info'],
duration: 4000, });
panelClass: ['snack-info'] }
});
} private generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
private generateId(): string { }
return Date.now().toString(36) + Math.random().toString(36).substr(2);
} ngOnDestroy() {
this.riskAlertsSubscription?.unsubscribe();
ngOnDestroy() { }
this.riskAlertsSubscription?.unsubscribe(); }
}
}

View file

@ -1,209 +1,238 @@
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http';
import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
export interface TradingStrategy { export interface TradingStrategy {
id: string; id: string;
name: string; name: string;
description: string; description: string;
status: 'ACTIVE' | 'INACTIVE' | 'PAUSED' | 'ERROR'; status: 'ACTIVE' | 'INACTIVE' | 'PAUSED' | 'ERROR';
type: string; type: string;
symbols: string[]; symbols: string[];
parameters: Record<string, any>; parameters: Record<string, any>;
performance: { performance: {
totalTrades: number; totalTrades: number;
winRate: number; winRate: number;
totalReturn: number; totalReturn: number;
sharpeRatio: number; sharpeRatio: number;
maxDrawdown: number; maxDrawdown: number;
}; };
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
export interface BacktestRequest { export interface BacktestRequest {
strategyType: string; strategyType: string;
strategyParams: Record<string, any>; strategyParams: Record<string, any>;
symbols: string[]; symbols: string[];
startDate: Date | string; startDate: Date | string;
endDate: Date | string; endDate: Date | string;
initialCapital: number; initialCapital: number;
dataResolution: '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d'; dataResolution: '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d';
commission: number; commission: number;
slippage: number; slippage: number;
mode: 'event' | 'vector'; mode: 'event' | 'vector';
} }
export interface BacktestResult { export interface BacktestResult {
strategyId: string; strategyId: string;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
duration: number; duration: number;
initialCapital: number; initialCapital: number;
finalCapital: number; finalCapital: number;
totalReturn: number; totalReturn: number;
annualizedReturn: number; annualizedReturn: number;
sharpeRatio: number; sharpeRatio: number;
maxDrawdown: number; maxDrawdown: number;
maxDrawdownDuration: number; maxDrawdownDuration: number;
winRate: number; winRate: number;
totalTrades: number; totalTrades: number;
winningTrades: number; winningTrades: number;
losingTrades: number; losingTrades: number;
averageWinningTrade: number; averageWinningTrade: number;
averageLosingTrade: number; averageLosingTrade: number;
profitFactor: number; profitFactor: number;
dailyReturns: Array<{ date: Date; return: number }>; dailyReturns: Array<{ date: Date; return: number }>;
trades: Array<{ trades: Array<{
symbol: string; symbol: string;
entryTime: Date; entryTime: Date;
entryPrice: number; entryPrice: number;
exitTime: Date; exitTime: Date;
exitPrice: number; exitPrice: number;
quantity: number; quantity: number;
pnl: number; pnl: number;
pnlPercent: number; pnlPercent: number;
}>; }>;
// Advanced metrics // Advanced metrics
sortinoRatio?: number; sortinoRatio?: number;
calmarRatio?: number; calmarRatio?: number;
omegaRatio?: number; omegaRatio?: number;
cagr?: number; cagr?: number;
volatility?: number; volatility?: number;
ulcerIndex?: number; ulcerIndex?: number;
} }
interface ApiResponse<T> { interface ApiResponse<T> {
success: boolean; success: boolean;
data: T; data: T;
error?: string; error?: string;
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class StrategyService { export class StrategyService {
private apiBaseUrl = '/api'; // Will be proxied to the correct backend endpoint private apiBaseUrl = '/api'; // Will be proxied to the correct backend endpoint
constructor(private http: HttpClient) { } constructor(private http: HttpClient) {}
// Strategy Management // Strategy Management
getStrategies(): Observable<ApiResponse<TradingStrategy[]>> { getStrategies(): Observable<ApiResponse<TradingStrategy[]>> {
return this.http.get<ApiResponse<TradingStrategy[]>>(`${this.apiBaseUrl}/strategies`); return this.http.get<ApiResponse<TradingStrategy[]>>(`${this.apiBaseUrl}/strategies`);
} }
getStrategy(id: string): Observable<ApiResponse<TradingStrategy>> { getStrategy(id: string): Observable<ApiResponse<TradingStrategy>> {
return this.http.get<ApiResponse<TradingStrategy>>(`${this.apiBaseUrl}/strategies/${id}`); return this.http.get<ApiResponse<TradingStrategy>>(`${this.apiBaseUrl}/strategies/${id}`);
} }
createStrategy(strategy: Partial<TradingStrategy>): Observable<ApiResponse<TradingStrategy>> { createStrategy(strategy: Partial<TradingStrategy>): Observable<ApiResponse<TradingStrategy>> {
return this.http.post<ApiResponse<TradingStrategy>>(`${this.apiBaseUrl}/strategies`, strategy); return this.http.post<ApiResponse<TradingStrategy>>(`${this.apiBaseUrl}/strategies`, strategy);
} }
updateStrategy(id: string, updates: Partial<TradingStrategy>): Observable<ApiResponse<TradingStrategy>> { updateStrategy(
return this.http.put<ApiResponse<TradingStrategy>>(`${this.apiBaseUrl}/strategies/${id}`, updates); id: string,
} updates: Partial<TradingStrategy>
): Observable<ApiResponse<TradingStrategy>> {
startStrategy(id: string): Observable<ApiResponse<TradingStrategy>> { return this.http.put<ApiResponse<TradingStrategy>>(
return this.http.post<ApiResponse<TradingStrategy>>(`${this.apiBaseUrl}/strategies/${id}/start`, {}); `${this.apiBaseUrl}/strategies/${id}`,
} updates
);
stopStrategy(id: string): Observable<ApiResponse<TradingStrategy>> { }
return this.http.post<ApiResponse<TradingStrategy>>(`${this.apiBaseUrl}/strategies/${id}/stop`, {});
} startStrategy(id: string): Observable<ApiResponse<TradingStrategy>> {
return this.http.post<ApiResponse<TradingStrategy>>(
pauseStrategy(id: string): Observable<ApiResponse<TradingStrategy>> { `${this.apiBaseUrl}/strategies/${id}/start`,
return this.http.post<ApiResponse<TradingStrategy>>(`${this.apiBaseUrl}/strategies/${id}/pause`, {}); {}
} );
}
// Backtest Management
getStrategyTypes(): Observable<ApiResponse<string[]>> { stopStrategy(id: string): Observable<ApiResponse<TradingStrategy>> {
return this.http.get<ApiResponse<string[]>>(`${this.apiBaseUrl}/strategy-types`); return this.http.post<ApiResponse<TradingStrategy>>(
} `${this.apiBaseUrl}/strategies/${id}/stop`,
{}
getStrategyParameters(type: string): Observable<ApiResponse<Record<string, any>>> { );
return this.http.get<ApiResponse<Record<string, any>>>(`${this.apiBaseUrl}/strategy-parameters/${type}`); }
}
pauseStrategy(id: string): Observable<ApiResponse<TradingStrategy>> {
runBacktest(request: BacktestRequest): Observable<ApiResponse<BacktestResult>> { return this.http.post<ApiResponse<TradingStrategy>>(
return this.http.post<ApiResponse<BacktestResult>>(`${this.apiBaseUrl}/backtest`, request); `${this.apiBaseUrl}/strategies/${id}/pause`,
} {}
getBacktestResult(id: string): Observable<ApiResponse<BacktestResult>> { );
return this.http.get<ApiResponse<BacktestResult>>(`${this.apiBaseUrl}/backtest/${id}`); }
}
// Backtest Management
optimizeStrategy( getStrategyTypes(): Observable<ApiResponse<string[]>> {
baseRequest: BacktestRequest, return this.http.get<ApiResponse<string[]>>(`${this.apiBaseUrl}/strategy-types`);
parameterGrid: Record<string, any[]> }
): Observable<ApiResponse<Array<BacktestResult & { parameters: Record<string, any> }>>> {
return this.http.post<ApiResponse<Array<BacktestResult & { parameters: Record<string, any> }>>>( getStrategyParameters(type: string): Observable<ApiResponse<Record<string, any>>> {
`${this.apiBaseUrl}/backtest/optimize`, return this.http.get<ApiResponse<Record<string, any>>>(
{ baseRequest, parameterGrid } `${this.apiBaseUrl}/strategy-parameters/${type}`
); );
} }
// Strategy Signals and Trades runBacktest(request: BacktestRequest): Observable<ApiResponse<BacktestResult>> {
getStrategySignals(strategyId: string): Observable<ApiResponse<Array<{ return this.http.post<ApiResponse<BacktestResult>>(`${this.apiBaseUrl}/backtest`, request);
id: string; }
strategyId: string; getBacktestResult(id: string): Observable<ApiResponse<BacktestResult>> {
symbol: string; return this.http.get<ApiResponse<BacktestResult>>(`${this.apiBaseUrl}/backtest/${id}`);
action: string; }
price: number;
quantity: number; optimizeStrategy(
timestamp: Date; baseRequest: BacktestRequest,
confidence: number; parameterGrid: Record<string, any[]>
metadata?: any; ): Observable<ApiResponse<Array<BacktestResult & { parameters: Record<string, any> }>>> {
}>>> { return this.http.post<ApiResponse<Array<BacktestResult & { parameters: Record<string, any> }>>>(
return this.http.get<ApiResponse<any[]>>(`${this.apiBaseUrl}/strategies/${strategyId}/signals`); `${this.apiBaseUrl}/backtest/optimize`,
} { baseRequest, parameterGrid }
);
getStrategyTrades(strategyId: string): Observable<ApiResponse<Array<{ }
id: string;
strategyId: string; // Strategy Signals and Trades
symbol: string; getStrategySignals(strategyId: string): Observable<
entryPrice: number; ApiResponse<
entryTime: Date; Array<{
exitPrice: number; id: string;
exitTime: Date; strategyId: string;
quantity: number; symbol: string;
pnl: number; action: string;
pnlPercent: number; price: number;
}>>> { quantity: number;
return this.http.get<ApiResponse<any[]>>(`${this.apiBaseUrl}/strategies/${strategyId}/trades`); timestamp: Date;
} confidence: number;
metadata?: any;
// Helper methods for common transformations }>
formatBacktestRequest(formData: any): BacktestRequest { >
// Handle date formatting and parameter conversion > {
return { return this.http.get<ApiResponse<any[]>>(`${this.apiBaseUrl}/strategies/${strategyId}/signals`);
...formData, }
startDate: formData.startDate instanceof Date ? formData.startDate.toISOString() : formData.startDate,
endDate: formData.endDate instanceof Date ? formData.endDate.toISOString() : formData.endDate, getStrategyTrades(strategyId: string): Observable<
strategyParams: this.convertParameterTypes(formData.strategyType, formData.strategyParams) ApiResponse<
}; Array<{
} id: string;
strategyId: string;
private convertParameterTypes(strategyType: string, params: Record<string, any>): Record<string, any> { symbol: string;
// Convert string parameters to correct types based on strategy requirements entryPrice: number;
const result: Record<string, any> = {}; entryTime: Date;
exitPrice: number;
for (const [key, value] of Object.entries(params)) { exitTime: Date;
if (typeof value === 'string') { quantity: number;
// Try to convert to number if it looks like a number pnl: number;
if (!isNaN(Number(value))) { pnlPercent: number;
result[key] = Number(value); }>
} else if (value.toLowerCase() === 'true') { >
result[key] = true; > {
} else if (value.toLowerCase() === 'false') { return this.http.get<ApiResponse<any[]>>(`${this.apiBaseUrl}/strategies/${strategyId}/trades`);
result[key] = false; }
} else {
result[key] = value; // Helper methods for common transformations
} formatBacktestRequest(formData: any): BacktestRequest {
} else { // Handle date formatting and parameter conversion
result[key] = value; return {
} ...formData,
} startDate:
formData.startDate instanceof Date ? formData.startDate.toISOString() : formData.startDate,
return result; endDate: formData.endDate instanceof Date ? formData.endDate.toISOString() : formData.endDate,
} strategyParams: this.convertParameterTypes(formData.strategyType, formData.strategyParams),
} };
}
private convertParameterTypes(
strategyType: string,
params: Record<string, any>
): Record<string, any> {
// Convert string parameters to correct types based on strategy requirements
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(params)) {
if (typeof value === 'string') {
// Try to convert to number if it looks like a number
if (!isNaN(Number(value))) {
result[key] = Number(value);
} else if (value.toLowerCase() === 'true') {
result[key] = true;
} else if (value.toLowerCase() === 'false') {
result[key] = false;
} else {
result[key] = value;
}
} else {
result[key] = value;
}
}
return result;
}
}

View file

@ -1,218 +1,215 @@
import { Injectable, signal } from '@angular/core'; import { Injectable, signal } from '@angular/core';
import { BehaviorSubject, Observable, Subject } from 'rxjs'; import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { filter, map } from 'rxjs/operators'; import { filter, map } from 'rxjs/operators';
export interface WebSocketMessage { export interface WebSocketMessage {
type: string; type: string;
data: any; data: any;
timestamp: string; timestamp: string;
} }
export interface MarketDataUpdate { export interface MarketDataUpdate {
symbol: string; symbol: string;
price: number; price: number;
change: number; change: number;
changePercent: number; changePercent: number;
volume: number; volume: number;
timestamp: string; timestamp: string;
} }
export interface RiskAlert { export interface RiskAlert {
id: string; id: string;
symbol: string; symbol: string;
alertType: 'POSITION_LIMIT' | 'DAILY_LOSS' | 'VOLATILITY' | 'PORTFOLIO_RISK'; alertType: 'POSITION_LIMIT' | 'DAILY_LOSS' | 'VOLATILITY' | 'PORTFOLIO_RISK';
message: string; message: string;
severity: 'LOW' | 'MEDIUM' | 'HIGH'; severity: 'LOW' | 'MEDIUM' | 'HIGH';
timestamp: string; timestamp: string;
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class WebSocketService { export class WebSocketService {
private readonly WS_ENDPOINTS = { private readonly WS_ENDPOINTS = {
marketData: 'ws://localhost:3001/ws', marketData: 'ws://localhost:3001/ws',
riskGuardian: 'ws://localhost:3002/ws', riskGuardian: 'ws://localhost:3002/ws',
strategyOrchestrator: 'ws://localhost:3003/ws' strategyOrchestrator: 'ws://localhost:3003/ws',
}; };
private connections = new Map<string, WebSocket>(); private connections = new Map<string, WebSocket>();
private messageSubjects = new Map<string, Subject<WebSocketMessage>>(); private messageSubjects = new Map<string, Subject<WebSocketMessage>>();
// Connection status signals // Connection status signals
public isConnected = signal<boolean>(false); public isConnected = signal<boolean>(false);
public connectionStatus = signal<{ [key: string]: boolean }>({ public connectionStatus = signal<{ [key: string]: boolean }>({
marketData: false, marketData: false,
riskGuardian: false, riskGuardian: false,
strategyOrchestrator: false strategyOrchestrator: false,
}); });
constructor() { constructor() {
this.initializeConnections(); this.initializeConnections();
} }
private initializeConnections() { private initializeConnections() {
// Initialize WebSocket connections for all services // Initialize WebSocket connections for all services
Object.entries(this.WS_ENDPOINTS).forEach(([service, url]) => { Object.entries(this.WS_ENDPOINTS).forEach(([service, url]) => {
this.connect(service, url); this.connect(service, url);
}); });
} }
private connect(serviceName: string, url: string) { private connect(serviceName: string, url: string) {
try { try {
const ws = new WebSocket(url); const ws = new WebSocket(url);
const messageSubject = new Subject<WebSocketMessage>(); const messageSubject = new Subject<WebSocketMessage>();
ws.onopen = () => { ws.onopen = () => {
console.log(`Connected to ${serviceName} WebSocket`); console.log(`Connected to ${serviceName} WebSocket`);
this.updateConnectionStatus(serviceName, true); this.updateConnectionStatus(serviceName, true);
}; };
ws.onmessage = (event) => { ws.onmessage = event => {
try { try {
const message: WebSocketMessage = JSON.parse(event.data); const message: WebSocketMessage = JSON.parse(event.data);
messageSubject.next(message); messageSubject.next(message);
} catch (error) { } catch (error) {
console.error(`Failed to parse WebSocket message from ${serviceName}:`, error); console.error(`Failed to parse WebSocket message from ${serviceName}:`, error);
} }
}; };
ws.onclose = () => { ws.onclose = () => {
console.log(`Disconnected from ${serviceName} WebSocket`); console.log(`Disconnected from ${serviceName} WebSocket`);
this.updateConnectionStatus(serviceName, false); this.updateConnectionStatus(serviceName, false);
// Attempt to reconnect after 5 seconds // Attempt to reconnect after 5 seconds
setTimeout(() => { setTimeout(() => {
this.connect(serviceName, url); this.connect(serviceName, url);
}, 5000); }, 5000);
}; };
ws.onerror = (error) => { ws.onerror = error => {
console.error(`WebSocket error for ${serviceName}:`, error); console.error(`WebSocket error for ${serviceName}:`, error);
this.updateConnectionStatus(serviceName, false); this.updateConnectionStatus(serviceName, false);
}; };
this.connections.set(serviceName, ws); this.connections.set(serviceName, ws);
this.messageSubjects.set(serviceName, messageSubject); this.messageSubjects.set(serviceName, messageSubject);
} catch (error) {
} catch (error) { console.error(`Failed to connect to ${serviceName} WebSocket:`, error);
console.error(`Failed to connect to ${serviceName} WebSocket:`, error); this.updateConnectionStatus(serviceName, false);
this.updateConnectionStatus(serviceName, false); }
} }
}
private updateConnectionStatus(serviceName: string, isConnected: boolean) {
private updateConnectionStatus(serviceName: string, isConnected: boolean) { const currentStatus = this.connectionStatus();
const currentStatus = this.connectionStatus(); const newStatus = { ...currentStatus, [serviceName]: isConnected };
const newStatus = { ...currentStatus, [serviceName]: isConnected }; this.connectionStatus.set(newStatus);
this.connectionStatus.set(newStatus);
// Update overall connection status
// Update overall connection status const overallConnected = Object.values(newStatus).some(status => status);
const overallConnected = Object.values(newStatus).some(status => status); this.isConnected.set(overallConnected);
this.isConnected.set(overallConnected); }
}
// Market Data Updates
// Market Data Updates getMarketDataUpdates(): Observable<MarketDataUpdate> {
getMarketDataUpdates(): Observable<MarketDataUpdate> { const subject = this.messageSubjects.get('marketData');
const subject = this.messageSubjects.get('marketData'); if (!subject) {
if (!subject) { throw new Error('Market data WebSocket not initialized');
throw new Error('Market data WebSocket not initialized'); }
}
return subject.asObservable().pipe(
return subject.asObservable().pipe( filter(message => message.type === 'market_data_update'),
filter(message => message.type === 'market_data_update'), map(message => message.data as MarketDataUpdate)
map(message => message.data as MarketDataUpdate) );
); }
}
// Risk Alerts
// Risk Alerts getRiskAlerts(): Observable<RiskAlert> {
getRiskAlerts(): Observable<RiskAlert> { const subject = this.messageSubjects.get('riskGuardian');
const subject = this.messageSubjects.get('riskGuardian'); if (!subject) {
if (!subject) { throw new Error('Risk Guardian WebSocket not initialized');
throw new Error('Risk Guardian WebSocket not initialized'); }
}
return subject.asObservable().pipe(
return subject.asObservable().pipe( filter(message => message.type === 'risk_alert'),
filter(message => message.type === 'risk_alert'), map(message => message.data as RiskAlert)
map(message => message.data as RiskAlert) );
); }
} // Strategy Updates
// Strategy Updates getStrategyUpdates(): Observable<any> {
getStrategyUpdates(): Observable<any> { const subject = this.messageSubjects.get('strategyOrchestrator');
const subject = this.messageSubjects.get('strategyOrchestrator'); if (!subject) {
if (!subject) { throw new Error('Strategy Orchestrator WebSocket not initialized');
throw new Error('Strategy Orchestrator WebSocket not initialized'); }
}
return subject.asObservable().pipe(
return subject.asObservable().pipe( filter(message => message.type === 'strategy_update'),
filter(message => message.type === 'strategy_update'), map(message => message.data)
map(message => message.data) );
); }
}
// Strategy Signals
// Strategy Signals getStrategySignals(strategyId?: string): Observable<any> {
getStrategySignals(strategyId?: string): Observable<any> { const subject = this.messageSubjects.get('strategyOrchestrator');
const subject = this.messageSubjects.get('strategyOrchestrator'); if (!subject) {
if (!subject) { throw new Error('Strategy Orchestrator WebSocket not initialized');
throw new Error('Strategy Orchestrator WebSocket not initialized'); }
}
return subject.asObservable().pipe(
return subject.asObservable().pipe( filter(
filter(message => message =>
message.type === 'strategy_signal' && message.type === 'strategy_signal' &&
(!strategyId || message.data.strategyId === strategyId) (!strategyId || message.data.strategyId === strategyId)
), ),
map(message => message.data) map(message => message.data)
); );
} }
// Strategy Trades // Strategy Trades
getStrategyTrades(strategyId?: string): Observable<any> { getStrategyTrades(strategyId?: string): Observable<any> {
const subject = this.messageSubjects.get('strategyOrchestrator'); const subject = this.messageSubjects.get('strategyOrchestrator');
if (!subject) { if (!subject) {
throw new Error('Strategy Orchestrator WebSocket not initialized'); throw new Error('Strategy Orchestrator WebSocket not initialized');
} }
return subject.asObservable().pipe( return subject.asObservable().pipe(
filter(message => filter(
message.type === 'strategy_trade' && message =>
(!strategyId || message.data.strategyId === strategyId) message.type === 'strategy_trade' &&
), (!strategyId || message.data.strategyId === strategyId)
map(message => message.data) ),
); map(message => message.data)
} );
}
// All strategy-related messages, useful for components that need all types
getAllStrategyMessages(): Observable<WebSocketMessage> { // All strategy-related messages, useful for components that need all types
const subject = this.messageSubjects.get('strategyOrchestrator'); getAllStrategyMessages(): Observable<WebSocketMessage> {
if (!subject) { const subject = this.messageSubjects.get('strategyOrchestrator');
throw new Error('Strategy Orchestrator WebSocket not initialized'); if (!subject) {
} throw new Error('Strategy Orchestrator WebSocket not initialized');
}
return subject.asObservable().pipe(
filter(message => return subject.asObservable().pipe(filter(message => message.type.startsWith('strategy_')));
message.type.startsWith('strategy_') }
)
); // Send messages
} sendMessage(serviceName: string, message: any) {
const ws = this.connections.get(serviceName);
// Send messages if (ws && ws.readyState === WebSocket.OPEN) {
sendMessage(serviceName: string, message: any) { ws.send(JSON.stringify(message));
const ws = this.connections.get(serviceName); } else {
if (ws && ws.readyState === WebSocket.OPEN) { console.warn(`Cannot send message to ${serviceName}: WebSocket not connected`);
ws.send(JSON.stringify(message)); }
} else { }
console.warn(`Cannot send message to ${serviceName}: WebSocket not connected`);
} // Cleanup
} disconnect() {
this.connections.forEach((ws, serviceName) => {
// Cleanup if (ws.readyState === WebSocket.OPEN) {
disconnect() { ws.close();
this.connections.forEach((ws, serviceName) => { }
if (ws.readyState === WebSocket.OPEN) { });
ws.close(); this.connections.clear();
} this.messageSubjects.clear();
}); }
this.connections.clear(); }
this.messageSubjects.clear();
}
}

View file

@ -1,6 +1,5 @@
import { bootstrapApplication } from '@angular/platform-browser'; import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config'; import { App } from './app/app';
import { App } from './app/app'; import { appConfig } from './app/app.config';
bootstrapApplication(App, appConfig) bootstrapApplication(App, appConfig).catch(err => console.error(err));
.catch((err) => console.error(err));

View file

@ -1,106 +1,100 @@
/** /**
* Data Service - Combined live and historical data ingestion with queue-based architecture * Data Service - Combined live and historical data ingestion with queue-based architecture
*/ */
import { getLogger } from '@stock-bot/logger'; import { Hono } from 'hono';
import { loadEnvVariables } from '@stock-bot/config'; import { loadEnvVariables } from '@stock-bot/config';
import { Hono } from 'hono'; import { getLogger } from '@stock-bot/logger';
import { Shutdown } from '@stock-bot/shutdown'; import { Shutdown } from '@stock-bot/shutdown';
import { queueManager } from './services/queue.service'; import { initializeProxyCache } from './providers/proxy.tasks';
import { initializeBatchCache } from './utils/batch-helpers'; import { queueManager } from './services/queue.service';
import { initializeProxyCache } from './providers/proxy.tasks'; import { initializeBatchCache } from './utils/batch-helpers';
import { import { healthRoutes, marketDataRoutes, proxyRoutes, queueRoutes, testRoutes } from './routes';
healthRoutes,
queueRoutes, // Load environment variables
marketDataRoutes, loadEnvVariables();
proxyRoutes,
testRoutes const app = new Hono();
} from './routes'; const logger = getLogger('data-service');
const PORT = parseInt(process.env.DATA_SERVICE_PORT || '3002');
// Load environment variables let server: any = null;
loadEnvVariables();
// Initialize shutdown manager with 15 second timeout
const app = new Hono(); const shutdown = Shutdown.getInstance({ timeout: 15000 });
const logger = getLogger('data-service');
const PORT = parseInt(process.env.DATA_SERVICE_PORT || '3002'); // Register all routes
let server: any = null; app.route('', healthRoutes);
app.route('', queueRoutes);
// Initialize shutdown manager with 15 second timeout app.route('', marketDataRoutes);
const shutdown = Shutdown.getInstance({ timeout: 15000 }); app.route('', proxyRoutes);
app.route('', testRoutes);
// Register all routes
app.route('', healthRoutes); // Initialize services
app.route('', queueRoutes); async function initializeServices() {
app.route('', marketDataRoutes); logger.info('Initializing data service...');
app.route('', proxyRoutes);
app.route('', testRoutes); try {
// Initialize batch cache FIRST - before queue service
// Initialize services logger.info('Starting batch cache initialization...');
async function initializeServices() { await initializeBatchCache();
logger.info('Initializing data service...'); logger.info('Batch cache initialized');
try { // Initialize proxy cache - before queue service
// Initialize batch cache FIRST - before queue service logger.info('Starting proxy cache initialization...');
logger.info('Starting batch cache initialization...'); await initializeProxyCache();
await initializeBatchCache(); logger.info('Proxy cache initialized');
logger.info('Batch cache initialized');
// Initialize queue service (Redis connections should be ready now)
// Initialize proxy cache - before queue service logger.info('Starting queue service initialization...');
logger.info('Starting proxy cache initialization...'); await queueManager.initialize();
await initializeProxyCache(); logger.info('Queue service initialized');
logger.info('Proxy cache initialized');
logger.info('All services initialized successfully');
// Initialize queue service (Redis connections should be ready now) } catch (error) {
logger.info('Starting queue service initialization...'); logger.error('Failed to initialize services', { error });
await queueManager.initialize(); throw error;
logger.info('Queue service initialized'); }
}
logger.info('All services initialized successfully');
} catch (error) { // Start server
logger.error('Failed to initialize services', { error }); async function startServer() {
throw error; await initializeServices();
} // Start the HTTP server using Bun's native serve
} server = Bun.serve({
port: PORT,
// Start server fetch: app.fetch,
async function startServer() { development: process.env.NODE_ENV === 'development',
await initializeServices(); });
// Start the HTTP server using Bun's native serve logger.info(`Data Service started on port ${PORT}`);
server = Bun.serve({ }
port: PORT,
fetch: app.fetch, // Register shutdown handlers
development: process.env.NODE_ENV === 'development', shutdown.onShutdown(async () => {
}); if (server) {
logger.info(`Data Service started on port ${PORT}`); logger.info('Stopping HTTP server...');
} try {
server.stop();
// Register shutdown handlers logger.info('HTTP server stopped successfully');
shutdown.onShutdown(async () => { } catch (error) {
if (server) { logger.error('Error stopping HTTP server', { error });
logger.info('Stopping HTTP server...'); }
try { }
server.stop(); });
logger.info('HTTP server stopped successfully');
} catch (error) { shutdown.onShutdown(async () => {
logger.error('Error stopping HTTP server', { error }); logger.info('Shutting down queue manager...');
} try {
} await queueManager.shutdown();
}); logger.info('Queue manager shut down successfully');
} catch (error) {
shutdown.onShutdown(async () => { logger.error('Error shutting down queue manager', { error });
logger.info('Shutting down queue manager...'); throw error; // Re-throw to mark shutdown as failed
try { }
await queueManager.shutdown(); });
logger.info('Queue manager shut down successfully');
} catch (error) { // Start the application
logger.error('Error shutting down queue manager', { error }); startServer().catch(error => {
throw error; // Re-throw to mark shutdown as failed logger.error('Failed to start server', { error });
} process.exit(1);
}); });
// Start the application logger.info('Data service startup initiated with graceful shutdown handlers');
startServer().catch(error => {
logger.error('Failed to start server', { error });
process.exit(1);
});
logger.info('Data service startup initiated with graceful shutdown handlers');

View file

@ -1,131 +1,131 @@
import { ProxyInfo } from 'libs/http/src/types'; import { ProxyInfo } from 'libs/http/src/types';
import { ProviderConfig } from '../services/provider-registry.service'; import { getLogger } from '@stock-bot/logger';
import { getLogger } from '@stock-bot/logger'; import { ProviderConfig } from '../services/provider-registry.service';
// Create logger for this provider // Create logger for this provider
const logger = getLogger('proxy-provider'); const logger = getLogger('proxy-provider');
// This will run at the same time each day as when the app started // This will run at the same time each day as when the app started
const getEvery24HourCron = (): string => { const getEvery24HourCron = (): string => {
const now = new Date(); const now = new Date();
const hours = now.getHours(); const hours = now.getHours();
const minutes = now.getMinutes(); const minutes = now.getMinutes();
return `${minutes} ${hours} * * *`; // Every day at startup time return `${minutes} ${hours} * * *`; // Every day at startup time
}; };
export const proxyProvider: ProviderConfig = { export const proxyProvider: ProviderConfig = {
name: 'proxy-provider', name: 'proxy-provider',
operations: {'fetch-and-check': async (payload: { sources?: string[] }) => { operations: {
const { proxyService } = await import('./proxy.tasks'); 'fetch-and-check': async (payload: { sources?: string[] }) => {
const { queueManager } = await import('../services/queue.service'); const { proxyService } = await import('./proxy.tasks');
const { processItems } = await import('../utils/batch-helpers'); const { queueManager } = await import('../services/queue.service');
const { processItems } = await import('../utils/batch-helpers');
const proxies = await proxyService.fetchProxiesFromSources();
const proxies = await proxyService.fetchProxiesFromSources();
if (proxies.length === 0) {
return { proxiesFetched: 0, jobsCreated: 0 }; if (proxies.length === 0) {
} return { proxiesFetched: 0, jobsCreated: 0 };
}
// Use generic function with routing parameters
const result = await processItems( // Use generic function with routing parameters
proxies, const result = await processItems(
(proxy, index) => ({ proxies,
proxy, (proxy, index) => ({
index, proxy,
source: 'batch-processing' index,
}), source: 'batch-processing',
queueManager, }),
{ queueManager,
totalDelayHours: 4,//parseFloat(process.env.PROXY_VALIDATION_HOURS || '1'), {
batchSize: parseInt(process.env.PROXY_BATCH_SIZE || '200'), totalDelayHours: 4, //parseFloat(process.env.PROXY_VALIDATION_HOURS || '1'),
useBatching: process.env.PROXY_DIRECT_MODE !== 'true', batchSize: parseInt(process.env.PROXY_BATCH_SIZE || '200'),
priority: 2, useBatching: process.env.PROXY_DIRECT_MODE !== 'true',
provider: 'proxy-provider', priority: 2,
operation: 'check-proxy' provider: 'proxy-provider',
} operation: 'check-proxy',
);return { }
proxiesFetched: result.totalItems, );
jobsCreated: result.jobsCreated, return {
mode: result.mode, proxiesFetched: result.totalItems,
batchesCreated: result.batchesCreated, jobsCreated: result.jobsCreated,
processingTimeMs: result.duration mode: result.mode,
}; batchesCreated: result.batchesCreated,
}, processingTimeMs: result.duration,
'process-batch-items': async (payload: any) => { };
// Process a batch using the simplified batch helpers },
const { processBatchJob } = await import('../utils/batch-helpers'); 'process-batch-items': async (payload: any) => {
const { queueManager } = await import('../services/queue.service'); // Process a batch using the simplified batch helpers
const { processBatchJob } = await import('../utils/batch-helpers');
return await processBatchJob(payload, queueManager); const { queueManager } = await import('../services/queue.service');
},
return await processBatchJob(payload, queueManager);
'check-proxy': async (payload: { },
proxy: ProxyInfo,
source?: string, 'check-proxy': async (payload: {
batchIndex?: number, proxy: ProxyInfo;
itemIndex?: number, source?: string;
total?: number batchIndex?: number;
}) => { itemIndex?: number;
const { checkProxy } = await import('./proxy.tasks'); total?: number;
}) => {
try { const { checkProxy } = await import('./proxy.tasks');
const result = await checkProxy(payload.proxy);
try {
logger.debug('Proxy validated', { const result = await checkProxy(payload.proxy);
proxy: `${payload.proxy.host}:${payload.proxy.port}`,
isWorking: result.isWorking, logger.debug('Proxy validated', {
responseTime: result.responseTime, proxy: `${payload.proxy.host}:${payload.proxy.port}`,
batchIndex: payload.batchIndex isWorking: result.isWorking,
}); responseTime: result.responseTime,
batchIndex: payload.batchIndex,
return { });
result,
proxy: payload.proxy, return {
// Only include batch info if it exists (for batch mode) result,
...(payload.batchIndex !== undefined && { proxy: payload.proxy,
batchInfo: { // Only include batch info if it exists (for batch mode)
batchIndex: payload.batchIndex, ...(payload.batchIndex !== undefined && {
itemIndex: payload.itemIndex, batchInfo: {
total: payload.total, batchIndex: payload.batchIndex,
source: payload.source itemIndex: payload.itemIndex,
} total: payload.total,
}) source: payload.source,
}; },
} catch (error) { }),
logger.warn('Proxy validation failed', { };
proxy: `${payload.proxy.host}:${payload.proxy.port}`, } catch (error) {
error: error instanceof Error ? error.message : String(error), logger.warn('Proxy validation failed', {
batchIndex: payload.batchIndex proxy: `${payload.proxy.host}:${payload.proxy.port}`,
}); error: error instanceof Error ? error.message : String(error),
batchIndex: payload.batchIndex,
return { });
result: { isWorking: false, error: String(error) },
proxy: payload.proxy, return {
// Only include batch info if it exists (for batch mode) result: { isWorking: false, error: String(error) },
...(payload.batchIndex !== undefined && { proxy: payload.proxy,
batchInfo: { // Only include batch info if it exists (for batch mode)
batchIndex: payload.batchIndex, ...(payload.batchIndex !== undefined && {
itemIndex: payload.itemIndex, batchInfo: {
total: payload.total, batchIndex: payload.batchIndex,
source: payload.source itemIndex: payload.itemIndex,
} total: payload.total,
}) source: payload.source,
}; },
} }),
} };
}, }
scheduledJobs: [ },
{ },
type: 'proxy-maintenance', scheduledJobs: [
operation: 'fetch-and-check', {
payload: {}, type: 'proxy-maintenance',
// should remove and just run at the same time so app restarts dont keeping adding same jobs operation: 'fetch-and-check',
cronPattern: getEvery24HourCron(), payload: {},
priority: 5, // should remove and just run at the same time so app restarts dont keeping adding same jobs
immediately: true, // Don't run immediately during startup to avoid conflicts cronPattern: getEvery24HourCron(),
description: 'Fetch and validate proxy list from sources' priority: 5,
} immediately: true, // Don't run immediately during startup to avoid conflicts
] description: 'Fetch and validate proxy list from sources',
}; },
],
};

View file

@ -1,436 +1,536 @@
import { getLogger } from '@stock-bot/logger'; import pLimit from 'p-limit';
import { createCache, type CacheProvider } from '@stock-bot/cache'; import { createCache, type CacheProvider } from '@stock-bot/cache';
import { HttpClient, ProxyInfo } from '@stock-bot/http'; import { HttpClient, ProxyInfo } from '@stock-bot/http';
import pLimit from 'p-limit'; import { getLogger } from '@stock-bot/logger';
// Type definitions // Type definitions
export interface ProxySource { export interface ProxySource {
id: string; id: string;
url: string; url: string;
protocol: string; protocol: string;
working?: number; // Optional, used for stats working?: number; // Optional, used for stats
total?: number; // Optional, used for stats total?: number; // Optional, used for stats
percentWorking?: number; // Optional, used for stats percentWorking?: number; // Optional, used for stats
lastChecked?: Date; // Optional, used for stats lastChecked?: Date; // Optional, used for stats
} }
// Shared configuration and utilities // Shared configuration and utilities
const PROXY_CONFIG = { const PROXY_CONFIG = {
CACHE_KEY: 'active', CACHE_KEY: 'active',
CACHE_STATS_KEY: 'stats', CACHE_STATS_KEY: 'stats',
CACHE_TTL: 86400, // 24 hours CACHE_TTL: 86400, // 24 hours
CHECK_TIMEOUT: 7000, CHECK_TIMEOUT: 7000,
CHECK_IP: '99.246.102.205', CHECK_IP: '99.246.102.205',
CHECK_URL: 'https://proxy-detection.stare.gg/?api_key=bd406bf53ddc6abe1d9de5907830a955', CHECK_URL: 'https://proxy-detection.stare.gg/?api_key=bd406bf53ddc6abe1d9de5907830a955',
CONCURRENCY_LIMIT: 100, CONCURRENCY_LIMIT: 100,
PROXY_SOURCES: [ PROXY_SOURCES: [
{id: 'prxchk', url: 'https://raw.githubusercontent.com/prxchk/proxy-list/main/http.txt', protocol: 'http'}, {
{id: 'casals', url: 'https://raw.githubusercontent.com/casals-ar/proxy-list/main/http', protocol: 'http'}, id: 'prxchk',
{id: 'sunny9577', url: 'https://raw.githubusercontent.com/sunny9577/proxy-scraper/master/proxies.txt', protocol: 'http'}, url: 'https://raw.githubusercontent.com/prxchk/proxy-list/main/http.txt',
{id: 'themiralay', url: 'https://raw.githubusercontent.com/themiralay/Proxy-List-World/refs/heads/master/data.txt', protocol: 'http'}, protocol: 'http',
{id: 'casa-ls', url: 'https://raw.githubusercontent.com/casa-ls/proxy-list/refs/heads/main/http', protocol: 'http'}, },
{id: 'databay', url: 'https://raw.githubusercontent.com/databay-labs/free-proxy-list/refs/heads/master/http.txt', protocol: 'http'}, {
{id: 'speedx', url: 'https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/http.txt', protocol: 'http'}, id: 'casals',
{id: 'monosans', url: 'https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/http.txt', protocol: 'http'}, url: 'https://raw.githubusercontent.com/casals-ar/proxy-list/main/http',
protocol: 'http',
{id: 'murong', url: 'https://raw.githubusercontent.com/MuRongPIG/Proxy-Master/main/http.txt', protocol: 'http'}, },
{id: 'vakhov-fresh', url: 'https://raw.githubusercontent.com/vakhov/fresh-proxy-list/master/http.txt', protocol: 'http'}, {
{id: 'kangproxy', url: 'https://raw.githubusercontent.com/officialputuid/KangProxy/refs/heads/KangProxy/http/http.txt', protocol: 'http'}, id: 'sunny9577',
{id: 'gfpcom', url: 'https://raw.githubusercontent.com/gfpcom/free-proxy-list/refs/heads/main/list/http.txt', protocol: 'http'}, url: 'https://raw.githubusercontent.com/sunny9577/proxy-scraper/master/proxies.txt',
{id: 'dpangestuw', url: 'https://raw.githubusercontent.com/dpangestuw/Free-Proxy/refs/heads/main/http_proxies.txt', protocol: 'http'}, protocol: 'http',
{id: 'gitrecon', url: 'https://raw.githubusercontent.com/gitrecon1455/fresh-proxy-list/refs/heads/main/proxylist.txt', protocol: 'http'}, },
{id: 'vakhov-master', url: 'https://raw.githubusercontent.com/vakhov/fresh-proxy-list/refs/heads/master/http.txt', protocol: 'http'}, {
{id: 'breaking-tech', url: 'https://raw.githubusercontent.com/BreakingTechFr/Proxy_Free/refs/heads/main/proxies/http.txt', protocol: 'http'}, id: 'themiralay',
{id: 'ercindedeoglu', url: 'https://raw.githubusercontent.com/ErcinDedeoglu/proxies/main/proxies/http.txt', protocol: 'http'}, url: 'https://raw.githubusercontent.com/themiralay/Proxy-List-World/refs/heads/master/data.txt',
{id: 'tuanminpay', url: 'https://raw.githubusercontent.com/TuanMinPay/live-proxy/master/http.txt', protocol: 'http'}, protocol: 'http',
},
{id: 'r00tee-https', url: 'https://raw.githubusercontent.com/r00tee/Proxy-List/refs/heads/main/Https.txt', protocol: 'https'}, {
{id: 'ercindedeoglu-https', url: 'https://raw.githubusercontent.com/ErcinDedeoglu/proxies/main/proxies/https.txt', protocol: 'https'}, id: 'casa-ls',
{id: 'vakhov-fresh-https', url: 'https://raw.githubusercontent.com/vakhov/fresh-proxy-list/refs/heads/master/https.txt', protocol: 'https'}, url: 'https://raw.githubusercontent.com/casa-ls/proxy-list/refs/heads/main/http',
{id: 'databay-https', url: 'https://raw.githubusercontent.com/databay-labs/free-proxy-list/refs/heads/master/https.txt', protocol: 'https'}, protocol: 'http',
{id: 'kangproxy-https', url: 'https://raw.githubusercontent.com/officialputuid/KangProxy/refs/heads/KangProxy/https/https.txt', protocol: 'https'}, },
{id: 'zloi-user-https', url: 'https://raw.githubusercontent.com/zloi-user/hideip.me/refs/heads/master/https.txt', protocol: 'https'}, {
{id: 'gfpcom-https', url: 'https://raw.githubusercontent.com/gfpcom/free-proxy-list/refs/heads/main/list/https.txt', protocol: 'https'}, id: 'databay',
] url: 'https://raw.githubusercontent.com/databay-labs/free-proxy-list/refs/heads/master/http.txt',
}; protocol: 'http',
},
// Shared instances (module-scoped, not global) {
let logger: ReturnType<typeof getLogger>; id: 'speedx',
let cache: CacheProvider; url: 'https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/http.txt',
let httpClient: HttpClient; protocol: 'http',
let concurrencyLimit: ReturnType<typeof pLimit>; },
let proxyStats: ProxySource[] = PROXY_CONFIG.PROXY_SOURCES.map(source => ({ {
id: source.id, id: 'monosans',
total: 0, url: 'https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/http.txt',
working: 0, protocol: 'http',
lastChecked: new Date(), },
protocol: source.protocol,
url: source.url, {
})); id: 'murong',
url: 'https://raw.githubusercontent.com/MuRongPIG/Proxy-Master/main/http.txt',
protocol: 'http',
// make a function that takes in source id and a boolean success and updates the proxyStats array },
async function updateProxyStats(sourceId: string, success: boolean) { {
const source = proxyStats.find(s => s.id === sourceId); id: 'vakhov-fresh',
if (source !== undefined) { url: 'https://raw.githubusercontent.com/vakhov/fresh-proxy-list/master/http.txt',
if(typeof source.working !== 'number') protocol: 'http',
source.working = 0; },
if(typeof source.total !== 'number') {
source.total = 0; id: 'kangproxy',
source.total += 1; url: 'https://raw.githubusercontent.com/officialputuid/KangProxy/refs/heads/KangProxy/http/http.txt',
if (success) { protocol: 'http',
source.working += 1; },
} {
source.percentWorking = source.working / source.total * 100; id: 'gfpcom',
source.lastChecked = new Date(); url: 'https://raw.githubusercontent.com/gfpcom/free-proxy-list/refs/heads/main/list/http.txt',
await cache.set(`${PROXY_CONFIG.CACHE_STATS_KEY}:${source.id}`, source, PROXY_CONFIG.CACHE_TTL); protocol: 'http',
return source; },
} else { {
logger.warn(`Unknown proxy source: ${sourceId}`); id: 'dpangestuw',
} url: 'https://raw.githubusercontent.com/dpangestuw/Free-Proxy/refs/heads/main/http_proxies.txt',
} protocol: 'http',
},
// make a function that resets proxyStats {
async function resetProxyStats(): Promise<void> { id: 'gitrecon',
proxyStats = PROXY_CONFIG.PROXY_SOURCES.map(source => ({ url: 'https://raw.githubusercontent.com/gitrecon1455/fresh-proxy-list/refs/heads/main/proxylist.txt',
id: source.id, protocol: 'http',
total: 0, },
working: 0, {
lastChecked: new Date(), id: 'vakhov-master',
protocol: source.protocol, url: 'https://raw.githubusercontent.com/vakhov/fresh-proxy-list/refs/heads/master/http.txt',
url: source.url, protocol: 'http',
})); },
for (const source of proxyStats) { {
await cache.set(`${PROXY_CONFIG.CACHE_STATS_KEY}:${source.id}`, source, PROXY_CONFIG.CACHE_TTL); id: 'breaking-tech',
} url: 'https://raw.githubusercontent.com/BreakingTechFr/Proxy_Free/refs/heads/main/proxies/http.txt',
return Promise.resolve(); protocol: 'http',
} },
{
/** id: 'ercindedeoglu',
* Update proxy data in cache with working/total stats and average response time url: 'https://raw.githubusercontent.com/ErcinDedeoglu/proxies/main/proxies/http.txt',
* @param proxy - The proxy to update protocol: 'http',
* @param isWorking - Whether the proxy is currently working },
*/ {
async function updateProxyInCache(proxy: ProxyInfo, isWorking: boolean): Promise<void> { id: 'tuanminpay',
const cacheKey = `${PROXY_CONFIG.CACHE_KEY}:${proxy.protocol}://${proxy.host}:${proxy.port}`; url: 'https://raw.githubusercontent.com/TuanMinPay/live-proxy/master/http.txt',
protocol: 'http',
try { },
const existing: any = await cache.get(cacheKey);
{
// For failed proxies, only update if they already exist id: 'r00tee-https',
if (!isWorking && !existing) { url: 'https://raw.githubusercontent.com/r00tee/Proxy-List/refs/heads/main/Https.txt',
logger.debug('Proxy not in cache, skipping failed update', { protocol: 'https',
proxy: `${proxy.host}:${proxy.port}` },
}); {
return; id: 'ercindedeoglu-https',
} url: 'https://raw.githubusercontent.com/ErcinDedeoglu/proxies/main/proxies/https.txt',
protocol: 'https',
// Calculate new average response time if we have a response time },
let newAverageResponseTime = existing?.averageResponseTime; {
if (proxy.responseTime !== undefined) { id: 'vakhov-fresh-https',
const existingAvg = existing?.averageResponseTime || 0; url: 'https://raw.githubusercontent.com/vakhov/fresh-proxy-list/refs/heads/master/https.txt',
const existingTotal = existing?.total || 0; protocol: 'https',
},
// Calculate weighted average: (existing_avg * existing_count + new_response) / (existing_count + 1) {
newAverageResponseTime = existingTotal > 0 id: 'databay-https',
? ((existingAvg * existingTotal) + proxy.responseTime) / (existingTotal + 1) url: 'https://raw.githubusercontent.com/databay-labs/free-proxy-list/refs/heads/master/https.txt',
: proxy.responseTime; protocol: 'https',
} },
{
// Build updated proxy data id: 'kangproxy-https',
const updated = { url: 'https://raw.githubusercontent.com/officialputuid/KangProxy/refs/heads/KangProxy/https/https.txt',
...existing, protocol: 'https',
...proxy, // Keep latest proxy info },
total: (existing?.total || 0) + 1, {
working: isWorking ? (existing?.working || 0) + 1 : (existing?.working || 0), id: 'zloi-user-https',
isWorking, url: 'https://raw.githubusercontent.com/zloi-user/hideip.me/refs/heads/master/https.txt',
lastChecked: new Date(), protocol: 'https',
// Add firstSeen only for new entries },
...(existing ? {} : { firstSeen: new Date() }), {
// Update average response time if we calculated a new one id: 'gfpcom-https',
...(newAverageResponseTime !== undefined ? { averageResponseTime: newAverageResponseTime } : {}) url: 'https://raw.githubusercontent.com/gfpcom/free-proxy-list/refs/heads/main/list/https.txt',
}; protocol: 'https',
},
// Calculate success rate ],
updated.successRate = updated.total > 0 ? (updated.working / updated.total) * 100 : 0; };
// Save to cache: reset TTL for working proxies, keep existing TTL for failed ones // Shared instances (module-scoped, not global)
const cacheOptions = isWorking ? PROXY_CONFIG.CACHE_TTL : undefined; let logger: ReturnType<typeof getLogger>;
await cache.set(cacheKey, updated, cacheOptions); let cache: CacheProvider;
let httpClient: HttpClient;
logger.debug(`Updated ${isWorking ? 'working' : 'failed'} proxy in cache`, { let concurrencyLimit: ReturnType<typeof pLimit>;
proxy: `${proxy.host}:${proxy.port}`, let proxyStats: ProxySource[] = PROXY_CONFIG.PROXY_SOURCES.map(source => ({
working: updated.working, id: source.id,
total: updated.total, total: 0,
successRate: updated.successRate.toFixed(1) + '%', working: 0,
avgResponseTime: updated.averageResponseTime ? `${updated.averageResponseTime.toFixed(0)}ms` : 'N/A' lastChecked: new Date(),
}); protocol: source.protocol,
url: source.url,
} catch (error) { }));
logger.error('Failed to update proxy in cache', {
proxy: `${proxy.host}:${proxy.port}`, // make a function that takes in source id and a boolean success and updates the proxyStats array
error: error instanceof Error ? error.message : String(error) async function updateProxyStats(sourceId: string, success: boolean) {
}); const source = proxyStats.find(s => s.id === sourceId);
} if (source !== undefined) {
} if (typeof source.working !== 'number') source.working = 0;
if (typeof source.total !== 'number') source.total = 0;
/** source.total += 1;
* Initialize proxy cache for use during application startup if (success) {
* This should be called before any proxy operations source.working += 1;
*/ }
export async function initializeProxyCache(): Promise<void> { source.percentWorking = (source.working / source.total) * 100;
logger = getLogger('proxy-tasks'); source.lastChecked = new Date();
cache = createCache({ await cache.set(`${PROXY_CONFIG.CACHE_STATS_KEY}:${source.id}`, source, PROXY_CONFIG.CACHE_TTL);
keyPrefix: 'proxy:', return source;
ttl: PROXY_CONFIG.CACHE_TTL, } else {
enableMetrics: true logger.warn(`Unknown proxy source: ${sourceId}`);
}); }
}
logger.info('Initializing proxy cache...');
await cache.waitForReady(10000); // make a function that resets proxyStats
logger.info('Proxy cache initialized successfully'); async function resetProxyStats(): Promise<void> {
proxyStats = PROXY_CONFIG.PROXY_SOURCES.map(source => ({
// Initialize other shared resources that don't require cache id: source.id,
httpClient = new HttpClient({ timeout: 10000 }, logger); total: 0,
concurrencyLimit = pLimit(PROXY_CONFIG.CONCURRENCY_LIMIT); working: 0,
lastChecked: new Date(),
logger.info('Proxy tasks initialized'); protocol: source.protocol,
} url: source.url,
}));
async function initializeSharedResources() { for (const source of proxyStats) {
if (!logger) { await cache.set(`${PROXY_CONFIG.CACHE_STATS_KEY}:${source.id}`, source, PROXY_CONFIG.CACHE_TTL);
// If not initialized at startup, initialize with fallback mode }
logger = getLogger('proxy-tasks'); return Promise.resolve();
cache = createCache({ }
keyPrefix: 'proxy:',
ttl: PROXY_CONFIG.CACHE_TTL, /**
enableMetrics: true * Update proxy data in cache with working/total stats and average response time
}); * @param proxy - The proxy to update
* @param isWorking - Whether the proxy is currently working
httpClient = new HttpClient({ timeout: 10000 }, logger); */
concurrencyLimit = pLimit(PROXY_CONFIG.CONCURRENCY_LIMIT); async function updateProxyInCache(proxy: ProxyInfo, isWorking: boolean): Promise<void> {
const cacheKey = `${PROXY_CONFIG.CACHE_KEY}:${proxy.protocol}://${proxy.host}:${proxy.port}`;
logger.info('Proxy tasks initialized (fallback mode)');
} try {
} const existing: any = await cache.get(cacheKey);
// Individual task functions // For failed proxies, only update if they already exist
export async function queueProxyFetch(): Promise<string> { if (!isWorking && !existing) {
await initializeSharedResources(); logger.debug('Proxy not in cache, skipping failed update', {
proxy: `${proxy.host}:${proxy.port}`,
const { queueManager } = await import('../services/queue.service'); });
const job = await queueManager.addJob({ return;
type: 'proxy-fetch', }
provider: 'proxy-service',
operation: 'fetch-and-check', // Calculate new average response time if we have a response time
payload: {}, let newAverageResponseTime = existing?.averageResponseTime;
priority: 5 if (proxy.responseTime !== undefined) {
}); const existingAvg = existing?.averageResponseTime || 0;
const existingTotal = existing?.total || 0;
const jobId = job.id || 'unknown';
logger.info('Proxy fetch job queued', { jobId }); // Calculate weighted average: (existing_avg * existing_count + new_response) / (existing_count + 1)
return jobId; newAverageResponseTime =
} existingTotal > 0
? (existingAvg * existingTotal + proxy.responseTime) / (existingTotal + 1)
export async function queueProxyCheck(proxies: ProxyInfo[]): Promise<string> { : proxy.responseTime;
await initializeSharedResources(); }
const { queueManager } = await import('../services/queue.service'); // Build updated proxy data
const job = await queueManager.addJob({ const updated = {
type: 'proxy-check', ...existing,
provider: 'proxy-service', ...proxy, // Keep latest proxy info
operation: 'check-specific', total: (existing?.total || 0) + 1,
payload: { proxies }, working: isWorking ? (existing?.working || 0) + 1 : existing?.working || 0,
priority: 3 isWorking,
}); lastChecked: new Date(),
// Add firstSeen only for new entries
const jobId = job.id || 'unknown'; ...(existing ? {} : { firstSeen: new Date() }),
logger.info('Proxy check job queued', { jobId, count: proxies.length }); // Update average response time if we calculated a new one
return jobId; ...(newAverageResponseTime !== undefined
} ? { averageResponseTime: newAverageResponseTime }
: {}),
export async function fetchProxiesFromSources(): Promise<ProxyInfo[]> { };
await initializeSharedResources();
await resetProxyStats(); // Calculate success rate
updated.successRate = updated.total > 0 ? (updated.working / updated.total) * 100 : 0;
// Ensure concurrencyLimit is available before using it
if (!concurrencyLimit) { // Save to cache: reset TTL for working proxies, keep existing TTL for failed ones
logger.error('concurrencyLimit not initialized, using sequential processing'); const cacheOptions = isWorking ? PROXY_CONFIG.CACHE_TTL : undefined;
const result = []; await cache.set(cacheKey, updated, cacheOptions);
for (const source of PROXY_CONFIG.PROXY_SOURCES) {
const proxies = await fetchProxiesFromSource(source); logger.debug(`Updated ${isWorking ? 'working' : 'failed'} proxy in cache`, {
result.push(...proxies); proxy: `${proxy.host}:${proxy.port}`,
} working: updated.working,
let allProxies: ProxyInfo[] = result; total: updated.total,
allProxies = removeDuplicateProxies(allProxies); successRate: updated.successRate.toFixed(1) + '%',
return allProxies; avgResponseTime: updated.averageResponseTime
} ? `${updated.averageResponseTime.toFixed(0)}ms`
: 'N/A',
const sources = PROXY_CONFIG.PROXY_SOURCES.map(source => });
concurrencyLimit(() => fetchProxiesFromSource(source)) } catch (error) {
); logger.error('Failed to update proxy in cache', {
const result = await Promise.all(sources); proxy: `${proxy.host}:${proxy.port}`,
let allProxies: ProxyInfo[] = result.flat(); error: error instanceof Error ? error.message : String(error),
allProxies = removeDuplicateProxies(allProxies); });
// await checkProxies(allProxies); }
return allProxies; }
}
/**
export async function fetchProxiesFromSource(source: ProxySource): Promise<ProxyInfo[]> { * Initialize proxy cache for use during application startup
await initializeSharedResources(); * This should be called before any proxy operations
*/
const allProxies: ProxyInfo[] = []; export async function initializeProxyCache(): Promise<void> {
logger = getLogger('proxy-tasks');
try { cache = createCache({
logger.info(`Fetching proxies from ${source.url}`); keyPrefix: 'proxy:',
ttl: PROXY_CONFIG.CACHE_TTL,
const response = await httpClient.get(source.url, { enableMetrics: true,
timeout: 10000 });
});
logger.info('Initializing proxy cache...');
if (response.status !== 200) { await cache.waitForReady(10000);
logger.warn(`Failed to fetch from ${source.url}: ${response.status}`); logger.info('Proxy cache initialized successfully');
return [];
} // Initialize other shared resources that don't require cache
httpClient = new HttpClient({ timeout: 10000 }, logger);
const text = response.data; concurrencyLimit = pLimit(PROXY_CONFIG.CONCURRENCY_LIMIT);
const lines = text.split('\n').filter((line: string) => line.trim());
logger.info('Proxy tasks initialized');
for (const line of lines) { }
let trimmed = line.trim();
trimmed = cleanProxyUrl(trimmed); async function initializeSharedResources() {
if (!trimmed || trimmed.startsWith('#')) continue; if (!logger) {
// If not initialized at startup, initialize with fallback mode
// Parse formats like "host:port" or "host:port:user:pass" logger = getLogger('proxy-tasks');
const parts = trimmed.split(':'); cache = createCache({
if (parts.length >= 2) { keyPrefix: 'proxy:',
const proxy: ProxyInfo = { ttl: PROXY_CONFIG.CACHE_TTL,
source: source.id, enableMetrics: true,
protocol: source.protocol as 'http' | 'https' | 'socks4' | 'socks5', });
host: parts[0],
port: parseInt(parts[1]) httpClient = new HttpClient({ timeout: 10000 }, logger);
}; concurrencyLimit = pLimit(PROXY_CONFIG.CONCURRENCY_LIMIT);
if (!isNaN(proxy.port) && proxy.host) { logger.info('Proxy tasks initialized (fallback mode)');
allProxies.push(proxy); }
} }
}
} // Individual task functions
export async function queueProxyFetch(): Promise<string> {
logger.info(`Parsed ${allProxies.length} proxies from ${source.url}`); await initializeSharedResources();
} catch (error) { const { queueManager } = await import('../services/queue.service');
logger.error(`Error fetching proxies from ${source.url}`, error); const job = await queueManager.addJob({
return []; type: 'proxy-fetch',
} provider: 'proxy-service',
operation: 'fetch-and-check',
return allProxies; payload: {},
} priority: 5,
});
/**
* Check if a proxy is working const jobId = job.id || 'unknown';
*/ logger.info('Proxy fetch job queued', { jobId });
export async function checkProxy(proxy: ProxyInfo): Promise<ProxyInfo> { return jobId;
await initializeSharedResources(); }
let success = false; export async function queueProxyCheck(proxies: ProxyInfo[]): Promise<string> {
logger.debug(`Checking Proxy:`, { await initializeSharedResources();
protocol: proxy.protocol,
host: proxy.host, const { queueManager } = await import('../services/queue.service');
port: proxy.port, const job = await queueManager.addJob({
}); type: 'proxy-check',
provider: 'proxy-service',
try { operation: 'check-specific',
// Test the proxy payload: { proxies },
const response = await httpClient.get(PROXY_CONFIG.CHECK_URL, { priority: 3,
proxy, });
timeout: PROXY_CONFIG.CHECK_TIMEOUT
}); const jobId = job.id || 'unknown';
logger.info('Proxy check job queued', { jobId, count: proxies.length });
const isWorking = response.status >= 200 && response.status < 300; return jobId;
const result: ProxyInfo = { }
...proxy,
isWorking, export async function fetchProxiesFromSources(): Promise<ProxyInfo[]> {
lastChecked: new Date(), await initializeSharedResources();
responseTime: response.responseTime, await resetProxyStats();
};
// Ensure concurrencyLimit is available before using it
if (isWorking && !JSON.stringify(response.data).includes(PROXY_CONFIG.CHECK_IP)) { if (!concurrencyLimit) {
success = true; logger.error('concurrencyLimit not initialized, using sequential processing');
await updateProxyInCache(result, true); const result = [];
} else { for (const source of PROXY_CONFIG.PROXY_SOURCES) {
await updateProxyInCache(result, false); const proxies = await fetchProxiesFromSource(source);
} result.push(...proxies);
}
if( proxy.source ){ let allProxies: ProxyInfo[] = result;
await updateProxyStats(proxy.source, success); allProxies = removeDuplicateProxies(allProxies);
} return allProxies;
}
logger.debug('Proxy check completed', {
host: proxy.host, const sources = PROXY_CONFIG.PROXY_SOURCES.map(source =>
port: proxy.port, concurrencyLimit(() => fetchProxiesFromSource(source))
isWorking, );
}); const result = await Promise.all(sources);
let allProxies: ProxyInfo[] = result.flat();
return result; allProxies = removeDuplicateProxies(allProxies);
} catch (error) { // await checkProxies(allProxies);
const errorMessage = error instanceof Error ? error.message : String(error); return allProxies;
const result: ProxyInfo = { }
...proxy,
isWorking: false, export async function fetchProxiesFromSource(source: ProxySource): Promise<ProxyInfo[]> {
error: errorMessage, await initializeSharedResources();
lastChecked: new Date()
}; const allProxies: ProxyInfo[] = [];
// Update cache for failed proxy (increment total, don't update TTL) try {
await updateProxyInCache(result, false); logger.info(`Fetching proxies from ${source.url}`);
if( proxy.source ){ const response = await httpClient.get(source.url, {
await updateProxyStats(proxy.source, success); timeout: 10000,
} });
logger.debug('Proxy check failed', { if (response.status !== 200) {
host: proxy.host, logger.warn(`Failed to fetch from ${source.url}: ${response.status}`);
port: proxy.port, return [];
error: errorMessage }
});
const text = response.data;
return result; const lines = text.split('\n').filter((line: string) => line.trim());
}
} for (const line of lines) {
let trimmed = line.trim();
// Utility functions trimmed = cleanProxyUrl(trimmed);
function cleanProxyUrl(url: string): string { if (!trimmed || trimmed.startsWith('#')) continue;
return url
.replace(/^https?:\/\//, '') // Parse formats like "host:port" or "host:port:user:pass"
.replace(/^0+/, '') const parts = trimmed.split(':');
.replace(/:0+(\d)/g, ':$1'); if (parts.length >= 2) {
} const proxy: ProxyInfo = {
source: source.id,
function removeDuplicateProxies(proxies: ProxyInfo[]): ProxyInfo[] { protocol: source.protocol as 'http' | 'https' | 'socks4' | 'socks5',
const seen = new Set<string>(); host: parts[0],
const unique: ProxyInfo[] = []; port: parseInt(parts[1]),
};
for (const proxy of proxies) {
const key = `${proxy.protocol}://${proxy.host}:${proxy.port}`; if (!isNaN(proxy.port) && proxy.host) {
if (!seen.has(key)) { allProxies.push(proxy);
seen.add(key); }
unique.push(proxy); }
} }
}
logger.info(`Parsed ${allProxies.length} proxies from ${source.url}`);
return unique; } catch (error) {
} logger.error(`Error fetching proxies from ${source.url}`, error);
return [];
// Optional: Export a convenience object that groups related tasks }
export const proxyTasks = {
queueProxyFetch, return allProxies;
queueProxyCheck, }
fetchProxiesFromSources,
fetchProxiesFromSource, /**
checkProxy, * Check if a proxy is working
}; */
export async function checkProxy(proxy: ProxyInfo): Promise<ProxyInfo> {
// Export singleton instance for backward compatibility (optional) await initializeSharedResources();
// Remove this if you want to fully move to the task-based approach
export const proxyService = proxyTasks; let success = false;
logger.debug(`Checking Proxy:`, {
protocol: proxy.protocol,
host: proxy.host,
port: proxy.port,
});
try {
// Test the proxy
const response = await httpClient.get(PROXY_CONFIG.CHECK_URL, {
proxy,
timeout: PROXY_CONFIG.CHECK_TIMEOUT,
});
const isWorking = response.status >= 200 && response.status < 300;
const result: ProxyInfo = {
...proxy,
isWorking,
lastChecked: new Date(),
responseTime: response.responseTime,
};
if (isWorking && !JSON.stringify(response.data).includes(PROXY_CONFIG.CHECK_IP)) {
success = true;
await updateProxyInCache(result, true);
} else {
await updateProxyInCache(result, false);
}
if (proxy.source) {
await updateProxyStats(proxy.source, success);
}
logger.debug('Proxy check completed', {
host: proxy.host,
port: proxy.port,
isWorking,
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const result: ProxyInfo = {
...proxy,
isWorking: false,
error: errorMessage,
lastChecked: new Date(),
};
// Update cache for failed proxy (increment total, don't update TTL)
await updateProxyInCache(result, false);
if (proxy.source) {
await updateProxyStats(proxy.source, success);
}
logger.debug('Proxy check failed', {
host: proxy.host,
port: proxy.port,
error: errorMessage,
});
return result;
}
}
// Utility functions
function cleanProxyUrl(url: string): string {
return url
.replace(/^https?:\/\//, '')
.replace(/^0+/, '')
.replace(/:0+(\d)/g, ':$1');
}
function removeDuplicateProxies(proxies: ProxyInfo[]): ProxyInfo[] {
const seen = new Set<string>();
const unique: ProxyInfo[] = [];
for (const proxy of proxies) {
const key = `${proxy.protocol}://${proxy.host}:${proxy.port}`;
if (!seen.has(key)) {
seen.add(key);
unique.push(proxy);
}
}
return unique;
}
// Optional: Export a convenience object that groups related tasks
export const proxyTasks = {
queueProxyFetch,
queueProxyCheck,
fetchProxiesFromSources,
fetchProxiesFromSource,
checkProxy,
};
// Export singleton instance for backward compatibility (optional)
// Remove this if you want to fully move to the task-based approach
export const proxyService = proxyTasks;

View file

@ -1,174 +1,182 @@
import { ProviderConfig } from '../services/provider-registry.service'; import { getLogger } from '@stock-bot/logger';
import { getLogger } from '@stock-bot/logger'; import { ProviderConfig } from '../services/provider-registry.service';
const logger = getLogger('quotemedia-provider'); const logger = getLogger('quotemedia-provider');
export const quotemediaProvider: ProviderConfig = { export const quotemediaProvider: ProviderConfig = {
name: 'quotemedia', name: 'quotemedia',
operations: { 'live-data': async (payload: { symbol: string; fields?: string[] }) => { operations: {
logger.info('Fetching live data from QuoteMedia', { symbol: payload.symbol }); 'live-data': async (payload: { symbol: string; fields?: string[] }) => {
logger.info('Fetching live data from QuoteMedia', { symbol: payload.symbol });
// Simulate QuoteMedia API call
const mockData = { // Simulate QuoteMedia API call
symbol: payload.symbol, const mockData = {
price: Math.random() * 1000 + 100, symbol: payload.symbol,
volume: Math.floor(Math.random() * 1000000), price: Math.random() * 1000 + 100,
change: (Math.random() - 0.5) * 20, volume: Math.floor(Math.random() * 1000000),
changePercent: (Math.random() - 0.5) * 5, change: (Math.random() - 0.5) * 20,
timestamp: new Date().toISOString(), changePercent: (Math.random() - 0.5) * 5,
source: 'quotemedia', timestamp: new Date().toISOString(),
fields: payload.fields || ['price', 'volume', 'change'] source: 'quotemedia',
}; fields: payload.fields || ['price', 'volume', 'change'],
};
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 200)); // Simulate network delay
await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 200));
return mockData;
}, return mockData;
},
'historical-data': async (payload: {
symbol: string; 'historical-data': async (payload: {
from: Date; symbol: string;
to: Date; from: Date;
interval?: string; to: Date;
fields?: string[]; }) => { interval?: string;
logger.info('Fetching historical data from QuoteMedia', { fields?: string[];
symbol: payload.symbol, }) => {
from: payload.from, logger.info('Fetching historical data from QuoteMedia', {
to: payload.to, symbol: payload.symbol,
interval: payload.interval || '1d' from: payload.from,
}); to: payload.to,
interval: payload.interval || '1d',
// Generate mock historical data });
const days = Math.ceil((payload.to.getTime() - payload.from.getTime()) / (1000 * 60 * 60 * 24));
const data = []; // Generate mock historical data
const days = Math.ceil(
for (let i = 0; i < Math.min(days, 100); i++) { (payload.to.getTime() - payload.from.getTime()) / (1000 * 60 * 60 * 24)
const date = new Date(payload.from.getTime() + i * 24 * 60 * 60 * 1000); );
data.push({ const data = [];
date: date.toISOString().split('T')[0],
open: Math.random() * 1000 + 100, for (let i = 0; i < Math.min(days, 100); i++) {
high: Math.random() * 1000 + 100, const date = new Date(payload.from.getTime() + i * 24 * 60 * 60 * 1000);
low: Math.random() * 1000 + 100, data.push({
close: Math.random() * 1000 + 100, date: date.toISOString().split('T')[0],
volume: Math.floor(Math.random() * 1000000), open: Math.random() * 1000 + 100,
source: 'quotemedia' high: Math.random() * 1000 + 100,
}); low: Math.random() * 1000 + 100,
} close: Math.random() * 1000 + 100,
volume: Math.floor(Math.random() * 1000000),
// Simulate network delay source: 'quotemedia',
await new Promise(resolve => setTimeout(resolve, 200 + Math.random() * 300)); });
}
return {
symbol: payload.symbol, // Simulate network delay
interval: payload.interval || '1d', await new Promise(resolve => setTimeout(resolve, 200 + Math.random() * 300));
data,
source: 'quotemedia', return {
totalRecords: data.length symbol: payload.symbol,
}; interval: payload.interval || '1d',
}, data,
'batch-quotes': async (payload: { symbols: string[]; fields?: string[] }) => { source: 'quotemedia',
logger.info('Fetching batch quotes from QuoteMedia', { totalRecords: data.length,
symbols: payload.symbols, };
count: payload.symbols.length },
}); 'batch-quotes': async (payload: { symbols: string[]; fields?: string[] }) => {
logger.info('Fetching batch quotes from QuoteMedia', {
const quotes = payload.symbols.map(symbol => ({ symbols: payload.symbols,
symbol, count: payload.symbols.length,
price: Math.random() * 1000 + 100, });
volume: Math.floor(Math.random() * 1000000),
change: (Math.random() - 0.5) * 20, const quotes = payload.symbols.map(symbol => ({
timestamp: new Date().toISOString(), symbol,
source: 'quotemedia' price: Math.random() * 1000 + 100,
})); volume: Math.floor(Math.random() * 1000000),
change: (Math.random() - 0.5) * 20,
// Simulate network delay timestamp: new Date().toISOString(),
await new Promise(resolve => setTimeout(resolve, 300 + Math.random() * 200)); source: 'quotemedia',
}));
return {
quotes, // Simulate network delay
source: 'quotemedia', await new Promise(resolve => setTimeout(resolve, 300 + Math.random() * 200));
timestamp: new Date().toISOString(),
totalSymbols: payload.symbols.length return {
}; quotes,
}, 'company-profile': async (payload: { symbol: string }) => { source: 'quotemedia',
logger.info('Fetching company profile from QuoteMedia', { symbol: payload.symbol }); timestamp: new Date().toISOString(),
totalSymbols: payload.symbols.length,
// Simulate company profile data };
const profile = { },
symbol: payload.symbol, 'company-profile': async (payload: { symbol: string }) => {
companyName: `${payload.symbol} Corporation`, logger.info('Fetching company profile from QuoteMedia', { symbol: payload.symbol });
sector: 'Technology',
industry: 'Software', // Simulate company profile data
description: `${payload.symbol} is a leading technology company.`, const profile = {
marketCap: Math.floor(Math.random() * 1000000000000), symbol: payload.symbol,
employees: Math.floor(Math.random() * 100000), companyName: `${payload.symbol} Corporation`,
website: `https://www.${payload.symbol.toLowerCase()}.com`, sector: 'Technology',
source: 'quotemedia' industry: 'Software',
}; description: `${payload.symbol} is a leading technology company.`,
marketCap: Math.floor(Math.random() * 1000000000000),
await new Promise(resolve => setTimeout(resolve, 150 + Math.random() * 100)); employees: Math.floor(Math.random() * 100000),
website: `https://www.${payload.symbol.toLowerCase()}.com`,
return profile; source: 'quotemedia',
}, 'options-chain': async (payload: { symbol: string; expiration?: string }) => { };
logger.info('Fetching options chain from QuoteMedia', {
symbol: payload.symbol, await new Promise(resolve => setTimeout(resolve, 150 + Math.random() * 100));
expiration: payload.expiration
}); return profile;
},
// Generate mock options data 'options-chain': async (payload: { symbol: string; expiration?: string }) => {
const strikes = Array.from({ length: 20 }, (_, i) => 100 + i * 5); logger.info('Fetching options chain from QuoteMedia', {
const calls = strikes.map(strike => ({ symbol: payload.symbol,
strike, expiration: payload.expiration,
bid: Math.random() * 10, });
ask: Math.random() * 10 + 0.5,
volume: Math.floor(Math.random() * 1000), // Generate mock options data
openInterest: Math.floor(Math.random() * 5000) const strikes = Array.from({ length: 20 }, (_, i) => 100 + i * 5);
})); const calls = strikes.map(strike => ({
strike,
const puts = strikes.map(strike => ({ bid: Math.random() * 10,
strike, ask: Math.random() * 10 + 0.5,
bid: Math.random() * 10, volume: Math.floor(Math.random() * 1000),
ask: Math.random() * 10 + 0.5, openInterest: Math.floor(Math.random() * 5000),
volume: Math.floor(Math.random() * 1000), }));
openInterest: Math.floor(Math.random() * 5000)
})); const puts = strikes.map(strike => ({
strike,
await new Promise(resolve => setTimeout(resolve, 400 + Math.random() * 300)); bid: Math.random() * 10,
return { ask: Math.random() * 10 + 0.5,
symbol: payload.symbol, volume: Math.floor(Math.random() * 1000),
expiration: payload.expiration || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], openInterest: Math.floor(Math.random() * 5000),
calls, }));
puts,
source: 'quotemedia' await new Promise(resolve => setTimeout(resolve, 400 + Math.random() * 300));
}; return {
} symbol: payload.symbol,
}, expiration:
payload.expiration ||
scheduledJobs: [ new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
// { calls,
// type: 'quotemedia-premium-refresh', puts,
// operation: 'batch-quotes', source: 'quotemedia',
// payload: { symbols: ['AAPL', 'GOOGL', 'MSFT'] }, };
// cronPattern: '*/2 * * * *', // Every 2 minutes },
// priority: 7, },
// description: 'Refresh premium quotes with detailed market data'
// }, scheduledJobs: [
// { // {
// type: 'quotemedia-options-update', // type: 'quotemedia-premium-refresh',
// operation: 'options-chain', // operation: 'batch-quotes',
// payload: { symbol: 'SPY' }, // payload: { symbols: ['AAPL', 'GOOGL', 'MSFT'] },
// cronPattern: '*/10 * * * *', // Every 10 minutes // cronPattern: '*/2 * * * *', // Every 2 minutes
// priority: 5, // priority: 7,
// description: 'Update options chain data for SPY ETF' // description: 'Refresh premium quotes with detailed market data'
// }, // },
// { // {
// type: 'quotemedia-profiles', // type: 'quotemedia-options-update',
// operation: 'company-profile', // operation: 'options-chain',
// payload: { symbol: 'AAPL' }, // payload: { symbol: 'SPY' },
// cronPattern: '0 9 * * 1-5', // Weekdays at 9 AM // cronPattern: '*/10 * * * *', // Every 10 minutes
// priority: 3, // priority: 5,
// description: 'Update company profile data' // description: 'Update options chain data for SPY ETF'
// } // },
] // {
}; // type: 'quotemedia-profiles',
// operation: 'company-profile',
// payload: { symbol: 'AAPL' },
// cronPattern: '0 9 * * 1-5', // Weekdays at 9 AM
// priority: 3,
// description: 'Update company profile data'
// }
],
};

View file

@ -1,248 +1,254 @@
import { ProviderConfig } from '../services/provider-registry.service'; import { getLogger } from '@stock-bot/logger';
import { getLogger } from '@stock-bot/logger'; import { ProviderConfig } from '../services/provider-registry.service';
const logger = getLogger('yahoo-provider'); const logger = getLogger('yahoo-provider');
export const yahooProvider: ProviderConfig = { export const yahooProvider: ProviderConfig = {
name: 'yahoo-finance', name: 'yahoo-finance',
operations: { operations: {
'live-data': async (payload: { symbol: string; modules?: string[] }) => { 'live-data': async (payload: { symbol: string; modules?: string[] }) => {
logger.info('Fetching live data from Yahoo Finance', { symbol: payload.symbol });
logger.info('Fetching live data from Yahoo Finance', { symbol: payload.symbol }); // Simulate Yahoo Finance API call
const mockData = {
// Simulate Yahoo Finance API call symbol: payload.symbol,
const mockData = { regularMarketPrice: Math.random() * 1000 + 100,
symbol: payload.symbol, regularMarketVolume: Math.floor(Math.random() * 1000000),
regularMarketPrice: Math.random() * 1000 + 100, regularMarketChange: (Math.random() - 0.5) * 20,
regularMarketVolume: Math.floor(Math.random() * 1000000), regularMarketChangePercent: (Math.random() - 0.5) * 5,
regularMarketChange: (Math.random() - 0.5) * 20, preMarketPrice: Math.random() * 1000 + 100,
regularMarketChangePercent: (Math.random() - 0.5) * 5, postMarketPrice: Math.random() * 1000 + 100,
preMarketPrice: Math.random() * 1000 + 100, marketCap: Math.floor(Math.random() * 1000000000000),
postMarketPrice: Math.random() * 1000 + 100, peRatio: Math.random() * 50 + 5,
marketCap: Math.floor(Math.random() * 1000000000000), dividendYield: Math.random() * 0.1,
peRatio: Math.random() * 50 + 5, fiftyTwoWeekHigh: Math.random() * 1200 + 100,
dividendYield: Math.random() * 0.1, fiftyTwoWeekLow: Math.random() * 800 + 50,
fiftyTwoWeekHigh: Math.random() * 1200 + 100, timestamp: Date.now() / 1000,
fiftyTwoWeekLow: Math.random() * 800 + 50, source: 'yahoo-finance',
timestamp: Date.now() / 1000, modules: payload.modules || ['price', 'summaryDetail'],
source: 'yahoo-finance', };
modules: payload.modules || ['price', 'summaryDetail']
}; // Simulate network delay
await new Promise(resolve => setTimeout(resolve, 150 + Math.random() * 250));
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 150 + Math.random() * 250)); return mockData;
},
return mockData;
}, 'historical-data': async (payload: {
symbol: string;
'historical-data': async (payload: { period1: number;
symbol: string; period2: number;
period1: number; interval?: string;
period2: number; events?: string;
interval?: string; }) => {
events?: string; }) => { const { getLogger } = await import('@stock-bot/logger');
const { getLogger } = await import('@stock-bot/logger'); const logger = getLogger('yahoo-provider');
const logger = getLogger('yahoo-provider');
logger.info('Fetching historical data from Yahoo Finance', {
logger.info('Fetching historical data from Yahoo Finance', { symbol: payload.symbol,
symbol: payload.symbol, period1: payload.period1,
period1: payload.period1, period2: payload.period2,
period2: payload.period2, interval: payload.interval || '1d',
interval: payload.interval || '1d' });
});
// Generate mock historical data
// Generate mock historical data const days = Math.ceil((payload.period2 - payload.period1) / (24 * 60 * 60));
const days = Math.ceil((payload.period2 - payload.period1) / (24 * 60 * 60)); const data = [];
const data = [];
for (let i = 0; i < Math.min(days, 100); i++) {
for (let i = 0; i < Math.min(days, 100); i++) { const timestamp = payload.period1 + i * 24 * 60 * 60;
const timestamp = payload.period1 + i * 24 * 60 * 60; data.push({
data.push({ timestamp,
timestamp, date: new Date(timestamp * 1000).toISOString().split('T')[0],
date: new Date(timestamp * 1000).toISOString().split('T')[0], open: Math.random() * 1000 + 100,
open: Math.random() * 1000 + 100, high: Math.random() * 1000 + 100,
high: Math.random() * 1000 + 100, low: Math.random() * 1000 + 100,
low: Math.random() * 1000 + 100, close: Math.random() * 1000 + 100,
close: Math.random() * 1000 + 100, adjClose: Math.random() * 1000 + 100,
adjClose: Math.random() * 1000 + 100, volume: Math.floor(Math.random() * 1000000),
volume: Math.floor(Math.random() * 1000000), source: 'yahoo-finance',
source: 'yahoo-finance' });
}); }
}
// Simulate network delay
// Simulate network delay await new Promise(resolve => setTimeout(resolve, 250 + Math.random() * 350));
await new Promise(resolve => setTimeout(resolve, 250 + Math.random() * 350));
return {
return { symbol: payload.symbol,
symbol: payload.symbol, interval: payload.interval || '1d',
interval: payload.interval || '1d', timestamps: data.map(d => d.timestamp),
timestamps: data.map(d => d.timestamp), indicators: {
indicators: { quote: [
quote: [{ {
open: data.map(d => d.open), open: data.map(d => d.open),
high: data.map(d => d.high), high: data.map(d => d.high),
low: data.map(d => d.low), low: data.map(d => d.low),
close: data.map(d => d.close), close: data.map(d => d.close),
volume: data.map(d => d.volume) volume: data.map(d => d.volume),
}], },
adjclose: [{ ],
adjclose: data.map(d => d.adjClose) adjclose: [
}] {
}, adjclose: data.map(d => d.adjClose),
source: 'yahoo-finance', },
totalRecords: data.length ],
}; },
}, source: 'yahoo-finance',
'search': async (payload: { query: string; quotesCount?: number; newsCount?: number }) => { totalRecords: data.length,
const { getLogger } = await import('@stock-bot/logger'); };
const logger = getLogger('yahoo-provider'); },
search: async (payload: { query: string; quotesCount?: number; newsCount?: number }) => {
logger.info('Searching Yahoo Finance', { query: payload.query }); const { getLogger } = await import('@stock-bot/logger');
const logger = getLogger('yahoo-provider');
// Generate mock search results
const quotes = Array.from({ length: payload.quotesCount || 5 }, (_, i) => ({ logger.info('Searching Yahoo Finance', { query: payload.query });
symbol: `${payload.query.toUpperCase()}${i}`,
shortname: `${payload.query} Company ${i}`, // Generate mock search results
longname: `${payload.query} Corporation ${i}`, const quotes = Array.from({ length: payload.quotesCount || 5 }, (_, i) => ({
exchDisp: 'NASDAQ', symbol: `${payload.query.toUpperCase()}${i}`,
typeDisp: 'Equity', shortname: `${payload.query} Company ${i}`,
source: 'yahoo-finance' longname: `${payload.query} Corporation ${i}`,
})); exchDisp: 'NASDAQ',
typeDisp: 'Equity',
const news = Array.from({ length: payload.newsCount || 3 }, (_, i) => ({ source: 'yahoo-finance',
uuid: `news-${i}-${Date.now()}`, }));
title: `${payload.query} News Article ${i}`,
publisher: 'Financial News', const news = Array.from({ length: payload.newsCount || 3 }, (_, i) => ({
providerPublishTime: Date.now() - i * 3600000, uuid: `news-${i}-${Date.now()}`,
type: 'STORY', title: `${payload.query} News Article ${i}`,
source: 'yahoo-finance' publisher: 'Financial News',
})); providerPublishTime: Date.now() - i * 3600000,
type: 'STORY',
await new Promise(resolve => setTimeout(resolve, 200 + Math.random() * 200)); source: 'yahoo-finance',
}));
return {
quotes, await new Promise(resolve => setTimeout(resolve, 200 + Math.random() * 200));
news,
totalQuotes: quotes.length, return {
totalNews: news.length, quotes,
source: 'yahoo-finance' news,
}; totalQuotes: quotes.length,
}, 'financials': async (payload: { symbol: string; type?: 'income' | 'balance' | 'cash' }) => { totalNews: news.length,
const { getLogger } = await import('@stock-bot/logger'); source: 'yahoo-finance',
const logger = getLogger('yahoo-provider'); };
},
logger.info('Fetching financials from Yahoo Finance', { financials: async (payload: { symbol: string; type?: 'income' | 'balance' | 'cash' }) => {
symbol: payload.symbol, const { getLogger } = await import('@stock-bot/logger');
type: payload.type || 'income' const logger = getLogger('yahoo-provider');
});
logger.info('Fetching financials from Yahoo Finance', {
// Generate mock financial data symbol: payload.symbol,
const financials = { type: payload.type || 'income',
symbol: payload.symbol, });
type: payload.type || 'income',
currency: 'USD', // Generate mock financial data
annual: Array.from({ length: 4 }, (_, i) => ({ const financials = {
fiscalYear: 2024 - i, symbol: payload.symbol,
revenue: Math.floor(Math.random() * 100000000000), type: payload.type || 'income',
netIncome: Math.floor(Math.random() * 10000000000), currency: 'USD',
totalAssets: Math.floor(Math.random() * 500000000000), annual: Array.from({ length: 4 }, (_, i) => ({
totalDebt: Math.floor(Math.random() * 50000000000) fiscalYear: 2024 - i,
})), revenue: Math.floor(Math.random() * 100000000000),
quarterly: Array.from({ length: 4 }, (_, i) => ({ netIncome: Math.floor(Math.random() * 10000000000),
fiscalQuarter: `Q${4-i} 2024`, totalAssets: Math.floor(Math.random() * 500000000000),
revenue: Math.floor(Math.random() * 25000000000), totalDebt: Math.floor(Math.random() * 50000000000),
netIncome: Math.floor(Math.random() * 2500000000) })),
})), quarterly: Array.from({ length: 4 }, (_, i) => ({
source: 'yahoo-finance' fiscalQuarter: `Q${4 - i} 2024`,
}; revenue: Math.floor(Math.random() * 25000000000),
netIncome: Math.floor(Math.random() * 2500000000),
await new Promise(resolve => setTimeout(resolve, 300 + Math.random() * 200)); })),
source: 'yahoo-finance',
return financials; };
}, 'earnings': async (payload: { symbol: string; period?: 'annual' | 'quarterly' }) => {
const { getLogger } = await import('@stock-bot/logger'); await new Promise(resolve => setTimeout(resolve, 300 + Math.random() * 200));
const logger = getLogger('yahoo-provider');
return financials;
logger.info('Fetching earnings from Yahoo Finance', { },
symbol: payload.symbol, earnings: async (payload: { symbol: string; period?: 'annual' | 'quarterly' }) => {
period: payload.period || 'quarterly' const { getLogger } = await import('@stock-bot/logger');
}); const logger = getLogger('yahoo-provider');
// Generate mock earnings data logger.info('Fetching earnings from Yahoo Finance', {
const earnings = { symbol: payload.symbol,
symbol: payload.symbol, period: payload.period || 'quarterly',
period: payload.period || 'quarterly', });
earnings: Array.from({ length: 8 }, (_, i) => ({
quarter: `Q${(i % 4) + 1} ${2024 - Math.floor(i/4)}`, // Generate mock earnings data
epsEstimate: Math.random() * 5, const earnings = {
epsActual: Math.random() * 5, symbol: payload.symbol,
revenueEstimate: Math.floor(Math.random() * 50000000000), period: payload.period || 'quarterly',
revenueActual: Math.floor(Math.random() * 50000000000), earnings: Array.from({ length: 8 }, (_, i) => ({
surprise: (Math.random() - 0.5) * 2 quarter: `Q${(i % 4) + 1} ${2024 - Math.floor(i / 4)}`,
})), epsEstimate: Math.random() * 5,
source: 'yahoo-finance' epsActual: Math.random() * 5,
}; revenueEstimate: Math.floor(Math.random() * 50000000000),
revenueActual: Math.floor(Math.random() * 50000000000),
await new Promise(resolve => setTimeout(resolve, 250 + Math.random() * 150)); surprise: (Math.random() - 0.5) * 2,
})),
return earnings; source: 'yahoo-finance',
}, 'recommendations': async (payload: { symbol: string }) => { };
const { getLogger } = await import('@stock-bot/logger');
const logger = getLogger('yahoo-provider'); await new Promise(resolve => setTimeout(resolve, 250 + Math.random() * 150));
logger.info('Fetching recommendations from Yahoo Finance', { symbol: payload.symbol }); return earnings;
},
// Generate mock recommendations recommendations: async (payload: { symbol: string }) => {
const recommendations = { const { getLogger } = await import('@stock-bot/logger');
symbol: payload.symbol, const logger = getLogger('yahoo-provider');
current: {
strongBuy: Math.floor(Math.random() * 10), logger.info('Fetching recommendations from Yahoo Finance', { symbol: payload.symbol });
buy: Math.floor(Math.random() * 15),
hold: Math.floor(Math.random() * 20), // Generate mock recommendations
sell: Math.floor(Math.random() * 5), const recommendations = {
strongSell: Math.floor(Math.random() * 3) symbol: payload.symbol,
}, current: {
trend: Array.from({ length: 4 }, (_, i) => ({ strongBuy: Math.floor(Math.random() * 10),
period: `${i}m`, buy: Math.floor(Math.random() * 15),
strongBuy: Math.floor(Math.random() * 10), hold: Math.floor(Math.random() * 20),
buy: Math.floor(Math.random() * 15), sell: Math.floor(Math.random() * 5),
hold: Math.floor(Math.random() * 20), strongSell: Math.floor(Math.random() * 3),
sell: Math.floor(Math.random() * 5), },
strongSell: Math.floor(Math.random() * 3) trend: Array.from({ length: 4 }, (_, i) => ({
})), period: `${i}m`,
source: 'yahoo-finance' strongBuy: Math.floor(Math.random() * 10),
}; buy: Math.floor(Math.random() * 15),
hold: Math.floor(Math.random() * 20),
await new Promise(resolve => setTimeout(resolve, 180 + Math.random() * 120)); sell: Math.floor(Math.random() * 5),
return recommendations; strongSell: Math.floor(Math.random() * 3),
} })),
}, source: 'yahoo-finance',
};
scheduledJobs: [
// { await new Promise(resolve => setTimeout(resolve, 180 + Math.random() * 120));
// type: 'yahoo-market-refresh', return recommendations;
// operation: 'live-data', },
// payload: { symbol: 'AAPL' }, },
// cronPattern: '*/1 * * * *', // Every minute
// priority: 8, scheduledJobs: [
// description: 'Refresh Apple stock price from Yahoo Finance' // {
// }, // type: 'yahoo-market-refresh',
// { // operation: 'live-data',
// type: 'yahoo-sp500-update', // payload: { symbol: 'AAPL' },
// operation: 'live-data', // cronPattern: '*/1 * * * *', // Every minute
// payload: { symbol: 'SPY' }, // priority: 8,
// cronPattern: '*/2 * * * *', // Every 2 minutes // description: 'Refresh Apple stock price from Yahoo Finance'
// priority: 9, // },
// description: 'Update S&P 500 ETF price' // {
// }, // type: 'yahoo-sp500-update',
// { // operation: 'live-data',
// type: 'yahoo-earnings-check', // payload: { symbol: 'SPY' },
// operation: 'earnings', // cronPattern: '*/2 * * * *', // Every 2 minutes
// payload: { symbol: 'AAPL' }, // priority: 9,
// cronPattern: '0 16 * * 1-5', // Weekdays at 4 PM (market close) // description: 'Update S&P 500 ETF price'
// priority: 6, // },
// description: 'Check earnings data for Apple' // {
// } // type: 'yahoo-earnings-check',
] // operation: 'earnings',
}; // payload: { symbol: 'AAPL' },
// cronPattern: '0 16 * * 1-5', // Weekdays at 4 PM (market close)
// priority: 6,
// description: 'Check earnings data for Apple'
// }
],
};

View file

@ -7,14 +7,14 @@ import { queueManager } from '../services/queue.service';
export const healthRoutes = new Hono(); export const healthRoutes = new Hono();
// Health check endpoint // Health check endpoint
healthRoutes.get('/health', (c) => { healthRoutes.get('/health', c => {
return c.json({ return c.json({
service: 'data-service', service: 'data-service',
status: 'healthy', status: 'healthy',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
queue: { queue: {
status: 'running', status: 'running',
workers: queueManager.getWorkerCount() workers: queueManager.getWorkerCount(),
} },
}); });
}); });

View file

@ -10,10 +10,10 @@ const logger = getLogger('market-data-routes');
export const marketDataRoutes = new Hono(); export const marketDataRoutes = new Hono();
// Market data endpoints // Market data endpoints
marketDataRoutes.get('/api/live/:symbol', async (c) => { marketDataRoutes.get('/api/live/:symbol', async c => {
const symbol = c.req.param('symbol'); const symbol = c.req.param('symbol');
logger.info('Live data request', { symbol }); logger.info('Live data request', { symbol });
try { try {
// Queue job for live data using Yahoo provider // Queue job for live data using Yahoo provider
const job = await queueManager.addJob({ const job = await queueManager.addJob({
@ -21,13 +21,13 @@ marketDataRoutes.get('/api/live/:symbol', async (c) => {
service: 'market-data', service: 'market-data',
provider: 'yahoo-finance', provider: 'yahoo-finance',
operation: 'live-data', operation: 'live-data',
payload: { symbol } payload: { symbol },
}); });
return c.json({ return c.json({
status: 'success', status: 'success',
message: 'Live data job queued', message: 'Live data job queued',
jobId: job.id, jobId: job.id,
symbol symbol,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to queue live data job', { symbol, error }); logger.error('Failed to queue live data job', { symbol, error });
@ -35,37 +35,37 @@ marketDataRoutes.get('/api/live/:symbol', async (c) => {
} }
}); });
marketDataRoutes.get('/api/historical/:symbol', async (c) => { marketDataRoutes.get('/api/historical/:symbol', async c => {
const symbol = c.req.param('symbol'); const symbol = c.req.param('symbol');
const from = c.req.query('from'); const from = c.req.query('from');
const to = c.req.query('to'); const to = c.req.query('to');
logger.info('Historical data request', { symbol, from, to }); logger.info('Historical data request', { symbol, from, to });
try { try {
const fromDate = from ? new Date(from) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago const fromDate = from ? new Date(from) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
const toDate = to ? new Date(to) : new Date(); // Now const toDate = to ? new Date(to) : new Date(); // Now
// Queue job for historical data using Yahoo provider // Queue job for historical data using Yahoo provider
const job = await queueManager.addJob({ const job = await queueManager.addJob({
type: 'market-data-historical', type: 'market-data-historical',
service: 'market-data', service: 'market-data',
provider: 'yahoo-finance', provider: 'yahoo-finance',
operation: 'historical-data', operation: 'historical-data',
payload: { payload: {
symbol, symbol,
from: fromDate.toISOString(), from: fromDate.toISOString(),
to: toDate.toISOString() to: toDate.toISOString(),
} },
}); });
return c.json({ return c.json({
status: 'success', status: 'success',
message: 'Historical data job queued', message: 'Historical data job queued',
jobId: job.id, jobId: job.id,
symbol, symbol,
from: fromDate, from: fromDate,
to: toDate to: toDate,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to queue historical data job', { symbol, from, to, error }); logger.error('Failed to queue historical data job', { symbol, from, to, error });

View file

@ -10,20 +10,20 @@ const logger = getLogger('proxy-routes');
export const proxyRoutes = new Hono(); export const proxyRoutes = new Hono();
// Proxy management endpoints // Proxy management endpoints
proxyRoutes.post('/api/proxy/fetch', async (c) => { proxyRoutes.post('/api/proxy/fetch', async c => {
try { try {
const job = await queueManager.addJob({ const job = await queueManager.addJob({
type: 'proxy-fetch', type: 'proxy-fetch',
provider: 'proxy-provider', provider: 'proxy-provider',
operation: 'fetch-and-check', operation: 'fetch-and-check',
payload: {}, payload: {},
priority: 5 priority: 5,
}); });
return c.json({ return c.json({
status: 'success', status: 'success',
jobId: job.id, jobId: job.id,
message: 'Proxy fetch job queued' message: 'Proxy fetch job queued',
}); });
} catch (error) { } catch (error) {
logger.error('Failed to queue proxy fetch', { error }); logger.error('Failed to queue proxy fetch', { error });
@ -31,7 +31,7 @@ proxyRoutes.post('/api/proxy/fetch', async (c) => {
} }
}); });
proxyRoutes.post('/api/proxy/check', async (c) => { proxyRoutes.post('/api/proxy/check', async c => {
try { try {
const { proxies } = await c.req.json(); const { proxies } = await c.req.json();
const job = await queueManager.addJob({ const job = await queueManager.addJob({
@ -39,13 +39,13 @@ proxyRoutes.post('/api/proxy/check', async (c) => {
provider: 'proxy-provider', provider: 'proxy-provider',
operation: 'check-specific', operation: 'check-specific',
payload: { proxies }, payload: { proxies },
priority: 8 priority: 8,
}); });
return c.json({ return c.json({
status: 'success', status: 'success',
jobId: job.id, jobId: job.id,
message: `Proxy check job queued for ${proxies.length} proxies` message: `Proxy check job queued for ${proxies.length} proxies`,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to queue proxy check', { error }); logger.error('Failed to queue proxy check', { error });
@ -54,20 +54,20 @@ proxyRoutes.post('/api/proxy/check', async (c) => {
}); });
// Get proxy stats via queue // Get proxy stats via queue
proxyRoutes.get('/api/proxy/stats', async (c) => { proxyRoutes.get('/api/proxy/stats', async c => {
try { try {
const job = await queueManager.addJob({ const job = await queueManager.addJob({
type: 'proxy-stats', type: 'proxy-stats',
provider: 'proxy-provider', provider: 'proxy-provider',
operation: 'get-stats', operation: 'get-stats',
payload: {}, payload: {},
priority: 3 priority: 3,
}); });
return c.json({ return c.json({
status: 'success', status: 'success',
jobId: job.id, jobId: job.id,
message: 'Proxy stats job queued' message: 'Proxy stats job queued',
}); });
} catch (error) { } catch (error) {
logger.error('Failed to queue proxy stats', { error }); logger.error('Failed to queue proxy stats', { error });

View file

@ -10,7 +10,7 @@ const logger = getLogger('queue-routes');
export const queueRoutes = new Hono(); export const queueRoutes = new Hono();
// Queue management endpoints // Queue management endpoints
queueRoutes.get('/api/queue/status', async (c) => { queueRoutes.get('/api/queue/status', async c => {
try { try {
const status = await queueManager.getQueueStatus(); const status = await queueManager.getQueueStatus();
return c.json({ status: 'success', data: status }); return c.json({ status: 'success', data: status });
@ -20,7 +20,7 @@ queueRoutes.get('/api/queue/status', async (c) => {
} }
}); });
queueRoutes.post('/api/queue/job', async (c) => { queueRoutes.post('/api/queue/job', async c => {
try { try {
const jobData = await c.req.json(); const jobData = await c.req.json();
const job = await queueManager.addJob(jobData); const job = await queueManager.addJob(jobData);
@ -32,7 +32,7 @@ queueRoutes.post('/api/queue/job', async (c) => {
}); });
// Provider registry endpoints // Provider registry endpoints
queueRoutes.get('/api/providers', async (c) => { queueRoutes.get('/api/providers', async c => {
try { try {
const { providerRegistry } = await import('../services/provider-registry.service'); const { providerRegistry } = await import('../services/provider-registry.service');
const providers = providerRegistry.getProviders(); const providers = providerRegistry.getProviders();
@ -44,14 +44,14 @@ queueRoutes.get('/api/providers', async (c) => {
}); });
// Add new endpoint to see scheduled jobs // Add new endpoint to see scheduled jobs
queueRoutes.get('/api/scheduled-jobs', async (c) => { queueRoutes.get('/api/scheduled-jobs', async c => {
try { try {
const { providerRegistry } = await import('../services/provider-registry.service'); const { providerRegistry } = await import('../services/provider-registry.service');
const jobs = providerRegistry.getAllScheduledJobs(); const jobs = providerRegistry.getAllScheduledJobs();
return c.json({ return c.json({
status: 'success', status: 'success',
count: jobs.length, count: jobs.length,
jobs jobs,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get scheduled jobs info', { error }); logger.error('Failed to get scheduled jobs info', { error });
@ -59,7 +59,7 @@ queueRoutes.get('/api/scheduled-jobs', async (c) => {
} }
}); });
queueRoutes.post('/api/queue/drain', async (c) => { queueRoutes.post('/api/queue/drain', async c => {
try { try {
await queueManager.drainQueue(); await queueManager.drainQueue();
const status = await queueManager.getQueueStatus(); const status = await queueManager.getQueueStatus();

View file

@ -10,21 +10,21 @@ const logger = getLogger('test-routes');
export const testRoutes = new Hono(); export const testRoutes = new Hono();
// Test endpoint for new functional batch processing // Test endpoint for new functional batch processing
testRoutes.post('/api/test/batch-symbols', async (c) => { testRoutes.post('/api/test/batch-symbols', async c => {
try { try {
const { symbols, useBatching = false, totalDelayHours = 1 } = await c.req.json(); const { symbols, useBatching = false, totalDelayHours = 1 } = await c.req.json();
const { processItems } = await import('../utils/batch-helpers'); const { processItems } = await import('../utils/batch-helpers');
if (!symbols || !Array.isArray(symbols)) { if (!symbols || !Array.isArray(symbols)) {
return c.json({ status: 'error', message: 'symbols array is required' }, 400); return c.json({ status: 'error', message: 'symbols array is required' }, 400);
} }
const result = await processItems( const result = await processItems(
symbols, symbols,
(symbol, index) => ({ (symbol, index) => ({
symbol, symbol,
index, index,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}), }),
queueManager, queueManager,
{ {
@ -33,14 +33,14 @@ testRoutes.post('/api/test/batch-symbols', async (c) => {
batchSize: 10, batchSize: 10,
priority: 1, priority: 1,
provider: 'test-provider', provider: 'test-provider',
operation: 'live-data' operation: 'live-data',
} }
); );
return c.json({ return c.json({
status: 'success', status: 'success',
message: 'Batch processing started', message: 'Batch processing started',
result result,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to start batch symbol processing', { error }); logger.error('Failed to start batch symbol processing', { error });
@ -48,21 +48,21 @@ testRoutes.post('/api/test/batch-symbols', async (c) => {
} }
}); });
testRoutes.post('/api/test/batch-custom', async (c) => { testRoutes.post('/api/test/batch-custom', async c => {
try { try {
const { items, useBatching = false, totalDelayHours = 0.5 } = await c.req.json(); const { items, useBatching = false, totalDelayHours = 0.5 } = await c.req.json();
const { processItems } = await import('../utils/batch-helpers'); const { processItems } = await import('../utils/batch-helpers');
if (!items || !Array.isArray(items)) { if (!items || !Array.isArray(items)) {
return c.json({ status: 'error', message: 'items array is required' }, 400); return c.json({ status: 'error', message: 'items array is required' }, 400);
} }
const result = await processItems( const result = await processItems(
items, items,
(item, index) => ({ (item, index) => ({
originalItem: item, originalItem: item,
processIndex: index, processIndex: index,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}), }),
queueManager, queueManager,
{ {
@ -71,14 +71,14 @@ testRoutes.post('/api/test/batch-custom', async (c) => {
batchSize: 5, batchSize: 5,
priority: 1, priority: 1,
provider: 'test-provider', provider: 'test-provider',
operation: 'custom-test' operation: 'custom-test',
} }
); );
return c.json({ return c.json({
status: 'success', status: 'success',
message: 'Custom batch processing started', message: 'Custom batch processing started',
result result,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to start custom batch processing', { error }); logger.error('Failed to start custom batch processing', { error });

View file

@ -1,135 +1,135 @@
import { getLogger } from '@stock-bot/logger'; import { getLogger } from '@stock-bot/logger';
export interface JobHandler { export interface JobHandler {
(payload: any): Promise<any>; (payload: any): Promise<any>;
} }
export interface JobData { export interface JobData {
type?: string; type?: string;
provider: string; provider: string;
operation: string; operation: string;
payload: any; payload: any;
priority?: number; priority?: number;
immediately?: boolean; immediately?: boolean;
} }
export interface ScheduledJob { export interface ScheduledJob {
type: string; type: string;
operation: string; operation: string;
payload: any; payload: any;
cronPattern: string; cronPattern: string;
priority?: number; priority?: number;
description?: string; description?: string;
immediately?: boolean; immediately?: boolean;
} }
export interface ProviderConfig { export interface ProviderConfig {
name: string; name: string;
operations: Record<string, JobHandler>; operations: Record<string, JobHandler>;
scheduledJobs?: ScheduledJob[]; scheduledJobs?: ScheduledJob[];
} }
export interface ProviderRegistry { export interface ProviderRegistry {
registerProvider: (config: ProviderConfig) => void; registerProvider: (config: ProviderConfig) => void;
getHandler: (provider: string, operation: string) => JobHandler | null; getHandler: (provider: string, operation: string) => JobHandler | null;
getAllScheduledJobs: () => Array<{ provider: string; job: ScheduledJob }>; getAllScheduledJobs: () => Array<{ provider: string; job: ScheduledJob }>;
getProviders: () => Array<{ key: string; config: ProviderConfig }>; getProviders: () => Array<{ key: string; config: ProviderConfig }>;
hasProvider: (provider: string) => boolean; hasProvider: (provider: string) => boolean;
clear: () => void; clear: () => void;
} }
/** /**
* Create a new provider registry instance * Create a new provider registry instance
*/ */
export function createProviderRegistry(): ProviderRegistry { export function createProviderRegistry(): ProviderRegistry {
const logger = getLogger('provider-registry'); const logger = getLogger('provider-registry');
const providers = new Map<string, ProviderConfig>(); const providers = new Map<string, ProviderConfig>();
/** /**
* Register a provider with its operations * Register a provider with its operations
*/ */
function registerProvider(config: ProviderConfig): void { function registerProvider(config: ProviderConfig): void {
providers.set(config.name, config); providers.set(config.name, config);
logger.info(`Registered provider: ${config.name}`, { logger.info(`Registered provider: ${config.name}`, {
operations: Object.keys(config.operations), operations: Object.keys(config.operations),
scheduledJobs: config.scheduledJobs?.length || 0 scheduledJobs: config.scheduledJobs?.length || 0,
}); });
} }
/** /**
* Get a job handler for a specific provider and operation * Get a job handler for a specific provider and operation
*/ */
function getHandler(provider: string, operation: string): JobHandler | null { function getHandler(provider: string, operation: string): JobHandler | null {
const providerConfig = providers.get(provider); const providerConfig = providers.get(provider);
if (!providerConfig) { if (!providerConfig) {
logger.warn(`Provider not found: ${provider}`); logger.warn(`Provider not found: ${provider}`);
return null; return null;
} }
const handler = providerConfig.operations[operation]; const handler = providerConfig.operations[operation];
if (!handler) { if (!handler) {
logger.warn(`Operation not found: ${operation} in provider ${provider}`); logger.warn(`Operation not found: ${operation} in provider ${provider}`);
return null; return null;
} }
return handler; return handler;
} }
/** /**
* Get all scheduled jobs from all providers * Get all scheduled jobs from all providers
*/ */
function getAllScheduledJobs(): Array<{ provider: string; job: ScheduledJob }> { function getAllScheduledJobs(): Array<{ provider: string; job: ScheduledJob }> {
const allJobs: Array<{ provider: string; job: ScheduledJob }> = []; const allJobs: Array<{ provider: string; job: ScheduledJob }> = [];
for (const [key, config] of providers) { for (const [key, config] of providers) {
if (config.scheduledJobs) { if (config.scheduledJobs) {
for (const job of config.scheduledJobs) { for (const job of config.scheduledJobs) {
allJobs.push({ allJobs.push({
provider: config.name, provider: config.name,
job job,
}); });
} }
} }
} }
return allJobs; return allJobs;
} }
/** /**
* Get all registered providers with their configurations * Get all registered providers with their configurations
*/ */
function getProviders(): Array<{ key: string; config: ProviderConfig }> { function getProviders(): Array<{ key: string; config: ProviderConfig }> {
return Array.from(providers.entries()).map(([key, config]) => ({ return Array.from(providers.entries()).map(([key, config]) => ({
key, key,
config config,
})); }));
} }
/** /**
* Check if a provider exists * Check if a provider exists
*/ */
function hasProvider(provider: string): boolean { function hasProvider(provider: string): boolean {
return providers.has(provider); return providers.has(provider);
} }
/** /**
* Clear all providers (useful for testing) * Clear all providers (useful for testing)
*/ */
function clear(): void { function clear(): void {
providers.clear(); providers.clear();
logger.info('All providers cleared'); logger.info('All providers cleared');
} }
return { return {
registerProvider, registerProvider,
getHandler, getHandler,
getAllScheduledJobs, getAllScheduledJobs,
getProviders, getProviders,
hasProvider, hasProvider,
clear clear,
}; };
} }
// Create the default shared registry instance // Create the default shared registry instance
export const providerRegistry = createProviderRegistry(); export const providerRegistry = createProviderRegistry();

View file

@ -1,380 +1,416 @@
import { Queue, Worker, QueueEvents, type Job } from 'bullmq'; import { Queue, QueueEvents, Worker, type Job } from 'bullmq';
import { getLogger } from '@stock-bot/logger'; import { getLogger } from '@stock-bot/logger';
import { providerRegistry, type JobData } from './provider-registry.service'; import { providerRegistry, type JobData } from './provider-registry.service';
export class QueueService { export class QueueService {
private logger = getLogger('queue-service'); private logger = getLogger('queue-service');
private queue!: Queue; private queue!: Queue;
private workers: Worker[] = []; private workers: Worker[] = [];
private queueEvents!: QueueEvents; private queueEvents!: QueueEvents;
private config = { private config = {
workers: parseInt(process.env.WORKER_COUNT || '5'), workers: parseInt(process.env.WORKER_COUNT || '5'),
concurrency: parseInt(process.env.WORKER_CONCURRENCY || '20'), concurrency: parseInt(process.env.WORKER_CONCURRENCY || '20'),
redis: { redis: {
host: process.env.DRAGONFLY_HOST || 'localhost', host: process.env.DRAGONFLY_HOST || 'localhost',
port: parseInt(process.env.DRAGONFLY_PORT || '6379') port: parseInt(process.env.DRAGONFLY_PORT || '6379'),
} },
}; };
private get isInitialized() { private get isInitialized() {
return !!this.queue; return !!this.queue;
} }
constructor() { constructor() {
// Don't initialize in constructor to allow for proper async initialization // Don't initialize in constructor to allow for proper async initialization
} async initialize() { }
if (this.isInitialized) { async initialize() {
this.logger.warn('Queue service already initialized'); if (this.isInitialized) {
return; this.logger.warn('Queue service already initialized');
} return;
}
this.logger.info('Initializing queue service...');
this.logger.info('Initializing queue service...');
try {
// Step 1: Register providers try {
await this.registerProviders(); // Step 1: Register providers
await this.registerProviders();
// Step 2: Setup queue and workers
const connection = this.getConnection(); // Step 2: Setup queue and workers
const queueName = '{data-service-queue}'; const connection = this.getConnection();
const queueName = '{data-service-queue}';
this.queue = new Queue(queueName, { this.queue = new Queue(queueName, {
connection, connection,
defaultJobOptions: { defaultJobOptions: {
removeOnComplete: 10, removeOnComplete: 10,
removeOnFail: 5, removeOnFail: 5,
attempts: 3, attempts: 3,
backoff: { type: 'exponential', delay: 1000 } backoff: { type: 'exponential', delay: 1000 },
} },
}); });
this.queueEvents = new QueueEvents(queueName, { connection }); this.queueEvents = new QueueEvents(queueName, { connection });
// Step 3: Create workers // Step 3: Create workers
const { workerCount, totalConcurrency } = this.createWorkers(queueName, connection); const { workerCount, totalConcurrency } = this.createWorkers(queueName, connection);
// Step 4: Wait for readiness (parallel) // Step 4: Wait for readiness (parallel)
await Promise.all([ await Promise.all([
this.queue.waitUntilReady(), this.queue.waitUntilReady(),
this.queueEvents.waitUntilReady(), this.queueEvents.waitUntilReady(),
...this.workers.map(worker => worker.waitUntilReady()) ...this.workers.map(worker => worker.waitUntilReady()),
]); ]);
// Step 5: Setup events and scheduled tasks // Step 5: Setup events and scheduled tasks
this.setupQueueEvents(); this.setupQueueEvents();
await this.setupScheduledTasks(); await this.setupScheduledTasks();
this.logger.info('Queue service initialized successfully', { this.logger.info('Queue service initialized successfully', {
workers: workerCount, workers: workerCount,
totalConcurrency totalConcurrency,
}); });
} catch (error) {
} catch (error) { this.logger.error('Failed to initialize queue service', { error });
this.logger.error('Failed to initialize queue service', { error }); throw error;
throw error; }
} }
} private getConnection() { private getConnection() {
return { return {
...this.config.redis, ...this.config.redis,
maxRetriesPerRequest: null, maxRetriesPerRequest: null,
retryDelayOnFailover: 100, retryDelayOnFailover: 100,
lazyConnect: false lazyConnect: false,
}; };
} }
private createWorkers(queueName: string, connection: any) { private createWorkers(queueName: string, connection: any) {
for (let i = 0; i < this.config.workers; i++) { for (let i = 0; i < this.config.workers; i++) {
const worker = new Worker(queueName, this.processJob.bind(this), { const worker = new Worker(queueName, this.processJob.bind(this), {
connection: { ...connection }, connection: { ...connection },
concurrency: this.config.concurrency, concurrency: this.config.concurrency,
maxStalledCount: 1, maxStalledCount: 1,
stalledInterval: 30000, stalledInterval: 30000,
}); });
// Setup events inline // Setup events inline
worker.on('ready', () => this.logger.info(`Worker ${i + 1} ready`)); worker.on('ready', () => this.logger.info(`Worker ${i + 1} ready`));
worker.on('error', (error) => this.logger.error(`Worker ${i + 1} error`, { error })); worker.on('error', error => this.logger.error(`Worker ${i + 1} error`, { error }));
this.workers.push(worker); this.workers.push(worker);
} }
return { return {
workerCount: this.config.workers, workerCount: this.config.workers,
totalConcurrency: this.config.workers * this.config.concurrency totalConcurrency: this.config.workers * this.config.concurrency,
}; };
} private setupQueueEvents() { }
// Only log failures, not every completion private setupQueueEvents() {
this.queueEvents.on('failed', (job, error) => { // Add comprehensive logging to see job flow
this.logger.error('Job failed', { this.queueEvents.on('added', job => {
id: job.jobId, this.logger.debug('Job added to queue', {
error: String(error) id: job.jobId,
}); });
}); });
// Only log completions in debug mode this.queueEvents.on('waiting', job => {
if (process.env.LOG_LEVEL === 'debug') { this.logger.debug('Job moved to waiting', {
this.queueEvents.on('completed', (job) => { id: job.jobId,
this.logger.debug('Job completed', { id: job.jobId }); });
}); });
}
}private async registerProviders() { this.queueEvents.on('active', job => {
this.logger.info('Registering providers...'); this.logger.debug('Job became active', {
id: job.jobId,
try { });
// Define providers to register });
const providers = [
{ module: '../providers/proxy.provider', export: 'proxyProvider' }, this.queueEvents.on('delayed', job => {
{ module: '../providers/quotemedia.provider', export: 'quotemediaProvider' }, this.logger.debug('Job delayed', {
{ module: '../providers/yahoo.provider', export: 'yahooProvider' } id: job.jobId,
]; delay: job.delay,
});
// Import and register all providers });
for (const { module, export: exportName } of providers) {
const providerModule = await import(module); this.queueEvents.on('completed', job => {
providerRegistry.registerProvider(providerModule[exportName]); this.logger.debug('Job completed', {
} id: job.jobId,
});
this.logger.info('All providers registered successfully'); });
} catch (error) {
this.logger.error('Failed to register providers', { error }); this.queueEvents.on('failed', (job, error) => {
throw error; this.logger.error('Job failed', {
} id: job.jobId,
}private async processJob(job: Job) { error: String(error),
const { provider, operation, payload }: JobData = job.data; });
});
this.logger.info('Processing job', { }
id: job.id, private async registerProviders() {
provider, this.logger.info('Registering providers...');
operation,
payloadKeys: Object.keys(payload || {}) try {
}); try { // Define providers to register
let result; const providers = [
{ module: '../providers/proxy.provider', export: 'proxyProvider' },
if (operation === 'process-batch-items') { { module: '../providers/quotemedia.provider', export: 'quotemediaProvider' },
// Special handling for batch processing - requires 2 parameters { module: '../providers/yahoo.provider', export: 'yahooProvider' },
const { processBatchJob } = await import('../utils/batch-helpers'); ];
result = await processBatchJob(payload, this);
} else { // Import and register all providers
// Regular handler lookup - requires 1 parameter for (const { module, export: exportName } of providers) {
const handler = providerRegistry.getHandler(provider, operation); const providerModule = await import(module);
providerRegistry.registerProvider(providerModule[exportName]);
if (!handler) { }
throw new Error(`No handler found for ${provider}:${operation}`);
} this.logger.info('All providers registered successfully');
} catch (error) {
result = await handler(payload); this.logger.error('Failed to register providers', { error });
} throw error;
}
this.logger.info('Job completed successfully', { }
id: job.id, private async processJob(job: Job) {
provider, const { provider, operation, payload }: JobData = job.data;
operation
}); this.logger.info('Processing job', {
id: job.id,
return result; provider,
operation,
} catch (error) { payloadKeys: Object.keys(payload || {}),
const errorMessage = error instanceof Error ? error.message : String(error); });
this.logger.error('Job failed', { try {
id: job.id, let result;
provider,
operation, if (operation === 'process-batch-items') {
error: errorMessage // Special handling for batch processing - requires 2 parameters
}); const { processBatchJob } = await import('../utils/batch-helpers');
throw error; result = await processBatchJob(payload, this);
} } else {
} async addBulk(jobs: any[]): Promise<any[]> { // Regular handler lookup - requires 1 parameter
return await this.queue.addBulk(jobs); const handler = providerRegistry.getHandler(provider, operation);
}
if (!handler) {
private getTotalConcurrency() { throw new Error(`No handler found for ${provider}:${operation}`);
return this.workers.reduce((total, worker) => total + (worker.opts.concurrency || 1), 0); }
}
private async setupScheduledTasks() { result = await handler(payload);
const allScheduledJobs = providerRegistry.getAllScheduledJobs(); }
if (allScheduledJobs.length === 0) { this.logger.info('Job completed successfully', {
this.logger.warn('No scheduled jobs found in providers'); id: job.id,
return; provider,
} operation,
});
this.logger.info('Setting up scheduled tasks...', { count: allScheduledJobs.length });
return result;
// Use Promise.allSettled for parallel processing + better error handling } catch (error) {
const results = await Promise.allSettled( const errorMessage = error instanceof Error ? error.message : String(error);
allScheduledJobs.map(async ({ provider, job }) => { this.logger.error('Job failed', {
await this.addRecurringJob({ id: job.id,
type: job.type, provider,
provider, operation,
operation: job.operation, error: errorMessage,
payload: job.payload, });
priority: job.priority, throw error;
immediately: job.immediately || false }
}, job.cronPattern); }
return { provider, operation: job.operation }; async addBulk(jobs: any[]): Promise<any[]> {
}) return await this.queue.addBulk(jobs);
); }
// Log results private getTotalConcurrency() {
const successful = results.filter(r => r.status === 'fulfilled'); return this.workers.reduce((total, worker) => total + (worker.opts.concurrency || 1), 0);
const failed = results.filter(r => r.status === 'rejected'); }
if (failed.length > 0) { private async setupScheduledTasks() {
failed.forEach((result, index) => { const allScheduledJobs = providerRegistry.getAllScheduledJobs();
const { provider, job } = allScheduledJobs[index];
this.logger.error('Failed to register scheduled job', { if (allScheduledJobs.length === 0) {
provider, this.logger.warn('No scheduled jobs found in providers');
operation: job.operation, return;
error: result.reason }
});
}); this.logger.info('Setting up scheduled tasks...', { count: allScheduledJobs.length });
}
// Use Promise.allSettled for parallel processing + better error handling
this.logger.info('Scheduled tasks setup complete', { const results = await Promise.allSettled(
successful: successful.length, allScheduledJobs.map(async ({ provider, job }) => {
failed: failed.length await this.addRecurringJob(
}); {
} private async addJobInternal(jobData: JobData, options: any = {}) { type: job.type,
if (!this.isInitialized) { provider,
throw new Error('Queue service not initialized'); operation: job.operation,
} payload: job.payload,
priority: job.priority,
const jobType = jobData.type || `${jobData.provider}-${jobData.operation}`; immediately: job.immediately || false,
return this.queue.add(jobType, jobData, { },
priority: jobData.priority || 0, job.cronPattern
removeOnComplete: 10, );
removeOnFail: 5,
...options return { provider, operation: job.operation };
}); })
} );
async addJob(jobData: JobData, options?: any) { // Log results
return this.addJobInternal(jobData, options); const successful = results.filter(r => r.status === 'fulfilled');
} async addRecurringJob(jobData: JobData, cronPattern: string, options?: any) { const failed = results.filter(r => r.status === 'rejected');
const jobKey = `recurring-${jobData.provider}-${jobData.operation}`;
if (failed.length > 0) {
return this.addJobInternal(jobData, { failed.forEach((result, index) => {
repeat: { const { provider, job } = allScheduledJobs[index];
pattern: cronPattern, this.logger.error('Failed to register scheduled job', {
tz: 'UTC', provider,
immediately: jobData.immediately || false, operation: job.operation,
}, error: result.reason,
jobId: jobKey, });
removeOnComplete: 1, });
removeOnFail: 1, }
attempts: 2,
backoff: { this.logger.info('Scheduled tasks setup complete', {
type: 'fixed', successful: successful.length,
delay: 5000 failed: failed.length,
}, });
...options }
}); private async addJobInternal(jobData: JobData, options: any = {}) {
} if (!this.isInitialized) {
async getJobStats() { throw new Error('Queue service not initialized');
if (!this.isInitialized) { }
throw new Error('Queue service not initialized. Call initialize() first.');
} const jobType = jobData.type || `${jobData.provider}-${jobData.operation}`;
const [waiting, active, completed, failed, delayed] = await Promise.all([ return this.queue.add(jobType, jobData, {
this.queue.getWaiting(), priority: jobData.priority || 0,
this.queue.getActive(), removeOnComplete: 10,
this.queue.getCompleted(), removeOnFail: 5,
this.queue.getFailed(), ...options,
this.queue.getDelayed() });
]); }
return { async addJob(jobData: JobData, options?: any) {
waiting: waiting.length, return this.addJobInternal(jobData, options);
active: active.length, }
completed: completed.length,
failed: failed.length, async addRecurringJob(jobData: JobData, cronPattern: string, options?: any) {
delayed: delayed.length const jobKey = `recurring-${jobData.provider}-${jobData.operation}`;
};
} return this.addJobInternal(jobData, {
async drainQueue() { repeat: {
if (this.isInitialized) { pattern: cronPattern,
await this.queue.drain(); tz: 'UTC',
} immediately: jobData.immediately || false,
} },
async getQueueStatus() { jobId: jobKey,
if (!this.isInitialized) { removeOnComplete: 1,
throw new Error('Queue service not initialized'); removeOnFail: 1,
} attempts: 2,
backoff: {
const stats = await this.getJobStats(); type: 'fixed',
return { delay: 5000,
...stats, },
workers: this.workers.length, ...options,
concurrency: this.getTotalConcurrency() });
}; }
} async getJobStats() {
async shutdown() { if (!this.isInitialized) {
if (!this.isInitialized) { throw new Error('Queue service not initialized. Call initialize() first.');
this.logger.warn('Queue service not initialized, nothing to shutdown'); }
return; const [waiting, active, completed, failed, delayed] = await Promise.all([
} this.queue.getWaiting(),
this.queue.getActive(),
this.logger.info('Shutting down queue service gracefully...'); this.queue.getCompleted(),
this.queue.getFailed(),
try { this.queue.getDelayed(),
// Step 1: Stop accepting new jobs and wait for current jobs to finish ]);
this.logger.debug('Closing workers gracefully...');
const workerClosePromises = this.workers.map(async (worker, index) => { return {
this.logger.debug(`Closing worker ${index + 1}/${this.workers.length}`); waiting: waiting.length,
try { active: active.length,
// Wait for current jobs to finish, then close completed: completed.length,
await Promise.race([ failed: failed.length,
worker.close(), delayed: delayed.length,
new Promise((_, reject) => };
setTimeout(() => reject(new Error(`Worker ${index + 1} close timeout`)), 5000) }
) async drainQueue() {
]); if (this.isInitialized) {
this.logger.debug(`Worker ${index + 1} closed successfully`); await this.queue.drain();
} catch (error) { }
this.logger.error(`Failed to close worker ${index + 1}`, { error }); }
// Force close if graceful close fails async getQueueStatus() {
await worker.close(true); if (!this.isInitialized) {
} throw new Error('Queue service not initialized');
}); }
await Promise.allSettled(workerClosePromises); const stats = await this.getJobStats();
this.logger.debug('All workers closed'); return {
...stats,
// Step 2: Close queue and events with timeout protection workers: this.workers.length,
this.logger.debug('Closing queue and events...'); concurrency: this.getTotalConcurrency(),
await Promise.allSettled([ };
Promise.race([ }
this.queue.close(), async shutdown() {
new Promise((_, reject) => if (!this.isInitialized) {
setTimeout(() => reject(new Error('Queue close timeout')), 3000) this.logger.warn('Queue service not initialized, nothing to shutdown');
) return;
]).catch(error => this.logger.error('Queue close error', { error })), }
Promise.race([ this.logger.info('Shutting down queue service gracefully...');
this.queueEvents.close(),
new Promise((_, reject) => try {
setTimeout(() => reject(new Error('QueueEvents close timeout')), 3000) // Step 1: Stop accepting new jobs and wait for current jobs to finish
) this.logger.debug('Closing workers gracefully...');
]).catch(error => this.logger.error('QueueEvents close error', { error })) const workerClosePromises = this.workers.map(async (worker, index) => {
]); this.logger.debug(`Closing worker ${index + 1}/${this.workers.length}`);
try {
this.logger.info('Queue service shutdown completed successfully'); // Wait for current jobs to finish, then close
} catch (error) { await Promise.race([
this.logger.error('Error during queue service shutdown', { error }); worker.close(),
// Force close everything as last resort new Promise((_, reject) =>
try { setTimeout(() => reject(new Error(`Worker ${index + 1} close timeout`)), 5000)
await Promise.allSettled([ ),
...this.workers.map(worker => worker.close(true)), ]);
this.queue.close(), this.logger.debug(`Worker ${index + 1} closed successfully`);
this.queueEvents.close() } catch (error) {
]); this.logger.error(`Failed to close worker ${index + 1}`, { error });
} catch (forceCloseError) { // Force close if graceful close fails
this.logger.error('Force close also failed', { error: forceCloseError }); await worker.close(true);
} }
throw error; });
}
} await Promise.allSettled(workerClosePromises);
} this.logger.debug('All workers closed');
export const queueManager = new QueueService(); // Step 2: Close queue and events with timeout protection
this.logger.debug('Closing queue and events...');
await Promise.allSettled([
Promise.race([
this.queue.close(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Queue close timeout')), 3000)
),
]).catch(error => this.logger.error('Queue close error', { error })),
Promise.race([
this.queueEvents.close(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('QueueEvents close timeout')), 3000)
),
]).catch(error => this.logger.error('QueueEvents close error', { error })),
]);
this.logger.info('Queue service shutdown completed successfully');
} catch (error) {
this.logger.error('Error during queue service shutdown', { error });
// Force close everything as last resort
try {
await Promise.allSettled([
...this.workers.map(worker => worker.close(true)),
this.queue.close(),
this.queueEvents.close(),
]);
} catch (forceCloseError) {
this.logger.error('Force close also failed', { error: forceCloseError });
}
throw error;
}
}
}
export const queueManager = new QueueService();

View file

@ -1,5 +1,5 @@
import { CacheProvider, createCache } from '@stock-bot/cache';
import { getLogger } from '@stock-bot/logger'; import { getLogger } from '@stock-bot/logger';
import { createCache, CacheProvider } from '@stock-bot/cache';
import type { QueueService } from '../services/queue.service'; import type { QueueService } from '../services/queue.service';
const logger = getLogger('batch-helpers'); const logger = getLogger('batch-helpers');
@ -35,7 +35,7 @@ function getCache(): CacheProvider {
cacheProvider = createCache({ cacheProvider = createCache({
keyPrefix: 'batch:', keyPrefix: 'batch:',
ttl: 86400, // 24 hours default ttl: 86400, // 24 hours default
enableMetrics: true enableMetrics: true,
}); });
} }
return cacheProvider; return cacheProvider;
@ -62,13 +62,13 @@ export async function processItems<T>(
options: ProcessOptions options: ProcessOptions
): Promise<BatchResult> { ): Promise<BatchResult> {
const startTime = Date.now(); const startTime = Date.now();
if (items.length === 0) { if (items.length === 0) {
return { return {
jobsCreated: 0, jobsCreated: 0,
mode: 'direct', mode: 'direct',
totalItems: 0, totalItems: 0,
duration: 0 duration: 0,
}; };
} }
@ -76,23 +76,22 @@ export async function processItems<T>(
totalItems: items.length, totalItems: items.length,
mode: options.useBatching ? 'batch' : 'direct', mode: options.useBatching ? 'batch' : 'direct',
batchSize: options.batchSize, batchSize: options.batchSize,
totalDelayHours: options.totalDelayHours totalDelayHours: options.totalDelayHours,
}); });
try { try {
const result = options.useBatching const result = options.useBatching
? await processBatched(items, processor, queue, options) ? await processBatched(items, processor, queue, options)
: await processDirect(items, processor, queue, options); : await processDirect(items, processor, queue, options);
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
logger.info('Batch processing completed', { logger.info('Batch processing completed', {
...result, ...result,
duration: `${(duration / 1000).toFixed(1)}s` duration: `${(duration / 1000).toFixed(1)}s`,
}); });
return { ...result, duration }; return { ...result, duration };
} catch (error) { } catch (error) {
logger.error('Batch processing failed', error); logger.error('Batch processing failed', error);
throw error; throw error;
@ -108,13 +107,12 @@ async function processDirect<T>(
queue: QueueService, queue: QueueService,
options: ProcessOptions options: ProcessOptions
): Promise<Omit<BatchResult, 'duration'>> { ): Promise<Omit<BatchResult, 'duration'>> {
const totalDelayMs = options.totalDelayHours * 60 * 60 * 1000; const totalDelayMs = options.totalDelayHours * 60 * 60 * 1000;
const delayPerItem = totalDelayMs / items.length; const delayPerItem = totalDelayMs / items.length;
logger.info('Creating direct jobs', { logger.info('Creating direct jobs', {
totalItems: items.length, totalItems: items.length,
delayPerItem: `${(delayPerItem / 1000).toFixed(1)}s` delayPerItem: `${(delayPerItem / 1000).toFixed(1)}s`,
}); });
const jobs = items.map((item, index) => ({ const jobs = items.map((item, index) => ({
@ -124,23 +122,23 @@ async function processDirect<T>(
provider: options.provider || 'generic', provider: options.provider || 'generic',
operation: options.operation || 'process-item', operation: options.operation || 'process-item',
payload: processor(item, index), payload: processor(item, index),
priority: options.priority || 1 priority: options.priority || 1,
}, },
opts: { opts: {
delay: index * delayPerItem, delay: index * delayPerItem,
priority: options.priority || 1, priority: options.priority || 1,
attempts: options.retries || 3, attempts: options.retries || 3,
removeOnComplete: options.removeOnComplete || 10, removeOnComplete: options.removeOnComplete || 10,
removeOnFail: options.removeOnFail || 5 removeOnFail: options.removeOnFail || 5,
} },
})); }));
const createdJobs = await addJobsInChunks(queue, jobs); const createdJobs = await addJobsInChunks(queue, jobs);
return { return {
totalItems: items.length, totalItems: items.length,
jobsCreated: createdJobs.length, jobsCreated: createdJobs.length,
mode: 'direct' mode: 'direct',
}; };
} }
@ -153,7 +151,6 @@ async function processBatched<T>(
queue: QueueService, queue: QueueService,
options: ProcessOptions options: ProcessOptions
): Promise<Omit<BatchResult, 'duration'>> { ): Promise<Omit<BatchResult, 'duration'>> {
const batchSize = options.batchSize || 100; const batchSize = options.batchSize || 100;
const batches = createBatches(items, batchSize); const batches = createBatches(items, batchSize);
const totalDelayMs = options.totalDelayHours * 60 * 60 * 1000; const totalDelayMs = options.totalDelayHours * 60 * 60 * 1000;
@ -163,13 +160,13 @@ async function processBatched<T>(
totalItems: items.length, totalItems: items.length,
batchSize, batchSize,
totalBatches: batches.length, totalBatches: batches.length,
delayPerBatch: `${(delayPerBatch / 1000 / 60).toFixed(2)} minutes` delayPerBatch: `${(delayPerBatch / 1000 / 60).toFixed(2)} minutes`,
}); });
const batchJobs = await Promise.all( const batchJobs = await Promise.all(
batches.map(async (batch, batchIndex) => { batches.map(async (batch, batchIndex) => {
const payloadKey = await storePayload(batch, processor, options); const payloadKey = await storePayload(batch, processor, options);
return { return {
name: 'process-batch', name: 'process-batch',
data: { data: {
@ -180,17 +177,17 @@ async function processBatched<T>(
payloadKey, payloadKey,
batchIndex, batchIndex,
totalBatches: batches.length, totalBatches: batches.length,
itemCount: batch.length itemCount: batch.length,
}, },
priority: options.priority || 2 priority: options.priority || 2,
}, },
opts: { opts: {
delay: batchIndex * delayPerBatch, delay: batchIndex * delayPerBatch,
priority: options.priority || 2, priority: options.priority || 2,
attempts: options.retries || 3, attempts: options.retries || 3,
removeOnComplete: options.removeOnComplete || 10, removeOnComplete: options.removeOnComplete || 10,
removeOnFail: options.removeOnFail || 5 removeOnFail: options.removeOnFail || 5,
} },
}; };
}) })
); );
@ -201,7 +198,7 @@ async function processBatched<T>(
totalItems: items.length, totalItems: items.length,
jobsCreated: createdJobs.length, jobsCreated: createdJobs.length,
batchesCreated: batches.length, batchesCreated: batches.length,
mode: 'batch' mode: 'batch',
}; };
} }
@ -210,11 +207,11 @@ async function processBatched<T>(
*/ */
export async function processBatchJob(jobData: any, queue: QueueService): Promise<any> { export async function processBatchJob(jobData: any, queue: QueueService): Promise<any> {
const { payloadKey, batchIndex, totalBatches, itemCount } = jobData; const { payloadKey, batchIndex, totalBatches, itemCount } = jobData;
logger.debug('Processing batch job', { logger.debug('Processing batch job', {
batchIndex, batchIndex,
totalBatches, totalBatches,
itemCount itemCount,
}); });
try { try {
@ -225,7 +222,7 @@ export async function processBatchJob(jobData: any, queue: QueueService): Promis
} }
const { items, processorStr, options } = payload; const { items, processorStr, options } = payload;
// Deserialize the processor function // Deserialize the processor function
const processor = new Function('return ' + processorStr)(); const processor = new Function('return ' + processorStr)();
@ -236,26 +233,25 @@ export async function processBatchJob(jobData: any, queue: QueueService): Promis
provider: options.provider || 'generic', provider: options.provider || 'generic',
operation: options.operation || 'generic', operation: options.operation || 'generic',
payload: processor(item, index), payload: processor(item, index),
priority: options.priority || 1 priority: options.priority || 1,
}, },
opts: { opts: {
delay: index * (options.delayPerItem || 1000), delay: index * (options.delayPerItem || 1000),
priority: options.priority || 1, priority: options.priority || 1,
attempts: options.retries || 3 attempts: options.retries || 3,
} },
})); }));
const createdJobs = await addJobsInChunks(queue, jobs); const createdJobs = await addJobsInChunks(queue, jobs);
// Cleanup payload after successful processing // Cleanup payload after successful processing
await cleanupPayload(payloadKey); await cleanupPayload(payloadKey);
return { return {
batchIndex, batchIndex,
itemsProcessed: items.length, itemsProcessed: items.length,
jobsCreated: createdJobs.length jobsCreated: createdJobs.length,
}; };
} catch (error) { } catch (error) {
logger.error('Batch job processing failed', { batchIndex, error }); logger.error('Batch job processing failed', { batchIndex, error });
throw error; throw error;
@ -273,20 +269,20 @@ function createBatches<T>(items: T[], batchSize: number): T[][] {
} }
async function storePayload<T>( async function storePayload<T>(
items: T[], items: T[],
processor: (item: T, index: number) => any, processor: (item: T, index: number) => any,
options: ProcessOptions options: ProcessOptions
): Promise<string> { ): Promise<string> {
const cache = getCache(); const cache = getCache();
// Create more specific key: batch:provider:operation:payload_timestamp_random // Create more specific key: batch:provider:operation:payload_timestamp_random
const timestamp = Date.now(); const timestamp = Date.now();
const randomId = Math.random().toString(36).substr(2, 9); const randomId = Math.random().toString(36).substr(2, 9);
const provider = options.provider || 'generic'; const provider = options.provider || 'generic';
const operation = options.operation || 'generic'; const operation = options.operation || 'generic';
const key = `${provider}:${operation}:payload_${timestamp}_${randomId}`; const key = `${provider}:${operation}:payload_${timestamp}_${randomId}`;
const payload = { const payload = {
items, items,
processorStr: processor.toString(), processorStr: processor.toString(),
@ -296,33 +292,33 @@ async function storePayload<T>(
retries: options.retries || 3, retries: options.retries || 3,
// Store routing information for later use // Store routing information for later use
provider: options.provider || 'generic', provider: options.provider || 'generic',
operation: options.operation || 'generic' operation: options.operation || 'generic',
}, },
createdAt: Date.now() createdAt: Date.now(),
}; };
logger.debug('Storing batch payload', { logger.debug('Storing batch payload', {
key, key,
itemCount: items.length itemCount: items.length,
}); });
await cache.set(key, payload, options.ttl || 86400); await cache.set(key, payload, options.ttl || 86400);
logger.debug('Stored batch payload successfully', { logger.debug('Stored batch payload successfully', {
key, key,
itemCount: items.length itemCount: items.length,
}); });
return key; return key;
} }
async function loadPayload(key: string): Promise<any> { async function loadPayload(key: string): Promise<any> {
const cache = getCache(); const cache = getCache();
logger.debug('Loading batch payload', { key }); logger.debug('Loading batch payload', { key });
const data = await cache.get(key); const data = await cache.get(key);
if (!data) { if (!data) {
logger.error('Payload not found in cache', { key }); logger.error('Payload not found in cache', { key });
throw new Error(`Payload not found: ${key}`); throw new Error(`Payload not found: ${key}`);
@ -344,27 +340,25 @@ async function cleanupPayload(key: string): Promise<void> {
async function addJobsInChunks(queue: QueueService, jobs: any[], chunkSize = 100): Promise<any[]> { async function addJobsInChunks(queue: QueueService, jobs: any[], chunkSize = 100): Promise<any[]> {
const allCreatedJobs = []; const allCreatedJobs = [];
for (let i = 0; i < jobs.length; i += chunkSize) { for (let i = 0; i < jobs.length; i += chunkSize) {
const chunk = jobs.slice(i, i + chunkSize); const chunk = jobs.slice(i, i + chunkSize);
try { try {
const createdJobs = await queue.addBulk(chunk); const createdJobs = await queue.addBulk(chunk);
allCreatedJobs.push(...createdJobs); allCreatedJobs.push(...createdJobs);
// Small delay between chunks to avoid overwhelming Redis // Small delay between chunks to avoid overwhelming Redis
if (i + chunkSize < jobs.length) { if (i + chunkSize < jobs.length) {
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
} }
} catch (error) { } catch (error) {
logger.error('Failed to add job chunk', { logger.error('Failed to add job chunk', {
startIndex: i, startIndex: i,
chunkSize: chunk.length, chunkSize: chunk.length,
error error,
}); });
} }
} }
return allCreatedJobs; return allCreatedJobs;
} }

View file

@ -1,94 +1,94 @@
import { Order, OrderResult, OrderStatus } from '@stock-bot/types'; import { Order, OrderResult, OrderStatus } from '@stock-bot/types';
export interface BrokerInterface { export interface BrokerInterface {
/** /**
* Execute an order with the broker * Execute an order with the broker
*/ */
executeOrder(order: Order): Promise<OrderResult>; executeOrder(order: Order): Promise<OrderResult>;
/** /**
* Get order status from broker * Get order status from broker
*/ */
getOrderStatus(orderId: string): Promise<OrderStatus>; getOrderStatus(orderId: string): Promise<OrderStatus>;
/** /**
* Cancel an order * Cancel an order
*/ */
cancelOrder(orderId: string): Promise<boolean>; cancelOrder(orderId: string): Promise<boolean>;
/** /**
* Get current positions * Get current positions
*/ */
getPositions(): Promise<Position[]>; getPositions(): Promise<Position[]>;
/** /**
* Get account balance * Get account balance
*/ */
getAccountBalance(): Promise<AccountBalance>; getAccountBalance(): Promise<AccountBalance>;
} }
export interface Position { export interface Position {
symbol: string; symbol: string;
quantity: number; quantity: number;
averagePrice: number; averagePrice: number;
currentPrice: number; currentPrice: number;
unrealizedPnL: number; unrealizedPnL: number;
side: 'long' | 'short'; side: 'long' | 'short';
} }
export interface AccountBalance { export interface AccountBalance {
totalValue: number; totalValue: number;
availableCash: number; availableCash: number;
buyingPower: number; buyingPower: number;
marginUsed: number; marginUsed: number;
} }
export class MockBroker implements BrokerInterface { export class MockBroker implements BrokerInterface {
private orders: Map<string, OrderResult> = new Map(); private orders: Map<string, OrderResult> = new Map();
private positions: Position[] = []; private positions: Position[] = [];
async executeOrder(order: Order): Promise<OrderResult> { async executeOrder(order: Order): Promise<OrderResult> {
const orderId = `mock_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const orderId = `mock_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const result: OrderResult = { const result: OrderResult = {
orderId, orderId,
symbol: order.symbol, symbol: order.symbol,
quantity: order.quantity, quantity: order.quantity,
side: order.side, side: order.side,
status: 'filled', status: 'filled',
executedPrice: order.price || 100, // Mock price executedPrice: order.price || 100, // Mock price
executedAt: new Date(), executedAt: new Date(),
commission: 1.0 commission: 1.0,
}; };
this.orders.set(orderId, result); this.orders.set(orderId, result);
return result; return result;
} }
async getOrderStatus(orderId: string): Promise<OrderStatus> { async getOrderStatus(orderId: string): Promise<OrderStatus> {
const order = this.orders.get(orderId); const order = this.orders.get(orderId);
return order?.status || 'unknown'; return order?.status || 'unknown';
} }
async cancelOrder(orderId: string): Promise<boolean> { async cancelOrder(orderId: string): Promise<boolean> {
const order = this.orders.get(orderId); const order = this.orders.get(orderId);
if (order && order.status === 'pending') { if (order && order.status === 'pending') {
order.status = 'cancelled'; order.status = 'cancelled';
return true; return true;
} }
return false; return false;
} }
async getPositions(): Promise<Position[]> { async getPositions(): Promise<Position[]> {
return this.positions; return this.positions;
} }
async getAccountBalance(): Promise<AccountBalance> { async getAccountBalance(): Promise<AccountBalance> {
return { return {
totalValue: 100000, totalValue: 100000,
availableCash: 50000, availableCash: 50000,
buyingPower: 200000, buyingPower: 200000,
marginUsed: 0 marginUsed: 0,
}; };
} }
} }

View file

@ -1,57 +1,58 @@
import { Order, OrderResult } from '@stock-bot/types'; import { logger } from '@stock-bot/logger';
import { logger } from '@stock-bot/logger'; import { Order, OrderResult } from '@stock-bot/types';
import { BrokerInterface } from '../broker/interface.ts'; import { BrokerInterface } from '../broker/interface.ts';
export class OrderManager { export class OrderManager {
private broker: BrokerInterface; private broker: BrokerInterface;
private pendingOrders: Map<string, Order> = new Map(); private pendingOrders: Map<string, Order> = new Map();
constructor(broker: BrokerInterface) { constructor(broker: BrokerInterface) {
this.broker = broker; this.broker = broker;
} }
async executeOrder(order: Order): Promise<OrderResult> { async executeOrder(order: Order): Promise<OrderResult> {
try { try {
logger.info(`Executing order: ${order.symbol} ${order.side} ${order.quantity} @ ${order.price}`); logger.info(
`Executing order: ${order.symbol} ${order.side} ${order.quantity} @ ${order.price}`
// Add to pending orders );
const orderId = `order_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
this.pendingOrders.set(orderId, order); // Add to pending orders
const orderId = `order_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Execute with broker this.pendingOrders.set(orderId, order);
const result = await this.broker.executeOrder(order);
// Execute with broker
// Remove from pending const result = await this.broker.executeOrder(order);
this.pendingOrders.delete(orderId);
// Remove from pending
logger.info(`Order executed successfully: ${result.orderId}`); this.pendingOrders.delete(orderId);
return result;
logger.info(`Order executed successfully: ${result.orderId}`);
} catch (error) { return result;
logger.error('Order execution failed', error); } catch (error) {
throw error; logger.error('Order execution failed', error);
} throw error;
} }
}
async cancelOrder(orderId: string): Promise<boolean> {
try { async cancelOrder(orderId: string): Promise<boolean> {
const success = await this.broker.cancelOrder(orderId); try {
if (success) { const success = await this.broker.cancelOrder(orderId);
this.pendingOrders.delete(orderId); if (success) {
logger.info(`Order cancelled: ${orderId}`); this.pendingOrders.delete(orderId);
} logger.info(`Order cancelled: ${orderId}`);
return success; }
} catch (error) { return success;
logger.error('Order cancellation failed', error); } catch (error) {
throw error; logger.error('Order cancellation failed', error);
} throw error;
} }
}
async getOrderStatus(orderId: string) {
return await this.broker.getOrderStatus(orderId); async getOrderStatus(orderId: string) {
} return await this.broker.getOrderStatus(orderId);
}
getPendingOrders(): Order[] {
return Array.from(this.pendingOrders.values()); getPendingOrders(): Order[] {
} return Array.from(this.pendingOrders.values());
} }
}

View file

@ -1,111 +1,113 @@
import { Order } from '@stock-bot/types'; import { getLogger } from '@stock-bot/logger';
import { getLogger } from '@stock-bot/logger'; import { Order } from '@stock-bot/types';
export interface RiskRule { export interface RiskRule {
name: string; name: string;
validate(order: Order, context: RiskContext): Promise<RiskValidationResult>; validate(order: Order, context: RiskContext): Promise<RiskValidationResult>;
} }
export interface RiskContext { export interface RiskContext {
currentPositions: Map<string, number>; currentPositions: Map<string, number>;
accountBalance: number; accountBalance: number;
totalExposure: number; totalExposure: number;
maxPositionSize: number; maxPositionSize: number;
maxDailyLoss: number; maxDailyLoss: number;
} }
export interface RiskValidationResult { export interface RiskValidationResult {
isValid: boolean; isValid: boolean;
reason?: string; reason?: string;
severity: 'info' | 'warning' | 'error'; severity: 'info' | 'warning' | 'error';
} }
export class RiskManager { export class RiskManager {
private logger = getLogger('risk-manager'); private logger = getLogger('risk-manager');
private rules: RiskRule[] = []; private rules: RiskRule[] = [];
constructor() { constructor() {
this.initializeDefaultRules(); this.initializeDefaultRules();
} }
addRule(rule: RiskRule): void { addRule(rule: RiskRule): void {
this.rules.push(rule); this.rules.push(rule);
} }
async validateOrder(order: Order, context: RiskContext): Promise<RiskValidationResult> { async validateOrder(order: Order, context: RiskContext): Promise<RiskValidationResult> {
for (const rule of this.rules) { for (const rule of this.rules) {
const result = await rule.validate(order, context); const result = await rule.validate(order, context);
if (!result.isValid) { if (!result.isValid) {
logger.warn(`Risk rule violation: ${rule.name}`, { logger.warn(`Risk rule violation: ${rule.name}`, {
order, order,
reason: result.reason reason: result.reason,
}); });
return result; return result;
} }
} }
return { isValid: true, severity: 'info' }; return { isValid: true, severity: 'info' };
} }
private initializeDefaultRules(): void { private initializeDefaultRules(): void {
// Position size rule // Position size rule
this.addRule({ this.addRule({
name: 'MaxPositionSize', name: 'MaxPositionSize',
async validate(order: Order, context: RiskContext): Promise<RiskValidationResult> { async validate(order: Order, context: RiskContext): Promise<RiskValidationResult> {
const orderValue = order.quantity * (order.price || 0); const orderValue = order.quantity * (order.price || 0);
if (orderValue > context.maxPositionSize) { if (orderValue > context.maxPositionSize) {
return { return {
isValid: false, isValid: false,
reason: `Order size ${orderValue} exceeds maximum position size ${context.maxPositionSize}`, reason: `Order size ${orderValue} exceeds maximum position size ${context.maxPositionSize}`,
severity: 'error' severity: 'error',
}; };
} }
return { isValid: true, severity: 'info' }; return { isValid: true, severity: 'info' };
} },
}); });
// Balance check rule // Balance check rule
this.addRule({ this.addRule({
name: 'SufficientBalance', name: 'SufficientBalance',
async validate(order: Order, context: RiskContext): Promise<RiskValidationResult> { async validate(order: Order, context: RiskContext): Promise<RiskValidationResult> {
const orderValue = order.quantity * (order.price || 0); const orderValue = order.quantity * (order.price || 0);
if (order.side === 'buy' && orderValue > context.accountBalance) { if (order.side === 'buy' && orderValue > context.accountBalance) {
return { return {
isValid: false, isValid: false,
reason: `Insufficient balance: need ${orderValue}, have ${context.accountBalance}`, reason: `Insufficient balance: need ${orderValue}, have ${context.accountBalance}`,
severity: 'error' severity: 'error',
}; };
} }
return { isValid: true, severity: 'info' }; return { isValid: true, severity: 'info' };
} },
}); });
// Concentration risk rule // Concentration risk rule
this.addRule({ this.addRule({
name: 'ConcentrationLimit', name: 'ConcentrationLimit',
async validate(order: Order, context: RiskContext): Promise<RiskValidationResult> { async validate(order: Order, context: RiskContext): Promise<RiskValidationResult> {
const currentPosition = context.currentPositions.get(order.symbol) || 0; const currentPosition = context.currentPositions.get(order.symbol) || 0;
const newPosition = order.side === 'buy' ? const newPosition =
currentPosition + order.quantity : order.side === 'buy'
currentPosition - order.quantity; ? currentPosition + order.quantity
: currentPosition - order.quantity;
const positionValue = Math.abs(newPosition) * (order.price || 0);
const concentrationRatio = positionValue / context.accountBalance; const positionValue = Math.abs(newPosition) * (order.price || 0);
const concentrationRatio = positionValue / context.accountBalance;
if (concentrationRatio > 0.25) { // 25% max concentration
return { if (concentrationRatio > 0.25) {
isValid: false, // 25% max concentration
reason: `Position concentration ${(concentrationRatio * 100).toFixed(2)}% exceeds 25% limit`, return {
severity: 'warning' isValid: false,
}; reason: `Position concentration ${(concentrationRatio * 100).toFixed(2)}% exceeds 25% limit`,
} severity: 'warning',
};
return { isValid: true, severity: 'info' }; }
}
}); return { isValid: true, severity: 'info' };
} },
} });
}
}

View file

@ -1,97 +1,101 @@
import { Hono } from 'hono'; import { serve } from '@hono/node-server';
import { serve } from '@hono/node-server'; import { Hono } from 'hono';
import { getLogger } from '@stock-bot/logger'; import { config } from '@stock-bot/config';
import { config } from '@stock-bot/config'; import { getLogger } from '@stock-bot/logger';
// import { BrokerInterface } from './broker/interface.ts';
// import { OrderManager } from './execution/order-manager.ts'; // import { BrokerInterface } from './broker/interface.ts';
// import { RiskManager } from './execution/risk-manager.ts'; // import { OrderManager } from './execution/order-manager.ts';
// import { RiskManager } from './execution/risk-manager.ts';
const app = new Hono();
const logger = getLogger('execution-service'); const app = new Hono();
// Health check endpoint const logger = getLogger('execution-service');
app.get('/health', (c) => { // Health check endpoint
return c.json({ app.get('/health', c => {
status: 'healthy', return c.json({
service: 'execution-service', status: 'healthy',
timestamp: new Date().toISOString() service: 'execution-service',
}); timestamp: new Date().toISOString(),
}); });
});
// Order execution endpoints
app.post('/orders/execute', async (c) => { // Order execution endpoints
try { app.post('/orders/execute', async c => {
const orderRequest = await c.req.json(); try {
logger.info('Received order execution request', orderRequest); const orderRequest = await c.req.json();
logger.info('Received order execution request', orderRequest);
// TODO: Validate order and execute
return c.json({ // TODO: Validate order and execute
orderId: `order_${Date.now()}`, return c.json({
status: 'pending', orderId: `order_${Date.now()}`,
message: 'Order submitted for execution' status: 'pending',
}); message: 'Order submitted for execution',
} catch (error) { });
logger.error('Order execution failed', error); } catch (error) {
return c.json({ error: 'Order execution failed' }, 500); logger.error('Order execution failed', error);
} return c.json({ error: 'Order execution failed' }, 500);
}); }
});
app.get('/orders/:orderId/status', async (c) => {
const orderId = c.req.param('orderId'); app.get('/orders/:orderId/status', async c => {
const orderId = c.req.param('orderId');
try {
// TODO: Get order status from broker try {
return c.json({ // TODO: Get order status from broker
orderId, return c.json({
status: 'filled', orderId,
executedAt: new Date().toISOString() status: 'filled',
}); executedAt: new Date().toISOString(),
} catch (error) { });
logger.error('Failed to get order status', error); } catch (error) {
return c.json({ error: 'Failed to get order status' }, 500); logger.error('Failed to get order status', error);
} return c.json({ error: 'Failed to get order status' }, 500);
}); }
});
app.post('/orders/:orderId/cancel', async (c) => {
const orderId = c.req.param('orderId'); app.post('/orders/:orderId/cancel', async c => {
const orderId = c.req.param('orderId');
try {
// TODO: Cancel order with broker try {
return c.json({ // TODO: Cancel order with broker
orderId, return c.json({
status: 'cancelled', orderId,
cancelledAt: new Date().toISOString() status: 'cancelled',
}); cancelledAt: new Date().toISOString(),
} catch (error) { });
logger.error('Failed to cancel order', error); } catch (error) {
return c.json({ error: 'Failed to cancel order' }, 500); logger.error('Failed to cancel order', error);
} return c.json({ error: 'Failed to cancel order' }, 500);
}); }
});
// Risk management endpoints
app.get('/risk/position/:symbol', async (c) => { // Risk management endpoints
const symbol = c.req.param('symbol'); app.get('/risk/position/:symbol', async c => {
const symbol = c.req.param('symbol');
try {
// TODO: Get position risk metrics try {
return c.json({ // TODO: Get position risk metrics
symbol, return c.json({
position: 100, symbol,
exposure: 10000, position: 100,
risk: 'low' exposure: 10000,
}); risk: 'low',
} catch (error) { });
logger.error('Failed to get position risk', error); } catch (error) {
return c.json({ error: 'Failed to get position risk' }, 500); logger.error('Failed to get position risk', error);
} return c.json({ error: 'Failed to get position risk' }, 500);
}); }
});
const port = config.EXECUTION_SERVICE_PORT || 3004;
const port = config.EXECUTION_SERVICE_PORT || 3004;
logger.info(`Starting execution service on port ${port}`);
logger.info(`Starting execution service on port ${port}`);
serve({
fetch: app.fetch, serve(
port {
}, (info) => { fetch: app.fetch,
logger.info(`Execution service is running on port ${info.port}`); port,
}); },
info => {
logger.info(`Execution service is running on port ${info.port}`);
}
);

View file

@ -1,204 +1,210 @@
import { PortfolioSnapshot, Trade } from '../portfolio/portfolio-manager.ts'; import { PortfolioSnapshot, Trade } from '../portfolio/portfolio-manager.ts';
export interface PerformanceMetrics { export interface PerformanceMetrics {
totalReturn: number; totalReturn: number;
annualizedReturn: number; annualizedReturn: number;
sharpeRatio: number; sharpeRatio: number;
maxDrawdown: number; maxDrawdown: number;
volatility: number; volatility: number;
beta: number; beta: number;
alpha: number; alpha: number;
calmarRatio: number; calmarRatio: number;
sortinoRatio: number; sortinoRatio: number;
} }
export interface RiskMetrics { export interface RiskMetrics {
var95: number; // Value at Risk (95% confidence) var95: number; // Value at Risk (95% confidence)
cvar95: number; // Conditional Value at Risk cvar95: number; // Conditional Value at Risk
maxDrawdown: number; maxDrawdown: number;
downsideDeviation: number; downsideDeviation: number;
correlationMatrix: Record<string, Record<string, number>>; correlationMatrix: Record<string, Record<string, number>>;
} }
export class PerformanceAnalyzer { export class PerformanceAnalyzer {
private snapshots: PortfolioSnapshot[] = []; private snapshots: PortfolioSnapshot[] = [];
private benchmarkReturns: number[] = []; // S&P 500 or other benchmark private benchmarkReturns: number[] = []; // S&P 500 or other benchmark
addSnapshot(snapshot: PortfolioSnapshot): void { addSnapshot(snapshot: PortfolioSnapshot): void {
this.snapshots.push(snapshot); this.snapshots.push(snapshot);
// Keep only last 252 trading days (1 year) // Keep only last 252 trading days (1 year)
if (this.snapshots.length > 252) { if (this.snapshots.length > 252) {
this.snapshots = this.snapshots.slice(-252); this.snapshots = this.snapshots.slice(-252);
} }
} }
calculatePerformanceMetrics(period: 'daily' | 'weekly' | 'monthly' = 'daily'): PerformanceMetrics { calculatePerformanceMetrics(
if (this.snapshots.length < 2) { period: 'daily' | 'weekly' | 'monthly' = 'daily'
throw new Error('Need at least 2 snapshots to calculate performance'); ): PerformanceMetrics {
} if (this.snapshots.length < 2) {
throw new Error('Need at least 2 snapshots to calculate performance');
const returns = this.calculateReturns(period); }
const riskFreeRate = 0.02; // 2% annual risk-free rate
const returns = this.calculateReturns(period);
return { const riskFreeRate = 0.02; // 2% annual risk-free rate
totalReturn: this.calculateTotalReturn(),
annualizedReturn: this.calculateAnnualizedReturn(returns), return {
sharpeRatio: this.calculateSharpeRatio(returns, riskFreeRate), totalReturn: this.calculateTotalReturn(),
maxDrawdown: this.calculateMaxDrawdown(), annualizedReturn: this.calculateAnnualizedReturn(returns),
volatility: this.calculateVolatility(returns), sharpeRatio: this.calculateSharpeRatio(returns, riskFreeRate),
beta: this.calculateBeta(returns), maxDrawdown: this.calculateMaxDrawdown(),
alpha: this.calculateAlpha(returns, riskFreeRate), volatility: this.calculateVolatility(returns),
calmarRatio: this.calculateCalmarRatio(returns), beta: this.calculateBeta(returns),
sortinoRatio: this.calculateSortinoRatio(returns, riskFreeRate) alpha: this.calculateAlpha(returns, riskFreeRate),
}; calmarRatio: this.calculateCalmarRatio(returns),
} sortinoRatio: this.calculateSortinoRatio(returns, riskFreeRate),
};
calculateRiskMetrics(): RiskMetrics { }
const returns = this.calculateReturns('daily');
calculateRiskMetrics(): RiskMetrics {
return { const returns = this.calculateReturns('daily');
var95: this.calculateVaR(returns, 0.95),
cvar95: this.calculateCVaR(returns, 0.95), return {
maxDrawdown: this.calculateMaxDrawdown(), var95: this.calculateVaR(returns, 0.95),
downsideDeviation: this.calculateDownsideDeviation(returns), cvar95: this.calculateCVaR(returns, 0.95),
correlationMatrix: {} // TODO: Implement correlation matrix maxDrawdown: this.calculateMaxDrawdown(),
}; downsideDeviation: this.calculateDownsideDeviation(returns),
} correlationMatrix: {}, // TODO: Implement correlation matrix
};
private calculateReturns(period: 'daily' | 'weekly' | 'monthly'): number[] { }
if (this.snapshots.length < 2) return [];
private calculateReturns(period: 'daily' | 'weekly' | 'monthly'): number[] {
const returns: number[] = []; if (this.snapshots.length < 2) return [];
for (let i = 1; i < this.snapshots.length; i++) { const returns: number[] = [];
const currentValue = this.snapshots[i].totalValue;
const previousValue = this.snapshots[i - 1].totalValue; for (let i = 1; i < this.snapshots.length; i++) {
const return_ = (currentValue - previousValue) / previousValue; const currentValue = this.snapshots[i].totalValue;
returns.push(return_); const previousValue = this.snapshots[i - 1].totalValue;
} const return_ = (currentValue - previousValue) / previousValue;
returns.push(return_);
return returns; }
}
return returns;
private calculateTotalReturn(): number { }
if (this.snapshots.length < 2) return 0;
private calculateTotalReturn(): number {
const firstValue = this.snapshots[0].totalValue; if (this.snapshots.length < 2) return 0;
const lastValue = this.snapshots[this.snapshots.length - 1].totalValue;
const firstValue = this.snapshots[0].totalValue;
return (lastValue - firstValue) / firstValue; const lastValue = this.snapshots[this.snapshots.length - 1].totalValue;
}
return (lastValue - firstValue) / firstValue;
private calculateAnnualizedReturn(returns: number[]): number { }
if (returns.length === 0) return 0;
private calculateAnnualizedReturn(returns: number[]): number {
const avgReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length; if (returns.length === 0) return 0;
return Math.pow(1 + avgReturn, 252) - 1; // 252 trading days per year
} const avgReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
return Math.pow(1 + avgReturn, 252) - 1; // 252 trading days per year
private calculateVolatility(returns: number[]): number { }
if (returns.length === 0) return 0;
private calculateVolatility(returns: number[]): number {
const avgReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length; if (returns.length === 0) return 0;
const variance = returns.reduce((sum, ret) => sum + Math.pow(ret - avgReturn, 2), 0) / returns.length;
const avgReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
return Math.sqrt(variance * 252); // Annualized volatility const variance =
} returns.reduce((sum, ret) => sum + Math.pow(ret - avgReturn, 2), 0) / returns.length;
private calculateSharpeRatio(returns: number[], riskFreeRate: number): number { return Math.sqrt(variance * 252); // Annualized volatility
if (returns.length === 0) return 0; }
const avgReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length; private calculateSharpeRatio(returns: number[], riskFreeRate: number): number {
const annualizedReturn = Math.pow(1 + avgReturn, 252) - 1; if (returns.length === 0) return 0;
const volatility = this.calculateVolatility(returns);
const avgReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
if (volatility === 0) return 0; const annualizedReturn = Math.pow(1 + avgReturn, 252) - 1;
const volatility = this.calculateVolatility(returns);
return (annualizedReturn - riskFreeRate) / volatility;
} if (volatility === 0) return 0;
private calculateMaxDrawdown(): number { return (annualizedReturn - riskFreeRate) / volatility;
if (this.snapshots.length === 0) return 0; }
let maxDrawdown = 0; private calculateMaxDrawdown(): number {
let peak = this.snapshots[0].totalValue; if (this.snapshots.length === 0) return 0;
for (const snapshot of this.snapshots) { let maxDrawdown = 0;
if (snapshot.totalValue > peak) { let peak = this.snapshots[0].totalValue;
peak = snapshot.totalValue;
} for (const snapshot of this.snapshots) {
if (snapshot.totalValue > peak) {
const drawdown = (peak - snapshot.totalValue) / peak; peak = snapshot.totalValue;
maxDrawdown = Math.max(maxDrawdown, drawdown); }
}
const drawdown = (peak - snapshot.totalValue) / peak;
return maxDrawdown; maxDrawdown = Math.max(maxDrawdown, drawdown);
} }
private calculateBeta(returns: number[]): number { return maxDrawdown;
if (returns.length === 0 || this.benchmarkReturns.length === 0) return 1.0; }
// Simple beta calculation - would need actual benchmark data private calculateBeta(returns: number[]): number {
return 1.0; // Placeholder if (returns.length === 0 || this.benchmarkReturns.length === 0) return 1.0;
}
// Simple beta calculation - would need actual benchmark data
private calculateAlpha(returns: number[], riskFreeRate: number): number { return 1.0; // Placeholder
const beta = this.calculateBeta(returns); }
const portfolioReturn = this.calculateAnnualizedReturn(returns);
const benchmarkReturn = 0.10; // 10% benchmark return (placeholder) private calculateAlpha(returns: number[], riskFreeRate: number): number {
const beta = this.calculateBeta(returns);
return portfolioReturn - (riskFreeRate + beta * (benchmarkReturn - riskFreeRate)); const portfolioReturn = this.calculateAnnualizedReturn(returns);
} const benchmarkReturn = 0.1; // 10% benchmark return (placeholder)
private calculateCalmarRatio(returns: number[]): number { return portfolioReturn - (riskFreeRate + beta * (benchmarkReturn - riskFreeRate));
const annualizedReturn = this.calculateAnnualizedReturn(returns); }
const maxDrawdown = this.calculateMaxDrawdown();
private calculateCalmarRatio(returns: number[]): number {
if (maxDrawdown === 0) return 0; const annualizedReturn = this.calculateAnnualizedReturn(returns);
const maxDrawdown = this.calculateMaxDrawdown();
return annualizedReturn / maxDrawdown;
} if (maxDrawdown === 0) return 0;
private calculateSortinoRatio(returns: number[], riskFreeRate: number): number { return annualizedReturn / maxDrawdown;
const annualizedReturn = this.calculateAnnualizedReturn(returns); }
const downsideDeviation = this.calculateDownsideDeviation(returns);
private calculateSortinoRatio(returns: number[], riskFreeRate: number): number {
if (downsideDeviation === 0) return 0; const annualizedReturn = this.calculateAnnualizedReturn(returns);
const downsideDeviation = this.calculateDownsideDeviation(returns);
return (annualizedReturn - riskFreeRate) / downsideDeviation;
} if (downsideDeviation === 0) return 0;
private calculateDownsideDeviation(returns: number[]): number { return (annualizedReturn - riskFreeRate) / downsideDeviation;
if (returns.length === 0) return 0; }
const negativeReturns = returns.filter(ret => ret < 0); private calculateDownsideDeviation(returns: number[]): number {
if (negativeReturns.length === 0) return 0; if (returns.length === 0) return 0;
const avgNegativeReturn = negativeReturns.reduce((sum, ret) => sum + ret, 0) / negativeReturns.length; const negativeReturns = returns.filter(ret => ret < 0);
const variance = negativeReturns.reduce((sum, ret) => sum + Math.pow(ret - avgNegativeReturn, 2), 0) / negativeReturns.length; if (negativeReturns.length === 0) return 0;
return Math.sqrt(variance * 252); // Annualized const avgNegativeReturn =
} negativeReturns.reduce((sum, ret) => sum + ret, 0) / negativeReturns.length;
const variance =
private calculateVaR(returns: number[], confidence: number): number { negativeReturns.reduce((sum, ret) => sum + Math.pow(ret - avgNegativeReturn, 2), 0) /
if (returns.length === 0) return 0; negativeReturns.length;
const sortedReturns = returns.slice().sort((a, b) => a - b); return Math.sqrt(variance * 252); // Annualized
const index = Math.floor((1 - confidence) * sortedReturns.length); }
return -sortedReturns[index]; // Return as positive value private calculateVaR(returns: number[], confidence: number): number {
} if (returns.length === 0) return 0;
private calculateCVaR(returns: number[], confidence: number): number { const sortedReturns = returns.slice().sort((a, b) => a - b);
if (returns.length === 0) return 0; const index = Math.floor((1 - confidence) * sortedReturns.length);
const sortedReturns = returns.slice().sort((a, b) => a - b); return -sortedReturns[index]; // Return as positive value
const cutoffIndex = Math.floor((1 - confidence) * sortedReturns.length); }
const tailReturns = sortedReturns.slice(0, cutoffIndex + 1);
private calculateCVaR(returns: number[], confidence: number): number {
if (tailReturns.length === 0) return 0; if (returns.length === 0) return 0;
const avgTailReturn = tailReturns.reduce((sum, ret) => sum + ret, 0) / tailReturns.length; const sortedReturns = returns.slice().sort((a, b) => a - b);
return -avgTailReturn; // Return as positive value const cutoffIndex = Math.floor((1 - confidence) * sortedReturns.length);
} const tailReturns = sortedReturns.slice(0, cutoffIndex + 1);
}
if (tailReturns.length === 0) return 0;
const avgTailReturn = tailReturns.reduce((sum, ret) => sum + ret, 0) / tailReturns.length;
return -avgTailReturn; // Return as positive value
}
}

View file

@ -1,133 +1,136 @@
import { Hono } from 'hono'; import { serve } from '@hono/node-server';
import { serve } from '@hono/node-server'; import { Hono } from 'hono';
import { getLogger } from '@stock-bot/logger'; import { config } from '@stock-bot/config';
import { config } from '@stock-bot/config'; import { getLogger } from '@stock-bot/logger';
import { PortfolioManager } from './portfolio/portfolio-manager.ts'; import { PerformanceAnalyzer } from './analytics/performance-analyzer.ts';
import { PerformanceAnalyzer } from './analytics/performance-analyzer.ts'; import { PortfolioManager } from './portfolio/portfolio-manager.ts';
const app = new Hono(); const app = new Hono();
const logger = getLogger('portfolio-service'); const logger = getLogger('portfolio-service');
// Health check endpoint // Health check endpoint
app.get('/health', (c) => { app.get('/health', c => {
return c.json({ return c.json({
status: 'healthy', status: 'healthy',
service: 'portfolio-service', service: 'portfolio-service',
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
}); });
// Portfolio endpoints // Portfolio endpoints
app.get('/portfolio/overview', async (c) => { app.get('/portfolio/overview', async c => {
try { try {
// TODO: Get portfolio overview // TODO: Get portfolio overview
return c.json({ return c.json({
totalValue: 125000, totalValue: 125000,
totalReturn: 25000, totalReturn: 25000,
totalReturnPercent: 25.0, totalReturnPercent: 25.0,
dayChange: 1250, dayChange: 1250,
dayChangePercent: 1.0, dayChangePercent: 1.0,
positions: [] positions: [],
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get portfolio overview', error); logger.error('Failed to get portfolio overview', error);
return c.json({ error: 'Failed to get portfolio overview' }, 500); return c.json({ error: 'Failed to get portfolio overview' }, 500);
} }
}); });
app.get('/portfolio/positions', async (c) => { app.get('/portfolio/positions', async c => {
try { try {
// TODO: Get current positions // TODO: Get current positions
return c.json([ return c.json([
{ {
symbol: 'AAPL', symbol: 'AAPL',
quantity: 100, quantity: 100,
averagePrice: 150.0, averagePrice: 150.0,
currentPrice: 155.0, currentPrice: 155.0,
marketValue: 15500, marketValue: 15500,
unrealizedPnL: 500, unrealizedPnL: 500,
unrealizedPnLPercent: 3.33 unrealizedPnLPercent: 3.33,
} },
]); ]);
} catch (error) { } catch (error) {
logger.error('Failed to get positions', error); logger.error('Failed to get positions', error);
return c.json({ error: 'Failed to get positions' }, 500); return c.json({ error: 'Failed to get positions' }, 500);
} }
}); });
app.get('/portfolio/history', async (c) => { app.get('/portfolio/history', async c => {
const days = c.req.query('days') || '30'; const days = c.req.query('days') || '30';
try { try {
// TODO: Get portfolio history // TODO: Get portfolio history
return c.json({ return c.json({
period: `${days} days`, period: `${days} days`,
data: [] data: [],
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get portfolio history', error); logger.error('Failed to get portfolio history', error);
return c.json({ error: 'Failed to get portfolio history' }, 500); return c.json({ error: 'Failed to get portfolio history' }, 500);
} }
}); });
// Performance analytics endpoints // Performance analytics endpoints
app.get('/analytics/performance', async (c) => { app.get('/analytics/performance', async c => {
const period = c.req.query('period') || '1M'; const period = c.req.query('period') || '1M';
try { try {
// TODO: Calculate performance metrics // TODO: Calculate performance metrics
return c.json({ return c.json({
period, period,
totalReturn: 0.25, totalReturn: 0.25,
annualizedReturn: 0.30, annualizedReturn: 0.3,
sharpeRatio: 1.5, sharpeRatio: 1.5,
maxDrawdown: 0.05, maxDrawdown: 0.05,
volatility: 0.15, volatility: 0.15,
beta: 1.1, beta: 1.1,
alpha: 0.02 alpha: 0.02,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get performance analytics', error); logger.error('Failed to get performance analytics', error);
return c.json({ error: 'Failed to get performance analytics' }, 500); return c.json({ error: 'Failed to get performance analytics' }, 500);
} }
}); });
app.get('/analytics/risk', async (c) => { app.get('/analytics/risk', async c => {
try { try {
// TODO: Calculate risk metrics // TODO: Calculate risk metrics
return c.json({ return c.json({
var95: 0.02, var95: 0.02,
cvar95: 0.03, cvar95: 0.03,
maxDrawdown: 0.05, maxDrawdown: 0.05,
downside_deviation: 0.08, downside_deviation: 0.08,
correlation_matrix: {} correlation_matrix: {},
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get risk analytics', error); logger.error('Failed to get risk analytics', error);
return c.json({ error: 'Failed to get risk analytics' }, 500); return c.json({ error: 'Failed to get risk analytics' }, 500);
} }
}); });
app.get('/analytics/attribution', async (c) => { app.get('/analytics/attribution', async c => {
try { try {
// TODO: Calculate performance attribution // TODO: Calculate performance attribution
return c.json({ return c.json({
sector_allocation: {}, sector_allocation: {},
security_selection: {}, security_selection: {},
interaction_effect: {} interaction_effect: {},
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get attribution analytics', error); logger.error('Failed to get attribution analytics', error);
return c.json({ error: 'Failed to get attribution analytics' }, 500); return c.json({ error: 'Failed to get attribution analytics' }, 500);
} }
}); });
const port = config.PORTFOLIO_SERVICE_PORT || 3005; const port = config.PORTFOLIO_SERVICE_PORT || 3005;
logger.info(`Starting portfolio service on port ${port}`); logger.info(`Starting portfolio service on port ${port}`);
serve({ serve(
fetch: app.fetch, {
port fetch: app.fetch,
}, (info) => { port,
logger.info(`Portfolio service is running on port ${info.port}`); },
}); info => {
logger.info(`Portfolio service is running on port ${info.port}`);
}
);

View file

@ -1,159 +1,159 @@
import { getLogger } from '@stock-bot/logger'; import { getLogger } from '@stock-bot/logger';
export interface Position { export interface Position {
symbol: string; symbol: string;
quantity: number; quantity: number;
averagePrice: number; averagePrice: number;
currentPrice: number; currentPrice: number;
marketValue: number; marketValue: number;
unrealizedPnL: number; unrealizedPnL: number;
unrealizedPnLPercent: number; unrealizedPnLPercent: number;
costBasis: number; costBasis: number;
lastUpdated: Date; lastUpdated: Date;
} }
export interface PortfolioSnapshot { export interface PortfolioSnapshot {
timestamp: Date; timestamp: Date;
totalValue: number; totalValue: number;
cashBalance: number; cashBalance: number;
positions: Position[]; positions: Position[];
totalReturn: number; totalReturn: number;
totalReturnPercent: number; totalReturnPercent: number;
dayChange: number; dayChange: number;
dayChangePercent: number; dayChangePercent: number;
} }
export interface Trade { export interface Trade {
id: string; id: string;
symbol: string; symbol: string;
quantity: number; quantity: number;
price: number; price: number;
side: 'buy' | 'sell'; side: 'buy' | 'sell';
timestamp: Date; timestamp: Date;
commission: number; commission: number;
} }
export class PortfolioManager { export class PortfolioManager {
private logger = getLogger('PortfolioManager'); private logger = getLogger('PortfolioManager');
private positions: Map<string, Position> = new Map(); private positions: Map<string, Position> = new Map();
private trades: Trade[] = []; private trades: Trade[] = [];
private cashBalance: number = 100000; // Starting cash private cashBalance: number = 100000; // Starting cash
constructor(initialCash: number = 100000) { constructor(initialCash: number = 100000) {
this.cashBalance = initialCash; this.cashBalance = initialCash;
} }
addTrade(trade: Trade): void { addTrade(trade: Trade): void {
this.trades.push(trade); this.trades.push(trade);
this.updatePosition(trade); this.updatePosition(trade);
logger.info(`Trade added: ${trade.symbol} ${trade.side} ${trade.quantity} @ ${trade.price}`); logger.info(`Trade added: ${trade.symbol} ${trade.side} ${trade.quantity} @ ${trade.price}`);
} }
private updatePosition(trade: Trade): void { private updatePosition(trade: Trade): void {
const existing = this.positions.get(trade.symbol); const existing = this.positions.get(trade.symbol);
if (!existing) { if (!existing) {
// New position // New position
if (trade.side === 'buy') { if (trade.side === 'buy') {
this.positions.set(trade.symbol, { this.positions.set(trade.symbol, {
symbol: trade.symbol, symbol: trade.symbol,
quantity: trade.quantity, quantity: trade.quantity,
averagePrice: trade.price, averagePrice: trade.price,
currentPrice: trade.price, currentPrice: trade.price,
marketValue: trade.quantity * trade.price, marketValue: trade.quantity * trade.price,
unrealizedPnL: 0, unrealizedPnL: 0,
unrealizedPnLPercent: 0, unrealizedPnLPercent: 0,
costBasis: trade.quantity * trade.price + trade.commission, costBasis: trade.quantity * trade.price + trade.commission,
lastUpdated: trade.timestamp lastUpdated: trade.timestamp,
}); });
this.cashBalance -= (trade.quantity * trade.price + trade.commission); this.cashBalance -= trade.quantity * trade.price + trade.commission;
} }
return; return;
} }
// Update existing position // Update existing position
if (trade.side === 'buy') { if (trade.side === 'buy') {
const newQuantity = existing.quantity + trade.quantity; const newQuantity = existing.quantity + trade.quantity;
const newCostBasis = existing.costBasis + (trade.quantity * trade.price) + trade.commission; const newCostBasis = existing.costBasis + trade.quantity * trade.price + trade.commission;
existing.quantity = newQuantity; existing.quantity = newQuantity;
existing.averagePrice = (newCostBasis - this.getTotalCommissions(trade.symbol)) / newQuantity; existing.averagePrice = (newCostBasis - this.getTotalCommissions(trade.symbol)) / newQuantity;
existing.costBasis = newCostBasis; existing.costBasis = newCostBasis;
existing.lastUpdated = trade.timestamp; existing.lastUpdated = trade.timestamp;
this.cashBalance -= (trade.quantity * trade.price + trade.commission); this.cashBalance -= trade.quantity * trade.price + trade.commission;
} else if (trade.side === 'sell') {
} else if (trade.side === 'sell') { existing.quantity -= trade.quantity;
existing.quantity -= trade.quantity; existing.lastUpdated = trade.timestamp;
existing.lastUpdated = trade.timestamp;
const proceeds = trade.quantity * trade.price - trade.commission;
const proceeds = trade.quantity * trade.price - trade.commission; this.cashBalance += proceeds;
this.cashBalance += proceeds;
// Remove position if quantity is zero
// Remove position if quantity is zero if (existing.quantity <= 0) {
if (existing.quantity <= 0) { this.positions.delete(trade.symbol);
this.positions.delete(trade.symbol); }
} }
} }
}
updatePrice(symbol: string, price: number): void {
updatePrice(symbol: string, price: number): void { const position = this.positions.get(symbol);
const position = this.positions.get(symbol); if (position) {
if (position) { position.currentPrice = price;
position.currentPrice = price; position.marketValue = position.quantity * price;
position.marketValue = position.quantity * price; position.unrealizedPnL = position.marketValue - position.quantity * position.averagePrice;
position.unrealizedPnL = position.marketValue - (position.quantity * position.averagePrice); position.unrealizedPnLPercent =
position.unrealizedPnLPercent = position.unrealizedPnL / (position.quantity * position.averagePrice) * 100; (position.unrealizedPnL / (position.quantity * position.averagePrice)) * 100;
position.lastUpdated = new Date(); position.lastUpdated = new Date();
} }
} }
getPosition(symbol: string): Position | undefined { getPosition(symbol: string): Position | undefined {
return this.positions.get(symbol); return this.positions.get(symbol);
} }
getAllPositions(): Position[] { getAllPositions(): Position[] {
return Array.from(this.positions.values()); return Array.from(this.positions.values());
} }
getPortfolioSnapshot(): PortfolioSnapshot { getPortfolioSnapshot(): PortfolioSnapshot {
const positions = this.getAllPositions(); const positions = this.getAllPositions();
const totalMarketValue = positions.reduce((sum, pos) => sum + pos.marketValue, 0); const totalMarketValue = positions.reduce((sum, pos) => sum + pos.marketValue, 0);
const totalValue = totalMarketValue + this.cashBalance; const totalValue = totalMarketValue + this.cashBalance;
const totalUnrealizedPnL = positions.reduce((sum, pos) => sum + pos.unrealizedPnL, 0); const totalUnrealizedPnL = positions.reduce((sum, pos) => sum + pos.unrealizedPnL, 0);
return { return {
timestamp: new Date(), timestamp: new Date(),
totalValue, totalValue,
cashBalance: this.cashBalance, cashBalance: this.cashBalance,
positions, positions,
totalReturn: totalUnrealizedPnL, // Simplified - should include realized gains totalReturn: totalUnrealizedPnL, // Simplified - should include realized gains
totalReturnPercent: (totalUnrealizedPnL / (totalValue - totalUnrealizedPnL)) * 100, totalReturnPercent: (totalUnrealizedPnL / (totalValue - totalUnrealizedPnL)) * 100,
dayChange: 0, // TODO: Calculate from previous day dayChange: 0, // TODO: Calculate from previous day
dayChangePercent: 0 dayChangePercent: 0,
}; };
} }
getTrades(symbol?: string): Trade[] { getTrades(symbol?: string): Trade[] {
if (symbol) { if (symbol) {
return this.trades.filter(trade => trade.symbol === symbol); return this.trades.filter(trade => trade.symbol === symbol);
} }
return this.trades; return this.trades;
} }
private getTotalCommissions(symbol: string): number { private getTotalCommissions(symbol: string): number {
return this.trades return this.trades
.filter(trade => trade.symbol === symbol) .filter(trade => trade.symbol === symbol)
.reduce((sum, trade) => sum + trade.commission, 0); .reduce((sum, trade) => sum + trade.commission, 0);
} }
getCashBalance(): number { getCashBalance(): number {
return this.cashBalance; return this.cashBalance;
} }
getNetLiquidationValue(): number { getNetLiquidationValue(): number {
const positions = this.getAllPositions(); const positions = this.getAllPositions();
const positionValue = positions.reduce((sum, pos) => sum + pos.marketValue, 0); const positionValue = positions.reduce((sum, pos) => sum + pos.marketValue, 0);
return positionValue + this.cashBalance; return positionValue + this.cashBalance;
} }
} }

View file

@ -1,54 +1,54 @@
/** /**
* Processing Service - Technical indicators and data processing * Processing Service - Technical indicators and data processing
*/ */
import { getLogger } from '@stock-bot/logger'; import { serve } from '@hono/node-server';
import { loadEnvVariables } from '@stock-bot/config'; import { Hono } from 'hono';
import { Hono } from 'hono'; import { loadEnvVariables } from '@stock-bot/config';
import { serve } from '@hono/node-server'; import { getLogger } from '@stock-bot/logger';
// Load environment variables // Load environment variables
loadEnvVariables(); loadEnvVariables();
const app = new Hono(); const app = new Hono();
const logger = getLogger('processing-service'); const logger = getLogger('processing-service');
const PORT = parseInt(process.env.PROCESSING_SERVICE_PORT || '3003'); const PORT = parseInt(process.env.PROCESSING_SERVICE_PORT || '3003');
// Health check endpoint // Health check endpoint
app.get('/health', (c) => { app.get('/health', c => {
return c.json({ return c.json({
service: 'processing-service', service: 'processing-service',
status: 'healthy', status: 'healthy',
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
}); });
// Technical indicators endpoint // Technical indicators endpoint
app.post('/api/indicators', async (c) => { app.post('/api/indicators', async c => {
const body = await c.req.json(); const body = await c.req.json();
logger.info('Technical indicators request', { indicators: body.indicators }); logger.info('Technical indicators request', { indicators: body.indicators });
// TODO: Implement technical indicators processing // TODO: Implement technical indicators processing
return c.json({ return c.json({
message: 'Technical indicators endpoint - not implemented yet', message: 'Technical indicators endpoint - not implemented yet',
requestedIndicators: body.indicators requestedIndicators: body.indicators,
}); });
}); });
// Vectorized processing endpoint // Vectorized processing endpoint
app.post('/api/vectorized/process', async (c) => { app.post('/api/vectorized/process', async c => {
const body = await c.req.json(); const body = await c.req.json();
logger.info('Vectorized processing request', { dataPoints: body.data?.length }); logger.info('Vectorized processing request', { dataPoints: body.data?.length });
// TODO: Implement vectorized processing // TODO: Implement vectorized processing
return c.json({ return c.json({
message: 'Vectorized processing endpoint - not implemented yet' message: 'Vectorized processing endpoint - not implemented yet',
}); });
}); });
// Start server // Start server
serve({ serve({
fetch: app.fetch, fetch: app.fetch,
port: PORT, port: PORT,
}); });
logger.info(`Processing Service started on port ${PORT}`); logger.info(`Processing Service started on port ${PORT}`);

View file

@ -1,82 +1,77 @@
/** /**
* Technical Indicators Service * Technical Indicators Service
* Leverages @stock-bot/utils for calculations * Leverages @stock-bot/utils for calculations
*/ */
import { getLogger } from '@stock-bot/logger'; import { getLogger } from '@stock-bot/logger';
import { import { ema, macd, rsi, sma } from '@stock-bot/utils';
sma,
ema, const logger = getLogger('indicators-service');
rsi,
macd export interface IndicatorRequest {
} from '@stock-bot/utils'; symbol: string;
data: number[];
const logger = getLogger('indicators-service'); indicators: string[];
parameters?: Record<string, any>;
export interface IndicatorRequest { }
symbol: string;
data: number[]; export interface IndicatorResult {
indicators: string[]; symbol: string;
parameters?: Record<string, any>; timestamp: Date;
} indicators: Record<string, number[]>;
}
export interface IndicatorResult {
symbol: string; export class IndicatorsService {
timestamp: Date; async calculateIndicators(request: IndicatorRequest): Promise<IndicatorResult> {
indicators: Record<string, number[]>; logger.info('Calculating indicators', {
} symbol: request.symbol,
indicators: request.indicators,
export class IndicatorsService { dataPoints: request.data.length,
async calculateIndicators(request: IndicatorRequest): Promise<IndicatorResult> { });
logger.info('Calculating indicators', {
symbol: request.symbol, const results: Record<string, number[]> = {};
indicators: request.indicators,
dataPoints: request.data.length for (const indicator of request.indicators) {
}); try {
switch (indicator.toLowerCase()) {
const results: Record<string, number[]> = {}; case 'sma':
const smaPeriod = request.parameters?.smaPeriod || 20;
for (const indicator of request.indicators) { results.sma = sma(request.data, smaPeriod);
try { break;
switch (indicator.toLowerCase()) {
case 'sma': case 'ema':
const smaPeriod = request.parameters?.smaPeriod || 20; const emaPeriod = request.parameters?.emaPeriod || 20;
results.sma = sma(request.data, smaPeriod); results.ema = ema(request.data, emaPeriod);
break; break;
case 'ema': case 'rsi':
const emaPeriod = request.parameters?.emaPeriod || 20; const rsiPeriod = request.parameters?.rsiPeriod || 14;
results.ema = ema(request.data, emaPeriod); results.rsi = rsi(request.data, rsiPeriod);
break; break;
case 'rsi': case 'macd':
const rsiPeriod = request.parameters?.rsiPeriod || 14; const fast = request.parameters?.macdFast || 12;
results.rsi = rsi(request.data, rsiPeriod); const slow = request.parameters?.macdSlow || 26;
break; const signal = request.parameters?.macdSignal || 9;
results.macd = macd(request.data, fast, slow, signal).macd;
case 'macd': break;
const fast = request.parameters?.macdFast || 12;
const slow = request.parameters?.macdSlow || 26; case 'stochastic':
const signal = request.parameters?.macdSignal || 9; // TODO: Implement stochastic oscillator
results.macd = macd(request.data, fast, slow, signal).macd; logger.warn('Stochastic oscillator not implemented yet');
break; break;
case 'stochastic': default:
// TODO: Implement stochastic oscillator logger.warn('Unknown indicator requested', { indicator });
logger.warn('Stochastic oscillator not implemented yet'); }
break; } catch (error) {
logger.error('Error calculating indicator', { indicator, error });
default: }
logger.warn('Unknown indicator requested', { indicator }); }
}
} catch (error) { return {
logger.error('Error calculating indicator', { indicator, error }); symbol: request.symbol,
} timestamp: new Date(),
} indicators: results,
};
return { }
symbol: request.symbol, }
timestamp: new Date(),
indicators: results
};
}
}

View file

@ -1,75 +1,75 @@
/** /**
* Event-Driven Backtesting Mode * Event-Driven Backtesting Mode
* Processes data point by point with realistic order execution * Processes data point by point with realistic order execution
*/ */
import { ExecutionMode, Order, OrderResult, MarketData } from '../../framework/execution-mode'; import { ExecutionMode, MarketData, Order, OrderResult } from '../../framework/execution-mode';
export interface BacktestConfig { export interface BacktestConfig {
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
initialCapital: number; initialCapital: number;
slippageModel?: string; slippageModel?: string;
commissionModel?: string; commissionModel?: string;
} }
export class EventMode extends ExecutionMode { export class EventMode extends ExecutionMode {
name = 'event-driven'; name = 'event-driven';
private simulationTime: Date; private simulationTime: Date;
private historicalData: Map<string, MarketData[]> = new Map(); private historicalData: Map<string, MarketData[]> = new Map();
constructor(private config: BacktestConfig) { constructor(private config: BacktestConfig) {
super(); super();
this.simulationTime = config.startDate; this.simulationTime = config.startDate;
} }
async executeOrder(order: Order): Promise<OrderResult> { async executeOrder(order: Order): Promise<OrderResult> {
this.logger.debug('Simulating order execution', { this.logger.debug('Simulating order execution', {
orderId: order.id, orderId: order.id,
simulationTime: this.simulationTime simulationTime: this.simulationTime,
}); });
// TODO: Implement realistic order simulation // TODO: Implement realistic order simulation
// Include slippage, commission, market impact // Include slippage, commission, market impact
const simulatedResult: OrderResult = { const simulatedResult: OrderResult = {
orderId: order.id, orderId: order.id,
symbol: order.symbol, symbol: order.symbol,
executedQuantity: order.quantity, executedQuantity: order.quantity,
executedPrice: 100, // TODO: Get realistic price executedPrice: 100, // TODO: Get realistic price
commission: 1.0, // TODO: Calculate based on commission model commission: 1.0, // TODO: Calculate based on commission model
slippage: 0.01, // TODO: Calculate based on slippage model slippage: 0.01, // TODO: Calculate based on slippage model
timestamp: this.simulationTime, timestamp: this.simulationTime,
executionTime: 50 // ms executionTime: 50, // ms
}; };
return simulatedResult; return simulatedResult;
} }
getCurrentTime(): Date { getCurrentTime(): Date {
return this.simulationTime; return this.simulationTime;
} }
async getMarketData(symbol: string): Promise<MarketData> { async getMarketData(symbol: string): Promise<MarketData> {
const data = this.historicalData.get(symbol) || []; const data = this.historicalData.get(symbol) || [];
const currentData = data.find(d => d.timestamp <= this.simulationTime); const currentData = data.find(d => d.timestamp <= this.simulationTime);
if (!currentData) { if (!currentData) {
throw new Error(`No market data available for ${symbol} at ${this.simulationTime}`); throw new Error(`No market data available for ${symbol} at ${this.simulationTime}`);
} }
return currentData; return currentData;
} }
async publishEvent(event: string, data: any): Promise<void> { async publishEvent(event: string, data: any): Promise<void> {
// In-memory event bus for simulation // In-memory event bus for simulation
this.logger.debug('Publishing simulation event', { event, data }); this.logger.debug('Publishing simulation event', { event, data });
} }
// Simulation control methods // Simulation control methods
advanceTime(newTime: Date): void { advanceTime(newTime: Date): void {
this.simulationTime = newTime; this.simulationTime = newTime;
} }
loadHistoricalData(symbol: string, data: MarketData[]): void { loadHistoricalData(symbol: string, data: MarketData[]): void {
this.historicalData.set(symbol, data); this.historicalData.set(symbol, data);
} }
} }

View file

@ -1,422 +1,425 @@
import { getLogger } from '@stock-bot/logger'; import { create } from 'domain';
import { EventBus } from '@stock-bot/event-bus'; import { DataFrame } from '@stock-bot/data-frame';
import { VectorEngine, VectorizedBacktestResult } from '@stock-bot/vector-engine'; import { EventBus } from '@stock-bot/event-bus';
import { DataFrame } from '@stock-bot/data-frame'; import { getLogger } from '@stock-bot/logger';
import { ExecutionMode, BacktestContext, BacktestResult } from '../framework/execution-mode'; import { VectorEngine, VectorizedBacktestResult } from '@stock-bot/vector-engine';
import { EventMode } from './event-mode'; import { BacktestContext, BacktestResult, ExecutionMode } from '../framework/execution-mode';
import VectorizedMode from './vectorized-mode'; import { EventMode } from './event-mode';
import { create } from 'domain'; import VectorizedMode from './vectorized-mode';
export interface HybridModeConfig { export interface HybridModeConfig {
vectorizedThreshold: number; // Switch to vectorized if data points > threshold vectorizedThreshold: number; // Switch to vectorized if data points > threshold
warmupPeriod: number; // Number of periods for initial vectorized calculation warmupPeriod: number; // Number of periods for initial vectorized calculation
eventDrivenRealtime: boolean; // Use event-driven for real-time portions eventDrivenRealtime: boolean; // Use event-driven for real-time portions
optimizeIndicators: boolean; // Pre-calculate indicators vectorized optimizeIndicators: boolean; // Pre-calculate indicators vectorized
batchSize: number; // Size of batches for hybrid processing batchSize: number; // Size of batches for hybrid processing
} }
export class HybridMode extends ExecutionMode { export class HybridMode extends ExecutionMode {
private vectorEngine: VectorEngine; private vectorEngine: VectorEngine;
private eventMode: EventMode; private eventMode: EventMode;
private vectorizedMode: VectorizedMode; private vectorizedMode: VectorizedMode;
private config: HybridModeConfig; private config: HybridModeConfig;
private precomputedIndicators: Map<string, number[]> = new Map(); private precomputedIndicators: Map<string, number[]> = new Map();
private currentIndex: number = 0; private currentIndex: number = 0;
constructor( constructor(context: BacktestContext, eventBus: EventBus, config: HybridModeConfig = {}) {
context: BacktestContext, super(context, eventBus);
eventBus: EventBus,
config: HybridModeConfig = {} this.config = {
) { vectorizedThreshold: 50000,
super(context, eventBus); warmupPeriod: 1000,
eventDrivenRealtime: true,
this.config = { optimizeIndicators: true,
vectorizedThreshold: 50000, batchSize: 10000,
warmupPeriod: 1000, ...config,
eventDrivenRealtime: true, };
optimizeIndicators: true,
batchSize: 10000, this.vectorEngine = new VectorEngine();
...config this.eventMode = new EventMode(context, eventBus);
}; this.vectorizedMode = new VectorizedMode(context, eventBus);
this.vectorEngine = new VectorEngine(); this.logger = getLogger('hybrid-mode');
this.eventMode = new EventMode(context, eventBus); }
this.vectorizedMode = new VectorizedMode(context, eventBus);
async initialize(): Promise<void> {
this.logger = getLogger('hybrid-mode'); await super.initialize();
}
// Initialize both modes
async initialize(): Promise<void> { await this.eventMode.initialize();
await super.initialize(); await this.vectorizedMode.initialize();
// Initialize both modes this.logger.info('Hybrid mode initialized', {
await this.eventMode.initialize(); backtestId: this.context.backtestId,
await this.vectorizedMode.initialize(); config: this.config,
});
this.logger.info('Hybrid mode initialized', { }
backtestId: this.context.backtestId,
config: this.config async execute(): Promise<BacktestResult> {
}); const startTime = Date.now();
} this.logger.info('Starting hybrid backtest execution');
async execute(): Promise<BacktestResult> { try {
const startTime = Date.now(); // Determine execution strategy based on data size
this.logger.info('Starting hybrid backtest execution'); const dataSize = await this.estimateDataSize();
try { if (dataSize <= this.config.vectorizedThreshold) {
// Determine execution strategy based on data size // Small dataset: use pure vectorized approach
const dataSize = await this.estimateDataSize(); this.logger.info('Using pure vectorized approach for small dataset', { dataSize });
return await this.vectorizedMode.execute();
if (dataSize <= this.config.vectorizedThreshold) { }
// Small dataset: use pure vectorized approach
this.logger.info('Using pure vectorized approach for small dataset', { dataSize }); // Large dataset: use hybrid approach
return await this.vectorizedMode.execute(); this.logger.info('Using hybrid approach for large dataset', { dataSize });
} return await this.executeHybrid(startTime);
} catch (error) {
// Large dataset: use hybrid approach this.logger.error('Hybrid backtest failed', {
this.logger.info('Using hybrid approach for large dataset', { dataSize }); error,
return await this.executeHybrid(startTime); backtestId: this.context.backtestId,
});
} catch (error) {
this.logger.error('Hybrid backtest failed', { await this.eventBus.publishBacktestUpdate(this.context.backtestId, 0, {
error, status: 'failed',
backtestId: this.context.backtestId error: error.message,
}); });
await this.eventBus.publishBacktestUpdate( throw error;
this.context.backtestId, }
0, }
{ status: 'failed', error: error.message }
); private async executeHybrid(startTime: number): Promise<BacktestResult> {
// Phase 1: Vectorized warmup and indicator pre-computation
throw error; const warmupResult = await this.executeWarmupPhase();
}
} // Phase 2: Event-driven processing with pre-computed indicators
const eventResult = await this.executeEventPhase(warmupResult);
private async executeHybrid(startTime: number): Promise<BacktestResult> {
// Phase 1: Vectorized warmup and indicator pre-computation // Phase 3: Combine results
const warmupResult = await this.executeWarmupPhase(); const combinedResult = this.combineResults(warmupResult, eventResult, startTime);
// Phase 2: Event-driven processing with pre-computed indicators await this.eventBus.publishBacktestUpdate(this.context.backtestId, 100, {
const eventResult = await this.executeEventPhase(warmupResult); status: 'completed',
result: combinedResult,
// Phase 3: Combine results });
const combinedResult = this.combineResults(warmupResult, eventResult, startTime);
this.logger.info('Hybrid backtest completed', {
await this.eventBus.publishBacktestUpdate( backtestId: this.context.backtestId,
this.context.backtestId, duration: Date.now() - startTime,
100, totalTrades: combinedResult.trades.length,
{ status: 'completed', result: combinedResult } warmupTrades: warmupResult.trades.length,
); eventTrades: eventResult.trades.length,
});
this.logger.info('Hybrid backtest completed', {
backtestId: this.context.backtestId, return combinedResult;
duration: Date.now() - startTime, }
totalTrades: combinedResult.trades.length,
warmupTrades: warmupResult.trades.length, private async executeWarmupPhase(): Promise<BacktestResult> {
eventTrades: eventResult.trades.length this.logger.info('Executing vectorized warmup phase', {
}); warmupPeriod: this.config.warmupPeriod,
});
return combinedResult;
} // Load warmup data
const warmupData = await this.loadWarmupData();
private async executeWarmupPhase(): Promise<BacktestResult> { const dataFrame = this.createDataFrame(warmupData);
this.logger.info('Executing vectorized warmup phase', {
warmupPeriod: this.config.warmupPeriod // Pre-compute indicators for entire dataset if optimization is enabled
}); if (this.config.optimizeIndicators) {
await this.precomputeIndicators(dataFrame);
// Load warmup data }
const warmupData = await this.loadWarmupData();
const dataFrame = this.createDataFrame(warmupData); // Run vectorized backtest on warmup period
const strategyCode = this.generateStrategyCode();
// Pre-compute indicators for entire dataset if optimization is enabled const vectorResult = await this.vectorEngine.executeVectorizedStrategy(
if (this.config.optimizeIndicators) { dataFrame.head(this.config.warmupPeriod),
await this.precomputeIndicators(dataFrame); strategyCode
} );
// Run vectorized backtest on warmup period // Convert to standard format
const strategyCode = this.generateStrategyCode(); return this.convertVectorizedResult(vectorResult, Date.now());
const vectorResult = await this.vectorEngine.executeVectorizedStrategy( }
dataFrame.head(this.config.warmupPeriod),
strategyCode private async executeEventPhase(warmupResult: BacktestResult): Promise<BacktestResult> {
); this.logger.info('Executing event-driven phase');
// Convert to standard format // Set up event mode with warmup context
return this.convertVectorizedResult(vectorResult, Date.now()); this.currentIndex = this.config.warmupPeriod;
}
// Create modified context for event phase
private async executeEventPhase(warmupResult: BacktestResult): Promise<BacktestResult> { const eventContext: BacktestContext = {
this.logger.info('Executing event-driven phase'); ...this.context,
initialPortfolio: this.extractFinalPortfolio(warmupResult),
// Set up event mode with warmup context };
this.currentIndex = this.config.warmupPeriod;
// Execute event-driven backtest for remaining data
// Create modified context for event phase const eventMode = new EventMode(eventContext, this.eventBus);
const eventContext: BacktestContext = { await eventMode.initialize();
...this.context,
initialPortfolio: this.extractFinalPortfolio(warmupResult) // Override indicator calculations to use pre-computed values
}; if (this.config.optimizeIndicators) {
this.overrideIndicatorCalculations(eventMode);
// Execute event-driven backtest for remaining data }
const eventMode = new EventMode(eventContext, this.eventBus);
await eventMode.initialize(); return await eventMode.execute();
}
// Override indicator calculations to use pre-computed values
if (this.config.optimizeIndicators) { private async precomputeIndicators(dataFrame: DataFrame): Promise<void> {
this.overrideIndicatorCalculations(eventMode); this.logger.info('Pre-computing indicators vectorized');
}
const close = dataFrame.getColumn('close');
return await eventMode.execute(); const high = dataFrame.getColumn('high');
} const low = dataFrame.getColumn('low');
private async precomputeIndicators(dataFrame: DataFrame): Promise<void> { // Import technical indicators from vector engine
this.logger.info('Pre-computing indicators vectorized'); const { TechnicalIndicators } = await import('@stock-bot/vector-engine');
const close = dataFrame.getColumn('close'); // Pre-compute common indicators
const high = dataFrame.getColumn('high'); this.precomputedIndicators.set('sma_20', TechnicalIndicators.sma(close, 20));
const low = dataFrame.getColumn('low'); this.precomputedIndicators.set('sma_50', TechnicalIndicators.sma(close, 50));
this.precomputedIndicators.set('ema_12', TechnicalIndicators.ema(close, 12));
// Import technical indicators from vector engine this.precomputedIndicators.set('ema_26', TechnicalIndicators.ema(close, 26));
const { TechnicalIndicators } = await import('@stock-bot/vector-engine'); this.precomputedIndicators.set('rsi', TechnicalIndicators.rsi(close));
this.precomputedIndicators.set('atr', TechnicalIndicators.atr(high, low, close));
// Pre-compute common indicators
this.precomputedIndicators.set('sma_20', TechnicalIndicators.sma(close, 20)); const macd = TechnicalIndicators.macd(close);
this.precomputedIndicators.set('sma_50', TechnicalIndicators.sma(close, 50)); this.precomputedIndicators.set('macd', macd.macd);
this.precomputedIndicators.set('ema_12', TechnicalIndicators.ema(close, 12)); this.precomputedIndicators.set('macd_signal', macd.signal);
this.precomputedIndicators.set('ema_26', TechnicalIndicators.ema(close, 26)); this.precomputedIndicators.set('macd_histogram', macd.histogram);
this.precomputedIndicators.set('rsi', TechnicalIndicators.rsi(close));
this.precomputedIndicators.set('atr', TechnicalIndicators.atr(high, low, close)); const bb = TechnicalIndicators.bollingerBands(close);
this.precomputedIndicators.set('bb_upper', bb.upper);
const macd = TechnicalIndicators.macd(close); this.precomputedIndicators.set('bb_middle', bb.middle);
this.precomputedIndicators.set('macd', macd.macd); this.precomputedIndicators.set('bb_lower', bb.lower);
this.precomputedIndicators.set('macd_signal', macd.signal);
this.precomputedIndicators.set('macd_histogram', macd.histogram); this.logger.info('Indicators pre-computed', {
indicators: Array.from(this.precomputedIndicators.keys()),
const bb = TechnicalIndicators.bollingerBands(close); });
this.precomputedIndicators.set('bb_upper', bb.upper); }
this.precomputedIndicators.set('bb_middle', bb.middle);
this.precomputedIndicators.set('bb_lower', bb.lower); private overrideIndicatorCalculations(eventMode: EventMode): void {
// Override the event mode's indicator calculations to use pre-computed values
this.logger.info('Indicators pre-computed', { // This is a simplified approach - in production you'd want a more sophisticated interface
indicators: Array.from(this.precomputedIndicators.keys()) const originalCalculateIndicators = (eventMode as any).calculateIndicators;
});
} (eventMode as any).calculateIndicators = (symbol: string, index: number) => {
const indicators: Record<string, number> = {};
private overrideIndicatorCalculations(eventMode: EventMode): void {
// Override the event mode's indicator calculations to use pre-computed values for (const [name, values] of this.precomputedIndicators.entries()) {
// This is a simplified approach - in production you'd want a more sophisticated interface if (index < values.length) {
const originalCalculateIndicators = (eventMode as any).calculateIndicators; indicators[name] = values[index];
}
(eventMode as any).calculateIndicators = (symbol: string, index: number) => { }
const indicators: Record<string, number> = {};
return indicators;
for (const [name, values] of this.precomputedIndicators.entries()) { };
if (index < values.length) { }
indicators[name] = values[index];
} private async estimateDataSize(): Promise<number> {
} // Estimate the number of data points for the backtest period
const startTime = new Date(this.context.startDate).getTime();
return indicators; const endTime = new Date(this.context.endDate).getTime();
}; const timeRange = endTime - startTime;
}
// Assume 1-minute intervals (60000ms)
private async estimateDataSize(): Promise<number> { const estimatedPoints = Math.floor(timeRange / 60000);
// Estimate the number of data points for the backtest period
const startTime = new Date(this.context.startDate).getTime(); this.logger.debug('Estimated data size', {
const endTime = new Date(this.context.endDate).getTime(); timeRange,
const timeRange = endTime - startTime; estimatedPoints,
threshold: this.config.vectorizedThreshold,
// Assume 1-minute intervals (60000ms) });
const estimatedPoints = Math.floor(timeRange / 60000);
return estimatedPoints;
this.logger.debug('Estimated data size', { }
timeRange,
estimatedPoints, private async loadWarmupData(): Promise<any[]> {
threshold: this.config.vectorizedThreshold // Load historical data for warmup phase
}); // This should load more data than just the warmup period for indicator calculations
const data = [];
return estimatedPoints; const startTime = new Date(this.context.startDate).getTime();
} const warmupEndTime = startTime + this.config.warmupPeriod * 60000;
private async loadWarmupData(): Promise<any[]> { // Add extra lookback for indicator calculations
// Load historical data for warmup phase const lookbackTime = startTime - 200 * 60000; // 200 periods lookback
// This should load more data than just the warmup period for indicator calculations
const data = []; for (let timestamp = lookbackTime; timestamp <= warmupEndTime; timestamp += 60000) {
const startTime = new Date(this.context.startDate).getTime(); const basePrice = 100 + Math.sin(timestamp / 1000000) * 10;
const warmupEndTime = startTime + (this.config.warmupPeriod * 60000); const volatility = 0.02;
// Add extra lookback for indicator calculations const open = basePrice + (Math.random() - 0.5) * volatility * basePrice;
const lookbackTime = startTime - (200 * 60000); // 200 periods lookback const close = open + (Math.random() - 0.5) * volatility * basePrice;
const high = Math.max(open, close) + Math.random() * volatility * basePrice;
for (let timestamp = lookbackTime; timestamp <= warmupEndTime; timestamp += 60000) { const low = Math.min(open, close) - Math.random() * volatility * basePrice;
const basePrice = 100 + Math.sin(timestamp / 1000000) * 10; const volume = Math.floor(Math.random() * 10000) + 1000;
const volatility = 0.02;
data.push({
const open = basePrice + (Math.random() - 0.5) * volatility * basePrice; timestamp,
const close = open + (Math.random() - 0.5) * volatility * basePrice; symbol: this.context.symbol,
const high = Math.max(open, close) + Math.random() * volatility * basePrice; open,
const low = Math.min(open, close) - Math.random() * volatility * basePrice; high,
const volume = Math.floor(Math.random() * 10000) + 1000; low,
close,
data.push({ volume,
timestamp, });
symbol: this.context.symbol, }
open,
high, return data;
low, }
close,
volume private createDataFrame(data: any[]): DataFrame {
}); return new DataFrame(data, {
} columns: ['timestamp', 'symbol', 'open', 'high', 'low', 'close', 'volume'],
dtypes: {
return data; timestamp: 'number',
} symbol: 'string',
open: 'number',
private createDataFrame(data: any[]): DataFrame { high: 'number',
return new DataFrame(data, { low: 'number',
columns: ['timestamp', 'symbol', 'open', 'high', 'low', 'close', 'volume'], close: 'number',
dtypes: { volume: 'number',
timestamp: 'number', },
symbol: 'string', });
open: 'number', }
high: 'number',
low: 'number', private generateStrategyCode(): string {
close: 'number', // Generate strategy code based on context
volume: 'number' const strategy = this.context.strategy;
}
}); if (strategy.type === 'sma_crossover') {
} return 'sma_crossover';
}
private generateStrategyCode(): string {
// Generate strategy code based on context return strategy.code || 'sma_crossover';
const strategy = this.context.strategy; }
if (strategy.type === 'sma_crossover') { private convertVectorizedResult(
return 'sma_crossover'; vectorResult: VectorizedBacktestResult,
} startTime: number
): BacktestResult {
return strategy.code || 'sma_crossover'; return {
} backtestId: this.context.backtestId,
strategy: this.context.strategy,
private convertVectorizedResult(vectorResult: VectorizedBacktestResult, startTime: number): BacktestResult { symbol: this.context.symbol,
return { startDate: this.context.startDate,
backtestId: this.context.backtestId, endDate: this.context.endDate,
strategy: this.context.strategy, mode: 'hybrid-vectorized',
symbol: this.context.symbol, duration: Date.now() - startTime,
startDate: this.context.startDate, trades: vectorResult.trades.map(trade => ({
endDate: this.context.endDate, id: `trade_${trade.entryIndex}_${trade.exitIndex}`,
mode: 'hybrid-vectorized', symbol: this.context.symbol,
duration: Date.now() - startTime, side: trade.side,
trades: vectorResult.trades.map(trade => ({ entryTime: vectorResult.timestamps[trade.entryIndex],
id: `trade_${trade.entryIndex}_${trade.exitIndex}`, exitTime: vectorResult.timestamps[trade.exitIndex],
symbol: this.context.symbol, entryPrice: trade.entryPrice,
side: trade.side, exitPrice: trade.exitPrice,
entryTime: vectorResult.timestamps[trade.entryIndex], quantity: trade.quantity,
exitTime: vectorResult.timestamps[trade.exitIndex], pnl: trade.pnl,
entryPrice: trade.entryPrice, commission: 0,
exitPrice: trade.exitPrice, slippage: 0,
quantity: trade.quantity, })),
pnl: trade.pnl, performance: {
commission: 0, totalReturn: vectorResult.metrics.totalReturns,
slippage: 0 sharpeRatio: vectorResult.metrics.sharpeRatio,
})), maxDrawdown: vectorResult.metrics.maxDrawdown,
performance: { winRate: vectorResult.metrics.winRate,
totalReturn: vectorResult.metrics.totalReturns, profitFactor: vectorResult.metrics.profitFactor,
sharpeRatio: vectorResult.metrics.sharpeRatio, totalTrades: vectorResult.metrics.totalTrades,
maxDrawdown: vectorResult.metrics.maxDrawdown, winningTrades: vectorResult.trades.filter(t => t.pnl > 0).length,
winRate: vectorResult.metrics.winRate, losingTrades: vectorResult.trades.filter(t => t.pnl <= 0).length,
profitFactor: vectorResult.metrics.profitFactor, avgTrade: vectorResult.metrics.avgTrade,
totalTrades: vectorResult.metrics.totalTrades, avgWin:
winningTrades: vectorResult.trades.filter(t => t.pnl > 0).length, vectorResult.trades.filter(t => t.pnl > 0).reduce((sum, t) => sum + t.pnl, 0) /
losingTrades: vectorResult.trades.filter(t => t.pnl <= 0).length, vectorResult.trades.filter(t => t.pnl > 0).length || 0,
avgTrade: vectorResult.metrics.avgTrade, avgLoss:
avgWin: vectorResult.trades.filter(t => t.pnl > 0) vectorResult.trades.filter(t => t.pnl <= 0).reduce((sum, t) => sum + t.pnl, 0) /
.reduce((sum, t) => sum + t.pnl, 0) / vectorResult.trades.filter(t => t.pnl > 0).length || 0, vectorResult.trades.filter(t => t.pnl <= 0).length || 0,
avgLoss: vectorResult.trades.filter(t => t.pnl <= 0) largestWin: Math.max(...vectorResult.trades.map(t => t.pnl), 0),
.reduce((sum, t) => sum + t.pnl, 0) / vectorResult.trades.filter(t => t.pnl <= 0).length || 0, largestLoss: Math.min(...vectorResult.trades.map(t => t.pnl), 0),
largestWin: Math.max(...vectorResult.trades.map(t => t.pnl), 0), },
largestLoss: Math.min(...vectorResult.trades.map(t => t.pnl), 0) equity: vectorResult.equity,
}, drawdown: vectorResult.metrics.drawdown,
equity: vectorResult.equity, metadata: {
drawdown: vectorResult.metrics.drawdown, mode: 'hybrid-vectorized',
metadata: { dataPoints: vectorResult.timestamps.length,
mode: 'hybrid-vectorized', signals: Object.keys(vectorResult.signals),
dataPoints: vectorResult.timestamps.length, optimizations: ['vectorized_warmup', 'precomputed_indicators'],
signals: Object.keys(vectorResult.signals), },
optimizations: ['vectorized_warmup', 'precomputed_indicators'] };
} }
};
} private extractFinalPortfolio(warmupResult: BacktestResult): any {
// Extract the final portfolio state from warmup phase
private extractFinalPortfolio(warmupResult: BacktestResult): any { const finalEquity = warmupResult.equity[warmupResult.equity.length - 1] || 10000;
// Extract the final portfolio state from warmup phase
const finalEquity = warmupResult.equity[warmupResult.equity.length - 1] || 10000; return {
cash: finalEquity,
return { positions: [], // Simplified - in production would track actual positions
cash: finalEquity, equity: finalEquity,
positions: [], // Simplified - in production would track actual positions };
equity: finalEquity }
};
} private combineResults(
warmupResult: BacktestResult,
private combineResults(warmupResult: BacktestResult, eventResult: BacktestResult, startTime: number): BacktestResult { eventResult: BacktestResult,
// Combine results from both phases startTime: number
const combinedTrades = [...warmupResult.trades, ...eventResult.trades]; ): BacktestResult {
const combinedEquity = [...warmupResult.equity, ...eventResult.equity]; // Combine results from both phases
const combinedDrawdown = [...(warmupResult.drawdown || []), ...(eventResult.drawdown || [])]; const combinedTrades = [...warmupResult.trades, ...eventResult.trades];
const combinedEquity = [...warmupResult.equity, ...eventResult.equity];
// Recalculate combined performance metrics const combinedDrawdown = [...(warmupResult.drawdown || []), ...(eventResult.drawdown || [])];
const totalPnL = combinedTrades.reduce((sum, trade) => sum + trade.pnl, 0);
const winningTrades = combinedTrades.filter(t => t.pnl > 0); // Recalculate combined performance metrics
const losingTrades = combinedTrades.filter(t => t.pnl <= 0); const totalPnL = combinedTrades.reduce((sum, trade) => sum + trade.pnl, 0);
const winningTrades = combinedTrades.filter(t => t.pnl > 0);
const grossProfit = winningTrades.reduce((sum, t) => sum + t.pnl, 0); const losingTrades = combinedTrades.filter(t => t.pnl <= 0);
const grossLoss = Math.abs(losingTrades.reduce((sum, t) => sum + t.pnl, 0));
const grossProfit = winningTrades.reduce((sum, t) => sum + t.pnl, 0);
return { const grossLoss = Math.abs(losingTrades.reduce((sum, t) => sum + t.pnl, 0));
backtestId: this.context.backtestId,
strategy: this.context.strategy, return {
symbol: this.context.symbol, backtestId: this.context.backtestId,
startDate: this.context.startDate, strategy: this.context.strategy,
endDate: this.context.endDate, symbol: this.context.symbol,
mode: 'hybrid', startDate: this.context.startDate,
duration: Date.now() - startTime, endDate: this.context.endDate,
trades: combinedTrades, mode: 'hybrid',
performance: { duration: Date.now() - startTime,
totalReturn: (combinedEquity[combinedEquity.length - 1] - combinedEquity[0]) / combinedEquity[0], trades: combinedTrades,
sharpeRatio: eventResult.performance.sharpeRatio, // Use event result for more accurate calculation performance: {
maxDrawdown: Math.max(...combinedDrawdown), totalReturn:
winRate: winningTrades.length / combinedTrades.length, (combinedEquity[combinedEquity.length - 1] - combinedEquity[0]) / combinedEquity[0],
profitFactor: grossLoss !== 0 ? grossProfit / grossLoss : Infinity, sharpeRatio: eventResult.performance.sharpeRatio, // Use event result for more accurate calculation
totalTrades: combinedTrades.length, maxDrawdown: Math.max(...combinedDrawdown),
winningTrades: winningTrades.length, winRate: winningTrades.length / combinedTrades.length,
losingTrades: losingTrades.length, profitFactor: grossLoss !== 0 ? grossProfit / grossLoss : Infinity,
avgTrade: totalPnL / combinedTrades.length, totalTrades: combinedTrades.length,
avgWin: grossProfit / winningTrades.length || 0, winningTrades: winningTrades.length,
avgLoss: grossLoss / losingTrades.length || 0, losingTrades: losingTrades.length,
largestWin: Math.max(...combinedTrades.map(t => t.pnl), 0), avgTrade: totalPnL / combinedTrades.length,
largestLoss: Math.min(...combinedTrades.map(t => t.pnl), 0) avgWin: grossProfit / winningTrades.length || 0,
}, avgLoss: grossLoss / losingTrades.length || 0,
equity: combinedEquity, largestWin: Math.max(...combinedTrades.map(t => t.pnl), 0),
drawdown: combinedDrawdown, largestLoss: Math.min(...combinedTrades.map(t => t.pnl), 0),
metadata: { },
mode: 'hybrid', equity: combinedEquity,
phases: ['vectorized-warmup', 'event-driven'], drawdown: combinedDrawdown,
warmupPeriod: this.config.warmupPeriod, metadata: {
optimizations: ['precomputed_indicators', 'hybrid_execution'], mode: 'hybrid',
warmupTrades: warmupResult.trades.length, phases: ['vectorized-warmup', 'event-driven'],
eventTrades: eventResult.trades.length warmupPeriod: this.config.warmupPeriod,
} optimizations: ['precomputed_indicators', 'hybrid_execution'],
}; warmupTrades: warmupResult.trades.length,
} eventTrades: eventResult.trades.length,
},
async cleanup(): Promise<void> { };
await super.cleanup(); }
await this.eventMode.cleanup();
await this.vectorizedMode.cleanup(); async cleanup(): Promise<void> {
this.precomputedIndicators.clear(); await super.cleanup();
this.logger.info('Hybrid mode cleanup completed'); await this.eventMode.cleanup();
} await this.vectorizedMode.cleanup();
} this.precomputedIndicators.clear();
this.logger.info('Hybrid mode cleanup completed');
export default HybridMode; }
}
export default HybridMode;

View file

@ -1,31 +1,31 @@
/** /**
* Live Trading Mode * Live Trading Mode
* Executes orders through real brokers * Executes orders through real brokers
*/ */
import { ExecutionMode, Order, OrderResult, MarketData } from '../../framework/execution-mode'; import { ExecutionMode, MarketData, Order, OrderResult } from '../../framework/execution-mode';
export class LiveMode extends ExecutionMode { export class LiveMode extends ExecutionMode {
name = 'live'; name = 'live';
async executeOrder(order: Order): Promise<OrderResult> { async executeOrder(order: Order): Promise<OrderResult> {
this.logger.info('Executing live order', { orderId: order.id }); this.logger.info('Executing live order', { orderId: order.id });
// TODO: Implement real broker integration // TODO: Implement real broker integration
// This will connect to actual brokerage APIs // This will connect to actual brokerage APIs
throw new Error('Live broker integration not implemented yet'); throw new Error('Live broker integration not implemented yet');
} }
getCurrentTime(): Date { getCurrentTime(): Date {
return new Date(); // Real time return new Date(); // Real time
} }
async getMarketData(symbol: string): Promise<MarketData> { async getMarketData(symbol: string): Promise<MarketData> {
// TODO: Get live market data // TODO: Get live market data
throw new Error('Live market data fetching not implemented yet'); throw new Error('Live market data fetching not implemented yet');
} }
async publishEvent(event: string, data: any): Promise<void> { async publishEvent(event: string, data: any): Promise<void> {
// TODO: Publish to real event bus (Dragonfly) // TODO: Publish to real event bus (Dragonfly)
this.logger.debug('Publishing event', { event, data }); this.logger.debug('Publishing event', { event, data });
} }
} }

View file

@ -1,239 +1,236 @@
import { getLogger } from '@stock-bot/logger'; import { DataFrame } from '@stock-bot/data-frame';
import { EventBus } from '@stock-bot/event-bus'; import { EventBus } from '@stock-bot/event-bus';
import { VectorEngine, VectorizedBacktestResult } from '@stock-bot/vector-engine'; import { getLogger } from '@stock-bot/logger';
import { DataFrame } from '@stock-bot/data-frame'; import { VectorEngine, VectorizedBacktestResult } from '@stock-bot/vector-engine';
import { ExecutionMode, BacktestContext, BacktestResult } from '../framework/execution-mode'; import { BacktestContext, BacktestResult, ExecutionMode } from '../framework/execution-mode';
export interface VectorizedModeConfig { export interface VectorizedModeConfig {
batchSize?: number; batchSize?: number;
enableOptimization?: boolean; enableOptimization?: boolean;
parallelProcessing?: boolean; parallelProcessing?: boolean;
} }
export class VectorizedMode extends ExecutionMode { export class VectorizedMode extends ExecutionMode {
private vectorEngine: VectorEngine; private vectorEngine: VectorEngine;
private config: VectorizedModeConfig; private config: VectorizedModeConfig;
private logger = getLogger('vectorized-mode'); private logger = getLogger('vectorized-mode');
constructor( constructor(context: BacktestContext, eventBus: EventBus, config: VectorizedModeConfig = {}) {
context: BacktestContext, super(context, eventBus);
eventBus: EventBus, this.vectorEngine = new VectorEngine();
config: VectorizedModeConfig = {} this.config = {
) { batchSize: 10000,
super(context, eventBus); enableOptimization: true,
this.vectorEngine = new VectorEngine(); parallelProcessing: true,
this.config = { ...config,
batchSize: 10000, };
enableOptimization: true, }
parallelProcessing: true,
...config async initialize(): Promise<void> {
}; await super.initialize();
} this.logger.info('Vectorized mode initialized', {
backtestId: this.context.backtestId,
async initialize(): Promise<void> { config: this.config,
await super.initialize(); });
this.logger.info('Vectorized mode initialized', { }
backtestId: this.context.backtestId,
config: this.config async execute(): Promise<BacktestResult> {
}); const startTime = Date.now();
} this.logger.info('Starting vectorized backtest execution');
async execute(): Promise<BacktestResult> { try {
const startTime = Date.now(); // Load all data at once for vectorized processing
this.logger.info('Starting vectorized backtest execution'); const data = await this.loadHistoricalData();
try { // Convert to DataFrame format
// Load all data at once for vectorized processing const dataFrame = this.createDataFrame(data);
const data = await this.loadHistoricalData();
// Execute vectorized strategy
// Convert to DataFrame format const strategyCode = this.generateStrategyCode();
const dataFrame = this.createDataFrame(data); const vectorResult = await this.vectorEngine.executeVectorizedStrategy(
dataFrame,
// Execute vectorized strategy strategyCode
const strategyCode = this.generateStrategyCode(); );
const vectorResult = await this.vectorEngine.executeVectorizedStrategy(
dataFrame, // Convert to standard backtest result format
strategyCode const result = this.convertVectorizedResult(vectorResult, startTime);
);
// Emit completion event
// Convert to standard backtest result format await this.eventBus.publishBacktestUpdate(this.context.backtestId, 100, {
const result = this.convertVectorizedResult(vectorResult, startTime); status: 'completed',
result,
// Emit completion event });
await this.eventBus.publishBacktestUpdate(
this.context.backtestId, this.logger.info('Vectorized backtest completed', {
100, backtestId: this.context.backtestId,
{ status: 'completed', result } duration: Date.now() - startTime,
); totalTrades: result.trades.length,
});
this.logger.info('Vectorized backtest completed', {
backtestId: this.context.backtestId, return result;
duration: Date.now() - startTime, } catch (error) {
totalTrades: result.trades.length this.logger.error('Vectorized backtest failed', {
}); error,
backtestId: this.context.backtestId,
return result; });
} catch (error) { await this.eventBus.publishBacktestUpdate(this.context.backtestId, 0, {
this.logger.error('Vectorized backtest failed', { status: 'failed',
error, error: error.message,
backtestId: this.context.backtestId });
});
throw error;
await this.eventBus.publishBacktestUpdate( }
this.context.backtestId, }
0,
{ status: 'failed', error: error.message } private async loadHistoricalData(): Promise<any[]> {
); // Load all historical data at once
// This is much more efficient than loading tick by tick
throw error; const data = [];
}
} // Simulate loading data (in production, this would be a bulk database query)
const startTime = new Date(this.context.startDate).getTime();
private async loadHistoricalData(): Promise<any[]> { const endTime = new Date(this.context.endDate).getTime();
// Load all historical data at once const interval = 60000; // 1 minute intervals
// This is much more efficient than loading tick by tick
const data = []; for (let timestamp = startTime; timestamp <= endTime; timestamp += interval) {
// Simulate OHLCV data
// Simulate loading data (in production, this would be a bulk database query) const basePrice = 100 + Math.sin(timestamp / 1000000) * 10;
const startTime = new Date(this.context.startDate).getTime(); const volatility = 0.02;
const endTime = new Date(this.context.endDate).getTime();
const interval = 60000; // 1 minute intervals const open = basePrice + (Math.random() - 0.5) * volatility * basePrice;
const close = open + (Math.random() - 0.5) * volatility * basePrice;
for (let timestamp = startTime; timestamp <= endTime; timestamp += interval) { const high = Math.max(open, close) + Math.random() * volatility * basePrice;
// Simulate OHLCV data const low = Math.min(open, close) - Math.random() * volatility * basePrice;
const basePrice = 100 + Math.sin(timestamp / 1000000) * 10; const volume = Math.floor(Math.random() * 10000) + 1000;
const volatility = 0.02;
data.push({
const open = basePrice + (Math.random() - 0.5) * volatility * basePrice; timestamp,
const close = open + (Math.random() - 0.5) * volatility * basePrice; symbol: this.context.symbol,
const high = Math.max(open, close) + Math.random() * volatility * basePrice; open,
const low = Math.min(open, close) - Math.random() * volatility * basePrice; high,
const volume = Math.floor(Math.random() * 10000) + 1000; low,
close,
data.push({ volume,
timestamp, });
symbol: this.context.symbol, }
open,
high, return data;
low, }
close,
volume private createDataFrame(data: any[]): DataFrame {
}); return new DataFrame(data, {
} columns: ['timestamp', 'symbol', 'open', 'high', 'low', 'close', 'volume'],
dtypes: {
return data; timestamp: 'number',
} symbol: 'string',
open: 'number',
private createDataFrame(data: any[]): DataFrame { high: 'number',
return new DataFrame(data, { low: 'number',
columns: ['timestamp', 'symbol', 'open', 'high', 'low', 'close', 'volume'], close: 'number',
dtypes: { volume: 'number',
timestamp: 'number', },
symbol: 'string', });
open: 'number', }
high: 'number',
low: 'number', private generateStrategyCode(): string {
close: 'number', // Convert strategy configuration to vectorized strategy code
volume: 'number' // This is a simplified example - in production you'd have a more sophisticated compiler
} const strategy = this.context.strategy;
});
} if (strategy.type === 'sma_crossover') {
return 'sma_crossover';
private generateStrategyCode(): string { }
// Convert strategy configuration to vectorized strategy code
// This is a simplified example - in production you'd have a more sophisticated compiler // Add more strategy types as needed
const strategy = this.context.strategy; return strategy.code || 'sma_crossover';
}
if (strategy.type === 'sma_crossover') {
return 'sma_crossover'; private convertVectorizedResult(
} vectorResult: VectorizedBacktestResult,
startTime: number
// Add more strategy types as needed ): BacktestResult {
return strategy.code || 'sma_crossover'; return {
} backtestId: this.context.backtestId,
strategy: this.context.strategy,
private convertVectorizedResult( symbol: this.context.symbol,
vectorResult: VectorizedBacktestResult, startDate: this.context.startDate,
startTime: number endDate: this.context.endDate,
): BacktestResult { mode: 'vectorized',
return { duration: Date.now() - startTime,
backtestId: this.context.backtestId, trades: vectorResult.trades.map(trade => ({
strategy: this.context.strategy, id: `trade_${trade.entryIndex}_${trade.exitIndex}`,
symbol: this.context.symbol, symbol: this.context.symbol,
startDate: this.context.startDate, side: trade.side,
endDate: this.context.endDate, entryTime: vectorResult.timestamps[trade.entryIndex],
mode: 'vectorized', exitTime: vectorResult.timestamps[trade.exitIndex],
duration: Date.now() - startTime, entryPrice: trade.entryPrice,
trades: vectorResult.trades.map(trade => ({ exitPrice: trade.exitPrice,
id: `trade_${trade.entryIndex}_${trade.exitIndex}`, quantity: trade.quantity,
symbol: this.context.symbol, pnl: trade.pnl,
side: trade.side, commission: 0, // Simplified
entryTime: vectorResult.timestamps[trade.entryIndex], slippage: 0,
exitTime: vectorResult.timestamps[trade.exitIndex], })),
entryPrice: trade.entryPrice, performance: {
exitPrice: trade.exitPrice, totalReturn: vectorResult.metrics.totalReturns,
quantity: trade.quantity, sharpeRatio: vectorResult.metrics.sharpeRatio,
pnl: trade.pnl, maxDrawdown: vectorResult.metrics.maxDrawdown,
commission: 0, // Simplified winRate: vectorResult.metrics.winRate,
slippage: 0 profitFactor: vectorResult.metrics.profitFactor,
})), totalTrades: vectorResult.metrics.totalTrades,
performance: { winningTrades: vectorResult.trades.filter(t => t.pnl > 0).length,
totalReturn: vectorResult.metrics.totalReturns, losingTrades: vectorResult.trades.filter(t => t.pnl <= 0).length,
sharpeRatio: vectorResult.metrics.sharpeRatio, avgTrade: vectorResult.metrics.avgTrade,
maxDrawdown: vectorResult.metrics.maxDrawdown, avgWin:
winRate: vectorResult.metrics.winRate, vectorResult.trades.filter(t => t.pnl > 0).reduce((sum, t) => sum + t.pnl, 0) /
profitFactor: vectorResult.metrics.profitFactor, vectorResult.trades.filter(t => t.pnl > 0).length || 0,
totalTrades: vectorResult.metrics.totalTrades, avgLoss:
winningTrades: vectorResult.trades.filter(t => t.pnl > 0).length, vectorResult.trades.filter(t => t.pnl <= 0).reduce((sum, t) => sum + t.pnl, 0) /
losingTrades: vectorResult.trades.filter(t => t.pnl <= 0).length, vectorResult.trades.filter(t => t.pnl <= 0).length || 0,
avgTrade: vectorResult.metrics.avgTrade, largestWin: Math.max(...vectorResult.trades.map(t => t.pnl), 0),
avgWin: vectorResult.trades.filter(t => t.pnl > 0) largestLoss: Math.min(...vectorResult.trades.map(t => t.pnl), 0),
.reduce((sum, t) => sum + t.pnl, 0) / vectorResult.trades.filter(t => t.pnl > 0).length || 0, },
avgLoss: vectorResult.trades.filter(t => t.pnl <= 0) equity: vectorResult.equity,
.reduce((sum, t) => sum + t.pnl, 0) / vectorResult.trades.filter(t => t.pnl <= 0).length || 0, drawdown: vectorResult.metrics.drawdown,
largestWin: Math.max(...vectorResult.trades.map(t => t.pnl), 0), metadata: {
largestLoss: Math.min(...vectorResult.trades.map(t => t.pnl), 0) mode: 'vectorized',
}, dataPoints: vectorResult.timestamps.length,
equity: vectorResult.equity, signals: Object.keys(vectorResult.signals),
drawdown: vectorResult.metrics.drawdown, optimizations: this.config.enableOptimization ? ['vectorized_computation'] : [],
metadata: { },
mode: 'vectorized', };
dataPoints: vectorResult.timestamps.length, }
signals: Object.keys(vectorResult.signals),
optimizations: this.config.enableOptimization ? ['vectorized_computation'] : [] async cleanup(): Promise<void> {
} await super.cleanup();
}; this.logger.info('Vectorized mode cleanup completed');
} }
async cleanup(): Promise<void> { // Batch processing capabilities
await super.cleanup(); async batchBacktest(
this.logger.info('Vectorized mode cleanup completed'); strategies: Array<{ id: string; config: any }>
} ): Promise<Record<string, BacktestResult>> {
this.logger.info('Starting batch vectorized backtest', {
// Batch processing capabilities strategiesCount: strategies.length,
async batchBacktest(strategies: Array<{ id: string; config: any }>): Promise<Record<string, BacktestResult>> { });
this.logger.info('Starting batch vectorized backtest', {
strategiesCount: strategies.length const data = await this.loadHistoricalData();
}); const dataFrame = this.createDataFrame(data);
const data = await this.loadHistoricalData(); const strategyConfigs = strategies.map(s => ({
const dataFrame = this.createDataFrame(data); id: s.id,
code: this.generateStrategyCode(),
const strategyConfigs = strategies.map(s => ({ }));
id: s.id,
code: this.generateStrategyCode() const batchResults = await this.vectorEngine.batchBacktest(dataFrame, strategyConfigs);
})); const results: Record<string, BacktestResult> = {};
const batchResults = await this.vectorEngine.batchBacktest(dataFrame, strategyConfigs); for (const [strategyId, vectorResult] of Object.entries(batchResults)) {
const results: Record<string, BacktestResult> = {}; results[strategyId] = this.convertVectorizedResult(vectorResult, Date.now());
}
for (const [strategyId, vectorResult] of Object.entries(batchResults)) {
results[strategyId] = this.convertVectorizedResult(vectorResult, Date.now()); return results;
} }
}
return results;
} export default VectorizedMode;
}
export default VectorizedMode;

View file

@ -1,285 +1,283 @@
#!/usr/bin/env bun #!/usr/bin/env bun
/** /**
* Strategy Service CLI * Strategy Service CLI
* Command-line interface for running backtests and managing strategies * Command-line interface for running backtests and managing strategies
*/ */
import { program } from 'commander';
import { program } from 'commander'; import { createEventBus } from '@stock-bot/event-bus';
import { getLogger } from '@stock-bot/logger'; import { getLogger } from '@stock-bot/logger';
import { createEventBus } from '@stock-bot/event-bus'; import { EventMode } from '../backtesting/modes/event-mode';
import { BacktestContext } from '../framework/execution-mode'; import HybridMode from '../backtesting/modes/hybrid-mode';
import { LiveMode } from '../backtesting/modes/live-mode'; import { LiveMode } from '../backtesting/modes/live-mode';
import { EventMode } from '../backtesting/modes/event-mode'; import VectorizedMode from '../backtesting/modes/vectorized-mode';
import VectorizedMode from '../backtesting/modes/vectorized-mode'; import { BacktestContext } from '../framework/execution-mode';
import HybridMode from '../backtesting/modes/hybrid-mode';
const logger = getLogger('strategy-cli');
const logger = getLogger('strategy-cli');
interface CLIBacktestConfig {
interface CLIBacktestConfig { strategy: string;
strategy: string; strategies: string;
strategies: string; symbol: string;
symbol: string; startDate: string;
startDate: string; endDate: string;
endDate: string; mode: 'live' | 'event' | 'vectorized' | 'hybrid';
mode: 'live' | 'event' | 'vectorized' | 'hybrid'; initialCapital?: number;
initialCapital?: number; config?: string;
config?: string; output?: string;
output?: string; verbose?: boolean;
verbose?: boolean; }
}
async function runBacktest(options: CLIBacktestConfig): Promise<void> {
async function runBacktest(options: CLIBacktestConfig): Promise<void> { logger.info('Starting backtest from CLI', { options });
logger.info('Starting backtest from CLI', { options });
try {
try { // Initialize event bus
// Initialize event bus const eventBus = createEventBus({
const eventBus = createEventBus({ serviceName: 'strategy-cli',
serviceName: 'strategy-cli', enablePersistence: false, // Disable Redis for CLI
enablePersistence: false // Disable Redis for CLI });
});
// Create backtest context
// Create backtest context const context: BacktestContext = {
const context: BacktestContext = { backtestId: `cli_${Date.now()}`,
backtestId: `cli_${Date.now()}`, strategy: {
strategy: { id: options.strategy,
id: options.strategy, name: options.strategy,
name: options.strategy, type: options.strategy,
type: options.strategy, code: options.strategy,
code: options.strategy, parameters: {},
parameters: {} },
}, symbol: options.symbol,
symbol: options.symbol, startDate: options.startDate,
startDate: options.startDate, endDate: options.endDate,
endDate: options.endDate, initialCapital: options.initialCapital || 10000,
initialCapital: options.initialCapital || 10000, mode: options.mode,
mode: options.mode };
};
// Load additional config if provided
// Load additional config if provided if (options.config) {
if (options.config) { const configData = await loadConfig(options.config);
const configData = await loadConfig(options.config); context.strategy.parameters = { ...context.strategy.parameters, ...configData };
context.strategy.parameters = { ...context.strategy.parameters, ...configData }; }
}
// Create and execute the appropriate mode
// Create and execute the appropriate mode let executionMode;
let executionMode;
switch (options.mode) {
switch (options.mode) { case 'live':
case 'live': executionMode = new LiveMode(context, eventBus);
executionMode = new LiveMode(context, eventBus); break;
break; case 'event':
case 'event': executionMode = new EventMode(context, eventBus);
executionMode = new EventMode(context, eventBus); break;
break; case 'vectorized':
case 'vectorized': executionMode = new VectorizedMode(context, eventBus);
executionMode = new VectorizedMode(context, eventBus); break;
break; case 'hybrid':
case 'hybrid': executionMode = new HybridMode(context, eventBus);
executionMode = new HybridMode(context, eventBus); break;
break; default:
default: throw new Error(`Unknown execution mode: ${options.mode}`);
throw new Error(`Unknown execution mode: ${options.mode}`); }
}
// Subscribe to progress updates
// Subscribe to progress updates eventBus.subscribe('backtest.update', message => {
eventBus.subscribe('backtest.update', (message) => { const { backtestId, progress, ...data } = message.data;
const { backtestId, progress, ...data } = message.data; console.log(`Progress: ${progress}%`, data);
console.log(`Progress: ${progress}%`, data); });
});
await executionMode.initialize();
await executionMode.initialize(); const result = await executionMode.execute();
const result = await executionMode.execute(); await executionMode.cleanup();
await executionMode.cleanup();
// Display results
// Display results displayResults(result);
displayResults(result);
// Save results if output specified
// Save results if output specified if (options.output) {
if (options.output) { await saveResults(result, options.output);
await saveResults(result, options.output); }
}
await eventBus.close();
await eventBus.close(); } catch (error) {
logger.error('Backtest failed', error);
} catch (error) { process.exit(1);
logger.error('Backtest failed', error); }
process.exit(1); }
}
} async function loadConfig(configPath: string): Promise<any> {
try {
async function loadConfig(configPath: string): Promise<any> { if (configPath.endsWith('.json')) {
try { const file = Bun.file(configPath);
if (configPath.endsWith('.json')) { return await file.json();
const file = Bun.file(configPath); } else {
return await file.json(); // Assume it's a JavaScript/TypeScript module
} else { return await import(configPath);
// Assume it's a JavaScript/TypeScript module }
return await import(configPath); } catch (error) {
} logger.error('Failed to load config', { configPath, error });
} catch (error) { throw new Error(`Failed to load config from ${configPath}: ${(error as Error).message}`);
logger.error('Failed to load config', { configPath, error }); }
throw new Error(`Failed to load config from ${configPath}: ${(error as Error).message}`); }
}
} function displayResults(result: any): void {
console.log('\n=== Backtest Results ===');
function displayResults(result: any): void { console.log(`Strategy: ${result.strategy.name}`);
console.log('\n=== Backtest Results ==='); console.log(`Symbol: ${result.symbol}`);
console.log(`Strategy: ${result.strategy.name}`); console.log(`Period: ${result.startDate} to ${result.endDate}`);
console.log(`Symbol: ${result.symbol}`); console.log(`Mode: ${result.mode}`);
console.log(`Period: ${result.startDate} to ${result.endDate}`); console.log(`Duration: ${result.duration}ms`);
console.log(`Mode: ${result.mode}`);
console.log(`Duration: ${result.duration}ms`); console.log('\n--- Performance ---');
console.log(`Total Return: ${(result.performance.totalReturn * 100).toFixed(2)}%`);
console.log('\n--- Performance ---'); console.log(`Sharpe Ratio: ${result.performance.sharpeRatio.toFixed(3)}`);
console.log(`Total Return: ${(result.performance.totalReturn * 100).toFixed(2)}%`); console.log(`Max Drawdown: ${(result.performance.maxDrawdown * 100).toFixed(2)}%`);
console.log(`Sharpe Ratio: ${result.performance.sharpeRatio.toFixed(3)}`); console.log(`Win Rate: ${(result.performance.winRate * 100).toFixed(1)}%`);
console.log(`Max Drawdown: ${(result.performance.maxDrawdown * 100).toFixed(2)}%`); console.log(`Profit Factor: ${result.performance.profitFactor.toFixed(2)}`);
console.log(`Win Rate: ${(result.performance.winRate * 100).toFixed(1)}%`);
console.log(`Profit Factor: ${result.performance.profitFactor.toFixed(2)}`); console.log('\n--- Trading Stats ---');
console.log(`Total Trades: ${result.performance.totalTrades}`);
console.log('\n--- Trading Stats ---'); console.log(`Winning Trades: ${result.performance.winningTrades}`);
console.log(`Total Trades: ${result.performance.totalTrades}`); console.log(`Losing Trades: ${result.performance.losingTrades}`);
console.log(`Winning Trades: ${result.performance.winningTrades}`); console.log(`Average Trade: ${result.performance.avgTrade.toFixed(2)}`);
console.log(`Losing Trades: ${result.performance.losingTrades}`); console.log(`Average Win: ${result.performance.avgWin.toFixed(2)}`);
console.log(`Average Trade: ${result.performance.avgTrade.toFixed(2)}`); console.log(`Average Loss: ${result.performance.avgLoss.toFixed(2)}`);
console.log(`Average Win: ${result.performance.avgWin.toFixed(2)}`); console.log(`Largest Win: ${result.performance.largestWin.toFixed(2)}`);
console.log(`Average Loss: ${result.performance.avgLoss.toFixed(2)}`); console.log(`Largest Loss: ${result.performance.largestLoss.toFixed(2)}`);
console.log(`Largest Win: ${result.performance.largestWin.toFixed(2)}`);
console.log(`Largest Loss: ${result.performance.largestLoss.toFixed(2)}`); if (result.metadata) {
console.log('\n--- Metadata ---');
if (result.metadata) { Object.entries(result.metadata).forEach(([key, value]) => {
console.log('\n--- Metadata ---'); console.log(`${key}: ${Array.isArray(value) ? value.join(', ') : value}`);
Object.entries(result.metadata).forEach(([key, value]) => { });
console.log(`${key}: ${Array.isArray(value) ? value.join(', ') : value}`); }
}); }
}
} async function saveResults(result: any, outputPath: string): Promise<void> {
try {
async function saveResults(result: any, outputPath: string): Promise<void> { if (outputPath.endsWith('.json')) {
try { await Bun.write(outputPath, JSON.stringify(result, null, 2));
if (outputPath.endsWith('.json')) { } else if (outputPath.endsWith('.csv')) {
await Bun.write(outputPath, JSON.stringify(result, null, 2)); const csv = convertTradesToCSV(result.trades);
} else if (outputPath.endsWith('.csv')) { await Bun.write(outputPath, csv);
const csv = convertTradesToCSV(result.trades); } else {
await Bun.write(outputPath, csv); // Default to JSON
} else { await Bun.write(outputPath + '.json', JSON.stringify(result, null, 2));
// Default to JSON }
await Bun.write(outputPath + '.json', JSON.stringify(result, null, 2));
} logger.info(`\nResults saved to: ${outputPath}`);
} catch (error) {
logger.info(`\nResults saved to: ${outputPath}`); logger.error('Failed to save results', { outputPath, error });
} catch (error) { }
logger.error('Failed to save results', { outputPath, error }); }
}
} function convertTradesToCSV(trades: any[]): string {
if (trades.length === 0) return 'No trades executed\n';
function convertTradesToCSV(trades: any[]): string {
if (trades.length === 0) return 'No trades executed\n'; const headers = Object.keys(trades[0]).join(',');
const rows = trades.map(trade =>
const headers = Object.keys(trades[0]).join(','); Object.values(trade)
const rows = trades.map(trade => .map(value => (typeof value === 'string' ? `"${value}"` : value))
Object.values(trade).map(value => .join(',')
typeof value === 'string' ? `"${value}"` : value );
).join(',')
); return [headers, ...rows].join('\n');
}
return [headers, ...rows].join('\n');
} async function listStrategies(): Promise<void> {
console.log('Available strategies:');
async function listStrategies(): Promise<void> { console.log(' sma_crossover - Simple Moving Average Crossover');
console.log('Available strategies:'); console.log(' ema_crossover - Exponential Moving Average Crossover');
console.log(' sma_crossover - Simple Moving Average Crossover'); console.log(' rsi_mean_reversion - RSI Mean Reversion');
console.log(' ema_crossover - Exponential Moving Average Crossover'); console.log(' macd_trend - MACD Trend Following');
console.log(' rsi_mean_reversion - RSI Mean Reversion'); console.log(' bollinger_bands - Bollinger Bands Strategy');
console.log(' macd_trend - MACD Trend Following'); // Add more as they're implemented
console.log(' bollinger_bands - Bollinger Bands Strategy'); }
// Add more as they're implemented
} async function validateStrategy(strategy: string): Promise<void> {
console.log(`Validating strategy: ${strategy}`);
async function validateStrategy(strategy: string): Promise<void> {
console.log(`Validating strategy: ${strategy}`); // TODO: Add strategy validation logic
// This could check if the strategy exists, has valid parameters, etc.
// TODO: Add strategy validation logic
// This could check if the strategy exists, has valid parameters, etc. const validStrategies = [
'sma_crossover',
const validStrategies = ['sma_crossover', 'ema_crossover', 'rsi_mean_reversion', 'macd_trend', 'bollinger_bands']; 'ema_crossover',
'rsi_mean_reversion',
if (!validStrategies.includes(strategy)) { 'macd_trend',
console.warn(`Warning: Strategy '${strategy}' is not in the list of known strategies`); 'bollinger_bands',
console.log('Use --list-strategies to see available strategies'); ];
} else {
console.log(`✓ Strategy '${strategy}' is valid`); if (!validStrategies.includes(strategy)) {
} console.warn(`Warning: Strategy '${strategy}' is not in the list of known strategies`);
} console.log('Use --list-strategies to see available strategies');
} else {
// CLI Commands console.log(`✓ Strategy '${strategy}' is valid`);
program }
.name('strategy-cli') }
.description('Stock Trading Bot Strategy CLI')
.version('1.0.0'); // CLI Commands
program.name('strategy-cli').description('Stock Trading Bot Strategy CLI').version('1.0.0');
program
.command('backtest') program
.description('Run a backtest') .command('backtest')
.requiredOption('-s, --strategy <strategy>', 'Strategy to test') .description('Run a backtest')
.requiredOption('--symbol <symbol>', 'Symbol to trade') .requiredOption('-s, --strategy <strategy>', 'Strategy to test')
.requiredOption('--start-date <date>', 'Start date (YYYY-MM-DD)') .requiredOption('--symbol <symbol>', 'Symbol to trade')
.requiredOption('--end-date <date>', 'End date (YYYY-MM-DD)') .requiredOption('--start-date <date>', 'Start date (YYYY-MM-DD)')
.option('-m, --mode <mode>', 'Execution mode', 'vectorized') .requiredOption('--end-date <date>', 'End date (YYYY-MM-DD)')
.option('-c, --initial-capital <amount>', 'Initial capital', '10000') .option('-m, --mode <mode>', 'Execution mode', 'vectorized')
.option('--config <path>', 'Configuration file path') .option('-c, --initial-capital <amount>', 'Initial capital', '10000')
.option('-o, --output <path>', 'Output file path') .option('--config <path>', 'Configuration file path')
.option('-v, --verbose', 'Verbose output') .option('-o, --output <path>', 'Output file path')
.action(async (options: CLIBacktestConfig) => { .option('-v, --verbose', 'Verbose output')
await runBacktest(options); .action(async (options: CLIBacktestConfig) => {
}); await runBacktest(options);
});
program
.command('list-strategies') program.command('list-strategies').description('List available strategies').action(listStrategies);
.description('List available strategies')
.action(listStrategies); program
.command('validate')
program .description('Validate a strategy')
.command('validate') .requiredOption('-s, --strategy <strategy>', 'Strategy to validate')
.description('Validate a strategy') .action(async (options: CLIBacktestConfig) => {
.requiredOption('-s, --strategy <strategy>', 'Strategy to validate') await validateStrategy(options.strategy);
.action(async (options: CLIBacktestConfig) => { });
await validateStrategy(options.strategy);
}); program
.command('compare')
program .description('Compare multiple strategies')
.command('compare') .requiredOption('--strategies <strategies>', 'Comma-separated list of strategies')
.description('Compare multiple strategies') .requiredOption('--symbol <symbol>', 'Symbol to trade')
.requiredOption('--strategies <strategies>', 'Comma-separated list of strategies') .requiredOption('--start-date <date>', 'Start date (YYYY-MM-DD)')
.requiredOption('--symbol <symbol>', 'Symbol to trade') .requiredOption('--end-date <date>', 'End date (YYYY-MM-DD)')
.requiredOption('--start-date <date>', 'Start date (YYYY-MM-DD)') .option('-m, --mode <mode>', 'Execution mode', 'vectorized')
.requiredOption('--end-date <date>', 'End date (YYYY-MM-DD)') .option('-c, --initial-capital <amount>', 'Initial capital', '10000')
.option('-m, --mode <mode>', 'Execution mode', 'vectorized') .option('-o, --output <path>', 'Output directory')
.option('-c, --initial-capital <amount>', 'Initial capital', '10000') .action(async (options: CLIBacktestConfig) => {
.option('-o, --output <path>', 'Output directory') const strategies = options.strategies.split(',').map((s: string) => s.trim());
.action(async (options: CLIBacktestConfig) => { console.log(`Comparing strategies: ${strategies.join(', ')}`);
const strategies = options.strategies.split(',').map((s: string) => s.trim());
console.log(`Comparing strategies: ${strategies.join(', ')}`); const results: any[] = [];
const results: any[] = []; for (const strategy of strategies) {
console.log(`\nRunning ${strategy}...`);
for (const strategy of strategies) { try {
console.log(`\nRunning ${strategy}...`); await runBacktest({
try { ...options,
await runBacktest({ strategy,
...options, output: options.output ? `${options.output}/${strategy}.json` : undefined,
strategy, });
output: options.output ? `${options.output}/${strategy}.json` : undefined } catch (error) {
}); console.error(`Failed to run ${strategy}:`, (error as Error).message);
} catch (error) { }
console.error(`Failed to run ${strategy}:`, (error as Error).message); }
}
} console.log('\nComparison completed!');
});
console.log('\nComparison completed!');
}); // Parse command line arguments
program.parse();
// Parse command line arguments
program.parse(); export { runBacktest, listStrategies, validateStrategy };
export { runBacktest, listStrategies, validateStrategy };

View file

@ -1,80 +1,80 @@
/** /**
* Execution Mode Framework * Execution Mode Framework
* Base classes for different execution modes (live, event-driven, vectorized) * Base classes for different execution modes (live, event-driven, vectorized)
*/ */
import { getLogger } from '@stock-bot/logger'; import { getLogger } from '@stock-bot/logger';
const logger = getLogger('execution-mode'); const logger = getLogger('execution-mode');
export interface Order { export interface Order {
id: string; id: string;
symbol: string; symbol: string;
side: 'BUY' | 'SELL'; side: 'BUY' | 'SELL';
quantity: number; quantity: number;
type: 'MARKET' | 'LIMIT'; type: 'MARKET' | 'LIMIT';
price?: number; price?: number;
timestamp: Date; timestamp: Date;
} }
export interface OrderResult { export interface OrderResult {
orderId: string; orderId: string;
symbol: string; symbol: string;
executedQuantity: number; executedQuantity: number;
executedPrice: number; executedPrice: number;
commission: number; commission: number;
slippage: number; slippage: number;
timestamp: Date; timestamp: Date;
executionTime: number; executionTime: number;
} }
export interface MarketData { export interface MarketData {
symbol: string; symbol: string;
timestamp: Date; timestamp: Date;
open: number; open: number;
high: number; high: number;
low: number; low: number;
close: number; close: number;
volume: number; volume: number;
} }
export abstract class ExecutionMode { export abstract class ExecutionMode {
protected logger = getLogger(this.constructor.name); protected logger = getLogger(this.constructor.name);
abstract name: string; abstract name: string;
abstract executeOrder(order: Order): Promise<OrderResult>; abstract executeOrder(order: Order): Promise<OrderResult>;
abstract getCurrentTime(): Date; abstract getCurrentTime(): Date;
abstract getMarketData(symbol: string): Promise<MarketData>; abstract getMarketData(symbol: string): Promise<MarketData>;
abstract publishEvent(event: string, data: any): Promise<void>; abstract publishEvent(event: string, data: any): Promise<void>;
} }
export enum BacktestMode { export enum BacktestMode {
LIVE = 'live', LIVE = 'live',
EVENT_DRIVEN = 'event-driven', EVENT_DRIVEN = 'event-driven',
VECTORIZED = 'vectorized', VECTORIZED = 'vectorized',
HYBRID = 'hybrid' HYBRID = 'hybrid',
} }
export class ModeFactory { export class ModeFactory {
static create(mode: BacktestMode, config?: any): ExecutionMode { static create(mode: BacktestMode, config?: any): ExecutionMode {
switch (mode) { switch (mode) {
case BacktestMode.LIVE: case BacktestMode.LIVE:
// TODO: Import and create LiveMode // TODO: Import and create LiveMode
throw new Error('LiveMode not implemented yet'); throw new Error('LiveMode not implemented yet');
case BacktestMode.EVENT_DRIVEN: case BacktestMode.EVENT_DRIVEN:
// TODO: Import and create EventBacktestMode // TODO: Import and create EventBacktestMode
throw new Error('EventBacktestMode not implemented yet'); throw new Error('EventBacktestMode not implemented yet');
case BacktestMode.VECTORIZED: case BacktestMode.VECTORIZED:
// TODO: Import and create VectorBacktestMode // TODO: Import and create VectorBacktestMode
throw new Error('VectorBacktestMode not implemented yet'); throw new Error('VectorBacktestMode not implemented yet');
case BacktestMode.HYBRID: case BacktestMode.HYBRID:
// TODO: Import and create HybridBacktestMode // TODO: Import and create HybridBacktestMode
throw new Error('HybridBacktestMode not implemented yet'); throw new Error('HybridBacktestMode not implemented yet');
default: default:
throw new Error(`Unknown mode: ${mode}`); throw new Error(`Unknown mode: ${mode}`);
} }
} }
} }

View file

@ -1,89 +1,89 @@
/** /**
* Strategy Service - Multi-mode strategy execution and backtesting * Strategy Service - Multi-mode strategy execution and backtesting
*/ */
import { getLogger } from '@stock-bot/logger'; import { serve } from '@hono/node-server';
import { loadEnvVariables } from '@stock-bot/config'; import { Hono } from 'hono';
import { Hono } from 'hono'; import { loadEnvVariables } from '@stock-bot/config';
import { serve } from '@hono/node-server'; import { getLogger } from '@stock-bot/logger';
// Load environment variables // Load environment variables
loadEnvVariables(); loadEnvVariables();
const app = new Hono(); const app = new Hono();
const logger = getLogger('strategy-service'); const logger = getLogger('strategy-service');
const PORT = parseInt(process.env.STRATEGY_SERVICE_PORT || '3004'); const PORT = parseInt(process.env.STRATEGY_SERVICE_PORT || '3004');
// Health check endpoint // Health check endpoint
app.get('/health', (c) => { app.get('/health', c => {
return c.json({ return c.json({
service: 'strategy-service', service: 'strategy-service',
status: 'healthy', status: 'healthy',
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
}); });
// Strategy execution endpoints // Strategy execution endpoints
app.post('/api/strategy/run', async (c) => { app.post('/api/strategy/run', async c => {
const body = await c.req.json(); const body = await c.req.json();
logger.info('Strategy run request', { logger.info('Strategy run request', {
strategy: body.strategy, strategy: body.strategy,
mode: body.mode mode: body.mode,
}); });
// TODO: Implement strategy execution // TODO: Implement strategy execution
return c.json({ return c.json({
message: 'Strategy execution endpoint - not implemented yet', message: 'Strategy execution endpoint - not implemented yet',
strategy: body.strategy, strategy: body.strategy,
mode: body.mode mode: body.mode,
}); });
}); });
// Backtesting endpoints // Backtesting endpoints
app.post('/api/backtest/event', async (c) => { app.post('/api/backtest/event', async c => {
const body = await c.req.json(); const body = await c.req.json();
logger.info('Event-driven backtest request', { strategy: body.strategy }); logger.info('Event-driven backtest request', { strategy: body.strategy });
// TODO: Implement event-driven backtesting // TODO: Implement event-driven backtesting
return c.json({ return c.json({
message: 'Event-driven backtest endpoint - not implemented yet' message: 'Event-driven backtest endpoint - not implemented yet',
}); });
}); });
app.post('/api/backtest/vector', async (c) => { app.post('/api/backtest/vector', async c => {
const body = await c.req.json(); const body = await c.req.json();
logger.info('Vectorized backtest request', { strategy: body.strategy }); logger.info('Vectorized backtest request', { strategy: body.strategy });
// TODO: Implement vectorized backtesting // TODO: Implement vectorized backtesting
return c.json({ return c.json({
message: 'Vectorized backtest endpoint - not implemented yet' message: 'Vectorized backtest endpoint - not implemented yet',
}); });
}); });
app.post('/api/backtest/hybrid', async (c) => { app.post('/api/backtest/hybrid', async c => {
const body = await c.req.json(); const body = await c.req.json();
logger.info('Hybrid backtest request', { strategy: body.strategy }); logger.info('Hybrid backtest request', { strategy: body.strategy });
// TODO: Implement hybrid backtesting // TODO: Implement hybrid backtesting
return c.json({ return c.json({
message: 'Hybrid backtest endpoint - not implemented yet' message: 'Hybrid backtest endpoint - not implemented yet',
}); });
}); });
// Parameter optimization endpoint // Parameter optimization endpoint
app.post('/api/optimize', async (c) => { app.post('/api/optimize', async c => {
const body = await c.req.json(); const body = await c.req.json();
logger.info('Parameter optimization request', { strategy: body.strategy }); logger.info('Parameter optimization request', { strategy: body.strategy });
// TODO: Implement parameter optimization // TODO: Implement parameter optimization
return c.json({ return c.json({
message: 'Parameter optimization endpoint - not implemented yet' message: 'Parameter optimization endpoint - not implemented yet',
}); });
}); });
// Start server // Start server
serve({ serve({
fetch: app.fetch, fetch: app.fetch,
port: PORT, port: PORT,
}); });
logger.info(`Strategy Service started on port ${PORT}`); logger.info(`Strategy Service started on port ${PORT}`);

View file

@ -8,6 +8,7 @@
"ioredis": "^5.6.1", "ioredis": "^5.6.1",
}, },
"devDependencies": { "devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.4.2",
"@testcontainers/mongodb": "^10.7.2", "@testcontainers/mongodb": "^10.7.2",
"@testcontainers/postgresql": "^10.7.2", "@testcontainers/postgresql": "^10.7.2",
"@types/bun": "latest", "@types/bun": "latest",
@ -17,6 +18,7 @@
"bun-types": "^1.2.15", "bun-types": "^1.2.15",
"mongodb-memory-server": "^9.1.6", "mongodb-memory-server": "^9.1.6",
"pg-mem": "^2.8.1", "pg-mem": "^2.8.1",
"prettier": "^3.5.3",
"supertest": "^6.3.4", "supertest": "^6.3.4",
"turbo": "^2.5.4", "turbo": "^2.5.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",
@ -533,6 +535,8 @@
"@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
"@ianvs/prettier-plugin-sort-imports": ["@ianvs/prettier-plugin-sort-imports@4.4.2", "", { "dependencies": { "@babel/generator": "^7.26.2", "@babel/parser": "^7.26.2", "@babel/traverse": "^7.25.9", "@babel/types": "^7.26.0", "semver": "^7.5.2" }, "peerDependencies": { "@vue/compiler-sfc": "2.7.x || 3.x", "prettier": "2 || 3 || ^4.0.0-0" }, "optionalPeers": ["@vue/compiler-sfc"] }, "sha512-KkVFy3TLh0OFzimbZglMmORi+vL/i2OFhEs5M07R9w0IwWAGpsNNyE4CY/2u0YoMF5bawKC2+8/fUH60nnNtjw=="],
"@inquirer/checkbox": ["@inquirer/checkbox@4.1.8", "", { "dependencies": { "@inquirer/core": "^10.1.13", "@inquirer/figures": "^1.0.12", "@inquirer/type": "^3.0.7", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-d/QAsnwuHX2OPolxvYcgSj7A9DO9H6gVOy2DvBTx+P2LH2iRTo/RSGV3iwCzW024nP9hw98KIuDmdyhZQj1UQg=="], "@inquirer/checkbox": ["@inquirer/checkbox@4.1.8", "", { "dependencies": { "@inquirer/core": "^10.1.13", "@inquirer/figures": "^1.0.12", "@inquirer/type": "^3.0.7", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-d/QAsnwuHX2OPolxvYcgSj7A9DO9H6gVOy2DvBTx+P2LH2iRTo/RSGV3iwCzW024nP9hw98KIuDmdyhZQj1UQg=="],
"@inquirer/confirm": ["@inquirer/confirm@5.1.10", "", { "dependencies": { "@inquirer/core": "^10.1.11", "@inquirer/type": "^3.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-FxbQ9giWxUWKUk2O5XZ6PduVnH2CZ/fmMKMBkH71MHJvWr7WL5AHKevhzF1L5uYWB2P548o1RzVxrNd3dpmk6g=="], "@inquirer/confirm": ["@inquirer/confirm@5.1.10", "", { "dependencies": { "@inquirer/core": "^10.1.11", "@inquirer/type": "^3.0.6" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-FxbQ9giWxUWKUk2O5XZ6PduVnH2CZ/fmMKMBkH71MHJvWr7WL5AHKevhzF1L5uYWB2P548o1RzVxrNd3dpmk6g=="],
@ -1775,6 +1779,8 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="],
"proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], "proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="],
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],

View file

@ -1,6 +1,6 @@
import Redis from 'ioredis'; import Redis from 'ioredis';
import { getLogger } from '@stock-bot/logger';
import { dragonflyConfig } from '@stock-bot/config'; import { dragonflyConfig } from '@stock-bot/config';
import { getLogger } from '@stock-bot/logger';
interface ConnectionConfig { interface ConnectionConfig {
name: string; name: string;
@ -33,7 +33,7 @@ export class RedisConnectionManager {
*/ */
getConnection(config: ConnectionConfig): Redis { getConnection(config: ConnectionConfig): Redis {
const { name, singleton = false, db } = config; const { name, singleton = false, db } = config;
if (singleton) { if (singleton) {
// Use shared connection across all instances // Use shared connection across all instances
if (!RedisConnectionManager.sharedConnections.has(name)) { if (!RedisConnectionManager.sharedConnections.has(name)) {
@ -66,7 +66,9 @@ export class RedisConnectionManager {
retryDelayOnFailover: dragonflyConfig.DRAGONFLY_RETRY_DELAY, retryDelayOnFailover: dragonflyConfig.DRAGONFLY_RETRY_DELAY,
connectTimeout: dragonflyConfig.DRAGONFLY_CONNECT_TIMEOUT, connectTimeout: dragonflyConfig.DRAGONFLY_CONNECT_TIMEOUT,
commandTimeout: dragonflyConfig.DRAGONFLY_COMMAND_TIMEOUT, commandTimeout: dragonflyConfig.DRAGONFLY_COMMAND_TIMEOUT,
keepAlive: dragonflyConfig.DRAGONFLY_ENABLE_KEEPALIVE ? dragonflyConfig.DRAGONFLY_KEEPALIVE_INTERVAL * 1000 : 0, keepAlive: dragonflyConfig.DRAGONFLY_ENABLE_KEEPALIVE
? dragonflyConfig.DRAGONFLY_KEEPALIVE_INTERVAL * 1000
: 0,
connectionName: name, connectionName: name,
lazyConnect: false, // Connect immediately instead of waiting for first command lazyConnect: false, // Connect immediately instead of waiting for first command
...(dragonflyConfig.DRAGONFLY_TLS && { ...(dragonflyConfig.DRAGONFLY_TLS && {
@ -90,7 +92,7 @@ export class RedisConnectionManager {
this.logger.info(`Redis connection ready: ${name}`); this.logger.info(`Redis connection ready: ${name}`);
}); });
redis.on('error', (err) => { redis.on('error', err => {
this.logger.error(`Redis connection error for ${name}:`, err); this.logger.error(`Redis connection error for ${name}:`, err);
}); });
@ -121,7 +123,7 @@ export class RedisConnectionManager {
*/ */
async closeAllConnections(): Promise<void> { async closeAllConnections(): Promise<void> {
// Close instance-specific connections // Close instance-specific connections
const instancePromises = Array.from(this.connections.values()).map(conn => const instancePromises = Array.from(this.connections.values()).map(conn =>
this.closeConnection(conn) this.closeConnection(conn)
); );
await Promise.all(instancePromises); await Promise.all(instancePromises);
@ -129,8 +131,8 @@ export class RedisConnectionManager {
// Close shared connections (only if this is the last instance) // Close shared connections (only if this is the last instance)
if (RedisConnectionManager.instance === this) { if (RedisConnectionManager.instance === this) {
const sharedPromises = Array.from(RedisConnectionManager.sharedConnections.values()).map(conn => const sharedPromises = Array.from(RedisConnectionManager.sharedConnections.values()).map(
this.closeConnection(conn) conn => this.closeConnection(conn)
); );
await Promise.all(sharedPromises); await Promise.all(sharedPromises);
RedisConnectionManager.sharedConnections.clear(); RedisConnectionManager.sharedConnections.clear();
@ -145,7 +147,7 @@ export class RedisConnectionManager {
getConnectionCount(): { shared: number; unique: number } { getConnectionCount(): { shared: number; unique: number } {
return { return {
shared: RedisConnectionManager.sharedConnections.size, shared: RedisConnectionManager.sharedConnections.size,
unique: this.connections.size unique: this.connections.size,
}; };
} }
@ -155,7 +157,7 @@ export class RedisConnectionManager {
getConnectionNames(): { shared: string[]; unique: string[] } { getConnectionNames(): { shared: string[]; unique: string[] } {
return { return {
shared: Array.from(RedisConnectionManager.sharedConnections.keys()), shared: Array.from(RedisConnectionManager.sharedConnections.keys()),
unique: Array.from(this.connections.keys()) unique: Array.from(this.connections.keys()),
}; };
} }
@ -198,10 +200,7 @@ export class RedisConnectionManager {
*/ */
static async waitForAllConnections(timeout: number = 30000): Promise<void> { static async waitForAllConnections(timeout: number = 30000): Promise<void> {
const instance = this.getInstance(); const instance = this.getInstance();
const allConnections = new Map([ const allConnections = new Map([...instance.connections, ...this.sharedConnections]);
...instance.connections,
...this.sharedConnections
]);
if (allConnections.size === 0) { if (allConnections.size === 0) {
instance.logger.info('No Redis connections to wait for'); instance.logger.info('No Redis connections to wait for');
@ -210,7 +209,7 @@ export class RedisConnectionManager {
instance.logger.info(`Waiting for ${allConnections.size} Redis connections to be ready...`); instance.logger.info(`Waiting for ${allConnections.size} Redis connections to be ready...`);
const connectionPromises = Array.from(allConnections.entries()).map(([name, redis]) => const connectionPromises = Array.from(allConnections.entries()).map(([name, redis]) =>
instance.waitForConnection(redis, name, timeout) instance.waitForConnection(redis, name, timeout)
); );
@ -259,15 +258,12 @@ export class RedisConnectionManager {
*/ */
static areAllConnectionsReady(): boolean { static areAllConnectionsReady(): boolean {
const instance = this.getInstance(); const instance = this.getInstance();
const allConnections = new Map([ const allConnections = new Map([...instance.connections, ...this.sharedConnections]);
...instance.connections,
...this.sharedConnections return (
]); allConnections.size > 0 &&
Array.from(allConnections.keys()).every(name => this.readyConnections.has(name))
return allConnections.size > 0 && );
Array.from(allConnections.keys()).every(name =>
this.readyConnections.has(name)
);
} }
} }

View file

@ -1,92 +1,91 @@
import { RedisCache } from './redis-cache'; import { RedisConnectionManager } from './connection-manager';
import { RedisConnectionManager } from './connection-manager'; import { RedisCache } from './redis-cache';
import type { CacheProvider, CacheOptions } from './types'; import type { CacheOptions, CacheProvider } from './types';
// Cache instances registry to prevent multiple instances with same prefix // Cache instances registry to prevent multiple instances with same prefix
const cacheInstances = new Map<string, CacheProvider>(); const cacheInstances = new Map<string, CacheProvider>();
/** /**
* Create a Redis cache instance with trading-optimized defaults * Create a Redis cache instance with trading-optimized defaults
*/ */
export function createCache(options: Partial<CacheOptions> = {}): CacheProvider { export function createCache(options: Partial<CacheOptions> = {}): CacheProvider {
const defaultOptions: CacheOptions = { const defaultOptions: CacheOptions = {
keyPrefix: 'cache:', keyPrefix: 'cache:',
ttl: 3600, // 1 hour default ttl: 3600, // 1 hour default
enableMetrics: true, enableMetrics: true,
shared: true, // Default to shared connections shared: true, // Default to shared connections
...options ...options,
}; };
// For shared connections, reuse cache instances with the same key prefix // For shared connections, reuse cache instances with the same key prefix
if (defaultOptions.shared) { if (defaultOptions.shared) {
const cacheKey = `${defaultOptions.keyPrefix}-${defaultOptions.ttl}`; const cacheKey = `${defaultOptions.keyPrefix}-${defaultOptions.ttl}`;
if (cacheInstances.has(cacheKey)) { if (cacheInstances.has(cacheKey)) {
return cacheInstances.get(cacheKey)!; return cacheInstances.get(cacheKey)!;
} }
const cache = new RedisCache(defaultOptions); const cache = new RedisCache(defaultOptions);
cacheInstances.set(cacheKey, cache); cacheInstances.set(cacheKey, cache);
return cache; return cache;
} }
// For non-shared connections, always create new instances // For non-shared connections, always create new instances
return new RedisCache(defaultOptions); return new RedisCache(defaultOptions);
} }
/** /**
* Create a cache instance for trading data * Create a cache instance for trading data
*/ */
export function createTradingCache(options: Partial<CacheOptions> = {}): CacheProvider { export function createTradingCache(options: Partial<CacheOptions> = {}): CacheProvider {
return createCache({ return createCache({
keyPrefix: 'trading:', keyPrefix: 'trading:',
ttl: 3600, // 1 hour default ttl: 3600, // 1 hour default
enableMetrics: true, enableMetrics: true,
shared: true, shared: true,
...options ...options,
}); });
} }
/** /**
* Create a cache for market data with shorter TTL * Create a cache for market data with shorter TTL
*/ */
export function createMarketDataCache(options: Partial<CacheOptions> = {}): CacheProvider { export function createMarketDataCache(options: Partial<CacheOptions> = {}): CacheProvider {
return createCache({ return createCache({
keyPrefix: 'market:', keyPrefix: 'market:',
ttl: 300, // 5 minutes for market data ttl: 300, // 5 minutes for market data
enableMetrics: true, enableMetrics: true,
shared: true, shared: true,
...options ...options,
}); });
} }
/** /**
* Create a cache for indicators with longer TTL * Create a cache for indicators with longer TTL
*/ */
export function createIndicatorCache(options: Partial<CacheOptions> = {}): CacheProvider { export function createIndicatorCache(options: Partial<CacheOptions> = {}): CacheProvider {
return createCache({ return createCache({
keyPrefix: 'indicators:', keyPrefix: 'indicators:',
ttl: 1800, // 30 minutes for indicators ttl: 1800, // 30 minutes for indicators
enableMetrics: true, enableMetrics: true,
shared: true, shared: true,
...options ...options,
}); });
} }
// Export types and classes // Export types and classes
export type { export type {
CacheProvider, CacheProvider,
CacheOptions, CacheOptions,
CacheConfig, CacheConfig,
CacheStats, CacheStats,
CacheKey, CacheKey,
SerializationOptions SerializationOptions,
} from './types'; } from './types';
export { RedisCache } from './redis-cache'; export { RedisCache } from './redis-cache';
export { RedisConnectionManager } from './connection-manager'; export { RedisConnectionManager } from './connection-manager';
export { CacheKeyGenerator } from './key-generator'; export { CacheKeyGenerator } from './key-generator';
// Default export for convenience
// Default export for convenience export default createCache;
export default createCache;

View file

@ -1,73 +1,73 @@
export class CacheKeyGenerator { export class CacheKeyGenerator {
/** /**
* Generate cache key for market data * Generate cache key for market data
*/ */
static marketData(symbol: string, timeframe: string, date?: Date): string { static marketData(symbol: string, timeframe: string, date?: Date): string {
const dateStr = date ? date.toISOString().split('T')[0] : 'latest'; const dateStr = date ? date.toISOString().split('T')[0] : 'latest';
return `market:${symbol.toLowerCase()}:${timeframe}:${dateStr}`; return `market:${symbol.toLowerCase()}:${timeframe}:${dateStr}`;
} }
/** /**
* Generate cache key for technical indicators * Generate cache key for technical indicators
*/ */
static indicator(symbol: string, indicator: string, period: number, dataHash: string): string { static indicator(symbol: string, indicator: string, period: number, dataHash: string): string {
return `indicator:${symbol.toLowerCase()}:${indicator}:${period}:${dataHash}`; return `indicator:${symbol.toLowerCase()}:${indicator}:${period}:${dataHash}`;
} }
/** /**
* Generate cache key for backtest results * Generate cache key for backtest results
*/ */
static backtest(strategyName: string, params: Record<string, any>): string { static backtest(strategyName: string, params: Record<string, any>): string {
const paramHash = this.hashObject(params); const paramHash = this.hashObject(params);
return `backtest:${strategyName}:${paramHash}`; return `backtest:${strategyName}:${paramHash}`;
} }
/** /**
* Generate cache key for strategy results * Generate cache key for strategy results
*/ */
static strategy(strategyName: string, symbol: string, timeframe: string): string { static strategy(strategyName: string, symbol: string, timeframe: string): string {
return `strategy:${strategyName}:${symbol.toLowerCase()}:${timeframe}`; return `strategy:${strategyName}:${symbol.toLowerCase()}:${timeframe}`;
} }
/** /**
* Generate cache key for user sessions * Generate cache key for user sessions
*/ */
static userSession(userId: string): string { static userSession(userId: string): string {
return `session:${userId}`; return `session:${userId}`;
} }
/** /**
* Generate cache key for portfolio data * Generate cache key for portfolio data
*/ */
static portfolio(userId: string, portfolioId: string): string { static portfolio(userId: string, portfolioId: string): string {
return `portfolio:${userId}:${portfolioId}`; return `portfolio:${userId}:${portfolioId}`;
} }
/** /**
* Generate cache key for real-time prices * Generate cache key for real-time prices
*/ */
static realtimePrice(symbol: string): string { static realtimePrice(symbol: string): string {
return `price:realtime:${symbol.toLowerCase()}`; return `price:realtime:${symbol.toLowerCase()}`;
} }
/** /**
* Generate cache key for order book data * Generate cache key for order book data
*/ */
static orderBook(symbol: string, depth: number = 10): string { static orderBook(symbol: string, depth: number = 10): string {
return `orderbook:${symbol.toLowerCase()}:${depth}`; return `orderbook:${symbol.toLowerCase()}:${depth}`;
} }
/** /**
* Create a simple hash from object for cache keys * Create a simple hash from object for cache keys
*/ */
private static hashObject(obj: Record<string, any>): string { private static hashObject(obj: Record<string, any>): string {
const str = JSON.stringify(obj, Object.keys(obj).sort()); const str = JSON.stringify(obj, Object.keys(obj).sort());
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i); const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char; hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer hash = hash & hash; // Convert to 32-bit integer
} }
return Math.abs(hash).toString(36); return Math.abs(hash).toString(36);
} }
} }

View file

@ -1,7 +1,7 @@
import Redis from 'ioredis'; import Redis from 'ioredis';
import { getLogger } from '@stock-bot/logger'; import { getLogger } from '@stock-bot/logger';
import { CacheProvider, CacheOptions, CacheStats } from './types';
import { RedisConnectionManager } from './connection-manager'; import { RedisConnectionManager } from './connection-manager';
import { CacheOptions, CacheProvider, CacheStats } from './types';
/** /**
* Simplified Redis-based cache provider using connection manager * Simplified Redis-based cache provider using connection manager
@ -15,27 +15,33 @@ export class RedisCache implements CacheProvider {
private isConnected = false; private isConnected = false;
private startTime = Date.now(); private startTime = Date.now();
private connectionManager: RedisConnectionManager; private connectionManager: RedisConnectionManager;
private stats: CacheStats = { private stats: CacheStats = {
hits: 0, hits: 0,
misses: 0, misses: 0,
errors: 0, errors: 0,
hitRate: 0, hitRate: 0,
total: 0, total: 0,
uptime: 0 uptime: 0,
}; };
constructor(options: CacheOptions = {}) { constructor(options: CacheOptions = {}) {
this.defaultTTL = options.ttl ?? 3600; // 1 hour default this.defaultTTL = options.ttl ?? 3600; // 1 hour default
this.keyPrefix = options.keyPrefix ?? 'cache:'; this.keyPrefix = options.keyPrefix ?? 'cache:';
this.enableMetrics = options.enableMetrics ?? true; this.enableMetrics = options.enableMetrics ?? true;
// Get connection manager instance // Get connection manager instance
this.connectionManager = RedisConnectionManager.getInstance(); this.connectionManager = RedisConnectionManager.getInstance();
// Generate connection name based on cache type // Generate connection name based on cache type
const baseName = options.name || this.keyPrefix.replace(':', '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase() || 'CACHE'; const baseName =
options.name ||
this.keyPrefix
.replace(':', '')
.replace(/[^a-zA-Z0-9]/g, '')
.toUpperCase() ||
'CACHE';
// Get Redis connection (shared by default for cache) // Get Redis connection (shared by default for cache)
this.redis = this.connectionManager.getConnection({ this.redis = this.connectionManager.getConnection({
name: `${baseName}-SERVICE`, name: `${baseName}-SERVICE`,
@ -110,7 +116,7 @@ export class RedisCache implements CacheProvider {
return await operation(); return await operation();
} catch (error) { } catch (error) {
this.logger.error(`Redis ${operationName} failed`, { this.logger.error(`Redis ${operationName} failed`, {
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error),
}); });
this.updateStats(false, true); this.updateStats(false, true);
return fallback; return fallback;
@ -122,7 +128,7 @@ export class RedisCache implements CacheProvider {
async () => { async () => {
const fullKey = this.getKey(key); const fullKey = this.getKey(key);
const value = await this.redis.get(fullKey); const value = await this.redis.get(fullKey);
if (value === null) { if (value === null) {
this.updateStats(false); this.updateStats(false);
this.logger.debug('Cache miss', { key }); this.logger.debug('Cache miss', { key });
@ -131,7 +137,7 @@ export class RedisCache implements CacheProvider {
this.updateStats(true); this.updateStats(true);
this.logger.debug('Cache hit', { key }); this.logger.debug('Cache hit', { key });
try { try {
return JSON.parse(value) as T; return JSON.parse(value) as T;
} catch { } catch {
@ -144,23 +150,29 @@ export class RedisCache implements CacheProvider {
); );
} }
async set<T>(key: string, value: T, options?: number | { async set<T>(
ttl?: number; key: string,
preserveTTL?: boolean; value: T,
onlyIfExists?: boolean; options?:
onlyIfNotExists?: boolean; | number
getOldValue?: boolean; | {
}): Promise<T | null> { ttl?: number;
preserveTTL?: boolean;
onlyIfExists?: boolean;
onlyIfNotExists?: boolean;
getOldValue?: boolean;
}
): Promise<T | null> {
return this.safeExecute( return this.safeExecute(
async () => { async () => {
const fullKey = this.getKey(key); const fullKey = this.getKey(key);
const serialized = typeof value === 'string' ? value : JSON.stringify(value); const serialized = typeof value === 'string' ? value : JSON.stringify(value);
// Handle backward compatibility - if options is a number, treat as TTL // Handle backward compatibility - if options is a number, treat as TTL
const config = typeof options === 'number' ? { ttl: options } : (options || {}); const config = typeof options === 'number' ? { ttl: options } : options || {};
let oldValue: T | null = null; let oldValue: T | null = null;
// Get old value if requested // Get old value if requested
if (config.getOldValue) { if (config.getOldValue) {
const existingValue = await this.redis.get(fullKey); const existingValue = await this.redis.get(fullKey);
@ -172,15 +184,17 @@ export class RedisCache implements CacheProvider {
} }
} }
} }
// Handle preserveTTL logic // Handle preserveTTL logic
if (config.preserveTTL) { if (config.preserveTTL) {
const currentTTL = await this.redis.ttl(fullKey); const currentTTL = await this.redis.ttl(fullKey);
if (currentTTL === -2) { if (currentTTL === -2) {
// Key doesn't exist // Key doesn't exist
if (config.onlyIfExists) { if (config.onlyIfExists) {
this.logger.debug('Set skipped - key does not exist and onlyIfExists is true', { key }); this.logger.debug('Set skipped - key does not exist and onlyIfExists is true', {
key,
});
return oldValue; return oldValue;
} }
// Set with default or specified TTL // Set with default or specified TTL
@ -201,7 +215,7 @@ export class RedisCache implements CacheProvider {
if (config.onlyIfExists && config.onlyIfNotExists) { if (config.onlyIfExists && config.onlyIfNotExists) {
throw new Error('Cannot specify both onlyIfExists and onlyIfNotExists'); throw new Error('Cannot specify both onlyIfExists and onlyIfNotExists');
} }
if (config.onlyIfExists) { if (config.onlyIfExists) {
// Only set if key exists (XX flag) // Only set if key exists (XX flag)
const ttl = config.ttl ?? this.defaultTTL; const ttl = config.ttl ?? this.defaultTTL;
@ -223,10 +237,10 @@ export class RedisCache implements CacheProvider {
const ttl = config.ttl ?? this.defaultTTL; const ttl = config.ttl ?? this.defaultTTL;
await this.redis.setex(fullKey, ttl, serialized); await this.redis.setex(fullKey, ttl, serialized);
} }
this.logger.debug('Cache set', { key, ttl: config.ttl ?? this.defaultTTL }); this.logger.debug('Cache set', { key, ttl: config.ttl ?? this.defaultTTL });
} }
return oldValue; return oldValue;
}, },
null, null,
@ -278,8 +292,8 @@ export class RedisCache implements CacheProvider {
const pong = await this.redis.ping(); const pong = await this.redis.ping();
return pong === 'PONG'; return pong === 'PONG';
} catch (error) { } catch (error) {
this.logger.error('Redis health check failed', { this.logger.error('Redis health check failed', {
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error),
}); });
return false; return false;
} }
@ -288,7 +302,7 @@ export class RedisCache implements CacheProvider {
getStats(): CacheStats { getStats(): CacheStats {
return { return {
...this.stats, ...this.stats,
uptime: Date.now() - this.startTime uptime: Date.now() - this.startTime,
}; };
} }
@ -308,7 +322,7 @@ export class RedisCache implements CacheProvider {
resolve(); resolve();
}); });
this.redis.once('error', (error) => { this.redis.once('error', error => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
reject(error); reject(error);
}); });
@ -318,12 +332,12 @@ export class RedisCache implements CacheProvider {
isReady(): boolean { isReady(): boolean {
// Always check the actual Redis connection status // Always check the actual Redis connection status
const ready = this.redis.status === 'ready'; const ready = this.redis.status === 'ready';
// Update local flag if we're not using shared connection // Update local flag if we're not using shared connection
if (this.isConnected !== ready) { if (this.isConnected !== ready) {
this.isConnected = ready; this.isConnected = ready;
} }
return ready; return ready;
} }
@ -334,7 +348,7 @@ export class RedisCache implements CacheProvider {
async setIfExists<T>(key: string, value: T, ttl?: number): Promise<boolean> { async setIfExists<T>(key: string, value: T, ttl?: number): Promise<boolean> {
const result = await this.set(key, value, { ttl, onlyIfExists: true }); const result = await this.set(key, value, { ttl, onlyIfExists: true });
return result !== null || await this.exists(key); return result !== null || (await this.exists(key));
} }
async setIfNotExists<T>(key: string, value: T, ttl?: number): Promise<boolean> { async setIfNotExists<T>(key: string, value: T, ttl?: number): Promise<boolean> {
@ -347,11 +361,15 @@ export class RedisCache implements CacheProvider {
} }
// Atomic update with transformation // Atomic update with transformation
async updateField<T>(key: string, updater: (current: T | null) => T, ttl?: number): Promise<T | null> { async updateField<T>(
key: string,
updater: (current: T | null) => T,
ttl?: number
): Promise<T | null> {
return this.safeExecute( return this.safeExecute(
async () => { async () => {
const fullKey = this.getKey(key); const fullKey = this.getKey(key);
// Use Lua script for atomic read-modify-write // Use Lua script for atomic read-modify-write
const luaScript = ` const luaScript = `
local key = KEYS[1] local key = KEYS[1]
@ -363,13 +381,12 @@ export class RedisCache implements CacheProvider {
-- Return current value for processing -- Return current value for processing
return {current_value, current_ttl} return {current_value, current_ttl}
`; `;
const [currentValue, currentTTL] = await this.redis.eval( const [currentValue, currentTTL] = (await this.redis.eval(luaScript, 1, fullKey)) as [
luaScript, string | null,
1, number,
fullKey ];
) as [string | null, number];
// Parse current value // Parse current value
let parsed: T | null = null; let parsed: T | null = null;
if (currentValue !== null) { if (currentValue !== null) {
@ -379,10 +396,10 @@ export class RedisCache implements CacheProvider {
parsed = currentValue as unknown as T; parsed = currentValue as unknown as T;
} }
} }
// Apply updater function // Apply updater function
const newValue = updater(parsed); const newValue = updater(parsed);
// Set the new value with appropriate TTL logic // Set the new value with appropriate TTL logic
if (ttl !== undefined) { if (ttl !== undefined) {
// Use specified TTL // Use specified TTL
@ -394,7 +411,7 @@ export class RedisCache implements CacheProvider {
// Preserve existing TTL // Preserve existing TTL
await this.set(key, newValue, { preserveTTL: true }); await this.set(key, newValue, { preserveTTL: true });
} }
return parsed; return parsed;
}, },
null, null,

View file

@ -1,84 +1,90 @@
export interface CacheProvider { export interface CacheProvider {
get<T>(key: string): Promise<T | null>; get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, options?: number | { set<T>(
ttl?: number; key: string,
preserveTTL?: boolean; value: T,
onlyIfExists?: boolean; options?:
onlyIfNotExists?: boolean; | number
getOldValue?: boolean; | {
}): Promise<T | null>; ttl?: number;
del(key: string): Promise<void>; preserveTTL?: boolean;
exists(key: string): Promise<boolean>; onlyIfExists?: boolean;
clear(): Promise<void>; onlyIfNotExists?: boolean;
getStats(): CacheStats; getOldValue?: boolean;
health(): Promise<boolean>; }
): Promise<T | null>;
/** del(key: string): Promise<void>;
* Wait for the cache to be ready and connected exists(key: string): Promise<boolean>;
* @param timeout Maximum time to wait in milliseconds (default: 5000) clear(): Promise<void>;
* @returns Promise that resolves when cache is ready getStats(): CacheStats;
*/ health(): Promise<boolean>;
waitForReady(timeout?: number): Promise<void>;
/**
/** * Wait for the cache to be ready and connected
* Check if the cache is currently ready * @param timeout Maximum time to wait in milliseconds (default: 5000)
*/ * @returns Promise that resolves when cache is ready
isReady(): boolean; */
waitForReady(timeout?: number): Promise<void>;
// Enhanced cache methods
/** /**
* Update value preserving existing TTL * Check if the cache is currently ready
*/ */
update?<T>(key: string, value: T): Promise<T | null>; isReady(): boolean;
/** // Enhanced cache methods
* Set value only if key exists /**
*/ * Update value preserving existing TTL
setIfExists?<T>(key: string, value: T, ttl?: number): Promise<boolean>; */
update?<T>(key: string, value: T): Promise<T | null>;
/**
* Set value only if key doesn't exist /**
*/ * Set value only if key exists
setIfNotExists?<T>(key: string, value: T, ttl?: number): Promise<boolean>; */
setIfExists?<T>(key: string, value: T, ttl?: number): Promise<boolean>;
/**
* Replace existing key's value and TTL /**
*/ * Set value only if key doesn't exist
replace?<T>(key: string, value: T, ttl?: number): Promise<T | null>; */
/** setIfNotExists?<T>(key: string, value: T, ttl?: number): Promise<boolean>;
* Atomically update field with transformation function
*/ /**
updateField?<T>(key: string, updater: (current: T | null) => T, ttl?: number): Promise<T | null>; * Replace existing key's value and TTL
} */
replace?<T>(key: string, value: T, ttl?: number): Promise<T | null>;
export interface CacheOptions { /**
ttl?: number; * Atomically update field with transformation function
keyPrefix?: string; */
enableMetrics?: boolean; updateField?<T>(key: string, updater: (current: T | null) => T, ttl?: number): Promise<T | null>;
name?: string; // Name for connection identification }
shared?: boolean; // Whether to use shared connection
} export interface CacheOptions {
ttl?: number;
export interface CacheStats { keyPrefix?: string;
hits: number; enableMetrics?: boolean;
misses: number; name?: string; // Name for connection identification
errors: number; shared?: boolean; // Whether to use shared connection
hitRate: number; }
total: number;
uptime: number; export interface CacheStats {
} hits: number;
misses: number;
export interface CacheConfig { errors: number;
type: 'redis'; hitRate: number;
keyPrefix?: string; total: number;
defaultTTL?: number; uptime: number;
enableMetrics?: boolean; }
compression?: boolean;
} export interface CacheConfig {
type: 'redis';
export type CacheKey = string | (() => string); keyPrefix?: string;
defaultTTL?: number;
export interface SerializationOptions { enableMetrics?: boolean;
compress?: boolean; compression?: boolean;
binary?: boolean; }
}
export type CacheKey = string | (() => string);
export interface SerializationOptions {
compress?: boolean;
binary?: boolean;
}

View file

@ -1,111 +1,118 @@
/** /**
* Admin interfaces configuration using Yup * Admin interfaces configuration using Yup
* PgAdmin, Mongo Express, Redis Insight for database management * PgAdmin, Mongo Express, Redis Insight for database management
*/ */
import { cleanEnv, envValidators } from './env-utils'; import { cleanEnv, envValidators } from './env-utils';
const { str, port, bool, strWithChoices } = envValidators; const { str, port, bool, strWithChoices } = envValidators;
/** /**
* PgAdmin configuration with validation and defaults * PgAdmin configuration with validation and defaults
*/ */
export const pgAdminConfig = cleanEnv(process.env, { export const pgAdminConfig = cleanEnv(process.env, {
// PgAdmin Server // PgAdmin Server
PGADMIN_HOST: str('localhost', 'PgAdmin host'), PGADMIN_HOST: str('localhost', 'PgAdmin host'),
PGADMIN_PORT: port(8080, 'PgAdmin port'), PGADMIN_PORT: port(8080, 'PgAdmin port'),
// Authentication // Authentication
PGADMIN_DEFAULT_EMAIL: str('admin@tradingbot.local', 'PgAdmin default admin email'), PGADMIN_DEFAULT_EMAIL: str('admin@tradingbot.local', 'PgAdmin default admin email'),
PGADMIN_DEFAULT_PASSWORD: str('admin123', 'PgAdmin default admin password'), PGADMIN_DEFAULT_PASSWORD: str('admin123', 'PgAdmin default admin password'),
// Configuration // Configuration
PGADMIN_SERVER_MODE: bool(false, 'Enable server mode (multi-user)'), PGADMIN_SERVER_MODE: bool(false, 'Enable server mode (multi-user)'),
PGADMIN_DISABLE_POSTFIX: bool(true, 'Disable postfix for email'), PGADMIN_DISABLE_POSTFIX: bool(true, 'Disable postfix for email'),
PGADMIN_CONFIG_ENHANCED_COOKIE_PROTECTION: bool(true, 'Enhanced cookie protection'), PGADMIN_CONFIG_ENHANCED_COOKIE_PROTECTION: bool(true, 'Enhanced cookie protection'),
// Security // Security
PGADMIN_MASTER_PASSWORD_REQUIRED: bool(false, 'Require master password'), PGADMIN_MASTER_PASSWORD_REQUIRED: bool(false, 'Require master password'),
PGADMIN_SESSION_TIMEOUT: str('60', 'Session timeout in minutes'), PGADMIN_SESSION_TIMEOUT: str('60', 'Session timeout in minutes'),
}); });
/** /**
* Mongo Express configuration with validation and defaults * Mongo Express configuration with validation and defaults
*/ */
export const mongoExpressConfig = cleanEnv(process.env, { export const mongoExpressConfig = cleanEnv(process.env, {
// Mongo Express Server // Mongo Express Server
MONGO_EXPRESS_HOST: str('localhost', 'Mongo Express host'), MONGO_EXPRESS_HOST: str('localhost', 'Mongo Express host'),
MONGO_EXPRESS_PORT: port(8081, 'Mongo Express port'), MONGO_EXPRESS_PORT: port(8081, 'Mongo Express port'),
// MongoDB Connection // MongoDB Connection
MONGO_EXPRESS_MONGODB_SERVER: str('mongodb', 'MongoDB server name/host'), MONGO_EXPRESS_MONGODB_SERVER: str('mongodb', 'MongoDB server name/host'),
MONGO_EXPRESS_MONGODB_PORT: port(27017, 'MongoDB port'), MONGO_EXPRESS_MONGODB_PORT: port(27017, 'MongoDB port'),
MONGO_EXPRESS_MONGODB_ADMINUSERNAME: str('trading_admin', 'MongoDB admin username'), MONGO_EXPRESS_MONGODB_ADMINUSERNAME: str('trading_admin', 'MongoDB admin username'),
MONGO_EXPRESS_MONGODB_ADMINPASSWORD: str('', 'MongoDB admin password'), MONGO_EXPRESS_MONGODB_ADMINPASSWORD: str('', 'MongoDB admin password'),
// Basic Authentication for Mongo Express // Basic Authentication for Mongo Express
MONGO_EXPRESS_BASICAUTH_USERNAME: str('admin', 'Basic auth username for Mongo Express'), MONGO_EXPRESS_BASICAUTH_USERNAME: str('admin', 'Basic auth username for Mongo Express'),
MONGO_EXPRESS_BASICAUTH_PASSWORD: str('admin123', 'Basic auth password for Mongo Express'), MONGO_EXPRESS_BASICAUTH_PASSWORD: str('admin123', 'Basic auth password for Mongo Express'),
// Configuration // Configuration
MONGO_EXPRESS_ENABLE_ADMIN: bool(true, 'Enable admin features'), MONGO_EXPRESS_ENABLE_ADMIN: bool(true, 'Enable admin features'),
MONGO_EXPRESS_OPTIONS_EDITOR_THEME: str('rubyblue', 'Editor theme (rubyblue, 3024-night, etc.)'), MONGO_EXPRESS_OPTIONS_EDITOR_THEME: str('rubyblue', 'Editor theme (rubyblue, 3024-night, etc.)'),
MONGO_EXPRESS_REQUEST_SIZE: str('100kb', 'Maximum request size'), MONGO_EXPRESS_REQUEST_SIZE: str('100kb', 'Maximum request size'),
}); });
/** /**
* Redis Insight configuration with validation and defaults * Redis Insight configuration with validation and defaults
*/ */
export const redisInsightConfig = cleanEnv(process.env, { export const redisInsightConfig = cleanEnv(process.env, {
// Redis Insight Server // Redis Insight Server
REDIS_INSIGHT_HOST: str('localhost', 'Redis Insight host'), REDIS_INSIGHT_HOST: str('localhost', 'Redis Insight host'),
REDIS_INSIGHT_PORT: port(8001, 'Redis Insight port'), REDIS_INSIGHT_PORT: port(8001, 'Redis Insight port'),
// Redis Connection Settings // Redis Connection Settings
REDIS_INSIGHT_REDIS_HOSTS: str('local:dragonfly:6379', 'Redis hosts in format name:host:port,name:host:port'), REDIS_INSIGHT_REDIS_HOSTS: str(
'local:dragonfly:6379',
// Configuration 'Redis hosts in format name:host:port,name:host:port'
REDIS_INSIGHT_LOG_LEVEL: strWithChoices(['error', 'warn', 'info', 'verbose', 'debug'], 'info', 'Redis Insight log level'), ),
REDIS_INSIGHT_DISABLE_ANALYTICS: bool(true, 'Disable analytics collection'),
REDIS_INSIGHT_BUILD_TYPE: str('DOCKER', 'Build type identifier'), // Configuration
}); REDIS_INSIGHT_LOG_LEVEL: strWithChoices(
['error', 'warn', 'info', 'verbose', 'debug'],
// Export typed configuration objects 'info',
export type PgAdminConfig = typeof pgAdminConfig; 'Redis Insight log level'
export type MongoExpressConfig = typeof mongoExpressConfig; ),
export type RedisInsightConfig = typeof redisInsightConfig; REDIS_INSIGHT_DISABLE_ANALYTICS: bool(true, 'Disable analytics collection'),
REDIS_INSIGHT_BUILD_TYPE: str('DOCKER', 'Build type identifier'),
// Export individual config values for convenience });
export const {
PGADMIN_HOST, // Export typed configuration objects
PGADMIN_PORT, export type PgAdminConfig = typeof pgAdminConfig;
PGADMIN_DEFAULT_EMAIL, export type MongoExpressConfig = typeof mongoExpressConfig;
PGADMIN_DEFAULT_PASSWORD, export type RedisInsightConfig = typeof redisInsightConfig;
PGADMIN_SERVER_MODE,
PGADMIN_DISABLE_POSTFIX, // Export individual config values for convenience
PGADMIN_CONFIG_ENHANCED_COOKIE_PROTECTION, export const {
PGADMIN_MASTER_PASSWORD_REQUIRED, PGADMIN_HOST,
PGADMIN_SESSION_TIMEOUT, PGADMIN_PORT,
} = pgAdminConfig; PGADMIN_DEFAULT_EMAIL,
PGADMIN_DEFAULT_PASSWORD,
export const { PGADMIN_SERVER_MODE,
MONGO_EXPRESS_HOST, PGADMIN_DISABLE_POSTFIX,
MONGO_EXPRESS_PORT, PGADMIN_CONFIG_ENHANCED_COOKIE_PROTECTION,
MONGO_EXPRESS_MONGODB_SERVER, PGADMIN_MASTER_PASSWORD_REQUIRED,
MONGO_EXPRESS_MONGODB_PORT, PGADMIN_SESSION_TIMEOUT,
MONGO_EXPRESS_MONGODB_ADMINUSERNAME, } = pgAdminConfig;
MONGO_EXPRESS_MONGODB_ADMINPASSWORD,
MONGO_EXPRESS_BASICAUTH_USERNAME, export const {
MONGO_EXPRESS_BASICAUTH_PASSWORD, MONGO_EXPRESS_HOST,
MONGO_EXPRESS_ENABLE_ADMIN, MONGO_EXPRESS_PORT,
MONGO_EXPRESS_OPTIONS_EDITOR_THEME, MONGO_EXPRESS_MONGODB_SERVER,
MONGO_EXPRESS_REQUEST_SIZE, MONGO_EXPRESS_MONGODB_PORT,
} = mongoExpressConfig; MONGO_EXPRESS_MONGODB_ADMINUSERNAME,
MONGO_EXPRESS_MONGODB_ADMINPASSWORD,
export const { MONGO_EXPRESS_BASICAUTH_USERNAME,
REDIS_INSIGHT_HOST, MONGO_EXPRESS_BASICAUTH_PASSWORD,
REDIS_INSIGHT_PORT, MONGO_EXPRESS_ENABLE_ADMIN,
REDIS_INSIGHT_REDIS_HOSTS, MONGO_EXPRESS_OPTIONS_EDITOR_THEME,
REDIS_INSIGHT_LOG_LEVEL, MONGO_EXPRESS_REQUEST_SIZE,
REDIS_INSIGHT_DISABLE_ANALYTICS, } = mongoExpressConfig;
REDIS_INSIGHT_BUILD_TYPE,
} = redisInsightConfig; export const {
REDIS_INSIGHT_HOST,
REDIS_INSIGHT_PORT,
REDIS_INSIGHT_REDIS_HOSTS,
REDIS_INSIGHT_LOG_LEVEL,
REDIS_INSIGHT_DISABLE_ANALYTICS,
REDIS_INSIGHT_BUILD_TYPE,
} = redisInsightConfig;

View file

@ -1,68 +1,63 @@
/** /**
* Core configuration module for the Stock Bot platform using Yup * Core configuration module for the Stock Bot platform using Yup
*/ */
import { config as dotenvConfig } from 'dotenv'; import path from 'node:path';
import path from 'node:path'; import { config as dotenvConfig } from 'dotenv';
/** /**
* Represents an error related to configuration validation * Represents an error related to configuration validation
*/ */
export class ConfigurationError extends Error { export class ConfigurationError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
this.name = 'ConfigurationError'; this.name = 'ConfigurationError';
} }
} }
/** /**
* Environment types * Environment types
*/ */
export enum Environment { export enum Environment {
Development = 'development', Development = 'development',
Testing = 'testing', Testing = 'testing',
Staging = 'staging', Staging = 'staging',
Production = 'production' Production = 'production',
} }
/** /**
* Loads environment variables from .env files based on the current environment * Loads environment variables from .env files based on the current environment
*/ */
export function loadEnvVariables(envOverride?: string): void { export function loadEnvVariables(envOverride?: string): void {
const env = envOverride || process.env.NODE_ENV || 'development'; const env = envOverride || process.env.NODE_ENV || 'development';
console.log(`Current environment: ${env}`); console.log(`Current environment: ${env}`);
// Order of loading: // Order of loading:
// 1. .env (base environment variables) // 1. .env (base environment variables)
// 2. .env.{environment} (environment-specific variables) // 2. .env.{environment} (environment-specific variables)
// 3. .env.local (local overrides, not to be committed) // 3. .env.local (local overrides, not to be committed)
const envFiles = [ const envFiles = ['.env', `.env.${env}`, '.env.local'];
'.env',
`.env.${env}`, for (const file of envFiles) {
'.env.local' dotenvConfig({ path: path.resolve(process.cwd(), file) });
]; }
}
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';
* Gets the current environment from process.env.NODE_ENV switch (env) {
*/ case 'development':
export function getEnvironment(): Environment { return Environment.Development;
const env = process.env.NODE_ENV?.toLowerCase() || 'development'; case 'testing':
switch (env) { case 'test': // Handle both 'test' and 'testing' for compatibility
case 'development': return Environment.Testing;
return Environment.Development; case 'staging':
case 'testing': return Environment.Staging;
case 'test': // Handle both 'test' and 'testing' for compatibility case 'production':
return Environment.Testing; return Environment.Production;
case 'staging': default:
return Environment.Staging; return Environment.Development;
case 'production': }
return Environment.Production; }
default:
return Environment.Development;
}
}

View file

@ -1,184 +1,185 @@
/** /**
* Data provider configurations using Yup * Data provider configurations using Yup
*/ */
import { cleanEnv, envValidators } from './env-utils'; import { cleanEnv, envValidators } from './env-utils';
const { str, num, bool, strWithChoices } = envValidators; const { str, num, bool, strWithChoices } = envValidators;
export interface ProviderConfig { export interface ProviderConfig {
name: string; name: string;
type: 'rest' | 'websocket'; type: 'rest' | 'websocket';
enabled: boolean; enabled: boolean;
baseUrl?: string; baseUrl?: string;
apiKey?: string; apiKey?: string;
apiSecret?: string; apiSecret?: string;
rateLimits?: { rateLimits?: {
maxRequestsPerMinute?: number; maxRequestsPerMinute?: number;
maxRequestsPerSecond?: number; maxRequestsPerSecond?: number;
maxRequestsPerHour?: number; maxRequestsPerHour?: number;
}; };
} }
/** /**
* Data providers configuration with validation and defaults * Data providers configuration with validation and defaults
*/ */
export const dataProvidersConfig = cleanEnv(process.env, { export const dataProvidersConfig = cleanEnv(process.env, {
// Default Provider // Default Provider
DEFAULT_DATA_PROVIDER: strWithChoices(['alpaca', 'polygon', 'yahoo', 'iex'], 'alpaca', 'Default data provider'), DEFAULT_DATA_PROVIDER: strWithChoices(
['alpaca', 'polygon', 'yahoo', 'iex'],
// Alpaca Configuration 'alpaca',
ALPACA_API_KEY: str('', 'Alpaca API key'), 'Default data provider'
ALPACA_API_SECRET: str('', 'Alpaca API secret'), ),
ALPACA_BASE_URL: str('https://data.alpaca.markets/v1beta1', 'Alpaca base URL'),
ALPACA_RATE_LIMIT: num(200, 'Alpaca rate limit per minute'), // Alpaca Configuration
ALPACA_ENABLED: bool(true, 'Enable Alpaca provider'), ALPACA_API_KEY: str('', 'Alpaca API key'),
ALPACA_API_SECRET: str('', 'Alpaca API secret'),
// Polygon Configuration ALPACA_BASE_URL: str('https://data.alpaca.markets/v1beta1', 'Alpaca base URL'),
POLYGON_API_KEY: str('', 'Polygon API key'), ALPACA_RATE_LIMIT: num(200, 'Alpaca rate limit per minute'),
POLYGON_BASE_URL: str('https://api.polygon.io', 'Polygon base URL'), ALPACA_ENABLED: bool(true, 'Enable Alpaca provider'),
POLYGON_RATE_LIMIT: num(5, 'Polygon rate limit per minute'),
POLYGON_ENABLED: bool(false, 'Enable Polygon provider'), // Polygon Configuration
POLYGON_API_KEY: str('', 'Polygon API key'),
// Yahoo Finance Configuration POLYGON_BASE_URL: str('https://api.polygon.io', 'Polygon base URL'),
YAHOO_BASE_URL: str('https://query1.finance.yahoo.com', 'Yahoo Finance base URL'), POLYGON_RATE_LIMIT: num(5, 'Polygon rate limit per minute'),
YAHOO_RATE_LIMIT: num(2000, 'Yahoo Finance rate limit per hour'), POLYGON_ENABLED: bool(false, 'Enable Polygon provider'),
YAHOO_ENABLED: bool(true, 'Enable Yahoo Finance provider'),
// Yahoo Finance Configuration
// IEX Cloud Configuration YAHOO_BASE_URL: str('https://query1.finance.yahoo.com', 'Yahoo Finance base URL'),
IEX_API_KEY: str('', 'IEX Cloud API key'), YAHOO_RATE_LIMIT: num(2000, 'Yahoo Finance rate limit per hour'),
IEX_BASE_URL: str('https://cloud.iexapis.com/stable', 'IEX Cloud base URL'), YAHOO_ENABLED: bool(true, 'Enable Yahoo Finance provider'),
IEX_RATE_LIMIT: num(100, 'IEX Cloud rate limit per second'),
IEX_ENABLED: bool(false, 'Enable IEX Cloud provider'), // IEX Cloud Configuration
IEX_API_KEY: str('', 'IEX Cloud API key'),
// Connection Settings IEX_BASE_URL: str('https://cloud.iexapis.com/stable', 'IEX Cloud base URL'),
DATA_PROVIDER_TIMEOUT: num(30000, 'Request timeout in milliseconds'), IEX_RATE_LIMIT: num(100, 'IEX Cloud rate limit per second'),
DATA_PROVIDER_RETRIES: num(3, 'Number of retry attempts'), IEX_ENABLED: bool(false, 'Enable IEX Cloud provider'),
DATA_PROVIDER_RETRY_DELAY: num(1000, 'Retry delay in milliseconds'),
// Connection Settings
// Cache Settings DATA_PROVIDER_TIMEOUT: num(30000, 'Request timeout in milliseconds'),
DATA_CACHE_ENABLED: bool(true, 'Enable data caching'), DATA_PROVIDER_RETRIES: num(3, 'Number of retry attempts'),
DATA_CACHE_TTL: num(300000, 'Cache TTL in milliseconds'), DATA_PROVIDER_RETRY_DELAY: num(1000, 'Retry delay in milliseconds'),
DATA_CACHE_MAX_SIZE: num(1000, 'Maximum cache entries'),
}); // Cache Settings
DATA_CACHE_ENABLED: bool(true, 'Enable data caching'),
/** DATA_CACHE_TTL: num(300000, 'Cache TTL in milliseconds'),
* Helper function to get provider-specific configuration DATA_CACHE_MAX_SIZE: num(1000, 'Maximum cache entries'),
*/ });
export function getProviderConfig(providerName: string) {
// make a interface for the provider config /**
* Helper function to get provider-specific configuration
const name = providerName.toUpperCase(); */
export function getProviderConfig(providerName: string) {
switch (name) { // make a interface for the provider config
case 'ALPACA':
return { const name = providerName.toUpperCase();
name: 'alpaca',
type: 'rest' as const, switch (name) {
enabled: dataProvidersConfig.ALPACA_ENABLED, case 'ALPACA':
baseUrl: dataProvidersConfig.ALPACA_BASE_URL, return {
apiKey: dataProvidersConfig.ALPACA_API_KEY, name: 'alpaca',
apiSecret: dataProvidersConfig.ALPACA_API_SECRET, type: 'rest' as const,
rateLimits: { enabled: dataProvidersConfig.ALPACA_ENABLED,
maxRequestsPerMinute: dataProvidersConfig.ALPACA_RATE_LIMIT baseUrl: dataProvidersConfig.ALPACA_BASE_URL,
} apiKey: dataProvidersConfig.ALPACA_API_KEY,
}; apiSecret: dataProvidersConfig.ALPACA_API_SECRET,
rateLimits: {
case 'POLYGON': maxRequestsPerMinute: dataProvidersConfig.ALPACA_RATE_LIMIT,
return { },
name: 'polygon', };
type: 'rest' as const,
enabled: dataProvidersConfig.POLYGON_ENABLED, case 'POLYGON':
baseUrl: dataProvidersConfig.POLYGON_BASE_URL, return {
apiKey: dataProvidersConfig.POLYGON_API_KEY, name: 'polygon',
rateLimits: { type: 'rest' as const,
maxRequestsPerMinute: dataProvidersConfig.POLYGON_RATE_LIMIT enabled: dataProvidersConfig.POLYGON_ENABLED,
} baseUrl: dataProvidersConfig.POLYGON_BASE_URL,
}; apiKey: dataProvidersConfig.POLYGON_API_KEY,
rateLimits: {
case 'YAHOO': maxRequestsPerMinute: dataProvidersConfig.POLYGON_RATE_LIMIT,
return { },
name: 'yahoo', };
type: 'rest' as const,
enabled: dataProvidersConfig.YAHOO_ENABLED, case 'YAHOO':
baseUrl: dataProvidersConfig.YAHOO_BASE_URL, return {
rateLimits: { name: 'yahoo',
maxRequestsPerHour: dataProvidersConfig.YAHOO_RATE_LIMIT type: 'rest' as const,
} enabled: dataProvidersConfig.YAHOO_ENABLED,
}; baseUrl: dataProvidersConfig.YAHOO_BASE_URL,
rateLimits: {
case 'IEX': maxRequestsPerHour: dataProvidersConfig.YAHOO_RATE_LIMIT,
return { },
name: 'iex', };
type: 'rest' as const,
enabled: dataProvidersConfig.IEX_ENABLED, case 'IEX':
baseUrl: dataProvidersConfig.IEX_BASE_URL, return {
apiKey: dataProvidersConfig.IEX_API_KEY, name: 'iex',
rateLimits: { type: 'rest' as const,
maxRequestsPerSecond: dataProvidersConfig.IEX_RATE_LIMIT enabled: dataProvidersConfig.IEX_ENABLED,
} baseUrl: dataProvidersConfig.IEX_BASE_URL,
}; apiKey: dataProvidersConfig.IEX_API_KEY,
rateLimits: {
default: maxRequestsPerSecond: dataProvidersConfig.IEX_RATE_LIMIT,
throw new Error(`Unknown provider: ${providerName}`); },
} };
}
default:
/** throw new Error(`Unknown provider: ${providerName}`);
* Get all enabled providers }
*/ }
export function getEnabledProviders() {
const providers = ['alpaca', 'polygon', 'yahoo', 'iex']; /**
return providers * Get all enabled providers
.map(provider => getProviderConfig(provider)) */
.filter(config => config.enabled); export function getEnabledProviders() {
} const providers = ['alpaca', 'polygon', 'yahoo', 'iex'];
return providers.map(provider => getProviderConfig(provider)).filter(config => config.enabled);
/** }
* Get the default provider configuration
*/ /**
export function getDefaultProvider() { * Get the default provider configuration
return getProviderConfig(dataProvidersConfig.DEFAULT_DATA_PROVIDER); */
} export function getDefaultProvider() {
return getProviderConfig(dataProvidersConfig.DEFAULT_DATA_PROVIDER);
// Export typed configuration object }
export type DataProvidersConfig = typeof dataProvidersConfig;
export class DataProviders { // Export typed configuration object
static getProviderConfig(providerName: string): ProviderConfig { export type DataProvidersConfig = typeof dataProvidersConfig;
return getProviderConfig(providerName); export class DataProviders {
} static getProviderConfig(providerName: string): ProviderConfig {
return getProviderConfig(providerName);
static getEnabledProviders(): ProviderConfig[] { }
return getEnabledProviders();
} static getEnabledProviders(): ProviderConfig[] {
return getEnabledProviders();
static getDefaultProvider(): ProviderConfig { }
return getDefaultProvider();
} static getDefaultProvider(): ProviderConfig {
} return getDefaultProvider();
}
}
// Export individual config values for convenience
export const { // Export individual config values for convenience
DEFAULT_DATA_PROVIDER, export const {
ALPACA_API_KEY, DEFAULT_DATA_PROVIDER,
ALPACA_API_SECRET, ALPACA_API_KEY,
ALPACA_BASE_URL, ALPACA_API_SECRET,
ALPACA_RATE_LIMIT, ALPACA_BASE_URL,
ALPACA_ENABLED, ALPACA_RATE_LIMIT,
POLYGON_API_KEY, ALPACA_ENABLED,
POLYGON_BASE_URL, POLYGON_API_KEY,
POLYGON_RATE_LIMIT, POLYGON_BASE_URL,
POLYGON_ENABLED, POLYGON_RATE_LIMIT,
YAHOO_BASE_URL, POLYGON_ENABLED,
YAHOO_RATE_LIMIT, YAHOO_BASE_URL,
YAHOO_ENABLED, YAHOO_RATE_LIMIT,
IEX_API_KEY, YAHOO_ENABLED,
IEX_BASE_URL, IEX_API_KEY,
IEX_RATE_LIMIT, IEX_BASE_URL,
IEX_ENABLED, IEX_RATE_LIMIT,
DATA_PROVIDER_TIMEOUT, IEX_ENABLED,
DATA_PROVIDER_RETRIES, DATA_PROVIDER_TIMEOUT,
DATA_PROVIDER_RETRY_DELAY, DATA_PROVIDER_RETRIES,
DATA_CACHE_ENABLED, DATA_PROVIDER_RETRY_DELAY,
DATA_CACHE_TTL, DATA_CACHE_ENABLED,
DATA_CACHE_MAX_SIZE, DATA_CACHE_TTL,
} = dataProvidersConfig; DATA_CACHE_MAX_SIZE,
} = dataProvidersConfig;

View file

@ -1,56 +1,56 @@
/** /**
* Database configuration using Yup * Database configuration using Yup
*/ */
import { cleanEnv, envValidators } from './env-utils'; import { cleanEnv, envValidators } from './env-utils';
const { str, port, num, bool } = envValidators; const { str, port, num, bool } = envValidators;
/** /**
* Database configuration with validation and defaults * Database configuration with validation and defaults
*/ */
export const databaseConfig = cleanEnv(process.env, { export const databaseConfig = cleanEnv(process.env, {
// PostgreSQL Configuration // PostgreSQL Configuration
DB_HOST: str('localhost', 'Database host'), DB_HOST: str('localhost', 'Database host'),
DB_PORT: port(5432, 'Database port'), DB_PORT: port(5432, 'Database port'),
DB_NAME: str('stockbot', 'Database name'), DB_NAME: str('stockbot', 'Database name'),
DB_USER: str('stockbot', 'Database user'), DB_USER: str('stockbot', 'Database user'),
DB_PASSWORD: str('', 'Database password'), DB_PASSWORD: str('', 'Database password'),
// Connection Pool Settings // Connection Pool Settings
DB_POOL_MIN: num(2, 'Minimum pool connections'), DB_POOL_MIN: num(2, 'Minimum pool connections'),
DB_POOL_MAX: num(10, 'Maximum pool connections'), DB_POOL_MAX: num(10, 'Maximum pool connections'),
DB_POOL_IDLE_TIMEOUT: num(30000, 'Pool idle timeout in ms'), DB_POOL_IDLE_TIMEOUT: num(30000, 'Pool idle timeout in ms'),
// SSL Configuration // SSL Configuration
DB_SSL: bool(false, 'Enable SSL for database connection'), DB_SSL: bool(false, 'Enable SSL for database connection'),
DB_SSL_REJECT_UNAUTHORIZED: bool(true, 'Reject unauthorized SSL certificates'), DB_SSL_REJECT_UNAUTHORIZED: bool(true, 'Reject unauthorized SSL certificates'),
// Additional Settings // Additional Settings
DB_QUERY_TIMEOUT: num(30000, 'Query timeout in ms'), DB_QUERY_TIMEOUT: num(30000, 'Query timeout in ms'),
DB_CONNECTION_TIMEOUT: num(5000, 'Connection timeout in ms'), DB_CONNECTION_TIMEOUT: num(5000, 'Connection timeout in ms'),
DB_STATEMENT_TIMEOUT: num(30000, 'Statement timeout in ms'), DB_STATEMENT_TIMEOUT: num(30000, 'Statement timeout in ms'),
DB_LOCK_TIMEOUT: num(10000, 'Lock timeout in ms'), DB_LOCK_TIMEOUT: num(10000, 'Lock timeout in ms'),
DB_IDLE_IN_TRANSACTION_SESSION_TIMEOUT: num(60000, 'Idle in transaction timeout in ms'), DB_IDLE_IN_TRANSACTION_SESSION_TIMEOUT: num(60000, 'Idle in transaction timeout in ms'),
}); });
// Export typed configuration object // Export typed configuration object
export type DatabaseConfig = typeof databaseConfig; export type DatabaseConfig = typeof databaseConfig;
// Export individual config values for convenience // Export individual config values for convenience
export const { export const {
DB_HOST, DB_HOST,
DB_PORT, DB_PORT,
DB_NAME, DB_NAME,
DB_USER, DB_USER,
DB_PASSWORD, DB_PASSWORD,
DB_POOL_MIN, DB_POOL_MIN,
DB_POOL_MAX, DB_POOL_MAX,
DB_POOL_IDLE_TIMEOUT, DB_POOL_IDLE_TIMEOUT,
DB_SSL, DB_SSL,
DB_SSL_REJECT_UNAUTHORIZED, DB_SSL_REJECT_UNAUTHORIZED,
DB_QUERY_TIMEOUT, DB_QUERY_TIMEOUT,
DB_CONNECTION_TIMEOUT, DB_CONNECTION_TIMEOUT,
DB_STATEMENT_TIMEOUT, DB_STATEMENT_TIMEOUT,
DB_LOCK_TIMEOUT, DB_LOCK_TIMEOUT,
DB_IDLE_IN_TRANSACTION_SESSION_TIMEOUT, DB_IDLE_IN_TRANSACTION_SESSION_TIMEOUT,
} = databaseConfig; } = databaseConfig;

View file

@ -1,81 +1,81 @@
/** /**
* Dragonfly (Redis replacement) configuration using Yup * Dragonfly (Redis replacement) configuration using Yup
* High-performance caching and event streaming * High-performance caching and event streaming
*/ */
import { cleanEnv, envValidators } from './env-utils'; import { cleanEnv, envValidators } from './env-utils';
const { str, port, num, bool } = envValidators; const { str, port, num, bool } = envValidators;
/** /**
* Dragonfly configuration with validation and defaults * Dragonfly configuration with validation and defaults
*/ */
export const dragonflyConfig = cleanEnv(process.env, { export const dragonflyConfig = cleanEnv(process.env, {
// Dragonfly Connection // Dragonfly Connection
DRAGONFLY_HOST: str('localhost', 'Dragonfly host'), DRAGONFLY_HOST: str('localhost', 'Dragonfly host'),
DRAGONFLY_PORT: port(6379, 'Dragonfly port'), DRAGONFLY_PORT: port(6379, 'Dragonfly port'),
DRAGONFLY_PASSWORD: str('', 'Dragonfly password (if auth enabled)'), DRAGONFLY_PASSWORD: str('', 'Dragonfly password (if auth enabled)'),
DRAGONFLY_USERNAME: str('', 'Dragonfly username (if ACL enabled)'), DRAGONFLY_USERNAME: str('', 'Dragonfly username (if ACL enabled)'),
// Database Selection // Database Selection
DRAGONFLY_DATABASE: num(0, 'Dragonfly database number (0-15)'), DRAGONFLY_DATABASE: num(0, 'Dragonfly database number (0-15)'),
// Connection Pool Settings // Connection Pool Settings
DRAGONFLY_MAX_RETRIES: num(3, 'Maximum retry attempts'), DRAGONFLY_MAX_RETRIES: num(3, 'Maximum retry attempts'),
DRAGONFLY_RETRY_DELAY: num(50, 'Retry delay in ms'), DRAGONFLY_RETRY_DELAY: num(50, 'Retry delay in ms'),
DRAGONFLY_CONNECT_TIMEOUT: num(10000, 'Connection timeout in ms'), DRAGONFLY_CONNECT_TIMEOUT: num(10000, 'Connection timeout in ms'),
DRAGONFLY_COMMAND_TIMEOUT: num(5000, 'Command timeout in ms'), DRAGONFLY_COMMAND_TIMEOUT: num(5000, 'Command timeout in ms'),
// Pool Configuration // Pool Configuration
DRAGONFLY_POOL_SIZE: num(10, 'Connection pool size'), DRAGONFLY_POOL_SIZE: num(10, 'Connection pool size'),
DRAGONFLY_POOL_MIN: num(1, 'Minimum pool connections'), DRAGONFLY_POOL_MIN: num(1, 'Minimum pool connections'),
DRAGONFLY_POOL_MAX: num(20, 'Maximum pool connections'), DRAGONFLY_POOL_MAX: num(20, 'Maximum pool connections'),
// TLS Settings // TLS Settings
DRAGONFLY_TLS: bool(false, 'Enable TLS for Dragonfly connection'), DRAGONFLY_TLS: bool(false, 'Enable TLS for Dragonfly connection'),
DRAGONFLY_TLS_CERT_FILE: str('', 'Path to TLS certificate file'), DRAGONFLY_TLS_CERT_FILE: str('', 'Path to TLS certificate file'),
DRAGONFLY_TLS_KEY_FILE: str('', 'Path to TLS key file'), DRAGONFLY_TLS_KEY_FILE: str('', 'Path to TLS key file'),
DRAGONFLY_TLS_CA_FILE: str('', 'Path to TLS CA certificate file'), DRAGONFLY_TLS_CA_FILE: str('', 'Path to TLS CA certificate file'),
DRAGONFLY_TLS_SKIP_VERIFY: bool(false, 'Skip TLS certificate verification'), DRAGONFLY_TLS_SKIP_VERIFY: bool(false, 'Skip TLS certificate verification'),
// Performance Settings // Performance Settings
DRAGONFLY_ENABLE_KEEPALIVE: bool(true, 'Enable TCP keepalive'), DRAGONFLY_ENABLE_KEEPALIVE: bool(true, 'Enable TCP keepalive'),
DRAGONFLY_KEEPALIVE_INTERVAL: num(60, 'Keepalive interval in seconds'), DRAGONFLY_KEEPALIVE_INTERVAL: num(60, 'Keepalive interval in seconds'),
// Clustering (if using cluster mode) // Clustering (if using cluster mode)
DRAGONFLY_CLUSTER_MODE: bool(false, 'Enable cluster mode'), DRAGONFLY_CLUSTER_MODE: bool(false, 'Enable cluster mode'),
DRAGONFLY_CLUSTER_NODES: str('', 'Comma-separated list of cluster nodes (host:port)'), DRAGONFLY_CLUSTER_NODES: str('', 'Comma-separated list of cluster nodes (host:port)'),
// Memory and Cache Settings // Memory and Cache Settings
DRAGONFLY_MAX_MEMORY: str('2gb', 'Maximum memory usage'), DRAGONFLY_MAX_MEMORY: str('2gb', 'Maximum memory usage'),
DRAGONFLY_CACHE_MODE: bool(true, 'Enable cache mode'), DRAGONFLY_CACHE_MODE: bool(true, 'Enable cache mode'),
}); });
// Export typed configuration object // Export typed configuration object
export type DragonflyConfig = typeof dragonflyConfig; export type DragonflyConfig = typeof dragonflyConfig;
// Export individual config values for convenience // Export individual config values for convenience
export const { export const {
DRAGONFLY_HOST, DRAGONFLY_HOST,
DRAGONFLY_PORT, DRAGONFLY_PORT,
DRAGONFLY_PASSWORD, DRAGONFLY_PASSWORD,
DRAGONFLY_USERNAME, DRAGONFLY_USERNAME,
DRAGONFLY_DATABASE, DRAGONFLY_DATABASE,
DRAGONFLY_MAX_RETRIES, DRAGONFLY_MAX_RETRIES,
DRAGONFLY_RETRY_DELAY, DRAGONFLY_RETRY_DELAY,
DRAGONFLY_CONNECT_TIMEOUT, DRAGONFLY_CONNECT_TIMEOUT,
DRAGONFLY_COMMAND_TIMEOUT, DRAGONFLY_COMMAND_TIMEOUT,
DRAGONFLY_POOL_SIZE, DRAGONFLY_POOL_SIZE,
DRAGONFLY_POOL_MIN, DRAGONFLY_POOL_MIN,
DRAGONFLY_POOL_MAX, DRAGONFLY_POOL_MAX,
DRAGONFLY_TLS, DRAGONFLY_TLS,
DRAGONFLY_TLS_CERT_FILE, DRAGONFLY_TLS_CERT_FILE,
DRAGONFLY_TLS_KEY_FILE, DRAGONFLY_TLS_KEY_FILE,
DRAGONFLY_TLS_CA_FILE, DRAGONFLY_TLS_CA_FILE,
DRAGONFLY_TLS_SKIP_VERIFY, DRAGONFLY_TLS_SKIP_VERIFY,
DRAGONFLY_ENABLE_KEEPALIVE, DRAGONFLY_ENABLE_KEEPALIVE,
DRAGONFLY_KEEPALIVE_INTERVAL, DRAGONFLY_KEEPALIVE_INTERVAL,
DRAGONFLY_CLUSTER_MODE, DRAGONFLY_CLUSTER_MODE,
DRAGONFLY_CLUSTER_NODES, DRAGONFLY_CLUSTER_NODES,
DRAGONFLY_MAX_MEMORY, DRAGONFLY_MAX_MEMORY,
DRAGONFLY_CACHE_MODE, DRAGONFLY_CACHE_MODE,
} = dragonflyConfig; } = dragonflyConfig;

View file

@ -1,162 +1,165 @@
/** /**
* Environment validation utilities using Yup * Environment validation utilities using Yup
*/ */
import * as yup from 'yup'; import { existsSync } from 'fs';
import { config } from 'dotenv'; import { join } from 'path';
import { join } from 'path'; import { config } from 'dotenv';
import { existsSync } from 'fs'; import * as yup from 'yup';
// Function to find and load environment variables // Function to find and load environment variables
function loadEnvFiles() { function loadEnvFiles() {
const cwd = process.cwd(); const cwd = process.cwd();
const possiblePaths = [ const possiblePaths = [
// Current working directory // Current working directory
join(cwd, '.env'), join(cwd, '.env'),
join(cwd, '.env.local'), join(cwd, '.env.local'),
// Root of the workspace (common pattern) // Root of the workspace (common pattern)
join(cwd, '../../.env'), join(cwd, '../../.env'),
join(cwd, '../../../.env'), join(cwd, '../../../.env'),
// Config library directory // Config library directory
join(__dirname, '../.env'), join(__dirname, '../.env'),
join(__dirname, '../../.env'), join(__dirname, '../../.env'),
join(__dirname, '../../../.env'), join(__dirname, '../../../.env'),
]; ];
// Try to load each possible .env file // Try to load each possible .env file
for (const envPath of possiblePaths) { for (const envPath of possiblePaths) {
if (existsSync(envPath)) { if (existsSync(envPath)) {
console.log(`📄 Loading environment from: ${envPath}`); console.log(`📄 Loading environment from: ${envPath}`);
config({ path: envPath }); config({ path: envPath });
break; // Use the first .env file found break; // Use the first .env file found
} }
} }
// Also try to load environment-specific files // Also try to load environment-specific files
const environment = process.env.NODE_ENV || 'development'; const environment = process.env.NODE_ENV || 'development';
const envSpecificPaths = [ const envSpecificPaths = [
join(cwd, `.env.${environment}`), join(cwd, `.env.${environment}`),
join(cwd, `.env.${environment}.local`), join(cwd, `.env.${environment}.local`),
]; ];
for (const envPath of envSpecificPaths) { for (const envPath of envSpecificPaths) {
if (existsSync(envPath)) { if (existsSync(envPath)) {
console.log(`📄 Loading ${environment} environment from: ${envPath}`); console.log(`📄 Loading ${environment} environment from: ${envPath}`);
config({ path: envPath, override: false }); // Don't override existing vars config({ path: envPath, override: false }); // Don't override existing vars
} }
} }
} }
// Load environment variables // Load environment variables
loadEnvFiles(); loadEnvFiles();
/** /**
* Creates a Yup schema for environment variable validation * Creates a Yup schema for environment variable validation
*/ */
export function createEnvSchema(shape: Record<string, any>) { export function createEnvSchema(shape: Record<string, any>) {
return yup.object(shape); return yup.object(shape);
} }
/** /**
* Validates environment variables against a Yup schema * Validates environment variables against a Yup schema
*/ */
export function validateEnv( export function validateEnv(schema: yup.ObjectSchema<any>, env = process.env): any {
schema: yup.ObjectSchema<any>, try {
env = process.env const result = schema.validateSync(env, { abortEarly: false });
): any { return result;
try { } catch (error) {
const result = schema.validateSync(env, { abortEarly: false }); if (error instanceof yup.ValidationError) {
return result; console.error('❌ Invalid environment variables:');
} catch (error) { error.inner.forEach(err => {
if (error instanceof yup.ValidationError) { console.error(` ${err.path}: ${err.message}`);
console.error('❌ Invalid environment variables:'); });
error.inner.forEach((err) => { }
console.error(` ${err.path}: ${err.message}`); throw new Error('Environment validation failed');
}); }
} }
throw new Error('Environment validation failed');
} /**
} * Manually load environment variables from a specific path
*/
/** export function loadEnv(path?: string) {
* Manually load environment variables from a specific path if (path) {
*/ console.log(`📄 Manually loading environment from: ${path}`);
export function loadEnv(path?: string) { config({ path });
if (path) { } else {
console.log(`📄 Manually loading environment from: ${path}`); loadEnvFiles();
config({ path }); }
} else { }
loadEnvFiles();
} /**
} * Helper functions for common validation patterns
*/
/** export const envValidators = {
* Helper functions for common validation patterns // String with default
*/ str: (defaultValue?: string, description?: string) => yup.string().default(defaultValue || ''),
export const envValidators = {
// String with default // String with choices (enum)
str: (defaultValue?: string, description?: string) => strWithChoices: (choices: string[], defaultValue?: string, description?: string) =>
yup.string().default(defaultValue || ''), yup
.string()
// String with choices (enum) .oneOf(choices)
strWithChoices: (choices: string[], defaultValue?: string, description?: string) => .default(defaultValue || choices[0]),
yup.string().oneOf(choices).default(defaultValue || choices[0]),
// Required string
// Required string requiredStr: (description?: string) => yup.string().required('Required'),
requiredStr: (description?: string) =>
yup.string().required('Required'), // Port number
port: (defaultValue?: number, description?: string) =>
// Port number yup
port: (defaultValue?: number, description?: string) => .number()
yup.number() .integer()
.integer() .min(1)
.min(1) .max(65535)
.max(65535) .transform((val, originalVal) => {
.transform((val, originalVal) => { if (typeof originalVal === 'string') {
if (typeof originalVal === 'string') { return parseInt(originalVal, 10);
return parseInt(originalVal, 10); }
} return val;
return val; })
}) .default(defaultValue || 3000),
.default(defaultValue || 3000),
// Number with default
// Number with default num: (defaultValue?: number, description?: string) =>
num: (defaultValue?: number, description?: string) => yup
yup.number() .number()
.transform((val, originalVal) => { .transform((val, originalVal) => {
if (typeof originalVal === 'string') { if (typeof originalVal === 'string') {
return parseFloat(originalVal); return parseFloat(originalVal);
} }
return val; return val;
}) })
.default(defaultValue || 0), .default(defaultValue || 0),
// Boolean with default // Boolean with default
bool: (defaultValue?: boolean, description?: string) => bool: (defaultValue?: boolean, description?: string) =>
yup.boolean() yup
.transform((val, originalVal) => { .boolean()
if (typeof originalVal === 'string') { .transform((val, originalVal) => {
return originalVal === 'true' || originalVal === '1'; if (typeof originalVal === 'string') {
} return originalVal === 'true' || originalVal === '1';
return val; }
}) return val;
.default(defaultValue || false), })
.default(defaultValue || false),
// URL validation
url: (defaultValue?: string, description?: string) => // URL validation
yup.string().url().default(defaultValue || 'http://localhost'), url: (defaultValue?: string, description?: string) =>
yup
// Email validation .string()
email: (description?: string) => .url()
yup.string().email(), .default(defaultValue || 'http://localhost'),
};
// Email validation
/** email: (description?: string) => yup.string().email(),
* Legacy compatibility - creates a cleanEnv-like function };
*/
export function cleanEnv( /**
env: Record<string, string | undefined>, * Legacy compatibility - creates a cleanEnv-like function
validators: Record<string, any> */
): any { export function cleanEnv(
const schema = createEnvSchema(validators); env: Record<string, string | undefined>,
return validateEnv(schema, env); validators: Record<string, any>
} ): any {
const schema = createEnvSchema(validators);
return validateEnv(schema, env);
}

View file

@ -1,20 +1,20 @@
/** /**
* @stock-bot/config * @stock-bot/config
* *
* Configuration management library for Stock Bot platform using Yup * Configuration management library for Stock Bot platform using Yup
*/ */
// Re-export everything from all modules // Re-export everything from all modules
export * from './env-utils'; export * from './env-utils';
export * from './core'; export * from './core';
export * from './admin-interfaces'; export * from './admin-interfaces';
export * from './database'; export * from './database';
export * from './dragonfly'; export * from './dragonfly';
export * from './postgres'; export * from './postgres';
export * from './questdb'; export * from './questdb';
export * from './mongodb'; export * from './mongodb';
export * from './logging'; export * from './logging';
export * from './loki'; export * from './loki';
export * from './monitoring'; export * from './monitoring';
export * from './data-providers'; export * from './data-providers';
export * from './risk'; export * from './risk';

View file

@ -1,74 +1,74 @@
/** /**
* Logging configuration using Yup * Logging configuration using Yup
* Application logging settings without Loki (Loki config is in monitoring.ts) * Application logging settings without Loki (Loki config is in monitoring.ts)
*/ */
import { cleanEnv, envValidators } from './env-utils'; import { cleanEnv, envValidators } from './env-utils';
const { str, bool, num, strWithChoices } = envValidators; const { str, bool, num, strWithChoices } = envValidators;
/** /**
* Logging configuration with validation and defaults * Logging configuration with validation and defaults
*/ */
export const loggingConfig = cleanEnv(process.env, { export const loggingConfig = cleanEnv(process.env, {
// Basic Logging Settings // Basic Logging Settings
LOG_LEVEL: strWithChoices(['debug', 'info', 'warn', 'error'], 'info', 'Logging level'), LOG_LEVEL: strWithChoices(['debug', 'info', 'warn', 'error'], 'info', 'Logging level'),
LOG_FORMAT: strWithChoices(['json', 'simple', 'combined'], 'json', 'Log output format'), LOG_FORMAT: strWithChoices(['json', 'simple', 'combined'], 'json', 'Log output format'),
LOG_CONSOLE: bool(true, 'Enable console logging'), LOG_CONSOLE: bool(true, 'Enable console logging'),
LOG_FILE: bool(false, 'Enable file logging'), LOG_FILE: bool(false, 'Enable file logging'),
// File Logging Settings // File Logging Settings
LOG_FILE_PATH: str('logs', 'Log file directory path'), LOG_FILE_PATH: str('logs', 'Log file directory path'),
LOG_FILE_MAX_SIZE: str('20m', 'Maximum log file size'), LOG_FILE_MAX_SIZE: str('20m', 'Maximum log file size'),
LOG_FILE_MAX_FILES: num(14, 'Maximum number of log files to keep'), LOG_FILE_MAX_FILES: num(14, 'Maximum number of log files to keep'),
LOG_FILE_DATE_PATTERN: str('YYYY-MM-DD', 'Log file date pattern'), LOG_FILE_DATE_PATTERN: str('YYYY-MM-DD', 'Log file date pattern'),
// Error Logging // Error Logging
LOG_ERROR_FILE: bool(true, 'Enable separate error log file'), LOG_ERROR_FILE: bool(true, 'Enable separate error log file'),
LOG_ERROR_STACK: bool(true, 'Include stack traces in error logs'), LOG_ERROR_STACK: bool(true, 'Include stack traces in error logs'),
// Performance Logging // Performance Logging
LOG_PERFORMANCE: bool(false, 'Enable performance logging'), LOG_PERFORMANCE: bool(false, 'Enable performance logging'),
LOG_SQL_QUERIES: bool(false, 'Log SQL queries'), LOG_SQL_QUERIES: bool(false, 'Log SQL queries'),
LOG_HTTP_REQUESTS: bool(true, 'Log HTTP requests'), LOG_HTTP_REQUESTS: bool(true, 'Log HTTP requests'),
// Structured Logging // Structured Logging
LOG_STRUCTURED: bool(true, 'Use structured logging format'), LOG_STRUCTURED: bool(true, 'Use structured logging format'),
LOG_TIMESTAMP: bool(true, 'Include timestamps in logs'), LOG_TIMESTAMP: bool(true, 'Include timestamps in logs'),
LOG_CALLER_INFO: bool(false, 'Include caller information in logs'), LOG_CALLER_INFO: bool(false, 'Include caller information in logs'),
// Log Filtering // Log Filtering
LOG_SILENT_MODULES: str('', 'Comma-separated list of modules to silence'), LOG_SILENT_MODULES: str('', 'Comma-separated list of modules to silence'),
LOG_VERBOSE_MODULES: str('', 'Comma-separated list of modules for verbose logging'), LOG_VERBOSE_MODULES: str('', 'Comma-separated list of modules for verbose logging'),
// Application Context // Application Context
LOG_SERVICE_NAME: str('stock-bot', 'Service name for log context'), LOG_SERVICE_NAME: str('stock-bot', 'Service name for log context'),
LOG_SERVICE_VERSION: str('1.0.0', 'Service version for log context'), LOG_SERVICE_VERSION: str('1.0.0', 'Service version for log context'),
LOG_ENVIRONMENT: str('development', 'Environment for log context'), LOG_ENVIRONMENT: str('development', 'Environment for log context'),
}); });
// Export typed configuration object // Export typed configuration object
export type LoggingConfig = typeof loggingConfig; export type LoggingConfig = typeof loggingConfig;
// Export individual config values for convenience // Export individual config values for convenience
export const { export const {
LOG_LEVEL, LOG_LEVEL,
LOG_FORMAT, LOG_FORMAT,
LOG_CONSOLE, LOG_CONSOLE,
LOG_FILE, LOG_FILE,
LOG_FILE_PATH, LOG_FILE_PATH,
LOG_FILE_MAX_SIZE, LOG_FILE_MAX_SIZE,
LOG_FILE_MAX_FILES, LOG_FILE_MAX_FILES,
LOG_FILE_DATE_PATTERN, LOG_FILE_DATE_PATTERN,
LOG_ERROR_FILE, LOG_ERROR_FILE,
LOG_ERROR_STACK, LOG_ERROR_STACK,
LOG_PERFORMANCE, LOG_PERFORMANCE,
LOG_SQL_QUERIES, LOG_SQL_QUERIES,
LOG_HTTP_REQUESTS, LOG_HTTP_REQUESTS,
LOG_STRUCTURED, LOG_STRUCTURED,
LOG_TIMESTAMP, LOG_TIMESTAMP,
LOG_CALLER_INFO, LOG_CALLER_INFO,
LOG_SILENT_MODULES, LOG_SILENT_MODULES,
LOG_VERBOSE_MODULES, LOG_VERBOSE_MODULES,
LOG_SERVICE_NAME, LOG_SERVICE_NAME,
LOG_SERVICE_VERSION, LOG_SERVICE_VERSION,
LOG_ENVIRONMENT, LOG_ENVIRONMENT,
} = loggingConfig; } = loggingConfig;

View file

@ -1,63 +1,63 @@
/** /**
* Loki log aggregation configuration using Yup * Loki log aggregation configuration using Yup
* Centralized logging configuration for the Stock Bot platform * Centralized logging configuration for the Stock Bot platform
*/ */
import { cleanEnv, envValidators } from './env-utils'; import { cleanEnv, envValidators } from './env-utils';
const { str, port, bool, num } = envValidators; const { str, port, bool, num } = envValidators;
/** /**
* Loki configuration with validation and defaults * Loki configuration with validation and defaults
*/ */
export const lokiConfig = cleanEnv(process.env, { export const lokiConfig = cleanEnv(process.env, {
// Loki Server // Loki Server
LOKI_HOST: str('localhost', 'Loki host'), LOKI_HOST: str('localhost', 'Loki host'),
LOKI_PORT: port(3100, 'Loki port'), LOKI_PORT: port(3100, 'Loki port'),
LOKI_URL: str('', 'Complete Loki URL (overrides host/port)'), LOKI_URL: str('', 'Complete Loki URL (overrides host/port)'),
// Authentication // Authentication
LOKI_USERNAME: str('', 'Loki username (if auth enabled)'), LOKI_USERNAME: str('', 'Loki username (if auth enabled)'),
LOKI_PASSWORD: str('', 'Loki password (if auth enabled)'), LOKI_PASSWORD: str('', 'Loki password (if auth enabled)'),
LOKI_TENANT_ID: str('', 'Loki tenant ID (for multi-tenancy)'), LOKI_TENANT_ID: str('', 'Loki tenant ID (for multi-tenancy)'),
// Push Configuration // Push Configuration
LOKI_PUSH_TIMEOUT: num(10000, 'Push timeout in ms'), LOKI_PUSH_TIMEOUT: num(10000, 'Push timeout in ms'),
LOKI_BATCH_SIZE: num(1024, 'Batch size for log entries'), LOKI_BATCH_SIZE: num(1024, 'Batch size for log entries'),
LOKI_BATCH_WAIT: num(5, 'Batch wait time in ms'), LOKI_BATCH_WAIT: num(5, 'Batch wait time in ms'),
// Retention Settings // Retention Settings
LOKI_RETENTION_PERIOD: str('30d', 'Log retention period'), LOKI_RETENTION_PERIOD: str('30d', 'Log retention period'),
LOKI_MAX_CHUNK_AGE: str('1h', 'Maximum chunk age'), LOKI_MAX_CHUNK_AGE: str('1h', 'Maximum chunk age'),
// TLS Settings // TLS Settings
LOKI_TLS_ENABLED: bool(false, 'Enable TLS for Loki'), LOKI_TLS_ENABLED: bool(false, 'Enable TLS for Loki'),
LOKI_TLS_INSECURE: bool(false, 'Skip TLS verification'), LOKI_TLS_INSECURE: bool(false, 'Skip TLS verification'),
// Log Labels // Log Labels
LOKI_DEFAULT_LABELS: str('', 'Default labels for all log entries (JSON format)'), LOKI_DEFAULT_LABELS: str('', 'Default labels for all log entries (JSON format)'),
LOKI_SERVICE_LABEL: str('stock-bot', 'Service label for log entries'), LOKI_SERVICE_LABEL: str('stock-bot', 'Service label for log entries'),
LOKI_ENVIRONMENT_LABEL: str('development', 'Environment label for log entries'), LOKI_ENVIRONMENT_LABEL: str('development', 'Environment label for log entries'),
}); });
// Export typed configuration object // Export typed configuration object
export type LokiConfig = typeof lokiConfig; export type LokiConfig = typeof lokiConfig;
// Export individual config values for convenience // Export individual config values for convenience
export const { export const {
LOKI_HOST, LOKI_HOST,
LOKI_PORT, LOKI_PORT,
LOKI_URL, LOKI_URL,
LOKI_USERNAME, LOKI_USERNAME,
LOKI_PASSWORD, LOKI_PASSWORD,
LOKI_TENANT_ID, LOKI_TENANT_ID,
LOKI_PUSH_TIMEOUT, LOKI_PUSH_TIMEOUT,
LOKI_BATCH_SIZE, LOKI_BATCH_SIZE,
LOKI_BATCH_WAIT, LOKI_BATCH_WAIT,
LOKI_RETENTION_PERIOD, LOKI_RETENTION_PERIOD,
LOKI_MAX_CHUNK_AGE, LOKI_MAX_CHUNK_AGE,
LOKI_TLS_ENABLED, LOKI_TLS_ENABLED,
LOKI_TLS_INSECURE, LOKI_TLS_INSECURE,
LOKI_DEFAULT_LABELS, LOKI_DEFAULT_LABELS,
LOKI_SERVICE_LABEL, LOKI_SERVICE_LABEL,
LOKI_ENVIRONMENT_LABEL, LOKI_ENVIRONMENT_LABEL,
} = lokiConfig; } = lokiConfig;

View file

@ -1,73 +1,77 @@
/** /**
* MongoDB configuration using Yup * MongoDB configuration using Yup
* Document storage for sentiment data, raw documents, and unstructured data * Document storage for sentiment data, raw documents, and unstructured data
*/ */
import { cleanEnv, envValidators } from './env-utils'; import { cleanEnv, envValidators } from './env-utils';
const { str, port, bool, num, strWithChoices } = envValidators; const { str, port, bool, num, strWithChoices } = envValidators;
/** /**
* MongoDB configuration with validation and defaults * MongoDB configuration with validation and defaults
*/ */
export const mongodbConfig = cleanEnv(process.env, { export const mongodbConfig = cleanEnv(process.env, {
// MongoDB Connection // MongoDB Connection
MONGODB_HOST: str('localhost', 'MongoDB host'), MONGODB_HOST: str('localhost', 'MongoDB host'),
MONGODB_PORT: port(27017, 'MongoDB port'), MONGODB_PORT: port(27017, 'MongoDB port'),
MONGODB_DATABASE: str('trading_documents', 'MongoDB database name'), MONGODB_DATABASE: str('trading_documents', 'MongoDB database name'),
// Authentication // Authentication
MONGODB_USERNAME: str('trading_admin', 'MongoDB username'), MONGODB_USERNAME: str('trading_admin', 'MongoDB username'),
MONGODB_PASSWORD: str('', 'MongoDB password'), MONGODB_PASSWORD: str('', 'MongoDB password'),
MONGODB_AUTH_SOURCE: str('admin', 'MongoDB authentication database'), MONGODB_AUTH_SOURCE: str('admin', 'MongoDB authentication database'),
// Connection URI (alternative to individual settings) // Connection URI (alternative to individual settings)
MONGODB_URI: str('', 'Complete MongoDB connection URI (overrides individual settings)'), MONGODB_URI: str('', 'Complete MongoDB connection URI (overrides individual settings)'),
// Connection Pool Settings // Connection Pool Settings
MONGODB_MAX_POOL_SIZE: num(10, 'Maximum connection pool size'), MONGODB_MAX_POOL_SIZE: num(10, 'Maximum connection pool size'),
MONGODB_MIN_POOL_SIZE: num(0, 'Minimum connection pool size'), MONGODB_MIN_POOL_SIZE: num(0, 'Minimum connection pool size'),
MONGODB_MAX_IDLE_TIME: num(30000, 'Maximum idle time for connections in ms'), MONGODB_MAX_IDLE_TIME: num(30000, 'Maximum idle time for connections in ms'),
// Timeouts // Timeouts
MONGODB_CONNECT_TIMEOUT: num(10000, 'Connection timeout in ms'), MONGODB_CONNECT_TIMEOUT: num(10000, 'Connection timeout in ms'),
MONGODB_SOCKET_TIMEOUT: num(30000, 'Socket timeout in ms'), MONGODB_SOCKET_TIMEOUT: num(30000, 'Socket timeout in ms'),
MONGODB_SERVER_SELECTION_TIMEOUT: num(5000, 'Server selection timeout in ms'), MONGODB_SERVER_SELECTION_TIMEOUT: num(5000, 'Server selection timeout in ms'),
// SSL/TLS Settings // SSL/TLS Settings
MONGODB_TLS: bool(false, 'Enable TLS for MongoDB connection'), MONGODB_TLS: bool(false, 'Enable TLS for MongoDB connection'),
MONGODB_TLS_INSECURE: bool(false, 'Allow invalid certificates in TLS mode'), MONGODB_TLS_INSECURE: bool(false, 'Allow invalid certificates in TLS mode'),
MONGODB_TLS_CA_FILE: str('', 'Path to TLS CA certificate file'), MONGODB_TLS_CA_FILE: str('', 'Path to TLS CA certificate file'),
// Additional Settings // Additional Settings
MONGODB_RETRY_WRITES: bool(true, 'Enable retryable writes'), MONGODB_RETRY_WRITES: bool(true, 'Enable retryable writes'),
MONGODB_JOURNAL: bool(true, 'Enable write concern journal'), MONGODB_JOURNAL: bool(true, 'Enable write concern journal'),
MONGODB_READ_PREFERENCE: strWithChoices(['primary', 'primaryPreferred', 'secondary', 'secondaryPreferred', 'nearest'], 'primary', 'MongoDB read preference'), MONGODB_READ_PREFERENCE: strWithChoices(
MONGODB_WRITE_CONCERN: str('majority', 'Write concern level'), ['primary', 'primaryPreferred', 'secondary', 'secondaryPreferred', 'nearest'],
}); 'primary',
'MongoDB read preference'
// Export typed configuration object ),
export type MongoDbConfig = typeof mongodbConfig; MONGODB_WRITE_CONCERN: str('majority', 'Write concern level'),
});
// Export individual config values for convenience
export const { // Export typed configuration object
MONGODB_HOST, export type MongoDbConfig = typeof mongodbConfig;
MONGODB_PORT,
MONGODB_DATABASE, // Export individual config values for convenience
MONGODB_USERNAME, export const {
MONGODB_PASSWORD, MONGODB_HOST,
MONGODB_AUTH_SOURCE, MONGODB_PORT,
MONGODB_URI, MONGODB_DATABASE,
MONGODB_MAX_POOL_SIZE, MONGODB_USERNAME,
MONGODB_MIN_POOL_SIZE, MONGODB_PASSWORD,
MONGODB_MAX_IDLE_TIME, MONGODB_AUTH_SOURCE,
MONGODB_CONNECT_TIMEOUT, MONGODB_URI,
MONGODB_SOCKET_TIMEOUT, MONGODB_MAX_POOL_SIZE,
MONGODB_SERVER_SELECTION_TIMEOUT, MONGODB_MIN_POOL_SIZE,
MONGODB_TLS, MONGODB_MAX_IDLE_TIME,
MONGODB_TLS_INSECURE, MONGODB_CONNECT_TIMEOUT,
MONGODB_TLS_CA_FILE, MONGODB_SOCKET_TIMEOUT,
MONGODB_RETRY_WRITES, MONGODB_SERVER_SELECTION_TIMEOUT,
MONGODB_JOURNAL, MONGODB_TLS,
MONGODB_READ_PREFERENCE, MONGODB_TLS_INSECURE,
MONGODB_WRITE_CONCERN, MONGODB_TLS_CA_FILE,
} = mongodbConfig; MONGODB_RETRY_WRITES,
MONGODB_JOURNAL,
MONGODB_READ_PREFERENCE,
MONGODB_WRITE_CONCERN,
} = mongodbConfig;

View file

@ -1,88 +1,92 @@
/** /**
* Monitoring configuration using Yup * Monitoring configuration using Yup
* Prometheus metrics, Grafana visualization, and Loki logging * Prometheus metrics, Grafana visualization, and Loki logging
*/ */
import { cleanEnv, envValidators } from './env-utils'; import { cleanEnv, envValidators } from './env-utils';
const { str, port, bool, num, strWithChoices } = envValidators; const { str, port, bool, num, strWithChoices } = envValidators;
/** /**
* Prometheus configuration with validation and defaults * Prometheus configuration with validation and defaults
*/ */
export const prometheusConfig = cleanEnv(process.env, { export const prometheusConfig = cleanEnv(process.env, {
// Prometheus Server // Prometheus Server
PROMETHEUS_HOST: str('localhost', 'Prometheus host'), PROMETHEUS_HOST: str('localhost', 'Prometheus host'),
PROMETHEUS_PORT: port(9090, 'Prometheus port'), PROMETHEUS_PORT: port(9090, 'Prometheus port'),
PROMETHEUS_URL: str('', 'Complete Prometheus URL (overrides host/port)'), PROMETHEUS_URL: str('', 'Complete Prometheus URL (overrides host/port)'),
// Authentication // Authentication
PROMETHEUS_USERNAME: str('', 'Prometheus username (if auth enabled)'), PROMETHEUS_USERNAME: str('', 'Prometheus username (if auth enabled)'),
PROMETHEUS_PASSWORD: str('', 'Prometheus password (if auth enabled)'), PROMETHEUS_PASSWORD: str('', 'Prometheus password (if auth enabled)'),
// Metrics Collection // Metrics Collection
PROMETHEUS_SCRAPE_INTERVAL: str('15s', 'Default scrape interval'), PROMETHEUS_SCRAPE_INTERVAL: str('15s', 'Default scrape interval'),
PROMETHEUS_EVALUATION_INTERVAL: str('15s', 'Rule evaluation interval'), PROMETHEUS_EVALUATION_INTERVAL: str('15s', 'Rule evaluation interval'),
PROMETHEUS_RETENTION_TIME: str('15d', 'Data retention time'), PROMETHEUS_RETENTION_TIME: str('15d', 'Data retention time'),
// TLS Settings // TLS Settings
PROMETHEUS_TLS_ENABLED: bool(false, 'Enable TLS for Prometheus'), PROMETHEUS_TLS_ENABLED: bool(false, 'Enable TLS for Prometheus'),
PROMETHEUS_TLS_INSECURE: bool(false, 'Skip TLS verification'), PROMETHEUS_TLS_INSECURE: bool(false, 'Skip TLS verification'),
}); });
/** /**
* Grafana configuration with validation and defaults * Grafana configuration with validation and defaults
*/ */
export const grafanaConfig = cleanEnv(process.env, { export const grafanaConfig = cleanEnv(process.env, {
// Grafana Server // Grafana Server
GRAFANA_HOST: str('localhost', 'Grafana host'), GRAFANA_HOST: str('localhost', 'Grafana host'),
GRAFANA_PORT: port(3000, 'Grafana port'), GRAFANA_PORT: port(3000, 'Grafana port'),
GRAFANA_URL: str('', 'Complete Grafana URL (overrides host/port)'), GRAFANA_URL: str('', 'Complete Grafana URL (overrides host/port)'),
// Authentication // Authentication
GRAFANA_ADMIN_USER: str('admin', 'Grafana admin username'), GRAFANA_ADMIN_USER: str('admin', 'Grafana admin username'),
GRAFANA_ADMIN_PASSWORD: str('admin', 'Grafana admin password'), GRAFANA_ADMIN_PASSWORD: str('admin', 'Grafana admin password'),
// Security Settings // Security Settings
GRAFANA_ALLOW_SIGN_UP: bool(false, 'Allow user sign up'), GRAFANA_ALLOW_SIGN_UP: bool(false, 'Allow user sign up'),
GRAFANA_SECRET_KEY: str('', 'Grafana secret key for encryption'), GRAFANA_SECRET_KEY: str('', 'Grafana secret key for encryption'),
// Database Settings // Database Settings
GRAFANA_DATABASE_TYPE: strWithChoices(['mysql', 'postgres', 'sqlite3'], 'sqlite3', 'Grafana database type'), GRAFANA_DATABASE_TYPE: strWithChoices(
GRAFANA_DATABASE_URL: str('', 'Grafana database URL'), ['mysql', 'postgres', 'sqlite3'],
'sqlite3',
// Feature Flags 'Grafana database type'
GRAFANA_DISABLE_GRAVATAR: bool(true, 'Disable Gravatar avatars'), ),
GRAFANA_ENABLE_GZIP: bool(true, 'Enable gzip compression'), GRAFANA_DATABASE_URL: str('', 'Grafana database URL'),
});
// Feature Flags
// Export typed configuration objects GRAFANA_DISABLE_GRAVATAR: bool(true, 'Disable Gravatar avatars'),
export type PrometheusConfig = typeof prometheusConfig; GRAFANA_ENABLE_GZIP: bool(true, 'Enable gzip compression'),
export type GrafanaConfig = typeof grafanaConfig; });
// Export individual config values for convenience // Export typed configuration objects
export const { export type PrometheusConfig = typeof prometheusConfig;
PROMETHEUS_HOST, export type GrafanaConfig = typeof grafanaConfig;
PROMETHEUS_PORT,
PROMETHEUS_URL, // Export individual config values for convenience
PROMETHEUS_USERNAME, export const {
PROMETHEUS_PASSWORD, PROMETHEUS_HOST,
PROMETHEUS_SCRAPE_INTERVAL, PROMETHEUS_PORT,
PROMETHEUS_EVALUATION_INTERVAL, PROMETHEUS_URL,
PROMETHEUS_RETENTION_TIME, PROMETHEUS_USERNAME,
PROMETHEUS_TLS_ENABLED, PROMETHEUS_PASSWORD,
PROMETHEUS_TLS_INSECURE, PROMETHEUS_SCRAPE_INTERVAL,
} = prometheusConfig; PROMETHEUS_EVALUATION_INTERVAL,
PROMETHEUS_RETENTION_TIME,
export const { PROMETHEUS_TLS_ENABLED,
GRAFANA_HOST, PROMETHEUS_TLS_INSECURE,
GRAFANA_PORT, } = prometheusConfig;
GRAFANA_URL,
GRAFANA_ADMIN_USER, export const {
GRAFANA_ADMIN_PASSWORD, GRAFANA_HOST,
GRAFANA_ALLOW_SIGN_UP, GRAFANA_PORT,
GRAFANA_SECRET_KEY, GRAFANA_URL,
GRAFANA_DATABASE_TYPE, GRAFANA_ADMIN_USER,
GRAFANA_DATABASE_URL, GRAFANA_ADMIN_PASSWORD,
GRAFANA_DISABLE_GRAVATAR, GRAFANA_ALLOW_SIGN_UP,
GRAFANA_ENABLE_GZIP, GRAFANA_SECRET_KEY,
} = grafanaConfig; GRAFANA_DATABASE_TYPE,
GRAFANA_DATABASE_URL,
GRAFANA_DISABLE_GRAVATAR,
GRAFANA_ENABLE_GZIP,
} = grafanaConfig;

View file

@ -1,56 +1,56 @@
/** /**
* PostgreSQL configuration using Yup * PostgreSQL configuration using Yup
*/ */
import { cleanEnv, envValidators } from './env-utils'; import { cleanEnv, envValidators } from './env-utils';
const { str, port, bool, num } = envValidators; const { str, port, bool, num } = envValidators;
/** /**
* PostgreSQL configuration with validation and defaults * PostgreSQL configuration with validation and defaults
*/ */
export const postgresConfig = cleanEnv(process.env, { export const postgresConfig = cleanEnv(process.env, {
// PostgreSQL Connection Settings // PostgreSQL Connection Settings
POSTGRES_HOST: str('localhost', 'PostgreSQL host'), POSTGRES_HOST: str('localhost', 'PostgreSQL host'),
POSTGRES_PORT: port(5432, 'PostgreSQL port'), POSTGRES_PORT: port(5432, 'PostgreSQL port'),
POSTGRES_DATABASE: str('stockbot', 'PostgreSQL database name'), POSTGRES_DATABASE: str('stockbot', 'PostgreSQL database name'),
POSTGRES_USERNAME: str('stockbot', 'PostgreSQL username'), POSTGRES_USERNAME: str('stockbot', 'PostgreSQL username'),
POSTGRES_PASSWORD: str('', 'PostgreSQL password'), POSTGRES_PASSWORD: str('', 'PostgreSQL password'),
// Connection Pool Settings // Connection Pool Settings
POSTGRES_POOL_MIN: num(2, 'Minimum pool connections'), POSTGRES_POOL_MIN: num(2, 'Minimum pool connections'),
POSTGRES_POOL_MAX: num(10, 'Maximum pool connections'), POSTGRES_POOL_MAX: num(10, 'Maximum pool connections'),
POSTGRES_POOL_IDLE_TIMEOUT: num(30000, 'Pool idle timeout in ms'), POSTGRES_POOL_IDLE_TIMEOUT: num(30000, 'Pool idle timeout in ms'),
// SSL Configuration // SSL Configuration
POSTGRES_SSL: bool(false, 'Enable SSL for PostgreSQL connection'), POSTGRES_SSL: bool(false, 'Enable SSL for PostgreSQL connection'),
POSTGRES_SSL_REJECT_UNAUTHORIZED: bool(true, 'Reject unauthorized SSL certificates'), POSTGRES_SSL_REJECT_UNAUTHORIZED: bool(true, 'Reject unauthorized SSL certificates'),
// Additional Settings // Additional Settings
POSTGRES_QUERY_TIMEOUT: num(30000, 'Query timeout in ms'), POSTGRES_QUERY_TIMEOUT: num(30000, 'Query timeout in ms'),
POSTGRES_CONNECTION_TIMEOUT: num(5000, 'Connection timeout in ms'), POSTGRES_CONNECTION_TIMEOUT: num(5000, 'Connection timeout in ms'),
POSTGRES_STATEMENT_TIMEOUT: num(30000, 'Statement timeout in ms'), POSTGRES_STATEMENT_TIMEOUT: num(30000, 'Statement timeout in ms'),
POSTGRES_LOCK_TIMEOUT: num(10000, 'Lock timeout in ms'), POSTGRES_LOCK_TIMEOUT: num(10000, 'Lock timeout in ms'),
POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT: num(60000, 'Idle in transaction timeout in ms'), POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT: num(60000, 'Idle in transaction timeout in ms'),
}); });
// Export typed configuration object // Export typed configuration object
export type PostgresConfig = typeof postgresConfig; export type PostgresConfig = typeof postgresConfig;
// Export individual config values for convenience // Export individual config values for convenience
export const { export const {
POSTGRES_HOST, POSTGRES_HOST,
POSTGRES_PORT, POSTGRES_PORT,
POSTGRES_DATABASE, POSTGRES_DATABASE,
POSTGRES_USERNAME, POSTGRES_USERNAME,
POSTGRES_PASSWORD, POSTGRES_PASSWORD,
POSTGRES_POOL_MIN, POSTGRES_POOL_MIN,
POSTGRES_POOL_MAX, POSTGRES_POOL_MAX,
POSTGRES_POOL_IDLE_TIMEOUT, POSTGRES_POOL_IDLE_TIMEOUT,
POSTGRES_SSL, POSTGRES_SSL,
POSTGRES_SSL_REJECT_UNAUTHORIZED, POSTGRES_SSL_REJECT_UNAUTHORIZED,
POSTGRES_QUERY_TIMEOUT, POSTGRES_QUERY_TIMEOUT,
POSTGRES_CONNECTION_TIMEOUT, POSTGRES_CONNECTION_TIMEOUT,
POSTGRES_STATEMENT_TIMEOUT, POSTGRES_STATEMENT_TIMEOUT,
POSTGRES_LOCK_TIMEOUT, POSTGRES_LOCK_TIMEOUT,
POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT, POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT,
} = postgresConfig; } = postgresConfig;

View file

@ -1,55 +1,55 @@
/** /**
* QuestDB configuration using Yup * QuestDB configuration using Yup
* Time-series database for OHLCV data, indicators, and performance metrics * Time-series database for OHLCV data, indicators, and performance metrics
*/ */
import { cleanEnv, envValidators } from './env-utils'; import { cleanEnv, envValidators } from './env-utils';
const { str, port, bool, num } = envValidators; const { str, port, bool, num } = envValidators;
/** /**
* QuestDB configuration with validation and defaults * QuestDB configuration with validation and defaults
*/ */
export const questdbConfig = cleanEnv(process.env, { export const questdbConfig = cleanEnv(process.env, {
// QuestDB Connection // QuestDB Connection
QUESTDB_HOST: str('localhost', 'QuestDB host'), QUESTDB_HOST: str('localhost', 'QuestDB host'),
QUESTDB_HTTP_PORT: port(9000, 'QuestDB HTTP port (web console)'), QUESTDB_HTTP_PORT: port(9000, 'QuestDB HTTP port (web console)'),
QUESTDB_PG_PORT: port(8812, 'QuestDB PostgreSQL wire protocol port'), QUESTDB_PG_PORT: port(8812, 'QuestDB PostgreSQL wire protocol port'),
QUESTDB_INFLUX_PORT: port(9009, 'QuestDB InfluxDB line protocol port'), QUESTDB_INFLUX_PORT: port(9009, 'QuestDB InfluxDB line protocol port'),
// Authentication (if enabled) // Authentication (if enabled)
QUESTDB_USER: str('', 'QuestDB username (if auth enabled)'), QUESTDB_USER: str('', 'QuestDB username (if auth enabled)'),
QUESTDB_PASSWORD: str('', 'QuestDB password (if auth enabled)'), QUESTDB_PASSWORD: str('', 'QuestDB password (if auth enabled)'),
// Connection Settings // Connection Settings
QUESTDB_CONNECTION_TIMEOUT: num(5000, 'Connection timeout in ms'), QUESTDB_CONNECTION_TIMEOUT: num(5000, 'Connection timeout in ms'),
QUESTDB_REQUEST_TIMEOUT: num(30000, 'Request timeout in ms'), QUESTDB_REQUEST_TIMEOUT: num(30000, 'Request timeout in ms'),
QUESTDB_RETRY_ATTEMPTS: num(3, 'Number of retry attempts'), QUESTDB_RETRY_ATTEMPTS: num(3, 'Number of retry attempts'),
// TLS Settings // TLS Settings
QUESTDB_TLS_ENABLED: bool(false, 'Enable TLS for QuestDB connection'), QUESTDB_TLS_ENABLED: bool(false, 'Enable TLS for QuestDB connection'),
QUESTDB_TLS_VERIFY_SERVER_CERT: bool(true, 'Verify server certificate'), QUESTDB_TLS_VERIFY_SERVER_CERT: bool(true, 'Verify server certificate'),
// Database Settings // Database Settings
QUESTDB_DEFAULT_DATABASE: str('qdb', 'Default database name'), QUESTDB_DEFAULT_DATABASE: str('qdb', 'Default database name'),
QUESTDB_TELEMETRY_ENABLED: bool(false, 'Enable telemetry'), QUESTDB_TELEMETRY_ENABLED: bool(false, 'Enable telemetry'),
}); });
// Export typed configuration object // Export typed configuration object
export type QuestDbConfig = typeof questdbConfig; export type QuestDbConfig = typeof questdbConfig;
// Export individual config values for convenience // Export individual config values for convenience
export const { export const {
QUESTDB_HOST, QUESTDB_HOST,
QUESTDB_HTTP_PORT, QUESTDB_HTTP_PORT,
QUESTDB_PG_PORT, QUESTDB_PG_PORT,
QUESTDB_INFLUX_PORT, QUESTDB_INFLUX_PORT,
QUESTDB_USER, QUESTDB_USER,
QUESTDB_PASSWORD, QUESTDB_PASSWORD,
QUESTDB_CONNECTION_TIMEOUT, QUESTDB_CONNECTION_TIMEOUT,
QUESTDB_REQUEST_TIMEOUT, QUESTDB_REQUEST_TIMEOUT,
QUESTDB_RETRY_ATTEMPTS, QUESTDB_RETRY_ATTEMPTS,
QUESTDB_TLS_ENABLED, QUESTDB_TLS_ENABLED,
QUESTDB_TLS_VERIFY_SERVER_CERT, QUESTDB_TLS_VERIFY_SERVER_CERT,
QUESTDB_DEFAULT_DATABASE, QUESTDB_DEFAULT_DATABASE,
QUESTDB_TELEMETRY_ENABLED, QUESTDB_TELEMETRY_ENABLED,
} = questdbConfig; } = questdbConfig;

View file

@ -1,80 +1,80 @@
/** /**
* Risk management configuration using Yup * Risk management configuration using Yup
*/ */
import { cleanEnv, envValidators } from './env-utils'; import { cleanEnv, envValidators } from './env-utils';
const { str, num, bool, strWithChoices } = envValidators; const { str, num, bool, strWithChoices } = envValidators;
/** /**
* Risk configuration with validation and defaults * Risk configuration with validation and defaults
*/ */
export const riskConfig = cleanEnv(process.env, { export const riskConfig = cleanEnv(process.env, {
// Position Sizing // Position Sizing
RISK_MAX_POSITION_SIZE: num(0.1, 'Maximum position size as percentage of portfolio'), RISK_MAX_POSITION_SIZE: num(0.1, 'Maximum position size as percentage of portfolio'),
RISK_MAX_PORTFOLIO_EXPOSURE: num(0.8, 'Maximum portfolio exposure percentage'), RISK_MAX_PORTFOLIO_EXPOSURE: num(0.8, 'Maximum portfolio exposure percentage'),
RISK_MAX_SINGLE_ASSET_EXPOSURE: num(0.2, 'Maximum exposure to single asset'), RISK_MAX_SINGLE_ASSET_EXPOSURE: num(0.2, 'Maximum exposure to single asset'),
RISK_MAX_SECTOR_EXPOSURE: num(0.3, 'Maximum exposure to single sector'), RISK_MAX_SECTOR_EXPOSURE: num(0.3, 'Maximum exposure to single sector'),
// Stop Loss and Take Profit // Stop Loss and Take Profit
RISK_DEFAULT_STOP_LOSS: num(0.05, 'Default stop loss percentage'), RISK_DEFAULT_STOP_LOSS: num(0.05, 'Default stop loss percentage'),
RISK_DEFAULT_TAKE_PROFIT: num(0.15, 'Default take profit percentage'), RISK_DEFAULT_TAKE_PROFIT: num(0.15, 'Default take profit percentage'),
RISK_TRAILING_STOP_ENABLED: bool(true, 'Enable trailing stop losses'), RISK_TRAILING_STOP_ENABLED: bool(true, 'Enable trailing stop losses'),
RISK_TRAILING_STOP_DISTANCE: num(0.03, 'Trailing stop distance percentage'), RISK_TRAILING_STOP_DISTANCE: num(0.03, 'Trailing stop distance percentage'),
// Risk Limits // Risk Limits
RISK_MAX_DAILY_LOSS: num(0.05, 'Maximum daily loss percentage'), RISK_MAX_DAILY_LOSS: num(0.05, 'Maximum daily loss percentage'),
RISK_MAX_WEEKLY_LOSS: num(0.1, 'Maximum weekly loss percentage'), RISK_MAX_WEEKLY_LOSS: num(0.1, 'Maximum weekly loss percentage'),
RISK_MAX_MONTHLY_LOSS: num(0.2, 'Maximum monthly loss percentage'), RISK_MAX_MONTHLY_LOSS: num(0.2, 'Maximum monthly loss percentage'),
// Volatility Controls // Volatility Controls
RISK_MAX_VOLATILITY_THRESHOLD: num(0.4, 'Maximum volatility threshold'), RISK_MAX_VOLATILITY_THRESHOLD: num(0.4, 'Maximum volatility threshold'),
RISK_VOLATILITY_LOOKBACK_DAYS: num(20, 'Volatility calculation lookback period'), RISK_VOLATILITY_LOOKBACK_DAYS: num(20, 'Volatility calculation lookback period'),
// Correlation Controls // Correlation Controls
RISK_MAX_CORRELATION_THRESHOLD: num(0.7, 'Maximum correlation between positions'), RISK_MAX_CORRELATION_THRESHOLD: num(0.7, 'Maximum correlation between positions'),
RISK_CORRELATION_LOOKBACK_DAYS: num(60, 'Correlation calculation lookback period'), RISK_CORRELATION_LOOKBACK_DAYS: num(60, 'Correlation calculation lookback period'),
// Leverage Controls // Leverage Controls
RISK_MAX_LEVERAGE: num(2.0, 'Maximum leverage allowed'), RISK_MAX_LEVERAGE: num(2.0, 'Maximum leverage allowed'),
RISK_MARGIN_CALL_THRESHOLD: num(0.3, 'Margin call threshold'), RISK_MARGIN_CALL_THRESHOLD: num(0.3, 'Margin call threshold'),
// Circuit Breakers // Circuit Breakers
RISK_CIRCUIT_BREAKER_ENABLED: bool(true, 'Enable circuit breakers'), RISK_CIRCUIT_BREAKER_ENABLED: bool(true, 'Enable circuit breakers'),
RISK_CIRCUIT_BREAKER_LOSS_THRESHOLD: num(0.1, 'Circuit breaker loss threshold'), RISK_CIRCUIT_BREAKER_LOSS_THRESHOLD: num(0.1, 'Circuit breaker loss threshold'),
RISK_CIRCUIT_BREAKER_COOLDOWN_MINUTES: num(60, 'Circuit breaker cooldown period'), RISK_CIRCUIT_BREAKER_COOLDOWN_MINUTES: num(60, 'Circuit breaker cooldown period'),
// Risk Model // Risk Model
RISK_MODEL_TYPE: strWithChoices(['var', 'cvar', 'expected_shortfall'], 'var', 'Risk model type'), RISK_MODEL_TYPE: strWithChoices(['var', 'cvar', 'expected_shortfall'], 'var', 'Risk model type'),
RISK_CONFIDENCE_LEVEL: num(0.95, 'Risk model confidence level'), RISK_CONFIDENCE_LEVEL: num(0.95, 'Risk model confidence level'),
RISK_TIME_HORIZON_DAYS: num(1, 'Risk time horizon in days'), RISK_TIME_HORIZON_DAYS: num(1, 'Risk time horizon in days'),
}); });
// Export typed configuration object // Export typed configuration object
export type RiskConfig = typeof riskConfig; export type RiskConfig = typeof riskConfig;
// Export individual config values for convenience // Export individual config values for convenience
export const { export const {
RISK_MAX_POSITION_SIZE, RISK_MAX_POSITION_SIZE,
RISK_MAX_PORTFOLIO_EXPOSURE, RISK_MAX_PORTFOLIO_EXPOSURE,
RISK_MAX_SINGLE_ASSET_EXPOSURE, RISK_MAX_SINGLE_ASSET_EXPOSURE,
RISK_MAX_SECTOR_EXPOSURE, RISK_MAX_SECTOR_EXPOSURE,
RISK_DEFAULT_STOP_LOSS, RISK_DEFAULT_STOP_LOSS,
RISK_DEFAULT_TAKE_PROFIT, RISK_DEFAULT_TAKE_PROFIT,
RISK_TRAILING_STOP_ENABLED, RISK_TRAILING_STOP_ENABLED,
RISK_TRAILING_STOP_DISTANCE, RISK_TRAILING_STOP_DISTANCE,
RISK_MAX_DAILY_LOSS, RISK_MAX_DAILY_LOSS,
RISK_MAX_WEEKLY_LOSS, RISK_MAX_WEEKLY_LOSS,
RISK_MAX_MONTHLY_LOSS, RISK_MAX_MONTHLY_LOSS,
RISK_MAX_VOLATILITY_THRESHOLD, RISK_MAX_VOLATILITY_THRESHOLD,
RISK_VOLATILITY_LOOKBACK_DAYS, RISK_VOLATILITY_LOOKBACK_DAYS,
RISK_MAX_CORRELATION_THRESHOLD, RISK_MAX_CORRELATION_THRESHOLD,
RISK_CORRELATION_LOOKBACK_DAYS, RISK_CORRELATION_LOOKBACK_DAYS,
RISK_MAX_LEVERAGE, RISK_MAX_LEVERAGE,
RISK_MARGIN_CALL_THRESHOLD, RISK_MARGIN_CALL_THRESHOLD,
RISK_CIRCUIT_BREAKER_ENABLED, RISK_CIRCUIT_BREAKER_ENABLED,
RISK_CIRCUIT_BREAKER_LOSS_THRESHOLD, RISK_CIRCUIT_BREAKER_LOSS_THRESHOLD,
RISK_CIRCUIT_BREAKER_COOLDOWN_MINUTES, RISK_CIRCUIT_BREAKER_COOLDOWN_MINUTES,
RISK_MODEL_TYPE, RISK_MODEL_TYPE,
RISK_CONFIDENCE_LEVEL, RISK_CONFIDENCE_LEVEL,
RISK_TIME_HORIZON_DAYS, RISK_TIME_HORIZON_DAYS,
} = riskConfig; } = riskConfig;

View file

@ -1,433 +1,445 @@
/** /**
* Integration Tests for Config Library * Integration Tests for Config Library
* *
* Tests the entire configuration system including module interactions, * Tests the entire configuration system including module interactions,
* environment loading, validation across modules, and type exports. * environment loading, validation across modules, and type exports.
*/ */
import { describe, test, expect, beforeEach } from 'bun:test'; import { beforeEach, describe, expect, test } from 'bun:test';
import { setTestEnv, clearEnvVars, getMinimalTestEnv } from '../test/setup'; import { clearEnvVars, getMinimalTestEnv, setTestEnv } from '../test/setup';
describe('Config Library Integration', () => { describe('Config Library Integration', () => {
beforeEach(() => { beforeEach(() => {
// Clear module cache for clean state // Clear module cache for clean state
// Note: Bun handles module caching differently than Jest // Note: Bun handles module caching differently than Jest
}); });
describe('Complete Configuration Loading', () => { test('should load all configuration modules successfully', async () => { describe('Complete Configuration Loading', () => {
setTestEnv(getMinimalTestEnv()); test('should load all configuration modules successfully', async () => {
// Import all modules setTestEnv(getMinimalTestEnv());
const [ // Import all modules
{ Environment, getEnvironment }, const [
{ postgresConfig }, { Environment, getEnvironment },
{ questdbConfig }, { postgresConfig },
{ mongodbConfig }, { questdbConfig },
{ loggingConfig }, { mongodbConfig },
{ riskConfig } { loggingConfig },
] = await Promise.all([ { riskConfig },
import('../src/core'), ] = await Promise.all([
import('../src/postgres'), import('../src/core'),
import('../src/questdb'), import('../src/postgres'),
import('../src/mongodb'), import('../src/questdb'),
import('../src/logging'), import('../src/mongodb'),
import('../src/risk') import('../src/logging'),
]); import('../src/risk'),
]);
// Verify all configs are loaded
expect(Environment).toBeDefined(); // Verify all configs are loaded
expect(getEnvironment).toBeDefined(); expect(Environment).toBeDefined();
expect(postgresConfig).toBeDefined(); expect(getEnvironment).toBeDefined();
expect(questdbConfig).toBeDefined(); expect(postgresConfig).toBeDefined();
expect(mongodbConfig).toBeDefined(); expect(questdbConfig).toBeDefined();
expect(loggingConfig).toBeDefined(); expect(mongodbConfig).toBeDefined();
expect(riskConfig).toBeDefined(); expect(loggingConfig).toBeDefined();
// Verify core utilities expect(riskConfig).toBeDefined();
expect(getEnvironment()).toBe(Environment.Testing); // Should be Testing due to NODE_ENV=test in setup // Verify core utilities
expect(postgresConfig.POSTGRES_HOST).toBe('localhost'); expect(questdbConfig.QUESTDB_HOST).toBe('localhost'); expect(getEnvironment()).toBe(Environment.Testing); // Should be Testing due to NODE_ENV=test in setup
expect(mongodbConfig.MONGODB_HOST).toBe('localhost'); // fix: use correct property expect(postgresConfig.POSTGRES_HOST).toBe('localhost');
expect(loggingConfig.LOG_LEVEL).toBeDefined(); expect(questdbConfig.QUESTDB_HOST).toBe('localhost');
expect(riskConfig.RISK_MAX_POSITION_SIZE).toBe(0.1); expect(mongodbConfig.MONGODB_HOST).toBe('localhost'); // fix: use correct property
}); test('should handle missing required environment variables gracefully', async () => { expect(loggingConfig.LOG_LEVEL).toBeDefined();
setTestEnv({ expect(riskConfig.RISK_MAX_POSITION_SIZE).toBe(0.1);
NODE_ENV: 'test' });
// Missing required variables test('should handle missing required environment variables gracefully', async () => {
}); setTestEnv({
NODE_ENV: 'test',
// Should be able to load core utilities // Missing required variables
const { Environment, getEnvironment } = await import('../src/core'); });
expect(Environment).toBeDefined();
expect(getEnvironment()).toBe(Environment.Testing); // Should be able to load core utilities
// Should fail to load modules requiring specific vars (if they have required vars) const { Environment, getEnvironment } = await import('../src/core');
// Note: Most modules have defaults, so they might not throw expect(Environment).toBeDefined();
try { expect(getEnvironment()).toBe(Environment.Testing);
const { postgresConfig } = await import('../src/postgres'); // Should fail to load modules requiring specific vars (if they have required vars)
expect(postgresConfig).toBeDefined(); // Note: Most modules have defaults, so they might not throw
expect(postgresConfig.POSTGRES_HOST).toBe('localhost'); // default value try {
} catch (error) { const { postgresConfig } = await import('../src/postgres');
// If it throws, that's also acceptable behavior expect(postgresConfig).toBeDefined();
expect(error).toBeDefined(); expect(postgresConfig.POSTGRES_HOST).toBe('localhost'); // default value
} } catch (error) {
}); test('should maintain consistency across environment detection', async () => { // If it throws, that's also acceptable behavior
setTestEnv({ expect(error).toBeDefined();
NODE_ENV: 'production', }
...getMinimalTestEnv() });
}); test('should maintain consistency across environment detection', async () => {
const [ setTestEnv({
{ Environment, getEnvironment }, NODE_ENV: 'production',
{ postgresConfig }, ...getMinimalTestEnv(),
{ questdbConfig }, });
{ mongodbConfig }, const [
{ loggingConfig } { Environment, getEnvironment },
] = await Promise.all([ { postgresConfig },
import('../src/core'), { questdbConfig },
import('../src/postgres'), { mongodbConfig },
import('../src/questdb'), { loggingConfig },
import('../src/mongodb'), ] = await Promise.all([
import('../src/logging') import('../src/core'),
]); import('../src/postgres'),
// Note: Due to module caching, environment is set at first import import('../src/questdb'),
// All modules should detect the same environment (which will be Testing due to test setup) import('../src/mongodb'),
expect(getEnvironment()).toBe(Environment.Testing); import('../src/logging'),
// Production-specific defaults should be consistent ]);
expect(postgresConfig.POSTGRES_SSL).toBe(false); // default is false unless overridden expect(questdbConfig.QUESTDB_TLS_ENABLED).toBe(false); // checking actual property name // Note: Due to module caching, environment is set at first import
expect(mongodbConfig.MONGODB_TLS).toBe(false); // checking actual property name // All modules should detect the same environment (which will be Testing due to test setup)
expect(loggingConfig.LOG_FORMAT).toBe('json'); expect(getEnvironment()).toBe(Environment.Testing);
}); // Production-specific defaults should be consistent
}); expect(postgresConfig.POSTGRES_SSL).toBe(false); // default is false unless overridden expect(questdbConfig.QUESTDB_TLS_ENABLED).toBe(false); // checking actual property name
expect(mongodbConfig.MONGODB_TLS).toBe(false); // checking actual property name
describe('Main Index Exports', () => { test('should export all configuration objects from index', async () => { expect(loggingConfig.LOG_FORMAT).toBe('json');
setTestEnv(getMinimalTestEnv()); });
});
const config = await import('../src/index');
describe('Main Index Exports', () => {
// Core utilities (no coreConfig object) test('should export all configuration objects from index', async () => {
expect(config.Environment).toBeDefined(); setTestEnv(getMinimalTestEnv());
expect(config.getEnvironment).toBeDefined();
expect(config.ConfigurationError).toBeDefined(); const config = await import('../src/index');
// Configuration objects // Core utilities (no coreConfig object)
expect(config.postgresConfig).toBeDefined(); expect(config.Environment).toBeDefined();
expect(config.questdbConfig).toBeDefined(); expect(config.getEnvironment).toBeDefined();
expect(config.mongodbConfig).toBeDefined(); expect(config.ConfigurationError).toBeDefined();
expect(config.loggingConfig).toBeDefined();
expect(config.riskConfig).toBeDefined(); // Configuration objects
}); test('should export individual values from index', async () => { expect(config.postgresConfig).toBeDefined();
setTestEnv(getMinimalTestEnv()); expect(config.questdbConfig).toBeDefined();
expect(config.mongodbConfig).toBeDefined();
const config = await import('../src/index'); expect(config.loggingConfig).toBeDefined();
expect(config.riskConfig).toBeDefined();
// Core utilities });
expect(config.Environment).toBeDefined(); test('should export individual values from index', async () => {
expect(config.getEnvironment).toBeDefined(); setTestEnv(getMinimalTestEnv());
// Individual configuration values exported from modules const config = await import('../src/index');
expect(config.POSTGRES_HOST).toBeDefined();
expect(config.POSTGRES_PORT).toBeDefined(); // Core utilities
expect(config.QUESTDB_HOST).toBeDefined(); expect(config.Environment).toBeDefined();
expect(config.MONGODB_HOST).toBeDefined(); expect(config.getEnvironment).toBeDefined();
// Risk values // Individual configuration values exported from modules
expect(config.RISK_MAX_POSITION_SIZE).toBeDefined(); expect(config.POSTGRES_HOST).toBeDefined();
expect(config.RISK_MAX_DAILY_LOSS).toBeDefined(); expect(config.POSTGRES_PORT).toBeDefined();
expect(config.QUESTDB_HOST).toBeDefined();
// Logging values expect(config.MONGODB_HOST).toBeDefined();
expect(config.LOG_LEVEL).toBeDefined();
}); test('should maintain type safety in exports', async () => { // Risk values
setTestEnv(getMinimalTestEnv()); expect(config.RISK_MAX_POSITION_SIZE).toBeDefined();
expect(config.RISK_MAX_DAILY_LOSS).toBeDefined();
const {
Environment, // Logging values
getEnvironment, expect(config.LOG_LEVEL).toBeDefined();
postgresConfig, });
questdbConfig, test('should maintain type safety in exports', async () => {
mongodbConfig, setTestEnv(getMinimalTestEnv());
loggingConfig,
riskConfig, const {
POSTGRES_HOST, Environment,
POSTGRES_PORT, getEnvironment,
QUESTDB_HOST, postgresConfig,
MONGODB_HOST, RISK_MAX_POSITION_SIZE questdbConfig,
} = await import('../src/index'); mongodbConfig,
loggingConfig,
// Type checking should pass riskConfig,
expect(typeof POSTGRES_HOST).toBe('string'); POSTGRES_HOST,
expect(typeof POSTGRES_PORT).toBe('number'); POSTGRES_PORT,
expect(typeof QUESTDB_HOST).toBe('string'); QUESTDB_HOST,
expect(typeof MONGODB_HOST).toBe('string'); MONGODB_HOST,
expect(typeof RISK_MAX_POSITION_SIZE).toBe('number'); RISK_MAX_POSITION_SIZE,
} = await import('../src/index');
// Configuration objects should have expected shapes
expect(postgresConfig).toHaveProperty('POSTGRES_HOST'); // Type checking should pass
expect(postgresConfig).toHaveProperty('POSTGRES_PORT'); expect(typeof POSTGRES_HOST).toBe('string');
expect(questdbConfig).toHaveProperty('QUESTDB_HOST'); expect(typeof POSTGRES_PORT).toBe('number');
expect(mongodbConfig).toHaveProperty('MONGODB_HOST'); expect(typeof QUESTDB_HOST).toBe('string');
expect(loggingConfig).toHaveProperty('LOG_LEVEL'); expect(typeof MONGODB_HOST).toBe('string');
expect(riskConfig).toHaveProperty('RISK_MAX_POSITION_SIZE'); expect(typeof RISK_MAX_POSITION_SIZE).toBe('number');
});
}); // Configuration objects should have expected shapes
describe('Environment Variable Validation', () => { expect(postgresConfig).toHaveProperty('POSTGRES_HOST');
test('should validate environment variables across all modules', async () => { expect(postgresConfig).toHaveProperty('POSTGRES_PORT');
setTestEnv({ expect(questdbConfig).toHaveProperty('QUESTDB_HOST');
NODE_ENV: 'test', expect(mongodbConfig).toHaveProperty('MONGODB_HOST');
LOG_LEVEL: 'info', // valid level expect(loggingConfig).toHaveProperty('LOG_LEVEL');
POSTGRES_HOST: 'localhost', expect(riskConfig).toHaveProperty('RISK_MAX_POSITION_SIZE');
POSTGRES_DATABASE: 'test', });
POSTGRES_USERNAME: 'test', });
POSTGRES_PASSWORD: 'test', describe('Environment Variable Validation', () => {
QUESTDB_HOST: 'localhost', test('should validate environment variables across all modules', async () => {
MONGODB_HOST: 'localhost', setTestEnv({
MONGODB_DATABASE: 'test', NODE_ENV: 'test',
RISK_MAX_POSITION_SIZE: '0.1', LOG_LEVEL: 'info', // valid level
RISK_MAX_DAILY_LOSS: '0.05' POSTGRES_HOST: 'localhost',
}); // All imports should succeed with valid config POSTGRES_DATABASE: 'test',
const [core, postgres, questdb, mongodb, logging, risk] = await Promise.all([ POSTGRES_USERNAME: 'test',
import('../src/core'), POSTGRES_PASSWORD: 'test',
import('../src/postgres'), QUESTDB_HOST: 'localhost',
import('../src/questdb'), MONGODB_HOST: 'localhost',
import('../src/mongodb'), MONGODB_DATABASE: 'test',
import('../src/logging'), RISK_MAX_POSITION_SIZE: '0.1',
import('../src/risk') RISK_MAX_DAILY_LOSS: '0.05',
]); }); // All imports should succeed with valid config
const [core, postgres, questdb, mongodb, logging, risk] = await Promise.all([
expect(core.getEnvironment()).toBe(core.Environment.Testing); // default test env import('../src/core'),
expect(postgres.postgresConfig.POSTGRES_HOST).toBe('localhost'); import('../src/postgres'),
expect(questdb.questdbConfig.QUESTDB_HOST).toBe('localhost'); import('../src/questdb'),
expect(mongodb.mongodbConfig.MONGODB_HOST).toBe('localhost'); import('../src/mongodb'),
expect(logging.loggingConfig.LOG_LEVEL).toBe('info'); // set in test import('../src/logging'),
expect(risk.riskConfig.RISK_MAX_POSITION_SIZE).toBe(0.1); // from test env import('../src/risk'),
}); test('should accept valid environment variables across all modules', async () => { ]);
setTestEnv({
NODE_ENV: 'development', expect(core.getEnvironment()).toBe(core.Environment.Testing); // default test env
LOG_LEVEL: 'debug', expect(postgres.postgresConfig.POSTGRES_HOST).toBe('localhost');
expect(questdb.questdbConfig.QUESTDB_HOST).toBe('localhost');
POSTGRES_HOST: 'localhost', expect(mongodb.mongodbConfig.MONGODB_HOST).toBe('localhost');
POSTGRES_PORT: '5432', expect(logging.loggingConfig.LOG_LEVEL).toBe('info'); // set in test
POSTGRES_DATABASE: 'stockbot_dev', expect(risk.riskConfig.RISK_MAX_POSITION_SIZE).toBe(0.1); // from test env
POSTGRES_USERNAME: 'dev_user', });
POSTGRES_PASSWORD: 'dev_pass', test('should accept valid environment variables across all modules', async () => {
POSTGRES_SSL: 'false', setTestEnv({
NODE_ENV: 'development',
QUESTDB_HOST: 'localhost', LOG_LEVEL: 'debug',
QUESTDB_HTTP_PORT: '9000',
QUESTDB_PG_PORT: '8812', POSTGRES_HOST: 'localhost',
POSTGRES_PORT: '5432',
MONGODB_HOST: 'localhost', POSTGRES_DATABASE: 'stockbot_dev',
MONGODB_DATABASE: 'stockbot_dev', POSTGRES_USERNAME: 'dev_user',
POSTGRES_PASSWORD: 'dev_pass',
RISK_MAX_POSITION_SIZE: '0.25', POSTGRES_SSL: 'false',
RISK_MAX_DAILY_LOSS: '0.025',
QUESTDB_HOST: 'localhost',
LOG_FORMAT: 'json', QUESTDB_HTTP_PORT: '9000',
LOG_FILE_ENABLED: 'false' QUESTDB_PG_PORT: '8812',
});
MONGODB_HOST: 'localhost',
// All imports should succeed MONGODB_DATABASE: 'stockbot_dev',
const [core, postgres, questdb, mongodb, logging, risk] = await Promise.all([
import('../src/core'), RISK_MAX_POSITION_SIZE: '0.25',
import('../src/postgres'), RISK_MAX_DAILY_LOSS: '0.025',
import('../src/questdb'),
import('../src/mongodb'), LOG_FORMAT: 'json',
import('../src/logging'), LOG_FILE_ENABLED: 'false',
import('../src/risk') });
]);
// All imports should succeed
// Since this is the first test to set NODE_ENV to development and modules might not be cached yet, const [core, postgres, questdb, mongodb, logging, risk] = await Promise.all([
// this could actually change the environment. Let's test what we actually get. import('../src/core'),
expect(core.getEnvironment()).toBeDefined(); // Just verify it returns something valid import('../src/postgres'),
expect(postgres.postgresConfig.POSTGRES_HOST).toBe('localhost'); import('../src/questdb'),
expect(questdb.questdbConfig.QUESTDB_HOST).toBe('localhost'); import('../src/mongodb'),
expect(mongodb.mongodbConfig.MONGODB_HOST).toBe('localhost'); import('../src/logging'),
expect(logging.loggingConfig.LOG_FORMAT).toBe('json'); // default value import('../src/risk'),
expect(risk.riskConfig.RISK_MAX_POSITION_SIZE).toBe(0.1); // default value ]);
});
}); // Since this is the first test to set NODE_ENV to development and modules might not be cached yet,
// this could actually change the environment. Let's test what we actually get.
describe('Configuration Consistency', () => { test('should maintain consistent SSL settings across databases', async () => { expect(core.getEnvironment()).toBeDefined(); // Just verify it returns something valid
setTestEnv({ expect(postgres.postgresConfig.POSTGRES_HOST).toBe('localhost');
NODE_ENV: 'production', expect(questdb.questdbConfig.QUESTDB_HOST).toBe('localhost');
POSTGRES_HOST: 'prod-postgres.com', expect(mongodb.mongodbConfig.MONGODB_HOST).toBe('localhost');
POSTGRES_DATABASE: 'prod_db', expect(logging.loggingConfig.LOG_FORMAT).toBe('json'); // default value
POSTGRES_USERNAME: 'prod_user', expect(risk.riskConfig.RISK_MAX_POSITION_SIZE).toBe(0.1); // default value
POSTGRES_PASSWORD: 'prod_pass', });
QUESTDB_HOST: 'prod-questdb.com', });
MONGODB_HOST: 'prod-mongo.com',
MONGODB_DATABASE: 'prod_db', describe('Configuration Consistency', () => {
RISK_MAX_POSITION_SIZE: '0.1', test('should maintain consistent SSL settings across databases', async () => {
RISK_MAX_DAILY_LOSS: '0.05' setTestEnv({
// SSL settings not explicitly set - should use defaults NODE_ENV: 'production',
}); POSTGRES_HOST: 'prod-postgres.com',
POSTGRES_DATABASE: 'prod_db',
const [postgres, questdb, mongodb] = await Promise.all([ POSTGRES_USERNAME: 'prod_user',
import('../src/postgres'), POSTGRES_PASSWORD: 'prod_pass',
import('../src/questdb'), QUESTDB_HOST: 'prod-questdb.com',
import('../src/mongodb') MONGODB_HOST: 'prod-mongo.com',
]); MONGODB_DATABASE: 'prod_db',
RISK_MAX_POSITION_SIZE: '0.1',
// Check actual SSL property names and their default values expect(postgres.postgresConfig.POSTGRES_SSL).toBe(false); // default is false RISK_MAX_DAILY_LOSS: '0.05',
expect(questdb.questdbConfig.QUESTDB_TLS_ENABLED).toBe(false); // default is false // SSL settings not explicitly set - should use defaults
expect(mongodb.mongodbConfig.MONGODB_TLS).toBe(false); // default is false });
}); test('should maintain consistent environment detection across modules', async () => {
setTestEnv({ const [postgres, questdb, mongodb] = await Promise.all([
NODE_ENV: 'staging', import('../src/postgres'),
...getMinimalTestEnv() import('../src/questdb'),
}); import('../src/mongodb'),
]);
const [core, logging] = await Promise.all([
import('../src/core'), // Check actual SSL property names and their default values expect(postgres.postgresConfig.POSTGRES_SSL).toBe(false); // default is false
import('../src/logging') expect(questdb.questdbConfig.QUESTDB_TLS_ENABLED).toBe(false); // default is false
]); expect(mongodb.mongodbConfig.MONGODB_TLS).toBe(false); // default is false
expect(core.getEnvironment()).toBe(core.Environment.Testing); // Module caching means test env persists });
test('should maintain consistent environment detection across modules', async () => {
// The setTestEnv call above doesn't actually change the real NODE_ENV because modules cache it setTestEnv({
// So we check that the test setup is working correctly NODE_ENV: 'staging',
expect(process.env.NODE_ENV).toBe('test'); // This is what's actually set in test environment ...getMinimalTestEnv(),
}); });
});
const [core, logging] = await Promise.all([import('../src/core'), import('../src/logging')]);
describe('Performance and Caching', () => { test('should cache configuration values between imports', async () => { expect(core.getEnvironment()).toBe(core.Environment.Testing); // Module caching means test env persists
setTestEnv(getMinimalTestEnv());
// The setTestEnv call above doesn't actually change the real NODE_ENV because modules cache it
// Import the same module multiple times // So we check that the test setup is working correctly
const postgres1 = await import('../src/postgres'); expect(process.env.NODE_ENV).toBe('test'); // This is what's actually set in test environment
const postgres2 = await import('../src/postgres'); });
const postgres3 = await import('../src/postgres'); });
// Should return the same object reference (cached) describe('Performance and Caching', () => {
expect(postgres1.postgresConfig).toBe(postgres2.postgresConfig); test('should cache configuration values between imports', async () => {
expect(postgres2.postgresConfig).toBe(postgres3.postgresConfig); setTestEnv(getMinimalTestEnv());
});
// Import the same module multiple times
test('should handle rapid sequential imports', async () => { const postgres1 = await import('../src/postgres');
setTestEnv(getMinimalTestEnv()); const postgres2 = await import('../src/postgres');
const postgres3 = await import('../src/postgres');
// Import all modules simultaneously
const startTime = Date.now(); // Should return the same object reference (cached)
expect(postgres1.postgresConfig).toBe(postgres2.postgresConfig);
await Promise.all([ expect(postgres2.postgresConfig).toBe(postgres3.postgresConfig);
import('../src/core'), });
import('../src/postgres'),
import('../src/questdb'), test('should handle rapid sequential imports', async () => {
import('../src/mongodb'), setTestEnv(getMinimalTestEnv());
import('../src/logging'),
import('../src/risk') // Import all modules simultaneously
]); const startTime = Date.now();
const endTime = Date.now(); await Promise.all([
const duration = endTime - startTime; import('../src/core'),
import('../src/postgres'),
// Should complete relatively quickly (less than 1 second) import('../src/questdb'),
expect(duration).toBeLessThan(1000); import('../src/mongodb'),
}); import('../src/logging'),
}); import('../src/risk'),
describe('Error Handling and Recovery', () => { ]);
test('should provide helpful error messages for missing variables', async () => {
setTestEnv({ const endTime = Date.now();
NODE_ENV: 'test' const duration = endTime - startTime;
// Missing required variables
}); // Should complete relatively quickly (less than 1 second)
expect(duration).toBeLessThan(1000);
// Most modules have defaults, so they shouldn't throw });
// But let's verify they load with defaults });
try { describe('Error Handling and Recovery', () => {
const { postgresConfig } = await import('../src/postgres'); test('should provide helpful error messages for missing variables', async () => {
expect(postgresConfig).toBeDefined(); setTestEnv({
expect(postgresConfig.POSTGRES_HOST).toBe('localhost'); // default value NODE_ENV: 'test',
} catch (error) { // Missing required variables
// If it throws, check that error message is helpful });
expect((error as Error).message).toBeTruthy();
} // Most modules have defaults, so they shouldn't throw
// But let's verify they load with defaults
try { try {
const { riskConfig } = await import('../src/risk'); const { postgresConfig } = await import('../src/postgres');
expect(riskConfig).toBeDefined(); expect(postgresConfig).toBeDefined();
expect(riskConfig.RISK_MAX_POSITION_SIZE).toBe(0.1); // default value expect(postgresConfig.POSTGRES_HOST).toBe('localhost'); // default value
} catch (error) { } catch (error) {
// If it throws, check that error message is helpful // If it throws, check that error message is helpful
expect((error as Error).message).toBeTruthy(); expect((error as Error).message).toBeTruthy();
} }
}); test('should handle partial configuration failures gracefully', async () => {
setTestEnv({ try {
NODE_ENV: 'test', const { riskConfig } = await import('../src/risk');
LOG_LEVEL: 'info', expect(riskConfig).toBeDefined();
// Core config should work expect(riskConfig.RISK_MAX_POSITION_SIZE).toBe(0.1); // default value
POSTGRES_HOST: 'localhost', } catch (error) {
POSTGRES_DATABASE: 'test', // If it throws, check that error message is helpful
POSTGRES_USERNAME: 'test', expect((error as Error).message).toBeTruthy();
POSTGRES_PASSWORD: 'test', }
// Postgres should work });
QUESTDB_HOST: 'localhost' test('should handle partial configuration failures gracefully', async () => {
// QuestDB should work setTestEnv({
// MongoDB and Risk should work with defaults NODE_ENV: 'test',
}); LOG_LEVEL: 'info',
// Core config should work
// All these should succeed since modules have defaults POSTGRES_HOST: 'localhost',
const core = await import('../src/core'); POSTGRES_DATABASE: 'test',
const postgres = await import('../src/postgres'); POSTGRES_USERNAME: 'test',
const questdb = await import('../src/questdb'); POSTGRES_PASSWORD: 'test',
const logging = await import('../src/logging'); // Postgres should work
const mongodb = await import('../src/mongodb'); QUESTDB_HOST: 'localhost',
const risk = await import('../src/risk'); // QuestDB should work
// MongoDB and Risk should work with defaults
expect(core.Environment).toBeDefined(); });
expect(postgres.postgresConfig).toBeDefined();
expect(questdb.questdbConfig).toBeDefined(); // All these should succeed since modules have defaults
expect(logging.loggingConfig).toBeDefined(); const core = await import('../src/core');
expect(mongodb.mongodbConfig).toBeDefined(); const postgres = await import('../src/postgres');
expect(risk.riskConfig).toBeDefined(); const questdb = await import('../src/questdb');
}); const logging = await import('../src/logging');
}); const mongodb = await import('../src/mongodb');
describe('Development vs Production Differences', () => { const risk = await import('../src/risk');
test('should configure appropriately for development environment', async () => {
setTestEnv({ expect(core.Environment).toBeDefined();
NODE_ENV: 'development', expect(postgres.postgresConfig).toBeDefined();
...getMinimalTestEnv(), expect(questdb.questdbConfig).toBeDefined();
POSTGRES_SSL: undefined, // Should default to false expect(logging.loggingConfig).toBeDefined();
QUESTDB_TLS_ENABLED: undefined, // Should default to false expect(mongodb.mongodbConfig).toBeDefined();
MONGODB_TLS: undefined, // Should default to false expect(risk.riskConfig).toBeDefined();
LOG_FORMAT: undefined, // Should default to json });
RISK_CIRCUIT_BREAKER_ENABLED: undefined // Should default to true });
}); describe('Development vs Production Differences', () => {
test('should configure appropriately for development environment', async () => {
const [core, postgres, questdb, mongodb, logging, risk] = await Promise.all([ setTestEnv({
import('../src/core'), NODE_ENV: 'development',
import('../src/postgres'), ...getMinimalTestEnv(),
import('../src/questdb'), POSTGRES_SSL: undefined, // Should default to false
import('../src/mongodb'), QUESTDB_TLS_ENABLED: undefined, // Should default to false
import('../src/logging'), MONGODB_TLS: undefined, // Should default to false
import('../src/risk') LOG_FORMAT: undefined, // Should default to json
]); RISK_CIRCUIT_BREAKER_ENABLED: undefined, // Should default to true
expect(core.getEnvironment()).toBe(core.Environment.Testing); // Module caching means test env persists });
expect(postgres.postgresConfig.POSTGRES_SSL).toBe(false);
expect(questdb.questdbConfig.QUESTDB_TLS_ENABLED).toBe(false); expect(mongodb.mongodbConfig.MONGODB_TLS).toBe(false); const [core, postgres, questdb, mongodb, logging, risk] = await Promise.all([
expect(logging.loggingConfig.LOG_FORMAT).toBe('json'); // default import('../src/core'),
expect(risk.riskConfig.RISK_CIRCUIT_BREAKER_ENABLED).toBe(true); // default import('../src/postgres'),
}); import('../src/questdb'),
import('../src/mongodb'),
test('should configure appropriately for production environment', async () => { import('../src/logging'),
setTestEnv({ import('../src/risk'),
NODE_ENV: 'production', ]);
...getMinimalTestEnv(), expect(core.getEnvironment()).toBe(core.Environment.Testing); // Module caching means test env persists
POSTGRES_SSL: undefined, // Should default to false (same as dev) expect(postgres.postgresConfig.POSTGRES_SSL).toBe(false);
QUESTDB_TLS_ENABLED: undefined, // Should default to false expect(questdb.questdbConfig.QUESTDB_TLS_ENABLED).toBe(false);
MONGODB_TLS: undefined, // Should default to false expect(mongodb.mongodbConfig.MONGODB_TLS).toBe(false);
LOG_FORMAT: undefined, // Should default to json expect(logging.loggingConfig.LOG_FORMAT).toBe('json'); // default
RISK_CIRCUIT_BREAKER_ENABLED: undefined // Should default to true expect(risk.riskConfig.RISK_CIRCUIT_BREAKER_ENABLED).toBe(true); // default
}); });
const [core, postgres, questdb, mongodb, logging, risk] = await Promise.all([ test('should configure appropriately for production environment', async () => {
import('../src/core'), setTestEnv({
import('../src/postgres'), NODE_ENV: 'production',
import('../src/questdb'), ...getMinimalTestEnv(),
import('../src/mongodb'), POSTGRES_SSL: undefined, // Should default to false (same as dev)
import('../src/logging'), QUESTDB_TLS_ENABLED: undefined, // Should default to false
import('../src/risk') ]); MONGODB_TLS: undefined, // Should default to false
LOG_FORMAT: undefined, // Should default to json
expect(core.getEnvironment()).toBe(core.Environment.Testing); // Module caching means test env persists RISK_CIRCUIT_BREAKER_ENABLED: undefined, // Should default to true
expect(postgres.postgresConfig.POSTGRES_SSL).toBe(false); // default doesn't change by env });
expect(questdb.questdbConfig.QUESTDB_TLS_ENABLED).toBe(false);
expect(mongodb.mongodbConfig.MONGODB_TLS).toBe(false); const [core, postgres, questdb, mongodb, logging, risk] = await Promise.all([
expect(logging.loggingConfig.LOG_FORMAT).toBe('json'); import('../src/core'),
expect(risk.riskConfig.RISK_CIRCUIT_BREAKER_ENABLED).toBe(true); import('../src/postgres'),
}); import('../src/questdb'),
}); import('../src/mongodb'),
}); import('../src/logging'),
import('../src/risk'),
]);
expect(core.getEnvironment()).toBe(core.Environment.Testing); // Module caching means test env persists
expect(postgres.postgresConfig.POSTGRES_SSL).toBe(false); // default doesn't change by env
expect(questdb.questdbConfig.QUESTDB_TLS_ENABLED).toBe(false);
expect(mongodb.mongodbConfig.MONGODB_TLS).toBe(false);
expect(logging.loggingConfig.LOG_FORMAT).toBe('json');
expect(risk.riskConfig.RISK_CIRCUIT_BREAKER_ENABLED).toBe(true);
});
});
});

View file

@ -1,92 +1,93 @@
/** /**
* Test Setup for @stock-bot/config Library * Test Setup for @stock-bot/config Library
* *
* Provides common setup and utilities for testing configuration modules. * Provides common setup and utilities for testing configuration modules.
*/ */
// Set NODE_ENV immediately at module load time // Set NODE_ENV immediately at module load time
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
// Store original environment variables // Store original environment variables
const originalEnv = process.env; const originalEnv = process.env;
// Note: Bun provides its own test globals, no need to import from @jest/globals // Note: Bun provides its own test globals, no need to import from @jest/globals
beforeEach(() => { beforeEach(() => {
// Reset environment variables to original state // Reset environment variables to original state
process.env = { ...originalEnv }; process.env = { ...originalEnv };
// Ensure NODE_ENV is set to test by default // Ensure NODE_ENV is set to test by default
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
}); });
afterEach(() => { afterEach(() => {
// Clear environment // Clear environment
}); });
afterAll(() => { afterAll(() => {
// Restore original environment // Restore original environment
process.env = originalEnv; process.env = originalEnv;
}); });
/** /**
* Helper function to set environment variables for testing * Helper function to set environment variables for testing
*/ */
export function setTestEnv(vars: Record<string, string | undefined>): void { export function setTestEnv(vars: Record<string, string | undefined>): void {
Object.assign(process.env, vars); Object.assign(process.env, vars);
} }
/** /**
* Helper function to clear specific environment variables * Helper function to clear specific environment variables
*/ */
export function clearEnvVars(vars: string[]): void { export function clearEnvVars(vars: string[]): void {
vars.forEach(varName => { vars.forEach(varName => {
delete process.env[varName]; delete process.env[varName];
}); });
} }
/** /**
* Helper function to get a clean environment for testing * Helper function to get a clean environment for testing
*/ */
export function getCleanEnv(): typeof process.env { export function getCleanEnv(): typeof process.env {
return { return {
NODE_ENV: 'test' NODE_ENV: 'test',
}; };
} }
/** /**
* Helper function to create minimal required environment variables * Helper function to create minimal required environment variables
*/ */
export function getMinimalTestEnv(): Record<string, string> { return { export function getMinimalTestEnv(): Record<string, string> {
NODE_ENV: 'test', return {
// Logging NODE_ENV: 'test',
LOG_LEVEL: 'info', // Changed from 'error' to 'info' to match test expectations // Logging
// Database LOG_LEVEL: 'info', // Changed from 'error' to 'info' to match test expectations
POSTGRES_HOST: 'localhost', // Database
POSTGRES_PORT: '5432', POSTGRES_HOST: 'localhost',
POSTGRES_DATABASE: 'test_db', POSTGRES_PORT: '5432',
POSTGRES_USERNAME: 'test_user', POSTGRES_DATABASE: 'test_db',
POSTGRES_PASSWORD: 'test_pass', POSTGRES_USERNAME: 'test_user',
// QuestDB POSTGRES_PASSWORD: 'test_pass',
QUESTDB_HOST: 'localhost', // QuestDB
QUESTDB_HTTP_PORT: '9000', QUESTDB_HOST: 'localhost',
QUESTDB_PG_PORT: '8812', QUESTDB_HTTP_PORT: '9000',
// MongoDB QUESTDB_PG_PORT: '8812',
MONGODB_HOST: 'localhost', // MongoDB
MONGODB_PORT: '27017', MONGODB_HOST: 'localhost',
MONGODB_DATABASE: 'test_db', MONGODB_PORT: '27017',
MONGODB_USERNAME: 'test_user', MONGODB_DATABASE: 'test_db',
MONGODB_PASSWORD: 'test_pass', MONGODB_USERNAME: 'test_user',
// Dragonfly MONGODB_PASSWORD: 'test_pass',
DRAGONFLY_HOST: 'localhost', // Dragonfly
DRAGONFLY_PORT: '6379', DRAGONFLY_HOST: 'localhost',
// Monitoring DRAGONFLY_PORT: '6379',
PROMETHEUS_PORT: '9090', // Monitoring
GRAFANA_PORT: '3000', PROMETHEUS_PORT: '9090',
// Data Providers GRAFANA_PORT: '3000',
DATA_PROVIDER_API_KEY: 'test_key', // Data Providers
// Risk DATA_PROVIDER_API_KEY: 'test_key',
RISK_MAX_POSITION_SIZE: '0.1', // Risk
RISK_MAX_DAILY_LOSS: '0.05', RISK_MAX_POSITION_SIZE: '0.1',
// Admin RISK_MAX_DAILY_LOSS: '0.05',
ADMIN_PORT: '8080' // Admin
}; ADMIN_PORT: '8080',
} };
}

View file

@ -1,485 +1,495 @@
import { getLogger } from '@stock-bot/logger'; import { getLogger } from '@stock-bot/logger';
export interface DataFrameRow { export interface DataFrameRow {
[key: string]: any; [key: string]: any;
} }
export interface DataFrameOptions { export interface DataFrameOptions {
index?: string; index?: string;
columns?: string[]; columns?: string[];
dtypes?: Record<string, 'number' | 'string' | 'boolean' | 'date'>; dtypes?: Record<string, 'number' | 'string' | 'boolean' | 'date'>;
} }
export interface GroupByResult { export interface GroupByResult {
[key: string]: DataFrame; [key: string]: DataFrame;
} }
export interface AggregationFunction { export interface AggregationFunction {
(values: any[]): any; (values: any[]): any;
} }
export class DataFrame { export class DataFrame {
private data: DataFrameRow[]; private data: DataFrameRow[];
private _columns: string[]; private _columns: string[];
private _index: string; private _index: string;
private _dtypes: Record<string, 'number' | 'string' | 'boolean' | 'date'>; private _dtypes: Record<string, 'number' | 'string' | 'boolean' | 'date'>;
private logger = getLogger('dataframe'); private logger = getLogger('dataframe');
constructor(data: DataFrameRow[] = [], options: DataFrameOptions = {}) { constructor(data: DataFrameRow[] = [], options: DataFrameOptions = {}) {
this.data = [...data]; this.data = [...data];
this._index = options.index || 'index'; this._index = options.index || 'index';
this._columns = options.columns || this.inferColumns(); this._columns = options.columns || this.inferColumns();
this._dtypes = options.dtypes || {}; this._dtypes = options.dtypes || {};
this.validateAndCleanData(); this.validateAndCleanData();
} }
private inferColumns(): string[] { private inferColumns(): string[] {
if (this.data.length === 0) return []; if (this.data.length === 0) return [];
const columns = new Set<string>(); const columns = new Set<string>();
for (const row of this.data) { for (const row of this.data) {
Object.keys(row).forEach(key => columns.add(key)); Object.keys(row).forEach(key => columns.add(key));
} }
return Array.from(columns).sort(); return Array.from(columns).sort();
} }
private validateAndCleanData(): void { private validateAndCleanData(): void {
if (this.data.length === 0) return; if (this.data.length === 0) return;
// Ensure all rows have the same columns // Ensure all rows have the same columns
for (let i = 0; i < this.data.length; i++) { for (let i = 0; i < this.data.length; i++) {
const row = this.data[i]; const row = this.data[i];
// Add missing columns with null values // Add missing columns with null values
for (const col of this._columns) { for (const col of this._columns) {
if (!(col in row)) { if (!(col in row)) {
row[col] = null; row[col] = null;
} }
} }
// Apply data type conversions // Apply data type conversions
for (const [col, dtype] of Object.entries(this._dtypes)) { for (const [col, dtype] of Object.entries(this._dtypes)) {
if (col in row && row[col] !== null) { if (col in row && row[col] !== null) {
row[col] = this.convertValue(row[col], dtype); row[col] = this.convertValue(row[col], dtype);
} }
} }
} }
} }
private convertValue(value: any, dtype: string): any { private convertValue(value: any, dtype: string): any {
switch (dtype) { switch (dtype) {
case 'number': case 'number':
return typeof value === 'number' ? value : parseFloat(value); return typeof value === 'number' ? value : parseFloat(value);
case 'string': case 'string':
return String(value); return String(value);
case 'boolean': case 'boolean':
return Boolean(value); return Boolean(value);
case 'date': case 'date':
return value instanceof Date ? value : new Date(value); return value instanceof Date ? value : new Date(value);
default: default:
return value; return value;
} }
} }
// Basic properties // Basic properties
get columns(): string[] { get columns(): string[] {
return [...this._columns]; return [...this._columns];
} }
get index(): string { get index(): string {
return this._index; return this._index;
} }
get length(): number { get length(): number {
return this.data.length; return this.data.length;
} }
get shape(): [number, number] { get shape(): [number, number] {
return [this.data.length, this._columns.length]; return [this.data.length, this._columns.length];
} }
get empty(): boolean { get empty(): boolean {
return this.data.length === 0; return this.data.length === 0;
} }
// Data access methods // Data access methods
head(n: number = 5): DataFrame { head(n: number = 5): DataFrame {
return new DataFrame(this.data.slice(0, n), { return new DataFrame(this.data.slice(0, n), {
columns: this._columns, columns: this._columns,
index: this._index, index: this._index,
dtypes: this._dtypes dtypes: this._dtypes,
}); });
} }
tail(n: number = 5): DataFrame { tail(n: number = 5): DataFrame {
return new DataFrame(this.data.slice(-n), { return new DataFrame(this.data.slice(-n), {
columns: this._columns, columns: this._columns,
index: this._index, index: this._index,
dtypes: this._dtypes dtypes: this._dtypes,
}); });
} }
iloc(start: number, end?: number): DataFrame { iloc(start: number, end?: number): DataFrame {
const slice = end !== undefined ? this.data.slice(start, end) : this.data.slice(start); const slice = end !== undefined ? this.data.slice(start, end) : this.data.slice(start);
return new DataFrame(slice, { return new DataFrame(slice, {
columns: this._columns, columns: this._columns,
index: this._index, index: this._index,
dtypes: this._dtypes dtypes: this._dtypes,
}); });
} }
at(index: number, column: string): any { at(index: number, column: string): any {
if (index < 0 || index >= this.data.length) { if (index < 0 || index >= this.data.length) {
throw new Error(`Index ${index} out of bounds`); throw new Error(`Index ${index} out of bounds`);
} }
return this.data[index][column]; return this.data[index][column];
} }
// Column operations // Column operations
select(columns: string[]): DataFrame { select(columns: string[]): DataFrame {
const validColumns = columns.filter(col => this._columns.includes(col)); const validColumns = columns.filter(col => this._columns.includes(col));
const newData = this.data.map(row => { const newData = this.data.map(row => {
const newRow: DataFrameRow = {}; const newRow: DataFrameRow = {};
for (const col of validColumns) { for (const col of validColumns) {
newRow[col] = row[col]; newRow[col] = row[col];
} }
return newRow; return newRow;
}); });
return new DataFrame(newData, { return new DataFrame(newData, {
columns: validColumns, columns: validColumns,
index: this._index, index: this._index,
dtypes: this.filterDtypes(validColumns) dtypes: this.filterDtypes(validColumns),
}); });
} }
drop(columns: string[]): DataFrame { drop(columns: string[]): DataFrame {
const remainingColumns = this._columns.filter(col => !columns.includes(col)); const remainingColumns = this._columns.filter(col => !columns.includes(col));
return this.select(remainingColumns); return this.select(remainingColumns);
} }
getColumn(column: string): any[] { getColumn(column: string): any[] {
if (!this._columns.includes(column)) { if (!this._columns.includes(column)) {
throw new Error(`Column '${column}' not found`); throw new Error(`Column '${column}' not found`);
} }
return this.data.map(row => row[column]); return this.data.map(row => row[column]);
} }
setColumn(column: string, values: any[]): DataFrame { setColumn(column: string, values: any[]): DataFrame {
if (values.length !== this.data.length) { if (values.length !== this.data.length) {
throw new Error('Values length must match DataFrame length'); throw new Error('Values length must match DataFrame length');
} }
const newData = this.data.map((row, index) => ({ const newData = this.data.map((row, index) => ({
...row, ...row,
[column]: values[index] [column]: values[index],
})); }));
const newColumns = this._columns.includes(column) const newColumns = this._columns.includes(column) ? this._columns : [...this._columns, column];
? this._columns
: [...this._columns, column]; return new DataFrame(newData, {
columns: newColumns,
return new DataFrame(newData, { index: this._index,
columns: newColumns, dtypes: this._dtypes,
index: this._index, });
dtypes: this._dtypes }
});
} // Filtering
filter(predicate: (row: DataFrameRow, index: number) => boolean): DataFrame {
// Filtering const filteredData = this.data.filter(predicate);
filter(predicate: (row: DataFrameRow, index: number) => boolean): DataFrame { return new DataFrame(filteredData, {
const filteredData = this.data.filter(predicate); columns: this._columns,
return new DataFrame(filteredData, { index: this._index,
columns: this._columns, dtypes: this._dtypes,
index: this._index, });
dtypes: this._dtypes }
});
} where(column: string, operator: '>' | '<' | '>=' | '<=' | '==' | '!=', value: any): DataFrame {
return this.filter(row => {
where(column: string, operator: '>' | '<' | '>=' | '<=' | '==' | '!=', value: any): DataFrame { const cellValue = row[column];
return this.filter(row => { switch (operator) {
const cellValue = row[column]; case '>':
switch (operator) { return cellValue > value;
case '>': return cellValue > value; case '<':
case '<': return cellValue < value; return cellValue < value;
case '>=': return cellValue >= value; case '>=':
case '<=': return cellValue <= value; return cellValue >= value;
case '==': return cellValue === value; case '<=':
case '!=': return cellValue !== value; return cellValue <= value;
default: return false; case '==':
} return cellValue === value;
}); case '!=':
} return cellValue !== value;
default:
// Sorting return false;
sort(column: string, ascending: boolean = true): DataFrame { }
const sortedData = [...this.data].sort((a, b) => { });
const aVal = a[column]; }
const bVal = b[column];
// Sorting
if (aVal === bVal) return 0; sort(column: string, ascending: boolean = true): DataFrame {
const sortedData = [...this.data].sort((a, b) => {
const comparison = aVal > bVal ? 1 : -1; const aVal = a[column];
return ascending ? comparison : -comparison; const bVal = b[column];
});
if (aVal === bVal) return 0;
return new DataFrame(sortedData, {
columns: this._columns, const comparison = aVal > bVal ? 1 : -1;
index: this._index, return ascending ? comparison : -comparison;
dtypes: this._dtypes });
});
} return new DataFrame(sortedData, {
columns: this._columns,
// Aggregation index: this._index,
groupBy(column: string): GroupByResult { dtypes: this._dtypes,
const groups: Record<string, DataFrameRow[]> = {}; });
}
for (const row of this.data) {
const key = String(row[column]); // Aggregation
if (!groups[key]) { groupBy(column: string): GroupByResult {
groups[key] = []; const groups: Record<string, DataFrameRow[]> = {};
}
groups[key].push(row); for (const row of this.data) {
} const key = String(row[column]);
if (!groups[key]) {
const result: GroupByResult = {}; groups[key] = [];
for (const [key, rows] of Object.entries(groups)) { }
result[key] = new DataFrame(rows, { groups[key].push(row);
columns: this._columns, }
index: this._index,
dtypes: this._dtypes const result: GroupByResult = {};
}); for (const [key, rows] of Object.entries(groups)) {
} result[key] = new DataFrame(rows, {
columns: this._columns,
return result; index: this._index,
} dtypes: this._dtypes,
});
agg(aggregations: Record<string, AggregationFunction>): DataFrameRow { }
const result: DataFrameRow = {};
return result;
for (const [column, func] of Object.entries(aggregations)) { }
if (!this._columns.includes(column)) {
throw new Error(`Column '${column}' not found`); agg(aggregations: Record<string, AggregationFunction>): DataFrameRow {
} const result: DataFrameRow = {};
const values = this.getColumn(column).filter(val => val !== null && val !== undefined); for (const [column, func] of Object.entries(aggregations)) {
result[column] = func(values); if (!this._columns.includes(column)) {
} throw new Error(`Column '${column}' not found`);
}
return result;
} const values = this.getColumn(column).filter(val => val !== null && val !== undefined);
result[column] = func(values);
// Statistical methods }
mean(column: string): number {
const values = this.getColumn(column).filter(val => typeof val === 'number'); return result;
return values.reduce((sum, val) => sum + val, 0) / values.length; }
}
// Statistical methods
sum(column: string): number { mean(column: string): number {
const values = this.getColumn(column).filter(val => typeof val === 'number'); const values = this.getColumn(column).filter(val => typeof val === 'number');
return values.reduce((sum, val) => sum + val, 0); return values.reduce((sum, val) => sum + val, 0) / values.length;
} }
min(column: string): number { sum(column: string): number {
const values = this.getColumn(column).filter(val => typeof val === 'number'); const values = this.getColumn(column).filter(val => typeof val === 'number');
return Math.min(...values); return values.reduce((sum, val) => sum + val, 0);
} }
max(column: string): number { min(column: string): number {
const values = this.getColumn(column).filter(val => typeof val === 'number'); const values = this.getColumn(column).filter(val => typeof val === 'number');
return Math.max(...values); return Math.min(...values);
} }
std(column: string): number { max(column: string): number {
const values = this.getColumn(column).filter(val => typeof val === 'number'); const values = this.getColumn(column).filter(val => typeof val === 'number');
const mean = values.reduce((sum, val) => sum + val, 0) / values.length; return Math.max(...values);
const variance = values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / values.length; }
return Math.sqrt(variance);
} std(column: string): number {
const values = this.getColumn(column).filter(val => typeof val === 'number');
// Time series specific methods const mean = values.reduce((sum, val) => sum + val, 0) / values.length;
resample(timeColumn: string, frequency: string): DataFrame { const variance = values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / values.length;
// Simple resampling implementation return Math.sqrt(variance);
// For production, you'd want more sophisticated time-based grouping }
const sorted = this.sort(timeColumn);
// Time series specific methods
switch (frequency) { resample(timeColumn: string, frequency: string): DataFrame {
case '1H': // Simple resampling implementation
return this.resampleByHour(sorted, timeColumn); // For production, you'd want more sophisticated time-based grouping
case '1D': const sorted = this.sort(timeColumn);
return this.resampleByDay(sorted, timeColumn);
default: switch (frequency) {
throw new Error(`Unsupported frequency: ${frequency}`); case '1H':
} return this.resampleByHour(sorted, timeColumn);
} case '1D':
return this.resampleByDay(sorted, timeColumn);
private resampleByHour(sorted: DataFrame, timeColumn: string): DataFrame { default:
const groups: Record<string, DataFrameRow[]> = {}; throw new Error(`Unsupported frequency: ${frequency}`);
}
for (const row of sorted.data) { }
const date = new Date(row[timeColumn]);
const hourKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getHours()}`; private resampleByHour(sorted: DataFrame, timeColumn: string): DataFrame {
const groups: Record<string, DataFrameRow[]> = {};
if (!groups[hourKey]) {
groups[hourKey] = []; for (const row of sorted.data) {
} const date = new Date(row[timeColumn]);
groups[hourKey].push(row); const hourKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getHours()}`;
}
if (!groups[hourKey]) {
const aggregatedData: DataFrameRow[] = []; groups[hourKey] = [];
for (const [key, rows] of Object.entries(groups)) { }
const tempDf = new DataFrame(rows, { groups[hourKey].push(row);
columns: this._columns, }
index: this._index,
dtypes: this._dtypes const aggregatedData: DataFrameRow[] = [];
}); for (const [key, rows] of Object.entries(groups)) {
const tempDf = new DataFrame(rows, {
// Create OHLCV aggregation columns: this._columns,
const aggregated: DataFrameRow = { index: this._index,
[timeColumn]: rows[0][timeColumn], dtypes: this._dtypes,
open: rows[0].close || rows[0].price, });
high: tempDf.max('high') || tempDf.max('close') || tempDf.max('price'),
low: tempDf.min('low') || tempDf.min('close') || tempDf.min('price'), // Create OHLCV aggregation
close: rows[rows.length - 1].close || rows[rows.length - 1].price, const aggregated: DataFrameRow = {
volume: tempDf.sum('volume') || 0 [timeColumn]: rows[0][timeColumn],
}; open: rows[0].close || rows[0].price,
high: tempDf.max('high') || tempDf.max('close') || tempDf.max('price'),
aggregatedData.push(aggregated); low: tempDf.min('low') || tempDf.min('close') || tempDf.min('price'),
} close: rows[rows.length - 1].close || rows[rows.length - 1].price,
volume: tempDf.sum('volume') || 0,
return new DataFrame(aggregatedData); };
}
aggregatedData.push(aggregated);
private resampleByDay(sorted: DataFrame, timeColumn: string): DataFrame { }
// Similar to resampleByHour but group by day
const groups: Record<string, DataFrameRow[]> = {}; return new DataFrame(aggregatedData);
}
for (const row of sorted.data) {
const date = new Date(row[timeColumn]); private resampleByDay(sorted: DataFrame, timeColumn: string): DataFrame {
const dayKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; // Similar to resampleByHour but group by day
const groups: Record<string, DataFrameRow[]> = {};
if (!groups[dayKey]) {
groups[dayKey] = []; for (const row of sorted.data) {
} const date = new Date(row[timeColumn]);
groups[dayKey].push(row); const dayKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
}
if (!groups[dayKey]) {
const aggregatedData: DataFrameRow[] = []; groups[dayKey] = [];
for (const [key, rows] of Object.entries(groups)) { }
const tempDf = new DataFrame(rows, { groups[dayKey].push(row);
columns: this._columns, }
index: this._index,
dtypes: this._dtypes const aggregatedData: DataFrameRow[] = [];
}); for (const [key, rows] of Object.entries(groups)) {
const tempDf = new DataFrame(rows, {
const aggregated: DataFrameRow = { columns: this._columns,
[timeColumn]: rows[0][timeColumn], index: this._index,
open: rows[0].close || rows[0].price, dtypes: this._dtypes,
high: tempDf.max('high') || tempDf.max('close') || tempDf.max('price'), });
low: tempDf.min('low') || tempDf.min('close') || tempDf.min('price'),
close: rows[rows.length - 1].close || rows[rows.length - 1].price, const aggregated: DataFrameRow = {
volume: tempDf.sum('volume') || 0 [timeColumn]: rows[0][timeColumn],
}; open: rows[0].close || rows[0].price,
high: tempDf.max('high') || tempDf.max('close') || tempDf.max('price'),
aggregatedData.push(aggregated); low: tempDf.min('low') || tempDf.min('close') || tempDf.min('price'),
} close: rows[rows.length - 1].close || rows[rows.length - 1].price,
volume: tempDf.sum('volume') || 0,
return new DataFrame(aggregatedData); };
}
aggregatedData.push(aggregated);
// Utility methods }
copy(): DataFrame {
return new DataFrame(this.data.map(row => ({ ...row })), { return new DataFrame(aggregatedData);
columns: this._columns, }
index: this._index,
dtypes: { ...this._dtypes } // Utility methods
}); copy(): DataFrame {
} return new DataFrame(
this.data.map(row => ({ ...row })),
concat(other: DataFrame): DataFrame { {
const combinedData = [...this.data, ...other.data]; columns: this._columns,
const combinedColumns = Array.from(new Set([...this._columns, ...other._columns])); index: this._index,
dtypes: { ...this._dtypes },
return new DataFrame(combinedData, { }
columns: combinedColumns, );
index: this._index, }
dtypes: { ...this._dtypes, ...other._dtypes }
}); concat(other: DataFrame): DataFrame {
} const combinedData = [...this.data, ...other.data];
const combinedColumns = Array.from(new Set([...this._columns, ...other._columns]));
toArray(): DataFrameRow[] {
return this.data.map(row => ({ ...row })); return new DataFrame(combinedData, {
} columns: combinedColumns,
index: this._index,
toJSON(): string { dtypes: { ...this._dtypes, ...other._dtypes },
return JSON.stringify(this.data); });
} }
private filterDtypes(columns: string[]): Record<string, 'number' | 'string' | 'boolean' | 'date'> { toArray(): DataFrameRow[] {
const filtered: Record<string, 'number' | 'string' | 'boolean' | 'date'> = {}; return this.data.map(row => ({ ...row }));
for (const col of columns) { }
if (this._dtypes[col]) {
filtered[col] = this._dtypes[col]; toJSON(): string {
} return JSON.stringify(this.data);
} }
return filtered;
} private filterDtypes(
columns: string[]
// Display method ): Record<string, 'number' | 'string' | 'boolean' | 'date'> {
toString(): string { const filtered: Record<string, 'number' | 'string' | 'boolean' | 'date'> = {};
if (this.empty) { for (const col of columns) {
return 'Empty DataFrame'; if (this._dtypes[col]) {
} filtered[col] = this._dtypes[col];
}
const maxRows = 10; }
const displayData = this.data.slice(0, maxRows); return filtered;
}
let result = `DataFrame (${this.length} rows x ${this._columns.length} columns)\n`;
result += this._columns.join('\t') + '\n'; // Display method
result += '-'.repeat(this._columns.join('\t').length) + '\n'; toString(): string {
if (this.empty) {
for (const row of displayData) { return 'Empty DataFrame';
const values = this._columns.map(col => String(row[col] ?? 'null')); }
result += values.join('\t') + '\n';
} const maxRows = 10;
const displayData = this.data.slice(0, maxRows);
if (this.length > maxRows) {
result += `... (${this.length - maxRows} more rows)\n`; let result = `DataFrame (${this.length} rows x ${this._columns.length} columns)\n`;
} result += this._columns.join('\t') + '\n';
result += '-'.repeat(this._columns.join('\t').length) + '\n';
return result;
} for (const row of displayData) {
} const values = this._columns.map(col => String(row[col] ?? 'null'));
result += values.join('\t') + '\n';
// Factory functions }
export function createDataFrame(data: DataFrameRow[], options?: DataFrameOptions): DataFrame {
return new DataFrame(data, options); if (this.length > maxRows) {
} result += `... (${this.length - maxRows} more rows)\n`;
}
export function readCSV(csvData: string, options?: DataFrameOptions): DataFrame {
const lines = csvData.trim().split('\n'); return result;
if (lines.length === 0) { }
return new DataFrame(); }
}
// Factory functions
const headers = lines[0].split(',').map(h => h.trim()); export function createDataFrame(data: DataFrameRow[], options?: DataFrameOptions): DataFrame {
const data: DataFrameRow[] = []; return new DataFrame(data, options);
}
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim()); export function readCSV(csvData: string, options?: DataFrameOptions): DataFrame {
const row: DataFrameRow = {}; const lines = csvData.trim().split('\n');
if (lines.length === 0) {
for (let j = 0; j < headers.length; j++) { return new DataFrame();
row[headers[j]] = values[j] || null; }
}
const headers = lines[0].split(',').map(h => h.trim());
data.push(row); const data: DataFrameRow[] = [];
}
for (let i = 1; i < lines.length; i++) {
return new DataFrame(data, { const values = lines[i].split(',').map(v => v.trim());
columns: headers, const row: DataFrameRow = {};
...options
}); for (let j = 0; j < headers.length; j++) {
} row[headers[j]] = values[j] || null;
}
data.push(row);
}
return new DataFrame(data, {
columns: headers,
...options,
});
}

File diff suppressed because it is too large Load diff

View file

@ -1,53 +1,56 @@
import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios'; import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios';
import type { RequestConfig, HttpResponse } from '../types'; import { ProxyManager } from '../proxy-manager';
import type { RequestAdapter } from './types'; import type { HttpResponse, RequestConfig } from '../types';
import { ProxyManager } from '../proxy-manager'; import { HttpError } from '../types';
import { HttpError } from '../types'; import type { RequestAdapter } from './types';
/** /**
* Axios adapter for SOCKS proxies * Axios adapter for SOCKS proxies
*/ */
export class AxiosAdapter implements RequestAdapter { export class AxiosAdapter implements RequestAdapter {
canHandle(config: RequestConfig): boolean { canHandle(config: RequestConfig): boolean {
// Axios handles SOCKS proxies // Axios handles SOCKS proxies
return Boolean(config.proxy && (config.proxy.protocol === 'socks4' || config.proxy.protocol === 'socks5')); return Boolean(
} config.proxy && (config.proxy.protocol === 'socks4' || config.proxy.protocol === 'socks5')
);
async request<T = any>(config: RequestConfig, signal: AbortSignal): Promise<HttpResponse<T>> { }
const { url, method = 'GET', headers, data, proxy } = config;
async request<T = any>(config: RequestConfig, signal: AbortSignal): Promise<HttpResponse<T>> {
if (!proxy) { const { url, method = 'GET', headers, data, proxy } = config;
throw new Error('Axios adapter requires proxy configuration');
} if (!proxy) {
throw new Error('Axios adapter requires proxy configuration');
// Create proxy configuration using ProxyManager }
const axiosConfig: AxiosRequestConfig = {
...ProxyManager.createAxiosConfig(proxy), // Create proxy configuration using ProxyManager
url, const axiosConfig: AxiosRequestConfig = {
method, ...ProxyManager.createAxiosConfig(proxy),
headers, url,
data, method,
signal, headers,
// Don't throw on non-2xx status codes - let caller handle data,
validateStatus: () => true, signal,
}; const response: AxiosResponse<T> = await axios(axiosConfig); // Don't throw on non-2xx status codes - let caller handle
validateStatus: () => true,
const httpResponse: HttpResponse<T> = { };
data: response.data, const response: AxiosResponse<T> = await axios(axiosConfig);
status: response.status,
headers: response.headers as Record<string, string>, const httpResponse: HttpResponse<T> = {
ok: response.status >= 200 && response.status < 300, data: response.data,
}; status: response.status,
headers: response.headers as Record<string, string>,
// Throw HttpError for non-2xx status codes ok: response.status >= 200 && response.status < 300,
if (!httpResponse.ok) { };
throw new HttpError(
`Request failed with status ${response.status}`, // Throw HttpError for non-2xx status codes
response.status, if (!httpResponse.ok) {
httpResponse throw new HttpError(
); `Request failed with status ${response.status}`,
} response.status,
httpResponse
return httpResponse; );
} }
}
return httpResponse;
}
}

View file

@ -1,28 +1,28 @@
import type { RequestConfig } from '../types'; import type { RequestConfig } from '../types';
import type { RequestAdapter } from './types'; import { AxiosAdapter } from './axios-adapter';
import { FetchAdapter } from './fetch-adapter'; import { FetchAdapter } from './fetch-adapter';
import { AxiosAdapter } from './axios-adapter'; import type { RequestAdapter } from './types';
/** /**
* Factory for creating the appropriate request adapter * Factory for creating the appropriate request adapter
*/ */
export class AdapterFactory { export class AdapterFactory {
private static adapters: RequestAdapter[] = [ private static adapters: RequestAdapter[] = [
new AxiosAdapter(), // Check SOCKS first new AxiosAdapter(), // Check SOCKS first
new FetchAdapter(), // Fallback to fetch for everything else new FetchAdapter(), // Fallback to fetch for everything else
]; ];
/** /**
* Get the appropriate adapter for the given configuration * Get the appropriate adapter for the given configuration
*/ */
static getAdapter(config: RequestConfig): RequestAdapter { static getAdapter(config: RequestConfig): RequestAdapter {
for (const adapter of this.adapters) { for (const adapter of this.adapters) {
if (adapter.canHandle(config)) { if (adapter.canHandle(config)) {
return adapter; return adapter;
} }
} }
// Fallback to fetch adapter // Fallback to fetch adapter
return new FetchAdapter(); return new FetchAdapter();
} }
} }

View file

@ -1,66 +1,67 @@
import type { RequestConfig, HttpResponse } from '../types'; import { ProxyManager } from '../proxy-manager';
import type { RequestAdapter } from './types'; import type { HttpResponse, RequestConfig } from '../types';
import { ProxyManager } from '../proxy-manager'; import { HttpError } from '../types';
import { HttpError } from '../types'; import type { RequestAdapter } from './types';
/** /**
* Fetch adapter for HTTP/HTTPS proxies and non-proxy requests * Fetch adapter for HTTP/HTTPS proxies and non-proxy requests
*/ */
export class FetchAdapter implements RequestAdapter { export class FetchAdapter implements RequestAdapter {
canHandle(config: RequestConfig): boolean { canHandle(config: RequestConfig): boolean {
// Fetch handles non-proxy requests and HTTP/HTTPS proxies // Fetch handles non-proxy requests and HTTP/HTTPS proxies
return !config.proxy || config.proxy.protocol === 'http' || config.proxy.protocol === 'https'; return !config.proxy || config.proxy.protocol === 'http' || config.proxy.protocol === 'https';
} }
async request<T = any>(config: RequestConfig, signal: AbortSignal): Promise<HttpResponse<T>> { async request<T = any>(config: RequestConfig, signal: AbortSignal): Promise<HttpResponse<T>> {
const { url, method = 'GET', headers, data, proxy } = config; const { url, method = 'GET', headers, data, proxy } = config;
// Prepare fetch options // Prepare fetch options
const fetchOptions: RequestInit = { const fetchOptions: RequestInit = {
method, method,
headers, headers,
signal, signal,
}; };
// Add body for non-GET requests // Add body for non-GET requests
if (data && method !== 'GET') { if (data && method !== 'GET') {
fetchOptions.body = typeof data === 'string' ? data : JSON.stringify(data); fetchOptions.body = typeof data === 'string' ? data : JSON.stringify(data);
if (typeof data === 'object') { if (typeof data === 'object') {
fetchOptions.headers = { 'Content-Type': 'application/json', ...fetchOptions.headers }; fetchOptions.headers = { 'Content-Type': 'application/json', ...fetchOptions.headers };
} }
} }
// Add proxy if needed (using Bun's built-in proxy support) // Add proxy if needed (using Bun's built-in proxy support)
if (proxy) { if (proxy) {
(fetchOptions as any).proxy = ProxyManager.createProxyUrl(proxy); (fetchOptions as any).proxy = ProxyManager.createProxyUrl(proxy);
} const response = await fetch(url, fetchOptions); }
const response = await fetch(url, fetchOptions);
// Parse response based on content type
let responseData: T; // Parse response based on content type
const contentType = response.headers.get('content-type') || ''; let responseData: T;
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
responseData = await response.json() as T; if (contentType.includes('application/json')) {
} else { responseData = (await response.json()) as T;
responseData = await response.text() as T; } else {
} responseData = (await response.text()) as T;
}
const httpResponse: HttpResponse<T> = {
data: responseData, const httpResponse: HttpResponse<T> = {
status: response.status, data: responseData,
headers: Object.fromEntries(response.headers.entries()), status: response.status,
ok: response.ok, headers: Object.fromEntries(response.headers.entries()),
}; ok: response.ok,
};
// Throw HttpError for non-2xx status codes
if (!response.ok) { // Throw HttpError for non-2xx status codes
throw new HttpError( if (!response.ok) {
`Request failed with status ${response.status}`, throw new HttpError(
response.status, `Request failed with status ${response.status}`,
httpResponse response.status,
); httpResponse
} );
}
return httpResponse;
} return httpResponse;
} }
}

View file

@ -1,4 +1,4 @@
export * from './types'; export * from './types';
export * from './fetch-adapter'; export * from './fetch-adapter';
export * from './axios-adapter'; export * from './axios-adapter';
export * from './factory'; export * from './factory';

View file

@ -1,16 +1,16 @@
import type { RequestConfig, HttpResponse } from '../types'; import type { HttpResponse, RequestConfig } from '../types';
/** /**
* Request adapter interface for different HTTP implementations * Request adapter interface for different HTTP implementations
*/ */
export interface RequestAdapter { export interface RequestAdapter {
/** /**
* Execute an HTTP request * Execute an HTTP request
*/ */
request<T = any>(config: RequestConfig, signal: AbortSignal): Promise<HttpResponse<T>>; request<T = any>(config: RequestConfig, signal: AbortSignal): Promise<HttpResponse<T>>;
/** /**
* Check if this adapter can handle the given configuration * Check if this adapter can handle the given configuration
*/ */
canHandle(config: RequestConfig): boolean; canHandle(config: RequestConfig): boolean;
} }

View file

@ -1,155 +1,175 @@
import type { Logger } from '@stock-bot/logger'; import type { Logger } from '@stock-bot/logger';
import type { import { AdapterFactory } from './adapters/index';
HttpClientConfig, import { ProxyManager } from './proxy-manager';
RequestConfig, import type { HttpClientConfig, HttpResponse, RequestConfig } from './types';
HttpResponse, import { HttpError } from './types';
} from './types';
import { HttpError } from './types'; export class HttpClient {
import { ProxyManager } from './proxy-manager'; private readonly config: HttpClientConfig;
import { AdapterFactory } from './adapters/index'; private readonly logger?: Logger;
export class HttpClient { constructor(config: HttpClientConfig = {}, logger?: Logger) {
private readonly config: HttpClientConfig; this.config = config;
private readonly logger?: Logger; this.logger = logger?.child('http-client');
}
constructor(config: HttpClientConfig = {}, logger?: Logger) {
this.config = config; // Convenience methods
this.logger = logger?.child('http-client'); async get<T = any>(
} url: string,
config: Omit<RequestConfig, 'method' | 'url'> = {}
// Convenience methods ): Promise<HttpResponse<T>> {
async get<T = any>(url: string, config: Omit<RequestConfig, 'method' | 'url'> = {}): Promise<HttpResponse<T>> { return this.request<T>({ ...config, method: 'GET', url });
return this.request<T>({ ...config, method: 'GET', url }); }
}
async post<T = any>(
async post<T = any>(url: string, data?: any, config: Omit<RequestConfig, 'method' | 'url' | 'data'> = {}): Promise<HttpResponse<T>> { url: string,
return this.request<T>({ ...config, method: 'POST', url, data }); data?: any,
} config: Omit<RequestConfig, 'method' | 'url' | 'data'> = {}
): Promise<HttpResponse<T>> {
async put<T = any>(url: string, data?: any, config: Omit<RequestConfig, 'method' | 'url' | 'data'> = {}): Promise<HttpResponse<T>> { return this.request<T>({ ...config, method: 'POST', url, data });
return this.request<T>({ ...config, method: 'PUT', url, data }); }
}
async put<T = any>(
async del<T = any>(url: string, config: Omit<RequestConfig, 'method' | 'url'> = {}): Promise<HttpResponse<T>> { url: string,
return this.request<T>({ ...config, method: 'DELETE', url }); data?: any,
} config: Omit<RequestConfig, 'method' | 'url' | 'data'> = {}
): Promise<HttpResponse<T>> {
async patch<T = any>(url: string, data?: any, config: Omit<RequestConfig, 'method' | 'url' | 'data'> = {}): Promise<HttpResponse<T>> { return this.request<T>({ ...config, method: 'PUT', url, data });
return this.request<T>({ ...config, method: 'PATCH', url, data }); }
}
async del<T = any>(
/** url: string,
* Main request method - clean and simple config: Omit<RequestConfig, 'method' | 'url'> = {}
*/ ): Promise<HttpResponse<T>> {
async request<T = any>(config: RequestConfig): Promise<HttpResponse<T>> { return this.request<T>({ ...config, method: 'DELETE', url });
const finalConfig = this.mergeConfig(config); }
const startTime = Date.now();
async patch<T = any>(
this.logger?.debug('Making HTTP request', { url: string,
method: finalConfig.method, data?: any,
url: finalConfig.url, config: Omit<RequestConfig, 'method' | 'url' | 'data'> = {}
hasProxy: !!finalConfig.proxy ): Promise<HttpResponse<T>> {
}); return this.request<T>({ ...config, method: 'PATCH', url, data });
}
try {
const response = await this.executeRequest<T>(finalConfig); /**
response.responseTime = Date.now() - startTime; * Main request method - clean and simple
*/
this.logger?.debug('HTTP request successful', { async request<T = any>(config: RequestConfig): Promise<HttpResponse<T>> {
method: finalConfig.method, const finalConfig = this.mergeConfig(config);
url: finalConfig.url, const startTime = Date.now();
status: response.status,
responseTime: response.responseTime, this.logger?.debug('Making HTTP request', {
}); method: finalConfig.method,
url: finalConfig.url,
return response; hasProxy: !!finalConfig.proxy,
} catch (error) { });
if( this.logger?.getServiceName() === 'proxy-tasks' ) {
this.logger?.debug('HTTP request failed', { try {
method: finalConfig.method, const response = await this.executeRequest<T>(finalConfig);
url: finalConfig.url, response.responseTime = Date.now() - startTime;
error: (error as Error).message,
}); this.logger?.debug('HTTP request successful', {
}else{ method: finalConfig.method,
this.logger?.warn('HTTP request failed', { url: finalConfig.url,
method: finalConfig.method, status: response.status,
url: finalConfig.url, responseTime: response.responseTime,
error: (error as Error).message, });
});
} return response;
throw error; } catch (error) {
} if (this.logger?.getServiceName() === 'proxy-tasks') {
} this.logger?.debug('HTTP request failed', {
method: finalConfig.method,
/** url: finalConfig.url,
* Execute request with timeout handling - no race conditions error: (error as Error).message,
*/ private async executeRequest<T>(config: RequestConfig): Promise<HttpResponse<T>> { });
const timeout = config.timeout ?? this.config.timeout ?? 30000; } else {
const controller = new AbortController(); this.logger?.warn('HTTP request failed', {
const startTime = Date.now(); method: finalConfig.method,
let timeoutId: NodeJS.Timeout | undefined; url: finalConfig.url,
error: (error as Error).message,
// Set up timeout });
// Create a timeout promise that will reject }
const timeoutPromise = new Promise<never>((_, reject) => { throw error;
timeoutId = setTimeout(() => { }
const elapsed = Date.now() - startTime; }
this.logger?.debug('Request timeout triggered', {
url: config.url, /**
method: config.method, * Execute request with timeout handling - no race conditions
timeout, */ private async executeRequest<T>(config: RequestConfig): Promise<HttpResponse<T>> {
elapsed const timeout = config.timeout ?? this.config.timeout ?? 30000;
}); const controller = new AbortController();
const startTime = Date.now();
// Attempt to abort (may or may not work with Bun) let timeoutId: NodeJS.Timeout | undefined;
controller.abort();
// Set up timeout
// Force rejection regardless of signal behavior // Create a timeout promise that will reject
reject(new HttpError(`Request timeout after ${timeout}ms (elapsed: ${elapsed}ms)`)); const timeoutPromise = new Promise<never>((_, reject) => {
}, timeout); timeoutId = setTimeout(() => {
}); const elapsed = Date.now() - startTime;
this.logger?.debug('Request timeout triggered', {
try { url: config.url,
// Get the appropriate adapter method: config.method,
const adapter = AdapterFactory.getAdapter(config); timeout,
elapsed,
const response = await Promise.race([ });
adapter.request<T>(config, controller.signal),
timeoutPromise // Attempt to abort (may or may not work with Bun)
]); controller.abort();
this.logger?.debug('Adapter request successful', { url: config.url, elapsedMs: Date.now() - startTime }); // Force rejection regardless of signal behavior
// Clear timeout on success reject(new HttpError(`Request timeout after ${timeout}ms (elapsed: ${elapsed}ms)`));
clearTimeout(timeoutId); }, timeout);
});
return response;
} catch (error) { try {
const elapsed = Date.now() - startTime; // Get the appropriate adapter
this.logger?.debug('Adapter failed successful', { url: config.url, elapsedMs: Date.now() - startTime }); const adapter = AdapterFactory.getAdapter(config);
clearTimeout(timeoutId);
const response = await Promise.race([
// Handle timeout adapter.request<T>(config, controller.signal),
if (controller.signal.aborted) { timeoutPromise,
throw new HttpError(`Request timeout after ${timeout}ms`); ]);
}
this.logger?.debug('Adapter request successful', {
// Re-throw other errors url: config.url,
if (error instanceof HttpError) { elapsedMs: Date.now() - startTime,
throw error; });
} // Clear timeout on success
clearTimeout(timeoutId);
throw new HttpError(`Request failed: ${(error as Error).message}`);
} return response;
} } catch (error) {
const elapsed = Date.now() - startTime;
/** this.logger?.debug('Adapter failed successful', {
* Merge configs with defaults url: config.url,
*/ elapsedMs: Date.now() - startTime,
private mergeConfig(config: RequestConfig): RequestConfig { });
return { clearTimeout(timeoutId);
...config,
headers: { ...this.config.headers, ...config.headers }, // Handle timeout
timeout: config.timeout ?? this.config.timeout, if (controller.signal.aborted) {
}; throw new HttpError(`Request timeout after ${timeout}ms`);
} }
}
// Re-throw other errors
if (error instanceof HttpError) {
throw error;
}
throw new HttpError(`Request failed: ${(error as Error).message}`);
}
}
/**
* Merge configs with defaults
*/
private mergeConfig(config: RequestConfig): RequestConfig {
return {
...config,
headers: { ...this.config.headers, ...config.headers },
timeout: config.timeout ?? this.config.timeout,
};
}
}

View file

@ -1,8 +1,8 @@
// Re-export all types and classes // Re-export all types and classes
export * from './types'; export * from './types';
export * from './client'; export * from './client';
export * from './proxy-manager'; export * from './proxy-manager';
export * from './adapters/index'; export * from './adapters/index';
// Default export // Default export
export { HttpClient as default } from './client'; export { HttpClient as default } from './client';

View file

@ -1,66 +1,66 @@
import axios, { AxiosRequestConfig, type AxiosInstance } from 'axios'; import axios, { AxiosRequestConfig, type AxiosInstance } from 'axios';
import { SocksProxyAgent } from 'socks-proxy-agent'; import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent';
import { HttpProxyAgent } from 'http-proxy-agent'; import { SocksProxyAgent } from 'socks-proxy-agent';
import type { ProxyInfo } from './types'; import type { ProxyInfo } from './types';
export class ProxyManager { export class ProxyManager {
/** /**
* Determine if we should use Bun fetch (HTTP/HTTPS) or Axios (SOCKS) * Determine if we should use Bun fetch (HTTP/HTTPS) or Axios (SOCKS)
*/ */
static shouldUseBunFetch(proxy: ProxyInfo): boolean { static shouldUseBunFetch(proxy: ProxyInfo): boolean {
return proxy.protocol === 'http' || proxy.protocol === 'https'; return proxy.protocol === 'http' || proxy.protocol === 'https';
} }
/** /**
* Create proxy URL for both Bun fetch and Axios proxy agents * Create proxy URL for both Bun fetch and Axios proxy agents
*/ */
static createProxyUrl(proxy: ProxyInfo): string { static createProxyUrl(proxy: ProxyInfo): string {
const { protocol, host, port, username, password } = proxy; const { protocol, host, port, username, password } = proxy;
if (username && password) { if (username && password) {
return `${protocol}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`; return `${protocol}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
} }
return `${protocol}://${host}:${port}`; return `${protocol}://${host}:${port}`;
} }
/** /**
* Create appropriate agent for Axios based on proxy type * Create appropriate agent for Axios based on proxy type
*/ */
static createProxyAgent(proxy: ProxyInfo) { static createProxyAgent(proxy: ProxyInfo) {
this.validateConfig(proxy); this.validateConfig(proxy);
const proxyUrl = this.createProxyUrl(proxy); const proxyUrl = this.createProxyUrl(proxy);
switch (proxy.protocol) { switch (proxy.protocol) {
case 'socks4': case 'socks4':
case 'socks5': case 'socks5':
// console.log(`Using SOCKS proxy: ${proxyUrl}`); // console.log(`Using SOCKS proxy: ${proxyUrl}`);
return new SocksProxyAgent(proxyUrl); return new SocksProxyAgent(proxyUrl);
case 'http': case 'http':
return new HttpProxyAgent(proxyUrl); return new HttpProxyAgent(proxyUrl);
case 'https': case 'https':
return new HttpsProxyAgent(proxyUrl); return new HttpsProxyAgent(proxyUrl);
default: default:
throw new Error(`Unsupported proxy protocol: ${proxy.protocol}`); throw new Error(`Unsupported proxy protocol: ${proxy.protocol}`);
} }
} }
/** /**
* Create Axios instance with proxy configuration * Create Axios instance with proxy configuration
*/ */
static createAxiosConfig(proxy: ProxyInfo): AxiosRequestConfig { static createAxiosConfig(proxy: ProxyInfo): AxiosRequestConfig {
const agent = this.createProxyAgent(proxy); const agent = this.createProxyAgent(proxy);
return { return {
httpAgent: agent, httpAgent: agent,
httpsAgent: agent, httpsAgent: agent,
}; };
} }
/** /**
* Simple proxy config validation * Simple proxy config validation
*/ */
static validateConfig(proxy: ProxyInfo): void { static validateConfig(proxy: ProxyInfo): void {
if (!proxy.host || !proxy.port) { if (!proxy.host || !proxy.port) {
throw new Error('Proxy host and port are required'); throw new Error('Proxy host and port are required');
} }
if (!['http', 'https', 'socks4', 'socks5'].includes(proxy.protocol)) { if (!['http', 'https', 'socks4', 'socks5'].includes(proxy.protocol)) {
throw new Error(`Unsupported proxy protocol: ${proxy.protocol}`); throw new Error(`Unsupported proxy protocol: ${proxy.protocol}`);
} }
} }
} }

View file

@ -1,55 +1,55 @@
// Minimal types for fast HTTP client // Minimal types for fast HTTP client
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
export interface ProxyInfo { export interface ProxyInfo {
source?: string; source?: string;
protocol: 'http' | 'https' | 'socks4' | 'socks5'; protocol: 'http' | 'https' | 'socks4' | 'socks5';
host: string; host: string;
port: number; port: number;
username?: string; username?: string;
password?: string; password?: string;
url?: string; // Full proxy URL for adapters url?: string; // Full proxy URL for adapters
isWorking?: boolean; isWorking?: boolean;
responseTime?: number; responseTime?: number;
error?: string; error?: string;
// Enhanced tracking properties // Enhanced tracking properties
working?: number; // Number of successful checks working?: number; // Number of successful checks
total?: number; // Total number of checks total?: number; // Total number of checks
successRate?: number; // Success rate percentage successRate?: number; // Success rate percentage
averageResponseTime?: number; // Average response time in milliseconds averageResponseTime?: number; // Average response time in milliseconds
firstSeen?: Date; // When the proxy was first added to cache firstSeen?: Date; // When the proxy was first added to cache
lastChecked?: Date; // When the proxy was last checked lastChecked?: Date; // When the proxy was last checked
} }
export interface HttpClientConfig { export interface HttpClientConfig {
timeout?: number; timeout?: number;
headers?: Record<string, string>; headers?: Record<string, string>;
} }
export interface RequestConfig { export interface RequestConfig {
method?: HttpMethod; method?: HttpMethod;
url: string; url: string;
headers?: Record<string, string>; headers?: Record<string, string>;
data?: any; // Changed from 'body' to 'data' for consistency data?: any; // Changed from 'body' to 'data' for consistency
timeout?: number; timeout?: number;
proxy?: ProxyInfo; proxy?: ProxyInfo;
} }
export interface HttpResponse<T = any> { export interface HttpResponse<T = any> {
data: T; data: T;
status: number; status: number;
headers: Record<string, string>; headers: Record<string, string>;
ok: boolean; ok: boolean;
responseTime?: number; responseTime?: number;
} }
export class HttpError extends Error { export class HttpError extends Error {
constructor( constructor(
message: string, message: string,
public status?: number, public status?: number,
public response?: HttpResponse public response?: HttpResponse
) { ) {
super(message); super(message);
this.name = 'HttpError'; this.name = 'HttpError';
} }
} }

View file

@ -1,154 +1,161 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { HttpClient, HttpError } from '../src/index'; import { HttpClient, HttpError } from '../src/index';
import { MockServer } from './mock-server'; import { MockServer } from './mock-server';
/** /**
* Integration tests for HTTP client with real network scenarios * Integration tests for HTTP client with real network scenarios
* These tests use external services and may be affected by network conditions * These tests use external services and may be affected by network conditions
*/ */
let mockServer: MockServer; let mockServer: MockServer;
let mockServerBaseUrl: string; let mockServerBaseUrl: string;
beforeAll(async () => { beforeAll(async () => {
mockServer = new MockServer(); mockServer = new MockServer();
await mockServer.start(); await mockServer.start();
mockServerBaseUrl = mockServer.getBaseUrl(); mockServerBaseUrl = mockServer.getBaseUrl();
}); });
afterAll(async () => { afterAll(async () => {
await mockServer.stop(); await mockServer.stop();
}); });
describe('HTTP Integration Tests', () => { describe('HTTP Integration Tests', () => {
let client: HttpClient; let client: HttpClient;
beforeAll(() => { beforeAll(() => {
client = new HttpClient({ client = new HttpClient({
timeout: 10000 timeout: 10000,
}); });
}); });
describe('Real-world scenarios', () => { describe('Real-world scenarios', () => {
test('should handle JSON API responses', async () => { test('should handle JSON API responses', async () => {
try { try {
const response = await client.get('https://jsonplaceholder.typicode.com/posts/1'); const response = await client.get('https://jsonplaceholder.typicode.com/posts/1');
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.data).toHaveProperty('id'); expect(response.data).toHaveProperty('id');
expect(response.data).toHaveProperty('title'); expect(response.data).toHaveProperty('title');
expect(response.data).toHaveProperty('body'); expect(response.data).toHaveProperty('body');
} catch (error) { } catch (error) {
console.warn('External API test skipped due to network issues:', (error as Error).message); console.warn('External API test skipped due to network issues:', (error as Error).message);
} }
}); });
test('should handle large responses', async () => { test('should handle large responses', async () => {
try { try {
const response = await client.get('https://jsonplaceholder.typicode.com/posts'); const response = await client.get('https://jsonplaceholder.typicode.com/posts');
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(Array.isArray(response.data)).toBe(true); expect(Array.isArray(response.data)).toBe(true);
expect(response.data.length).toBeGreaterThan(0); expect(response.data.length).toBeGreaterThan(0);
} catch (error) { } catch (error) {
console.warn('Large response test skipped due to network issues:', (error as Error).message); console.warn(
} 'Large response test skipped due to network issues:',
}); (error as Error).message
);
test('should handle POST with JSON data', async () => { }
try { });
const postData = {
title: 'Integration Test Post', test('should handle POST with JSON data', async () => {
body: 'This is a test post from integration tests', try {
userId: 1 const postData = {
}; title: 'Integration Test Post',
body: 'This is a test post from integration tests',
const response = await client.post('https://jsonplaceholder.typicode.com/posts', postData); userId: 1,
};
expect(response.status).toBe(201);
expect(response.data).toHaveProperty('id'); const response = await client.post('https://jsonplaceholder.typicode.com/posts', postData);
expect(response.data.title).toBe(postData.title);
} catch (error) { expect(response.status).toBe(201);
console.warn('POST integration test skipped due to network issues:', (error as Error).message); expect(response.data).toHaveProperty('id');
} expect(response.data.title).toBe(postData.title);
}); } catch (error) {
}); console.warn(
'POST integration test skipped due to network issues:',
describe('Error scenarios with mock server', () => { test('should handle various HTTP status codes', async () => { (error as Error).message
const successCodes = [200, 201]; );
const errorCodes = [400, 401, 403, 404, 500, 503]; }
});
// Test success codes });
for (const statusCode of successCodes) {
const response = await client.get(`${mockServerBaseUrl}/status/${statusCode}`); describe('Error scenarios with mock server', () => {
expect(response.status).toBe(statusCode); test('should handle various HTTP status codes', async () => {
} const successCodes = [200, 201];
const errorCodes = [400, 401, 403, 404, 500, 503];
// Test error codes (should throw HttpError)
for (const statusCode of errorCodes) { // Test success codes
await expect( for (const statusCode of successCodes) {
client.get(`${mockServerBaseUrl}/status/${statusCode}`) const response = await client.get(`${mockServerBaseUrl}/status/${statusCode}`);
).rejects.toThrow(HttpError); expect(response.status).toBe(statusCode);
} }
});
// Test error codes (should throw HttpError)
test('should handle malformed responses gracefully', async () => { for (const statusCode of errorCodes) {
// Mock server returns valid JSON, so this test verifies our client handles it properly await expect(client.get(`${mockServerBaseUrl}/status/${statusCode}`)).rejects.toThrow(
const response = await client.get(`${mockServerBaseUrl}/`); HttpError
expect(response.status).toBe(200); );
expect(typeof response.data).toBe('object'); }
}); });
test('should handle concurrent requests', async () => { test('should handle malformed responses gracefully', async () => {
const requests = Array.from({ length: 5 }, (_, i) => // Mock server returns valid JSON, so this test verifies our client handles it properly
client.get(`${mockServerBaseUrl}/`, { const response = await client.get(`${mockServerBaseUrl}/`);
headers: { 'X-Request-ID': `req-${i}` } expect(response.status).toBe(200);
}) expect(typeof response.data).toBe('object');
); });
const responses = await Promise.all(requests); test('should handle concurrent requests', async () => {
const requests = Array.from({ length: 5 }, (_, i) =>
responses.forEach((response, index) => { client.get(`${mockServerBaseUrl}/`, {
expect(response.status).toBe(200); headers: { 'X-Request-ID': `req-${i}` },
expect(response.data.headers).toHaveProperty('x-request-id', `req-${index}`); })
}); );
});
}); const responses = await Promise.all(requests);
describe('Performance and reliability', () => { responses.forEach((response, index) => {
test('should handle rapid sequential requests', async () => { expect(response.status).toBe(200);
const startTime = Date.now(); expect(response.data.headers).toHaveProperty('x-request-id', `req-${index}`);
const requests = []; });
});
for (let i = 0; i < 10; i++) { });
requests.push(client.get(`${mockServerBaseUrl}/`));
} describe('Performance and reliability', () => {
test('should handle rapid sequential requests', async () => {
const responses = await Promise.all(requests); const startTime = Date.now();
const endTime = Date.now(); const requests = [];
expect(responses).toHaveLength(10); for (let i = 0; i < 10; i++) {
responses.forEach(response => { requests.push(client.get(`${mockServerBaseUrl}/`));
expect(response.status).toBe(200); }
});
const responses = await Promise.all(requests);
console.log(`Completed 10 requests in ${endTime - startTime}ms`); const endTime = Date.now();
});
expect(responses).toHaveLength(10);
test('should maintain connection efficiency', async () => { responses.forEach(response => {
const clientWithKeepAlive = new HttpClient({ expect(response.status).toBe(200);
timeout: 5000 });
});
console.log(`Completed 10 requests in ${endTime - startTime}ms`);
const requests = Array.from({ length: 3 }, () => });
clientWithKeepAlive.get(`${mockServerBaseUrl}/`)
); test('should maintain connection efficiency', async () => {
const clientWithKeepAlive = new HttpClient({
const responses = await Promise.all(requests); timeout: 5000,
});
responses.forEach(response => {
expect(response.status).toBe(200); const requests = Array.from({ length: 3 }, () =>
}); clientWithKeepAlive.get(`${mockServerBaseUrl}/`)
}); );
});
}); const responses = await Promise.all(requests);
responses.forEach(response => {
expect(response.status).toBe(200);
});
});
});
});

View file

@ -1,159 +1,155 @@
import { describe, test, expect, beforeEach, beforeAll, afterAll } from 'bun:test'; import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { HttpClient, HttpError, ProxyManager } from '../src/index'; import { HttpClient, HttpError, ProxyManager } from '../src/index';
import type { ProxyInfo } from '../src/types'; import type { ProxyInfo } from '../src/types';
import { MockServer } from './mock-server'; import { MockServer } from './mock-server';
// Global mock server instance // Global mock server instance
let mockServer: MockServer; let mockServer: MockServer;
let mockServerBaseUrl: string; let mockServerBaseUrl: string;
beforeAll(async () => { beforeAll(async () => {
// Start mock server for all tests // Start mock server for all tests
mockServer = new MockServer(); mockServer = new MockServer();
await mockServer.start(); await mockServer.start();
mockServerBaseUrl = mockServer.getBaseUrl(); mockServerBaseUrl = mockServer.getBaseUrl();
}); });
afterAll(async () => { afterAll(async () => {
// Stop mock server // Stop mock server
await mockServer.stop(); await mockServer.stop();
}); });
describe('HttpClient', () => { describe('HttpClient', () => {
let client: HttpClient; let client: HttpClient;
beforeEach(() => { beforeEach(() => {
client = new HttpClient(); client = new HttpClient();
}); });
describe('Basic functionality', () => { describe('Basic functionality', () => {
test('should create client with default config', () => { test('should create client with default config', () => {
expect(client).toBeInstanceOf(HttpClient); expect(client).toBeInstanceOf(HttpClient);
}); });
test('should make GET request', async () => { test('should make GET request', async () => {
const response = await client.get(`${mockServerBaseUrl}/`); const response = await client.get(`${mockServerBaseUrl}/`);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.data).toHaveProperty('url'); expect(response.data).toHaveProperty('url');
expect(response.data).toHaveProperty('method', 'GET'); expect(response.data).toHaveProperty('method', 'GET');
}); });
test('should make POST request with body', async () => { test('should make POST request with body', async () => {
const testData = { const testData = {
title: 'Test Post', title: 'Test Post',
body: 'Test body', body: 'Test body',
userId: 1, userId: 1,
}; };
const response = await client.post(`${mockServerBaseUrl}/post`, testData); const response = await client.post(`${mockServerBaseUrl}/post`, testData);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.data).toHaveProperty('data'); expect(response.data).toHaveProperty('data');
expect(response.data.data).toEqual(testData); expect(response.data.data).toEqual(testData);
}); });
test('should handle custom headers', async () => { test('should handle custom headers', async () => {
const customHeaders = { const customHeaders = {
'X-Custom-Header': 'test-value', 'X-Custom-Header': 'test-value',
'User-Agent': 'StockBot-HTTP-Client/1.0' 'User-Agent': 'StockBot-HTTP-Client/1.0',
}; };
const response = await client.get(`${mockServerBaseUrl}/headers`, { const response = await client.get(`${mockServerBaseUrl}/headers`, {
headers: customHeaders headers: customHeaders,
}); });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.data.headers).toHaveProperty('x-custom-header', 'test-value'); expect(response.data.headers).toHaveProperty('x-custom-header', 'test-value');
expect(response.data.headers).toHaveProperty('user-agent', 'StockBot-HTTP-Client/1.0'); expect(response.data.headers).toHaveProperty('user-agent', 'StockBot-HTTP-Client/1.0');
}); });
test('should handle timeout', async () => { test('should handle timeout', async () => {
const clientWithTimeout = new HttpClient({ timeout: 1 }); // 1ms timeout const clientWithTimeout = new HttpClient({ timeout: 1 }); // 1ms timeout
await expect( await expect(clientWithTimeout.get('https://httpbin.org/delay/1')).rejects.toThrow();
clientWithTimeout.get('https://httpbin.org/delay/1') });
).rejects.toThrow(); });
}); describe('Error handling', () => {
}); test('should handle HTTP errors', async () => {
describe('Error handling', () => { await expect(client.get(`${mockServerBaseUrl}/status/404`)).rejects.toThrow(HttpError);
test('should handle HTTP errors', async () => { });
await expect(
client.get(`${mockServerBaseUrl}/status/404`) test('should handle network errors gracefully', async () => {
).rejects.toThrow(HttpError); await expect(
}); client.get('https://nonexistent-domain-that-will-fail-12345.test')
).rejects.toThrow();
test('should handle network errors gracefully', async () => { });
await expect(
client.get('https://nonexistent-domain-that-will-fail-12345.test') test('should handle invalid URLs', async () => {
).rejects.toThrow(); await expect(client.get('not:/a:valid/url')).rejects.toThrow();
}); });
});
test('should handle invalid URLs', async () => {
await expect( describe('HTTP methods', () => {
client.get('not:/a:valid/url') test('should make PUT request', async () => {
).rejects.toThrow(); const testData = { id: 1, name: 'Updated' };
}); const response = await client.put(`${mockServerBaseUrl}/post`, testData);
}); expect(response.status).toBe(200);
});
describe('HTTP methods', () => {
test('should make PUT request', async () => { test('should make DELETE request', async () => {
const testData = { id: 1, name: 'Updated' }; const response = await client.del(`${mockServerBaseUrl}/`);
const response = await client.put(`${mockServerBaseUrl}/post`, testData); expect(response.status).toBe(200);
expect(response.status).toBe(200); expect(response.data.method).toBe('DELETE');
}); });
test('should make DELETE request', async () => { test('should make PATCH request', async () => {
const response = await client.del(`${mockServerBaseUrl}/`); const testData = { name: 'Patched' };
expect(response.status).toBe(200); const response = await client.patch(`${mockServerBaseUrl}/post`, testData);
expect(response.data.method).toBe('DELETE'); expect(response.status).toBe(200);
}); });
});
test('should make PATCH request', async () => { });
const testData = { name: 'Patched' };
const response = await client.patch(`${mockServerBaseUrl}/post`, testData); describe('ProxyManager', () => {
expect(response.status).toBe(200); test('should determine when to use Bun fetch', () => {
}); const httpProxy: ProxyInfo = {
}); protocol: 'http',
}); host: 'proxy.example.com',
port: 8080,
describe('ProxyManager', () => { };
test('should determine when to use Bun fetch', () => {
const httpProxy: ProxyInfo = { const socksProxy: ProxyInfo = {
protocol: 'http', protocol: 'socks5',
host: 'proxy.example.com', host: 'proxy.example.com',
port: 8080 port: 1080,
}; };
const socksProxy: ProxyInfo = { expect(ProxyManager.shouldUseBunFetch(httpProxy)).toBe(true);
protocol: 'socks5', expect(ProxyManager.shouldUseBunFetch(socksProxy)).toBe(false);
host: 'proxy.example.com', });
port: 1080
}; test('should create proxy URL for Bun fetch', () => {
const proxy: ProxyInfo = {
expect(ProxyManager.shouldUseBunFetch(httpProxy)).toBe(true); protocol: 'http',
expect(ProxyManager.shouldUseBunFetch(socksProxy)).toBe(false); host: 'proxy.example.com',
}); port: 8080,
username: 'user',
test('should create proxy URL for Bun fetch', () => { password: 'pass',
const proxy: ProxyInfo = { };
protocol: 'http',
host: 'proxy.example.com', const proxyUrl = ProxyManager.createProxyUrl(proxy);
port: 8080, expect(proxyUrl).toBe('http://user:pass@proxy.example.com:8080');
username: 'user', });
password: 'pass' };
test('should create proxy URL without credentials', () => {
const proxyUrl = ProxyManager.createProxyUrl(proxy); const proxy: ProxyInfo = {
expect(proxyUrl).toBe('http://user:pass@proxy.example.com:8080'); protocol: 'https',
}); host: 'proxy.example.com',
port: 8080,
test('should create proxy URL without credentials', () => { };
const proxy: ProxyInfo = {
protocol: 'https', const proxyUrl = ProxyManager.createProxyUrl(proxy);
host: 'proxy.example.com', expect(proxyUrl).toBe('https://proxy.example.com:8080');
port: 8080 }; });
});
const proxyUrl = ProxyManager.createProxyUrl(proxy);
expect(proxyUrl).toBe('https://proxy.example.com:8080');
});
});

View file

@ -1,131 +1,132 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { MockServer } from './mock-server'; import { MockServer } from './mock-server';
/** /**
* Tests for the MockServer utility * Tests for the MockServer utility
* Ensures our test infrastructure works correctly * Ensures our test infrastructure works correctly
*/ */
describe('MockServer', () => { describe('MockServer', () => {
let mockServer: MockServer; let mockServer: MockServer;
let baseUrl: string; let baseUrl: string;
beforeAll(async () => { beforeAll(async () => {
mockServer = new MockServer(); mockServer = new MockServer();
await mockServer.start(); await mockServer.start();
baseUrl = mockServer.getBaseUrl(); baseUrl = mockServer.getBaseUrl();
}); });
afterAll(async () => { afterAll(async () => {
await mockServer.stop(); await mockServer.stop();
}); });
describe('Server lifecycle', () => { describe('Server lifecycle', () => {
test('should start and provide base URL', () => { test('should start and provide base URL', () => {
expect(baseUrl).toMatch(/^http:\/\/localhost:\d+$/); expect(baseUrl).toMatch(/^http:\/\/localhost:\d+$/);
expect(mockServer.getBaseUrl()).toBe(baseUrl); expect(mockServer.getBaseUrl()).toBe(baseUrl);
}); });
test('should be reachable', async () => { test('should be reachable', async () => {
const response = await fetch(`${baseUrl}/`); const response = await fetch(`${baseUrl}/`);
expect(response.ok).toBe(true); expect(response.ok).toBe(true);
}); });
}); });
describe('Status endpoints', () => { describe('Status endpoints', () => {
test('should return correct status codes', async () => { test('should return correct status codes', async () => {
const statusCodes = [200, 201, 400, 401, 403, 404, 500, 503]; const statusCodes = [200, 201, 400, 401, 403, 404, 500, 503];
for (const status of statusCodes) { for (const status of statusCodes) {
const response = await fetch(`${baseUrl}/status/${status}`); const response = await fetch(`${baseUrl}/status/${status}`);
expect(response.status).toBe(status); expect(response.status).toBe(status);
} }
}); });
}); });
describe('Headers endpoint', () => { describe('Headers endpoint', () => {
test('should echo request headers', async () => { test('should echo request headers', async () => {
const response = await fetch(`${baseUrl}/headers`, { const response = await fetch(`${baseUrl}/headers`, {
headers: { headers: {
'X-Test-Header': 'test-value', 'X-Test-Header': 'test-value',
'User-Agent': 'MockServer-Test' 'User-Agent': 'MockServer-Test',
} }); },
});
expect(response.ok).toBe(true);
const data = await response.json(); expect(response.ok).toBe(true);
expect(data.headers).toHaveProperty('x-test-header', 'test-value'); const data = await response.json();
expect(data.headers).toHaveProperty('user-agent', 'MockServer-Test'); expect(data.headers).toHaveProperty('x-test-header', 'test-value');
}); expect(data.headers).toHaveProperty('user-agent', 'MockServer-Test');
}); });
});
describe('Basic auth endpoint', () => {
test('should authenticate valid credentials', async () => { describe('Basic auth endpoint', () => {
const username = 'testuser'; test('should authenticate valid credentials', async () => {
const password = 'testpass'; const username = 'testuser';
const credentials = btoa(`${username}:${password}`); const password = 'testpass';
const credentials = btoa(`${username}:${password}`);
const response = await fetch(`${baseUrl}/basic-auth/${username}/${password}`, {
headers: { const response = await fetch(`${baseUrl}/basic-auth/${username}/${password}`, {
'Authorization': `Basic ${credentials}` headers: {
} Authorization: `Basic ${credentials}`,
}); },
});
expect(response.ok).toBe(true);
const data = await response.json(); expect(response.ok).toBe(true);
expect(data.authenticated).toBe(true); const data = await response.json();
expect(data.user).toBe(username); expect(data.authenticated).toBe(true);
}); expect(data.user).toBe(username);
});
test('should reject invalid credentials', async () => {
const credentials = btoa('wrong:credentials'); test('should reject invalid credentials', async () => {
const credentials = btoa('wrong:credentials');
const response = await fetch(`${baseUrl}/basic-auth/user/pass`, {
headers: { const response = await fetch(`${baseUrl}/basic-auth/user/pass`, {
'Authorization': `Basic ${credentials}` headers: {
} Authorization: `Basic ${credentials}`,
}); },
});
expect(response.status).toBe(401);
}); expect(response.status).toBe(401);
});
test('should reject missing auth header', async () => {
const response = await fetch(`${baseUrl}/basic-auth/user/pass`); test('should reject missing auth header', async () => {
expect(response.status).toBe(401); const response = await fetch(`${baseUrl}/basic-auth/user/pass`);
}); expect(response.status).toBe(401);
}); });
});
describe('POST endpoint', () => {
test('should echo POST data', async () => { describe('POST endpoint', () => {
const testData = { test('should echo POST data', async () => {
message: 'Hello, MockServer!', const testData = {
timestamp: Date.now() message: 'Hello, MockServer!',
}; timestamp: Date.now(),
};
const response = await fetch(`${baseUrl}/post`, {
method: 'POST', const response = await fetch(`${baseUrl}/post`, {
headers: { method: 'POST',
'Content-Type': 'application/json' headers: {
}, 'Content-Type': 'application/json',
body: JSON.stringify(testData) },
}); body: JSON.stringify(testData),
});
expect(response.ok).toBe(true);
const data = await response.json(); expect(response.ok).toBe(true);
expect(data.data).toEqual(testData); const data = await response.json();
expect(data.method).toBe('POST'); expect(data.data).toEqual(testData);
expect(data.headers).toHaveProperty('content-type', 'application/json'); expect(data.method).toBe('POST');
}); expect(data.headers).toHaveProperty('content-type', 'application/json');
}); });
});
describe('Default endpoint', () => {
test('should return request information', async () => { describe('Default endpoint', () => {
const response = await fetch(`${baseUrl}/unknown-endpoint`); test('should return request information', async () => {
const response = await fetch(`${baseUrl}/unknown-endpoint`);
expect(response.ok).toBe(true);
const data = await response.json(); expect(response.ok).toBe(true);
expect(data.url).toBe(`${baseUrl}/unknown-endpoint`); const data = await response.json();
expect(data.method).toBe('GET'); expect(data.url).toBe(`${baseUrl}/unknown-endpoint`);
expect(data.headers).toBeDefined(); expect(data.method).toBe('GET');
}); expect(data.headers).toBeDefined();
}); });
}); });
});

View file

@ -1,114 +1,116 @@
/** /**
* Mock HTTP server for testing the HTTP client * Mock HTTP server for testing the HTTP client
* Replaces external dependency on httpbin.org with a local server * Replaces external dependency on httpbin.org with a local server
*/ */
export class MockServer { export class MockServer {
private server: ReturnType<typeof Bun.serve> | null = null; private server: ReturnType<typeof Bun.serve> | null = null;
private port: number = 0; private port: number = 0;
/** /**
* Start the mock server on a random port * Start the mock server on a random port
*/ */
async start(): Promise<void> { async start(): Promise<void> {
this.server = Bun.serve({ this.server = Bun.serve({
port: 1, // Use any available port port: 1, // Use any available port
fetch: this.handleRequest.bind(this), fetch: this.handleRequest.bind(this),
error: this.handleError.bind(this), error: this.handleError.bind(this),
}); });
this.port = this.server.port || 1; this.port = this.server.port || 1;
console.log(`Mock server started on port ${this.port}`); console.log(`Mock server started on port ${this.port}`);
} }
/** /**
* Stop the mock server * Stop the mock server
*/ */
async stop(): Promise<void> { async stop(): Promise<void> {
if (this.server) { if (this.server) {
this.server.stop(true); this.server.stop(true);
this.server = null; this.server = null;
this.port = 0; this.port = 0;
console.log('Mock server stopped'); console.log('Mock server stopped');
} }
} }
/** /**
* Get the base URL of the mock server * Get the base URL of the mock server
*/ */
getBaseUrl(): string { getBaseUrl(): string {
if (!this.server) { if (!this.server) {
throw new Error('Server not started'); throw new Error('Server not started');
} }
return `http://localhost:${this.port}`; return `http://localhost:${this.port}`;
} }
/** /**
* Handle incoming requests * Handle incoming requests
*/ private async handleRequest(req: Request): Promise<Response> { */ private async handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url); const url = new URL(req.url);
const path = url.pathname; const path = url.pathname;
console.log(`Mock server handling request: ${req.method} ${path}`); console.log(`Mock server handling request: ${req.method} ${path}`);
// Status endpoints // Status endpoints
if (path.startsWith('/status/')) { if (path.startsWith('/status/')) {
const status = parseInt(path.replace('/status/', ''), 10); const status = parseInt(path.replace('/status/', ''), 10);
console.log(`Returning status: ${status}`); console.log(`Returning status: ${status}`);
return new Response(null, { status }); return new Response(null, { status });
} // Headers endpoint } // Headers endpoint
if (path === '/headers') { if (path === '/headers') {
const headers = Object.fromEntries([...req.headers.entries()]); const headers = Object.fromEntries([...req.headers.entries()]);
console.log('Headers endpoint called, received headers:', headers); console.log('Headers endpoint called, received headers:', headers);
return Response.json({ headers }); return Response.json({ headers });
} // Basic auth endpoint } // Basic auth endpoint
if (path.startsWith('/basic-auth/')) { if (path.startsWith('/basic-auth/')) {
const parts = path.split('/').filter(Boolean); const parts = path.split('/').filter(Boolean);
const expectedUsername = parts[1]; const expectedUsername = parts[1];
const expectedPassword = parts[2]; const expectedPassword = parts[2];
console.log(`Basic auth endpoint called: expected user=${expectedUsername}, pass=${expectedPassword}`); console.log(
`Basic auth endpoint called: expected user=${expectedUsername}, pass=${expectedPassword}`
const authHeader = req.headers.get('authorization'); );
if (!authHeader || !authHeader.startsWith('Basic ')) {
console.log('Missing or invalid Authorization header'); const authHeader = req.headers.get('authorization');
return new Response('Unauthorized', { status: 401 }); if (!authHeader || !authHeader.startsWith('Basic ')) {
} console.log('Missing or invalid Authorization header');
return new Response('Unauthorized', { status: 401 });
const base64Credentials = authHeader.split(' ')[1]; }
const credentials = atob(base64Credentials);
const [username, password] = credentials.split(':'); const base64Credentials = authHeader.split(' ')[1];
const credentials = atob(base64Credentials);
if (username === expectedUsername && password === expectedPassword) { const [username, password] = credentials.split(':');
return Response.json({
authenticated: true, if (username === expectedUsername && password === expectedPassword) {
user: username return Response.json({
}); authenticated: true,
} user: username,
});
return new Response('Unauthorized', { status: 401 }); }
}
return new Response('Unauthorized', { status: 401 });
// Echo request body }
if (path === '/post' && req.method === 'POST') {
const data = await req.json(); // Echo request body
return Response.json({ if (path === '/post' && req.method === 'POST') {
data, const data = await req.json();
headers: Object.fromEntries([...req.headers.entries()]), return Response.json({
method: req.method data,
}); headers: Object.fromEntries([...req.headers.entries()]),
} method: req.method,
});
// Default response }
return Response.json({
url: req.url, // Default response
method: req.method, return Response.json({
headers: Object.fromEntries([...req.headers.entries()]) url: req.url,
}); method: req.method,
} headers: Object.fromEntries([...req.headers.entries()]),
});
/** }
* Handle errors
*/ /**
private handleError(error: Error): Response { * Handle errors
return new Response('Server error', { status: 500 }); */
} private handleError(error: Error): Response {
} return new Response('Server error', { status: 500 });
}
}

View file

@ -1,18 +1,14 @@
/** /**
* @stock-bot/logger - Simplified logging library * @stock-bot/logger - Simplified logging library
* *
* Main exports for the logger library * Main exports for the logger library
*/ */
// Core logger classes and functions // Core logger classes and functions
export { export { Logger, getLogger, shutdownLoggers } from './logger';
Logger,
getLogger, // Type definitions
shutdownLoggers export type { LogLevel, LogContext, LogMetadata } from './types';
} from './logger';
// Default export
// Type definitions export { getLogger as default } from './logger';
export type { LogLevel, LogContext, LogMetadata } from './types';
// Default export
export { getLogger as default } from './logger';

View file

@ -1,271 +1,270 @@
/** /**
* Simplified Pino-based logger for Stock Bot platform * Simplified Pino-based logger for Stock Bot platform
* *
* Features: * Features:
* - High performance JSON logging with Pino * - High performance JSON logging with Pino
* - Console, file, and Loki transports * - Console, file, and Loki transports
* - Structured logging with metadata * - Structured logging with metadata
* - Service-specific context * - Service-specific context
*/ */
import pino from 'pino'; import pino from 'pino';
import { loggingConfig, lokiConfig } from '@stock-bot/config'; import { loggingConfig, lokiConfig } from '@stock-bot/config';
import type { LogLevel, LogContext, LogMetadata } from './types'; import type { LogContext, LogLevel, LogMetadata } from './types';
// Simple cache for logger instances // Simple cache for logger instances
const loggerCache = new Map<string, pino.Logger>(); const loggerCache = new Map<string, pino.Logger>();
console.log('Logger cache initialized: ', loggingConfig.LOG_LEVEL); console.log('Logger cache initialized: ', loggingConfig.LOG_LEVEL);
/** /**
* Create transport configuration * Create transport configuration
*/ */
function createTransports(serviceName: string): any { function createTransports(serviceName: string): any {
const targets: any[] = []; const targets: any[] = [];
// const isDev = loggingConfig.LOG_ENVIRONMENT === 'development'; // const isDev = loggingConfig.LOG_ENVIRONMENT === 'development';
// Console transport // Console transport
if (loggingConfig.LOG_CONSOLE) { if (loggingConfig.LOG_CONSOLE) {
targets.push({ targets.push({
target: 'pino-pretty', target: 'pino-pretty',
level: loggingConfig.LOG_LEVEL, // Only show errors on console level: loggingConfig.LOG_LEVEL, // Only show errors on console
options: { options: {
colorize: true, colorize: true,
translateTime: 'yyyy-mm-dd HH:MM:ss.l', translateTime: 'yyyy-mm-dd HH:MM:ss.l',
messageFormat: '[{service}{childName}] {msg}', messageFormat: '[{service}{childName}] {msg}',
singleLine: true, singleLine: true,
hideObject: false, hideObject: false,
ignore: 'pid,hostname,service,environment,version,childName', ignore: 'pid,hostname,service,environment,version,childName',
errorLikeObjectKeys: ['err', 'error'], errorLikeObjectKeys: ['err', 'error'],
errorProps: 'message,stack,name,code', errorProps: 'message,stack,name,code',
} },
}); });
} }
// File transport // File transport
if (loggingConfig.LOG_FILE) { if (loggingConfig.LOG_FILE) {
targets.push({ targets.push({
target: 'pino/file', target: 'pino/file',
level: loggingConfig.LOG_LEVEL, level: loggingConfig.LOG_LEVEL,
options: { options: {
destination: `${loggingConfig.LOG_FILE_PATH}/${serviceName}.log`, destination: `${loggingConfig.LOG_FILE_PATH}/${serviceName}.log`,
mkdir: true mkdir: true,
} },
}); });
} }
// Loki transport // Loki transport
if (lokiConfig.LOKI_HOST) { if (lokiConfig.LOKI_HOST) {
targets.push({ targets.push({
target: 'pino-loki', target: 'pino-loki',
level: loggingConfig.LOG_LEVEL, level: loggingConfig.LOG_LEVEL,
options: { options: {
host: lokiConfig.LOKI_URL || `http://${lokiConfig.LOKI_HOST}:${lokiConfig.LOKI_PORT}`, host: lokiConfig.LOKI_URL || `http://${lokiConfig.LOKI_HOST}:${lokiConfig.LOKI_PORT}`,
labels: { labels: {
service: serviceName, service: serviceName,
environment: lokiConfig.LOKI_ENVIRONMENT_LABEL environment: lokiConfig.LOKI_ENVIRONMENT_LABEL,
}, },
ignore: 'childName', ignore: 'childName',
} },
}); });
} }
return targets.length > 0 ? { targets } : null; return targets.length > 0 ? { targets } : null;
} }
/** /**
* Get or create pino logger * Get or create pino logger
*/ */
function getPinoLogger(serviceName: string): pino.Logger { function getPinoLogger(serviceName: string): pino.Logger {
if (!loggerCache.has(serviceName)) { if (!loggerCache.has(serviceName)) {
const transport = createTransports(serviceName); const transport = createTransports(serviceName);
const config: pino.LoggerOptions = { const config: pino.LoggerOptions = {
level: loggingConfig.LOG_LEVEL, level: loggingConfig.LOG_LEVEL,
base: { base: {
service: serviceName, service: serviceName,
environment: loggingConfig.LOG_ENVIRONMENT, environment: loggingConfig.LOG_ENVIRONMENT,
version: loggingConfig.LOG_SERVICE_VERSION version: loggingConfig.LOG_SERVICE_VERSION,
} },
}; };
if (transport) { if (transport) {
config.transport = transport; config.transport = transport;
} }
loggerCache.set(serviceName, pino(config)); loggerCache.set(serviceName, pino(config));
} }
return loggerCache.get(serviceName)!; return loggerCache.get(serviceName)!;
} }
/**
/** * Simplified Logger class
* Simplified Logger class */
*/ export class Logger {
export class Logger { private pino: pino.Logger;
private pino: pino.Logger; private context: LogContext;
private context: LogContext; private serviceName: string;
private serviceName: string; private childName?: string;
private childName?: string;
constructor(serviceName: string, context: LogContext = {}) {
constructor(serviceName: string, context: LogContext = {}) { this.pino = getPinoLogger(serviceName);
this.pino = getPinoLogger(serviceName); this.context = context;
this.context = context; this.serviceName = serviceName;
this.serviceName = serviceName; }
}
/**
/** * Core log method
* Core log method */
*/ private log(level: LogLevel, message: string | object, metadata?: LogMetadata): void {
private log(level: LogLevel, message: string | object, metadata?: LogMetadata): void { const data = { ...this.context, ...metadata };
const data = { ...this.context, ...metadata };
if (typeof message === 'string') {
if (typeof message === 'string') { (this.pino as any)[level](data, message);
(this.pino as any)[level](data, message); } else {
} else { (this.pino as any)[level]({ ...data, data: message }, 'Object logged');
(this.pino as any)[level]({ ...data, data: message }, 'Object logged'); }
} }
}
// Simple log level methods
// Simple log level methods debug(message: string | object, metadata?: LogMetadata): void {
debug(message: string | object, metadata?: LogMetadata): void { this.log('debug', message, metadata);
this.log('debug', message, metadata); }
}
info(message: string | object, metadata?: LogMetadata): void {
info(message: string | object, metadata?: LogMetadata): void { this.log('info', message, metadata);
this.log('info', message, metadata); }
}
warn(message: string | object, metadata?: LogMetadata): void {
warn(message: string | object, metadata?: LogMetadata): void { this.log('warn', message, metadata);
this.log('warn', message, metadata); }
}
error(message: string | object, metadata?: (LogMetadata & { error?: any }) | unknown): void {
error(message: string | object, metadata?: LogMetadata & { error?: any } | unknown): void { let data: any = {};
let data: any = {};
// Handle metadata parameter normalization
// Handle metadata parameter normalization if (metadata instanceof Error) {
if (metadata instanceof Error) { // Direct Error object as metadata
// Direct Error object as metadata data = { error: metadata };
data = { error: metadata }; } else if (metadata !== null && typeof metadata === 'object') {
} else if (metadata !== null && typeof metadata === 'object') { // Object metadata (including arrays, but not null)
// Object metadata (including arrays, but not null) data = { ...metadata };
data = { ...metadata }; } else if (metadata !== undefined) {
} else if (metadata !== undefined) { // Primitive values (string, number, boolean, etc.)
// Primitive values (string, number, boolean, etc.) data = { metadata };
data = { metadata }; }
}
// Handle multiple error properties in metadata
// Handle multiple error properties in metadata const errorKeys = ['error', 'err', 'primaryError', 'secondaryError'];
const errorKeys = ['error', 'err', 'primaryError', 'secondaryError']; errorKeys.forEach(key => {
errorKeys.forEach(key => { if (data[key]) {
if (data[key]) { const normalizedKey = key === 'error' ? 'err' : `${key}_normalized`;
const normalizedKey = key === 'error' ? 'err' : `${key}_normalized`; data[normalizedKey] = this.normalizeError(data[key]);
data[normalizedKey] = this.normalizeError(data[key]);
// Only delete the original 'error' key to maintain other error properties
// Only delete the original 'error' key to maintain other error properties if (key === 'error') {
if (key === 'error') { delete data.error;
delete data.error; }
} }
} });
});
this.log('error', message, data);
this.log('error', message, data); }
}
/**
/** * Normalize any error type to a structured format
* Normalize any error type to a structured format */
*/ private normalizeError(error: any): any {
private normalizeError(error: any): any { if (error instanceof Error) {
if (error instanceof Error) { return {
return { name: error.name,
name: error.name, message: error.message,
message: error.message, stack: error.stack,
stack: error.stack, };
}; }
}
if (error && typeof error === 'object') {
if (error && typeof error === 'object') { // Handle error-like objects
// Handle error-like objects return {
return { name: error.name || 'UnknownError',
name: error.name || 'UnknownError', message: error.message || error.toString(),
message: error.message || error.toString(), ...(error.stack && { stack: error.stack }),
...(error.stack && { stack: error.stack }), ...(error.code && { code: error.code }),
...(error.code && { code: error.code }), ...(error.status && { status: error.status }),
...(error.status && { status: error.status }) };
}; }
}
// Handle primitives (string, number, etc.)
// Handle primitives (string, number, etc.) return {
return { name: 'UnknownError',
name: 'UnknownError', message: String(error),
message: String(error) };
}; }
} /**
/** * Create child logger with additional context
* Create child logger with additional context */
*/ child(serviceName: string, context?: LogContext): Logger {
child(serviceName: string, context?: LogContext): Logger { // Create child logger that shares the same pino instance with additional context
// Create child logger that shares the same pino instance with additional context const childLogger = Object.create(Logger.prototype);
const childLogger = Object.create(Logger.prototype); childLogger.serviceName = this.serviceName;
childLogger.serviceName = this.serviceName; childLogger.childName = serviceName;
childLogger.childName = serviceName; childLogger.context = { ...this.context, ...context };
childLogger.context = { ...this.context, ...context }; const childBindings = {
const childBindings = { service: this.serviceName,
service: this.serviceName, childName: ' -> ' + serviceName,
childName: ' -> ' + serviceName, ...(context || childLogger.context),
...(context || childLogger.context) };
};
childLogger.pino = this.pino.child(childBindings);
childLogger.pino = this.pino.child(childBindings); return childLogger;
return childLogger; // }
// } // childLogger.pino = this.pino.child(context || childLogger.context); // Let pino handle level inheritance naturally
// childLogger.pino = this.pino.child(context || childLogger.context); // Let pino handle level inheritance naturally // return childLogger;
// return childLogger; }
}
// Getters for service and context
// Getters for service and context getServiceName(): string {
getServiceName(): string { return this.serviceName;
return this.serviceName; }
} getChildName(): string | undefined {
getChildName(): string | undefined { return this.childName;
return this.childName; }
} }
}
/**
/** * Main factory function
* Main factory function */
*/ export function getLogger(serviceName: string, context?: LogContext): Logger {
export function getLogger(serviceName: string, context?: LogContext): Logger { return new Logger(serviceName, context);
return new Logger(serviceName, context); }
}
/**
/** * Gracefully shutdown all logger instances
* Gracefully shutdown all logger instances * This should be called during application shutdown to ensure all logs are flushed
* This should be called during application shutdown to ensure all logs are flushed */
*/ export async function shutdownLoggers(): Promise<void> {
export async function shutdownLoggers(): Promise<void> { const flushPromises = Array.from(loggerCache.values()).map(logger => {
const flushPromises = Array.from(loggerCache.values()).map(logger => { return new Promise<void>(resolve => {
return new Promise<void>((resolve) => { if (typeof logger.flush === 'function') {
if (typeof logger.flush === 'function') { logger.flush(err => {
logger.flush((err) => { if (err) {
if (err) { console.error('Logger flush error:', err);
console.error('Logger flush error:', err); }
} resolve();
resolve(); });
}); } else {
} else { resolve();
resolve(); }
} });
}); });
});
try {
try { await Promise.allSettled(flushPromises);
await Promise.allSettled(flushPromises); console.log('All loggers flushed successfully');
console.log('All loggers flushed successfully'); } catch (error) {
} catch (error) { console.error('Logger flush failed:', error);
console.error('Logger flush failed:', error); } finally {
} finally { loggerCache.clear();
loggerCache.clear(); }
} }
}
// Export types for convenience
// Export types for convenience export type { LogLevel, LogContext, LogMetadata } from './types';
export type { LogLevel, LogContext, LogMetadata } from './types';

View file

@ -1,16 +1,16 @@
/** /**
* Simplified type definitions for the logger library * Simplified type definitions for the logger library
*/ */
// Standard log levels (simplified to pino defaults) // Standard log levels (simplified to pino defaults)
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
// Context that persists across log calls // Context that persists across log calls
export interface LogContext { export interface LogContext {
[key: string]: any; [key: string]: any;
} }
// Metadata for individual log entries // Metadata for individual log entries
export interface LogMetadata { export interface LogMetadata {
[key: string]: any; [key: string]: any;
} }

View file

@ -1,200 +1,201 @@
/** /**
* Advanced Logger Tests * Advanced Logger Tests
* *
* Tests for advanced logger functionality including complex metadata handling, * Tests for advanced logger functionality including complex metadata handling,
* child loggers, and advanced error scenarios. * child loggers, and advanced error scenarios.
*/ */
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { Logger, shutdownLoggers } from '../src'; import { Logger, shutdownLoggers } from '../src';
import { loggerTestHelpers } from './setup'; import { loggerTestHelpers } from './setup';
describe('Advanced Logger Features', () => { describe('Advanced Logger Features', () => {
let logger: Logger; let logger: Logger;
let testLoggerInstance: ReturnType<typeof loggerTestHelpers.createTestLogger>; let testLoggerInstance: ReturnType<typeof loggerTestHelpers.createTestLogger>;
beforeEach(() => { beforeEach(() => {
testLoggerInstance = loggerTestHelpers.createTestLogger('advanced-features'); testLoggerInstance = loggerTestHelpers.createTestLogger('advanced-features');
logger = testLoggerInstance.logger; logger = testLoggerInstance.logger;
}); afterEach(async () => { });
testLoggerInstance.clearCapturedLogs(); afterEach(async () => {
// Clear any global logger cache testLoggerInstance.clearCapturedLogs();
await shutdownLoggers(); // Clear any global logger cache
}); await shutdownLoggers();
});
describe('Complex Metadata Handling', () => {
it('should handle nested metadata objects', () => { describe('Complex Metadata Handling', () => {
const complexMetadata = { it('should handle nested metadata objects', () => {
user: { id: '123', name: 'John Doe' }, const complexMetadata = {
session: { id: 'sess-456', timeout: 3600 }, user: { id: '123', name: 'John Doe' },
request: { method: 'POST', path: '/api/test' } session: { id: 'sess-456', timeout: 3600 },
}; request: { method: 'POST', path: '/api/test' },
};
logger.info('Complex operation', complexMetadata);
logger.info('Complex operation', complexMetadata);
const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); const logs = testLoggerInstance.getCapturedLogs();
expect(logs[0].user).toEqual({ id: '123', name: 'John Doe' }); expect(logs.length).toBe(1);
expect(logs[0].session).toEqual({ id: 'sess-456', timeout: 3600 }); expect(logs[0].user).toEqual({ id: '123', name: 'John Doe' });
expect(logs[0].request).toEqual({ method: 'POST', path: '/api/test' }); expect(logs[0].session).toEqual({ id: 'sess-456', timeout: 3600 });
}); expect(logs[0].request).toEqual({ method: 'POST', path: '/api/test' });
});
it('should handle arrays in metadata', () => {
const arrayMetadata = { it('should handle arrays in metadata', () => {
tags: ['user', 'authentication', 'success'], const arrayMetadata = {
ids: [1, 2, 3, 4] tags: ['user', 'authentication', 'success'],
}; ids: [1, 2, 3, 4],
};
logger.info('Array metadata test', arrayMetadata);
logger.info('Array metadata test', arrayMetadata);
const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); const logs = testLoggerInstance.getCapturedLogs();
expect(logs[0].tags).toEqual(['user', 'authentication', 'success']); expect(logs.length).toBe(1);
expect(logs[0].ids).toEqual([1, 2, 3, 4]); expect(logs[0].tags).toEqual(['user', 'authentication', 'success']);
}); expect(logs[0].ids).toEqual([1, 2, 3, 4]);
});
it('should handle null and undefined metadata values', () => {
const nullMetadata = { it('should handle null and undefined metadata values', () => {
nullValue: null, const nullMetadata = {
undefinedValue: undefined, nullValue: null,
emptyString: '', undefinedValue: undefined,
zeroValue: 0 emptyString: '',
}; zeroValue: 0,
};
logger.info('Null metadata test', nullMetadata);
logger.info('Null metadata test', nullMetadata);
const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); const logs = testLoggerInstance.getCapturedLogs();
expect(logs[0].nullValue).toBe(null); expect(logs.length).toBe(1);
expect(logs[0].emptyString).toBe(''); expect(logs[0].nullValue).toBe(null);
expect(logs[0].zeroValue).toBe(0); expect(logs[0].emptyString).toBe('');
}); expect(logs[0].zeroValue).toBe(0);
}); });
});
describe('Child Logger Functionality', () => {
it('should create child logger with additional context', () => { describe('Child Logger Functionality', () => {
const childLogger = logger.child({ it('should create child logger with additional context', () => {
component: 'auth-service', const childLogger = logger.child({
version: '1.2.3' component: 'auth-service',
}); version: '1.2.3',
});
childLogger.info('Child logger message');
childLogger.info('Child logger message');
const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); const logs = testLoggerInstance.getCapturedLogs();
expect(logs[0].component).toBe('auth-service'); expect(logs.length).toBe(1);
expect(logs[0].version).toBe('1.2.3'); expect(logs[0].component).toBe('auth-service');
expect(logs[0].msg).toBe('Child logger message'); expect(logs[0].version).toBe('1.2.3');
}); expect(logs[0].msg).toBe('Child logger message');
});
it('should support nested child loggers', () => {
const childLogger = logger.child({ level1: 'parent' }); it('should support nested child loggers', () => {
const grandChildLogger = childLogger.child({ level2: 'child' }); const childLogger = logger.child({ level1: 'parent' });
const grandChildLogger = childLogger.child({ level2: 'child' });
grandChildLogger.warn('Nested child message');
grandChildLogger.warn('Nested child message');
const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); const logs = testLoggerInstance.getCapturedLogs();
expect(logs[0].level1).toBe('parent'); expect(logs.length).toBe(1);
expect(logs[0].level2).toBe('child'); expect(logs[0].level1).toBe('parent');
expect(logs[0].level).toBe('warn'); expect(logs[0].level2).toBe('child');
}); expect(logs[0].level).toBe('warn');
});
it('should merge child context with log metadata', () => {
const childLogger = logger.child({ service: 'api' }); it('should merge child context with log metadata', () => {
const childLogger = logger.child({ service: 'api' });
childLogger.info('Request processed', {
requestId: 'req-789', childLogger.info('Request processed', {
duration: 150 requestId: 'req-789',
}); duration: 150,
});
const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); const logs = testLoggerInstance.getCapturedLogs();
expect(logs[0].service).toBe('api'); expect(logs.length).toBe(1);
expect(logs[0].requestId).toBe('req-789'); expect(logs[0].service).toBe('api');
expect(logs[0].duration).toBe(150); expect(logs[0].requestId).toBe('req-789');
}); expect(logs[0].duration).toBe(150);
}); });
});
describe('Advanced Error Handling', () => {
it('should handle Error objects with custom properties', () => { describe('Advanced Error Handling', () => {
const customError = new Error('Custom error message'); it('should handle Error objects with custom properties', () => {
(customError as any).code = 'ERR_CUSTOM'; const customError = new Error('Custom error message');
(customError as any).statusCode = 500; (customError as any).code = 'ERR_CUSTOM';
(customError as any).statusCode = 500;
logger.error('Custom error occurred', { error: customError });
logger.error('Custom error occurred', { error: customError });
const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); const logs = testLoggerInstance.getCapturedLogs();
expect(logs[0].level).toBe('error'); expect(logs.length).toBe(1);
expect(logs[0].msg).toBe('Custom error occurred'); expect(logs[0].level).toBe('error');
}); expect(logs[0].msg).toBe('Custom error occurred');
});
it('should handle multiple errors in metadata', () => {
const error1 = new Error('First error'); it('should handle multiple errors in metadata', () => {
const error2 = new Error('Second error'); const error1 = new Error('First error');
const error2 = new Error('Second error');
logger.error('Multiple errors', {
primaryError: error1, logger.error('Multiple errors', {
secondaryError: error2, primaryError: error1,
context: 'batch processing' secondaryError: error2,
}); context: 'batch processing',
});
const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); const logs = testLoggerInstance.getCapturedLogs();
expect(logs[0].context).toBe('batch processing'); expect(logs.length).toBe(1);
}); expect(logs[0].context).toBe('batch processing');
it('should handle error objects with circular references', () => { });
const errorWithCircular: any = { name: 'CircularError', message: 'Circular reference error' }; it('should handle error objects with circular references', () => {
// Create a simple circular reference const errorWithCircular: any = { name: 'CircularError', message: 'Circular reference error' };
errorWithCircular.self = errorWithCircular; // Create a simple circular reference
errorWithCircular.self = errorWithCircular;
// Should not throw when logging circular references
expect(() => { // Should not throw when logging circular references
logger.error('Circular error test', { error: errorWithCircular }); expect(() => {
}).not.toThrow(); logger.error('Circular error test', { error: errorWithCircular });
}).not.toThrow();
const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); const logs = testLoggerInstance.getCapturedLogs();
expect(logs[0].level).toBe('error'); expect(logs.length).toBe(1);
expect(logs[0].level).toBe('error');
// Clean up circular reference to prevent memory issues
delete errorWithCircular.self; // Clean up circular reference to prevent memory issues
}); delete errorWithCircular.self;
}); });
describe('Performance and Edge Cases', () => { });
it('should handle moderate metadata objects', () => { describe('Performance and Edge Cases', () => {
const moderateMetadata: any = {}; it('should handle moderate metadata objects', () => {
for (let i = 0; i < 10; i++) { const moderateMetadata: any = {};
moderateMetadata[`key${i}`] = `value${i}`; for (let i = 0; i < 10; i++) {
} moderateMetadata[`key${i}`] = `value${i}`;
}
logger.debug('Moderate metadata test', moderateMetadata);
logger.debug('Moderate metadata test', moderateMetadata);
const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); const logs = testLoggerInstance.getCapturedLogs();
expect(logs[0].key0).toBe('value0'); expect(logs.length).toBe(1);
expect(logs[0].key9).toBe('value9'); expect(logs[0].key0).toBe('value0');
}); expect(logs[0].key9).toBe('value9');
});
it('should handle special characters in messages', () => {
const specialMessage = 'Special chars: 🚀 ñ ü'; it('should handle special characters in messages', () => {
const specialMessage = 'Special chars: 🚀 ñ ü';
logger.info(specialMessage);
logger.info(specialMessage);
const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); const logs = testLoggerInstance.getCapturedLogs();
expect(logs[0].msg).toBe(specialMessage); expect(logs.length).toBe(1);
}); expect(logs[0].msg).toBe(specialMessage);
});
it('should handle empty and whitespace-only messages', () => {
logger.info(''); it('should handle empty and whitespace-only messages', () => {
logger.info(' '); logger.info('');
logger.info(' ');
const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(2); const logs = testLoggerInstance.getCapturedLogs();
expect(logs[0].msg).toBe(''); expect(logs.length).toBe(2);
expect(logs[1].msg).toBe(' '); expect(logs[0].msg).toBe('');
}); expect(logs[1].msg).toBe(' ');
}); });
}); });
});

View file

@ -1,169 +1,169 @@
/** /**
* Basic Logger Tests * Basic Logger Tests
* *
* Tests for the core logger functionality and utilities. * Tests for the core logger functionality and utilities.
*/ */
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { Logger, getLogger, shutdownLoggers } from '../src'; import { getLogger, Logger, shutdownLoggers } from '../src';
import { loggerTestHelpers } from './setup'; import { loggerTestHelpers } from './setup';
describe('Basic Logger Tests', () => { describe('Basic Logger Tests', () => {
let logger: Logger; let logger: Logger;
let testLoggerInstance: ReturnType<typeof loggerTestHelpers.createTestLogger>; let testLoggerInstance: ReturnType<typeof loggerTestHelpers.createTestLogger>;
beforeEach(() => { beforeEach(() => {
testLoggerInstance = loggerTestHelpers.createTestLogger('utils-test'); testLoggerInstance = loggerTestHelpers.createTestLogger('utils-test');
logger = testLoggerInstance.logger; logger = testLoggerInstance.logger;
}); });
afterEach(async () => { afterEach(async () => {
testLoggerInstance.clearCapturedLogs(); testLoggerInstance.clearCapturedLogs();
// Clear any global logger cache // Clear any global logger cache
await shutdownLoggers(); await shutdownLoggers();
}); });
describe('Logger Factory Functions', () => { describe('Logger Factory Functions', () => {
it('should create logger with getLogger', () => { it('should create logger with getLogger', () => {
expect(typeof getLogger).toBe('function'); expect(typeof getLogger).toBe('function');
// Test that getLogger doesn't throw // Test that getLogger doesn't throw
expect(() => { expect(() => {
const anotherTestLoggerInstance = loggerTestHelpers.createTestLogger('factory-test'); const anotherTestLoggerInstance = loggerTestHelpers.createTestLogger('factory-test');
anotherTestLoggerInstance.logger.info('Factory test'); anotherTestLoggerInstance.logger.info('Factory test');
}).not.toThrow(); }).not.toThrow();
}); });
}); });
describe('Logger Methods', () => { describe('Logger Methods', () => {
it('should have all required logging methods', () => { it('should have all required logging methods', () => {
expect(typeof logger.debug).toBe('function'); expect(typeof logger.debug).toBe('function');
expect(typeof logger.info).toBe('function'); expect(typeof logger.info).toBe('function');
expect(typeof logger.warn).toBe('function'); expect(typeof logger.warn).toBe('function');
expect(typeof logger.error).toBe('function'); expect(typeof logger.error).toBe('function');
expect(typeof logger.child).toBe('function'); expect(typeof logger.child).toBe('function');
}); });
it('should log with different message types', () => { it('should log with different message types', () => {
// String message // String message
logger.info('String message'); logger.info('String message');
// Object message // Object message
logger.info({ event: 'object_message', data: 'test' }); logger.info({ event: 'object_message', data: 'test' });
const logs = testLoggerInstance.getCapturedLogs(); const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(2); expect(logs.length).toBe(2);
expect(logs[0].msg).toBe('String message'); expect(logs[0].msg).toBe('String message');
expect(logs[1].level).toBe('info'); expect(logs[1].level).toBe('info');
}); });
it('should handle metadata correctly', () => { it('should handle metadata correctly', () => {
const metadata = { const metadata = {
userId: 'user123', userId: 'user123',
sessionId: 'session456', sessionId: 'session456',
requestId: 'req789' requestId: 'req789',
}; };
logger.info('Request processed', metadata); logger.info('Request processed', metadata);
const logs = testLoggerInstance.getCapturedLogs(); const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); expect(logs.length).toBe(1);
expect(logs[0].userId).toBe('user123'); expect(logs[0].userId).toBe('user123');
expect(logs[0].sessionId).toBe('session456'); expect(logs[0].sessionId).toBe('session456');
expect(logs[0].requestId).toBe('req789'); expect(logs[0].requestId).toBe('req789');
}); });
}); });
describe('Child Logger Functionality', () => { describe('Child Logger Functionality', () => {
it('should create child loggers with additional context', () => { it('should create child loggers with additional context', () => {
const childLogger = logger.child({ const childLogger = logger.child({
module: 'payment', module: 'payment',
version: '1.0.0' version: '1.0.0',
}); });
childLogger.info('Payment processed'); childLogger.info('Payment processed');
const logs = testLoggerInstance.getCapturedLogs(); const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); expect(logs.length).toBe(1);
expect(logs[0].msg).toBe('Payment processed'); expect(logs[0].msg).toBe('Payment processed');
}); });
it('should inherit service name in child loggers', () => { it('should inherit service name in child loggers', () => {
const childLogger = logger.child({ operation: 'test' }); const childLogger = logger.child({ operation: 'test' });
childLogger.info('Child operation'); childLogger.info('Child operation');
const logs = testLoggerInstance.getCapturedLogs(); const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); expect(logs.length).toBe(1);
expect(logs[0].service).toBe('utils-test'); expect(logs[0].service).toBe('utils-test');
}); });
}); });
describe('Error Normalization', () => { describe('Error Normalization', () => {
it('should handle Error objects', () => { it('should handle Error objects', () => {
const error = new Error('Test error'); const error = new Error('Test error');
error.stack = 'Error stack trace'; error.stack = 'Error stack trace';
logger.error('Error test', error); logger.error('Error test', error);
const logs = testLoggerInstance.getCapturedLogs(); const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); expect(logs.length).toBe(1);
expect(logs[0].level).toBe('error'); expect(logs[0].level).toBe('error');
}); });
it('should handle error-like objects', () => { it('should handle error-like objects', () => {
const errorLike = { const errorLike = {
name: 'ValidationError', name: 'ValidationError',
message: 'Invalid input', message: 'Invalid input',
code: 'VALIDATION_FAILED' code: 'VALIDATION_FAILED',
}; };
logger.error('Validation failed', { error: errorLike }); logger.error('Validation failed', { error: errorLike });
const logs = testLoggerInstance.getCapturedLogs(); const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); expect(logs.length).toBe(1);
expect(logs[0].level).toBe('error'); expect(logs[0].level).toBe('error');
}); });
it('should handle primitive error values', () => { it('should handle primitive error values', () => {
logger.error('Simple error', { error: 'Error string' }); logger.error('Simple error', { error: 'Error string' });
const logs = testLoggerInstance.getCapturedLogs(); const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(1); expect(logs.length).toBe(1);
expect(logs[0].level).toBe('error'); expect(logs[0].level).toBe('error');
}); });
}); });
describe('Service Context', () => { describe('Service Context', () => {
it('should include service name in all logs', () => { it('should include service name in all logs', () => {
logger.debug('Debug message'); logger.debug('Debug message');
logger.info('Info message'); logger.info('Info message');
logger.warn('Warn message'); logger.warn('Warn message');
logger.error('Error message'); logger.error('Error message');
const logs = testLoggerInstance.getCapturedLogs(); const logs = testLoggerInstance.getCapturedLogs();
expect(logs.length).toBe(4); expect(logs.length).toBe(4);
logs.forEach(log => { logs.forEach(log => {
expect(log.service).toBe('utils-test'); expect(log.service).toBe('utils-test');
}); });
}); });
it('should support different service names', () => { it('should support different service names', () => {
const logger1Instance = loggerTestHelpers.createTestLogger('service-one'); const logger1Instance = loggerTestHelpers.createTestLogger('service-one');
const logger2Instance = loggerTestHelpers.createTestLogger('service-two'); const logger2Instance = loggerTestHelpers.createTestLogger('service-two');
logger1Instance.logger.info('Message from service one'); logger1Instance.logger.info('Message from service one');
logger2Instance.logger.info('Message from service two'); logger2Instance.logger.info('Message from service two');
// Since each logger instance has its own capture, we check them separately // Since each logger instance has its own capture, we check them separately
// or combine them if that's the desired test logic. // or combine them if that's the desired test logic.
// For this test, it seems we want to ensure they are separate. // For this test, it seems we want to ensure they are separate.
const logs1 = logger1Instance.getCapturedLogs(); const logs1 = logger1Instance.getCapturedLogs();
expect(logs1.length).toBe(1); expect(logs1.length).toBe(1);
expect(logs1[0].service).toBe('service-one'); expect(logs1[0].service).toBe('service-one');
const logs2 = logger2Instance.getCapturedLogs(); const logs2 = logger2Instance.getCapturedLogs();
expect(logs2.length).toBe(1); expect(logs2.length).toBe(1);
expect(logs2[0].service).toBe('service-two'); expect(logs2[0].service).toBe('service-two');
}); });
}); });
}); });

Some files were not shown because too many files have changed in this diff Show more