running prettier for cleanup
This commit is contained in:
parent
24b7ed15e4
commit
8955544593
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.completion": 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,8 +1,11 @@
|
|||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import {
|
||||
ApplicationConfig,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
provideZonelessChangeDetection,
|
||||
} from '@angular/core';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
|
|
@ -11,6 +14,6 @@ export const appConfig: ApplicationConfig = {
|
|||
provideZonelessChangeDetection(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
provideAnimationsAsync()
|
||||
]
|
||||
provideAnimationsAsync(),
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { Routes } from '@angular/router';
|
|||
import { DashboardComponent } from './pages/dashboard/dashboard.component';
|
||||
import { MarketDataComponent } from './pages/market-data/market-data.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 { SettingsComponent } from './pages/settings/settings.component';
|
||||
import { StrategiesComponent } from './pages/strategies/strategies.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
|
||||
|
|
@ -14,5 +14,5 @@ export const routes: Routes = [
|
|||
{ path: 'strategies', component: StrategiesComponent },
|
||||
{ path: 'risk-management', component: RiskManagementComponent },
|
||||
{ path: 'settings', component: SettingsComponent },
|
||||
{ path: '**', redirectTo: '/dashboard' }
|
||||
{ path: '**', redirectTo: '/dashboard' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ describe('App', () => {
|
|||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideZonelessChangeDetection()]
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { Component, signal } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { SidebarComponent } from './components/sidebar/sidebar.component';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { NotificationsComponent } from './components/notifications/notifications';
|
||||
import { SidebarComponent } from './components/sidebar/sidebar.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
|
|
@ -20,10 +20,10 @@ import { NotificationsComponent } from './components/notifications/notifications
|
|||
MatIconModule,
|
||||
MatChipsModule,
|
||||
SidebarComponent,
|
||||
NotificationsComponent
|
||||
NotificationsComponent,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css'
|
||||
styleUrl: './app.css',
|
||||
})
|
||||
export class App {
|
||||
protected title = 'Trading Dashboard';
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { NotificationService, Notification } from '../../services/notification.service';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { Notification, NotificationService } from '../../services/notification.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notifications',
|
||||
|
|
@ -17,10 +17,10 @@ import { NotificationService, Notification } from '../../services/notification.s
|
|||
MatBadgeModule,
|
||||
MatMenuModule,
|
||||
MatListModule,
|
||||
MatDividerModule
|
||||
MatDividerModule,
|
||||
],
|
||||
templateUrl: './notifications.html',
|
||||
styleUrl: './notifications.css'
|
||||
styleUrl: './notifications.css',
|
||||
})
|
||||
export class NotificationsComponent {
|
||||
private notificationService = inject(NotificationService);
|
||||
|
|
@ -51,21 +51,29 @@ export class NotificationsComponent {
|
|||
|
||||
getNotificationIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'error': return 'error';
|
||||
case 'warning': return 'warning';
|
||||
case 'success': return 'check_circle';
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'warning':
|
||||
return 'warning';
|
||||
case 'success':
|
||||
return 'check_circle';
|
||||
case 'info':
|
||||
default: return 'info';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
getNotificationColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'error': return 'text-red-600';
|
||||
case 'warning': return 'text-yellow-600';
|
||||
case 'success': return 'text-green-600';
|
||||
case 'error':
|
||||
return 'text-red-600';
|
||||
case 'warning':
|
||||
return 'text-yellow-600';
|
||||
case 'success':
|
||||
return 'text-green-600';
|
||||
case 'info':
|
||||
default: return 'text-blue-600';
|
||||
default:
|
||||
return 'text-blue-600';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Component, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { Router, NavigationEnd } from '@angular/router';
|
||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
|
||||
export interface NavigationItem {
|
||||
|
|
@ -16,14 +16,9 @@ export interface NavigationItem {
|
|||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatSidenavModule,
|
||||
MatButtonModule,
|
||||
MatIconModule
|
||||
],
|
||||
imports: [CommonModule, MatSidenavModule, MatButtonModule, MatIconModule],
|
||||
templateUrl: './sidebar.component.html',
|
||||
styleUrl: './sidebar.component.css'
|
||||
styleUrl: './sidebar.component.css',
|
||||
})
|
||||
export class SidebarComponent {
|
||||
opened = input<boolean>(true);
|
||||
|
|
@ -35,16 +30,16 @@ export class SidebarComponent {
|
|||
{ label: 'Portfolio', icon: 'account_balance_wallet', route: '/portfolio' },
|
||||
{ label: 'Strategies', icon: 'psychology', route: '/strategies' },
|
||||
{ label: 'Risk Management', icon: 'security', route: '/risk-management' },
|
||||
{ label: 'Settings', icon: 'settings', route: '/settings' }
|
||||
{ label: 'Settings', icon: 'settings', route: '/settings' },
|
||||
];
|
||||
|
||||
constructor(private router: Router) {
|
||||
// Listen to route changes to update active state
|
||||
this.router.events.pipe(
|
||||
filter(event => event instanceof NavigationEnd)
|
||||
).subscribe((event: NavigationEnd) => {
|
||||
this.updateActiveRoute(event.urlAfterRedirects);
|
||||
});
|
||||
this.router.events
|
||||
.pipe(filter(event => event instanceof NavigationEnd))
|
||||
.subscribe((event: NavigationEnd) => {
|
||||
this.updateActiveRoute(event.urlAfterRedirects);
|
||||
});
|
||||
}
|
||||
|
||||
onNavigationClick(route: string) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Component, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
|
||||
export interface MarketDataItem {
|
||||
symbol: string;
|
||||
|
|
@ -22,23 +22,23 @@ export interface MarketDataItem {
|
|||
MatTabsModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTableModule
|
||||
MatTableModule,
|
||||
],
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrl: './dashboard.component.css'
|
||||
styleUrl: './dashboard.component.css',
|
||||
})
|
||||
export class DashboardComponent {
|
||||
// Mock data for the dashboard
|
||||
protected marketData = signal<MarketDataItem[]>([
|
||||
{ symbol: 'AAPL', price: 192.53, change: 2.41, changePercent: 1.27 },
|
||||
{ symbol: 'GOOGL', price: 138.21, change: -1.82, changePercent: -1.30 },
|
||||
{ symbol: 'MSFT', price: 378.85, change: 4.12, changePercent: 1.10 },
|
||||
{ symbol: 'GOOGL', price: 138.21, change: -1.82, changePercent: -1.3 },
|
||||
{ symbol: 'MSFT', price: 378.85, change: 4.12, changePercent: 1.1 },
|
||||
{ symbol: 'TSLA', price: 248.42, change: -3.21, changePercent: -1.28 },
|
||||
]);
|
||||
|
||||
protected portfolioValue = signal(125420.50);
|
||||
protected dayChange = signal(2341.20);
|
||||
protected dayChangePercent = signal(1.90);
|
||||
protected portfolioValue = signal(125420.5);
|
||||
protected dayChange = signal(2341.2);
|
||||
protected dayChangePercent = signal(1.9);
|
||||
|
||||
protected displayedColumns: string[] = ['symbol', 'price', 'change', 'changePercent'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { Component, signal, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
import { WebSocketService } from '../../services/websocket.service';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
|
||||
export interface ExtendedMarketData {
|
||||
symbol: string;
|
||||
|
|
@ -33,10 +33,10 @@ export interface ExtendedMarketData {
|
|||
MatTableModule,
|
||||
MatTabsModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule
|
||||
MatSnackBarModule,
|
||||
],
|
||||
templateUrl: './market-data.component.html',
|
||||
styleUrl: './market-data.component.css'
|
||||
styleUrl: './market-data.component.css',
|
||||
})
|
||||
export class MarketDataComponent implements OnInit, OnDestroy {
|
||||
private apiService = inject(ApiService);
|
||||
|
|
@ -48,7 +48,14 @@ export class MarketDataComponent implements OnInit, OnDestroy {
|
|||
protected currentTime = signal<string>(new Date().toLocaleTimeString());
|
||||
protected isLoading = signal<boolean>(true);
|
||||
protected error = signal<string | null>(null);
|
||||
protected displayedColumns: string[] = ['symbol', 'price', 'change', 'changePercent', 'volume', 'marketCap'];
|
||||
protected displayedColumns: string[] = [
|
||||
'symbol',
|
||||
'price',
|
||||
'change',
|
||||
'changePercent',
|
||||
'volume',
|
||||
'marketCap',
|
||||
];
|
||||
ngOnInit() {
|
||||
// Update time every second
|
||||
const timeSubscription = interval(1000).subscribe(() => {
|
||||
|
|
@ -61,12 +68,12 @@ export class MarketDataComponent implements OnInit, OnDestroy {
|
|||
|
||||
// Subscribe to real-time market data updates
|
||||
const wsSubscription = this.webSocketService.getMarketDataUpdates().subscribe({
|
||||
next: (update) => {
|
||||
next: update => {
|
||||
this.updateMarketData(update);
|
||||
},
|
||||
error: (err) => {
|
||||
error: err => {
|
||||
console.error('WebSocket market data error:', err);
|
||||
}
|
||||
},
|
||||
});
|
||||
this.subscriptions.push(wsSubscription);
|
||||
|
||||
|
|
@ -84,20 +91,20 @@ export class MarketDataComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
private loadMarketData() {
|
||||
this.apiService.getMarketData().subscribe({
|
||||
next: (response) => {
|
||||
next: response => {
|
||||
// Convert MarketData to ExtendedMarketData with mock extended properties
|
||||
const extendedData: ExtendedMarketData[] = response.data.map(item => ({
|
||||
...item,
|
||||
marketCap: this.getMockMarketCap(item.symbol),
|
||||
high52Week: item.price * 1.3, // Mock 52-week high (30% above current)
|
||||
low52Week: item.price * 0.7 // Mock 52-week low (30% below current)
|
||||
low52Week: item.price * 0.7, // Mock 52-week low (30% below current)
|
||||
}));
|
||||
|
||||
this.marketData.set(extendedData);
|
||||
this.isLoading.set(false);
|
||||
this.error.set(null);
|
||||
},
|
||||
error: (err) => {
|
||||
error: err => {
|
||||
console.error('Failed to load market data:', err);
|
||||
this.error.set('Failed to load market data');
|
||||
this.isLoading.set(false);
|
||||
|
|
@ -105,17 +112,17 @@ export class MarketDataComponent implements OnInit, OnDestroy {
|
|||
|
||||
// Use mock data as fallback
|
||||
this.marketData.set(this.getMockData());
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private getMockMarketCap(symbol: string): string {
|
||||
const marketCaps: { [key: string]: string } = {
|
||||
'AAPL': '2.98T',
|
||||
'GOOGL': '1.78T',
|
||||
'MSFT': '3.08T',
|
||||
'TSLA': '789.2B',
|
||||
'AMZN': '1.59T'
|
||||
AAPL: '2.98T',
|
||||
GOOGL: '1.78T',
|
||||
MSFT: '3.08T',
|
||||
TSLA: '789.2B',
|
||||
AMZN: '1.59T',
|
||||
};
|
||||
return marketCaps[symbol] || '1.00T';
|
||||
}
|
||||
|
|
@ -130,7 +137,7 @@ export class MarketDataComponent implements OnInit, OnDestroy {
|
|||
volume: 45230000,
|
||||
marketCap: '2.98T',
|
||||
high52Week: 199.62,
|
||||
low52Week: 164.08
|
||||
low52Week: 164.08,
|
||||
},
|
||||
{
|
||||
symbol: 'GOOGL',
|
||||
|
|
@ -140,7 +147,7 @@ export class MarketDataComponent implements OnInit, OnDestroy {
|
|||
volume: 12450000,
|
||||
marketCap: '1.78T',
|
||||
high52Week: 3030.93,
|
||||
low52Week: 2193.62
|
||||
low52Week: 2193.62,
|
||||
},
|
||||
{
|
||||
symbol: 'MSFT',
|
||||
|
|
@ -150,17 +157,17 @@ export class MarketDataComponent implements OnInit, OnDestroy {
|
|||
volume: 23180000,
|
||||
marketCap: '3.08T',
|
||||
high52Week: 468.35,
|
||||
low52Week: 309.45
|
||||
low52Week: 309.45,
|
||||
},
|
||||
{
|
||||
symbol: 'TSLA',
|
||||
price: 248.50,
|
||||
price: 248.5,
|
||||
change: -5.21,
|
||||
changePercent: -2.05,
|
||||
volume: 89760000,
|
||||
marketCap: '789.2B',
|
||||
high52Week: 299.29,
|
||||
low52Week: 152.37
|
||||
low52Week: 152.37,
|
||||
},
|
||||
{
|
||||
symbol: 'AMZN',
|
||||
|
|
@ -170,8 +177,8 @@ export class MarketDataComponent implements OnInit, OnDestroy {
|
|||
volume: 34520000,
|
||||
marketCap: '1.59T',
|
||||
high52Week: 170.17,
|
||||
low52Week: 118.35
|
||||
}
|
||||
low52Week: 118.35,
|
||||
},
|
||||
];
|
||||
}
|
||||
refreshData() {
|
||||
|
|
@ -188,7 +195,7 @@ export class MarketDataComponent implements OnInit, OnDestroy {
|
|||
price: update.price,
|
||||
change: update.change,
|
||||
changePercent: update.changePercent,
|
||||
volume: update.volume
|
||||
volume: update.volume,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { Component, signal, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
|
||||
export interface Position {
|
||||
symbol: string;
|
||||
|
|
@ -44,10 +44,10 @@ export interface PortfolioSummary {
|
|||
MatTableModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule,
|
||||
MatTabsModule
|
||||
MatTabsModule,
|
||||
],
|
||||
templateUrl: './portfolio.component.html',
|
||||
styleUrl: './portfolio.component.css'
|
||||
styleUrl: './portfolio.component.css',
|
||||
})
|
||||
export class PortfolioComponent implements OnInit, OnDestroy {
|
||||
private apiService = inject(ApiService);
|
||||
|
|
@ -62,13 +62,21 @@ export class PortfolioComponent implements OnInit, OnDestroy {
|
|||
dayChange: 0,
|
||||
dayChangePercent: 0,
|
||||
cash: 0,
|
||||
positionsCount: 0
|
||||
positionsCount: 0,
|
||||
});
|
||||
|
||||
protected positions = signal<Position[]>([]);
|
||||
protected isLoading = signal<boolean>(true);
|
||||
protected error = signal<string | null>(null);
|
||||
protected displayedColumns = ['symbol', 'quantity', 'avgPrice', 'currentPrice', 'marketValue', 'unrealizedPnL', 'dayChange'];
|
||||
protected displayedColumns = [
|
||||
'symbol',
|
||||
'quantity',
|
||||
'avgPrice',
|
||||
'currentPrice',
|
||||
'marketValue',
|
||||
'unrealizedPnL',
|
||||
'dayChange',
|
||||
];
|
||||
|
||||
ngOnInit() {
|
||||
this.loadPortfolioData();
|
||||
|
|
@ -93,51 +101,52 @@ export class PortfolioComponent implements OnInit, OnDestroy {
|
|||
{
|
||||
symbol: 'AAPL',
|
||||
quantity: 100,
|
||||
avgPrice: 180.50,
|
||||
avgPrice: 180.5,
|
||||
currentPrice: 192.53,
|
||||
marketValue: 19253,
|
||||
unrealizedPnL: 1203,
|
||||
unrealizedPnLPercent: 6.67,
|
||||
dayChange: 241,
|
||||
dayChangePercent: 1.27
|
||||
dayChangePercent: 1.27,
|
||||
},
|
||||
{
|
||||
symbol: 'MSFT',
|
||||
quantity: 50,
|
||||
avgPrice: 400.00,
|
||||
avgPrice: 400.0,
|
||||
currentPrice: 415.26,
|
||||
marketValue: 20763,
|
||||
unrealizedPnL: 763,
|
||||
unrealizedPnLPercent: 3.82,
|
||||
dayChange: 436.50,
|
||||
dayChangePercent: 2.15
|
||||
dayChange: 436.5,
|
||||
dayChangePercent: 2.15,
|
||||
},
|
||||
{
|
||||
symbol: 'GOOGL',
|
||||
quantity: 10,
|
||||
avgPrice: 2900.00,
|
||||
avgPrice: 2900.0,
|
||||
currentPrice: 2847.56,
|
||||
marketValue: 28475.60,
|
||||
unrealizedPnL: -524.40,
|
||||
marketValue: 28475.6,
|
||||
unrealizedPnL: -524.4,
|
||||
unrealizedPnLPercent: -1.81,
|
||||
dayChange: -123.40,
|
||||
dayChangePercent: -0.43
|
||||
}
|
||||
dayChange: -123.4,
|
||||
dayChangePercent: -0.43,
|
||||
},
|
||||
];
|
||||
|
||||
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),
|
||||
totalCost: mockPositions.reduce((sum, pos) => sum + pos.avgPrice * pos.quantity, 0),
|
||||
totalPnL: mockPositions.reduce((sum, pos) => sum + pos.unrealizedPnL, 0),
|
||||
totalPnLPercent: 0,
|
||||
dayChange: mockPositions.reduce((sum, pos) => sum + pos.dayChange, 0),
|
||||
dayChangePercent: 0,
|
||||
cash: 25000,
|
||||
positionsCount: mockPositions.length
|
||||
positionsCount: mockPositions.length,
|
||||
};
|
||||
|
||||
summary.totalPnLPercent = (summary.totalPnL / summary.totalCost) * 100;
|
||||
summary.dayChangePercent = (summary.dayChange / (summary.totalValue - summary.dayChange)) * 100;
|
||||
summary.dayChangePercent =
|
||||
(summary.dayChange / (summary.totalValue - summary.dayChange)) * 100;
|
||||
|
||||
this.positions.set(mockPositions);
|
||||
this.portfolioSummary.set(summary);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { Component, signal, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ApiService, RiskThresholds, RiskEvaluation } from '../../services/api.service';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
import { ApiService, RiskEvaluation, RiskThresholds } from '../../services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-risk-management',
|
||||
|
|
@ -25,10 +25,10 @@ import { interval, Subscription } from 'rxjs';
|
|||
MatInputModule,
|
||||
MatSnackBarModule,
|
||||
MatProgressSpinnerModule,
|
||||
ReactiveFormsModule
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
templateUrl: './risk-management.component.html',
|
||||
styleUrl: './risk-management.component.css'
|
||||
styleUrl: './risk-management.component.css',
|
||||
})
|
||||
export class RiskManagementComponent implements OnInit, OnDestroy {
|
||||
private apiService = inject(ApiService);
|
||||
|
|
@ -50,7 +50,7 @@ export class RiskManagementComponent implements OnInit, OnDestroy {
|
|||
maxPositionSize: [0, [Validators.required, Validators.min(0)]],
|
||||
maxDailyLoss: [0, [Validators.required, Validators.min(0)]],
|
||||
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)]],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -71,30 +71,30 @@ export class RiskManagementComponent implements OnInit, OnDestroy {
|
|||
|
||||
private loadRiskThresholds() {
|
||||
this.apiService.getRiskThresholds().subscribe({
|
||||
next: (response) => {
|
||||
next: response => {
|
||||
this.riskThresholds.set(response.data);
|
||||
this.thresholdsForm.patchValue(response.data);
|
||||
this.isLoading.set(false);
|
||||
this.error.set(null);
|
||||
},
|
||||
error: (err) => {
|
||||
error: err => {
|
||||
console.error('Failed to load risk thresholds:', err);
|
||||
this.error.set('Failed to load risk thresholds');
|
||||
this.isLoading.set(false);
|
||||
this.snackBar.open('Failed to load risk thresholds', 'Dismiss', { duration: 5000 });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private loadRiskHistory() {
|
||||
this.apiService.getRiskHistory().subscribe({
|
||||
next: (response) => {
|
||||
next: response => {
|
||||
this.riskHistory.set(response.data);
|
||||
},
|
||||
error: (err) => {
|
||||
error: err => {
|
||||
console.error('Failed to load risk history:', err);
|
||||
this.snackBar.open('Failed to load risk history', 'Dismiss', { duration: 3000 });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -104,16 +104,16 @@ export class RiskManagementComponent implements OnInit, OnDestroy {
|
|||
const thresholds = this.thresholdsForm.value as RiskThresholds;
|
||||
|
||||
this.apiService.updateRiskThresholds(thresholds).subscribe({
|
||||
next: (response) => {
|
||||
next: response => {
|
||||
this.riskThresholds.set(response.data);
|
||||
this.isSaving.set(false);
|
||||
this.snackBar.open('Risk thresholds updated successfully', 'Dismiss', { duration: 3000 });
|
||||
},
|
||||
error: (err) => {
|
||||
error: err => {
|
||||
console.error('Failed to save risk thresholds:', err);
|
||||
this.isSaving.set(false);
|
||||
this.snackBar.open('Failed to save risk thresholds', 'Dismiss', { duration: 5000 });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -126,10 +126,14 @@ export class RiskManagementComponent implements OnInit, OnDestroy {
|
|||
|
||||
getRiskLevelColor(level: string): string {
|
||||
switch (level) {
|
||||
case 'LOW': return 'text-green-600';
|
||||
case 'MEDIUM': return 'text-yellow-600';
|
||||
case 'HIGH': return 'text-red-600';
|
||||
default: return 'text-gray-600';
|
||||
case 'LOW':
|
||||
return 'text-green-600';
|
||||
case 'MEDIUM':
|
||||
return 'text-yellow-600';
|
||||
case 'HIGH':
|
||||
return 'text-red-600';
|
||||
default:
|
||||
return 'text-gray-600';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
|
|
@ -8,6 +8,6 @@ import { MatIconModule } from '@angular/material/icon';
|
|||
standalone: true,
|
||||
imports: [CommonModule, MatCardModule, MatIconModule],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.css'
|
||||
styleUrl: './settings.component.css',
|
||||
})
|
||||
export class SettingsComponent {}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { BacktestResult } from '../../../services/strategy.service';
|
||||
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { Chart, ChartOptions } from 'chart.js/auto';
|
||||
import { BacktestResult } from '../../../services/strategy.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-drawdown-chart',
|
||||
|
|
@ -18,7 +18,7 @@ import { Chart, ChartOptions } from 'chart.js/auto';
|
|||
height: 300px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
`
|
||||
`,
|
||||
})
|
||||
export class DrawdownChartComponent implements OnChanges {
|
||||
@Input() backtestResult?: BacktestResult;
|
||||
|
|
@ -63,9 +63,9 @@ export class DrawdownChartComponent implements OnChanges {
|
|||
backgroundColor: 'rgba(255, 99, 132, 0.2)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
borderWidth: 2
|
||||
}
|
||||
]
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
|
|
@ -75,31 +75,31 @@ export class DrawdownChartComponent implements OnChanges {
|
|||
ticks: {
|
||||
maxTicksLimit: 12,
|
||||
maxRotation: 0,
|
||||
minRotation: 0
|
||||
minRotation: 0,
|
||||
},
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
callback: function (value) {
|
||||
return (value * 100).toFixed(1) + '%';
|
||||
}
|
||||
},
|
||||
},
|
||||
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
|
||||
suggestedMax: 0.01
|
||||
}
|
||||
suggestedMax: 0.01,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
label: function (context) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
|
|
@ -108,14 +108,14 @@ export class DrawdownChartComponent implements OnChanges {
|
|||
label += (context.parsed.y * 100).toFixed(2) + '%';
|
||||
}
|
||||
return label;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
}
|
||||
}
|
||||
} as ChartOptions
|
||||
},
|
||||
},
|
||||
} as ChartOptions,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +136,7 @@ export class DrawdownChartComponent implements OnChanges {
|
|||
const equityCurve: number[] = [];
|
||||
|
||||
for (const daily of sortedReturns) {
|
||||
equity *= (1 + daily.return);
|
||||
equity *= 1 + daily.return;
|
||||
equityCurve.push(equity);
|
||||
dates.push(new Date(daily.date));
|
||||
}
|
||||
|
|
@ -148,7 +148,7 @@ export class DrawdownChartComponent implements OnChanges {
|
|||
// Update high water mark
|
||||
hwm = Math.max(hwm, equityCurve[i]);
|
||||
// Calculate drawdown as percentage from high water mark
|
||||
const drawdown = (equityCurve[i] / hwm) - 1;
|
||||
const drawdown = equityCurve[i] / hwm - 1;
|
||||
drawdowns.push(drawdown);
|
||||
}
|
||||
|
||||
|
|
@ -159,7 +159,7 @@ export class DrawdownChartComponent implements OnChanges {
|
|||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { BacktestResult } from '../../../services/strategy.service';
|
||||
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { Chart, ChartOptions } from 'chart.js/auto';
|
||||
import { BacktestResult } from '../../../services/strategy.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-equity-chart',
|
||||
|
|
@ -18,7 +18,7 @@ import { Chart, ChartOptions } from 'chart.js/auto';
|
|||
height: 400px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
`
|
||||
`,
|
||||
})
|
||||
export class EquityChartComponent implements OnChanges {
|
||||
@Input() backtestResult?: BacktestResult;
|
||||
|
|
@ -63,7 +63,7 @@ export class EquityChartComponent implements OnChanges {
|
|||
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
fill: true
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
label: 'Benchmark',
|
||||
|
|
@ -73,9 +73,9 @@ export class EquityChartComponent implements OnChanges {
|
|||
borderDash: [5, 5],
|
||||
tension: 0.3,
|
||||
borderWidth: 1,
|
||||
fill: false
|
||||
}
|
||||
]
|
||||
fill: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
|
|
@ -85,46 +85,48 @@ export class EquityChartComponent implements OnChanges {
|
|||
ticks: {
|
||||
maxTicksLimit: 12,
|
||||
maxRotation: 0,
|
||||
minRotation: 0
|
||||
minRotation: 0,
|
||||
},
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
callback: function (value) {
|
||||
return '$' + value.toLocaleString();
|
||||
}
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(200, 200, 200, 0.2)'
|
||||
}
|
||||
}
|
||||
color: 'rgba(200, 200, 200, 0.2)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
label: function (context) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
|
||||
.format(context.parsed.y);
|
||||
label += new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(context.parsed.y);
|
||||
}
|
||||
return label;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
}
|
||||
}
|
||||
} as ChartOptions
|
||||
},
|
||||
},
|
||||
} as ChartOptions,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -165,7 +167,7 @@ export class EquityChartComponent implements OnChanges {
|
|||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,15 @@
|
|||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatGridListModule } from '@angular/material/grid-list';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatGridListModule } from '@angular/material/grid-list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { BacktestResult } from '../../../services/strategy.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-performance-metrics',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatGridListModule,
|
||||
MatDividerModule,
|
||||
MatTooltipModule
|
||||
],
|
||||
imports: [CommonModule, MatCardModule, MatGridListModule, MatDividerModule, MatTooltipModule],
|
||||
template: `
|
||||
<mat-card class="metrics-card">
|
||||
<mat-card-header>
|
||||
|
|
@ -27,21 +21,34 @@ import { BacktestResult } from '../../../services/strategy.service';
|
|||
<h3>Returns</h3>
|
||||
<div class="metrics-row">
|
||||
<div class="metric">
|
||||
<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)}}
|
||||
<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) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Annualized return (adjusted for the backtest duration)">Annualized Return</div>
|
||||
<div class="metric-value" [ngClass]="getReturnClass(backtestResult?.annualizedReturn || 0)">
|
||||
{{formatPercent(backtestResult?.annualizedReturn || 0)}}
|
||||
<div
|
||||
class="metric-name"
|
||||
matTooltip="Annualized return (adjusted for the backtest duration)"
|
||||
>
|
||||
Annualized Return
|
||||
</div>
|
||||
<div
|
||||
class="metric-value"
|
||||
[ngClass]="getReturnClass(backtestResult?.annualizedReturn || 0)"
|
||||
>
|
||||
{{ formatPercent(backtestResult?.annualizedReturn || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Compound Annual Growth Rate">CAGR</div>
|
||||
<div class="metric-value" [ngClass]="getReturnClass(backtestResult?.cagr || 0)">
|
||||
{{formatPercent(backtestResult?.cagr || 0)}}
|
||||
{{ formatPercent(backtestResult?.cagr || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -53,27 +60,38 @@ import { BacktestResult } from '../../../services/strategy.service';
|
|||
<h3>Risk Metrics</h3>
|
||||
<div class="metrics-row">
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Maximum peak-to-valley drawdown">Max Drawdown</div>
|
||||
<div class="metric-name" matTooltip="Maximum peak-to-valley drawdown">
|
||||
Max Drawdown
|
||||
</div>
|
||||
<div class="metric-value negative">
|
||||
{{formatPercent(backtestResult?.maxDrawdown || 0)}}
|
||||
{{ formatPercent(backtestResult?.maxDrawdown || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Number of days in the worst drawdown">Max DD Duration</div>
|
||||
<div class="metric-name" matTooltip="Number of days in the worst drawdown">
|
||||
Max DD Duration
|
||||
</div>
|
||||
<div class="metric-value">
|
||||
{{formatDays(backtestResult?.maxDrawdownDuration || 0)}}
|
||||
{{ formatDays(backtestResult?.maxDrawdownDuration || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Annualized standard deviation of returns">Volatility</div>
|
||||
<div class="metric-name" matTooltip="Annualized standard deviation of returns">
|
||||
Volatility
|
||||
</div>
|
||||
<div class="metric-value">
|
||||
{{formatPercent(backtestResult?.volatility || 0)}}
|
||||
{{ formatPercent(backtestResult?.volatility || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Square root of the sum of the squares of drawdowns">Ulcer Index</div>
|
||||
<div
|
||||
class="metric-name"
|
||||
matTooltip="Square root of the sum of the squares of drawdowns"
|
||||
>
|
||||
Ulcer Index
|
||||
</div>
|
||||
<div class="metric-value">
|
||||
{{(backtestResult?.ulcerIndex || 0).toFixed(4)}}
|
||||
{{ (backtestResult?.ulcerIndex || 0).toFixed(4) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -85,27 +103,50 @@ import { BacktestResult } from '../../../services/strategy.service';
|
|||
<h3>Risk-Adjusted Returns</h3>
|
||||
<div class="metrics-row">
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Excess return per unit of risk">Sharpe Ratio</div>
|
||||
<div class="metric-value" [ngClass]="getRatioClass(backtestResult?.sharpeRatio || 0)">
|
||||
{{(backtestResult?.sharpeRatio || 0).toFixed(2)}}
|
||||
<div class="metric-name" matTooltip="Excess return per unit of risk">
|
||||
Sharpe Ratio
|
||||
</div>
|
||||
<div
|
||||
class="metric-value"
|
||||
[ngClass]="getRatioClass(backtestResult?.sharpeRatio || 0)"
|
||||
>
|
||||
{{ (backtestResult?.sharpeRatio || 0).toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Return per unit of downside risk">Sortino Ratio</div>
|
||||
<div class="metric-value" [ngClass]="getRatioClass(backtestResult?.sortinoRatio || 0)">
|
||||
{{(backtestResult?.sortinoRatio || 0).toFixed(2)}}
|
||||
<div class="metric-name" matTooltip="Return per unit of downside risk">
|
||||
Sortino Ratio
|
||||
</div>
|
||||
<div
|
||||
class="metric-value"
|
||||
[ngClass]="getRatioClass(backtestResult?.sortinoRatio || 0)"
|
||||
>
|
||||
{{ (backtestResult?.sortinoRatio || 0).toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Return per unit of max drawdown">Calmar Ratio</div>
|
||||
<div class="metric-value" [ngClass]="getRatioClass(backtestResult?.calmarRatio || 0)">
|
||||
{{(backtestResult?.calmarRatio || 0).toFixed(2)}}
|
||||
<div class="metric-name" matTooltip="Return per unit of max drawdown">
|
||||
Calmar Ratio
|
||||
</div>
|
||||
<div
|
||||
class="metric-value"
|
||||
[ngClass]="getRatioClass(backtestResult?.calmarRatio || 0)"
|
||||
>
|
||||
{{ (backtestResult?.calmarRatio || 0).toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Probability-weighted ratio of gains vs. losses">Omega Ratio</div>
|
||||
<div class="metric-value" [ngClass]="getRatioClass(backtestResult?.omegaRatio || 0)">
|
||||
{{(backtestResult?.omegaRatio || 0).toFixed(2)}}
|
||||
<div
|
||||
class="metric-name"
|
||||
matTooltip="Probability-weighted ratio of gains vs. losses"
|
||||
>
|
||||
Omega Ratio
|
||||
</div>
|
||||
<div
|
||||
class="metric-value"
|
||||
[ngClass]="getRatioClass(backtestResult?.omegaRatio || 0)"
|
||||
>
|
||||
{{ (backtestResult?.omegaRatio || 0).toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -119,31 +160,36 @@ import { BacktestResult } from '../../../services/strategy.service';
|
|||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Total number of trades">Total Trades</div>
|
||||
<div class="metric-value">
|
||||
{{backtestResult?.totalTrades || 0}}
|
||||
{{ backtestResult?.totalTrades || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<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)}}
|
||||
{{ formatPercent(backtestResult?.winRate || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Average profit of winning trades">Avg Win</div>
|
||||
<div class="metric-value positive">
|
||||
{{formatPercent(backtestResult?.averageWinningTrade || 0)}}
|
||||
{{ formatPercent(backtestResult?.averageWinningTrade || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Average loss of losing trades">Avg Loss</div>
|
||||
<div class="metric-value negative">
|
||||
{{formatPercent(backtestResult?.averageLosingTrade || 0)}}
|
||||
{{ formatPercent(backtestResult?.averageLosingTrade || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Ratio of total gains to total losses">Profit Factor</div>
|
||||
<div class="metric-value" [ngClass]="getProfitFactorClass(backtestResult?.profitFactor || 0)">
|
||||
{{(backtestResult?.profitFactor || 0).toFixed(2)}}
|
||||
<div class="metric-name" matTooltip="Ratio of total gains to total losses">
|
||||
Profit Factor
|
||||
</div>
|
||||
<div
|
||||
class="metric-value"
|
||||
[ngClass]="getProfitFactorClass(backtestResult?.profitFactor || 0)"
|
||||
>
|
||||
{{ (backtestResult?.profitFactor || 0).toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -198,21 +244,21 @@ import { BacktestResult } from '../../../services/strategy.service';
|
|||
}
|
||||
|
||||
.positive {
|
||||
color: #4CAF50;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: #F44336;
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.neutral {
|
||||
color: #FFA000;
|
||||
color: #ffa000;
|
||||
}
|
||||
|
||||
mat-divider {
|
||||
margin: 8px 0;
|
||||
}
|
||||
`
|
||||
`,
|
||||
})
|
||||
export class PerformanceMetricsComponent {
|
||||
@Input() backtestResult?: BacktestResult;
|
||||
|
|
@ -222,7 +268,7 @@ export class PerformanceMetricsComponent {
|
|||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatSortModule, Sort } from '@angular/material/sort';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSortModule, Sort } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { BacktestResult } from '../../../services/strategy.service';
|
||||
|
||||
@Component({
|
||||
|
|
@ -16,7 +16,7 @@ import { BacktestResult } from '../../../services/strategy.service';
|
|||
MatSortModule,
|
||||
MatPaginatorModule,
|
||||
MatCardModule,
|
||||
MatIconModule
|
||||
MatIconModule,
|
||||
],
|
||||
template: `
|
||||
<mat-card class="trades-card">
|
||||
|
|
@ -24,64 +24,75 @@ import { BacktestResult } from '../../../services/strategy.service';
|
|||
<mat-card-title>Trades</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<table mat-table [dataSource]="displayedTrades" matSort (matSortChange)="sortData($event)" class="trades-table">
|
||||
|
||||
<table
|
||||
mat-table
|
||||
[dataSource]="displayedTrades"
|
||||
matSort
|
||||
(matSortChange)="sortData($event)"
|
||||
class="trades-table"
|
||||
>
|
||||
<!-- Symbol Column -->
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header> Symbol </th>
|
||||
<td mat-cell *matCellDef="let trade"> {{trade.symbol}} </td>
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Symbol</th>
|
||||
<td mat-cell *matCellDef="let trade">{{ trade.symbol }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Entry Date Column -->
|
||||
<ng-container matColumnDef="entryTime">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header> Entry Time </th>
|
||||
<td mat-cell *matCellDef="let trade"> {{formatDate(trade.entryTime)}} </td>
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Entry Time</th>
|
||||
<td mat-cell *matCellDef="let trade">{{ formatDate(trade.entryTime) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Entry Price Column -->
|
||||
<ng-container matColumnDef="entryPrice">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header> Entry Price </th>
|
||||
<td mat-cell *matCellDef="let trade"> {{formatCurrency(trade.entryPrice)}} </td>
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Entry Price</th>
|
||||
<td mat-cell *matCellDef="let trade">{{ formatCurrency(trade.entryPrice) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Exit Date Column -->
|
||||
<ng-container matColumnDef="exitTime">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header> Exit Time </th>
|
||||
<td mat-cell *matCellDef="let trade"> {{formatDate(trade.exitTime)}} </td>
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Exit Time</th>
|
||||
<td mat-cell *matCellDef="let trade">{{ formatDate(trade.exitTime) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Exit Price Column -->
|
||||
<ng-container matColumnDef="exitPrice">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header> Exit Price </th>
|
||||
<td mat-cell *matCellDef="let trade"> {{formatCurrency(trade.exitPrice)}} </td>
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Exit Price</th>
|
||||
<td mat-cell *matCellDef="let trade">{{ formatCurrency(trade.exitPrice) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Quantity Column -->
|
||||
<ng-container matColumnDef="quantity">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header> Quantity </th>
|
||||
<td mat-cell *matCellDef="let trade"> {{trade.quantity}} </td>
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Quantity</th>
|
||||
<td mat-cell *matCellDef="let trade">{{ trade.quantity }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- P&L Column -->
|
||||
<ng-container matColumnDef="pnl">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header> P&L </th>
|
||||
<td mat-cell *matCellDef="let trade"
|
||||
[ngClass]="{'positive': trade.pnl > 0, 'negative': trade.pnl < 0}">
|
||||
{{formatCurrency(trade.pnl)}}
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>P&L</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let trade"
|
||||
[ngClass]="{ positive: trade.pnl > 0, negative: trade.pnl < 0 }"
|
||||
>
|
||||
{{ formatCurrency(trade.pnl) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- P&L Percent Column -->
|
||||
<ng-container matColumnDef="pnlPercent">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header> P&L % </th>
|
||||
<td mat-cell *matCellDef="let trade"
|
||||
[ngClass]="{'positive': trade.pnlPercent > 0, 'negative': trade.pnlPercent < 0}">
|
||||
{{formatPercent(trade.pnlPercent)}}
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>P&L %</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let trade"
|
||||
[ngClass]="{ positive: trade.pnlPercent > 0, negative: trade.pnlPercent < 0 }"
|
||||
>
|
||||
{{ formatPercent(trade.pnlPercent) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator
|
||||
|
|
@ -89,7 +100,8 @@ import { BacktestResult } from '../../../services/strategy.service';
|
|||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="[5, 10, 25, 50]"
|
||||
(page)="pageChange($event)"
|
||||
aria-label="Select page">
|
||||
aria-label="Select page"
|
||||
>
|
||||
</mat-paginator>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
|
@ -104,23 +116,24 @@ import { BacktestResult } from '../../../services/strategy.service';
|
|||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.mat-column-pnl, .mat-column-pnlPercent {
|
||||
.mat-column-pnl,
|
||||
.mat-column-pnlPercent {
|
||||
text-align: right;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #4CAF50;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: #F44336;
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.mat-mdc-row:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
`
|
||||
`,
|
||||
})
|
||||
export class TradesTableComponent {
|
||||
@Input() set backtestResult(value: BacktestResult | undefined) {
|
||||
|
|
@ -138,8 +151,14 @@ export class TradesTableComponent {
|
|||
|
||||
// Table configuration
|
||||
displayedColumns: string[] = [
|
||||
'symbol', 'entryTime', 'entryPrice', 'exitTime',
|
||||
'exitPrice', 'quantity', 'pnl', 'pnlPercent'
|
||||
'symbol',
|
||||
'entryTime',
|
||||
'entryPrice',
|
||||
'exitTime',
|
||||
'exitPrice',
|
||||
'quantity',
|
||||
'pnl',
|
||||
'pnlPercent',
|
||||
];
|
||||
|
||||
// Pagination
|
||||
|
|
@ -160,20 +179,39 @@ export class TradesTableComponent {
|
|||
|
||||
const data = this._backtestResult?.trades.slice() || [];
|
||||
|
||||
this.displayedTrades = data.sort((a, b) => {
|
||||
const isAsc = sort.direction === 'asc';
|
||||
switch (sort.active) {
|
||||
case 'symbol': return this.compare(a.symbol, b.symbol, isAsc);
|
||||
case 'entryTime': return this.compare(new Date(a.entryTime).getTime(), new Date(b.entryTime).getTime(), isAsc);
|
||||
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);
|
||||
case 'exitPrice': return this.compare(a.exitPrice, b.exitPrice, isAsc);
|
||||
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);
|
||||
default: return 0;
|
||||
}
|
||||
}).slice(this.currentPage * this.pageSize, (this.currentPage + 1) * this.pageSize);
|
||||
this.displayedTrades = data
|
||||
.sort((a, b) => {
|
||||
const isAsc = sort.direction === 'asc';
|
||||
switch (sort.active) {
|
||||
case 'symbol':
|
||||
return this.compare(a.symbol, b.symbol, isAsc);
|
||||
case 'entryTime':
|
||||
return this.compare(
|
||||
new Date(a.entryTime).getTime(),
|
||||
new Date(b.entryTime).getTime(),
|
||||
isAsc
|
||||
);
|
||||
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
|
||||
);
|
||||
case 'exitPrice':
|
||||
return this.compare(a.exitPrice, b.exitPrice, isAsc);
|
||||
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);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
.slice(this.currentPage * this.pageSize, (this.currentPage + 1) * this.pageSize);
|
||||
}
|
||||
|
||||
// Handle page changes
|
||||
|
|
@ -211,7 +249,7 @@ export class TradesTableComponent {
|
|||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,23 @@
|
|||
import { Component, Inject, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { Component, Inject, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import {
|
||||
BacktestRequest,
|
||||
BacktestResult,
|
||||
StrategyService,
|
||||
TradingStrategy
|
||||
TradingStrategy,
|
||||
} from '../../../services/strategy.service';
|
||||
|
||||
@Component({
|
||||
|
|
@ -42,15 +37,25 @@ import {
|
|||
MatTabsModule,
|
||||
MatChipsModule,
|
||||
MatIconModule,
|
||||
MatSlideToggleModule
|
||||
MatSlideToggleModule,
|
||||
],
|
||||
templateUrl: './backtest-dialog.component.html',
|
||||
styleUrl: './backtest-dialog.component.css'
|
||||
styleUrl: './backtest-dialog.component.css',
|
||||
})
|
||||
export class BacktestDialogComponent implements OnInit {
|
||||
backtestForm: FormGroup;
|
||||
strategyTypes: string[] = [];
|
||||
availableSymbols: string[] = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA', 'META', 'NVDA', 'SPY', 'QQQ'];
|
||||
availableSymbols: string[] = [
|
||||
'AAPL',
|
||||
'MSFT',
|
||||
'GOOGL',
|
||||
'AMZN',
|
||||
'TSLA',
|
||||
'META',
|
||||
'NVDA',
|
||||
'SPY',
|
||||
'QQQ',
|
||||
];
|
||||
selectedSymbols: string[] = [];
|
||||
parameters: Record<string, any> = {};
|
||||
isRunning: boolean = false;
|
||||
|
|
@ -65,22 +70,25 @@ export class BacktestDialogComponent implements OnInit {
|
|||
// Initialize form with defaults
|
||||
this.backtestForm = this.fb.group({
|
||||
strategyType: ['', [Validators.required]],
|
||||
startDate: [new Date(new Date().setFullYear(new Date().getFullYear() - 1)), [Validators.required]],
|
||||
startDate: [
|
||||
new Date(new Date().setFullYear(new Date().getFullYear() - 1)),
|
||||
[Validators.required],
|
||||
],
|
||||
endDate: [new Date(), [Validators.required]],
|
||||
initialCapital: [100000, [Validators.required, Validators.min(1000)]],
|
||||
dataResolution: ['1d', [Validators.required]],
|
||||
commission: [0.001, [Validators.required, Validators.min(0), Validators.max(0.1)]],
|
||||
slippage: [0.0005, [Validators.required, Validators.min(0), Validators.max(0.1)]],
|
||||
mode: ['event', [Validators.required]]
|
||||
mode: ['event', [Validators.required]],
|
||||
});
|
||||
|
||||
// If strategy is provided, pre-populate the form
|
||||
if (data) {
|
||||
this.selectedSymbols = [...data.symbols];
|
||||
this.backtestForm.patchValue({
|
||||
strategyType: data.type
|
||||
strategyType: data.type,
|
||||
});
|
||||
this.parameters = {...data.parameters};
|
||||
this.parameters = { ...data.parameters };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +98,7 @@ export class BacktestDialogComponent implements OnInit {
|
|||
|
||||
loadStrategyTypes(): void {
|
||||
this.strategyService.getStrategyTypes().subscribe({
|
||||
next: (response) => {
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.strategyTypes = response.data;
|
||||
|
||||
|
|
@ -100,33 +108,33 @@ export class BacktestDialogComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
error: error => {
|
||||
console.error('Error loading strategy types:', error);
|
||||
this.strategyTypes = ['MOVING_AVERAGE_CROSSOVER', 'MEAN_REVERSION', 'CUSTOM'];
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onStrategyTypeChange(type: string): void {
|
||||
// Get default parameters for this strategy type
|
||||
this.strategyService.getStrategyParameters(type).subscribe({
|
||||
next: (response) => {
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
// If strategy is provided, merge default with existing
|
||||
if (this.data) {
|
||||
this.parameters = {
|
||||
...response.data,
|
||||
...this.data.parameters
|
||||
...this.data.parameters,
|
||||
};
|
||||
} else {
|
||||
this.parameters = response.data;
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
error: error => {
|
||||
console.error('Error loading parameters:', error);
|
||||
this.parameters = {};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -160,22 +168,22 @@ export class BacktestDialogComponent implements OnInit {
|
|||
dataResolution: formValue.dataResolution,
|
||||
commission: formValue.commission,
|
||||
slippage: formValue.slippage,
|
||||
mode: formValue.mode
|
||||
mode: formValue.mode,
|
||||
};
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
this.strategyService.runBacktest(backtestRequest).subscribe({
|
||||
next: (response) => {
|
||||
next: response => {
|
||||
this.isRunning = false;
|
||||
if (response.success) {
|
||||
this.backtestResult = response.data;
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
error: error => {
|
||||
this.isRunning = false;
|
||||
console.error('Backtest error:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,16 @@
|
|||
import { Component, Inject, OnInit } from '@angular/core';
|
||||
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
Validators
|
||||
} from '@angular/forms';
|
||||
import { Component, Inject, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import {
|
||||
StrategyService,
|
||||
TradingStrategy
|
||||
} from '../../../services/strategy.service';
|
||||
import { StrategyService, TradingStrategy } from '../../../services/strategy.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-strategy-dialog',
|
||||
|
|
@ -33,16 +25,26 @@ import {
|
|||
MatSelectModule,
|
||||
MatChipsModule,
|
||||
MatIconModule,
|
||||
MatAutocompleteModule
|
||||
MatAutocompleteModule,
|
||||
],
|
||||
templateUrl: './strategy-dialog.component.html',
|
||||
styleUrl: './strategy-dialog.component.css'
|
||||
styleUrl: './strategy-dialog.component.css',
|
||||
})
|
||||
export class StrategyDialogComponent implements OnInit {
|
||||
strategyForm: FormGroup;
|
||||
isEditMode: boolean = false;
|
||||
strategyTypes: string[] = [];
|
||||
availableSymbols: string[] = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA', 'META', 'NVDA', 'SPY', 'QQQ'];
|
||||
availableSymbols: string[] = [
|
||||
'AAPL',
|
||||
'MSFT',
|
||||
'GOOGL',
|
||||
'AMZN',
|
||||
'TSLA',
|
||||
'META',
|
||||
'NVDA',
|
||||
'SPY',
|
||||
'QQQ',
|
||||
];
|
||||
selectedSymbols: string[] = [];
|
||||
separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||
parameters: Record<string, any> = {};
|
||||
|
|
@ -67,9 +69,9 @@ export class StrategyDialogComponent implements OnInit {
|
|||
this.strategyForm.patchValue({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
type: data.type
|
||||
type: data.type,
|
||||
});
|
||||
this.parameters = {...data.parameters};
|
||||
this.parameters = { ...data.parameters };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +83,7 @@ export class StrategyDialogComponent implements OnInit {
|
|||
loadStrategyTypes(): void {
|
||||
// In a real implementation, this would call the API
|
||||
this.strategyService.getStrategyTypes().subscribe({
|
||||
next: (response) => {
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.strategyTypes = response.data;
|
||||
|
||||
|
|
@ -91,35 +93,35 @@ export class StrategyDialogComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
error: error => {
|
||||
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({
|
||||
next: (response) => {
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
// If editing, merge default with existing
|
||||
if (this.isEditMode && this.data) {
|
||||
this.parameters = {
|
||||
...response.data,
|
||||
...this.data.parameters
|
||||
...this.data.parameters,
|
||||
};
|
||||
} else {
|
||||
this.parameters = response.data;
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
error: error => {
|
||||
console.error('Error loading parameters:', error);
|
||||
// Fallback to empty parameters
|
||||
this.parameters = {};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -149,25 +151,25 @@ export class StrategyDialogComponent implements OnInit {
|
|||
|
||||
if (this.isEditMode && this.data) {
|
||||
this.strategyService.updateStrategy(this.data.id, strategy).subscribe({
|
||||
next: (response) => {
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
error: error => {
|
||||
console.error('Error updating strategy:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.strategyService.createStrategy(strategy).subscribe({
|
||||
next: (response) => {
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
error: error => {
|
||||
console.error('Error creating strategy:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { StrategyService, TradingStrategy } from '../../services/strategy.service';
|
||||
import { WebSocketService } from '../../services/websocket.service';
|
||||
import { StrategyDialogComponent } from './dialogs/strategy-dialog.component';
|
||||
import { BacktestDialogComponent } from './dialogs/backtest-dialog.component';
|
||||
import { StrategyDialogComponent } from './dialogs/strategy-dialog.component';
|
||||
import { StrategyDetailsComponent } from './strategy-details/strategy-details.component';
|
||||
|
||||
@Component({
|
||||
|
|
@ -36,10 +36,10 @@ import { StrategyDetailsComponent } from './strategy-details/strategy-details.co
|
|||
MatProgressBarModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
StrategyDetailsComponent
|
||||
StrategyDetailsComponent,
|
||||
],
|
||||
templateUrl: './strategies.component.html',
|
||||
styleUrl: './strategies.component.css'
|
||||
styleUrl: './strategies.component.css',
|
||||
})
|
||||
export class StrategiesComponent implements OnInit {
|
||||
strategies: TradingStrategy[] = [];
|
||||
|
|
@ -61,24 +61,26 @@ export class StrategiesComponent implements OnInit {
|
|||
loadStrategies(): void {
|
||||
this.isLoading = true;
|
||||
this.strategyService.getStrategies().subscribe({
|
||||
next: (response) => {
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.strategies = response.data;
|
||||
}
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: (error) => {
|
||||
error: error => {
|
||||
console.error('Error loading strategies:', error);
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
listenForStrategyUpdates(): void {
|
||||
this.webSocketService.messages.subscribe(message => {
|
||||
if (message.type === 'STRATEGY_CREATED' ||
|
||||
message.type === 'STRATEGY_UPDATED' ||
|
||||
message.type === 'STRATEGY_STATUS_CHANGED') {
|
||||
if (
|
||||
message.type === 'STRATEGY_CREATED' ||
|
||||
message.type === 'STRATEGY_UPDATED' ||
|
||||
message.type === 'STRATEGY_STATUS_CHANGED'
|
||||
) {
|
||||
// Refresh the strategy list when changes occur
|
||||
this.loadStrategies();
|
||||
}
|
||||
|
|
@ -87,17 +89,21 @@ export class StrategiesComponent implements OnInit {
|
|||
|
||||
getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'ACTIVE': return 'green';
|
||||
case 'PAUSED': return 'orange';
|
||||
case 'ERROR': return 'red';
|
||||
default: return 'gray';
|
||||
case 'ACTIVE':
|
||||
return 'green';
|
||||
case 'PAUSED':
|
||||
return 'orange';
|
||||
case 'ERROR':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
openStrategyDialog(strategy?: TradingStrategy): void {
|
||||
const dialogRef = this.dialog.open(StrategyDialogComponent, {
|
||||
width: '600px',
|
||||
data: strategy || null
|
||||
data: strategy || null,
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
|
|
@ -110,7 +116,7 @@ export class StrategiesComponent implements OnInit {
|
|||
openBacktestDialog(strategy?: TradingStrategy): void {
|
||||
const dialogRef = this.dialog.open(BacktestDialogComponent, {
|
||||
width: '800px',
|
||||
data: strategy || null
|
||||
data: strategy || null,
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
|
|
@ -126,18 +132,18 @@ export class StrategiesComponent implements OnInit {
|
|||
if (strategy.status === 'ACTIVE') {
|
||||
this.strategyService.pauseStrategy(strategy.id).subscribe({
|
||||
next: () => this.loadStrategies(),
|
||||
error: (error) => {
|
||||
error: error => {
|
||||
console.error('Error pausing strategy:', error);
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.strategyService.startStrategy(strategy.id).subscribe({
|
||||
next: () => this.loadStrategies(),
|
||||
error: (error) => {
|
||||
error: error => {
|
||||
console.error('Error starting strategy:', error);
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,17 +4,19 @@
|
|||
<mat-card class="flex-1 p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">{{strategy.name}}</h2>
|
||||
<p class="text-gray-600 text-sm">{{strategy.description}}</p>
|
||||
<h2 class="text-xl font-bold">{{ strategy.name }}</h2>
|
||||
<p class="text-gray-600 text-sm">{{ strategy.description }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button mat-raised-button color="primary" class="mr-2" (click)="openBacktestDialog()">
|
||||
Run Backtest
|
||||
</button>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-semibold"
|
||||
[style.background-color]="getStatusColor(strategy.status)"
|
||||
style="color: white;">
|
||||
{{strategy.status}}
|
||||
<span
|
||||
class="px-3 py-1 rounded-full text-xs font-semibold"
|
||||
[style.background-color]="getStatusColor(strategy.status)"
|
||||
style="color: white"
|
||||
>
|
||||
{{ strategy.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -22,20 +24,20 @@
|
|||
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 class="font-semibold text-sm text-gray-600">Type</h3>
|
||||
<p>{{strategy.type}}</p>
|
||||
<p>{{ strategy.type }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<h3 class="font-semibold text-sm text-gray-600">Symbols</h3>
|
||||
<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>
|
||||
|
|
@ -47,48 +49,68 @@
|
|||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Return</p>
|
||||
<p class="text-xl font-semibold"
|
||||
[ngClass]="{'text-green-600': performance.totalReturn >= 0, 'text-red-600': performance.totalReturn < 0}">
|
||||
{{performance.totalReturn | percent:'1.2-2'}}
|
||||
<p
|
||||
class="text-xl font-semibold"
|
||||
[ngClass]="{
|
||||
'text-green-600': performance.totalReturn >= 0,
|
||||
'text-red-600': performance.totalReturn < 0,
|
||||
}"
|
||||
>
|
||||
{{ performance.totalReturn | percent: '1.2-2' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<mat-divider class="my-4"></mat-divider>
|
||||
|
||||
<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
|
||||
</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
|
||||
</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
|
||||
</button>
|
||||
<button mat-button (click)="openEditDialog()">
|
||||
<mat-icon>edit</mat-icon> Edit
|
||||
</button>
|
||||
<button mat-button (click)="openEditDialog()"><mat-icon>edit</mat-icon> Edit</button>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
|
@ -98,12 +120,12 @@
|
|||
<h3 class="text-lg font-bold mb-3">Strategy Parameters</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div *ngFor="let param of strategy.parameters | keyvalue">
|
||||
<p class="text-sm text-gray-600">{{param.key}}</p>
|
||||
<p class="font-semibold">{{param.value}}</p>
|
||||
<p class="text-sm text-gray-600">{{ param.key }}</p>
|
||||
<p class="font-semibold">{{ param.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<h2 class="text-xl font-bold">Backtest Results</h2>
|
||||
|
||||
|
|
@ -140,18 +162,20 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let signal of signals">
|
||||
<td class="py-2">{{signal.timestamp | date:'short'}}</td>
|
||||
<td class="py-2">{{signal.symbol}}</td>
|
||||
<td class="py-2">{{ signal.timestamp | date: 'short' }}</td>
|
||||
<td class="py-2">{{ signal.symbol }}</td>
|
||||
<td class="py-2">
|
||||
<span class="px-2 py-1 rounded text-xs font-semibold"
|
||||
[style.background-color]="getSignalColor(signal.action)"
|
||||
style="color: white;">
|
||||
{{signal.action}}
|
||||
<span
|
||||
class="px-2 py-1 rounded text-xs font-semibold"
|
||||
[style.background-color]="getSignalColor(signal.action)"
|
||||
style="color: white"
|
||||
>
|
||||
{{ signal.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2">${{signal.price | number:'1.2-2'}}</td>
|
||||
<td class="py-2">{{signal.quantity}}</td>
|
||||
<td class="py-2">{{signal.confidence | percent:'1.0-0'}}</td>
|
||||
<td class="py-2">${{ signal.price | number: '1.2-2' }}</td>
|
||||
<td class="py-2">{{ signal.quantity }}</td>
|
||||
<td class="py-2">{{ signal.confidence | percent: '1.0-0' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -179,19 +203,30 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let trade of trades">
|
||||
<td class="py-2">{{trade.symbol}}</td>
|
||||
<td class="py-2">{{ trade.symbol }}</td>
|
||||
<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 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 class="py-2">{{trade.quantity}}</td>
|
||||
<td class="py-2" [ngClass]="{'text-green-600': trade.pnl >= 0, 'text-red-600': trade.pnl < 0}">
|
||||
${{trade.pnl | number:'1.2-2'}}
|
||||
<td class="py-2">{{ trade.quantity }}</td>
|
||||
<td
|
||||
class="py-2"
|
||||
[ngClass]="{ 'text-green-600': trade.pnl >= 0, 'text-red-600': trade.pnl < 0 }"
|
||||
>
|
||||
${{ trade.pnl | number: '1.2-2' }}
|
||||
</td>
|
||||
<td class="py-2" [ngClass]="{'text-green-600': trade.pnlPercent >= 0, 'text-red-600': trade.pnlPercent < 0}">
|
||||
{{trade.pnlPercent | number:'1.2-2'}}%
|
||||
<td
|
||||
class="py-2"
|
||||
[ngClass]="{
|
||||
'text-green-600': trade.pnlPercent >= 0,
|
||||
'text-red-600': trade.pnlPercent < 0,
|
||||
}"
|
||||
>
|
||||
{{ trade.pnlPercent | number: '1.2-2' }}%
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -208,7 +243,7 @@
|
|||
|
||||
<mat-card class="p-6 flex items-center" *ngIf="!strategy">
|
||||
<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>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,26 @@
|
|||
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { BacktestResult, TradingStrategy, StrategyService } from '../../../services/strategy.service';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import {
|
||||
BacktestResult,
|
||||
StrategyService,
|
||||
TradingStrategy,
|
||||
} from '../../../services/strategy.service';
|
||||
import { WebSocketService } from '../../../services/websocket.service';
|
||||
import { EquityChartComponent } from '../components/equity-chart.component';
|
||||
import { DrawdownChartComponent } from '../components/drawdown-chart.component';
|
||||
import { TradesTableComponent } from '../components/trades-table.component';
|
||||
import { EquityChartComponent } from '../components/equity-chart.component';
|
||||
import { PerformanceMetricsComponent } from '../components/performance-metrics.component';
|
||||
import { StrategyDialogComponent } from '../dialogs/strategy-dialog.component';
|
||||
import { TradesTableComponent } from '../components/trades-table.component';
|
||||
import { BacktestDialogComponent } from '../dialogs/backtest-dialog.component';
|
||||
import { StrategyDialogComponent } from '../dialogs/strategy-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-strategy-details',
|
||||
|
|
@ -34,10 +38,10 @@ import { BacktestDialogComponent } from '../dialogs/backtest-dialog.component';
|
|||
EquityChartComponent,
|
||||
DrawdownChartComponent,
|
||||
TradesTableComponent,
|
||||
PerformanceMetricsComponent
|
||||
PerformanceMetricsComponent,
|
||||
],
|
||||
templateUrl: './strategy-details.component.html',
|
||||
styleUrl: './strategy-details.component.css'
|
||||
styleUrl: './strategy-details.component.css',
|
||||
})
|
||||
export class StrategyDetailsComponent implements OnChanges {
|
||||
@Input() strategy: TradingStrategy | null = null;
|
||||
|
|
@ -70,30 +74,29 @@ export class StrategyDetailsComponent implements OnChanges {
|
|||
this.loadTrades();
|
||||
this.loadPerformance();
|
||||
}
|
||||
loadSignals(): void {
|
||||
loadSignals(): void {
|
||||
if (!this.strategy) return;
|
||||
|
||||
this.isLoadingSignals = true;
|
||||
|
||||
// First check if we can get real signals from the API
|
||||
this.strategyService.getStrategySignals(this.strategy.id)
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data && response.data.length > 0) {
|
||||
this.signals = response.data;
|
||||
} else {
|
||||
// Fallback to mock data if no real signals available
|
||||
this.signals = this.generateMockSignals();
|
||||
}
|
||||
this.isLoadingSignals = false;
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error loading signals', error);
|
||||
// Fallback to mock data on error
|
||||
this.strategyService.getStrategySignals(this.strategy.id).subscribe({
|
||||
next: response => {
|
||||
if (response.success && response.data && response.data.length > 0) {
|
||||
this.signals = response.data;
|
||||
} else {
|
||||
// Fallback to mock data if no real signals available
|
||||
this.signals = this.generateMockSignals();
|
||||
this.isLoadingSignals = false;
|
||||
}
|
||||
});
|
||||
this.isLoadingSignals = false;
|
||||
},
|
||||
error: error => {
|
||||
console.error('Error loading signals', error);
|
||||
// Fallback to mock data on error
|
||||
this.signals = this.generateMockSignals();
|
||||
this.isLoadingSignals = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
loadTrades(): void {
|
||||
|
|
@ -102,24 +105,23 @@ export class StrategyDetailsComponent implements OnChanges {
|
|||
this.isLoadingTrades = true;
|
||||
|
||||
// First check if we can get real trades from the API
|
||||
this.strategyService.getStrategyTrades(this.strategy.id)
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
if (response.success && response.data && response.data.length > 0) {
|
||||
this.trades = response.data;
|
||||
} else {
|
||||
// Fallback to mock data if no real trades available
|
||||
this.trades = this.generateMockTrades();
|
||||
}
|
||||
this.isLoadingTrades = false;
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error loading trades', error);
|
||||
// Fallback to mock data on error
|
||||
this.strategyService.getStrategyTrades(this.strategy.id).subscribe({
|
||||
next: response => {
|
||||
if (response.success && response.data && response.data.length > 0) {
|
||||
this.trades = response.data;
|
||||
} else {
|
||||
// Fallback to mock data if no real trades available
|
||||
this.trades = this.generateMockTrades();
|
||||
this.isLoadingTrades = false;
|
||||
}
|
||||
});
|
||||
this.isLoadingTrades = false;
|
||||
},
|
||||
error: error => {
|
||||
console.error('Error loading trades', error);
|
||||
// Fallback to mock data on error
|
||||
this.trades = this.generateMockTrades();
|
||||
this.isLoadingTrades = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
loadPerformance(): void {
|
||||
|
|
@ -134,51 +136,48 @@ export class StrategyDetailsComponent implements OnChanges {
|
|||
dailyReturn: 0.0012,
|
||||
volatility: 0.008,
|
||||
sortinoRatio: 1.2,
|
||||
calmarRatio: 0.7
|
||||
calmarRatio: 0.7,
|
||||
};
|
||||
}
|
||||
listenForUpdates(): void {
|
||||
if (!this.strategy) return;
|
||||
|
||||
// Subscribe to strategy signals
|
||||
this.webSocketService.getStrategySignals(this.strategy.id)
|
||||
.subscribe((signal: any) => {
|
||||
// Add the new signal to the top of the list
|
||||
this.signals = [signal, ...this.signals.slice(0, 9)]; // Keep only the latest 10 signals
|
||||
});
|
||||
this.webSocketService.getStrategySignals(this.strategy.id).subscribe((signal: any) => {
|
||||
// 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((trade: any) => {
|
||||
// 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.webSocketService.getStrategyTrades(this.strategy.id).subscribe((trade: any) => {
|
||||
// Add the new trade to the top of the list
|
||||
this.trades = [trade, ...this.trades.slice(0, 9)]; // Keep only the latest 10 trades
|
||||
|
||||
// Update performance metrics
|
||||
this.updatePerformanceMetrics();
|
||||
});
|
||||
// Update performance metrics
|
||||
this.updatePerformanceMetrics();
|
||||
});
|
||||
|
||||
// Subscribe to strategy status updates
|
||||
this.webSocketService.getStrategyUpdates()
|
||||
.subscribe((update: any) => {
|
||||
if (update.strategyId === this.strategy?.id) {
|
||||
// Update strategy status if changed
|
||||
if (update.status && this.strategy && this.strategy.status !== update.status) {
|
||||
this.strategy.status = update.status;
|
||||
}
|
||||
|
||||
// Update other fields if present
|
||||
if (update.performance && this.strategy) {
|
||||
this.strategy.performance = {
|
||||
...this.strategy.performance,
|
||||
...update.performance
|
||||
};
|
||||
this.performance = {
|
||||
...this.performance,
|
||||
...update.performance
|
||||
};
|
||||
}
|
||||
this.webSocketService.getStrategyUpdates().subscribe((update: any) => {
|
||||
if (update.strategyId === this.strategy?.id) {
|
||||
// Update strategy status if changed
|
||||
if (update.status && this.strategy && this.strategy.status !== update.status) {
|
||||
this.strategy.status = update.status;
|
||||
}
|
||||
});
|
||||
|
||||
// Update other fields if present
|
||||
if (update.performance && this.strategy) {
|
||||
this.strategy.performance = {
|
||||
...this.strategy.performance,
|
||||
...update.performance,
|
||||
};
|
||||
this.performance = {
|
||||
...this.performance,
|
||||
...update.performance,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('WebSocket listeners for strategy updates initialized');
|
||||
}
|
||||
|
|
@ -202,7 +201,7 @@ export class StrategyDetailsComponent implements OnChanges {
|
|||
...currentPerformance,
|
||||
totalTrades: this.trades.length,
|
||||
winRate: winRate,
|
||||
totalReturn: (currentPerformance.totalReturn || 0) + (totalPnl / 10000) // Approximate
|
||||
totalReturn: (currentPerformance.totalReturn || 0) + totalPnl / 10000, // Approximate
|
||||
};
|
||||
|
||||
// Update strategy performance as well
|
||||
|
|
@ -210,25 +209,32 @@ export class StrategyDetailsComponent implements OnChanges {
|
|||
this.strategy.performance = {
|
||||
...this.strategy.performance,
|
||||
totalTrades: this.trades.length,
|
||||
winRate: winRate
|
||||
winRate: winRate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'ACTIVE': return 'green';
|
||||
case 'PAUSED': return 'orange';
|
||||
case 'ERROR': return 'red';
|
||||
default: return 'gray';
|
||||
case 'ACTIVE':
|
||||
return 'green';
|
||||
case 'PAUSED':
|
||||
return 'orange';
|
||||
case 'ERROR':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
getSignalColor(action: string): string {
|
||||
switch (action) {
|
||||
case 'BUY': return 'green';
|
||||
case 'SELL': return 'red';
|
||||
default: return 'gray';
|
||||
case 'BUY':
|
||||
return 'green';
|
||||
case 'SELL':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -240,7 +246,7 @@ export class StrategyDetailsComponent implements OnChanges {
|
|||
|
||||
const dialogRef = this.dialog.open(BacktestDialogComponent, {
|
||||
width: '800px',
|
||||
data: this.strategy
|
||||
data: this.strategy,
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
|
|
@ -259,7 +265,7 @@ export class StrategyDetailsComponent implements OnChanges {
|
|||
|
||||
const dialogRef = this.dialog.open(StrategyDialogComponent, {
|
||||
width: '600px',
|
||||
data: this.strategy
|
||||
data: this.strategy,
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
|
|
@ -277,14 +283,14 @@ export class StrategyDetailsComponent implements OnChanges {
|
|||
if (!this.strategy) return;
|
||||
|
||||
this.strategyService.startStrategy(this.strategy.id).subscribe({
|
||||
next: (response) => {
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.strategy!.status = 'ACTIVE';
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
error: error => {
|
||||
console.error('Error starting strategy:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -295,14 +301,14 @@ export class StrategyDetailsComponent implements OnChanges {
|
|||
if (!this.strategy) return;
|
||||
|
||||
this.strategyService.pauseStrategy(this.strategy.id).subscribe({
|
||||
next: (response) => {
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.strategy!.status = 'PAUSED';
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
error: error => {
|
||||
console.error('Error pausing strategy:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -313,14 +319,14 @@ export class StrategyDetailsComponent implements OnChanges {
|
|||
if (!this.strategy) return;
|
||||
|
||||
this.strategyService.stopStrategy(this.strategy.id).subscribe({
|
||||
next: (response) => {
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.strategy!.status = 'INACTIVE';
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
error: error => {
|
||||
console.error('Error stopping strategy:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -333,7 +339,8 @@ export class StrategyDetailsComponent implements OnChanges {
|
|||
const now = new Date();
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const symbol = this.strategy.symbols[Math.floor(Math.random() * this.strategy.symbols.length)];
|
||||
const symbol =
|
||||
this.strategy.symbols[Math.floor(Math.random() * this.strategy.symbols.length)];
|
||||
const action = actions[Math.floor(Math.random() * actions.length)];
|
||||
|
||||
signals.push({
|
||||
|
|
@ -343,7 +350,7 @@ export class StrategyDetailsComponent implements OnChanges {
|
|||
confidence: 0.7 + Math.random() * 0.3,
|
||||
price: 100 + Math.random() * 50,
|
||||
timestamp: new Date(now.getTime() - i * 1000 * 60 * 30), // 30 min intervals
|
||||
quantity: Math.floor(10 + Math.random() * 90)
|
||||
quantity: Math.floor(10 + Math.random() * 90),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -357,7 +364,8 @@ export class StrategyDetailsComponent implements OnChanges {
|
|||
const now = new Date();
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const symbol = this.strategy.symbols[Math.floor(Math.random() * this.strategy.symbols.length)];
|
||||
const symbol =
|
||||
this.strategy.symbols[Math.floor(Math.random() * this.strategy.symbols.length)];
|
||||
const entryPrice = 100 + Math.random() * 50;
|
||||
const exitPrice = entryPrice * (1 + (Math.random() * 0.1 - 0.05)); // -5% to +5%
|
||||
const quantity = Math.floor(10 + Math.random() * 90);
|
||||
|
|
@ -372,7 +380,7 @@ export class StrategyDetailsComponent implements OnChanges {
|
|||
exitTime: new Date(now.getTime() - i * 1000 * 60 * 60),
|
||||
quantity,
|
||||
pnl,
|
||||
pnlPercent: ((exitPrice - entryPrice) / entryPrice) * 100
|
||||
pnlPercent: ((exitPrice - entryPrice) / entryPrice) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface RiskThresholds {
|
||||
|
|
@ -27,13 +27,13 @@ export interface MarketData {
|
|||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ApiService {
|
||||
private readonly baseUrls = {
|
||||
riskGuardian: 'http://localhost:3002',
|
||||
strategyOrchestrator: 'http://localhost:3003',
|
||||
marketDataGateway: 'http://localhost:3001'
|
||||
marketDataGateway: 'http://localhost:3001',
|
||||
};
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
|
@ -45,7 +45,9 @@ export class ApiService {
|
|||
);
|
||||
}
|
||||
|
||||
updateRiskThresholds(thresholds: RiskThresholds): Observable<{ success: boolean; data: RiskThresholds }> {
|
||||
updateRiskThresholds(
|
||||
thresholds: RiskThresholds
|
||||
): Observable<{ success: boolean; data: RiskThresholds }> {
|
||||
return this.http.put<{ success: boolean; data: RiskThresholds }>(
|
||||
`${this.baseUrls.riskGuardian}/api/risk/thresholds`,
|
||||
thresholds
|
||||
|
|
@ -84,7 +86,9 @@ export class ApiService {
|
|||
);
|
||||
}
|
||||
// Market Data Gateway API
|
||||
getMarketData(symbols: string[] = ['AAPL', 'GOOGL', 'MSFT', 'TSLA', 'AMZN']): Observable<{ success: boolean; data: MarketData[] }> {
|
||||
getMarketData(
|
||||
symbols: string[] = ['AAPL', 'GOOGL', 'MSFT', 'TSLA', 'AMZN']
|
||||
): Observable<{ success: boolean; data: MarketData[] }> {
|
||||
const symbolsParam = symbols.join(',');
|
||||
return this.http.get<{ success: boolean; data: MarketData[] }>(
|
||||
`${this.baseUrls.marketDataGateway}/api/market-data?symbols=${symbolsParam}`
|
||||
|
|
@ -92,7 +96,9 @@ export class ApiService {
|
|||
}
|
||||
|
||||
// Health checks
|
||||
checkServiceHealth(service: 'riskGuardian' | 'strategyOrchestrator' | 'marketDataGateway'): Observable<any> {
|
||||
checkServiceHealth(
|
||||
service: 'riskGuardian' | 'strategyOrchestrator' | 'marketDataGateway'
|
||||
): Observable<any> {
|
||||
return this.http.get(`${this.baseUrls[service]}/health`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Injectable, signal, inject } from '@angular/core';
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { WebSocketService, RiskAlert } from './websocket.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { RiskAlert, WebSocketService } from './websocket.service';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
|
|
@ -13,7 +13,7 @@ export interface Notification {
|
|||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class NotificationService {
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
|
@ -34,9 +34,9 @@ export class NotificationService {
|
|||
next: (alert: RiskAlert) => {
|
||||
this.handleRiskAlert(alert);
|
||||
},
|
||||
error: (err) => {
|
||||
error: err => {
|
||||
console.error('Risk alert subscription error:', err);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ export class NotificationService {
|
|||
title: `Risk Alert: ${alert.symbol}`,
|
||||
message: alert.message,
|
||||
timestamp: new Date(alert.timestamp),
|
||||
read: false
|
||||
read: false,
|
||||
};
|
||||
|
||||
this.addNotification(notification);
|
||||
|
|
@ -56,10 +56,14 @@ export class NotificationService {
|
|||
|
||||
private mapSeverityToType(severity: string): 'info' | 'warning' | 'error' | 'success' {
|
||||
switch (severity) {
|
||||
case 'HIGH': return 'error';
|
||||
case 'MEDIUM': return 'warning';
|
||||
case 'LOW': return 'info';
|
||||
default: return 'info';
|
||||
case 'HIGH':
|
||||
return 'error';
|
||||
case 'MEDIUM':
|
||||
return 'warning';
|
||||
case 'LOW':
|
||||
return 'info';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,14 +71,10 @@ export class NotificationService {
|
|||
const actionText = notification.type === 'error' ? 'Review' : 'Dismiss';
|
||||
const duration = notification.type === 'error' ? 10000 : 5000;
|
||||
|
||||
this.snackBar.open(
|
||||
`${notification.title}: ${notification.message}`,
|
||||
actionText,
|
||||
{
|
||||
duration,
|
||||
panelClass: [`snack-${notification.type}`]
|
||||
}
|
||||
);
|
||||
this.snackBar.open(`${notification.title}: ${notification.message}`, actionText, {
|
||||
duration,
|
||||
panelClass: [`snack-${notification.type}`],
|
||||
});
|
||||
}
|
||||
|
||||
// Public methods
|
||||
|
|
@ -87,9 +87,7 @@ export class NotificationService {
|
|||
|
||||
markAsRead(notificationId: string) {
|
||||
const current = this.notifications();
|
||||
const updated = current.map(n =>
|
||||
n.id === notificationId ? { ...n, read: true } : n
|
||||
);
|
||||
const updated = current.map(n => (n.id === notificationId ? { ...n, read: true } : n));
|
||||
this.notifications.set(updated);
|
||||
this.updateUnreadCount();
|
||||
}
|
||||
|
|
@ -126,12 +124,12 @@ export class NotificationService {
|
|||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false
|
||||
read: false,
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 3000,
|
||||
panelClass: ['snack-success']
|
||||
panelClass: ['snack-success'],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -142,12 +140,12 @@ export class NotificationService {
|
|||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false
|
||||
read: false,
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 8000,
|
||||
panelClass: ['snack-error']
|
||||
panelClass: ['snack-error'],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -158,12 +156,12 @@ export class NotificationService {
|
|||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false
|
||||
read: false,
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 5000,
|
||||
panelClass: ['snack-warning']
|
||||
panelClass: ['snack-warning'],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -174,12 +172,12 @@ export class NotificationService {
|
|||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false
|
||||
read: false,
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 4000,
|
||||
panelClass: ['snack-info']
|
||||
panelClass: ['snack-info'],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface TradingStrategy {
|
||||
|
|
@ -80,12 +80,12 @@ interface ApiResponse<T> {
|
|||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StrategyService {
|
||||
private apiBaseUrl = '/api'; // Will be proxied to the correct backend endpoint
|
||||
|
||||
constructor(private http: HttpClient) { }
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Strategy Management
|
||||
getStrategies(): Observable<ApiResponse<TradingStrategy[]>> {
|
||||
|
|
@ -100,20 +100,35 @@ export class StrategyService {
|
|||
return this.http.post<ApiResponse<TradingStrategy>>(`${this.apiBaseUrl}/strategies`, strategy);
|
||||
}
|
||||
|
||||
updateStrategy(id: string, updates: Partial<TradingStrategy>): Observable<ApiResponse<TradingStrategy>> {
|
||||
return this.http.put<ApiResponse<TradingStrategy>>(`${this.apiBaseUrl}/strategies/${id}`, updates);
|
||||
updateStrategy(
|
||||
id: string,
|
||||
updates: Partial<TradingStrategy>
|
||||
): Observable<ApiResponse<TradingStrategy>> {
|
||||
return this.http.put<ApiResponse<TradingStrategy>>(
|
||||
`${this.apiBaseUrl}/strategies/${id}`,
|
||||
updates
|
||||
);
|
||||
}
|
||||
|
||||
startStrategy(id: string): Observable<ApiResponse<TradingStrategy>> {
|
||||
return this.http.post<ApiResponse<TradingStrategy>>(`${this.apiBaseUrl}/strategies/${id}/start`, {});
|
||||
return this.http.post<ApiResponse<TradingStrategy>>(
|
||||
`${this.apiBaseUrl}/strategies/${id}/start`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
stopStrategy(id: string): Observable<ApiResponse<TradingStrategy>> {
|
||||
return this.http.post<ApiResponse<TradingStrategy>>(`${this.apiBaseUrl}/strategies/${id}/stop`, {});
|
||||
return this.http.post<ApiResponse<TradingStrategy>>(
|
||||
`${this.apiBaseUrl}/strategies/${id}/stop`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
pauseStrategy(id: string): Observable<ApiResponse<TradingStrategy>> {
|
||||
return this.http.post<ApiResponse<TradingStrategy>>(`${this.apiBaseUrl}/strategies/${id}/pause`, {});
|
||||
return this.http.post<ApiResponse<TradingStrategy>>(
|
||||
`${this.apiBaseUrl}/strategies/${id}/pause`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
// Backtest Management
|
||||
|
|
@ -122,7 +137,9 @@ export class StrategyService {
|
|||
}
|
||||
|
||||
getStrategyParameters(type: string): Observable<ApiResponse<Record<string, any>>> {
|
||||
return this.http.get<ApiResponse<Record<string, any>>>(`${this.apiBaseUrl}/strategy-parameters/${type}`);
|
||||
return this.http.get<ApiResponse<Record<string, any>>>(
|
||||
`${this.apiBaseUrl}/strategy-parameters/${type}`
|
||||
);
|
||||
}
|
||||
|
||||
runBacktest(request: BacktestRequest): Observable<ApiResponse<BacktestResult>> {
|
||||
|
|
@ -143,32 +160,40 @@ export class StrategyService {
|
|||
}
|
||||
|
||||
// Strategy Signals and Trades
|
||||
getStrategySignals(strategyId: string): Observable<ApiResponse<Array<{
|
||||
id: string;
|
||||
strategyId: string;
|
||||
symbol: string;
|
||||
action: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
timestamp: Date;
|
||||
confidence: number;
|
||||
metadata?: any;
|
||||
}>>> {
|
||||
getStrategySignals(strategyId: string): Observable<
|
||||
ApiResponse<
|
||||
Array<{
|
||||
id: string;
|
||||
strategyId: string;
|
||||
symbol: string;
|
||||
action: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
timestamp: Date;
|
||||
confidence: number;
|
||||
metadata?: any;
|
||||
}>
|
||||
>
|
||||
> {
|
||||
return this.http.get<ApiResponse<any[]>>(`${this.apiBaseUrl}/strategies/${strategyId}/signals`);
|
||||
}
|
||||
|
||||
getStrategyTrades(strategyId: string): Observable<ApiResponse<Array<{
|
||||
id: string;
|
||||
strategyId: string;
|
||||
symbol: string;
|
||||
entryPrice: number;
|
||||
entryTime: Date;
|
||||
exitPrice: number;
|
||||
exitTime: Date;
|
||||
quantity: number;
|
||||
pnl: number;
|
||||
pnlPercent: number;
|
||||
}>>> {
|
||||
getStrategyTrades(strategyId: string): Observable<
|
||||
ApiResponse<
|
||||
Array<{
|
||||
id: string;
|
||||
strategyId: string;
|
||||
symbol: string;
|
||||
entryPrice: number;
|
||||
entryTime: Date;
|
||||
exitPrice: number;
|
||||
exitTime: Date;
|
||||
quantity: number;
|
||||
pnl: number;
|
||||
pnlPercent: number;
|
||||
}>
|
||||
>
|
||||
> {
|
||||
return this.http.get<ApiResponse<any[]>>(`${this.apiBaseUrl}/strategies/${strategyId}/trades`);
|
||||
}
|
||||
|
||||
|
|
@ -177,13 +202,17 @@ export class StrategyService {
|
|||
// Handle date formatting and parameter conversion
|
||||
return {
|
||||
...formData,
|
||||
startDate: formData.startDate instanceof Date ? formData.startDate.toISOString() : formData.startDate,
|
||||
startDate:
|
||||
formData.startDate instanceof Date ? formData.startDate.toISOString() : formData.startDate,
|
||||
endDate: formData.endDate instanceof Date ? formData.endDate.toISOString() : formData.endDate,
|
||||
strategyParams: this.convertParameterTypes(formData.strategyType, formData.strategyParams)
|
||||
strategyParams: this.convertParameterTypes(formData.strategyType, formData.strategyParams),
|
||||
};
|
||||
}
|
||||
|
||||
private convertParameterTypes(strategyType: string, params: Record<string, any>): Record<string, any> {
|
||||
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> = {};
|
||||
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@ export interface RiskAlert {
|
|||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class WebSocketService {
|
||||
private readonly WS_ENDPOINTS = {
|
||||
marketData: 'ws://localhost:3001/ws',
|
||||
riskGuardian: 'ws://localhost:3002/ws',
|
||||
strategyOrchestrator: 'ws://localhost:3003/ws'
|
||||
strategyOrchestrator: 'ws://localhost:3003/ws',
|
||||
};
|
||||
|
||||
private connections = new Map<string, WebSocket>();
|
||||
|
|
@ -44,7 +44,7 @@ export class WebSocketService {
|
|||
public connectionStatus = signal<{ [key: string]: boolean }>({
|
||||
marketData: false,
|
||||
riskGuardian: false,
|
||||
strategyOrchestrator: false
|
||||
strategyOrchestrator: false,
|
||||
});
|
||||
|
||||
constructor() {
|
||||
|
|
@ -68,7 +68,7 @@ export class WebSocketService {
|
|||
this.updateConnectionStatus(serviceName, true);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
ws.onmessage = event => {
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data);
|
||||
messageSubject.next(message);
|
||||
|
|
@ -87,14 +87,13 @@ export class WebSocketService {
|
|||
}, 5000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
ws.onerror = error => {
|
||||
console.error(`WebSocket error for ${serviceName}:`, error);
|
||||
this.updateConnectionStatus(serviceName, false);
|
||||
};
|
||||
|
||||
this.connections.set(serviceName, ws);
|
||||
this.messageSubjects.set(serviceName, messageSubject);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect to ${serviceName} WebSocket:`, error);
|
||||
this.updateConnectionStatus(serviceName, false);
|
||||
|
|
@ -157,9 +156,10 @@ export class WebSocketService {
|
|||
}
|
||||
|
||||
return subject.asObservable().pipe(
|
||||
filter(message =>
|
||||
message.type === 'strategy_signal' &&
|
||||
(!strategyId || message.data.strategyId === strategyId)
|
||||
filter(
|
||||
message =>
|
||||
message.type === 'strategy_signal' &&
|
||||
(!strategyId || message.data.strategyId === strategyId)
|
||||
),
|
||||
map(message => message.data)
|
||||
);
|
||||
|
|
@ -173,9 +173,10 @@ export class WebSocketService {
|
|||
}
|
||||
|
||||
return subject.asObservable().pipe(
|
||||
filter(message =>
|
||||
message.type === 'strategy_trade' &&
|
||||
(!strategyId || message.data.strategyId === strategyId)
|
||||
filter(
|
||||
message =>
|
||||
message.type === 'strategy_trade' &&
|
||||
(!strategyId || message.data.strategyId === strategyId)
|
||||
),
|
||||
map(message => message.data)
|
||||
);
|
||||
|
|
@ -188,11 +189,7 @@ export class WebSocketService {
|
|||
throw new Error('Strategy Orchestrator WebSocket not initialized');
|
||||
}
|
||||
|
||||
return subject.asObservable().pipe(
|
||||
filter(message =>
|
||||
message.type.startsWith('strategy_')
|
||||
)
|
||||
);
|
||||
return subject.asObservable().pipe(filter(message => message.type.startsWith('strategy_')));
|
||||
}
|
||||
|
||||
// Send messages
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
import { appConfig } from './app/app.config';
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
bootstrapApplication(App, appConfig).catch(err => console.error(err));
|
||||
|
|
|
|||
|
|
@ -1,20 +1,14 @@
|
|||
/**
|
||||
* Data Service - Combined live and historical data ingestion with queue-based architecture
|
||||
*/
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { loadEnvVariables } from '@stock-bot/config';
|
||||
import { Hono } from 'hono';
|
||||
import { loadEnvVariables } from '@stock-bot/config';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { Shutdown } from '@stock-bot/shutdown';
|
||||
import { initializeProxyCache } from './providers/proxy.tasks';
|
||||
import { queueManager } from './services/queue.service';
|
||||
import { initializeBatchCache } from './utils/batch-helpers';
|
||||
import { initializeProxyCache } from './providers/proxy.tasks';
|
||||
import {
|
||||
healthRoutes,
|
||||
queueRoutes,
|
||||
marketDataRoutes,
|
||||
proxyRoutes,
|
||||
testRoutes
|
||||
} from './routes';
|
||||
import { healthRoutes, marketDataRoutes, proxyRoutes, queueRoutes, testRoutes } from './routes';
|
||||
|
||||
// Load environment variables
|
||||
loadEnvVariables();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ProxyInfo } from 'libs/http/src/types';
|
||||
import { ProviderConfig } from '../services/provider-registry.service';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { ProviderConfig } from '../services/provider-registry.service';
|
||||
|
||||
// Create logger for this provider
|
||||
const logger = getLogger('proxy-provider');
|
||||
|
|
@ -15,7 +15,8 @@ const getEvery24HourCron = (): string => {
|
|||
|
||||
export const proxyProvider: ProviderConfig = {
|
||||
name: 'proxy-provider',
|
||||
operations: {'fetch-and-check': async (payload: { sources?: string[] }) => {
|
||||
operations: {
|
||||
'fetch-and-check': async (payload: { sources?: string[] }) => {
|
||||
const { proxyService } = await import('./proxy.tasks');
|
||||
const { queueManager } = await import('../services/queue.service');
|
||||
const { processItems } = await import('../utils/batch-helpers');
|
||||
|
|
@ -32,23 +33,24 @@ export const proxyProvider: ProviderConfig = {
|
|||
(proxy, index) => ({
|
||||
proxy,
|
||||
index,
|
||||
source: 'batch-processing'
|
||||
source: 'batch-processing',
|
||||
}),
|
||||
queueManager,
|
||||
{
|
||||
totalDelayHours: 4,//parseFloat(process.env.PROXY_VALIDATION_HOURS || '1'),
|
||||
totalDelayHours: 4, //parseFloat(process.env.PROXY_VALIDATION_HOURS || '1'),
|
||||
batchSize: parseInt(process.env.PROXY_BATCH_SIZE || '200'),
|
||||
useBatching: process.env.PROXY_DIRECT_MODE !== 'true',
|
||||
priority: 2,
|
||||
provider: 'proxy-provider',
|
||||
operation: 'check-proxy'
|
||||
operation: 'check-proxy',
|
||||
}
|
||||
);return {
|
||||
);
|
||||
return {
|
||||
proxiesFetched: result.totalItems,
|
||||
jobsCreated: result.jobsCreated,
|
||||
mode: result.mode,
|
||||
batchesCreated: result.batchesCreated,
|
||||
processingTimeMs: result.duration
|
||||
processingTimeMs: result.duration,
|
||||
};
|
||||
},
|
||||
'process-batch-items': async (payload: any) => {
|
||||
|
|
@ -60,11 +62,11 @@ export const proxyProvider: ProviderConfig = {
|
|||
},
|
||||
|
||||
'check-proxy': async (payload: {
|
||||
proxy: ProxyInfo,
|
||||
source?: string,
|
||||
batchIndex?: number,
|
||||
itemIndex?: number,
|
||||
total?: number
|
||||
proxy: ProxyInfo;
|
||||
source?: string;
|
||||
batchIndex?: number;
|
||||
itemIndex?: number;
|
||||
total?: number;
|
||||
}) => {
|
||||
const { checkProxy } = await import('./proxy.tasks');
|
||||
|
||||
|
|
@ -75,7 +77,7 @@ export const proxyProvider: ProviderConfig = {
|
|||
proxy: `${payload.proxy.host}:${payload.proxy.port}`,
|
||||
isWorking: result.isWorking,
|
||||
responseTime: result.responseTime,
|
||||
batchIndex: payload.batchIndex
|
||||
batchIndex: payload.batchIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -87,15 +89,15 @@ export const proxyProvider: ProviderConfig = {
|
|||
batchIndex: payload.batchIndex,
|
||||
itemIndex: payload.itemIndex,
|
||||
total: payload.total,
|
||||
source: payload.source
|
||||
}
|
||||
})
|
||||
source: payload.source,
|
||||
},
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn('Proxy validation failed', {
|
||||
proxy: `${payload.proxy.host}:${payload.proxy.port}`,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
batchIndex: payload.batchIndex
|
||||
batchIndex: payload.batchIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -107,12 +109,12 @@ export const proxyProvider: ProviderConfig = {
|
|||
batchIndex: payload.batchIndex,
|
||||
itemIndex: payload.itemIndex,
|
||||
total: payload.total,
|
||||
source: payload.source
|
||||
}
|
||||
})
|
||||
source: payload.source,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
scheduledJobs: [
|
||||
{
|
||||
|
|
@ -123,9 +125,7 @@ export const proxyProvider: ProviderConfig = {
|
|||
cronPattern: getEvery24HourCron(),
|
||||
priority: 5,
|
||||
immediately: true, // Don't run immediately during startup to avoid conflicts
|
||||
description: 'Fetch and validate proxy list from sources'
|
||||
}
|
||||
]
|
||||
description: 'Fetch and validate proxy list from sources',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { getLogger } from '@stock-bot/logger';
|
||||
import pLimit from 'p-limit';
|
||||
import { createCache, type CacheProvider } from '@stock-bot/cache';
|
||||
import { HttpClient, ProxyInfo } from '@stock-bot/http';
|
||||
import pLimit from 'p-limit';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
|
||||
// Type definitions
|
||||
export interface ProxySource {
|
||||
|
|
@ -24,34 +24,134 @@ const PROXY_CONFIG = {
|
|||
CHECK_URL: 'https://proxy-detection.stare.gg/?api_key=bd406bf53ddc6abe1d9de5907830a955',
|
||||
CONCURRENCY_LIMIT: 100,
|
||||
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: 'sunny9577', url: 'https://raw.githubusercontent.com/sunny9577/proxy-scraper/master/proxies.txt', protocol: 'http'},
|
||||
{id: 'themiralay', url: 'https://raw.githubusercontent.com/themiralay/Proxy-List-World/refs/heads/master/data.txt', 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: 'monosans', url: 'https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/http.txt', protocol: 'http'},
|
||||
{
|
||||
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: 'sunny9577',
|
||||
url: 'https://raw.githubusercontent.com/sunny9577/proxy-scraper/master/proxies.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'themiralay',
|
||||
url: 'https://raw.githubusercontent.com/themiralay/Proxy-List-World/refs/heads/master/data.txt',
|
||||
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: 'monosans',
|
||||
url: 'https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/http.txt',
|
||||
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: 'gfpcom', url: 'https://raw.githubusercontent.com/gfpcom/free-proxy-list/refs/heads/main/list/http.txt', protocol: 'http'},
|
||||
{id: 'dpangestuw', url: 'https://raw.githubusercontent.com/dpangestuw/Free-Proxy/refs/heads/main/http_proxies.txt', 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: 'ercindedeoglu', url: 'https://raw.githubusercontent.com/ErcinDedeoglu/proxies/main/proxies/http.txt', protocol: 'http'},
|
||||
{id: 'tuanminpay', url: 'https://raw.githubusercontent.com/TuanMinPay/live-proxy/master/http.txt', 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: 'gfpcom',
|
||||
url: 'https://raw.githubusercontent.com/gfpcom/free-proxy-list/refs/heads/main/list/http.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'dpangestuw',
|
||||
url: 'https://raw.githubusercontent.com/dpangestuw/Free-Proxy/refs/heads/main/http_proxies.txt',
|
||||
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: 'ercindedeoglu',
|
||||
url: 'https://raw.githubusercontent.com/ErcinDedeoglu/proxies/main/proxies/http.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'tuanminpay',
|
||||
url: 'https://raw.githubusercontent.com/TuanMinPay/live-proxy/master/http.txt',
|
||||
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: 'vakhov-fresh-https', url: 'https://raw.githubusercontent.com/vakhov/fresh-proxy-list/refs/heads/master/https.txt', protocol: 'https'},
|
||||
{id: 'databay-https', url: 'https://raw.githubusercontent.com/databay-labs/free-proxy-list/refs/heads/master/https.txt', protocol: 'https'},
|
||||
{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: '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: 'vakhov-fresh-https',
|
||||
url: 'https://raw.githubusercontent.com/vakhov/fresh-proxy-list/refs/heads/master/https.txt',
|
||||
protocol: 'https',
|
||||
},
|
||||
{
|
||||
id: 'databay-https',
|
||||
url: 'https://raw.githubusercontent.com/databay-labs/free-proxy-list/refs/heads/master/https.txt',
|
||||
protocol: 'https',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Shared instances (module-scoped, not global)
|
||||
|
|
@ -60,28 +160,25 @@ let cache: CacheProvider;
|
|||
let httpClient: HttpClient;
|
||||
let concurrencyLimit: ReturnType<typeof pLimit>;
|
||||
let proxyStats: ProxySource[] = PROXY_CONFIG.PROXY_SOURCES.map(source => ({
|
||||
id: source.id,
|
||||
total: 0,
|
||||
working: 0,
|
||||
lastChecked: new Date(),
|
||||
protocol: source.protocol,
|
||||
url: source.url,
|
||||
}));
|
||||
|
||||
id: source.id,
|
||||
total: 0,
|
||||
working: 0,
|
||||
lastChecked: new Date(),
|
||||
protocol: source.protocol,
|
||||
url: source.url,
|
||||
}));
|
||||
|
||||
// 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);
|
||||
if (source !== undefined) {
|
||||
if(typeof source.working !== 'number')
|
||||
source.working = 0;
|
||||
if(typeof source.total !== 'number')
|
||||
source.total = 0;
|
||||
if (typeof source.working !== 'number') source.working = 0;
|
||||
if (typeof source.total !== 'number') source.total = 0;
|
||||
source.total += 1;
|
||||
if (success) {
|
||||
source.working += 1;
|
||||
}
|
||||
source.percentWorking = source.working / source.total * 100;
|
||||
source.percentWorking = (source.working / source.total) * 100;
|
||||
source.lastChecked = new Date();
|
||||
await cache.set(`${PROXY_CONFIG.CACHE_STATS_KEY}:${source.id}`, source, PROXY_CONFIG.CACHE_TTL);
|
||||
return source;
|
||||
|
|
@ -92,18 +189,18 @@ async function updateProxyStats(sourceId: string, success: boolean) {
|
|||
|
||||
// make a function that resets proxyStats
|
||||
async function resetProxyStats(): Promise<void> {
|
||||
proxyStats = PROXY_CONFIG.PROXY_SOURCES.map(source => ({
|
||||
id: source.id,
|
||||
total: 0,
|
||||
working: 0,
|
||||
lastChecked: new Date(),
|
||||
protocol: source.protocol,
|
||||
url: source.url,
|
||||
}));
|
||||
for (const source of proxyStats) {
|
||||
await cache.set(`${PROXY_CONFIG.CACHE_STATS_KEY}:${source.id}`, source, PROXY_CONFIG.CACHE_TTL);
|
||||
}
|
||||
return Promise.resolve();
|
||||
proxyStats = PROXY_CONFIG.PROXY_SOURCES.map(source => ({
|
||||
id: source.id,
|
||||
total: 0,
|
||||
working: 0,
|
||||
lastChecked: new Date(),
|
||||
protocol: source.protocol,
|
||||
url: source.url,
|
||||
}));
|
||||
for (const source of proxyStats) {
|
||||
await cache.set(`${PROXY_CONFIG.CACHE_STATS_KEY}:${source.id}`, source, PROXY_CONFIG.CACHE_TTL);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -120,7 +217,7 @@ async function updateProxyInCache(proxy: ProxyInfo, isWorking: boolean): Promise
|
|||
// For failed proxies, only update if they already exist
|
||||
if (!isWorking && !existing) {
|
||||
logger.debug('Proxy not in cache, skipping failed update', {
|
||||
proxy: `${proxy.host}:${proxy.port}`
|
||||
proxy: `${proxy.host}:${proxy.port}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -132,9 +229,10 @@ async function updateProxyInCache(proxy: ProxyInfo, isWorking: boolean): Promise
|
|||
const existingTotal = existing?.total || 0;
|
||||
|
||||
// Calculate weighted average: (existing_avg * existing_count + new_response) / (existing_count + 1)
|
||||
newAverageResponseTime = existingTotal > 0
|
||||
? ((existingAvg * existingTotal) + proxy.responseTime) / (existingTotal + 1)
|
||||
: proxy.responseTime;
|
||||
newAverageResponseTime =
|
||||
existingTotal > 0
|
||||
? (existingAvg * existingTotal + proxy.responseTime) / (existingTotal + 1)
|
||||
: proxy.responseTime;
|
||||
}
|
||||
|
||||
// Build updated proxy data
|
||||
|
|
@ -142,13 +240,15 @@ async function updateProxyInCache(proxy: ProxyInfo, isWorking: boolean): Promise
|
|||
...existing,
|
||||
...proxy, // Keep latest proxy info
|
||||
total: (existing?.total || 0) + 1,
|
||||
working: isWorking ? (existing?.working || 0) + 1 : (existing?.working || 0),
|
||||
working: isWorking ? (existing?.working || 0) + 1 : existing?.working || 0,
|
||||
isWorking,
|
||||
lastChecked: new Date(),
|
||||
// Add firstSeen only for new entries
|
||||
...(existing ? {} : { firstSeen: new Date() }),
|
||||
// Update average response time if we calculated a new one
|
||||
...(newAverageResponseTime !== undefined ? { averageResponseTime: newAverageResponseTime } : {})
|
||||
...(newAverageResponseTime !== undefined
|
||||
? { averageResponseTime: newAverageResponseTime }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Calculate success rate
|
||||
|
|
@ -163,13 +263,14 @@ async function updateProxyInCache(proxy: ProxyInfo, isWorking: boolean): Promise
|
|||
working: updated.working,
|
||||
total: updated.total,
|
||||
successRate: updated.successRate.toFixed(1) + '%',
|
||||
avgResponseTime: updated.averageResponseTime ? `${updated.averageResponseTime.toFixed(0)}ms` : 'N/A'
|
||||
avgResponseTime: updated.averageResponseTime
|
||||
? `${updated.averageResponseTime.toFixed(0)}ms`
|
||||
: 'N/A',
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to update proxy in cache', {
|
||||
proxy: `${proxy.host}:${proxy.port}`,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -183,7 +284,7 @@ export async function initializeProxyCache(): Promise<void> {
|
|||
cache = createCache({
|
||||
keyPrefix: 'proxy:',
|
||||
ttl: PROXY_CONFIG.CACHE_TTL,
|
||||
enableMetrics: true
|
||||
enableMetrics: true,
|
||||
});
|
||||
|
||||
logger.info('Initializing proxy cache...');
|
||||
|
|
@ -204,7 +305,7 @@ async function initializeSharedResources() {
|
|||
cache = createCache({
|
||||
keyPrefix: 'proxy:',
|
||||
ttl: PROXY_CONFIG.CACHE_TTL,
|
||||
enableMetrics: true
|
||||
enableMetrics: true,
|
||||
});
|
||||
|
||||
httpClient = new HttpClient({ timeout: 10000 }, logger);
|
||||
|
|
@ -224,7 +325,7 @@ export async function queueProxyFetch(): Promise<string> {
|
|||
provider: 'proxy-service',
|
||||
operation: 'fetch-and-check',
|
||||
payload: {},
|
||||
priority: 5
|
||||
priority: 5,
|
||||
});
|
||||
|
||||
const jobId = job.id || 'unknown';
|
||||
|
|
@ -241,7 +342,7 @@ export async function queueProxyCheck(proxies: ProxyInfo[]): Promise<string> {
|
|||
provider: 'proxy-service',
|
||||
operation: 'check-specific',
|
||||
payload: { proxies },
|
||||
priority: 3
|
||||
priority: 3,
|
||||
});
|
||||
|
||||
const jobId = job.id || 'unknown';
|
||||
|
|
@ -285,7 +386,7 @@ export async function fetchProxiesFromSource(source: ProxySource): Promise<Proxy
|
|||
logger.info(`Fetching proxies from ${source.url}`);
|
||||
|
||||
const response = await httpClient.get(source.url, {
|
||||
timeout: 10000
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
|
|
@ -308,7 +409,7 @@ export async function fetchProxiesFromSource(source: ProxySource): Promise<Proxy
|
|||
source: source.id,
|
||||
protocol: source.protocol as 'http' | 'https' | 'socks4' | 'socks5',
|
||||
host: parts[0],
|
||||
port: parseInt(parts[1])
|
||||
port: parseInt(parts[1]),
|
||||
};
|
||||
|
||||
if (!isNaN(proxy.port) && proxy.host) {
|
||||
|
|
@ -318,7 +419,6 @@ export async function fetchProxiesFromSource(source: ProxySource): Promise<Proxy
|
|||
}
|
||||
|
||||
logger.info(`Parsed ${allProxies.length} proxies from ${source.url}`);
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching proxies from ${source.url}`, error);
|
||||
return [];
|
||||
|
|
@ -344,7 +444,7 @@ export async function checkProxy(proxy: ProxyInfo): Promise<ProxyInfo> {
|
|||
// Test the proxy
|
||||
const response = await httpClient.get(PROXY_CONFIG.CHECK_URL, {
|
||||
proxy,
|
||||
timeout: PROXY_CONFIG.CHECK_TIMEOUT
|
||||
timeout: PROXY_CONFIG.CHECK_TIMEOUT,
|
||||
});
|
||||
|
||||
const isWorking = response.status >= 200 && response.status < 300;
|
||||
|
|
@ -362,7 +462,7 @@ export async function checkProxy(proxy: ProxyInfo): Promise<ProxyInfo> {
|
|||
await updateProxyInCache(result, false);
|
||||
}
|
||||
|
||||
if( proxy.source ){
|
||||
if (proxy.source) {
|
||||
await updateProxyStats(proxy.source, success);
|
||||
}
|
||||
|
||||
|
|
@ -379,20 +479,20 @@ export async function checkProxy(proxy: ProxyInfo): Promise<ProxyInfo> {
|
|||
...proxy,
|
||||
isWorking: false,
|
||||
error: errorMessage,
|
||||
lastChecked: new Date()
|
||||
lastChecked: new Date(),
|
||||
};
|
||||
|
||||
// Update cache for failed proxy (increment total, don't update TTL)
|
||||
await updateProxyInCache(result, false);
|
||||
|
||||
if( proxy.source ){
|
||||
if (proxy.source) {
|
||||
await updateProxyStats(proxy.source, success);
|
||||
}
|
||||
|
||||
logger.debug('Proxy check failed', {
|
||||
host: proxy.host,
|
||||
port: proxy.port,
|
||||
error: errorMessage
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { ProviderConfig } from '../services/provider-registry.service';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { ProviderConfig } from '../services/provider-registry.service';
|
||||
|
||||
const logger = getLogger('quotemedia-provider');
|
||||
|
||||
export const quotemediaProvider: ProviderConfig = {
|
||||
name: 'quotemedia',
|
||||
operations: { 'live-data': async (payload: { symbol: string; fields?: string[] }) => {
|
||||
operations: {
|
||||
'live-data': async (payload: { symbol: string; fields?: string[] }) => {
|
||||
logger.info('Fetching live data from QuoteMedia', { symbol: payload.symbol });
|
||||
|
||||
// Simulate QuoteMedia API call
|
||||
|
|
@ -17,7 +18,7 @@ export const quotemediaProvider: ProviderConfig = {
|
|||
changePercent: (Math.random() - 0.5) * 5,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'quotemedia',
|
||||
fields: payload.fields || ['price', 'volume', 'change']
|
||||
fields: payload.fields || ['price', 'volume', 'change'],
|
||||
};
|
||||
|
||||
// Simulate network delay
|
||||
|
|
@ -31,16 +32,19 @@ export const quotemediaProvider: ProviderConfig = {
|
|||
from: Date;
|
||||
to: Date;
|
||||
interval?: string;
|
||||
fields?: string[]; }) => {
|
||||
fields?: string[];
|
||||
}) => {
|
||||
logger.info('Fetching historical data from QuoteMedia', {
|
||||
symbol: payload.symbol,
|
||||
from: payload.from,
|
||||
to: payload.to,
|
||||
interval: payload.interval || '1d'
|
||||
interval: payload.interval || '1d',
|
||||
});
|
||||
|
||||
// Generate mock historical data
|
||||
const days = Math.ceil((payload.to.getTime() - payload.from.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const days = Math.ceil(
|
||||
(payload.to.getTime() - payload.from.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const data = [];
|
||||
|
||||
for (let i = 0; i < Math.min(days, 100); i++) {
|
||||
|
|
@ -52,7 +56,7 @@ export const quotemediaProvider: ProviderConfig = {
|
|||
low: Math.random() * 1000 + 100,
|
||||
close: Math.random() * 1000 + 100,
|
||||
volume: Math.floor(Math.random() * 1000000),
|
||||
source: 'quotemedia'
|
||||
source: 'quotemedia',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -64,13 +68,13 @@ export const quotemediaProvider: ProviderConfig = {
|
|||
interval: payload.interval || '1d',
|
||||
data,
|
||||
source: 'quotemedia',
|
||||
totalRecords: data.length
|
||||
totalRecords: data.length,
|
||||
};
|
||||
},
|
||||
'batch-quotes': async (payload: { symbols: string[]; fields?: string[] }) => {
|
||||
'batch-quotes': async (payload: { symbols: string[]; fields?: string[] }) => {
|
||||
logger.info('Fetching batch quotes from QuoteMedia', {
|
||||
symbols: payload.symbols,
|
||||
count: payload.symbols.length
|
||||
count: payload.symbols.length,
|
||||
});
|
||||
|
||||
const quotes = payload.symbols.map(symbol => ({
|
||||
|
|
@ -79,7 +83,7 @@ export const quotemediaProvider: ProviderConfig = {
|
|||
volume: Math.floor(Math.random() * 1000000),
|
||||
change: (Math.random() - 0.5) * 20,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'quotemedia'
|
||||
source: 'quotemedia',
|
||||
}));
|
||||
|
||||
// Simulate network delay
|
||||
|
|
@ -89,9 +93,10 @@ export const quotemediaProvider: ProviderConfig = {
|
|||
quotes,
|
||||
source: 'quotemedia',
|
||||
timestamp: new Date().toISOString(),
|
||||
totalSymbols: payload.symbols.length
|
||||
totalSymbols: payload.symbols.length,
|
||||
};
|
||||
}, 'company-profile': async (payload: { symbol: string }) => {
|
||||
},
|
||||
'company-profile': async (payload: { symbol: string }) => {
|
||||
logger.info('Fetching company profile from QuoteMedia', { symbol: payload.symbol });
|
||||
|
||||
// Simulate company profile data
|
||||
|
|
@ -104,16 +109,17 @@ export const quotemediaProvider: ProviderConfig = {
|
|||
marketCap: Math.floor(Math.random() * 1000000000000),
|
||||
employees: Math.floor(Math.random() * 100000),
|
||||
website: `https://www.${payload.symbol.toLowerCase()}.com`,
|
||||
source: 'quotemedia'
|
||||
source: 'quotemedia',
|
||||
};
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 150 + Math.random() * 100));
|
||||
|
||||
return profile;
|
||||
}, 'options-chain': async (payload: { symbol: string; expiration?: string }) => {
|
||||
},
|
||||
'options-chain': async (payload: { symbol: string; expiration?: string }) => {
|
||||
logger.info('Fetching options chain from QuoteMedia', {
|
||||
symbol: payload.symbol,
|
||||
expiration: payload.expiration
|
||||
expiration: payload.expiration,
|
||||
});
|
||||
|
||||
// Generate mock options data
|
||||
|
|
@ -123,7 +129,7 @@ export const quotemediaProvider: ProviderConfig = {
|
|||
bid: Math.random() * 10,
|
||||
ask: Math.random() * 10 + 0.5,
|
||||
volume: Math.floor(Math.random() * 1000),
|
||||
openInterest: Math.floor(Math.random() * 5000)
|
||||
openInterest: Math.floor(Math.random() * 5000),
|
||||
}));
|
||||
|
||||
const puts = strikes.map(strike => ({
|
||||
|
|
@ -131,18 +137,20 @@ export const quotemediaProvider: ProviderConfig = {
|
|||
bid: Math.random() * 10,
|
||||
ask: Math.random() * 10 + 0.5,
|
||||
volume: Math.floor(Math.random() * 1000),
|
||||
openInterest: Math.floor(Math.random() * 5000)
|
||||
openInterest: Math.floor(Math.random() * 5000),
|
||||
}));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 400 + Math.random() * 300));
|
||||
return {
|
||||
return {
|
||||
symbol: payload.symbol,
|
||||
expiration: payload.expiration || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
expiration:
|
||||
payload.expiration ||
|
||||
new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
calls,
|
||||
puts,
|
||||
source: 'quotemedia'
|
||||
source: 'quotemedia',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
scheduledJobs: [
|
||||
|
|
@ -170,5 +178,5 @@ export const quotemediaProvider: ProviderConfig = {
|
|||
// priority: 3,
|
||||
// description: 'Update company profile data'
|
||||
// }
|
||||
]
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ProviderConfig } from '../services/provider-registry.service';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { ProviderConfig } from '../services/provider-registry.service';
|
||||
|
||||
const logger = getLogger('yahoo-provider');
|
||||
|
||||
|
|
@ -7,8 +7,6 @@ export const yahooProvider: ProviderConfig = {
|
|||
name: 'yahoo-finance',
|
||||
operations: {
|
||||
'live-data': async (payload: { symbol: string; modules?: string[] }) => {
|
||||
|
||||
|
||||
logger.info('Fetching live data from Yahoo Finance', { symbol: payload.symbol });
|
||||
|
||||
// Simulate Yahoo Finance API call
|
||||
|
|
@ -27,7 +25,7 @@ export const yahooProvider: ProviderConfig = {
|
|||
fiftyTwoWeekLow: Math.random() * 800 + 50,
|
||||
timestamp: Date.now() / 1000,
|
||||
source: 'yahoo-finance',
|
||||
modules: payload.modules || ['price', 'summaryDetail']
|
||||
modules: payload.modules || ['price', 'summaryDetail'],
|
||||
};
|
||||
|
||||
// Simulate network delay
|
||||
|
|
@ -41,7 +39,8 @@ export const yahooProvider: ProviderConfig = {
|
|||
period1: number;
|
||||
period2: number;
|
||||
interval?: string;
|
||||
events?: string; }) => {
|
||||
events?: string;
|
||||
}) => {
|
||||
const { getLogger } = await import('@stock-bot/logger');
|
||||
const logger = getLogger('yahoo-provider');
|
||||
|
||||
|
|
@ -49,7 +48,7 @@ export const yahooProvider: ProviderConfig = {
|
|||
symbol: payload.symbol,
|
||||
period1: payload.period1,
|
||||
period2: payload.period2,
|
||||
interval: payload.interval || '1d'
|
||||
interval: payload.interval || '1d',
|
||||
});
|
||||
|
||||
// Generate mock historical data
|
||||
|
|
@ -67,7 +66,7 @@ export const yahooProvider: ProviderConfig = {
|
|||
close: Math.random() * 1000 + 100,
|
||||
adjClose: Math.random() * 1000 + 100,
|
||||
volume: Math.floor(Math.random() * 1000000),
|
||||
source: 'yahoo-finance'
|
||||
source: 'yahoo-finance',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -79,22 +78,26 @@ export const yahooProvider: ProviderConfig = {
|
|||
interval: payload.interval || '1d',
|
||||
timestamps: data.map(d => d.timestamp),
|
||||
indicators: {
|
||||
quote: [{
|
||||
open: data.map(d => d.open),
|
||||
high: data.map(d => d.high),
|
||||
low: data.map(d => d.low),
|
||||
close: data.map(d => d.close),
|
||||
volume: data.map(d => d.volume)
|
||||
}],
|
||||
adjclose: [{
|
||||
adjclose: data.map(d => d.adjClose)
|
||||
}]
|
||||
quote: [
|
||||
{
|
||||
open: data.map(d => d.open),
|
||||
high: data.map(d => d.high),
|
||||
low: data.map(d => d.low),
|
||||
close: data.map(d => d.close),
|
||||
volume: data.map(d => d.volume),
|
||||
},
|
||||
],
|
||||
adjclose: [
|
||||
{
|
||||
adjclose: data.map(d => d.adjClose),
|
||||
},
|
||||
],
|
||||
},
|
||||
source: 'yahoo-finance',
|
||||
totalRecords: data.length
|
||||
totalRecords: data.length,
|
||||
};
|
||||
},
|
||||
'search': async (payload: { query: string; quotesCount?: number; newsCount?: number }) => {
|
||||
search: async (payload: { query: string; quotesCount?: number; newsCount?: number }) => {
|
||||
const { getLogger } = await import('@stock-bot/logger');
|
||||
const logger = getLogger('yahoo-provider');
|
||||
|
||||
|
|
@ -107,7 +110,7 @@ export const yahooProvider: ProviderConfig = {
|
|||
longname: `${payload.query} Corporation ${i}`,
|
||||
exchDisp: 'NASDAQ',
|
||||
typeDisp: 'Equity',
|
||||
source: 'yahoo-finance'
|
||||
source: 'yahoo-finance',
|
||||
}));
|
||||
|
||||
const news = Array.from({ length: payload.newsCount || 3 }, (_, i) => ({
|
||||
|
|
@ -116,7 +119,7 @@ export const yahooProvider: ProviderConfig = {
|
|||
publisher: 'Financial News',
|
||||
providerPublishTime: Date.now() - i * 3600000,
|
||||
type: 'STORY',
|
||||
source: 'yahoo-finance'
|
||||
source: 'yahoo-finance',
|
||||
}));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200 + Math.random() * 200));
|
||||
|
|
@ -126,15 +129,16 @@ export const yahooProvider: ProviderConfig = {
|
|||
news,
|
||||
totalQuotes: quotes.length,
|
||||
totalNews: news.length,
|
||||
source: 'yahoo-finance'
|
||||
source: 'yahoo-finance',
|
||||
};
|
||||
}, 'financials': async (payload: { symbol: string; type?: 'income' | 'balance' | 'cash' }) => {
|
||||
},
|
||||
financials: async (payload: { symbol: string; type?: 'income' | 'balance' | 'cash' }) => {
|
||||
const { getLogger } = await import('@stock-bot/logger');
|
||||
const logger = getLogger('yahoo-provider');
|
||||
|
||||
logger.info('Fetching financials from Yahoo Finance', {
|
||||
symbol: payload.symbol,
|
||||
type: payload.type || 'income'
|
||||
type: payload.type || 'income',
|
||||
});
|
||||
|
||||
// Generate mock financial data
|
||||
|
|
@ -147,26 +151,27 @@ export const yahooProvider: ProviderConfig = {
|
|||
revenue: Math.floor(Math.random() * 100000000000),
|
||||
netIncome: Math.floor(Math.random() * 10000000000),
|
||||
totalAssets: Math.floor(Math.random() * 500000000000),
|
||||
totalDebt: Math.floor(Math.random() * 50000000000)
|
||||
totalDebt: Math.floor(Math.random() * 50000000000),
|
||||
})),
|
||||
quarterly: Array.from({ length: 4 }, (_, i) => ({
|
||||
fiscalQuarter: `Q${4-i} 2024`,
|
||||
fiscalQuarter: `Q${4 - i} 2024`,
|
||||
revenue: Math.floor(Math.random() * 25000000000),
|
||||
netIncome: Math.floor(Math.random() * 2500000000)
|
||||
netIncome: Math.floor(Math.random() * 2500000000),
|
||||
})),
|
||||
source: 'yahoo-finance'
|
||||
source: 'yahoo-finance',
|
||||
};
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 300 + Math.random() * 200));
|
||||
|
||||
return financials;
|
||||
}, 'earnings': async (payload: { symbol: string; period?: 'annual' | 'quarterly' }) => {
|
||||
},
|
||||
earnings: async (payload: { symbol: string; period?: 'annual' | 'quarterly' }) => {
|
||||
const { getLogger } = await import('@stock-bot/logger');
|
||||
const logger = getLogger('yahoo-provider');
|
||||
|
||||
logger.info('Fetching earnings from Yahoo Finance', {
|
||||
symbol: payload.symbol,
|
||||
period: payload.period || 'quarterly'
|
||||
period: payload.period || 'quarterly',
|
||||
});
|
||||
|
||||
// Generate mock earnings data
|
||||
|
|
@ -174,20 +179,21 @@ export const yahooProvider: ProviderConfig = {
|
|||
symbol: payload.symbol,
|
||||
period: payload.period || 'quarterly',
|
||||
earnings: Array.from({ length: 8 }, (_, i) => ({
|
||||
quarter: `Q${(i % 4) + 1} ${2024 - Math.floor(i/4)}`,
|
||||
quarter: `Q${(i % 4) + 1} ${2024 - Math.floor(i / 4)}`,
|
||||
epsEstimate: Math.random() * 5,
|
||||
epsActual: Math.random() * 5,
|
||||
revenueEstimate: Math.floor(Math.random() * 50000000000),
|
||||
revenueActual: Math.floor(Math.random() * 50000000000),
|
||||
surprise: (Math.random() - 0.5) * 2
|
||||
surprise: (Math.random() - 0.5) * 2,
|
||||
})),
|
||||
source: 'yahoo-finance'
|
||||
source: 'yahoo-finance',
|
||||
};
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 250 + Math.random() * 150));
|
||||
|
||||
return earnings;
|
||||
}, 'recommendations': async (payload: { symbol: string }) => {
|
||||
},
|
||||
recommendations: async (payload: { symbol: string }) => {
|
||||
const { getLogger } = await import('@stock-bot/logger');
|
||||
const logger = getLogger('yahoo-provider');
|
||||
|
||||
|
|
@ -201,7 +207,7 @@ export const yahooProvider: ProviderConfig = {
|
|||
buy: Math.floor(Math.random() * 15),
|
||||
hold: Math.floor(Math.random() * 20),
|
||||
sell: Math.floor(Math.random() * 5),
|
||||
strongSell: Math.floor(Math.random() * 3)
|
||||
strongSell: Math.floor(Math.random() * 3),
|
||||
},
|
||||
trend: Array.from({ length: 4 }, (_, i) => ({
|
||||
period: `${i}m`,
|
||||
|
|
@ -209,14 +215,14 @@ export const yahooProvider: ProviderConfig = {
|
|||
buy: Math.floor(Math.random() * 15),
|
||||
hold: Math.floor(Math.random() * 20),
|
||||
sell: Math.floor(Math.random() * 5),
|
||||
strongSell: Math.floor(Math.random() * 3)
|
||||
strongSell: Math.floor(Math.random() * 3),
|
||||
})),
|
||||
source: 'yahoo-finance'
|
||||
source: 'yahoo-finance',
|
||||
};
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 180 + Math.random() * 120));
|
||||
return recommendations;
|
||||
}
|
||||
return recommendations;
|
||||
},
|
||||
},
|
||||
|
||||
scheduledJobs: [
|
||||
|
|
@ -244,5 +250,5 @@ export const yahooProvider: ProviderConfig = {
|
|||
// priority: 6,
|
||||
// description: 'Check earnings data for Apple'
|
||||
// }
|
||||
]
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ import { queueManager } from '../services/queue.service';
|
|||
export const healthRoutes = new Hono();
|
||||
|
||||
// Health check endpoint
|
||||
healthRoutes.get('/health', (c) => {
|
||||
healthRoutes.get('/health', c => {
|
||||
return c.json({
|
||||
service: 'data-service',
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
queue: {
|
||||
status: 'running',
|
||||
workers: queueManager.getWorkerCount()
|
||||
}
|
||||
workers: queueManager.getWorkerCount(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const logger = getLogger('market-data-routes');
|
|||
export const marketDataRoutes = new Hono();
|
||||
|
||||
// Market data endpoints
|
||||
marketDataRoutes.get('/api/live/:symbol', async (c) => {
|
||||
marketDataRoutes.get('/api/live/:symbol', async c => {
|
||||
const symbol = c.req.param('symbol');
|
||||
logger.info('Live data request', { symbol });
|
||||
|
||||
|
|
@ -21,13 +21,13 @@ marketDataRoutes.get('/api/live/:symbol', async (c) => {
|
|||
service: 'market-data',
|
||||
provider: 'yahoo-finance',
|
||||
operation: 'live-data',
|
||||
payload: { symbol }
|
||||
payload: { symbol },
|
||||
});
|
||||
return c.json({
|
||||
status: 'success',
|
||||
message: 'Live data job queued',
|
||||
jobId: job.id,
|
||||
symbol
|
||||
symbol,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to queue live data job', { symbol, error });
|
||||
|
|
@ -35,7 +35,7 @@ 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 from = c.req.query('from');
|
||||
const to = c.req.query('to');
|
||||
|
|
@ -55,8 +55,8 @@ marketDataRoutes.get('/api/historical/:symbol', async (c) => {
|
|||
payload: {
|
||||
symbol,
|
||||
from: fromDate.toISOString(),
|
||||
to: toDate.toISOString()
|
||||
}
|
||||
to: toDate.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({
|
||||
|
|
@ -65,7 +65,7 @@ marketDataRoutes.get('/api/historical/:symbol', async (c) => {
|
|||
jobId: job.id,
|
||||
symbol,
|
||||
from: fromDate,
|
||||
to: toDate
|
||||
to: toDate,
|
||||
});
|
||||
} catch (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();
|
||||
|
||||
// Proxy management endpoints
|
||||
proxyRoutes.post('/api/proxy/fetch', async (c) => {
|
||||
proxyRoutes.post('/api/proxy/fetch', async c => {
|
||||
try {
|
||||
const job = await queueManager.addJob({
|
||||
type: 'proxy-fetch',
|
||||
provider: 'proxy-provider',
|
||||
operation: 'fetch-and-check',
|
||||
payload: {},
|
||||
priority: 5
|
||||
priority: 5,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
status: 'success',
|
||||
jobId: job.id,
|
||||
message: 'Proxy fetch job queued'
|
||||
message: 'Proxy fetch job queued',
|
||||
});
|
||||
} catch (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 {
|
||||
const { proxies } = await c.req.json();
|
||||
const job = await queueManager.addJob({
|
||||
|
|
@ -39,13 +39,13 @@ proxyRoutes.post('/api/proxy/check', async (c) => {
|
|||
provider: 'proxy-provider',
|
||||
operation: 'check-specific',
|
||||
payload: { proxies },
|
||||
priority: 8
|
||||
priority: 8,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
status: 'success',
|
||||
jobId: job.id,
|
||||
message: `Proxy check job queued for ${proxies.length} proxies`
|
||||
message: `Proxy check job queued for ${proxies.length} proxies`,
|
||||
});
|
||||
} catch (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
|
||||
proxyRoutes.get('/api/proxy/stats', async (c) => {
|
||||
proxyRoutes.get('/api/proxy/stats', async c => {
|
||||
try {
|
||||
const job = await queueManager.addJob({
|
||||
type: 'proxy-stats',
|
||||
provider: 'proxy-provider',
|
||||
operation: 'get-stats',
|
||||
payload: {},
|
||||
priority: 3
|
||||
priority: 3,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
status: 'success',
|
||||
jobId: job.id,
|
||||
message: 'Proxy stats job queued'
|
||||
message: 'Proxy stats job queued',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to queue proxy stats', { error });
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const logger = getLogger('queue-routes');
|
|||
export const queueRoutes = new Hono();
|
||||
|
||||
// Queue management endpoints
|
||||
queueRoutes.get('/api/queue/status', async (c) => {
|
||||
queueRoutes.get('/api/queue/status', async c => {
|
||||
try {
|
||||
const status = await queueManager.getQueueStatus();
|
||||
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 {
|
||||
const jobData = await c.req.json();
|
||||
const job = await queueManager.addJob(jobData);
|
||||
|
|
@ -32,7 +32,7 @@ queueRoutes.post('/api/queue/job', async (c) => {
|
|||
});
|
||||
|
||||
// Provider registry endpoints
|
||||
queueRoutes.get('/api/providers', async (c) => {
|
||||
queueRoutes.get('/api/providers', async c => {
|
||||
try {
|
||||
const { providerRegistry } = await import('../services/provider-registry.service');
|
||||
const providers = providerRegistry.getProviders();
|
||||
|
|
@ -44,14 +44,14 @@ queueRoutes.get('/api/providers', async (c) => {
|
|||
});
|
||||
|
||||
// Add new endpoint to see scheduled jobs
|
||||
queueRoutes.get('/api/scheduled-jobs', async (c) => {
|
||||
queueRoutes.get('/api/scheduled-jobs', async c => {
|
||||
try {
|
||||
const { providerRegistry } = await import('../services/provider-registry.service');
|
||||
const jobs = providerRegistry.getAllScheduledJobs();
|
||||
return c.json({
|
||||
status: 'success',
|
||||
count: jobs.length,
|
||||
jobs
|
||||
jobs,
|
||||
});
|
||||
} catch (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 {
|
||||
await queueManager.drainQueue();
|
||||
const status = await queueManager.getQueueStatus();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const logger = getLogger('test-routes');
|
|||
export const testRoutes = new Hono();
|
||||
|
||||
// Test endpoint for new functional batch processing
|
||||
testRoutes.post('/api/test/batch-symbols', async (c) => {
|
||||
testRoutes.post('/api/test/batch-symbols', async c => {
|
||||
try {
|
||||
const { symbols, useBatching = false, totalDelayHours = 1 } = await c.req.json();
|
||||
const { processItems } = await import('../utils/batch-helpers');
|
||||
|
|
@ -24,7 +24,7 @@ testRoutes.post('/api/test/batch-symbols', async (c) => {
|
|||
(symbol, index) => ({
|
||||
symbol,
|
||||
index,
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
queueManager,
|
||||
{
|
||||
|
|
@ -33,14 +33,14 @@ testRoutes.post('/api/test/batch-symbols', async (c) => {
|
|||
batchSize: 10,
|
||||
priority: 1,
|
||||
provider: 'test-provider',
|
||||
operation: 'live-data'
|
||||
operation: 'live-data',
|
||||
}
|
||||
);
|
||||
|
||||
return c.json({
|
||||
status: 'success',
|
||||
message: 'Batch processing started',
|
||||
result
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start batch symbol processing', { error });
|
||||
|
|
@ -48,7 +48,7 @@ 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 {
|
||||
const { items, useBatching = false, totalDelayHours = 0.5 } = await c.req.json();
|
||||
const { processItems } = await import('../utils/batch-helpers');
|
||||
|
|
@ -62,7 +62,7 @@ testRoutes.post('/api/test/batch-custom', async (c) => {
|
|||
(item, index) => ({
|
||||
originalItem: item,
|
||||
processIndex: index,
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
queueManager,
|
||||
{
|
||||
|
|
@ -71,14 +71,14 @@ testRoutes.post('/api/test/batch-custom', async (c) => {
|
|||
batchSize: 5,
|
||||
priority: 1,
|
||||
provider: 'test-provider',
|
||||
operation: 'custom-test'
|
||||
operation: 'custom-test',
|
||||
}
|
||||
);
|
||||
|
||||
return c.json({
|
||||
status: 'success',
|
||||
message: 'Custom batch processing started',
|
||||
result
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start custom batch processing', { error });
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export function createProviderRegistry(): ProviderRegistry {
|
|||
providers.set(config.name, config);
|
||||
logger.info(`Registered provider: ${config.name}`, {
|
||||
operations: Object.keys(config.operations),
|
||||
scheduledJobs: config.scheduledJobs?.length || 0
|
||||
scheduledJobs: config.scheduledJobs?.length || 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ export function createProviderRegistry(): ProviderRegistry {
|
|||
for (const job of config.scheduledJobs) {
|
||||
allJobs.push({
|
||||
provider: config.name,
|
||||
job
|
||||
job,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -102,7 +102,7 @@ export function createProviderRegistry(): ProviderRegistry {
|
|||
function getProviders(): Array<{ key: string; config: ProviderConfig }> {
|
||||
return Array.from(providers.entries()).map(([key, config]) => ({
|
||||
key,
|
||||
config
|
||||
config,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +127,7 @@ export function createProviderRegistry(): ProviderRegistry {
|
|||
getAllScheduledJobs,
|
||||
getProviders,
|
||||
hasProvider,
|
||||
clear
|
||||
clear,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Queue, Worker, QueueEvents, type Job } from 'bullmq';
|
||||
import { Queue, QueueEvents, Worker, type Job } from 'bullmq';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { providerRegistry, type JobData } from './provider-registry.service';
|
||||
|
||||
|
|
@ -13,8 +13,8 @@ export class QueueService {
|
|||
concurrency: parseInt(process.env.WORKER_CONCURRENCY || '20'),
|
||||
redis: {
|
||||
host: process.env.DRAGONFLY_HOST || 'localhost',
|
||||
port: parseInt(process.env.DRAGONFLY_PORT || '6379')
|
||||
}
|
||||
port: parseInt(process.env.DRAGONFLY_PORT || '6379'),
|
||||
},
|
||||
};
|
||||
|
||||
private get isInitialized() {
|
||||
|
|
@ -23,7 +23,8 @@ export class QueueService {
|
|||
|
||||
constructor() {
|
||||
// Don't initialize in constructor to allow for proper async initialization
|
||||
} async initialize() {
|
||||
}
|
||||
async initialize() {
|
||||
if (this.isInitialized) {
|
||||
this.logger.warn('Queue service already initialized');
|
||||
return;
|
||||
|
|
@ -38,15 +39,14 @@ export class QueueService {
|
|||
// Step 2: Setup queue and workers
|
||||
const connection = this.getConnection();
|
||||
const queueName = '{data-service-queue}';
|
||||
|
||||
this.queue = new Queue(queueName, {
|
||||
connection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 5,
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 1000 }
|
||||
}
|
||||
backoff: { type: 'exponential', delay: 1000 },
|
||||
},
|
||||
});
|
||||
|
||||
this.queueEvents = new QueueEvents(queueName, { connection });
|
||||
|
|
@ -58,7 +58,7 @@ export class QueueService {
|
|||
await Promise.all([
|
||||
this.queue.waitUntilReady(),
|
||||
this.queueEvents.waitUntilReady(),
|
||||
...this.workers.map(worker => worker.waitUntilReady())
|
||||
...this.workers.map(worker => worker.waitUntilReady()),
|
||||
]);
|
||||
|
||||
// Step 5: Setup events and scheduled tasks
|
||||
|
|
@ -67,19 +67,19 @@ export class QueueService {
|
|||
|
||||
this.logger.info('Queue service initialized successfully', {
|
||||
workers: workerCount,
|
||||
totalConcurrency
|
||||
totalConcurrency,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize queue service', { error });
|
||||
throw error;
|
||||
}
|
||||
} private getConnection() {
|
||||
}
|
||||
private getConnection() {
|
||||
return {
|
||||
...this.config.redis,
|
||||
maxRetriesPerRequest: null,
|
||||
retryDelayOnFailover: 100,
|
||||
lazyConnect: false
|
||||
lazyConnect: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -94,31 +94,57 @@ export class QueueService {
|
|||
|
||||
// Setup events inline
|
||||
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);
|
||||
}
|
||||
|
||||
return {
|
||||
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
|
||||
this.queueEvents.on('failed', (job, error) => {
|
||||
this.logger.error('Job failed', {
|
||||
}
|
||||
private setupQueueEvents() {
|
||||
// Add comprehensive logging to see job flow
|
||||
this.queueEvents.on('added', job => {
|
||||
this.logger.debug('Job added to queue', {
|
||||
id: job.jobId,
|
||||
error: String(error)
|
||||
});
|
||||
});
|
||||
|
||||
// Only log completions in debug mode
|
||||
if (process.env.LOG_LEVEL === 'debug') {
|
||||
this.queueEvents.on('completed', (job) => {
|
||||
this.logger.debug('Job completed', { id: job.jobId });
|
||||
this.queueEvents.on('waiting', job => {
|
||||
this.logger.debug('Job moved to waiting', {
|
||||
id: job.jobId,
|
||||
});
|
||||
}
|
||||
}private async registerProviders() {
|
||||
});
|
||||
|
||||
this.queueEvents.on('active', job => {
|
||||
this.logger.debug('Job became active', {
|
||||
id: job.jobId,
|
||||
});
|
||||
});
|
||||
|
||||
this.queueEvents.on('delayed', job => {
|
||||
this.logger.debug('Job delayed', {
|
||||
id: job.jobId,
|
||||
delay: job.delay,
|
||||
});
|
||||
});
|
||||
|
||||
this.queueEvents.on('completed', job => {
|
||||
this.logger.debug('Job completed', {
|
||||
id: job.jobId,
|
||||
});
|
||||
});
|
||||
|
||||
this.queueEvents.on('failed', (job, error) => {
|
||||
this.logger.error('Job failed', {
|
||||
id: job.jobId,
|
||||
error: String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
private async registerProviders() {
|
||||
this.logger.info('Registering providers...');
|
||||
|
||||
try {
|
||||
|
|
@ -126,7 +152,7 @@ export class QueueService {
|
|||
const providers = [
|
||||
{ module: '../providers/proxy.provider', export: 'proxyProvider' },
|
||||
{ module: '../providers/quotemedia.provider', export: 'quotemediaProvider' },
|
||||
{ module: '../providers/yahoo.provider', export: 'yahooProvider' }
|
||||
{ module: '../providers/yahoo.provider', export: 'yahooProvider' },
|
||||
];
|
||||
|
||||
// Import and register all providers
|
||||
|
|
@ -140,15 +166,17 @@ export class QueueService {
|
|||
this.logger.error('Failed to register providers', { error });
|
||||
throw error;
|
||||
}
|
||||
}private async processJob(job: Job) {
|
||||
}
|
||||
private async processJob(job: Job) {
|
||||
const { provider, operation, payload }: JobData = job.data;
|
||||
|
||||
this.logger.info('Processing job', {
|
||||
id: job.id,
|
||||
provider,
|
||||
operation,
|
||||
payloadKeys: Object.keys(payload || {})
|
||||
}); try {
|
||||
payloadKeys: Object.keys(payload || {}),
|
||||
});
|
||||
try {
|
||||
let result;
|
||||
|
||||
if (operation === 'process-batch-items') {
|
||||
|
|
@ -169,28 +197,30 @@ export class QueueService {
|
|||
this.logger.info('Job completed successfully', {
|
||||
id: job.id,
|
||||
provider,
|
||||
operation
|
||||
operation,
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error('Job failed', {
|
||||
id: job.id,
|
||||
provider,
|
||||
operation,
|
||||
error: errorMessage
|
||||
error: errorMessage,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
} async addBulk(jobs: any[]): Promise<any[]> {
|
||||
}
|
||||
|
||||
async addBulk(jobs: any[]): Promise<any[]> {
|
||||
return await this.queue.addBulk(jobs);
|
||||
}
|
||||
|
||||
private getTotalConcurrency() {
|
||||
return this.workers.reduce((total, worker) => total + (worker.opts.concurrency || 1), 0);
|
||||
}
|
||||
|
||||
private async setupScheduledTasks() {
|
||||
const allScheduledJobs = providerRegistry.getAllScheduledJobs();
|
||||
|
||||
|
|
@ -204,14 +234,17 @@ export class QueueService {
|
|||
// Use Promise.allSettled for parallel processing + better error handling
|
||||
const results = await Promise.allSettled(
|
||||
allScheduledJobs.map(async ({ provider, job }) => {
|
||||
await this.addRecurringJob({
|
||||
type: job.type,
|
||||
provider,
|
||||
operation: job.operation,
|
||||
payload: job.payload,
|
||||
priority: job.priority,
|
||||
immediately: job.immediately || false
|
||||
}, job.cronPattern);
|
||||
await this.addRecurringJob(
|
||||
{
|
||||
type: job.type,
|
||||
provider,
|
||||
operation: job.operation,
|
||||
payload: job.payload,
|
||||
priority: job.priority,
|
||||
immediately: job.immediately || false,
|
||||
},
|
||||
job.cronPattern
|
||||
);
|
||||
|
||||
return { provider, operation: job.operation };
|
||||
})
|
||||
|
|
@ -227,16 +260,17 @@ export class QueueService {
|
|||
this.logger.error('Failed to register scheduled job', {
|
||||
provider,
|
||||
operation: job.operation,
|
||||
error: result.reason
|
||||
error: result.reason,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.info('Scheduled tasks setup complete', {
|
||||
successful: successful.length,
|
||||
failed: failed.length
|
||||
failed: failed.length,
|
||||
});
|
||||
} private async addJobInternal(jobData: JobData, options: any = {}) {
|
||||
}
|
||||
private async addJobInternal(jobData: JobData, options: any = {}) {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('Queue service not initialized');
|
||||
}
|
||||
|
|
@ -246,13 +280,15 @@ export class QueueService {
|
|||
priority: jobData.priority || 0,
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 5,
|
||||
...options
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async addJob(jobData: JobData, options?: any) {
|
||||
return this.addJobInternal(jobData, options);
|
||||
} async addRecurringJob(jobData: JobData, cronPattern: string, options?: any) {
|
||||
}
|
||||
|
||||
async addRecurringJob(jobData: JobData, cronPattern: string, options?: any) {
|
||||
const jobKey = `recurring-${jobData.provider}-${jobData.operation}`;
|
||||
|
||||
return this.addJobInternal(jobData, {
|
||||
|
|
@ -267,9 +303,9 @@ export class QueueService {
|
|||
attempts: 2,
|
||||
backoff: {
|
||||
type: 'fixed',
|
||||
delay: 5000
|
||||
delay: 5000,
|
||||
},
|
||||
...options
|
||||
...options,
|
||||
});
|
||||
}
|
||||
async getJobStats() {
|
||||
|
|
@ -281,7 +317,7 @@ export class QueueService {
|
|||
this.queue.getActive(),
|
||||
this.queue.getCompleted(),
|
||||
this.queue.getFailed(),
|
||||
this.queue.getDelayed()
|
||||
this.queue.getDelayed(),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
|
@ -289,7 +325,7 @@ export class QueueService {
|
|||
active: active.length,
|
||||
completed: completed.length,
|
||||
failed: failed.length,
|
||||
delayed: delayed.length
|
||||
delayed: delayed.length,
|
||||
};
|
||||
}
|
||||
async drainQueue() {
|
||||
|
|
@ -306,7 +342,7 @@ export class QueueService {
|
|||
return {
|
||||
...stats,
|
||||
workers: this.workers.length,
|
||||
concurrency: this.getTotalConcurrency()
|
||||
concurrency: this.getTotalConcurrency(),
|
||||
};
|
||||
}
|
||||
async shutdown() {
|
||||
|
|
@ -328,7 +364,7 @@ export class QueueService {
|
|||
worker.close(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Worker ${index + 1} close timeout`)), 5000)
|
||||
)
|
||||
),
|
||||
]);
|
||||
this.logger.debug(`Worker ${index + 1} closed successfully`);
|
||||
} catch (error) {
|
||||
|
|
@ -348,15 +384,15 @@ export class QueueService {
|
|||
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 }))
|
||||
),
|
||||
]).catch(error => this.logger.error('QueueEvents close error', { error })),
|
||||
]);
|
||||
|
||||
this.logger.info('Queue service shutdown completed successfully');
|
||||
|
|
@ -367,7 +403,7 @@ export class QueueService {
|
|||
await Promise.allSettled([
|
||||
...this.workers.map(worker => worker.close(true)),
|
||||
this.queue.close(),
|
||||
this.queueEvents.close()
|
||||
this.queueEvents.close(),
|
||||
]);
|
||||
} catch (forceCloseError) {
|
||||
this.logger.error('Force close also failed', { error: forceCloseError });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { CacheProvider, createCache } from '@stock-bot/cache';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { createCache, CacheProvider } from '@stock-bot/cache';
|
||||
import type { QueueService } from '../services/queue.service';
|
||||
|
||||
const logger = getLogger('batch-helpers');
|
||||
|
|
@ -35,7 +35,7 @@ function getCache(): CacheProvider {
|
|||
cacheProvider = createCache({
|
||||
keyPrefix: 'batch:',
|
||||
ttl: 86400, // 24 hours default
|
||||
enableMetrics: true
|
||||
enableMetrics: true,
|
||||
});
|
||||
}
|
||||
return cacheProvider;
|
||||
|
|
@ -68,7 +68,7 @@ export async function processItems<T>(
|
|||
jobsCreated: 0,
|
||||
mode: 'direct',
|
||||
totalItems: 0,
|
||||
duration: 0
|
||||
duration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ export async function processItems<T>(
|
|||
totalItems: items.length,
|
||||
mode: options.useBatching ? 'batch' : 'direct',
|
||||
batchSize: options.batchSize,
|
||||
totalDelayHours: options.totalDelayHours
|
||||
totalDelayHours: options.totalDelayHours,
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -88,11 +88,10 @@ export async function processItems<T>(
|
|||
|
||||
logger.info('Batch processing completed', {
|
||||
...result,
|
||||
duration: `${(duration / 1000).toFixed(1)}s`
|
||||
duration: `${(duration / 1000).toFixed(1)}s`,
|
||||
});
|
||||
|
||||
return { ...result, duration };
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Batch processing failed', error);
|
||||
throw error;
|
||||
|
|
@ -108,13 +107,12 @@ async function processDirect<T>(
|
|||
queue: QueueService,
|
||||
options: ProcessOptions
|
||||
): Promise<Omit<BatchResult, 'duration'>> {
|
||||
|
||||
const totalDelayMs = options.totalDelayHours * 60 * 60 * 1000;
|
||||
const delayPerItem = totalDelayMs / items.length;
|
||||
|
||||
logger.info('Creating direct jobs', {
|
||||
totalItems: items.length,
|
||||
delayPerItem: `${(delayPerItem / 1000).toFixed(1)}s`
|
||||
delayPerItem: `${(delayPerItem / 1000).toFixed(1)}s`,
|
||||
});
|
||||
|
||||
const jobs = items.map((item, index) => ({
|
||||
|
|
@ -124,15 +122,15 @@ async function processDirect<T>(
|
|||
provider: options.provider || 'generic',
|
||||
operation: options.operation || 'process-item',
|
||||
payload: processor(item, index),
|
||||
priority: options.priority || 1
|
||||
priority: options.priority || 1,
|
||||
},
|
||||
opts: {
|
||||
delay: index * delayPerItem,
|
||||
priority: options.priority || 1,
|
||||
attempts: options.retries || 3,
|
||||
removeOnComplete: options.removeOnComplete || 10,
|
||||
removeOnFail: options.removeOnFail || 5
|
||||
}
|
||||
removeOnFail: options.removeOnFail || 5,
|
||||
},
|
||||
}));
|
||||
|
||||
const createdJobs = await addJobsInChunks(queue, jobs);
|
||||
|
|
@ -140,7 +138,7 @@ async function processDirect<T>(
|
|||
return {
|
||||
totalItems: items.length,
|
||||
jobsCreated: createdJobs.length,
|
||||
mode: 'direct'
|
||||
mode: 'direct',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -153,7 +151,6 @@ async function processBatched<T>(
|
|||
queue: QueueService,
|
||||
options: ProcessOptions
|
||||
): Promise<Omit<BatchResult, 'duration'>> {
|
||||
|
||||
const batchSize = options.batchSize || 100;
|
||||
const batches = createBatches(items, batchSize);
|
||||
const totalDelayMs = options.totalDelayHours * 60 * 60 * 1000;
|
||||
|
|
@ -163,7 +160,7 @@ async function processBatched<T>(
|
|||
totalItems: items.length,
|
||||
batchSize,
|
||||
totalBatches: batches.length,
|
||||
delayPerBatch: `${(delayPerBatch / 1000 / 60).toFixed(2)} minutes`
|
||||
delayPerBatch: `${(delayPerBatch / 1000 / 60).toFixed(2)} minutes`,
|
||||
});
|
||||
|
||||
const batchJobs = await Promise.all(
|
||||
|
|
@ -180,17 +177,17 @@ async function processBatched<T>(
|
|||
payloadKey,
|
||||
batchIndex,
|
||||
totalBatches: batches.length,
|
||||
itemCount: batch.length
|
||||
itemCount: batch.length,
|
||||
},
|
||||
priority: options.priority || 2
|
||||
priority: options.priority || 2,
|
||||
},
|
||||
opts: {
|
||||
delay: batchIndex * delayPerBatch,
|
||||
priority: options.priority || 2,
|
||||
attempts: options.retries || 3,
|
||||
removeOnComplete: options.removeOnComplete || 10,
|
||||
removeOnFail: options.removeOnFail || 5
|
||||
}
|
||||
removeOnFail: options.removeOnFail || 5,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
@ -201,7 +198,7 @@ async function processBatched<T>(
|
|||
totalItems: items.length,
|
||||
jobsCreated: createdJobs.length,
|
||||
batchesCreated: batches.length,
|
||||
mode: 'batch'
|
||||
mode: 'batch',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -214,7 +211,7 @@ export async function processBatchJob(jobData: any, queue: QueueService): Promis
|
|||
logger.debug('Processing batch job', {
|
||||
batchIndex,
|
||||
totalBatches,
|
||||
itemCount
|
||||
itemCount,
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -236,13 +233,13 @@ export async function processBatchJob(jobData: any, queue: QueueService): Promis
|
|||
provider: options.provider || 'generic',
|
||||
operation: options.operation || 'generic',
|
||||
payload: processor(item, index),
|
||||
priority: options.priority || 1
|
||||
priority: options.priority || 1,
|
||||
},
|
||||
opts: {
|
||||
delay: index * (options.delayPerItem || 1000),
|
||||
priority: options.priority || 1,
|
||||
attempts: options.retries || 3
|
||||
}
|
||||
attempts: options.retries || 3,
|
||||
},
|
||||
}));
|
||||
|
||||
const createdJobs = await addJobsInChunks(queue, jobs);
|
||||
|
|
@ -253,9 +250,8 @@ export async function processBatchJob(jobData: any, queue: QueueService): Promis
|
|||
return {
|
||||
batchIndex,
|
||||
itemsProcessed: items.length,
|
||||
jobsCreated: createdJobs.length
|
||||
jobsCreated: createdJobs.length,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Batch job processing failed', { batchIndex, error });
|
||||
throw error;
|
||||
|
|
@ -296,21 +292,21 @@ async function storePayload<T>(
|
|||
retries: options.retries || 3,
|
||||
// Store routing information for later use
|
||||
provider: options.provider || 'generic',
|
||||
operation: options.operation || 'generic'
|
||||
operation: options.operation || 'generic',
|
||||
},
|
||||
createdAt: Date.now()
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
logger.debug('Storing batch payload', {
|
||||
key,
|
||||
itemCount: items.length
|
||||
itemCount: items.length,
|
||||
});
|
||||
|
||||
await cache.set(key, payload, options.ttl || 86400);
|
||||
|
||||
logger.debug('Stored batch payload successfully', {
|
||||
key,
|
||||
itemCount: items.length
|
||||
itemCount: items.length,
|
||||
});
|
||||
|
||||
return key;
|
||||
|
|
@ -359,12 +355,10 @@ async function addJobsInChunks(queue: QueueService, jobs: any[], chunkSize = 100
|
|||
logger.error('Failed to add job chunk', {
|
||||
startIndex: i,
|
||||
chunkSize: chunk.length,
|
||||
error
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allCreatedJobs;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export class MockBroker implements BrokerInterface {
|
|||
status: 'filled',
|
||||
executedPrice: order.price || 100, // Mock price
|
||||
executedAt: new Date(),
|
||||
commission: 1.0
|
||||
commission: 1.0,
|
||||
};
|
||||
|
||||
this.orders.set(orderId, result);
|
||||
|
|
@ -88,7 +88,7 @@ export class MockBroker implements BrokerInterface {
|
|||
totalValue: 100000,
|
||||
availableCash: 50000,
|
||||
buyingPower: 200000,
|
||||
marginUsed: 0
|
||||
marginUsed: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Order, OrderResult } from '@stock-bot/types';
|
||||
import { logger } from '@stock-bot/logger';
|
||||
import { Order, OrderResult } from '@stock-bot/types';
|
||||
import { BrokerInterface } from '../broker/interface.ts';
|
||||
|
||||
export class OrderManager {
|
||||
|
|
@ -12,7 +12,9 @@ export class OrderManager {
|
|||
|
||||
async executeOrder(order: Order): Promise<OrderResult> {
|
||||
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)}`;
|
||||
|
|
@ -26,7 +28,6 @@ export class OrderManager {
|
|||
|
||||
logger.info(`Order executed successfully: ${result.orderId}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Order execution failed', error);
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Order } from '@stock-bot/types';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { Order } from '@stock-bot/types';
|
||||
|
||||
export interface RiskRule {
|
||||
name: string;
|
||||
|
|
@ -38,7 +38,7 @@ export class RiskManager {
|
|||
if (!result.isValid) {
|
||||
logger.warn(`Risk rule violation: ${rule.name}`, {
|
||||
order,
|
||||
reason: result.reason
|
||||
reason: result.reason,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
|
@ -58,12 +58,12 @@ export class RiskManager {
|
|||
return {
|
||||
isValid: false,
|
||||
reason: `Order size ${orderValue} exceeds maximum position size ${context.maxPositionSize}`,
|
||||
severity: 'error'
|
||||
severity: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true, severity: 'info' };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Balance check rule
|
||||
|
|
@ -76,12 +76,12 @@ export class RiskManager {
|
|||
return {
|
||||
isValid: false,
|
||||
reason: `Insufficient balance: need ${orderValue}, have ${context.accountBalance}`,
|
||||
severity: 'error'
|
||||
severity: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true, severity: 'info' };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Concentration risk rule
|
||||
|
|
@ -89,23 +89,25 @@ export class RiskManager {
|
|||
name: 'ConcentrationLimit',
|
||||
async validate(order: Order, context: RiskContext): Promise<RiskValidationResult> {
|
||||
const currentPosition = context.currentPositions.get(order.symbol) || 0;
|
||||
const newPosition = order.side === 'buy' ?
|
||||
currentPosition + order.quantity :
|
||||
currentPosition - order.quantity;
|
||||
const newPosition =
|
||||
order.side === 'buy'
|
||||
? currentPosition + order.quantity
|
||||
: currentPosition - order.quantity;
|
||||
|
||||
const positionValue = Math.abs(newPosition) * (order.price || 0);
|
||||
const concentrationRatio = positionValue / context.accountBalance;
|
||||
|
||||
if (concentrationRatio > 0.25) { // 25% max concentration
|
||||
if (concentrationRatio > 0.25) {
|
||||
// 25% max concentration
|
||||
return {
|
||||
isValid: false,
|
||||
reason: `Position concentration ${(concentrationRatio * 100).toFixed(2)}% exceeds 25% limit`,
|
||||
severity: 'warning'
|
||||
severity: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true, severity: 'info' };
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Hono } from 'hono';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { Hono } from 'hono';
|
||||
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 { RiskManager } from './execution/risk-manager.ts';
|
||||
|
|
@ -9,16 +10,16 @@ import { config } from '@stock-bot/config';
|
|||
const app = new Hono();
|
||||
const logger = getLogger('execution-service');
|
||||
// Health check endpoint
|
||||
app.get('/health', (c) => {
|
||||
app.get('/health', c => {
|
||||
return c.json({
|
||||
status: 'healthy',
|
||||
service: 'execution-service',
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Order execution endpoints
|
||||
app.post('/orders/execute', async (c) => {
|
||||
app.post('/orders/execute', async c => {
|
||||
try {
|
||||
const orderRequest = await c.req.json();
|
||||
logger.info('Received order execution request', orderRequest);
|
||||
|
|
@ -27,7 +28,7 @@ app.post('/orders/execute', async (c) => {
|
|||
return c.json({
|
||||
orderId: `order_${Date.now()}`,
|
||||
status: 'pending',
|
||||
message: 'Order submitted for execution'
|
||||
message: 'Order submitted for execution',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Order execution failed', error);
|
||||
|
|
@ -35,7 +36,7 @@ app.post('/orders/execute', async (c) => {
|
|||
}
|
||||
});
|
||||
|
||||
app.get('/orders/:orderId/status', async (c) => {
|
||||
app.get('/orders/:orderId/status', async c => {
|
||||
const orderId = c.req.param('orderId');
|
||||
|
||||
try {
|
||||
|
|
@ -43,7 +44,7 @@ app.get('/orders/:orderId/status', async (c) => {
|
|||
return c.json({
|
||||
orderId,
|
||||
status: 'filled',
|
||||
executedAt: new Date().toISOString()
|
||||
executedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get order status', error);
|
||||
|
|
@ -51,7 +52,7 @@ app.get('/orders/:orderId/status', async (c) => {
|
|||
}
|
||||
});
|
||||
|
||||
app.post('/orders/:orderId/cancel', async (c) => {
|
||||
app.post('/orders/:orderId/cancel', async c => {
|
||||
const orderId = c.req.param('orderId');
|
||||
|
||||
try {
|
||||
|
|
@ -59,7 +60,7 @@ app.post('/orders/:orderId/cancel', async (c) => {
|
|||
return c.json({
|
||||
orderId,
|
||||
status: 'cancelled',
|
||||
cancelledAt: new Date().toISOString()
|
||||
cancelledAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel order', error);
|
||||
|
|
@ -68,7 +69,7 @@ app.post('/orders/:orderId/cancel', async (c) => {
|
|||
});
|
||||
|
||||
// Risk management endpoints
|
||||
app.get('/risk/position/:symbol', async (c) => {
|
||||
app.get('/risk/position/:symbol', async c => {
|
||||
const symbol = c.req.param('symbol');
|
||||
|
||||
try {
|
||||
|
|
@ -77,7 +78,7 @@ app.get('/risk/position/:symbol', async (c) => {
|
|||
symbol,
|
||||
position: 100,
|
||||
exposure: 10000,
|
||||
risk: 'low'
|
||||
risk: 'low',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get position risk', error);
|
||||
|
|
@ -89,9 +90,12 @@ const port = config.EXECUTION_SERVICE_PORT || 3004;
|
|||
|
||||
logger.info(`Starting execution service on port ${port}`);
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
port
|
||||
}, (info) => {
|
||||
logger.info(`Execution service is running on port ${info.port}`);
|
||||
});
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port,
|
||||
},
|
||||
info => {
|
||||
logger.info(`Execution service is running on port ${info.port}`);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ export interface PerformanceMetrics {
|
|||
}
|
||||
|
||||
export interface RiskMetrics {
|
||||
var95: number; // Value at Risk (95% confidence)
|
||||
cvar95: number; // Conditional Value at Risk
|
||||
var95: number; // Value at Risk (95% confidence)
|
||||
cvar95: number; // Conditional Value at Risk
|
||||
maxDrawdown: number;
|
||||
downsideDeviation: number;
|
||||
correlationMatrix: Record<string, Record<string, number>>;
|
||||
|
|
@ -32,7 +32,9 @@ export class PerformanceAnalyzer {
|
|||
}
|
||||
}
|
||||
|
||||
calculatePerformanceMetrics(period: 'daily' | 'weekly' | 'monthly' = 'daily'): PerformanceMetrics {
|
||||
calculatePerformanceMetrics(
|
||||
period: 'daily' | 'weekly' | 'monthly' = 'daily'
|
||||
): PerformanceMetrics {
|
||||
if (this.snapshots.length < 2) {
|
||||
throw new Error('Need at least 2 snapshots to calculate performance');
|
||||
}
|
||||
|
|
@ -49,7 +51,7 @@ export class PerformanceAnalyzer {
|
|||
beta: this.calculateBeta(returns),
|
||||
alpha: this.calculateAlpha(returns, riskFreeRate),
|
||||
calmarRatio: this.calculateCalmarRatio(returns),
|
||||
sortinoRatio: this.calculateSortinoRatio(returns, riskFreeRate)
|
||||
sortinoRatio: this.calculateSortinoRatio(returns, riskFreeRate),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +63,7 @@ export class PerformanceAnalyzer {
|
|||
cvar95: this.calculateCVaR(returns, 0.95),
|
||||
maxDrawdown: this.calculateMaxDrawdown(),
|
||||
downsideDeviation: this.calculateDownsideDeviation(returns),
|
||||
correlationMatrix: {} // TODO: Implement correlation matrix
|
||||
correlationMatrix: {}, // TODO: Implement correlation matrix
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -100,7 +102,8 @@ export class PerformanceAnalyzer {
|
|||
if (returns.length === 0) return 0;
|
||||
|
||||
const avgReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
||||
const variance = returns.reduce((sum, ret) => sum + Math.pow(ret - avgReturn, 2), 0) / returns.length;
|
||||
const variance =
|
||||
returns.reduce((sum, ret) => sum + Math.pow(ret - avgReturn, 2), 0) / returns.length;
|
||||
|
||||
return Math.sqrt(variance * 252); // Annualized volatility
|
||||
}
|
||||
|
|
@ -145,7 +148,7 @@ export class PerformanceAnalyzer {
|
|||
private calculateAlpha(returns: number[], riskFreeRate: number): number {
|
||||
const beta = this.calculateBeta(returns);
|
||||
const portfolioReturn = this.calculateAnnualizedReturn(returns);
|
||||
const benchmarkReturn = 0.10; // 10% benchmark return (placeholder)
|
||||
const benchmarkReturn = 0.1; // 10% benchmark return (placeholder)
|
||||
|
||||
return portfolioReturn - (riskFreeRate + beta * (benchmarkReturn - riskFreeRate));
|
||||
}
|
||||
|
|
@ -174,8 +177,11 @@ export class PerformanceAnalyzer {
|
|||
const negativeReturns = returns.filter(ret => ret < 0);
|
||||
if (negativeReturns.length === 0) return 0;
|
||||
|
||||
const avgNegativeReturn = negativeReturns.reduce((sum, ret) => sum + ret, 0) / negativeReturns.length;
|
||||
const variance = negativeReturns.reduce((sum, ret) => sum + Math.pow(ret - avgNegativeReturn, 2), 0) / negativeReturns.length;
|
||||
const avgNegativeReturn =
|
||||
negativeReturns.reduce((sum, ret) => sum + ret, 0) / negativeReturns.length;
|
||||
const variance =
|
||||
negativeReturns.reduce((sum, ret) => sum + Math.pow(ret - avgNegativeReturn, 2), 0) /
|
||||
negativeReturns.length;
|
||||
|
||||
return Math.sqrt(variance * 252); // Annualized
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
import { Hono } from 'hono';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { Hono } from 'hono';
|
||||
import { config } from '@stock-bot/config';
|
||||
import { PortfolioManager } from './portfolio/portfolio-manager.ts';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { PerformanceAnalyzer } from './analytics/performance-analyzer.ts';
|
||||
import { PortfolioManager } from './portfolio/portfolio-manager.ts';
|
||||
|
||||
const app = new Hono();
|
||||
const logger = getLogger('portfolio-service');
|
||||
// Health check endpoint
|
||||
app.get('/health', (c) => {
|
||||
app.get('/health', c => {
|
||||
return c.json({
|
||||
status: 'healthy',
|
||||
service: 'portfolio-service',
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Portfolio endpoints
|
||||
app.get('/portfolio/overview', async (c) => {
|
||||
app.get('/portfolio/overview', async c => {
|
||||
try {
|
||||
// TODO: Get portfolio overview
|
||||
return c.json({
|
||||
|
|
@ -26,7 +26,7 @@ app.get('/portfolio/overview', async (c) => {
|
|||
totalReturnPercent: 25.0,
|
||||
dayChange: 1250,
|
||||
dayChangePercent: 1.0,
|
||||
positions: []
|
||||
positions: [],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get portfolio overview', error);
|
||||
|
|
@ -34,7 +34,7 @@ app.get('/portfolio/overview', async (c) => {
|
|||
}
|
||||
});
|
||||
|
||||
app.get('/portfolio/positions', async (c) => {
|
||||
app.get('/portfolio/positions', async c => {
|
||||
try {
|
||||
// TODO: Get current positions
|
||||
return c.json([
|
||||
|
|
@ -45,8 +45,8 @@ app.get('/portfolio/positions', async (c) => {
|
|||
currentPrice: 155.0,
|
||||
marketValue: 15500,
|
||||
unrealizedPnL: 500,
|
||||
unrealizedPnLPercent: 3.33
|
||||
}
|
||||
unrealizedPnLPercent: 3.33,
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get positions', error);
|
||||
|
|
@ -54,14 +54,14 @@ app.get('/portfolio/positions', async (c) => {
|
|||
}
|
||||
});
|
||||
|
||||
app.get('/portfolio/history', async (c) => {
|
||||
app.get('/portfolio/history', async c => {
|
||||
const days = c.req.query('days') || '30';
|
||||
|
||||
try {
|
||||
// TODO: Get portfolio history
|
||||
return c.json({
|
||||
period: `${days} days`,
|
||||
data: []
|
||||
data: [],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get portfolio history', error);
|
||||
|
|
@ -70,7 +70,7 @@ app.get('/portfolio/history', async (c) => {
|
|||
});
|
||||
|
||||
// Performance analytics endpoints
|
||||
app.get('/analytics/performance', async (c) => {
|
||||
app.get('/analytics/performance', async c => {
|
||||
const period = c.req.query('period') || '1M';
|
||||
|
||||
try {
|
||||
|
|
@ -78,12 +78,12 @@ app.get('/analytics/performance', async (c) => {
|
|||
return c.json({
|
||||
period,
|
||||
totalReturn: 0.25,
|
||||
annualizedReturn: 0.30,
|
||||
annualizedReturn: 0.3,
|
||||
sharpeRatio: 1.5,
|
||||
maxDrawdown: 0.05,
|
||||
volatility: 0.15,
|
||||
beta: 1.1,
|
||||
alpha: 0.02
|
||||
alpha: 0.02,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get performance analytics', error);
|
||||
|
|
@ -91,7 +91,7 @@ app.get('/analytics/performance', async (c) => {
|
|||
}
|
||||
});
|
||||
|
||||
app.get('/analytics/risk', async (c) => {
|
||||
app.get('/analytics/risk', async c => {
|
||||
try {
|
||||
// TODO: Calculate risk metrics
|
||||
return c.json({
|
||||
|
|
@ -99,7 +99,7 @@ app.get('/analytics/risk', async (c) => {
|
|||
cvar95: 0.03,
|
||||
maxDrawdown: 0.05,
|
||||
downside_deviation: 0.08,
|
||||
correlation_matrix: {}
|
||||
correlation_matrix: {},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get risk analytics', error);
|
||||
|
|
@ -107,13 +107,13 @@ app.get('/analytics/risk', async (c) => {
|
|||
}
|
||||
});
|
||||
|
||||
app.get('/analytics/attribution', async (c) => {
|
||||
app.get('/analytics/attribution', async c => {
|
||||
try {
|
||||
// TODO: Calculate performance attribution
|
||||
return c.json({
|
||||
sector_allocation: {},
|
||||
security_selection: {},
|
||||
interaction_effect: {}
|
||||
interaction_effect: {},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get attribution analytics', error);
|
||||
|
|
@ -125,9 +125,12 @@ const port = config.PORTFOLIO_SERVICE_PORT || 3005;
|
|||
|
||||
logger.info(`Starting portfolio service on port ${port}`);
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
port
|
||||
}, (info) => {
|
||||
logger.info(`Portfolio service is running on port ${info.port}`);
|
||||
});
|
||||
serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port,
|
||||
},
|
||||
info => {
|
||||
logger.info(`Portfolio service is running on port ${info.port}`);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -64,9 +64,9 @@ export class PortfolioManager {
|
|||
unrealizedPnL: 0,
|
||||
unrealizedPnLPercent: 0,
|
||||
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;
|
||||
}
|
||||
|
|
@ -74,15 +74,14 @@ export class PortfolioManager {
|
|||
// Update existing position
|
||||
if (trade.side === 'buy') {
|
||||
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.averagePrice = (newCostBasis - this.getTotalCommissions(trade.symbol)) / newQuantity;
|
||||
existing.costBasis = newCostBasis;
|
||||
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') {
|
||||
existing.quantity -= trade.quantity;
|
||||
existing.lastUpdated = trade.timestamp;
|
||||
|
|
@ -102,8 +101,9 @@ export class PortfolioManager {
|
|||
if (position) {
|
||||
position.currentPrice = price;
|
||||
position.marketValue = position.quantity * price;
|
||||
position.unrealizedPnL = position.marketValue - (position.quantity * position.averagePrice);
|
||||
position.unrealizedPnLPercent = position.unrealizedPnL / (position.quantity * position.averagePrice) * 100;
|
||||
position.unrealizedPnL = position.marketValue - position.quantity * position.averagePrice;
|
||||
position.unrealizedPnLPercent =
|
||||
(position.unrealizedPnL / (position.quantity * position.averagePrice)) * 100;
|
||||
position.lastUpdated = new Date();
|
||||
}
|
||||
}
|
||||
|
|
@ -130,7 +130,7 @@ export class PortfolioManager {
|
|||
totalReturn: totalUnrealizedPnL, // Simplified - should include realized gains
|
||||
totalReturnPercent: (totalUnrealizedPnL / (totalValue - totalUnrealizedPnL)) * 100,
|
||||
dayChange: 0, // TODO: Calculate from previous day
|
||||
dayChangePercent: 0
|
||||
dayChangePercent: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
/**
|
||||
* Processing Service - Technical indicators and data processing
|
||||
*/
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { loadEnvVariables } from '@stock-bot/config';
|
||||
import { Hono } from 'hono';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { Hono } from 'hono';
|
||||
import { loadEnvVariables } from '@stock-bot/config';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
|
||||
// Load environment variables
|
||||
loadEnvVariables();
|
||||
|
|
@ -14,34 +14,34 @@ const logger = getLogger('processing-service');
|
|||
const PORT = parseInt(process.env.PROCESSING_SERVICE_PORT || '3003');
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (c) => {
|
||||
app.get('/health', c => {
|
||||
return c.json({
|
||||
service: 'processing-service',
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Technical indicators endpoint
|
||||
app.post('/api/indicators', async (c) => {
|
||||
app.post('/api/indicators', async c => {
|
||||
const body = await c.req.json();
|
||||
logger.info('Technical indicators request', { indicators: body.indicators });
|
||||
|
||||
// TODO: Implement technical indicators processing
|
||||
return c.json({
|
||||
message: 'Technical indicators endpoint - not implemented yet',
|
||||
requestedIndicators: body.indicators
|
||||
requestedIndicators: body.indicators,
|
||||
});
|
||||
});
|
||||
|
||||
// Vectorized processing endpoint
|
||||
app.post('/api/vectorized/process', async (c) => {
|
||||
app.post('/api/vectorized/process', async c => {
|
||||
const body = await c.req.json();
|
||||
logger.info('Vectorized processing request', { dataPoints: body.data?.length });
|
||||
|
||||
// TODO: Implement vectorized processing
|
||||
return c.json({
|
||||
message: 'Vectorized processing endpoint - not implemented yet'
|
||||
message: 'Vectorized processing endpoint - not implemented yet',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,7 @@
|
|||
* Leverages @stock-bot/utils for calculations
|
||||
*/
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import {
|
||||
sma,
|
||||
ema,
|
||||
rsi,
|
||||
macd
|
||||
} from '@stock-bot/utils';
|
||||
import { ema, macd, rsi, sma } from '@stock-bot/utils';
|
||||
|
||||
const logger = getLogger('indicators-service');
|
||||
|
||||
|
|
@ -30,7 +25,7 @@ export class IndicatorsService {
|
|||
logger.info('Calculating indicators', {
|
||||
symbol: request.symbol,
|
||||
indicators: request.indicators,
|
||||
dataPoints: request.data.length
|
||||
dataPoints: request.data.length,
|
||||
});
|
||||
|
||||
const results: Record<string, number[]> = {};
|
||||
|
|
@ -76,7 +71,7 @@ export class IndicatorsService {
|
|||
return {
|
||||
symbol: request.symbol,
|
||||
timestamp: new Date(),
|
||||
indicators: results
|
||||
indicators: results,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Event-Driven Backtesting Mode
|
||||
* 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 {
|
||||
startDate: Date;
|
||||
|
|
@ -25,7 +25,7 @@ export class EventMode extends ExecutionMode {
|
|||
async executeOrder(order: Order): Promise<OrderResult> {
|
||||
this.logger.debug('Simulating order execution', {
|
||||
orderId: order.id,
|
||||
simulationTime: this.simulationTime
|
||||
simulationTime: this.simulationTime,
|
||||
});
|
||||
|
||||
// TODO: Implement realistic order simulation
|
||||
|
|
@ -38,7 +38,7 @@ export class EventMode extends ExecutionMode {
|
|||
commission: 1.0, // TODO: Calculate based on commission model
|
||||
slippage: 0.01, // TODO: Calculate based on slippage model
|
||||
timestamp: this.simulationTime,
|
||||
executionTime: 50 // ms
|
||||
executionTime: 50, // ms
|
||||
};
|
||||
|
||||
return simulatedResult;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { getLogger } from '@stock-bot/logger';
|
||||
import { EventBus } from '@stock-bot/event-bus';
|
||||
import { VectorEngine, VectorizedBacktestResult } from '@stock-bot/vector-engine';
|
||||
import { create } from 'domain';
|
||||
import { DataFrame } from '@stock-bot/data-frame';
|
||||
import { ExecutionMode, BacktestContext, BacktestResult } from '../framework/execution-mode';
|
||||
import { EventBus } from '@stock-bot/event-bus';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { VectorEngine, VectorizedBacktestResult } from '@stock-bot/vector-engine';
|
||||
import { BacktestContext, BacktestResult, ExecutionMode } from '../framework/execution-mode';
|
||||
import { EventMode } from './event-mode';
|
||||
import VectorizedMode from './vectorized-mode';
|
||||
import { create } from 'domain';
|
||||
|
||||
export interface HybridModeConfig {
|
||||
vectorizedThreshold: number; // Switch to vectorized if data points > threshold
|
||||
|
|
@ -23,11 +23,7 @@ export class HybridMode extends ExecutionMode {
|
|||
private precomputedIndicators: Map<string, number[]> = new Map();
|
||||
private currentIndex: number = 0;
|
||||
|
||||
constructor(
|
||||
context: BacktestContext,
|
||||
eventBus: EventBus,
|
||||
config: HybridModeConfig = {}
|
||||
) {
|
||||
constructor(context: BacktestContext, eventBus: EventBus, config: HybridModeConfig = {}) {
|
||||
super(context, eventBus);
|
||||
|
||||
this.config = {
|
||||
|
|
@ -36,7 +32,7 @@ export class HybridMode extends ExecutionMode {
|
|||
eventDrivenRealtime: true,
|
||||
optimizeIndicators: true,
|
||||
batchSize: 10000,
|
||||
...config
|
||||
...config,
|
||||
};
|
||||
|
||||
this.vectorEngine = new VectorEngine();
|
||||
|
|
@ -55,7 +51,7 @@ export class HybridMode extends ExecutionMode {
|
|||
|
||||
this.logger.info('Hybrid mode initialized', {
|
||||
backtestId: this.context.backtestId,
|
||||
config: this.config
|
||||
config: this.config,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -76,18 +72,16 @@ export class HybridMode extends ExecutionMode {
|
|||
// Large dataset: use hybrid approach
|
||||
this.logger.info('Using hybrid approach for large dataset', { dataSize });
|
||||
return await this.executeHybrid(startTime);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('Hybrid backtest failed', {
|
||||
error,
|
||||
backtestId: this.context.backtestId
|
||||
backtestId: this.context.backtestId,
|
||||
});
|
||||
|
||||
await this.eventBus.publishBacktestUpdate(
|
||||
this.context.backtestId,
|
||||
0,
|
||||
{ status: 'failed', error: error.message }
|
||||
);
|
||||
await this.eventBus.publishBacktestUpdate(this.context.backtestId, 0, {
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -103,18 +97,17 @@ export class HybridMode extends ExecutionMode {
|
|||
// Phase 3: Combine results
|
||||
const combinedResult = this.combineResults(warmupResult, eventResult, startTime);
|
||||
|
||||
await this.eventBus.publishBacktestUpdate(
|
||||
this.context.backtestId,
|
||||
100,
|
||||
{ status: 'completed', result: combinedResult }
|
||||
);
|
||||
await this.eventBus.publishBacktestUpdate(this.context.backtestId, 100, {
|
||||
status: 'completed',
|
||||
result: combinedResult,
|
||||
});
|
||||
|
||||
this.logger.info('Hybrid backtest completed', {
|
||||
backtestId: this.context.backtestId,
|
||||
duration: Date.now() - startTime,
|
||||
totalTrades: combinedResult.trades.length,
|
||||
warmupTrades: warmupResult.trades.length,
|
||||
eventTrades: eventResult.trades.length
|
||||
eventTrades: eventResult.trades.length,
|
||||
});
|
||||
|
||||
return combinedResult;
|
||||
|
|
@ -122,7 +115,7 @@ export class HybridMode extends ExecutionMode {
|
|||
|
||||
private async executeWarmupPhase(): Promise<BacktestResult> {
|
||||
this.logger.info('Executing vectorized warmup phase', {
|
||||
warmupPeriod: this.config.warmupPeriod
|
||||
warmupPeriod: this.config.warmupPeriod,
|
||||
});
|
||||
|
||||
// Load warmup data
|
||||
|
|
@ -154,7 +147,7 @@ export class HybridMode extends ExecutionMode {
|
|||
// Create modified context for event phase
|
||||
const eventContext: BacktestContext = {
|
||||
...this.context,
|
||||
initialPortfolio: this.extractFinalPortfolio(warmupResult)
|
||||
initialPortfolio: this.extractFinalPortfolio(warmupResult),
|
||||
};
|
||||
|
||||
// Execute event-driven backtest for remaining data
|
||||
|
|
@ -198,7 +191,7 @@ export class HybridMode extends ExecutionMode {
|
|||
this.precomputedIndicators.set('bb_lower', bb.lower);
|
||||
|
||||
this.logger.info('Indicators pre-computed', {
|
||||
indicators: Array.from(this.precomputedIndicators.keys())
|
||||
indicators: Array.from(this.precomputedIndicators.keys()),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +225,7 @@ export class HybridMode extends ExecutionMode {
|
|||
this.logger.debug('Estimated data size', {
|
||||
timeRange,
|
||||
estimatedPoints,
|
||||
threshold: this.config.vectorizedThreshold
|
||||
threshold: this.config.vectorizedThreshold,
|
||||
});
|
||||
|
||||
return estimatedPoints;
|
||||
|
|
@ -243,10 +236,10 @@ export class HybridMode extends ExecutionMode {
|
|||
// This should load more data than just the warmup period for indicator calculations
|
||||
const data = [];
|
||||
const startTime = new Date(this.context.startDate).getTime();
|
||||
const warmupEndTime = startTime + (this.config.warmupPeriod * 60000);
|
||||
const warmupEndTime = startTime + this.config.warmupPeriod * 60000;
|
||||
|
||||
// Add extra lookback for indicator calculations
|
||||
const lookbackTime = startTime - (200 * 60000); // 200 periods lookback
|
||||
const lookbackTime = startTime - 200 * 60000; // 200 periods lookback
|
||||
|
||||
for (let timestamp = lookbackTime; timestamp <= warmupEndTime; timestamp += 60000) {
|
||||
const basePrice = 100 + Math.sin(timestamp / 1000000) * 10;
|
||||
|
|
@ -265,7 +258,7 @@ export class HybridMode extends ExecutionMode {
|
|||
high,
|
||||
low,
|
||||
close,
|
||||
volume
|
||||
volume,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -282,8 +275,8 @@ export class HybridMode extends ExecutionMode {
|
|||
high: 'number',
|
||||
low: 'number',
|
||||
close: 'number',
|
||||
volume: 'number'
|
||||
}
|
||||
volume: 'number',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -298,7 +291,10 @@ export class HybridMode extends ExecutionMode {
|
|||
return strategy.code || 'sma_crossover';
|
||||
}
|
||||
|
||||
private convertVectorizedResult(vectorResult: VectorizedBacktestResult, startTime: number): BacktestResult {
|
||||
private convertVectorizedResult(
|
||||
vectorResult: VectorizedBacktestResult,
|
||||
startTime: number
|
||||
): BacktestResult {
|
||||
return {
|
||||
backtestId: this.context.backtestId,
|
||||
strategy: this.context.strategy,
|
||||
|
|
@ -318,7 +314,7 @@ export class HybridMode extends ExecutionMode {
|
|||
quantity: trade.quantity,
|
||||
pnl: trade.pnl,
|
||||
commission: 0,
|
||||
slippage: 0
|
||||
slippage: 0,
|
||||
})),
|
||||
performance: {
|
||||
totalReturn: vectorResult.metrics.totalReturns,
|
||||
|
|
@ -330,12 +326,14 @@ export class HybridMode extends ExecutionMode {
|
|||
winningTrades: vectorResult.trades.filter(t => t.pnl > 0).length,
|
||||
losingTrades: vectorResult.trades.filter(t => t.pnl <= 0).length,
|
||||
avgTrade: vectorResult.metrics.avgTrade,
|
||||
avgWin: vectorResult.trades.filter(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)
|
||||
.reduce((sum, t) => sum + t.pnl, 0) / vectorResult.trades.filter(t => t.pnl <= 0).length || 0,
|
||||
avgWin:
|
||||
vectorResult.trades.filter(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).reduce((sum, t) => sum + t.pnl, 0) /
|
||||
vectorResult.trades.filter(t => t.pnl <= 0).length || 0,
|
||||
largestWin: Math.max(...vectorResult.trades.map(t => t.pnl), 0),
|
||||
largestLoss: Math.min(...vectorResult.trades.map(t => t.pnl), 0)
|
||||
largestLoss: Math.min(...vectorResult.trades.map(t => t.pnl), 0),
|
||||
},
|
||||
equity: vectorResult.equity,
|
||||
drawdown: vectorResult.metrics.drawdown,
|
||||
|
|
@ -343,8 +341,8 @@ export class HybridMode extends ExecutionMode {
|
|||
mode: 'hybrid-vectorized',
|
||||
dataPoints: vectorResult.timestamps.length,
|
||||
signals: Object.keys(vectorResult.signals),
|
||||
optimizations: ['vectorized_warmup', 'precomputed_indicators']
|
||||
}
|
||||
optimizations: ['vectorized_warmup', 'precomputed_indicators'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -355,11 +353,15 @@ export class HybridMode extends ExecutionMode {
|
|||
return {
|
||||
cash: finalEquity,
|
||||
positions: [], // Simplified - in production would track actual positions
|
||||
equity: finalEquity
|
||||
equity: finalEquity,
|
||||
};
|
||||
}
|
||||
|
||||
private combineResults(warmupResult: BacktestResult, eventResult: BacktestResult, startTime: number): BacktestResult {
|
||||
private combineResults(
|
||||
warmupResult: BacktestResult,
|
||||
eventResult: BacktestResult,
|
||||
startTime: number
|
||||
): BacktestResult {
|
||||
// Combine results from both phases
|
||||
const combinedTrades = [...warmupResult.trades, ...eventResult.trades];
|
||||
const combinedEquity = [...warmupResult.equity, ...eventResult.equity];
|
||||
|
|
@ -383,7 +385,8 @@ export class HybridMode extends ExecutionMode {
|
|||
duration: Date.now() - startTime,
|
||||
trades: combinedTrades,
|
||||
performance: {
|
||||
totalReturn: (combinedEquity[combinedEquity.length - 1] - combinedEquity[0]) / combinedEquity[0],
|
||||
totalReturn:
|
||||
(combinedEquity[combinedEquity.length - 1] - combinedEquity[0]) / combinedEquity[0],
|
||||
sharpeRatio: eventResult.performance.sharpeRatio, // Use event result for more accurate calculation
|
||||
maxDrawdown: Math.max(...combinedDrawdown),
|
||||
winRate: winningTrades.length / combinedTrades.length,
|
||||
|
|
@ -395,7 +398,7 @@ export class HybridMode extends ExecutionMode {
|
|||
avgWin: grossProfit / winningTrades.length || 0,
|
||||
avgLoss: grossLoss / losingTrades.length || 0,
|
||||
largestWin: Math.max(...combinedTrades.map(t => t.pnl), 0),
|
||||
largestLoss: Math.min(...combinedTrades.map(t => t.pnl), 0)
|
||||
largestLoss: Math.min(...combinedTrades.map(t => t.pnl), 0),
|
||||
},
|
||||
equity: combinedEquity,
|
||||
drawdown: combinedDrawdown,
|
||||
|
|
@ -405,8 +408,8 @@ export class HybridMode extends ExecutionMode {
|
|||
warmupPeriod: this.config.warmupPeriod,
|
||||
optimizations: ['precomputed_indicators', 'hybrid_execution'],
|
||||
warmupTrades: warmupResult.trades.length,
|
||||
eventTrades: eventResult.trades.length
|
||||
}
|
||||
eventTrades: eventResult.trades.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Live Trading Mode
|
||||
* 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 {
|
||||
name = 'live';
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { getLogger } from '@stock-bot/logger';
|
||||
import { EventBus } from '@stock-bot/event-bus';
|
||||
import { VectorEngine, VectorizedBacktestResult } from '@stock-bot/vector-engine';
|
||||
import { DataFrame } from '@stock-bot/data-frame';
|
||||
import { ExecutionMode, BacktestContext, BacktestResult } from '../framework/execution-mode';
|
||||
import { EventBus } from '@stock-bot/event-bus';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { VectorEngine, VectorizedBacktestResult } from '@stock-bot/vector-engine';
|
||||
import { BacktestContext, BacktestResult, ExecutionMode } from '../framework/execution-mode';
|
||||
|
||||
export interface VectorizedModeConfig {
|
||||
batchSize?: number;
|
||||
|
|
@ -15,18 +15,14 @@ export class VectorizedMode extends ExecutionMode {
|
|||
private config: VectorizedModeConfig;
|
||||
private logger = getLogger('vectorized-mode');
|
||||
|
||||
constructor(
|
||||
context: BacktestContext,
|
||||
eventBus: EventBus,
|
||||
config: VectorizedModeConfig = {}
|
||||
) {
|
||||
constructor(context: BacktestContext, eventBus: EventBus, config: VectorizedModeConfig = {}) {
|
||||
super(context, eventBus);
|
||||
this.vectorEngine = new VectorEngine();
|
||||
this.config = {
|
||||
batchSize: 10000,
|
||||
enableOptimization: true,
|
||||
parallelProcessing: true,
|
||||
...config
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +30,7 @@ export class VectorizedMode extends ExecutionMode {
|
|||
await super.initialize();
|
||||
this.logger.info('Vectorized mode initialized', {
|
||||
backtestId: this.context.backtestId,
|
||||
config: this.config
|
||||
config: this.config,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -60,31 +56,28 @@ export class VectorizedMode extends ExecutionMode {
|
|||
const result = this.convertVectorizedResult(vectorResult, startTime);
|
||||
|
||||
// Emit completion event
|
||||
await this.eventBus.publishBacktestUpdate(
|
||||
this.context.backtestId,
|
||||
100,
|
||||
{ status: 'completed', result }
|
||||
);
|
||||
await this.eventBus.publishBacktestUpdate(this.context.backtestId, 100, {
|
||||
status: 'completed',
|
||||
result,
|
||||
});
|
||||
|
||||
this.logger.info('Vectorized backtest completed', {
|
||||
backtestId: this.context.backtestId,
|
||||
duration: Date.now() - startTime,
|
||||
totalTrades: result.trades.length
|
||||
totalTrades: result.trades.length,
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('Vectorized backtest failed', {
|
||||
error,
|
||||
backtestId: this.context.backtestId
|
||||
backtestId: this.context.backtestId,
|
||||
});
|
||||
|
||||
await this.eventBus.publishBacktestUpdate(
|
||||
this.context.backtestId,
|
||||
0,
|
||||
{ status: 'failed', error: error.message }
|
||||
);
|
||||
await this.eventBus.publishBacktestUpdate(this.context.backtestId, 0, {
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -118,7 +111,7 @@ export class VectorizedMode extends ExecutionMode {
|
|||
high,
|
||||
low,
|
||||
close,
|
||||
volume
|
||||
volume,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -135,8 +128,8 @@ export class VectorizedMode extends ExecutionMode {
|
|||
high: 'number',
|
||||
low: 'number',
|
||||
close: 'number',
|
||||
volume: 'number'
|
||||
}
|
||||
volume: 'number',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -176,7 +169,7 @@ export class VectorizedMode extends ExecutionMode {
|
|||
quantity: trade.quantity,
|
||||
pnl: trade.pnl,
|
||||
commission: 0, // Simplified
|
||||
slippage: 0
|
||||
slippage: 0,
|
||||
})),
|
||||
performance: {
|
||||
totalReturn: vectorResult.metrics.totalReturns,
|
||||
|
|
@ -188,12 +181,14 @@ export class VectorizedMode extends ExecutionMode {
|
|||
winningTrades: vectorResult.trades.filter(t => t.pnl > 0).length,
|
||||
losingTrades: vectorResult.trades.filter(t => t.pnl <= 0).length,
|
||||
avgTrade: vectorResult.metrics.avgTrade,
|
||||
avgWin: vectorResult.trades.filter(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)
|
||||
.reduce((sum, t) => sum + t.pnl, 0) / vectorResult.trades.filter(t => t.pnl <= 0).length || 0,
|
||||
avgWin:
|
||||
vectorResult.trades.filter(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).reduce((sum, t) => sum + t.pnl, 0) /
|
||||
vectorResult.trades.filter(t => t.pnl <= 0).length || 0,
|
||||
largestWin: Math.max(...vectorResult.trades.map(t => t.pnl), 0),
|
||||
largestLoss: Math.min(...vectorResult.trades.map(t => t.pnl), 0)
|
||||
largestLoss: Math.min(...vectorResult.trades.map(t => t.pnl), 0),
|
||||
},
|
||||
equity: vectorResult.equity,
|
||||
drawdown: vectorResult.metrics.drawdown,
|
||||
|
|
@ -201,8 +196,8 @@ export class VectorizedMode extends ExecutionMode {
|
|||
mode: 'vectorized',
|
||||
dataPoints: vectorResult.timestamps.length,
|
||||
signals: Object.keys(vectorResult.signals),
|
||||
optimizations: this.config.enableOptimization ? ['vectorized_computation'] : []
|
||||
}
|
||||
optimizations: this.config.enableOptimization ? ['vectorized_computation'] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -212,9 +207,11 @@ export class VectorizedMode extends ExecutionMode {
|
|||
}
|
||||
|
||||
// Batch processing capabilities
|
||||
async batchBacktest(strategies: Array<{ id: string; config: any }>): Promise<Record<string, BacktestResult>> {
|
||||
async batchBacktest(
|
||||
strategies: Array<{ id: string; config: any }>
|
||||
): Promise<Record<string, BacktestResult>> {
|
||||
this.logger.info('Starting batch vectorized backtest', {
|
||||
strategiesCount: strategies.length
|
||||
strategiesCount: strategies.length,
|
||||
});
|
||||
|
||||
const data = await this.loadHistoricalData();
|
||||
|
|
@ -222,7 +219,7 @@ export class VectorizedMode extends ExecutionMode {
|
|||
|
||||
const strategyConfigs = strategies.map(s => ({
|
||||
id: s.id,
|
||||
code: this.generateStrategyCode()
|
||||
code: this.generateStrategyCode(),
|
||||
}));
|
||||
|
||||
const batchResults = await this.vectorEngine.batchBacktest(dataFrame, strategyConfigs);
|
||||
|
|
|
|||
|
|
@ -3,15 +3,14 @@
|
|||
* Strategy Service CLI
|
||||
* Command-line interface for running backtests and managing strategies
|
||||
*/
|
||||
|
||||
import { program } from 'commander';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { createEventBus } from '@stock-bot/event-bus';
|
||||
import { BacktestContext } from '../framework/execution-mode';
|
||||
import { LiveMode } from '../backtesting/modes/live-mode';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { EventMode } from '../backtesting/modes/event-mode';
|
||||
import VectorizedMode from '../backtesting/modes/vectorized-mode';
|
||||
import HybridMode from '../backtesting/modes/hybrid-mode';
|
||||
import { LiveMode } from '../backtesting/modes/live-mode';
|
||||
import VectorizedMode from '../backtesting/modes/vectorized-mode';
|
||||
import { BacktestContext } from '../framework/execution-mode';
|
||||
|
||||
const logger = getLogger('strategy-cli');
|
||||
|
||||
|
|
@ -35,7 +34,7 @@ async function runBacktest(options: CLIBacktestConfig): Promise<void> {
|
|||
// Initialize event bus
|
||||
const eventBus = createEventBus({
|
||||
serviceName: 'strategy-cli',
|
||||
enablePersistence: false // Disable Redis for CLI
|
||||
enablePersistence: false, // Disable Redis for CLI
|
||||
});
|
||||
|
||||
// Create backtest context
|
||||
|
|
@ -46,13 +45,13 @@ async function runBacktest(options: CLIBacktestConfig): Promise<void> {
|
|||
name: options.strategy,
|
||||
type: options.strategy,
|
||||
code: options.strategy,
|
||||
parameters: {}
|
||||
parameters: {},
|
||||
},
|
||||
symbol: options.symbol,
|
||||
startDate: options.startDate,
|
||||
endDate: options.endDate,
|
||||
initialCapital: options.initialCapital || 10000,
|
||||
mode: options.mode
|
||||
mode: options.mode,
|
||||
};
|
||||
|
||||
// Load additional config if provided
|
||||
|
|
@ -82,7 +81,7 @@ async function runBacktest(options: CLIBacktestConfig): Promise<void> {
|
|||
}
|
||||
|
||||
// Subscribe to progress updates
|
||||
eventBus.subscribe('backtest.update', (message) => {
|
||||
eventBus.subscribe('backtest.update', message => {
|
||||
const { backtestId, progress, ...data } = message.data;
|
||||
console.log(`Progress: ${progress}%`, data);
|
||||
});
|
||||
|
|
@ -100,7 +99,6 @@ async function runBacktest(options: CLIBacktestConfig): Promise<void> {
|
|||
}
|
||||
|
||||
await eventBus.close();
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Backtest failed', error);
|
||||
process.exit(1);
|
||||
|
|
@ -178,9 +176,9 @@ function convertTradesToCSV(trades: any[]): string {
|
|||
|
||||
const headers = Object.keys(trades[0]).join(',');
|
||||
const rows = trades.map(trade =>
|
||||
Object.values(trade).map(value =>
|
||||
typeof value === 'string' ? `"${value}"` : value
|
||||
).join(',')
|
||||
Object.values(trade)
|
||||
.map(value => (typeof value === 'string' ? `"${value}"` : value))
|
||||
.join(',')
|
||||
);
|
||||
|
||||
return [headers, ...rows].join('\n');
|
||||
|
|
@ -202,7 +200,13 @@ async function validateStrategy(strategy: string): Promise<void> {
|
|||
// TODO: Add strategy validation logic
|
||||
// This could check if the strategy exists, has valid parameters, etc.
|
||||
|
||||
const validStrategies = ['sma_crossover', 'ema_crossover', 'rsi_mean_reversion', 'macd_trend', 'bollinger_bands'];
|
||||
const validStrategies = [
|
||||
'sma_crossover',
|
||||
'ema_crossover',
|
||||
'rsi_mean_reversion',
|
||||
'macd_trend',
|
||||
'bollinger_bands',
|
||||
];
|
||||
|
||||
if (!validStrategies.includes(strategy)) {
|
||||
console.warn(`Warning: Strategy '${strategy}' is not in the list of known strategies`);
|
||||
|
|
@ -213,10 +217,7 @@ async function validateStrategy(strategy: string): Promise<void> {
|
|||
}
|
||||
|
||||
// CLI Commands
|
||||
program
|
||||
.name('strategy-cli')
|
||||
.description('Stock Trading Bot Strategy CLI')
|
||||
.version('1.0.0');
|
||||
program.name('strategy-cli').description('Stock Trading Bot Strategy CLI').version('1.0.0');
|
||||
|
||||
program
|
||||
.command('backtest')
|
||||
|
|
@ -234,10 +235,7 @@ program
|
|||
await runBacktest(options);
|
||||
});
|
||||
|
||||
program
|
||||
.command('list-strategies')
|
||||
.description('List available strategies')
|
||||
.action(listStrategies);
|
||||
program.command('list-strategies').description('List available strategies').action(listStrategies);
|
||||
|
||||
program
|
||||
.command('validate')
|
||||
|
|
@ -269,7 +267,7 @@ program
|
|||
await runBacktest({
|
||||
...options,
|
||||
strategy,
|
||||
output: options.output ? `${options.output}/${strategy}.json` : undefined
|
||||
output: options.output ? `${options.output}/${strategy}.json` : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to run ${strategy}:`, (error as Error).message);
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export enum BacktestMode {
|
|||
LIVE = 'live',
|
||||
EVENT_DRIVEN = 'event-driven',
|
||||
VECTORIZED = 'vectorized',
|
||||
HYBRID = 'hybrid'
|
||||
HYBRID = 'hybrid',
|
||||
}
|
||||
|
||||
export class ModeFactory {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
/**
|
||||
* Strategy Service - Multi-mode strategy execution and backtesting
|
||||
*/
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { loadEnvVariables } from '@stock-bot/config';
|
||||
import { Hono } from 'hono';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { Hono } from 'hono';
|
||||
import { loadEnvVariables } from '@stock-bot/config';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
|
||||
// Load environment variables
|
||||
loadEnvVariables();
|
||||
|
|
@ -14,69 +14,69 @@ const logger = getLogger('strategy-service');
|
|||
const PORT = parseInt(process.env.STRATEGY_SERVICE_PORT || '3004');
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (c) => {
|
||||
app.get('/health', c => {
|
||||
return c.json({
|
||||
service: 'strategy-service',
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Strategy execution endpoints
|
||||
app.post('/api/strategy/run', async (c) => {
|
||||
app.post('/api/strategy/run', async c => {
|
||||
const body = await c.req.json();
|
||||
logger.info('Strategy run request', {
|
||||
strategy: body.strategy,
|
||||
mode: body.mode
|
||||
mode: body.mode,
|
||||
});
|
||||
|
||||
// TODO: Implement strategy execution
|
||||
return c.json({
|
||||
message: 'Strategy execution endpoint - not implemented yet',
|
||||
strategy: body.strategy,
|
||||
mode: body.mode
|
||||
mode: body.mode,
|
||||
});
|
||||
});
|
||||
|
||||
// Backtesting endpoints
|
||||
app.post('/api/backtest/event', async (c) => {
|
||||
app.post('/api/backtest/event', async c => {
|
||||
const body = await c.req.json();
|
||||
logger.info('Event-driven backtest request', { strategy: body.strategy });
|
||||
|
||||
// TODO: Implement event-driven backtesting
|
||||
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();
|
||||
logger.info('Vectorized backtest request', { strategy: body.strategy });
|
||||
|
||||
// TODO: Implement vectorized backtesting
|
||||
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();
|
||||
logger.info('Hybrid backtest request', { strategy: body.strategy });
|
||||
|
||||
// TODO: Implement hybrid backtesting
|
||||
return c.json({
|
||||
message: 'Hybrid backtest endpoint - not implemented yet'
|
||||
message: 'Hybrid backtest endpoint - not implemented yet',
|
||||
});
|
||||
});
|
||||
|
||||
// Parameter optimization endpoint
|
||||
app.post('/api/optimize', async (c) => {
|
||||
app.post('/api/optimize', async c => {
|
||||
const body = await c.req.json();
|
||||
logger.info('Parameter optimization request', { strategy: body.strategy });
|
||||
|
||||
// TODO: Implement parameter optimization
|
||||
return c.json({
|
||||
message: 'Parameter optimization endpoint - not implemented yet'
|
||||
message: 'Parameter optimization endpoint - not implemented yet',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
6
bun.lock
6
bun.lock
|
|
@ -8,6 +8,7 @@
|
|||
"ioredis": "^5.6.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.4.2",
|
||||
"@testcontainers/mongodb": "^10.7.2",
|
||||
"@testcontainers/postgresql": "^10.7.2",
|
||||
"@types/bun": "latest",
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
"bun-types": "^1.2.15",
|
||||
"mongodb-memory-server": "^9.1.6",
|
||||
"pg-mem": "^2.8.1",
|
||||
"prettier": "^3.5.3",
|
||||
"supertest": "^6.3.4",
|
||||
"turbo": "^2.5.4",
|
||||
"typescript": "^5.8.3",
|
||||
|
|
@ -533,6 +535,8 @@
|
|||
|
||||
"@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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
|
||||
|
|
|
|||
34
libs/cache/src/connection-manager.ts
vendored
34
libs/cache/src/connection-manager.ts
vendored
|
|
@ -1,6 +1,6 @@
|
|||
import Redis from 'ioredis';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { dragonflyConfig } from '@stock-bot/config';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
|
||||
interface ConnectionConfig {
|
||||
name: string;
|
||||
|
|
@ -66,7 +66,9 @@ export class RedisConnectionManager {
|
|||
retryDelayOnFailover: dragonflyConfig.DRAGONFLY_RETRY_DELAY,
|
||||
connectTimeout: dragonflyConfig.DRAGONFLY_CONNECT_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,
|
||||
lazyConnect: false, // Connect immediately instead of waiting for first command
|
||||
...(dragonflyConfig.DRAGONFLY_TLS && {
|
||||
|
|
@ -90,7 +92,7 @@ export class RedisConnectionManager {
|
|||
this.logger.info(`Redis connection ready: ${name}`);
|
||||
});
|
||||
|
||||
redis.on('error', (err) => {
|
||||
redis.on('error', err => {
|
||||
this.logger.error(`Redis connection error for ${name}:`, err);
|
||||
});
|
||||
|
||||
|
|
@ -129,8 +131,8 @@ export class RedisConnectionManager {
|
|||
|
||||
// Close shared connections (only if this is the last instance)
|
||||
if (RedisConnectionManager.instance === this) {
|
||||
const sharedPromises = Array.from(RedisConnectionManager.sharedConnections.values()).map(conn =>
|
||||
this.closeConnection(conn)
|
||||
const sharedPromises = Array.from(RedisConnectionManager.sharedConnections.values()).map(
|
||||
conn => this.closeConnection(conn)
|
||||
);
|
||||
await Promise.all(sharedPromises);
|
||||
RedisConnectionManager.sharedConnections.clear();
|
||||
|
|
@ -145,7 +147,7 @@ export class RedisConnectionManager {
|
|||
getConnectionCount(): { shared: number; unique: number } {
|
||||
return {
|
||||
shared: RedisConnectionManager.sharedConnections.size,
|
||||
unique: this.connections.size
|
||||
unique: this.connections.size,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -155,7 +157,7 @@ export class RedisConnectionManager {
|
|||
getConnectionNames(): { shared: string[]; unique: string[] } {
|
||||
return {
|
||||
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> {
|
||||
const instance = this.getInstance();
|
||||
const allConnections = new Map([
|
||||
...instance.connections,
|
||||
...this.sharedConnections
|
||||
]);
|
||||
const allConnections = new Map([...instance.connections, ...this.sharedConnections]);
|
||||
|
||||
if (allConnections.size === 0) {
|
||||
instance.logger.info('No Redis connections to wait for');
|
||||
|
|
@ -259,15 +258,12 @@ export class RedisConnectionManager {
|
|||
*/
|
||||
static areAllConnectionsReady(): boolean {
|
||||
const instance = this.getInstance();
|
||||
const allConnections = new Map([
|
||||
...instance.connections,
|
||||
...this.sharedConnections
|
||||
]);
|
||||
const allConnections = new Map([...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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
15
libs/cache/src/index.ts
vendored
15
libs/cache/src/index.ts
vendored
|
|
@ -1,6 +1,6 @@
|
|||
import { RedisCache } from './redis-cache';
|
||||
import { RedisConnectionManager } from './connection-manager';
|
||||
import type { CacheProvider, CacheOptions } from './types';
|
||||
import { RedisCache } from './redis-cache';
|
||||
import type { CacheOptions, CacheProvider } from './types';
|
||||
|
||||
// Cache instances registry to prevent multiple instances with same prefix
|
||||
const cacheInstances = new Map<string, CacheProvider>();
|
||||
|
|
@ -14,7 +14,7 @@ export function createCache(options: Partial<CacheOptions> = {}): CacheProvider
|
|||
ttl: 3600, // 1 hour default
|
||||
enableMetrics: true,
|
||||
shared: true, // Default to shared connections
|
||||
...options
|
||||
...options,
|
||||
};
|
||||
|
||||
// For shared connections, reuse cache instances with the same key prefix
|
||||
|
|
@ -43,7 +43,7 @@ export function createTradingCache(options: Partial<CacheOptions> = {}): CachePr
|
|||
ttl: 3600, // 1 hour default
|
||||
enableMetrics: true,
|
||||
shared: true,
|
||||
...options
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ export function createMarketDataCache(options: Partial<CacheOptions> = {}): Cach
|
|||
ttl: 300, // 5 minutes for market data
|
||||
enableMetrics: true,
|
||||
shared: true,
|
||||
...options
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ export function createIndicatorCache(options: Partial<CacheOptions> = {}): Cache
|
|||
ttl: 1800, // 30 minutes for indicators
|
||||
enableMetrics: true,
|
||||
shared: true,
|
||||
...options
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -80,13 +80,12 @@ export type {
|
|||
CacheConfig,
|
||||
CacheStats,
|
||||
CacheKey,
|
||||
SerializationOptions
|
||||
SerializationOptions,
|
||||
} from './types';
|
||||
|
||||
export { RedisCache } from './redis-cache';
|
||||
export { RedisConnectionManager } from './connection-manager';
|
||||
export { CacheKeyGenerator } from './key-generator';
|
||||
|
||||
|
||||
// Default export for convenience
|
||||
export default createCache;
|
||||
2
libs/cache/src/key-generator.ts
vendored
2
libs/cache/src/key-generator.ts
vendored
|
|
@ -65,7 +65,7 @@ export class CacheKeyGenerator {
|
|||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
|
|
|
|||
63
libs/cache/src/redis-cache.ts
vendored
63
libs/cache/src/redis-cache.ts
vendored
|
|
@ -1,7 +1,7 @@
|
|||
import Redis from 'ioredis';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { CacheProvider, CacheOptions, CacheStats } from './types';
|
||||
import { RedisConnectionManager } from './connection-manager';
|
||||
import { CacheOptions, CacheProvider, CacheStats } from './types';
|
||||
|
||||
/**
|
||||
* Simplified Redis-based cache provider using connection manager
|
||||
|
|
@ -22,7 +22,7 @@ export class RedisCache implements CacheProvider {
|
|||
errors: 0,
|
||||
hitRate: 0,
|
||||
total: 0,
|
||||
uptime: 0
|
||||
uptime: 0,
|
||||
};
|
||||
|
||||
constructor(options: CacheOptions = {}) {
|
||||
|
|
@ -34,7 +34,13 @@ export class RedisCache implements CacheProvider {
|
|||
this.connectionManager = RedisConnectionManager.getInstance();
|
||||
|
||||
// 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)
|
||||
this.redis = this.connectionManager.getConnection({
|
||||
|
|
@ -110,7 +116,7 @@ export class RedisCache implements CacheProvider {
|
|||
return await operation();
|
||||
} catch (error) {
|
||||
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);
|
||||
return fallback;
|
||||
|
|
@ -144,20 +150,26 @@ export class RedisCache implements CacheProvider {
|
|||
);
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, options?: number | {
|
||||
ttl?: number;
|
||||
preserveTTL?: boolean;
|
||||
onlyIfExists?: boolean;
|
||||
onlyIfNotExists?: boolean;
|
||||
getOldValue?: boolean;
|
||||
}): Promise<T | null> {
|
||||
async set<T>(
|
||||
key: string,
|
||||
value: T,
|
||||
options?:
|
||||
| number
|
||||
| {
|
||||
ttl?: number;
|
||||
preserveTTL?: boolean;
|
||||
onlyIfExists?: boolean;
|
||||
onlyIfNotExists?: boolean;
|
||||
getOldValue?: boolean;
|
||||
}
|
||||
): Promise<T | null> {
|
||||
return this.safeExecute(
|
||||
async () => {
|
||||
const fullKey = this.getKey(key);
|
||||
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
|
||||
// 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;
|
||||
|
||||
|
|
@ -180,7 +192,9 @@ export class RedisCache implements CacheProvider {
|
|||
if (currentTTL === -2) {
|
||||
// Key doesn't exist
|
||||
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;
|
||||
}
|
||||
// Set with default or specified TTL
|
||||
|
|
@ -279,7 +293,7 @@ export class RedisCache implements CacheProvider {
|
|||
return pong === 'PONG';
|
||||
} catch (error) {
|
||||
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;
|
||||
}
|
||||
|
|
@ -288,7 +302,7 @@ export class RedisCache implements CacheProvider {
|
|||
getStats(): CacheStats {
|
||||
return {
|
||||
...this.stats,
|
||||
uptime: Date.now() - this.startTime
|
||||
uptime: Date.now() - this.startTime,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -308,7 +322,7 @@ export class RedisCache implements CacheProvider {
|
|||
resolve();
|
||||
});
|
||||
|
||||
this.redis.once('error', (error) => {
|
||||
this.redis.once('error', error => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
});
|
||||
|
|
@ -334,7 +348,7 @@ export class RedisCache implements CacheProvider {
|
|||
|
||||
async setIfExists<T>(key: string, value: T, ttl?: number): Promise<boolean> {
|
||||
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> {
|
||||
|
|
@ -347,7 +361,11 @@ export class RedisCache implements CacheProvider {
|
|||
}
|
||||
|
||||
// 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(
|
||||
async () => {
|
||||
const fullKey = this.getKey(key);
|
||||
|
|
@ -364,11 +382,10 @@ export class RedisCache implements CacheProvider {
|
|||
return {current_value, current_ttl}
|
||||
`;
|
||||
|
||||
const [currentValue, currentTTL] = await this.redis.eval(
|
||||
luaScript,
|
||||
1,
|
||||
fullKey
|
||||
) as [string | null, number];
|
||||
const [currentValue, currentTTL] = (await this.redis.eval(luaScript, 1, fullKey)) as [
|
||||
string | null,
|
||||
number,
|
||||
];
|
||||
|
||||
// Parse current value
|
||||
let parsed: T | null = null;
|
||||
|
|
|
|||
22
libs/cache/src/types.ts
vendored
22
libs/cache/src/types.ts
vendored
|
|
@ -1,12 +1,18 @@
|
|||
export interface CacheProvider {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, options?: number | {
|
||||
ttl?: number;
|
||||
preserveTTL?: boolean;
|
||||
onlyIfExists?: boolean;
|
||||
onlyIfNotExists?: boolean;
|
||||
getOldValue?: boolean;
|
||||
}): Promise<T | null>;
|
||||
set<T>(
|
||||
key: string,
|
||||
value: T,
|
||||
options?:
|
||||
| number
|
||||
| {
|
||||
ttl?: number;
|
||||
preserveTTL?: boolean;
|
||||
onlyIfExists?: boolean;
|
||||
onlyIfNotExists?: boolean;
|
||||
getOldValue?: boolean;
|
||||
}
|
||||
): Promise<T | null>;
|
||||
del(key: string): Promise<void>;
|
||||
exists(key: string): Promise<boolean>;
|
||||
clear(): Promise<void>;
|
||||
|
|
@ -45,7 +51,7 @@ export interface CacheProvider {
|
|||
* Replace existing key's value and TTL
|
||||
*/
|
||||
replace?<T>(key: string, value: T, ttl?: number): Promise<T | null>;
|
||||
/**
|
||||
/**
|
||||
* Atomically update field with transformation function
|
||||
*/
|
||||
updateField?<T>(key: string, updater: (current: T | null) => T, ttl?: number): Promise<T | null>;
|
||||
|
|
|
|||
|
|
@ -61,10 +61,17 @@ export const redisInsightConfig = cleanEnv(process.env, {
|
|||
REDIS_INSIGHT_PORT: port(8001, 'Redis Insight port'),
|
||||
|
||||
// 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',
|
||||
'Redis hosts in format name:host:port,name:host:port'
|
||||
),
|
||||
|
||||
// Configuration
|
||||
REDIS_INSIGHT_LOG_LEVEL: strWithChoices(['error', 'warn', 'info', 'verbose', 'debug'], 'info', 'Redis Insight log level'),
|
||||
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'),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* Core configuration module for the Stock Bot platform using Yup
|
||||
*/
|
||||
import { config as dotenvConfig } from 'dotenv';
|
||||
import path from 'node:path';
|
||||
import { config as dotenvConfig } from 'dotenv';
|
||||
|
||||
/**
|
||||
* Represents an error related to configuration validation
|
||||
|
|
@ -21,7 +21,7 @@ export enum Environment {
|
|||
Development = 'development',
|
||||
Testing = 'testing',
|
||||
Staging = 'staging',
|
||||
Production = 'production'
|
||||
Production = 'production',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -35,11 +35,7 @@ export function loadEnvVariables(envOverride?: string): void {
|
|||
// 2. .env.{environment} (environment-specific variables)
|
||||
// 3. .env.local (local overrides, not to be committed)
|
||||
|
||||
const envFiles = [
|
||||
'.env',
|
||||
`.env.${env}`,
|
||||
'.env.local'
|
||||
];
|
||||
const envFiles = ['.env', `.env.${env}`, '.env.local'];
|
||||
|
||||
for (const file of envFiles) {
|
||||
dotenvConfig({ path: path.resolve(process.cwd(), file) });
|
||||
|
|
@ -55,7 +51,7 @@ export function getEnvironment(): Environment {
|
|||
case 'development':
|
||||
return Environment.Development;
|
||||
case 'testing':
|
||||
case 'test': // Handle both 'test' and 'testing' for compatibility
|
||||
case 'test': // Handle both 'test' and 'testing' for compatibility
|
||||
return Environment.Testing;
|
||||
case 'staging':
|
||||
return Environment.Staging;
|
||||
|
|
@ -63,6 +59,5 @@ export function getEnvironment(): Environment {
|
|||
return Environment.Production;
|
||||
default:
|
||||
return Environment.Development;
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ export interface ProviderConfig {
|
|||
*/
|
||||
export const dataProvidersConfig = cleanEnv(process.env, {
|
||||
// Default Provider
|
||||
DEFAULT_DATA_PROVIDER: strWithChoices(['alpaca', 'polygon', 'yahoo', 'iex'], 'alpaca', 'Default data provider'),
|
||||
DEFAULT_DATA_PROVIDER: strWithChoices(
|
||||
['alpaca', 'polygon', 'yahoo', 'iex'],
|
||||
'alpaca',
|
||||
'Default data provider'
|
||||
),
|
||||
|
||||
// Alpaca Configuration
|
||||
ALPACA_API_KEY: str('', 'Alpaca API key'),
|
||||
|
|
@ -78,8 +82,8 @@ export function getProviderConfig(providerName: string) {
|
|||
apiKey: dataProvidersConfig.ALPACA_API_KEY,
|
||||
apiSecret: dataProvidersConfig.ALPACA_API_SECRET,
|
||||
rateLimits: {
|
||||
maxRequestsPerMinute: dataProvidersConfig.ALPACA_RATE_LIMIT
|
||||
}
|
||||
maxRequestsPerMinute: dataProvidersConfig.ALPACA_RATE_LIMIT,
|
||||
},
|
||||
};
|
||||
|
||||
case 'POLYGON':
|
||||
|
|
@ -90,8 +94,8 @@ export function getProviderConfig(providerName: string) {
|
|||
baseUrl: dataProvidersConfig.POLYGON_BASE_URL,
|
||||
apiKey: dataProvidersConfig.POLYGON_API_KEY,
|
||||
rateLimits: {
|
||||
maxRequestsPerMinute: dataProvidersConfig.POLYGON_RATE_LIMIT
|
||||
}
|
||||
maxRequestsPerMinute: dataProvidersConfig.POLYGON_RATE_LIMIT,
|
||||
},
|
||||
};
|
||||
|
||||
case 'YAHOO':
|
||||
|
|
@ -101,8 +105,8 @@ export function getProviderConfig(providerName: string) {
|
|||
enabled: dataProvidersConfig.YAHOO_ENABLED,
|
||||
baseUrl: dataProvidersConfig.YAHOO_BASE_URL,
|
||||
rateLimits: {
|
||||
maxRequestsPerHour: dataProvidersConfig.YAHOO_RATE_LIMIT
|
||||
}
|
||||
maxRequestsPerHour: dataProvidersConfig.YAHOO_RATE_LIMIT,
|
||||
},
|
||||
};
|
||||
|
||||
case 'IEX':
|
||||
|
|
@ -113,8 +117,8 @@ export function getProviderConfig(providerName: string) {
|
|||
baseUrl: dataProvidersConfig.IEX_BASE_URL,
|
||||
apiKey: dataProvidersConfig.IEX_API_KEY,
|
||||
rateLimits: {
|
||||
maxRequestsPerSecond: dataProvidersConfig.IEX_RATE_LIMIT
|
||||
}
|
||||
maxRequestsPerSecond: dataProvidersConfig.IEX_RATE_LIMIT,
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
|
|
@ -127,9 +131,7 @@ export function getProviderConfig(providerName: string) {
|
|||
*/
|
||||
export function getEnabledProviders() {
|
||||
const providers = ['alpaca', 'polygon', 'yahoo', 'iex'];
|
||||
return providers
|
||||
.map(provider => getProviderConfig(provider))
|
||||
.filter(config => config.enabled);
|
||||
return providers.map(provider => getProviderConfig(provider)).filter(config => config.enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -155,7 +157,6 @@ export class DataProviders {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// Export individual config values for convenience
|
||||
export const {
|
||||
DEFAULT_DATA_PROVIDER,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
/**
|
||||
* Environment validation utilities using Yup
|
||||
*/
|
||||
import * as yup from 'yup';
|
||||
import { config } from 'dotenv';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { config } from 'dotenv';
|
||||
import * as yup from 'yup';
|
||||
|
||||
// Function to find and load environment variables
|
||||
function loadEnvFiles() {
|
||||
|
|
@ -59,17 +59,14 @@ export function createEnvSchema(shape: Record<string, any>) {
|
|||
/**
|
||||
* Validates environment variables against a Yup schema
|
||||
*/
|
||||
export function validateEnv(
|
||||
schema: yup.ObjectSchema<any>,
|
||||
env = process.env
|
||||
): any {
|
||||
export function validateEnv(schema: yup.ObjectSchema<any>, env = process.env): any {
|
||||
try {
|
||||
const result = schema.validateSync(env, { abortEarly: false });
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof yup.ValidationError) {
|
||||
console.error('❌ Invalid environment variables:');
|
||||
error.inner.forEach((err) => {
|
||||
error.inner.forEach(err => {
|
||||
console.error(` ${err.path}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
|
@ -94,20 +91,22 @@ export function loadEnv(path?: string) {
|
|||
*/
|
||||
export const envValidators = {
|
||||
// String with default
|
||||
str: (defaultValue?: string, description?: string) =>
|
||||
yup.string().default(defaultValue || ''),
|
||||
str: (defaultValue?: string, description?: string) => yup.string().default(defaultValue || ''),
|
||||
|
||||
// String with choices (enum)
|
||||
strWithChoices: (choices: string[], defaultValue?: string, description?: string) =>
|
||||
yup.string().oneOf(choices).default(defaultValue || choices[0]),
|
||||
yup
|
||||
.string()
|
||||
.oneOf(choices)
|
||||
.default(defaultValue || choices[0]),
|
||||
|
||||
// Required string
|
||||
requiredStr: (description?: string) =>
|
||||
yup.string().required('Required'),
|
||||
requiredStr: (description?: string) => yup.string().required('Required'),
|
||||
|
||||
// Port number
|
||||
port: (defaultValue?: number, description?: string) =>
|
||||
yup.number()
|
||||
yup
|
||||
.number()
|
||||
.integer()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
|
|
@ -121,7 +120,8 @@ export const envValidators = {
|
|||
|
||||
// Number with default
|
||||
num: (defaultValue?: number, description?: string) =>
|
||||
yup.number()
|
||||
yup
|
||||
.number()
|
||||
.transform((val, originalVal) => {
|
||||
if (typeof originalVal === 'string') {
|
||||
return parseFloat(originalVal);
|
||||
|
|
@ -132,7 +132,8 @@ export const envValidators = {
|
|||
|
||||
// Boolean with default
|
||||
bool: (defaultValue?: boolean, description?: string) =>
|
||||
yup.boolean()
|
||||
yup
|
||||
.boolean()
|
||||
.transform((val, originalVal) => {
|
||||
if (typeof originalVal === 'string') {
|
||||
return originalVal === 'true' || originalVal === '1';
|
||||
|
|
@ -143,11 +144,13 @@ export const envValidators = {
|
|||
|
||||
// URL validation
|
||||
url: (defaultValue?: string, description?: string) =>
|
||||
yup.string().url().default(defaultValue || 'http://localhost'),
|
||||
yup
|
||||
.string()
|
||||
.url()
|
||||
.default(defaultValue || 'http://localhost'),
|
||||
|
||||
// Email validation
|
||||
email: (description?: string) =>
|
||||
yup.string().email(),
|
||||
email: (description?: string) => yup.string().email(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const loggingConfig = cleanEnv(process.env, {
|
|||
LOG_STRUCTURED: bool(true, 'Use structured logging format'),
|
||||
LOG_TIMESTAMP: bool(true, 'Include timestamps 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_VERBOSE_MODULES: str('', 'Comma-separated list of modules for verbose logging'),
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,11 @@ export const mongodbConfig = cleanEnv(process.env, {
|
|||
// Additional Settings
|
||||
MONGODB_RETRY_WRITES: bool(true, 'Enable retryable writes'),
|
||||
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(
|
||||
['primary', 'primaryPreferred', 'secondary', 'secondaryPreferred', 'nearest'],
|
||||
'primary',
|
||||
'MongoDB read preference'
|
||||
),
|
||||
MONGODB_WRITE_CONCERN: str('majority', 'Write concern level'),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,11 @@ export const grafanaConfig = cleanEnv(process.env, {
|
|||
GRAFANA_SECRET_KEY: str('', 'Grafana secret key for encryption'),
|
||||
|
||||
// Database Settings
|
||||
GRAFANA_DATABASE_TYPE: strWithChoices(['mysql', 'postgres', 'sqlite3'], 'sqlite3', 'Grafana database type'),
|
||||
GRAFANA_DATABASE_TYPE: strWithChoices(
|
||||
['mysql', 'postgres', 'sqlite3'],
|
||||
'sqlite3',
|
||||
'Grafana database type'
|
||||
),
|
||||
GRAFANA_DATABASE_URL: str('', 'Grafana database URL'),
|
||||
|
||||
// Feature Flags
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
* environment loading, validation across modules, and type exports.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { setTestEnv, clearEnvVars, getMinimalTestEnv } from '../test/setup';
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { clearEnvVars, getMinimalTestEnv, setTestEnv } from '../test/setup';
|
||||
|
||||
describe('Config Library Integration', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -14,23 +14,24 @@ describe('Config Library Integration', () => {
|
|||
// Note: Bun handles module caching differently than Jest
|
||||
});
|
||||
|
||||
describe('Complete Configuration Loading', () => { test('should load all configuration modules successfully', async () => {
|
||||
describe('Complete Configuration Loading', () => {
|
||||
test('should load all configuration modules successfully', async () => {
|
||||
setTestEnv(getMinimalTestEnv());
|
||||
// Import all modules
|
||||
// Import all modules
|
||||
const [
|
||||
{ Environment, getEnvironment },
|
||||
{ postgresConfig },
|
||||
{ questdbConfig },
|
||||
{ mongodbConfig },
|
||||
{ loggingConfig },
|
||||
{ riskConfig }
|
||||
{ riskConfig },
|
||||
] = await Promise.all([
|
||||
import('../src/core'),
|
||||
import('../src/postgres'),
|
||||
import('../src/questdb'),
|
||||
import('../src/mongodb'),
|
||||
import('../src/logging'),
|
||||
import('../src/risk')
|
||||
import('../src/risk'),
|
||||
]);
|
||||
|
||||
// Verify all configs are loaded
|
||||
|
|
@ -41,15 +42,17 @@ describe('Config Library Integration', () => {
|
|||
expect(mongodbConfig).toBeDefined();
|
||||
expect(loggingConfig).toBeDefined();
|
||||
expect(riskConfig).toBeDefined();
|
||||
// Verify core utilities
|
||||
// Verify core utilities
|
||||
expect(getEnvironment()).toBe(Environment.Testing); // Should be Testing due to NODE_ENV=test in setup
|
||||
expect(postgresConfig.POSTGRES_HOST).toBe('localhost'); expect(questdbConfig.QUESTDB_HOST).toBe('localhost');
|
||||
expect(postgresConfig.POSTGRES_HOST).toBe('localhost');
|
||||
expect(questdbConfig.QUESTDB_HOST).toBe('localhost');
|
||||
expect(mongodbConfig.MONGODB_HOST).toBe('localhost'); // fix: use correct property
|
||||
expect(loggingConfig.LOG_LEVEL).toBeDefined();
|
||||
expect(riskConfig.RISK_MAX_POSITION_SIZE).toBe(0.1);
|
||||
}); test('should handle missing required environment variables gracefully', async () => {
|
||||
});
|
||||
test('should handle missing required environment variables gracefully', async () => {
|
||||
setTestEnv({
|
||||
NODE_ENV: 'test'
|
||||
NODE_ENV: 'test',
|
||||
// Missing required variables
|
||||
});
|
||||
|
||||
|
|
@ -57,7 +60,7 @@ describe('Config Library Integration', () => {
|
|||
const { Environment, getEnvironment } = await import('../src/core');
|
||||
expect(Environment).toBeDefined();
|
||||
expect(getEnvironment()).toBe(Environment.Testing);
|
||||
// Should fail to load modules requiring specific vars (if they have required vars)
|
||||
// Should fail to load modules requiring specific vars (if they have required vars)
|
||||
// Note: Most modules have defaults, so they might not throw
|
||||
try {
|
||||
const { postgresConfig } = await import('../src/postgres');
|
||||
|
|
@ -67,35 +70,37 @@ describe('Config Library Integration', () => {
|
|||
// If it throws, that's also acceptable behavior
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
}); test('should maintain consistency across environment detection', async () => {
|
||||
});
|
||||
test('should maintain consistency across environment detection', async () => {
|
||||
setTestEnv({
|
||||
NODE_ENV: 'production',
|
||||
...getMinimalTestEnv()
|
||||
...getMinimalTestEnv(),
|
||||
});
|
||||
const [
|
||||
const [
|
||||
{ Environment, getEnvironment },
|
||||
{ postgresConfig },
|
||||
{ questdbConfig },
|
||||
{ mongodbConfig },
|
||||
{ loggingConfig }
|
||||
{ loggingConfig },
|
||||
] = await Promise.all([
|
||||
import('../src/core'),
|
||||
import('../src/postgres'),
|
||||
import('../src/questdb'),
|
||||
import('../src/mongodb'),
|
||||
import('../src/logging')
|
||||
import('../src/logging'),
|
||||
]);
|
||||
// Note: Due to module caching, environment is set at first import
|
||||
// Note: Due to module caching, environment is set at first import
|
||||
// All modules should detect the same environment (which will be Testing due to test setup)
|
||||
expect(getEnvironment()).toBe(Environment.Testing);
|
||||
// Production-specific defaults should be consistent
|
||||
// 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
|
||||
expect(loggingConfig.LOG_FORMAT).toBe('json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Main Index Exports', () => { test('should export all configuration objects from index', async () => {
|
||||
describe('Main Index Exports', () => {
|
||||
test('should export all configuration objects from index', async () => {
|
||||
setTestEnv(getMinimalTestEnv());
|
||||
|
||||
const config = await import('../src/index');
|
||||
|
|
@ -111,7 +116,8 @@ describe('Config Library Integration', () => {
|
|||
expect(config.mongodbConfig).toBeDefined();
|
||||
expect(config.loggingConfig).toBeDefined();
|
||||
expect(config.riskConfig).toBeDefined();
|
||||
}); test('should export individual values from index', async () => {
|
||||
});
|
||||
test('should export individual values from index', async () => {
|
||||
setTestEnv(getMinimalTestEnv());
|
||||
|
||||
const config = await import('../src/index');
|
||||
|
|
@ -132,7 +138,8 @@ describe('Config Library Integration', () => {
|
|||
|
||||
// Logging values
|
||||
expect(config.LOG_LEVEL).toBeDefined();
|
||||
}); test('should maintain type safety in exports', async () => {
|
||||
});
|
||||
test('should maintain type safety in exports', async () => {
|
||||
setTestEnv(getMinimalTestEnv());
|
||||
|
||||
const {
|
||||
|
|
@ -146,7 +153,8 @@ describe('Config Library Integration', () => {
|
|||
POSTGRES_HOST,
|
||||
POSTGRES_PORT,
|
||||
QUESTDB_HOST,
|
||||
MONGODB_HOST, RISK_MAX_POSITION_SIZE
|
||||
MONGODB_HOST,
|
||||
RISK_MAX_POSITION_SIZE,
|
||||
} = await import('../src/index');
|
||||
|
||||
// Type checking should pass
|
||||
|
|
@ -178,15 +186,15 @@ describe('Config Library Integration', () => {
|
|||
MONGODB_HOST: 'localhost',
|
||||
MONGODB_DATABASE: 'test',
|
||||
RISK_MAX_POSITION_SIZE: '0.1',
|
||||
RISK_MAX_DAILY_LOSS: '0.05'
|
||||
}); // All imports should succeed with valid config
|
||||
RISK_MAX_DAILY_LOSS: '0.05',
|
||||
}); // All imports should succeed with valid config
|
||||
const [core, postgres, questdb, mongodb, logging, risk] = await Promise.all([
|
||||
import('../src/core'),
|
||||
import('../src/postgres'),
|
||||
import('../src/questdb'),
|
||||
import('../src/mongodb'),
|
||||
import('../src/logging'),
|
||||
import('../src/risk')
|
||||
import('../src/risk'),
|
||||
]);
|
||||
|
||||
expect(core.getEnvironment()).toBe(core.Environment.Testing); // default test env
|
||||
|
|
@ -195,7 +203,8 @@ describe('Config Library Integration', () => {
|
|||
expect(mongodb.mongodbConfig.MONGODB_HOST).toBe('localhost');
|
||||
expect(logging.loggingConfig.LOG_LEVEL).toBe('info'); // set in test
|
||||
expect(risk.riskConfig.RISK_MAX_POSITION_SIZE).toBe(0.1); // from test env
|
||||
}); test('should accept valid environment variables across all modules', async () => {
|
||||
});
|
||||
test('should accept valid environment variables across all modules', async () => {
|
||||
setTestEnv({
|
||||
NODE_ENV: 'development',
|
||||
LOG_LEVEL: 'debug',
|
||||
|
|
@ -218,7 +227,7 @@ describe('Config Library Integration', () => {
|
|||
RISK_MAX_DAILY_LOSS: '0.025',
|
||||
|
||||
LOG_FORMAT: 'json',
|
||||
LOG_FILE_ENABLED: 'false'
|
||||
LOG_FILE_ENABLED: 'false',
|
||||
});
|
||||
|
||||
// All imports should succeed
|
||||
|
|
@ -228,7 +237,7 @@ describe('Config Library Integration', () => {
|
|||
import('../src/questdb'),
|
||||
import('../src/mongodb'),
|
||||
import('../src/logging'),
|
||||
import('../src/risk')
|
||||
import('../src/risk'),
|
||||
]);
|
||||
|
||||
// Since this is the first test to set NODE_ENV to development and modules might not be cached yet,
|
||||
|
|
@ -242,7 +251,8 @@ describe('Config Library Integration', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Configuration Consistency', () => { test('should maintain consistent SSL settings across databases', async () => {
|
||||
describe('Configuration Consistency', () => {
|
||||
test('should maintain consistent SSL settings across databases', async () => {
|
||||
setTestEnv({
|
||||
NODE_ENV: 'production',
|
||||
POSTGRES_HOST: 'prod-postgres.com',
|
||||
|
|
@ -253,30 +263,28 @@ describe('Config Library Integration', () => {
|
|||
MONGODB_HOST: 'prod-mongo.com',
|
||||
MONGODB_DATABASE: 'prod_db',
|
||||
RISK_MAX_POSITION_SIZE: '0.1',
|
||||
RISK_MAX_DAILY_LOSS: '0.05'
|
||||
RISK_MAX_DAILY_LOSS: '0.05',
|
||||
// SSL settings not explicitly set - should use defaults
|
||||
});
|
||||
|
||||
const [postgres, questdb, mongodb] = await Promise.all([
|
||||
import('../src/postgres'),
|
||||
import('../src/questdb'),
|
||||
import('../src/mongodb')
|
||||
import('../src/mongodb'),
|
||||
]);
|
||||
|
||||
// Check actual SSL property names and their default values expect(postgres.postgresConfig.POSTGRES_SSL).toBe(false); // default is false
|
||||
expect(questdb.questdbConfig.QUESTDB_TLS_ENABLED).toBe(false); // default is false
|
||||
expect(mongodb.mongodbConfig.MONGODB_TLS).toBe(false); // default is false
|
||||
}); test('should maintain consistent environment detection across modules', async () => {
|
||||
});
|
||||
test('should maintain consistent environment detection across modules', async () => {
|
||||
setTestEnv({
|
||||
NODE_ENV: 'staging',
|
||||
...getMinimalTestEnv()
|
||||
...getMinimalTestEnv(),
|
||||
});
|
||||
|
||||
const [core, logging] = await Promise.all([
|
||||
import('../src/core'),
|
||||
import('../src/logging')
|
||||
]);
|
||||
expect(core.getEnvironment()).toBe(core.Environment.Testing); // Module caching means test env persists
|
||||
const [core, logging] = await Promise.all([import('../src/core'), import('../src/logging')]);
|
||||
expect(core.getEnvironment()).toBe(core.Environment.Testing); // Module caching means test env persists
|
||||
|
||||
// The setTestEnv call above doesn't actually change the real NODE_ENV because modules cache it
|
||||
// So we check that the test setup is working correctly
|
||||
|
|
@ -284,7 +292,8 @@ describe('Config Library Integration', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Performance and Caching', () => { test('should cache configuration values between imports', async () => {
|
||||
describe('Performance and Caching', () => {
|
||||
test('should cache configuration values between imports', async () => {
|
||||
setTestEnv(getMinimalTestEnv());
|
||||
|
||||
// Import the same module multiple times
|
||||
|
|
@ -309,7 +318,7 @@ describe('Config Library Integration', () => {
|
|||
import('../src/questdb'),
|
||||
import('../src/mongodb'),
|
||||
import('../src/logging'),
|
||||
import('../src/risk')
|
||||
import('../src/risk'),
|
||||
]);
|
||||
|
||||
const endTime = Date.now();
|
||||
|
|
@ -322,7 +331,7 @@ describe('Config Library Integration', () => {
|
|||
describe('Error Handling and Recovery', () => {
|
||||
test('should provide helpful error messages for missing variables', async () => {
|
||||
setTestEnv({
|
||||
NODE_ENV: 'test'
|
||||
NODE_ENV: 'test',
|
||||
// Missing required variables
|
||||
});
|
||||
|
||||
|
|
@ -345,7 +354,8 @@ describe('Config Library Integration', () => {
|
|||
// If it throws, check that error message is helpful
|
||||
expect((error as Error).message).toBeTruthy();
|
||||
}
|
||||
}); test('should handle partial configuration failures gracefully', async () => {
|
||||
});
|
||||
test('should handle partial configuration failures gracefully', async () => {
|
||||
setTestEnv({
|
||||
NODE_ENV: 'test',
|
||||
LOG_LEVEL: 'info',
|
||||
|
|
@ -355,7 +365,7 @@ describe('Config Library Integration', () => {
|
|||
POSTGRES_USERNAME: 'test',
|
||||
POSTGRES_PASSWORD: 'test',
|
||||
// Postgres should work
|
||||
QUESTDB_HOST: 'localhost'
|
||||
QUESTDB_HOST: 'localhost',
|
||||
// QuestDB should work
|
||||
// MongoDB and Risk should work with defaults
|
||||
});
|
||||
|
|
@ -382,10 +392,10 @@ describe('Config Library Integration', () => {
|
|||
NODE_ENV: 'development',
|
||||
...getMinimalTestEnv(),
|
||||
POSTGRES_SSL: undefined, // Should default to false
|
||||
QUESTDB_TLS_ENABLED: undefined, // Should default to false
|
||||
MONGODB_TLS: undefined, // Should default to false
|
||||
LOG_FORMAT: undefined, // Should default to json
|
||||
RISK_CIRCUIT_BREAKER_ENABLED: undefined // Should default to true
|
||||
QUESTDB_TLS_ENABLED: undefined, // Should default to false
|
||||
MONGODB_TLS: undefined, // Should default to false
|
||||
LOG_FORMAT: undefined, // Should default to json
|
||||
RISK_CIRCUIT_BREAKER_ENABLED: undefined, // Should default to true
|
||||
});
|
||||
|
||||
const [core, postgres, questdb, mongodb, logging, risk] = await Promise.all([
|
||||
|
|
@ -394,11 +404,12 @@ describe('Config Library Integration', () => {
|
|||
import('../src/questdb'),
|
||||
import('../src/mongodb'),
|
||||
import('../src/logging'),
|
||||
import('../src/risk')
|
||||
import('../src/risk'),
|
||||
]);
|
||||
expect(core.getEnvironment()).toBe(core.Environment.Testing); // Module caching means test env persists
|
||||
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);
|
||||
expect(questdb.questdbConfig.QUESTDB_TLS_ENABLED).toBe(false);
|
||||
expect(mongodb.mongodbConfig.MONGODB_TLS).toBe(false);
|
||||
expect(logging.loggingConfig.LOG_FORMAT).toBe('json'); // default
|
||||
expect(risk.riskConfig.RISK_CIRCUIT_BREAKER_ENABLED).toBe(true); // default
|
||||
});
|
||||
|
|
@ -408,10 +419,10 @@ describe('Config Library Integration', () => {
|
|||
NODE_ENV: 'production',
|
||||
...getMinimalTestEnv(),
|
||||
POSTGRES_SSL: undefined, // Should default to false (same as dev)
|
||||
QUESTDB_TLS_ENABLED: undefined, // Should default to false
|
||||
MONGODB_TLS: undefined, // Should default to false
|
||||
LOG_FORMAT: undefined, // Should default to json
|
||||
RISK_CIRCUIT_BREAKER_ENABLED: undefined // Should default to true
|
||||
QUESTDB_TLS_ENABLED: undefined, // Should default to false
|
||||
MONGODB_TLS: undefined, // Should default to false
|
||||
LOG_FORMAT: undefined, // Should default to json
|
||||
RISK_CIRCUIT_BREAKER_ENABLED: undefined, // Should default to true
|
||||
});
|
||||
|
||||
const [core, postgres, questdb, mongodb, logging, risk] = await Promise.all([
|
||||
|
|
@ -420,7 +431,8 @@ describe('Config Library Integration', () => {
|
|||
import('../src/questdb'),
|
||||
import('../src/mongodb'),
|
||||
import('../src/logging'),
|
||||
import('../src/risk') ]);
|
||||
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
|
||||
|
|
|
|||
|
|
@ -48,17 +48,18 @@ export function clearEnvVars(vars: string[]): void {
|
|||
*/
|
||||
export function getCleanEnv(): typeof process.env {
|
||||
return {
|
||||
NODE_ENV: 'test'
|
||||
NODE_ENV: 'test',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create minimal required environment variables
|
||||
*/
|
||||
export function getMinimalTestEnv(): Record<string, string> { return {
|
||||
export function getMinimalTestEnv(): Record<string, string> {
|
||||
return {
|
||||
NODE_ENV: 'test',
|
||||
// Logging
|
||||
LOG_LEVEL: 'info', // Changed from 'error' to 'info' to match test expectations
|
||||
LOG_LEVEL: 'info', // Changed from 'error' to 'info' to match test expectations
|
||||
// Database
|
||||
POSTGRES_HOST: 'localhost',
|
||||
POSTGRES_PORT: '5432',
|
||||
|
|
@ -87,6 +88,6 @@ export function getMinimalTestEnv(): Record<string, string> { return {
|
|||
RISK_MAX_POSITION_SIZE: '0.1',
|
||||
RISK_MAX_DAILY_LOSS: '0.05',
|
||||
// Admin
|
||||
ADMIN_PORT: '8080'
|
||||
ADMIN_PORT: '8080',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export class DataFrame {
|
|||
return new DataFrame(this.data.slice(0, n), {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
dtypes: this._dtypes,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ export class DataFrame {
|
|||
return new DataFrame(this.data.slice(-n), {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
dtypes: this._dtypes,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ export class DataFrame {
|
|||
return new DataFrame(slice, {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
dtypes: this._dtypes,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +151,7 @@ export class DataFrame {
|
|||
return new DataFrame(newData, {
|
||||
columns: validColumns,
|
||||
index: this._index,
|
||||
dtypes: this.filterDtypes(validColumns)
|
||||
dtypes: this.filterDtypes(validColumns),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -174,17 +174,15 @@ export class DataFrame {
|
|||
|
||||
const newData = this.data.map((row, index) => ({
|
||||
...row,
|
||||
[column]: values[index]
|
||||
[column]: values[index],
|
||||
}));
|
||||
|
||||
const newColumns = this._columns.includes(column)
|
||||
? this._columns
|
||||
: [...this._columns, column];
|
||||
const newColumns = this._columns.includes(column) ? this._columns : [...this._columns, column];
|
||||
|
||||
return new DataFrame(newData, {
|
||||
columns: newColumns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
dtypes: this._dtypes,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -194,7 +192,7 @@ export class DataFrame {
|
|||
return new DataFrame(filteredData, {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
dtypes: this._dtypes,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -202,13 +200,20 @@ export class DataFrame {
|
|||
return this.filter(row => {
|
||||
const cellValue = row[column];
|
||||
switch (operator) {
|
||||
case '>': return cellValue > value;
|
||||
case '<': return cellValue < value;
|
||||
case '>=': return cellValue >= value;
|
||||
case '<=': return cellValue <= value;
|
||||
case '==': return cellValue === value;
|
||||
case '!=': return cellValue !== value;
|
||||
default: return false;
|
||||
case '>':
|
||||
return cellValue > value;
|
||||
case '<':
|
||||
return cellValue < value;
|
||||
case '>=':
|
||||
return cellValue >= value;
|
||||
case '<=':
|
||||
return cellValue <= value;
|
||||
case '==':
|
||||
return cellValue === value;
|
||||
case '!=':
|
||||
return cellValue !== value;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -228,7 +233,7 @@ export class DataFrame {
|
|||
return new DataFrame(sortedData, {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
dtypes: this._dtypes,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -249,7 +254,7 @@ export class DataFrame {
|
|||
result[key] = new DataFrame(rows, {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
dtypes: this._dtypes,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -333,7 +338,7 @@ export class DataFrame {
|
|||
const tempDf = new DataFrame(rows, {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
dtypes: this._dtypes,
|
||||
});
|
||||
|
||||
// Create OHLCV aggregation
|
||||
|
|
@ -343,7 +348,7 @@ export class DataFrame {
|
|||
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,
|
||||
volume: tempDf.sum('volume') || 0
|
||||
volume: tempDf.sum('volume') || 0,
|
||||
};
|
||||
|
||||
aggregatedData.push(aggregated);
|
||||
|
|
@ -371,7 +376,7 @@ export class DataFrame {
|
|||
const tempDf = new DataFrame(rows, {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
dtypes: this._dtypes,
|
||||
});
|
||||
|
||||
const aggregated: DataFrameRow = {
|
||||
|
|
@ -380,7 +385,7 @@ export class DataFrame {
|
|||
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,
|
||||
volume: tempDf.sum('volume') || 0
|
||||
volume: tempDf.sum('volume') || 0,
|
||||
};
|
||||
|
||||
aggregatedData.push(aggregated);
|
||||
|
|
@ -391,11 +396,14 @@ export class DataFrame {
|
|||
|
||||
// Utility methods
|
||||
copy(): DataFrame {
|
||||
return new DataFrame(this.data.map(row => ({ ...row })), {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: { ...this._dtypes }
|
||||
});
|
||||
return new DataFrame(
|
||||
this.data.map(row => ({ ...row })),
|
||||
{
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: { ...this._dtypes },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
concat(other: DataFrame): DataFrame {
|
||||
|
|
@ -405,7 +413,7 @@ export class DataFrame {
|
|||
return new DataFrame(combinedData, {
|
||||
columns: combinedColumns,
|
||||
index: this._index,
|
||||
dtypes: { ...this._dtypes, ...other._dtypes }
|
||||
dtypes: { ...this._dtypes, ...other._dtypes },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -417,7 +425,9 @@ export class DataFrame {
|
|||
return JSON.stringify(this.data);
|
||||
}
|
||||
|
||||
private filterDtypes(columns: string[]): Record<string, 'number' | 'string' | 'boolean' | 'date'> {
|
||||
private filterDtypes(
|
||||
columns: string[]
|
||||
): Record<string, 'number' | 'string' | 'boolean' | 'date'> {
|
||||
const filtered: Record<string, 'number' | 'string' | 'boolean' | 'date'> = {};
|
||||
for (const col of columns) {
|
||||
if (this._dtypes[col]) {
|
||||
|
|
@ -480,6 +490,6 @@ export function readCSV(csvData: string, options?: DataFrameOptions): DataFrame
|
|||
|
||||
return new DataFrame(data, {
|
||||
columns: headers,
|
||||
...options
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { EventEmitter } from 'eventemitter3';
|
||||
import Redis from 'ioredis';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { dragonflyConfig } from '@stock-bot/config';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
|
||||
export interface EventBusMessage {
|
||||
id: string;
|
||||
|
|
@ -72,7 +72,9 @@ export class EventBus extends EventEmitter {
|
|||
this.subscriber.on('message', this.handleRedisMessage.bind(this));
|
||||
}
|
||||
|
||||
this.logger.info(`Redis event bus initialized (mode: ${this.useStreams ? 'streams' : 'pub/sub'})`);
|
||||
this.logger.info(
|
||||
`Redis event bus initialized (mode: ${this.useStreams ? 'streams' : 'pub/sub'})`
|
||||
);
|
||||
}
|
||||
|
||||
private handleRedisMessage(channel: string, message: string) {
|
||||
|
|
@ -90,7 +92,11 @@ export class EventBus extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
async publish<T = any>(type: string, data: T, metadata?: Record<string, any>): Promise<string | null> {
|
||||
async publish<T = any>(
|
||||
type: string,
|
||||
data: T,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<string | null> {
|
||||
const message: EventBusMessage = {
|
||||
id: this.generateId(),
|
||||
type,
|
||||
|
|
@ -109,17 +115,23 @@ export class EventBus extends EventEmitter {
|
|||
const messageId = await this.redis.xadd(
|
||||
streamKey,
|
||||
'*',
|
||||
'id', message.id,
|
||||
'type', message.type,
|
||||
'source', message.source,
|
||||
'timestamp', message.timestamp.toString(),
|
||||
'data', JSON.stringify(message.data),
|
||||
'metadata', JSON.stringify(message.metadata || {})
|
||||
'id',
|
||||
message.id,
|
||||
'type',
|
||||
message.type,
|
||||
'source',
|
||||
message.source,
|
||||
'timestamp',
|
||||
message.timestamp.toString(),
|
||||
'data',
|
||||
JSON.stringify(message.data),
|
||||
'metadata',
|
||||
JSON.stringify(message.metadata || {})
|
||||
);
|
||||
|
||||
this.logger.debug(`Published event to stream: ${type}`, {
|
||||
messageId,
|
||||
streamId: messageId
|
||||
streamId: messageId,
|
||||
});
|
||||
return messageId as string;
|
||||
} else {
|
||||
|
|
@ -156,7 +168,10 @@ export class EventBus extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
private async subscribeToStream<T = any>(eventType: string, handler: EventHandler<T>): Promise<void> {
|
||||
private async subscribeToStream<T = any>(
|
||||
eventType: string,
|
||||
handler: EventHandler<T>
|
||||
): Promise<void> {
|
||||
const streamKey = `events:${eventType}`;
|
||||
const groupName = `${eventType}-consumers`;
|
||||
const consumerName = `${this.serviceName}-${Date.now()}`;
|
||||
|
|
@ -194,10 +209,16 @@ export class EventBus extends EventEmitter {
|
|||
await this.claimPendingMessages(streamKey, groupName, consumerName, handler);
|
||||
|
||||
const messages = await this.redis.xreadgroup(
|
||||
'GROUP', groupName, consumerName,
|
||||
'COUNT', 10,
|
||||
'BLOCK', 1000,
|
||||
'STREAMS', streamKey, '>'
|
||||
'GROUP',
|
||||
groupName,
|
||||
consumerName,
|
||||
'COUNT',
|
||||
10,
|
||||
'BLOCK',
|
||||
1000,
|
||||
'STREAMS',
|
||||
streamKey,
|
||||
'>'
|
||||
);
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
|
|
@ -264,14 +285,16 @@ export class EventBus extends EventEmitter {
|
|||
|
||||
this.logger.debug(`Processed stream message: ${msgId}`, {
|
||||
eventType: message.type,
|
||||
source: message.source
|
||||
source: message.source,
|
||||
});
|
||||
|
||||
return;
|
||||
|
||||
} catch (error) {
|
||||
retryCount++;
|
||||
this.logger.error(`Error processing stream message ${msgId} (attempt ${retryCount}):`, error);
|
||||
this.logger.error(
|
||||
`Error processing stream message ${msgId} (attempt ${retryCount}):`,
|
||||
error
|
||||
);
|
||||
|
||||
if (retryCount >= this.maxRetries) {
|
||||
await this.moveToDeadLetterQueue(msgId, fields, streamKey, groupName, error);
|
||||
|
|
@ -290,13 +313,13 @@ export class EventBus extends EventEmitter {
|
|||
handler: EventHandler
|
||||
): Promise<void> {
|
||||
try {
|
||||
const pendingMessages = await this.redis.xpending(
|
||||
const pendingMessages = (await this.redis.xpending(
|
||||
streamKey,
|
||||
groupName,
|
||||
'-',
|
||||
'+',
|
||||
10
|
||||
) as any[];
|
||||
)) as any[];
|
||||
|
||||
if (!pendingMessages || pendingMessages.length === 0) {
|
||||
return;
|
||||
|
|
@ -311,13 +334,13 @@ export class EventBus extends EventEmitter {
|
|||
}
|
||||
|
||||
const messageIds = oldMessages.map((msg: any[]) => msg[0]);
|
||||
const claimedMessages = await this.redis.xclaim(
|
||||
const claimedMessages = (await this.redis.xclaim(
|
||||
streamKey,
|
||||
groupName,
|
||||
consumerName,
|
||||
60000,
|
||||
...messageIds
|
||||
) as [string, string[]][];
|
||||
)) as [string, string[]][];
|
||||
|
||||
for (const [msgId, fields] of claimedMessages) {
|
||||
await this.processStreamMessage(msgId, fields, streamKey, groupName, handler);
|
||||
|
|
@ -343,22 +366,35 @@ export class EventBus extends EventEmitter {
|
|||
await this.redis.xadd(
|
||||
dlqKey,
|
||||
'*',
|
||||
'original_id', msgId,
|
||||
'original_stream', streamKey,
|
||||
'error', (error as Error).message || 'Unknown error',
|
||||
'timestamp', Date.now().toString(),
|
||||
'id', message.id,
|
||||
'type', message.type,
|
||||
'source', message.source,
|
||||
'data', JSON.stringify(message.data),
|
||||
'metadata', JSON.stringify(message.metadata || {})
|
||||
'original_id',
|
||||
msgId,
|
||||
'original_stream',
|
||||
streamKey,
|
||||
'error',
|
||||
(error as Error).message || 'Unknown error',
|
||||
'timestamp',
|
||||
Date.now().toString(),
|
||||
'id',
|
||||
message.id,
|
||||
'type',
|
||||
message.type,
|
||||
'source',
|
||||
message.source,
|
||||
'data',
|
||||
JSON.stringify(message.data),
|
||||
'metadata',
|
||||
JSON.stringify(message.metadata || {})
|
||||
);
|
||||
|
||||
await this.redis.xack(streamKey, groupName, msgId);
|
||||
|
||||
this.logger.warn(`Moved message ${msgId} to dead letter queue: ${dlqKey}`, { error: (error as Error).message });
|
||||
this.logger.warn(`Moved message ${msgId} to dead letter queue: ${dlqKey}`, {
|
||||
error: (error as Error).message,
|
||||
});
|
||||
} catch (dlqError) {
|
||||
this.logger.error(`Failed to move message ${msgId} to dead letter queue:`, { error: dlqError });
|
||||
this.logger.error(`Failed to move message ${msgId} to dead letter queue:`, {
|
||||
error: dlqError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -393,8 +429,9 @@ export class EventBus extends EventEmitter {
|
|||
if (this.enablePersistence) {
|
||||
try {
|
||||
if (this.useStreams) {
|
||||
const consumersToStop = Array.from(this.consumers.entries())
|
||||
.filter(([key]) => key.startsWith(`${eventType}-`));
|
||||
const consumersToStop = Array.from(this.consumers.entries()).filter(([key]) =>
|
||||
key.startsWith(`${eventType}-`)
|
||||
);
|
||||
|
||||
for (const [key, consumerInfo] of consumersToStop) {
|
||||
consumerInfo.isRunning = false;
|
||||
|
|
@ -479,14 +516,17 @@ export class EventBus extends EventEmitter {
|
|||
let messages: [string, string[]][];
|
||||
|
||||
if (count) {
|
||||
messages = await this.redis.xrange(streamKey, startId, endId, 'COUNT', count) as [string, string[]][];
|
||||
messages = (await this.redis.xrange(streamKey, startId, endId, 'COUNT', count)) as [
|
||||
string,
|
||||
string[],
|
||||
][];
|
||||
} else {
|
||||
messages = await this.redis.xrange(streamKey, startId, endId) as [string, string[]][];
|
||||
messages = (await this.redis.xrange(streamKey, startId, endId)) as [string, string[]][];
|
||||
}
|
||||
|
||||
return messages.map(([id, fields]) => ({
|
||||
...this.parseStreamMessage(fields),
|
||||
id
|
||||
id,
|
||||
}));
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to read stream history for: ${eventType}`, error);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios';
|
||||
import type { RequestConfig, HttpResponse } from '../types';
|
||||
import type { RequestAdapter } from './types';
|
||||
import { ProxyManager } from '../proxy-manager';
|
||||
import type { HttpResponse, RequestConfig } from '../types';
|
||||
import { HttpError } from '../types';
|
||||
import type { RequestAdapter } from './types';
|
||||
|
||||
/**
|
||||
* Axios adapter for SOCKS proxies
|
||||
|
|
@ -10,7 +10,9 @@ import { HttpError } from '../types';
|
|||
export class AxiosAdapter implements RequestAdapter {
|
||||
canHandle(config: RequestConfig): boolean {
|
||||
// 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>> {
|
||||
|
|
@ -30,7 +32,8 @@ export class AxiosAdapter implements RequestAdapter {
|
|||
signal,
|
||||
// Don't throw on non-2xx status codes - let caller handle
|
||||
validateStatus: () => true,
|
||||
}; const response: AxiosResponse<T> = await axios(axiosConfig);
|
||||
};
|
||||
const response: AxiosResponse<T> = await axios(axiosConfig);
|
||||
|
||||
const httpResponse: HttpResponse<T> = {
|
||||
data: response.data,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { RequestConfig } from '../types';
|
||||
import type { RequestAdapter } from './types';
|
||||
import { FetchAdapter } from './fetch-adapter';
|
||||
import { AxiosAdapter } from './axios-adapter';
|
||||
import { FetchAdapter } from './fetch-adapter';
|
||||
import type { RequestAdapter } from './types';
|
||||
|
||||
/**
|
||||
* Factory for creating the appropriate request adapter
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { RequestConfig, HttpResponse } from '../types';
|
||||
import type { RequestAdapter } from './types';
|
||||
import { ProxyManager } from '../proxy-manager';
|
||||
import type { HttpResponse, RequestConfig } from '../types';
|
||||
import { HttpError } from '../types';
|
||||
import type { RequestAdapter } from './types';
|
||||
|
||||
/**
|
||||
* Fetch adapter for HTTP/HTTPS proxies and non-proxy requests
|
||||
|
|
@ -33,16 +33,17 @@ export class FetchAdapter implements RequestAdapter {
|
|||
// Add proxy if needed (using Bun's built-in proxy support)
|
||||
if (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;
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
responseData = await response.json() as T;
|
||||
responseData = (await response.json()) as T;
|
||||
} else {
|
||||
responseData = await response.text() as T;
|
||||
responseData = (await response.text()) as T;
|
||||
}
|
||||
|
||||
const httpResponse: HttpResponse<T> = {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { RequestConfig, HttpResponse } from '../types';
|
||||
import type { HttpResponse, RequestConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Request adapter interface for different HTTP implementations
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
import type { Logger } from '@stock-bot/logger';
|
||||
import type {
|
||||
HttpClientConfig,
|
||||
RequestConfig,
|
||||
HttpResponse,
|
||||
} from './types';
|
||||
import { HttpError } from './types';
|
||||
import { ProxyManager } from './proxy-manager';
|
||||
import { AdapterFactory } from './adapters/index';
|
||||
import { ProxyManager } from './proxy-manager';
|
||||
import type { HttpClientConfig, HttpResponse, RequestConfig } from './types';
|
||||
import { HttpError } from './types';
|
||||
|
||||
export class HttpClient {
|
||||
private readonly config: HttpClientConfig;
|
||||
|
|
@ -18,23 +14,41 @@ export class HttpClient {
|
|||
}
|
||||
|
||||
// Convenience methods
|
||||
async get<T = any>(url: string, config: Omit<RequestConfig, 'method' | 'url'> = {}): 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 });
|
||||
}
|
||||
|
||||
async post<T = any>(url: string, data?: any, config: Omit<RequestConfig, 'method' | 'url' | 'data'> = {}): Promise<HttpResponse<T>> {
|
||||
async post<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config: Omit<RequestConfig, 'method' | 'url' | 'data'> = {}
|
||||
): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, method: 'POST', url, data });
|
||||
}
|
||||
|
||||
async put<T = any>(url: string, 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: 'PUT', url, data });
|
||||
}
|
||||
|
||||
async del<T = any>(url: string, config: Omit<RequestConfig, 'method' | 'url'> = {}): Promise<HttpResponse<T>> {
|
||||
async del<T = any>(
|
||||
url: string,
|
||||
config: Omit<RequestConfig, 'method' | 'url'> = {}
|
||||
): Promise<HttpResponse<T>> {
|
||||
return this.request<T>({ ...config, method: 'DELETE', url });
|
||||
}
|
||||
|
||||
async patch<T = any>(url: string, 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: 'PATCH', url, data });
|
||||
}
|
||||
|
||||
|
|
@ -48,7 +62,7 @@ export class HttpClient {
|
|||
this.logger?.debug('Making HTTP request', {
|
||||
method: finalConfig.method,
|
||||
url: finalConfig.url,
|
||||
hasProxy: !!finalConfig.proxy
|
||||
hasProxy: !!finalConfig.proxy,
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -64,13 +78,13 @@ export class HttpClient {
|
|||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if( this.logger?.getServiceName() === 'proxy-tasks' ) {
|
||||
if (this.logger?.getServiceName() === 'proxy-tasks') {
|
||||
this.logger?.debug('HTTP request failed', {
|
||||
method: finalConfig.method,
|
||||
url: finalConfig.url,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}else{
|
||||
} else {
|
||||
this.logger?.warn('HTTP request failed', {
|
||||
method: finalConfig.method,
|
||||
url: finalConfig.url,
|
||||
|
|
@ -83,7 +97,7 @@ export class HttpClient {
|
|||
|
||||
/**
|
||||
* Execute request with timeout handling - no race conditions
|
||||
*/ private async executeRequest<T>(config: RequestConfig): Promise<HttpResponse<T>> {
|
||||
*/ private async executeRequest<T>(config: RequestConfig): Promise<HttpResponse<T>> {
|
||||
const timeout = config.timeout ?? this.config.timeout ?? 30000;
|
||||
const controller = new AbortController();
|
||||
const startTime = Date.now();
|
||||
|
|
@ -98,7 +112,7 @@ export class HttpClient {
|
|||
url: config.url,
|
||||
method: config.method,
|
||||
timeout,
|
||||
elapsed
|
||||
elapsed,
|
||||
});
|
||||
|
||||
// Attempt to abort (may or may not work with Bun)
|
||||
|
|
@ -115,17 +129,23 @@ export class HttpClient {
|
|||
|
||||
const response = await Promise.race([
|
||||
adapter.request<T>(config, controller.signal),
|
||||
timeoutPromise
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
this.logger?.debug('Adapter request successful', { url: config.url, elapsedMs: Date.now() - startTime });
|
||||
this.logger?.debug('Adapter request successful', {
|
||||
url: config.url,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
// Clear timeout on success
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
this.logger?.debug('Adapter failed successful', { url: config.url, elapsedMs: Date.now() - startTime });
|
||||
this.logger?.debug('Adapter failed successful', {
|
||||
url: config.url,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Handle timeout
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import axios, { AxiosRequestConfig, type AxiosInstance } from 'axios';
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent';
|
||||
import type { ProxyInfo } from './types';
|
||||
|
||||
export class ProxyManager {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
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 { MockServer } from './mock-server';
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ describe('HTTP Integration Tests', () => {
|
|||
|
||||
beforeAll(() => {
|
||||
client = new HttpClient({
|
||||
timeout: 10000
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -51,7 +51,10 @@ describe('HTTP Integration Tests', () => {
|
|||
expect(Array.isArray(response.data)).toBe(true);
|
||||
expect(response.data.length).toBeGreaterThan(0);
|
||||
} 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
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -60,7 +63,7 @@ describe('HTTP Integration Tests', () => {
|
|||
const postData = {
|
||||
title: 'Integration Test Post',
|
||||
body: 'This is a test post from integration tests',
|
||||
userId: 1
|
||||
userId: 1,
|
||||
};
|
||||
|
||||
const response = await client.post('https://jsonplaceholder.typicode.com/posts', postData);
|
||||
|
|
@ -69,12 +72,16 @@ describe('HTTP Integration Tests', () => {
|
|||
expect(response.data).toHaveProperty('id');
|
||||
expect(response.data.title).toBe(postData.title);
|
||||
} catch (error) {
|
||||
console.warn('POST integration test skipped due to network issues:', (error as Error).message);
|
||||
console.warn(
|
||||
'POST integration test skipped due to network issues:',
|
||||
(error as Error).message
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error scenarios with mock server', () => { test('should handle various HTTP status codes', async () => {
|
||||
describe('Error scenarios with mock server', () => {
|
||||
test('should handle various HTTP status codes', async () => {
|
||||
const successCodes = [200, 201];
|
||||
const errorCodes = [400, 401, 403, 404, 500, 503];
|
||||
|
||||
|
|
@ -86,9 +93,9 @@ describe('HTTP Integration Tests', () => {
|
|||
|
||||
// Test error codes (should throw HttpError)
|
||||
for (const statusCode of errorCodes) {
|
||||
await expect(
|
||||
client.get(`${mockServerBaseUrl}/status/${statusCode}`)
|
||||
).rejects.toThrow(HttpError);
|
||||
await expect(client.get(`${mockServerBaseUrl}/status/${statusCode}`)).rejects.toThrow(
|
||||
HttpError
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -102,7 +109,7 @@ describe('HTTP Integration Tests', () => {
|
|||
test('should handle concurrent requests', async () => {
|
||||
const requests = Array.from({ length: 5 }, (_, i) =>
|
||||
client.get(`${mockServerBaseUrl}/`, {
|
||||
headers: { 'X-Request-ID': `req-${i}` }
|
||||
headers: { 'X-Request-ID': `req-${i}` },
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -137,7 +144,7 @@ describe('HTTP Integration Tests', () => {
|
|||
|
||||
test('should maintain connection efficiency', async () => {
|
||||
const clientWithKeepAlive = new HttpClient({
|
||||
timeout: 5000
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const requests = Array.from({ length: 3 }, () =>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
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 type { ProxyInfo } from '../src/types';
|
||||
import { MockServer } from './mock-server';
|
||||
|
|
@ -56,11 +56,11 @@ describe('HttpClient', () => {
|
|||
test('should handle custom headers', async () => {
|
||||
const customHeaders = {
|
||||
'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`, {
|
||||
headers: customHeaders
|
||||
headers: customHeaders,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -71,16 +71,12 @@ describe('HttpClient', () => {
|
|||
test('should handle timeout', async () => {
|
||||
const clientWithTimeout = new HttpClient({ timeout: 1 }); // 1ms timeout
|
||||
|
||||
await expect(
|
||||
clientWithTimeout.get('https://httpbin.org/delay/1')
|
||||
).rejects.toThrow();
|
||||
await expect(clientWithTimeout.get('https://httpbin.org/delay/1')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
describe('Error handling', () => {
|
||||
test('should handle HTTP errors', async () => {
|
||||
await expect(
|
||||
client.get(`${mockServerBaseUrl}/status/404`)
|
||||
).rejects.toThrow(HttpError);
|
||||
await expect(client.get(`${mockServerBaseUrl}/status/404`)).rejects.toThrow(HttpError);
|
||||
});
|
||||
|
||||
test('should handle network errors gracefully', async () => {
|
||||
|
|
@ -90,9 +86,7 @@ describe('HttpClient', () => {
|
|||
});
|
||||
|
||||
test('should handle invalid URLs', async () => {
|
||||
await expect(
|
||||
client.get('not:/a:valid/url')
|
||||
).rejects.toThrow();
|
||||
await expect(client.get('not:/a:valid/url')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -122,13 +116,13 @@ describe('ProxyManager', () => {
|
|||
const httpProxy: ProxyInfo = {
|
||||
protocol: 'http',
|
||||
host: 'proxy.example.com',
|
||||
port: 8080
|
||||
port: 8080,
|
||||
};
|
||||
|
||||
const socksProxy: ProxyInfo = {
|
||||
protocol: 'socks5',
|
||||
host: 'proxy.example.com',
|
||||
port: 1080
|
||||
port: 1080,
|
||||
};
|
||||
|
||||
expect(ProxyManager.shouldUseBunFetch(httpProxy)).toBe(true);
|
||||
|
|
@ -141,7 +135,8 @@ describe('ProxyManager', () => {
|
|||
host: 'proxy.example.com',
|
||||
port: 8080,
|
||||
username: 'user',
|
||||
password: 'pass' };
|
||||
password: 'pass',
|
||||
};
|
||||
|
||||
const proxyUrl = ProxyManager.createProxyUrl(proxy);
|
||||
expect(proxyUrl).toBe('http://user:pass@proxy.example.com:8080');
|
||||
|
|
@ -151,7 +146,8 @@ describe('ProxyManager', () => {
|
|||
const proxy: ProxyInfo = {
|
||||
protocol: 'https',
|
||||
host: 'proxy.example.com',
|
||||
port: 8080 };
|
||||
port: 8080,
|
||||
};
|
||||
|
||||
const proxyUrl = ProxyManager.createProxyUrl(proxy);
|
||||
expect(proxyUrl).toBe('https://proxy.example.com:8080');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { MockServer } from './mock-server';
|
||||
|
||||
/**
|
||||
|
|
@ -48,8 +48,9 @@ describe('MockServer', () => {
|
|||
const response = await fetch(`${baseUrl}/headers`, {
|
||||
headers: {
|
||||
'X-Test-Header': 'test-value',
|
||||
'User-Agent': 'MockServer-Test'
|
||||
} });
|
||||
'User-Agent': 'MockServer-Test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const data = await response.json();
|
||||
|
|
@ -66,8 +67,8 @@ describe('MockServer', () => {
|
|||
|
||||
const response = await fetch(`${baseUrl}/basic-auth/${username}/${password}`, {
|
||||
headers: {
|
||||
'Authorization': `Basic ${credentials}`
|
||||
}
|
||||
Authorization: `Basic ${credentials}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
|
|
@ -81,8 +82,8 @@ describe('MockServer', () => {
|
|||
|
||||
const response = await fetch(`${baseUrl}/basic-auth/user/pass`, {
|
||||
headers: {
|
||||
'Authorization': `Basic ${credentials}`
|
||||
}
|
||||
Authorization: `Basic ${credentials}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
|
|
@ -98,15 +99,15 @@ describe('MockServer', () => {
|
|||
test('should echo POST data', async () => {
|
||||
const testData = {
|
||||
message: 'Hello, MockServer!',
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const response = await fetch(`${baseUrl}/post`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(testData)
|
||||
body: JSON.stringify(testData),
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export class MockServer {
|
|||
|
||||
/**
|
||||
* Handle incoming requests
|
||||
*/ private async handleRequest(req: Request): Promise<Response> {
|
||||
*/ private async handleRequest(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname;
|
||||
|
||||
|
|
@ -55,17 +55,19 @@ export class MockServer {
|
|||
const status = parseInt(path.replace('/status/', ''), 10);
|
||||
console.log(`Returning status: ${status}`);
|
||||
return new Response(null, { status });
|
||||
} // Headers endpoint
|
||||
} // Headers endpoint
|
||||
if (path === '/headers') {
|
||||
const headers = Object.fromEntries([...req.headers.entries()]);
|
||||
console.log('Headers endpoint called, received headers:', headers);
|
||||
return Response.json({ headers });
|
||||
} // Basic auth endpoint
|
||||
} // Basic auth endpoint
|
||||
if (path.startsWith('/basic-auth/')) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
const expectedUsername = parts[1];
|
||||
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 ')) {
|
||||
|
|
@ -80,7 +82,7 @@ export class MockServer {
|
|||
if (username === expectedUsername && password === expectedPassword) {
|
||||
return Response.json({
|
||||
authenticated: true,
|
||||
user: username
|
||||
user: username,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +95,7 @@ export class MockServer {
|
|||
return Response.json({
|
||||
data,
|
||||
headers: Object.fromEntries([...req.headers.entries()]),
|
||||
method: req.method
|
||||
method: req.method,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +103,7 @@ export class MockServer {
|
|||
return Response.json({
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
headers: Object.fromEntries([...req.headers.entries()])
|
||||
headers: Object.fromEntries([...req.headers.entries()]),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,7 @@
|
|||
*/
|
||||
|
||||
// Core logger classes and functions
|
||||
export {
|
||||
Logger,
|
||||
getLogger,
|
||||
shutdownLoggers
|
||||
} from './logger';
|
||||
export { Logger, getLogger, shutdownLoggers } from './logger';
|
||||
|
||||
// Type definitions
|
||||
export type { LogLevel, LogContext, LogMetadata } from './types';
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
import pino from 'pino';
|
||||
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
|
||||
const loggerCache = new Map<string, pino.Logger>();
|
||||
|
|
@ -35,7 +35,7 @@ function createTransports(serviceName: string): any {
|
|||
ignore: 'pid,hostname,service,environment,version,childName',
|
||||
errorLikeObjectKeys: ['err', 'error'],
|
||||
errorProps: 'message,stack,name,code',
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -46,8 +46,8 @@ function createTransports(serviceName: string): any {
|
|||
level: loggingConfig.LOG_LEVEL,
|
||||
options: {
|
||||
destination: `${loggingConfig.LOG_FILE_PATH}/${serviceName}.log`,
|
||||
mkdir: true
|
||||
}
|
||||
mkdir: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -60,10 +60,10 @@ function createTransports(serviceName: string): any {
|
|||
host: lokiConfig.LOKI_URL || `http://${lokiConfig.LOKI_HOST}:${lokiConfig.LOKI_PORT}`,
|
||||
labels: {
|
||||
service: serviceName,
|
||||
environment: lokiConfig.LOKI_ENVIRONMENT_LABEL
|
||||
environment: lokiConfig.LOKI_ENVIRONMENT_LABEL,
|
||||
},
|
||||
ignore: 'childName',
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -82,8 +82,8 @@ function getPinoLogger(serviceName: string): pino.Logger {
|
|||
base: {
|
||||
service: serviceName,
|
||||
environment: loggingConfig.LOG_ENVIRONMENT,
|
||||
version: loggingConfig.LOG_SERVICE_VERSION
|
||||
}
|
||||
version: loggingConfig.LOG_SERVICE_VERSION,
|
||||
},
|
||||
};
|
||||
|
||||
if (transport) {
|
||||
|
|
@ -96,7 +96,6 @@ function getPinoLogger(serviceName: string): pino.Logger {
|
|||
return loggerCache.get(serviceName)!;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Simplified Logger class
|
||||
*/
|
||||
|
|
@ -138,7 +137,7 @@ export class Logger {
|
|||
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 = {};
|
||||
|
||||
// Handle metadata parameter normalization
|
||||
|
|
@ -189,21 +188,21 @@ export class Logger {
|
|||
message: error.message || error.toString(),
|
||||
...(error.stack && { stack: error.stack }),
|
||||
...(error.code && { code: error.code }),
|
||||
...(error.status && { status: error.status })
|
||||
...(error.status && { status: error.status }),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle primitives (string, number, etc.)
|
||||
return {
|
||||
name: 'UnknownError',
|
||||
message: String(error)
|
||||
message: String(error),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Create child logger with additional context
|
||||
*/
|
||||
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);
|
||||
childLogger.serviceName = this.serviceName;
|
||||
childLogger.childName = serviceName;
|
||||
|
|
@ -211,12 +210,12 @@ export class Logger {
|
|||
const childBindings = {
|
||||
service: this.serviceName,
|
||||
childName: ' -> ' + serviceName,
|
||||
...(context || childLogger.context)
|
||||
...(context || childLogger.context),
|
||||
};
|
||||
|
||||
childLogger.pino = this.pino.child(childBindings);
|
||||
return childLogger;
|
||||
// }
|
||||
// }
|
||||
// childLogger.pino = this.pino.child(context || childLogger.context); // Let pino handle level inheritance naturally
|
||||
// return childLogger;
|
||||
}
|
||||
|
|
@ -243,9 +242,9 @@ export function getLogger(serviceName: string, context?: LogContext): Logger {
|
|||
*/
|
||||
export async function shutdownLoggers(): Promise<void> {
|
||||
const flushPromises = Array.from(loggerCache.values()).map(logger => {
|
||||
return new Promise<void>((resolve) => {
|
||||
return new Promise<void>(resolve => {
|
||||
if (typeof logger.flush === 'function') {
|
||||
logger.flush((err) => {
|
||||
logger.flush(err => {
|
||||
if (err) {
|
||||
console.error('Logger flush error:', err);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* 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 { loggerTestHelpers } from './setup';
|
||||
|
||||
|
|
@ -16,7 +16,8 @@ describe('Advanced Logger Features', () => {
|
|||
beforeEach(() => {
|
||||
testLoggerInstance = loggerTestHelpers.createTestLogger('advanced-features');
|
||||
logger = testLoggerInstance.logger;
|
||||
}); afterEach(async () => {
|
||||
});
|
||||
afterEach(async () => {
|
||||
testLoggerInstance.clearCapturedLogs();
|
||||
// Clear any global logger cache
|
||||
await shutdownLoggers();
|
||||
|
|
@ -27,7 +28,7 @@ describe('Advanced Logger Features', () => {
|
|||
const complexMetadata = {
|
||||
user: { id: '123', name: 'John Doe' },
|
||||
session: { id: 'sess-456', timeout: 3600 },
|
||||
request: { method: 'POST', path: '/api/test' }
|
||||
request: { method: 'POST', path: '/api/test' },
|
||||
};
|
||||
|
||||
logger.info('Complex operation', complexMetadata);
|
||||
|
|
@ -42,7 +43,7 @@ describe('Advanced Logger Features', () => {
|
|||
it('should handle arrays in metadata', () => {
|
||||
const arrayMetadata = {
|
||||
tags: ['user', 'authentication', 'success'],
|
||||
ids: [1, 2, 3, 4]
|
||||
ids: [1, 2, 3, 4],
|
||||
};
|
||||
|
||||
logger.info('Array metadata test', arrayMetadata);
|
||||
|
|
@ -58,7 +59,7 @@ describe('Advanced Logger Features', () => {
|
|||
nullValue: null,
|
||||
undefinedValue: undefined,
|
||||
emptyString: '',
|
||||
zeroValue: 0
|
||||
zeroValue: 0,
|
||||
};
|
||||
|
||||
logger.info('Null metadata test', nullMetadata);
|
||||
|
|
@ -75,7 +76,7 @@ describe('Advanced Logger Features', () => {
|
|||
it('should create child logger with additional context', () => {
|
||||
const childLogger = logger.child({
|
||||
component: 'auth-service',
|
||||
version: '1.2.3'
|
||||
version: '1.2.3',
|
||||
});
|
||||
|
||||
childLogger.info('Child logger message');
|
||||
|
|
@ -105,7 +106,7 @@ describe('Advanced Logger Features', () => {
|
|||
|
||||
childLogger.info('Request processed', {
|
||||
requestId: 'req-789',
|
||||
duration: 150
|
||||
duration: 150,
|
||||
});
|
||||
|
||||
const logs = testLoggerInstance.getCapturedLogs();
|
||||
|
|
@ -137,14 +138,14 @@ describe('Advanced Logger Features', () => {
|
|||
logger.error('Multiple errors', {
|
||||
primaryError: error1,
|
||||
secondaryError: error2,
|
||||
context: 'batch processing'
|
||||
context: 'batch processing',
|
||||
});
|
||||
|
||||
const logs = testLoggerInstance.getCapturedLogs();
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].context).toBe('batch processing');
|
||||
});
|
||||
it('should handle error objects with circular references', () => {
|
||||
it('should handle error objects with circular references', () => {
|
||||
const errorWithCircular: any = { name: 'CircularError', message: 'Circular reference error' };
|
||||
// Create a simple circular reference
|
||||
errorWithCircular.self = errorWithCircular;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
* Tests for the core logger functionality and utilities.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { Logger, getLogger, shutdownLoggers } from '../src';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { getLogger, Logger, shutdownLoggers } from '../src';
|
||||
import { loggerTestHelpers } from './setup';
|
||||
|
||||
describe('Basic Logger Tests', () => {
|
||||
|
|
@ -60,7 +60,7 @@ describe('Basic Logger Tests', () => {
|
|||
const metadata = {
|
||||
userId: 'user123',
|
||||
sessionId: 'session456',
|
||||
requestId: 'req789'
|
||||
requestId: 'req789',
|
||||
};
|
||||
|
||||
logger.info('Request processed', metadata);
|
||||
|
|
@ -77,7 +77,7 @@ describe('Basic Logger Tests', () => {
|
|||
it('should create child loggers with additional context', () => {
|
||||
const childLogger = logger.child({
|
||||
module: 'payment',
|
||||
version: '1.0.0'
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
childLogger.info('Payment processed');
|
||||
|
|
@ -113,7 +113,7 @@ describe('Basic Logger Tests', () => {
|
|||
const errorLike = {
|
||||
name: 'ValidationError',
|
||||
message: 'Invalid input',
|
||||
code: 'VALIDATION_FAILED'
|
||||
code: 'VALIDATION_FAILED',
|
||||
};
|
||||
|
||||
logger.error('Validation failed', { error: errorLike });
|
||||
|
|
|
|||
|
|
@ -4,12 +4,8 @@
|
|||
* Tests the core functionality of the simplified @stock-bot/logger package.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import {
|
||||
Logger,
|
||||
getLogger,
|
||||
shutdownLoggers
|
||||
} from '../src';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { getLogger, Logger, shutdownLoggers } from '../src';
|
||||
import { loggerTestHelpers } from './setup';
|
||||
|
||||
describe('Logger Integration Tests', () => {
|
||||
|
|
@ -82,7 +78,7 @@ describe('Logger Integration Tests', () => {
|
|||
// Create a child logger with additional context
|
||||
const childLogger = logger.child({
|
||||
transactionId: 'tx-789',
|
||||
operation: 'payment'
|
||||
operation: 'payment',
|
||||
});
|
||||
|
||||
// Log with child logger
|
||||
|
|
@ -137,7 +133,7 @@ describe('Logger Integration Tests', () => {
|
|||
const errorLike = {
|
||||
name: 'CustomError',
|
||||
message: 'Custom error message',
|
||||
code: 'ERR_CUSTOM'
|
||||
code: 'ERR_CUSTOM',
|
||||
};
|
||||
|
||||
logger.error('Custom error occurred', { error: errorLike });
|
||||
|
|
@ -163,7 +159,7 @@ describe('Logger Integration Tests', () => {
|
|||
const metadata = {
|
||||
requestId: 'req-123',
|
||||
userId: 'user-456',
|
||||
operation: 'data-fetch'
|
||||
operation: 'data-fetch',
|
||||
};
|
||||
|
||||
logger.info('Operation completed', metadata);
|
||||
|
|
@ -179,7 +175,7 @@ describe('Logger Integration Tests', () => {
|
|||
const objectMessage = {
|
||||
event: 'user_action',
|
||||
action: 'login',
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
logger.info(objectMessage);
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
* Provides utilities and mocks for testing logging operations.
|
||||
*/
|
||||
|
||||
import { Logger, LogMetadata, shutdownLoggers } from '../src';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach } from 'bun:test';
|
||||
import { Logger, LogMetadata, shutdownLoggers } from '../src';
|
||||
|
||||
// Store original console methods
|
||||
const originalConsole = {
|
||||
|
|
@ -14,23 +14,21 @@ const originalConsole = {
|
|||
info: console.info,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
debug: console.debug
|
||||
debug: console.debug,
|
||||
};
|
||||
|
||||
// Create a test logger helper
|
||||
export const loggerTestHelpers = {
|
||||
|
||||
|
||||
/**
|
||||
* Mock Loki transport
|
||||
*/
|
||||
mockLokiTransport: () => ({
|
||||
on: () => {},
|
||||
write: () => {}
|
||||
write: () => {},
|
||||
}),
|
||||
/**
|
||||
* Create a mock Hono context for middleware tests
|
||||
*/ createHonoContextMock: (options: any = {}) => {
|
||||
*/ createHonoContextMock: (options: any = {}) => {
|
||||
// Default path and method
|
||||
const path = options.path || '/test';
|
||||
const method = options.method || 'GET';
|
||||
|
|
@ -49,13 +47,13 @@ export const loggerTestHelpers = {
|
|||
raw: {
|
||||
url: `http://localhost${path}`,
|
||||
method,
|
||||
headers: rawHeaders
|
||||
headers: rawHeaders,
|
||||
},
|
||||
query: {},
|
||||
param: () => undefined,
|
||||
header: (name: string) => rawHeaders.get(name.toLowerCase()),
|
||||
headers: headerMap,
|
||||
...options.req
|
||||
...options.req,
|
||||
};
|
||||
|
||||
// Create mock response
|
||||
|
|
@ -64,9 +62,11 @@ export const loggerTestHelpers = {
|
|||
statusText: 'OK',
|
||||
body: null,
|
||||
headers: new Map(),
|
||||
clone: function() { return { ...this, text: async () => JSON.stringify(this.body) }; },
|
||||
clone: function () {
|
||||
return { ...this, text: async () => JSON.stringify(this.body) };
|
||||
},
|
||||
text: async () => JSON.stringify(res.body),
|
||||
...options.res
|
||||
...options.res,
|
||||
};
|
||||
|
||||
// Create context with all required Hono methods
|
||||
|
|
@ -79,10 +79,23 @@ export const loggerTestHelpers = {
|
|||
return c;
|
||||
},
|
||||
get: (key: string) => c[key],
|
||||
set: (key: string, value: any) => { c[key] = value; return c; },
|
||||
status: (code: number) => { c.res.status = code; return c; },
|
||||
json: (body: any) => { c.res.body = body; return c; },
|
||||
executionCtx: { waitUntil: (fn: Function) => { fn(); } }
|
||||
set: (key: string, value: any) => {
|
||||
c[key] = value;
|
||||
return c;
|
||||
},
|
||||
status: (code: number) => {
|
||||
c.res.status = code;
|
||||
return c;
|
||||
},
|
||||
json: (body: any) => {
|
||||
c.res.body = body;
|
||||
return c;
|
||||
},
|
||||
executionCtx: {
|
||||
waitUntil: (fn: Function) => {
|
||||
fn();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return c;
|
||||
|
|
@ -96,7 +109,7 @@ export const loggerTestHelpers = {
|
|||
// Do nothing, simulate middleware completion
|
||||
return;
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Setup environment before tests
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export class MongoDBAggregationBuilder {
|
|||
*/
|
||||
unwind(field: string, options?: any): this {
|
||||
this.pipeline.push({
|
||||
$unwind: options ? { path: field, ...options } : field
|
||||
$unwind: options ? { path: field, ...options } : field,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
|
@ -91,8 +91,8 @@ export class MongoDBAggregationBuilder {
|
|||
from,
|
||||
localField,
|
||||
foreignField,
|
||||
as
|
||||
}
|
||||
as,
|
||||
},
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
|
@ -145,7 +145,7 @@ export class MongoDBAggregationBuilder {
|
|||
if (timeframe) {
|
||||
matchConditions.timestamp = {
|
||||
$gte: timeframe.start,
|
||||
$lte: timeframe.end
|
||||
$lte: timeframe.end,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -156,11 +156,11 @@ export class MongoDBAggregationBuilder {
|
|||
return this.group({
|
||||
_id: {
|
||||
symbol: '$symbol',
|
||||
sentiment: '$sentiment_label'
|
||||
sentiment: '$sentiment_label',
|
||||
},
|
||||
count: { $sum: 1 },
|
||||
avgScore: { $avg: '$sentiment_score' },
|
||||
avgConfidence: { $avg: '$confidence' }
|
||||
avgConfidence: { $avg: '$confidence' },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -179,7 +179,7 @@ export class MongoDBAggregationBuilder {
|
|||
articleCount: { $sum: 1 },
|
||||
symbols: { $addToSet: '$symbols' },
|
||||
avgSentiment: { $avg: '$sentiment_score' },
|
||||
latestArticle: { $max: '$published_date' }
|
||||
latestArticle: { $max: '$published_date' },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -196,12 +196,12 @@ export class MongoDBAggregationBuilder {
|
|||
return this.group({
|
||||
_id: {
|
||||
cik: '$cik',
|
||||
company: '$company_name'
|
||||
company: '$company_name',
|
||||
},
|
||||
filingCount: { $sum: 1 },
|
||||
filingTypes: { $addToSet: '$filing_type' },
|
||||
latestFiling: { $max: '$filing_date' },
|
||||
symbols: { $addToSet: '$symbols' }
|
||||
symbols: { $addToSet: '$symbols' },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +216,7 @@ export class MongoDBAggregationBuilder {
|
|||
count: { $sum: 1 },
|
||||
avgSizeBytes: { $avg: '$size_bytes' },
|
||||
oldestDocument: { $min: '$created_at' },
|
||||
newestDocument: { $max: '$created_at' }
|
||||
newestDocument: { $max: '$created_at' },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -234,14 +234,14 @@ export class MongoDBAggregationBuilder {
|
|||
hour: { $dateToString: { format: '%Y-%m-%d %H:00:00', date: `$${dateField}` } },
|
||||
day: { $dateToString: { format: '%Y-%m-%d', date: `$${dateField}` } },
|
||||
week: { $dateToString: { format: '%Y-W%V', date: `$${dateField}` } },
|
||||
month: { $dateToString: { format: '%Y-%m', date: `$${dateField}` } }
|
||||
month: { $dateToString: { format: '%Y-%m', date: `$${dateField}` } },
|
||||
};
|
||||
|
||||
return this.group({
|
||||
_id: dateFormat[interval],
|
||||
count: { $sum: 1 },
|
||||
firstDocument: { $min: `$${dateField}` },
|
||||
lastDocument: { $max: `$${dateField}` }
|
||||
lastDocument: { $max: `$${dateField}` },
|
||||
}).sort({ _id: 1 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,29 @@
|
|||
import { MongoClient, Db, Collection, MongoClientOptions, Document, WithId, OptionalUnlessRequiredId } from 'mongodb';
|
||||
import {
|
||||
Collection,
|
||||
Db,
|
||||
Document,
|
||||
MongoClient,
|
||||
MongoClientOptions,
|
||||
OptionalUnlessRequiredId,
|
||||
WithId,
|
||||
} from 'mongodb';
|
||||
import * as yup from 'yup';
|
||||
import { mongodbConfig } from '@stock-bot/config';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import type {
|
||||
MongoDBClientConfig,
|
||||
MongoDBConnectionOptions,
|
||||
CollectionNames,
|
||||
DocumentBase,
|
||||
SentimentData,
|
||||
RawDocument,
|
||||
NewsArticle,
|
||||
SecFiling,
|
||||
EarningsTranscript,
|
||||
AnalystReport
|
||||
} from './types';
|
||||
import { MongoDBHealthMonitor } from './health';
|
||||
import { schemaMap } from './schemas';
|
||||
import * as yup from 'yup';
|
||||
import type {
|
||||
AnalystReport,
|
||||
CollectionNames,
|
||||
DocumentBase,
|
||||
EarningsTranscript,
|
||||
MongoDBClientConfig,
|
||||
MongoDBConnectionOptions,
|
||||
NewsArticle,
|
||||
RawDocument,
|
||||
SecFiling,
|
||||
SentimentData,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* MongoDB Client for Stock Bot
|
||||
|
|
@ -32,16 +40,13 @@ export class MongoDBClient {
|
|||
private readonly healthMonitor: MongoDBHealthMonitor;
|
||||
private isConnected = false;
|
||||
|
||||
constructor(
|
||||
config?: Partial<MongoDBClientConfig>,
|
||||
options?: MongoDBConnectionOptions
|
||||
) {
|
||||
constructor(config?: Partial<MongoDBClientConfig>, options?: MongoDBConnectionOptions) {
|
||||
this.config = this.buildConfig(config);
|
||||
this.options = {
|
||||
retryAttempts: 3,
|
||||
retryDelay: 1000,
|
||||
healthCheckInterval: 30000,
|
||||
...options
|
||||
...options,
|
||||
};
|
||||
|
||||
this.logger = getLogger('mongodb-client');
|
||||
|
|
@ -63,7 +68,9 @@ export class MongoDBClient {
|
|||
|
||||
for (let attempt = 1; attempt <= this.options.retryAttempts!; attempt++) {
|
||||
try {
|
||||
this.logger.info(`Connecting to MongoDB (attempt ${attempt}/${this.options.retryAttempts})...`);
|
||||
this.logger.info(
|
||||
`Connecting to MongoDB (attempt ${attempt}/${this.options.retryAttempts})...`
|
||||
);
|
||||
|
||||
this.client = new MongoClient(uri, clientOptions);
|
||||
await this.client.connect();
|
||||
|
|
@ -95,7 +102,9 @@ export class MongoDBClient {
|
|||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to connect to MongoDB after ${this.options.retryAttempts} attempts: ${lastError?.message}`);
|
||||
throw new Error(
|
||||
`Failed to connect to MongoDB after ${this.options.retryAttempts} attempts: ${lastError?.message}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -134,7 +143,8 @@ export class MongoDBClient {
|
|||
*/
|
||||
async insertOne<T extends DocumentBase>(
|
||||
collectionName: CollectionNames,
|
||||
document: Omit<T, '_id' | 'created_at' | 'updated_at'> & Partial<Pick<T, 'created_at' | 'updated_at'>>
|
||||
document: Omit<T, '_id' | 'created_at' | 'updated_at'> &
|
||||
Partial<Pick<T, 'created_at' | 'updated_at'>>
|
||||
): Promise<T> {
|
||||
const collection = this.getCollection<T>(collectionName);
|
||||
|
||||
|
|
@ -143,8 +153,8 @@ export class MongoDBClient {
|
|||
const docWithTimestamps = {
|
||||
...document,
|
||||
created_at: document.created_at || now,
|
||||
updated_at: now
|
||||
} as T; // Validate document if schema exists
|
||||
updated_at: now,
|
||||
} as T; // Validate document if schema exists
|
||||
if (collectionName in schemaMap) {
|
||||
try {
|
||||
(schemaMap as any)[collectionName].validateSync(docWithTimestamps);
|
||||
|
|
@ -155,7 +165,8 @@ export class MongoDBClient {
|
|||
}
|
||||
throw error;
|
||||
}
|
||||
}const result = await collection.insertOne(docWithTimestamps as OptionalUnlessRequiredId<T>);
|
||||
}
|
||||
const result = await collection.insertOne(docWithTimestamps as OptionalUnlessRequiredId<T>);
|
||||
return { ...docWithTimestamps, _id: result.insertedId } as T;
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +183,7 @@ export class MongoDBClient {
|
|||
// Add updated timestamp
|
||||
const updateWithTimestamp = {
|
||||
...update,
|
||||
updated_at: new Date()
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
const result = await collection.updateOne(filter, { $set: updateWithTimestamp });
|
||||
|
|
@ -187,7 +198,7 @@ export class MongoDBClient {
|
|||
options: any = {}
|
||||
): Promise<T[]> {
|
||||
const collection = this.getCollection<T>(collectionName);
|
||||
return await collection.find(filter, options).toArray() as T[];
|
||||
return (await collection.find(filter, options).toArray()) as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -198,7 +209,7 @@ export class MongoDBClient {
|
|||
filter: any
|
||||
): Promise<T | null> {
|
||||
const collection = this.getCollection<T>(collectionName);
|
||||
return await collection.findOne(filter) as T | null;
|
||||
return (await collection.findOne(filter)) as T | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -215,10 +226,7 @@ export class MongoDBClient {
|
|||
/**
|
||||
* Count documents
|
||||
*/
|
||||
async countDocuments(
|
||||
collectionName: CollectionNames,
|
||||
filter: any = {}
|
||||
): Promise<number> {
|
||||
async countDocuments(collectionName: CollectionNames, filter: any = {}): Promise<number> {
|
||||
const collection = this.getCollection(collectionName);
|
||||
return await collection.countDocuments(filter);
|
||||
}
|
||||
|
|
@ -233,37 +241,42 @@ export class MongoDBClient {
|
|||
|
||||
try {
|
||||
// Sentiment data indexes
|
||||
await this.db.collection('sentiment_data').createIndexes([
|
||||
{ key: { symbol: 1, timestamp: -1 } },
|
||||
{ key: { sentiment_label: 1 } },
|
||||
{ key: { source_type: 1 } },
|
||||
{ key: { created_at: -1 } }
|
||||
]);
|
||||
await this.db
|
||||
.collection('sentiment_data')
|
||||
.createIndexes([
|
||||
{ key: { symbol: 1, timestamp: -1 } },
|
||||
{ key: { sentiment_label: 1 } },
|
||||
{ key: { source_type: 1 } },
|
||||
{ key: { created_at: -1 } },
|
||||
]);
|
||||
|
||||
// News articles indexes
|
||||
await this.db.collection('news_articles').createIndexes([
|
||||
{ key: { symbols: 1, published_date: -1 } },
|
||||
{ key: { publication: 1 } },
|
||||
{ key: { categories: 1 } },
|
||||
{ key: { created_at: -1 } }
|
||||
]);
|
||||
await this.db
|
||||
.collection('news_articles')
|
||||
.createIndexes([
|
||||
{ key: { symbols: 1, published_date: -1 } },
|
||||
{ key: { publication: 1 } },
|
||||
{ key: { categories: 1 } },
|
||||
{ key: { created_at: -1 } },
|
||||
]);
|
||||
|
||||
// SEC filings indexes
|
||||
await this.db.collection('sec_filings').createIndexes([
|
||||
{ key: { symbols: 1, filing_date: -1 } },
|
||||
{ key: { filing_type: 1 } },
|
||||
{ key: { cik: 1 } },
|
||||
{ key: { created_at: -1 } }
|
||||
]); // Raw documents indexes
|
||||
await this.db.collection('raw_documents').createIndex(
|
||||
{ content_hash: 1 },
|
||||
{ unique: true }
|
||||
);
|
||||
await this.db.collection('raw_documents').createIndexes([
|
||||
{ key: { processing_status: 1 } },
|
||||
{ key: { document_type: 1 } },
|
||||
{ key: { created_at: -1 } }
|
||||
]);
|
||||
await this.db
|
||||
.collection('sec_filings')
|
||||
.createIndexes([
|
||||
{ key: { symbols: 1, filing_date: -1 } },
|
||||
{ key: { filing_type: 1 } },
|
||||
{ key: { cik: 1 } },
|
||||
{ key: { created_at: -1 } },
|
||||
]); // Raw documents indexes
|
||||
await this.db.collection('raw_documents').createIndex({ content_hash: 1 }, { unique: true });
|
||||
await this.db
|
||||
.collection('raw_documents')
|
||||
.createIndexes([
|
||||
{ key: { processing_status: 1 } },
|
||||
{ key: { document_type: 1 } },
|
||||
{ key: { created_at: -1 } },
|
||||
]);
|
||||
|
||||
this.logger.info('MongoDB indexes created successfully');
|
||||
} catch (error) {
|
||||
|
|
@ -316,27 +329,27 @@ export class MongoDBClient {
|
|||
maxPoolSize: mongodbConfig.MONGODB_MAX_POOL_SIZE,
|
||||
minPoolSize: mongodbConfig.MONGODB_MIN_POOL_SIZE,
|
||||
maxIdleTime: mongodbConfig.MONGODB_MAX_IDLE_TIME,
|
||||
...config?.poolSettings
|
||||
...config?.poolSettings,
|
||||
},
|
||||
timeouts: {
|
||||
connectTimeout: mongodbConfig.MONGODB_CONNECT_TIMEOUT,
|
||||
socketTimeout: mongodbConfig.MONGODB_SOCKET_TIMEOUT,
|
||||
serverSelectionTimeout: mongodbConfig.MONGODB_SERVER_SELECTION_TIMEOUT,
|
||||
...config?.timeouts
|
||||
...config?.timeouts,
|
||||
},
|
||||
tls: {
|
||||
enabled: mongodbConfig.MONGODB_TLS,
|
||||
insecure: mongodbConfig.MONGODB_TLS_INSECURE,
|
||||
caFile: mongodbConfig.MONGODB_TLS_CA_FILE,
|
||||
...config?.tls
|
||||
...config?.tls,
|
||||
},
|
||||
options: {
|
||||
retryWrites: mongodbConfig.MONGODB_RETRY_WRITES,
|
||||
journal: mongodbConfig.MONGODB_JOURNAL,
|
||||
readPreference: mongodbConfig.MONGODB_READ_PREFERENCE as any,
|
||||
writeConcern: mongodbConfig.MONGODB_WRITE_CONCERN,
|
||||
...config?.options
|
||||
}
|
||||
...config?.options,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -362,14 +375,18 @@ export class MongoDBClient {
|
|||
serverSelectionTimeoutMS: this.config.timeouts?.serverSelectionTimeout,
|
||||
retryWrites: this.config.options?.retryWrites,
|
||||
journal: this.config.options?.journal,
|
||||
readPreference: this.config.options?.readPreference, writeConcern: this.config.options?.writeConcern ? {
|
||||
w: this.config.options.writeConcern === 'majority'
|
||||
? 'majority' as const
|
||||
: parseInt(this.config.options.writeConcern, 10) || 1
|
||||
} : undefined,
|
||||
readPreference: this.config.options?.readPreference,
|
||||
writeConcern: this.config.options?.writeConcern
|
||||
? {
|
||||
w:
|
||||
this.config.options.writeConcern === 'majority'
|
||||
? ('majority' as const)
|
||||
: parseInt(this.config.options.writeConcern, 10) || 1,
|
||||
}
|
||||
: undefined,
|
||||
tls: this.config.tls?.enabled,
|
||||
tlsInsecure: this.config.tls?.insecure,
|
||||
tlsCAFile: this.config.tls?.caFile
|
||||
tlsCAFile: this.config.tls?.caFile,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { MongoDBClient } from './client';
|
||||
import { mongodbConfig } from '@stock-bot/config';
|
||||
import { MongoDBClient } from './client';
|
||||
import type { MongoDBClientConfig, MongoDBConnectionOptions } from './types';
|
||||
|
||||
/**
|
||||
|
|
@ -22,7 +22,7 @@ export function createDefaultMongoDBClient(): MongoDBClient {
|
|||
database: mongodbConfig.MONGODB_DATABASE,
|
||||
username: mongodbConfig.MONGODB_USERNAME,
|
||||
password: mongodbConfig.MONGODB_PASSWORD,
|
||||
uri: mongodbConfig.MONGODB_URI
|
||||
uri: mongodbConfig.MONGODB_URI,
|
||||
};
|
||||
|
||||
return new MongoDBClient(config);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export class MongoDBHealthMonitor {
|
|||
averageLatency: 0,
|
||||
errorRate: 0,
|
||||
connectionPoolUtilization: 0,
|
||||
documentsProcessed: 0
|
||||
documentsProcessed: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +121,6 @@ export class MongoDBHealthMonitor {
|
|||
errors.push(`High latency: ${latency}ms`);
|
||||
status = status === 'healthy' ? 'degraded' : status;
|
||||
}
|
||||
|
||||
} catch (statusError) {
|
||||
errors.push(`Failed to get server status: ${(statusError as Error).message}`);
|
||||
status = 'degraded';
|
||||
|
|
@ -143,7 +142,7 @@ export class MongoDBHealthMonitor {
|
|||
timestamp: new Date(),
|
||||
latency,
|
||||
connections: connectionStats,
|
||||
errors: errors.length > 0 ? errors : undefined
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
};
|
||||
|
||||
// Log health status changes
|
||||
|
|
@ -164,7 +163,10 @@ export class MongoDBHealthMonitor {
|
|||
const dur = serverStatus.dur || {};
|
||||
|
||||
// Calculate operations per second (approximate)
|
||||
const totalOps = Object.values(opcounters).reduce((sum: number, count: any) => sum + (count || 0), 0);
|
||||
const totalOps = Object.values(opcounters).reduce(
|
||||
(sum: number, count: any) => sum + (count || 0),
|
||||
0
|
||||
);
|
||||
this.metrics.operationsPerSecond = totalOps;
|
||||
|
||||
// Connection pool utilization
|
||||
|
|
@ -176,7 +178,8 @@ export class MongoDBHealthMonitor {
|
|||
// Average latency (from durability stats if available)
|
||||
if (dur.timeMS) {
|
||||
this.metrics.averageLatency = dur.timeMS.dt || 0;
|
||||
} } catch (error) {
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.debug('Error parsing server status for metrics:', error as any);
|
||||
}
|
||||
}
|
||||
|
|
@ -184,7 +187,11 @@ export class MongoDBHealthMonitor {
|
|||
/**
|
||||
* Get connection pool statistics
|
||||
*/
|
||||
private getConnectionPoolStats(serverStatus: any): { utilization: number; active: number; available: number } {
|
||||
private getConnectionPoolStats(serverStatus: any): {
|
||||
utilization: number;
|
||||
active: number;
|
||||
available: number;
|
||||
} {
|
||||
const connections = serverStatus.connections || {};
|
||||
const active = connections.current || 0;
|
||||
const available = connections.available || 0;
|
||||
|
|
@ -193,7 +200,7 @@ export class MongoDBHealthMonitor {
|
|||
return {
|
||||
utilization: total > 0 ? active / total : 0,
|
||||
active,
|
||||
available
|
||||
available,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -206,7 +213,7 @@ export class MongoDBHealthMonitor {
|
|||
return {
|
||||
active: 1,
|
||||
available: 9,
|
||||
total: 10
|
||||
total: 10,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export type {
|
|||
NewsArticle,
|
||||
SecFiling,
|
||||
EarningsTranscript,
|
||||
AnalystReport
|
||||
AnalystReport,
|
||||
} from './types';
|
||||
|
||||
// Schemas
|
||||
|
|
@ -33,7 +33,7 @@ export {
|
|||
newsArticleSchema,
|
||||
secFilingSchema,
|
||||
earningsTranscriptSchema,
|
||||
analystReportSchema
|
||||
analystReportSchema,
|
||||
} from './schemas';
|
||||
|
||||
// Utils
|
||||
|
|
|
|||
|
|
@ -26,11 +26,15 @@ export const sentimentDataSchema = documentBaseSchema.shape({
|
|||
processed_at: yup.date().required(),
|
||||
language: yup.string().default('en'),
|
||||
keywords: yup.array(yup.string()).required(),
|
||||
entities: yup.array(yup.object({
|
||||
name: yup.string().required(),
|
||||
type: yup.string().required(),
|
||||
confidence: yup.number().min(0).max(1).required(),
|
||||
})).required(),
|
||||
entities: yup
|
||||
.array(
|
||||
yup.object({
|
||||
name: yup.string().required(),
|
||||
type: yup.string().required(),
|
||||
confidence: yup.number().min(0).max(1).required(),
|
||||
})
|
||||
)
|
||||
.required(),
|
||||
});
|
||||
|
||||
// Raw Document Schema
|
||||
|
|
@ -77,10 +81,14 @@ export const secFilingSchema = documentBaseSchema.shape({
|
|||
url: yup.string().url().required(),
|
||||
content: yup.string().required(),
|
||||
extracted_data: yup.object().optional(),
|
||||
financial_statements: yup.array(yup.object({
|
||||
statement_type: yup.string().required(),
|
||||
data: yup.object().required(),
|
||||
})).optional(),
|
||||
financial_statements: yup
|
||||
.array(
|
||||
yup.object({
|
||||
statement_type: yup.string().required(),
|
||||
data: yup.object().required(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
processing_status: yup.string().oneOf(['pending', 'processed', 'failed']).required(),
|
||||
});
|
||||
|
||||
|
|
@ -92,16 +100,22 @@ export const earningsTranscriptSchema = documentBaseSchema.shape({
|
|||
year: yup.number().min(2000).max(3000).required(),
|
||||
call_date: yup.date().required(),
|
||||
transcript: yup.string().required(),
|
||||
participants: yup.array(yup.object({
|
||||
name: yup.string().required(),
|
||||
title: yup.string().required(),
|
||||
type: yup.string().oneOf(['executive', 'analyst']).required(),
|
||||
})).required(),
|
||||
participants: yup
|
||||
.array(
|
||||
yup.object({
|
||||
name: yup.string().required(),
|
||||
title: yup.string().required(),
|
||||
type: yup.string().oneOf(['executive', 'analyst']).required(),
|
||||
})
|
||||
)
|
||||
.required(),
|
||||
key_topics: yup.array(yup.string()).required(),
|
||||
sentiment_analysis: yup.object({
|
||||
overall_sentiment: yup.number().min(-1).max(1).required(),
|
||||
topic_sentiments: yup.object().required(),
|
||||
}).optional(),
|
||||
sentiment_analysis: yup
|
||||
.object({
|
||||
overall_sentiment: yup.number().min(-1).max(1).required(),
|
||||
topic_sentiments: yup.object().required(),
|
||||
})
|
||||
.optional(),
|
||||
financial_highlights: yup.object().optional(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { OptionalUnlessRequiredId, WithId } from 'mongodb';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import type { MongoDBClient } from './client';
|
||||
import type { CollectionNames, DocumentBase } from './types';
|
||||
import type { WithId, OptionalUnlessRequiredId } from 'mongodb';
|
||||
|
||||
/**
|
||||
* MongoDB Transaction Manager
|
||||
|
|
@ -42,17 +42,17 @@ export class MongoDBTransactionManager {
|
|||
const result = await session.withTransaction(
|
||||
async () => {
|
||||
return await operations(session);
|
||||
}, {
|
||||
},
|
||||
{
|
||||
readPreference: options?.readPreference as any,
|
||||
readConcern: { level: options?.readConcern || 'majority' } as any,
|
||||
writeConcern: options?.writeConcern || { w: 'majority' },
|
||||
maxCommitTimeMS: options?.maxCommitTimeMS || 10000
|
||||
maxCommitTimeMS: options?.maxCommitTimeMS || 10000,
|
||||
}
|
||||
);
|
||||
|
||||
this.logger.debug('MongoDB transaction completed successfully');
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('MongoDB transaction failed:', error);
|
||||
throw error;
|
||||
|
|
@ -71,7 +71,7 @@ export class MongoDBTransactionManager {
|
|||
}>,
|
||||
options?: { ordered?: boolean; bypassDocumentValidation?: boolean }
|
||||
): Promise<void> {
|
||||
await this.withTransaction(async (session) => {
|
||||
await this.withTransaction(async session => {
|
||||
for (const operation of operations) {
|
||||
const collection = this.client.getCollection(operation.collection);
|
||||
|
||||
|
|
@ -80,16 +80,18 @@ export class MongoDBTransactionManager {
|
|||
const documentsWithTimestamps = operation.documents.map(doc => ({
|
||||
...doc,
|
||||
created_at: doc.created_at || now,
|
||||
updated_at: now
|
||||
updated_at: now,
|
||||
}));
|
||||
|
||||
await collection.insertMany(documentsWithTimestamps, {
|
||||
session,
|
||||
ordered: options?.ordered ?? true,
|
||||
bypassDocumentValidation: options?.bypassDocumentValidation ?? false
|
||||
bypassDocumentValidation: options?.bypassDocumentValidation ?? false,
|
||||
});
|
||||
|
||||
this.logger.debug(`Inserted ${documentsWithTimestamps.length} documents into ${operation.collection}`);
|
||||
this.logger.debug(
|
||||
`Inserted ${documentsWithTimestamps.length} documents into ${operation.collection}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -105,7 +107,7 @@ export class MongoDBTransactionManager {
|
|||
options?: any;
|
||||
}>
|
||||
): Promise<void> {
|
||||
await this.withTransaction(async (session) => {
|
||||
await this.withTransaction(async session => {
|
||||
const results = [];
|
||||
|
||||
for (const operation of operations) {
|
||||
|
|
@ -116,18 +118,14 @@ export class MongoDBTransactionManager {
|
|||
...operation.update,
|
||||
$set: {
|
||||
...operation.update.$set,
|
||||
updated_at: new Date()
|
||||
}
|
||||
updated_at: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await collection.updateMany(
|
||||
operation.filter,
|
||||
updateWithTimestamp,
|
||||
{
|
||||
session,
|
||||
...operation.options
|
||||
}
|
||||
);
|
||||
const result = await collection.updateMany(operation.filter, updateWithTimestamp, {
|
||||
session,
|
||||
...operation.options,
|
||||
});
|
||||
|
||||
results.push(result);
|
||||
this.logger.debug(`Updated ${result.modifiedCount} documents in ${operation.collection}`);
|
||||
|
|
@ -146,7 +144,7 @@ export class MongoDBTransactionManager {
|
|||
filter: any,
|
||||
transform?: (doc: T) => T
|
||||
): Promise<number> {
|
||||
return await this.withTransaction(async (session) => {
|
||||
return await this.withTransaction(async session => {
|
||||
const sourceCollection = this.client.getCollection<T>(fromCollection);
|
||||
const targetCollection = this.client.getCollection<T>(toCollection);
|
||||
|
||||
|
|
@ -155,7 +153,7 @@ export class MongoDBTransactionManager {
|
|||
|
||||
if (documents.length === 0) {
|
||||
return 0;
|
||||
} // Transform documents if needed
|
||||
} // Transform documents if needed
|
||||
const documentsToInsert = transform
|
||||
? documents.map((doc: WithId<T>) => transform(doc as T))
|
||||
: documents;
|
||||
|
|
@ -164,13 +162,17 @@ export class MongoDBTransactionManager {
|
|||
const now = new Date();
|
||||
documentsToInsert.forEach(doc => {
|
||||
doc.updated_at = now;
|
||||
}); // Insert into target collection
|
||||
await targetCollection.insertMany(documentsToInsert as OptionalUnlessRequiredId<T>[], { session });
|
||||
}); // Insert into target collection
|
||||
await targetCollection.insertMany(documentsToInsert as OptionalUnlessRequiredId<T>[], {
|
||||
session,
|
||||
});
|
||||
|
||||
// Remove from source collection
|
||||
const deleteResult = await sourceCollection.deleteMany(filter, { session });
|
||||
|
||||
this.logger.info(`Moved ${documents.length} documents from ${fromCollection} to ${toCollection}`);
|
||||
this.logger.info(
|
||||
`Moved ${documents.length} documents from ${fromCollection} to ${toCollection}`
|
||||
);
|
||||
|
||||
return deleteResult.deletedCount || 0;
|
||||
});
|
||||
|
|
@ -188,15 +190,14 @@ export class MongoDBTransactionManager {
|
|||
let totalArchived = 0;
|
||||
|
||||
while (true) {
|
||||
const batchArchived = await this.withTransaction(async (session) => {
|
||||
const batchArchived = await this.withTransaction(async session => {
|
||||
const collection = this.client.getCollection(sourceCollection);
|
||||
const archiveCol = this.client.getCollection(archiveCollection);
|
||||
|
||||
// Find old documents
|
||||
const documents = await collection.find(
|
||||
{ created_at: { $lt: cutoffDate } },
|
||||
{ limit: batchSize, session }
|
||||
).toArray();
|
||||
const documents = await collection
|
||||
.find({ created_at: { $lt: cutoffDate } }, { limit: batchSize, session })
|
||||
.toArray();
|
||||
|
||||
if (documents.length === 0) {
|
||||
return 0;
|
||||
|
|
@ -207,7 +208,7 @@ export class MongoDBTransactionManager {
|
|||
const documentsToArchive = documents.map(doc => ({
|
||||
...doc,
|
||||
archived_at: now,
|
||||
archived_from: sourceCollection
|
||||
archived_from: sourceCollection,
|
||||
}));
|
||||
|
||||
// Insert into archive collection
|
||||
|
|
@ -215,10 +216,7 @@ export class MongoDBTransactionManager {
|
|||
|
||||
// Remove from source collection
|
||||
const ids = documents.map(doc => doc._id);
|
||||
const deleteResult = await collection.deleteMany(
|
||||
{ _id: { $in: ids } },
|
||||
{ session }
|
||||
);
|
||||
const deleteResult = await collection.deleteMany({ _id: { $in: ids } }, { session });
|
||||
|
||||
return deleteResult.deletedCount || 0;
|
||||
});
|
||||
|
|
@ -232,7 +230,9 @@ export class MongoDBTransactionManager {
|
|||
this.logger.debug(`Archived batch of ${batchArchived} documents`);
|
||||
}
|
||||
|
||||
this.logger.info(`Archived ${totalArchived} documents from ${sourceCollection} to ${archiveCollection}`);
|
||||
this.logger.info(
|
||||
`Archived ${totalArchived} documents from ${sourceCollection} to ${archiveCollection}`
|
||||
);
|
||||
return totalArchived;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import * as yup from 'yup';
|
||||
import type { ObjectId } from 'mongodb';
|
||||
import * as yup from 'yup';
|
||||
|
||||
/**
|
||||
* MongoDB Client Configuration
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { Pool, PoolClient, QueryResult as PgQueryResult, QueryResultRow } from 'pg';
|
||||
import { QueryResult as PgQueryResult, Pool, PoolClient, QueryResultRow } from 'pg';
|
||||
import { postgresConfig } from '@stock-bot/config';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { PostgreSQLHealthMonitor } from './health';
|
||||
import { PostgreSQLQueryBuilder } from './query-builder';
|
||||
import { PostgreSQLTransactionManager } from './transactions';
|
||||
import type {
|
||||
PostgreSQLClientConfig,
|
||||
PostgreSQLConnectionOptions,
|
||||
QueryResult,
|
||||
TransactionCallback
|
||||
TransactionCallback,
|
||||
} from './types';
|
||||
import { PostgreSQLHealthMonitor } from './health';
|
||||
import { PostgreSQLQueryBuilder } from './query-builder';
|
||||
import { PostgreSQLTransactionManager } from './transactions';
|
||||
|
||||
/**
|
||||
* PostgreSQL Client for Stock Bot
|
||||
|
|
@ -26,16 +26,13 @@ export class PostgreSQLClient {
|
|||
private readonly transactionManager: PostgreSQLTransactionManager;
|
||||
private isConnected = false;
|
||||
|
||||
constructor(
|
||||
config?: Partial<PostgreSQLClientConfig>,
|
||||
options?: PostgreSQLConnectionOptions
|
||||
) {
|
||||
constructor(config?: Partial<PostgreSQLClientConfig>, options?: PostgreSQLConnectionOptions) {
|
||||
this.config = this.buildConfig(config);
|
||||
this.options = {
|
||||
retryAttempts: 3,
|
||||
retryDelay: 1000,
|
||||
healthCheckInterval: 30000,
|
||||
...options
|
||||
...options,
|
||||
};
|
||||
|
||||
this.logger = getLogger('postgres-client');
|
||||
|
|
@ -55,7 +52,9 @@ export class PostgreSQLClient {
|
|||
|
||||
for (let attempt = 1; attempt <= this.options.retryAttempts!; attempt++) {
|
||||
try {
|
||||
this.logger.info(`Connecting to PostgreSQL (attempt ${attempt}/${this.options.retryAttempts})...`);
|
||||
this.logger.info(
|
||||
`Connecting to PostgreSQL (attempt ${attempt}/${this.options.retryAttempts})...`
|
||||
);
|
||||
|
||||
this.pool = new Pool(this.buildPoolConfig());
|
||||
|
||||
|
|
@ -89,7 +88,9 @@ export class PostgreSQLClient {
|
|||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to connect to PostgreSQL after ${this.options.retryAttempts} attempts: ${lastError?.message}`);
|
||||
throw new Error(
|
||||
`Failed to connect to PostgreSQL after ${this.options.retryAttempts} attempts: ${lastError?.message}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -115,7 +116,10 @@ export class PostgreSQLClient {
|
|||
/**
|
||||
* Execute a query
|
||||
*/
|
||||
async query<T extends QueryResultRow = any>(text: string, params?: any[]): Promise<QueryResult<T>> {
|
||||
async query<T extends QueryResultRow = any>(
|
||||
text: string,
|
||||
params?: any[]
|
||||
): Promise<QueryResult<T>> {
|
||||
if (!this.pool) {
|
||||
throw new Error('PostgreSQL client not connected');
|
||||
}
|
||||
|
|
@ -128,19 +132,19 @@ export class PostgreSQLClient {
|
|||
|
||||
this.logger.debug(`Query executed in ${executionTime}ms`, {
|
||||
query: text.substring(0, 100),
|
||||
params: params?.length
|
||||
params: params?.length,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
executionTime
|
||||
executionTime,
|
||||
} as QueryResult<T>;
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
this.logger.error(`Query failed after ${executionTime}ms:`, {
|
||||
error,
|
||||
query: text,
|
||||
params
|
||||
params,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -191,7 +195,10 @@ export class PostgreSQLClient {
|
|||
/**
|
||||
* Execute a stored procedure or function
|
||||
*/
|
||||
async callFunction<T extends QueryResultRow = any>(functionName: string, params?: any[]): Promise<QueryResult<T>> {
|
||||
async callFunction<T extends QueryResultRow = any>(
|
||||
functionName: string,
|
||||
params?: any[]
|
||||
): Promise<QueryResult<T>> {
|
||||
const placeholders = params ? params.map((_, i) => `$${i + 1}`).join(', ') : '';
|
||||
const query = `SELECT * FROM ${functionName}(${placeholders})`;
|
||||
return await this.query<T>(query, params);
|
||||
|
|
@ -278,12 +285,12 @@ export class PostgreSQLClient {
|
|||
min: postgresConfig.POSTGRES_POOL_MIN,
|
||||
max: postgresConfig.POSTGRES_POOL_MAX,
|
||||
idleTimeoutMillis: postgresConfig.POSTGRES_POOL_IDLE_TIMEOUT,
|
||||
...config?.poolSettings
|
||||
...config?.poolSettings,
|
||||
},
|
||||
ssl: {
|
||||
enabled: postgresConfig.POSTGRES_SSL,
|
||||
rejectUnauthorized: postgresConfig.POSTGRES_SSL_REJECT_UNAUTHORIZED,
|
||||
...config?.ssl
|
||||
...config?.ssl,
|
||||
},
|
||||
timeouts: {
|
||||
query: postgresConfig.POSTGRES_QUERY_TIMEOUT,
|
||||
|
|
@ -291,8 +298,8 @@ export class PostgreSQLClient {
|
|||
statement: postgresConfig.POSTGRES_STATEMENT_TIMEOUT,
|
||||
lock: postgresConfig.POSTGRES_LOCK_TIMEOUT,
|
||||
idleInTransaction: postgresConfig.POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT,
|
||||
...config?.timeouts
|
||||
}
|
||||
...config?.timeouts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -311,16 +318,18 @@ export class PostgreSQLClient {
|
|||
statement_timeout: this.config.timeouts?.statement,
|
||||
lock_timeout: this.config.timeouts?.lock,
|
||||
idle_in_transaction_session_timeout: this.config.timeouts?.idleInTransaction,
|
||||
ssl: this.config.ssl?.enabled ? {
|
||||
rejectUnauthorized: this.config.ssl.rejectUnauthorized
|
||||
} : false
|
||||
ssl: this.config.ssl?.enabled
|
||||
? {
|
||||
rejectUnauthorized: this.config.ssl.rejectUnauthorized,
|
||||
}
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
private setupErrorHandlers(): void {
|
||||
if (!this.pool) return;
|
||||
|
||||
this.pool.on('error', (error) => {
|
||||
this.pool.on('error', error => {
|
||||
this.logger.error('PostgreSQL pool error:', error);
|
||||
});
|
||||
|
||||
|
|
|
|||
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