running prettier for cleanup
This commit is contained in:
parent
fe7733aeb5
commit
d85cd58acd
151 changed files with 29158 additions and 27966 deletions
32
.githooks/pre-commit
Executable file
32
.githooks/pre-commit
Executable 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
110
.prettierignore
Normal 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
26
.prettierrc
Normal 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
21
.vscode/settings.json
vendored
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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'];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' }} @
|
||||||
|
{{ 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' }} @
|
||||||
|
{{ 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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
// }
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
// }
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' };
|
||||||
}
|
},
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
6
bun.lock
6
bun.lock
|
|
@ -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=="],
|
||||||
|
|
|
||||||
42
libs/cache/src/connection-manager.ts
vendored
42
libs/cache/src/connection-manager.ts
vendored
|
|
@ -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)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
183
libs/cache/src/index.ts
vendored
183
libs/cache/src/index.ts
vendored
|
|
@ -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;
|
|
||||||
|
|
|
||||||
146
libs/cache/src/key-generator.ts
vendored
146
libs/cache/src/key-generator.ts
vendored
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
libs/cache/src/redis-cache.ts
vendored
109
libs/cache/src/redis-cache.ts
vendored
|
|
@ -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,
|
||||||
|
|
|
||||||
174
libs/cache/src/types.ts
vendored
174
libs/cache/src/types.ts
vendored
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(' ');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue