Compare commits

...
Sign in to create a new pull request.

24 commits

Author SHA1 Message Date
5ded78f8e4 simple test 2025-06-12 08:03:45 -04:00
54314a0cde refactor of data-service 2025-06-12 08:03:09 -04:00
3097686849 fixed batching and waiting priority plus cleanup 2025-06-11 12:56:07 -04:00
d9bd33a822 testing 2025-06-11 11:11:47 -04:00
07f8964a8c prettier configs 2025-06-11 10:41:33 -04:00
eeae192872 linting 2025-06-11 10:38:05 -04:00
9d38f9a7b6 eslint 2025-06-11 10:35:15 -04:00
8955544593 running prettier for cleanup 2025-06-11 10:13:25 -04:00
24b7ed15e4 working on queue 2025-06-11 09:53:04 -04:00
be807378a3 fixed up delay time 2025-06-11 08:33:36 -04:00
16599c86da added env back and fixed up queue service 2025-06-11 08:03:55 -04:00
b645b58102 simplifid queue service 2025-06-11 07:28:47 -04:00
709fc347e9 queue service simplification 2025-06-10 23:35:33 -04:00
423b40866c made provider registry functional 2025-06-10 23:26:30 -04:00
aed5ff3d98 removed examples 2025-06-10 23:09:29 -04:00
84e6dee53f removed examples 2025-06-10 23:09:16 -04:00
4aa2942e43 simplified providers a bit 2025-06-10 23:08:46 -04:00
35b0eb3783 moved proxy redis init to app start 2025-06-10 22:50:10 -04:00
a7ec942916 added more specific batch keys 2025-06-10 22:43:51 -04:00
ed326c025e cleanup old init code on batcher 2025-06-10 22:28:56 -04:00
47ff92b567 still trying 2025-06-10 22:16:11 -04:00
2f074271cc trying to get simpler batcher working 2025-06-10 22:00:58 -04:00
df611a3ce3 cleaned up index 2025-06-10 21:06:01 -04:00
b49bea818b added routes and simplified batch processor 2025-06-10 20:59:53 -04:00
256 changed files with 35227 additions and 30090 deletions

164
.env Normal file
View file

@ -0,0 +1,164 @@
# ===========================================
# STOCK BOT PLATFORM - ENVIRONMENT VARIABLES
# ===========================================
# Core Application Settings
NODE_ENV=development
LOG_LEVEL=info
# Data Service Configuration
DATA_SERVICE_PORT=2001
# Queue and Worker Configuration
WORKER_COUNT=4
WORKER_CONCURRENCY=20
WEBSHARE_API_KEY=y8ay534rcbybdkk3evnzmt640xxfhy7252ce2t98
# ===========================================
# DATABASE CONFIGURATIONS
# ===========================================
# Dragonfly/Redis Configuration
DRAGONFLY_HOST=localhost
DRAGONFLY_PORT=6379
DRAGONFLY_PASSWORD=
# PostgreSQL Configuration
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=stockbot
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_SSL=false
# QuestDB Configuration
QUESTDB_HOST=localhost
QUESTDB_PORT=9000
QUESTDB_DB=qdb
QUESTDB_USER=admin
QUESTDB_PASSWORD=quest
# MongoDB Configuration
MONGODB_HOST=localhost
MONGODB_PORT=27017
MONGODB_DB=stockbot
MONGODB_USER=
MONGODB_PASSWORD=
MONGODB_URI=mongodb://localhost:27017/stockbot
# ===========================================
# DATA PROVIDER CONFIGURATIONS
# ===========================================
# Proxy Configuration
PROXY_VALIDATION_HOURS=24
PROXY_BATCH_SIZE=100
PROXY_DIRECT_MODE=false
# Yahoo Finance (if using API keys)
YAHOO_API_KEY=
YAHOO_API_SECRET=
# QuoteMedia Configuration
QUOTEMEDIA_API_KEY=
QUOTEMEDIA_BASE_URL=https://api.quotemedia.com
# ===========================================
# TRADING PLATFORM INTEGRATIONS
# ===========================================
# Alpaca Trading
ALPACA_API_KEY=
ALPACA_SECRET_KEY=
ALPACA_BASE_URL=https://paper-api.alpaca.markets
ALPACA_PAPER_TRADING=true
# Polygon.io
POLYGON_API_KEY=
POLYGON_BASE_URL=https://api.polygon.io
# ===========================================
# RISK MANAGEMENT
# ===========================================
# Risk Management Settings
MAX_POSITION_SIZE=10000
MAX_DAILY_LOSS=1000
MAX_PORTFOLIO_EXPOSURE=0.8
STOP_LOSS_PERCENTAGE=0.02
TAKE_PROFIT_PERCENTAGE=0.05
# ===========================================
# MONITORING AND OBSERVABILITY
# ===========================================
# Prometheus Configuration
PROMETHEUS_HOST=localhost
PROMETHEUS_PORT=9090
PROMETHEUS_METRICS_PORT=9091
PROMETHEUS_PUSHGATEWAY_URL=http://localhost:9091
# Grafana Configuration
GRAFANA_HOST=localhost
GRAFANA_PORT=3000
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin
# Loki Logging
LOKI_HOST=localhost
LOKI_PORT=3100
LOKI_URL=http://localhost:3100
# ===========================================
# CACHE CONFIGURATION
# ===========================================
# Cache Settings
CACHE_TTL=300
CACHE_MAX_ITEMS=10000
CACHE_ENABLED=true
# ===========================================
# SECURITY SETTINGS
# ===========================================
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_EXPIRES_IN=24h
# API Rate Limiting
RATE_LIMIT_WINDOW=15
RATE_LIMIT_MAX_REQUESTS=100
# ===========================================
# DEVELOPMENT SETTINGS
# ===========================================
# Debug Settings
DEBUG_MODE=false
VERBOSE_LOGGING=false
# Development Tools
HOT_RELOAD=true
SOURCE_MAPS=true
# ===========================================
# DOCKER CONFIGURATION
# ===========================================
# Docker-specific settings (used in docker-compose)
COMPOSE_PROJECT_NAME=stock-bot
DOCKER_BUILDKIT=1
# ===========================================
# MISCELLANEOUS
# ===========================================
# Timezone
TZ=UTC
# Application Metadata
APP_NAME=Stock Bot Platform
APP_VERSION=1.0.0
APP_DESCRIPTION=Advanced Stock Trading and Analysis Platform

81
.eslintignore Normal file
View file

@ -0,0 +1,81 @@
# 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
# Logs
*.log
logs/
**/logs/
# Database files
*.db
*.sqlite
*.sqlite3
# Temporary files
*.tmp
*.temp
.DS_Store
Thumbs.db
# Generated files
*.d.ts
**/*.d.ts
# JavaScript files (we're focusing on TypeScript)
*.js
*.mjs
!.eslintrc.js
!eslint.config.js
# Scripts and config directories
scripts/
monitoring/
database/
docker-compose*.yml
Dockerfile*
# Documentation
*.md
docs/
# Test coverage
coverage/
**/coverage/
# IDE/Editor files
.vscode/
.idea/
*.swp
*.swo
# Angular specific
**/.angular/
**/src/polyfills.ts

32
.githooks/pre-commit Executable file
View file

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

7
.gitignore vendored
View file

@ -11,13 +11,6 @@ build/
*.d.ts *.d.ts
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs # Logs
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*

105
.prettierignore Normal file
View file

@ -0,0 +1,105 @@
# 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

26
.prettierrc Normal file
View file

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

39
.vscode/settings.json vendored
View file

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

View file

@ -1,5 +1,5 @@
{ {
"plugins": { "plugins": {
"@tailwindcss/postcss": {} "@tailwindcss/postcss": {}
} }
} }

View file

@ -1,4 +1,4 @@
{ {
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"] "recommendations": ["angular.ng-template"]
} }

View file

@ -1,20 +1,20 @@
{ {
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "ng serve", "name": "ng serve",
"type": "chrome", "type": "chrome",
"request": "launch", "request": "launch",
"preLaunchTask": "npm: start", "preLaunchTask": "npm: start",
"url": "http://localhost:4200/" "url": "http://localhost:4200/"
}, },
{ {
"name": "ng test", "name": "ng test",
"type": "chrome", "type": "chrome",
"request": "launch", "request": "launch",
"preLaunchTask": "npm: test", "preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html" "url": "http://localhost:9876/debug.html"
} }
] ]
} }

View file

@ -1,42 +1,42 @@
{ {
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"type": "npm", "type": "npm",
"script": "start", "script": "start",
"isBackground": true, "isBackground": true,
"problemMatcher": { "problemMatcher": {
"owner": "typescript", "owner": "typescript",
"pattern": "$tsc", "pattern": "$tsc",
"background": { "background": {
"activeOnStart": true, "activeOnStart": true,
"beginsPattern": { "beginsPattern": {
"regexp": "(.*?)" "regexp": "(.*?)"
}, },
"endsPattern": { "endsPattern": {
"regexp": "bundle generation complete" "regexp": "bundle generation complete"
} }
} }
} }
}, },
{ {
"type": "npm", "type": "npm",
"script": "test", "script": "test",
"isBackground": true, "isBackground": true,
"problemMatcher": { "problemMatcher": {
"owner": "typescript", "owner": "typescript",
"pattern": "$tsc", "pattern": "$tsc",
"background": { "background": {
"activeOnStart": true, "activeOnStart": true,
"beginsPattern": { "beginsPattern": {
"regexp": "(.*?)" "regexp": "(.*?)"
}, },
"endsPattern": { "endsPattern": {
"regexp": "bundle generation complete" "regexp": "bundle generation complete"
} }
} }
} }
} }
] ]
} }

View file

@ -1,91 +1,90 @@
{ {
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1, "version": 1,
"cli": { "cli": {
"packageManager": "npm" "packageManager": "npm"
}, },
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
"trading-dashboard": { "trading-dashboard": {
"projectType": "application", "schematics": { "projectType": "application",
"@schematics/angular:component": { "schematics": {
"style": "css" "@schematics/angular:component": {
} "style": "css"
}, }
"root": "", },
"sourceRoot": "src", "root": "",
"prefix": "app", "sourceRoot": "src",
"architect": { "build": { "prefix": "app",
"builder": "@angular/build:application", "architect": {
"options": { "build": {
"browser": "src/main.ts", "builder": "@angular/build:application",
"tsConfig": "tsconfig.app.json", "options": {
"inlineStyleLanguage": "css", "browser": "src/main.ts",
"assets": [ "tsConfig": "tsconfig.app.json",
{ "inlineStyleLanguage": "css",
"glob": "**/*", "assets": [
"input": "public" {
} "glob": "**/*",
], "input": "public"
"styles": [ }
"src/styles.css" ],
] "styles": ["src/styles.css"]
}, },
"configurations": { "configurations": {
"production": { "production": {
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "500kB", "maximumWarning": "500kB",
"maximumError": "1MB" "maximumError": "1MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "4kB", "maximumWarning": "4kB",
"maximumError": "8kB" "maximumError": "8kB"
} }
], ],
"outputHashing": "all" "outputHashing": "all"
}, },
"development": { "development": {
"optimization": false, "optimization": false,
"extractLicenses": false, "extractLicenses": false,
"sourceMap": false "sourceMap": false
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": "production"
}, },
"serve": { "serve": {
"builder": "@angular/build:dev-server", "builder": "@angular/build:dev-server",
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "trading-dashboard:build:production" "buildTarget": "trading-dashboard:build:production"
}, },
"development": { "development": {
"buildTarget": "trading-dashboard:build:development" "buildTarget": "trading-dashboard:build:development"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular/build:extract-i18n" "builder": "@angular/build:extract-i18n"
}, "test": { },
"builder": "@angular/build:karma", "test": {
"options": { "builder": "@angular/build:karma",
"tsConfig": "tsconfig.spec.json", "options": {
"inlineStyleLanguage": "css", "tsConfig": "tsconfig.spec.json",
"assets": [ "inlineStyleLanguage": "css",
{ "assets": [
"glob": "**/*", {
"input": "public" "glob": "**/*",
} "input": "public"
], }
"styles": [ ],
"src/styles.css" "styles": ["src/styles.css"]
] }
} }
} }
} }
} }
} }
}

View file

@ -1,44 +1,44 @@
{ {
"name": "trading-dashboard", "name": "trading-dashboard",
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"devvvv": "ng serve --port 5173 --host 0.0.0.0", "devvvv": "ng serve --port 5173 --host 0.0.0.0",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^20.0.0", "@angular/animations": "^20.0.0",
"@angular/cdk": "^20.0.1", "@angular/cdk": "^20.0.1",
"@angular/common": "^20.0.0", "@angular/common": "^20.0.0",
"@angular/compiler": "^20.0.0", "@angular/compiler": "^20.0.0",
"@angular/core": "^20.0.0", "@angular/core": "^20.0.0",
"@angular/forms": "^20.0.0", "@angular/forms": "^20.0.0",
"@angular/material": "^20.0.1", "@angular/material": "^20.0.1",
"@angular/platform-browser": "^20.0.0", "@angular/platform-browser": "^20.0.0",
"@angular/router": "^20.0.0", "@angular/router": "^20.0.0",
"rxjs": "~7.8.2", "rxjs": "~7.8.2",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"zone.js": "~0.15.1" "zone.js": "~0.15.1"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "^20.0.0", "@angular/build": "^20.0.0",
"@angular/cli": "^20.0.0", "@angular/cli": "^20.0.0",
"@angular/compiler-cli": "^20.0.0", "@angular/compiler-cli": "^20.0.0",
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
"@types/jasmine": "~5.1.8", "@types/jasmine": "~5.1.8",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"jasmine-core": "~5.7.1", "jasmine-core": "~5.7.1",
"karma": "~6.4.4", "karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.1", "karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.5.4", "postcss": "^8.5.4",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"typescript": "~5.8.3" "typescript": "~5.8.3"
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,11 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/app", "outDir": "./out-tsc/app",
"types": [] "types": []
}, },
"include": [ "include": ["src/**/*.ts"],
"src/**/*.ts" "exclude": ["src/**/*.spec.ts"]
], }
"exclude": [
"src/**/*.spec.ts"
]
}

View file

@ -1,32 +1,32 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compileOnSave": false, "compileOnSave": false,
"compilerOptions": { "compilerOptions": {
"noImplicitOverride": true, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true, "noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"isolatedModules": true, "isolatedModules": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"importHelpers": true, "importHelpers": true,
"module": "preserve" "module": "preserve"
}, },
"angularCompilerOptions": { "angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false, "enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true, "strictInjectionParameters": true,
"strictInputAccessModifiers": true, "strictInputAccessModifiers": true,
"typeCheckHostBindings": true, "typeCheckHostBindings": true,
"strictTemplates": true "strictTemplates": true
}, },
"files": [], "files": [],
"references": [ "references": [
{ {
"path": "./tsconfig.app.json" "path": "./tsconfig.app.json"
}, },
{ {
"path": "./tsconfig.spec.json" "path": "./tsconfig.spec.json"
} }
] ]
} }

View file

@ -1,14 +1,10 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/spec", "outDir": "./out-tsc/spec",
"types": [ "types": ["jasmine"]
"jasmine" },
] "include": ["src/**/*.ts"]
}, }
"include": [
"src/**/*.ts"
]
}

View file

@ -1,244 +1,112 @@
/** /**
* Data Service - Combined live and historical data ingestion with queue-based architecture * Data Service - Combined live and historical data ingestion with queue-based architecture
*/ */
import { getLogger } from '@stock-bot/logger'; import { Hono } from 'hono';
import { loadEnvVariables } from '@stock-bot/config'; import { Browser } from '@stock-bot/browser';
import { Hono } from 'hono'; import { loadEnvVariables } from '@stock-bot/config';
import { serve } from '@hono/node-server'; import { getLogger } from '@stock-bot/logger';
import { queueManager } from './services/queue.service'; import { Shutdown } from '@stock-bot/shutdown';
import { initializeIBResources } from './providers/ib.tasks';
// Load environment variables import { initializeProxyResources } from './providers/proxy.tasks';
loadEnvVariables(); import { queueManager } from './services/queue.service';
import { initializeBatchCache } from './utils/batch-helpers';
const app = new Hono(); import { healthRoutes, marketDataRoutes, proxyRoutes, queueRoutes, testRoutes } from './routes';
const logger = getLogger('data-service');
// Load environment variables
const PORT = parseInt(process.env.DATA_SERVICE_PORT || '3002'); loadEnvVariables();
// Health check endpoint
app.get('/health', (c) => { const app = new Hono();
return c.json({ const logger = getLogger('data-service');
service: 'data-service', const PORT = parseInt(process.env.DATA_SERVICE_PORT || '3002');
status: 'healthy', let server: any = null;
timestamp: new Date().toISOString(),
queue: { // Initialize shutdown manager with 15 second timeout
status: 'running', const shutdown = Shutdown.getInstance({ timeout: 15000 });
workers: queueManager.getWorkerCount()
} // Register all routes
}); app.route('', healthRoutes);
}); app.route('', queueRoutes);
app.route('', marketDataRoutes);
// Queue management endpoints app.route('', proxyRoutes);
app.get('/api/queue/status', async (c) => { app.route('', testRoutes);
try {
const status = await queueManager.getQueueStatus(); // Initialize services
return c.json({ status: 'success', data: status }); async function initializeServices() {
} catch (error) { logger.info('Initializing data service...');
logger.error('Failed to get queue status', { error });
return c.json({ status: 'error', message: 'Failed to get queue status' }, 500); try {
} // Initialize browser resources
}); logger.info('Starting browser resources initialization...');
await Browser.initialize();
app.post('/api/queue/job', async (c) => { logger.info('Browser resources initialized');
try {
const jobData = await c.req.json(); // Initialize batch cache FIRST - before queue service
const job = await queueManager.addJob(jobData); logger.info('Starting batch cache initialization...');
return c.json({ status: 'success', jobId: job.id }); await initializeBatchCache();
} catch (error) { logger.info('Batch cache initialized');
logger.error('Failed to add job', { error });
return c.json({ status: 'error', message: 'Failed to add job' }, 500); // Initialize proxy cache - before queue service
} logger.info('Starting proxy cache initialization...');
}); await initializeProxyResources(true); // Wait for cache during startup
logger.info('Proxy cache initialized');
// Market data endpoints
app.get('/api/live/:symbol', async (c) => { // Initialize proxy cache - before queue service
const symbol = c.req.param('symbol'); logger.info('Starting proxy cache initialization...');
logger.info('Live data request', { symbol }); await initializeIBResources(true); // Wait for cache during startup
logger.info('Proxy cache initialized');
try { // Queue job for live data using Yahoo provider
const job = await queueManager.addJob({ // Initialize queue service (Redis connections should be ready now)
type: 'market-data-live', logger.info('Starting queue service initialization...');
service: 'market-data', await queueManager.initialize();
provider: 'yahoo-finance', logger.info('Queue service initialized');
operation: 'live-data',
payload: { symbol } logger.info('All services initialized successfully');
}); } catch (error) {
return c.json({ logger.error('Failed to initialize services', { error });
status: 'success', throw error;
message: 'Live data job queued', }
jobId: job.id, }
symbol
}); // Start server
} catch (error) { async function startServer() {
logger.error('Failed to queue live data job', { symbol, error }); await initializeServices();
return c.json({ status: 'error', message: 'Failed to queue live data job' }, 500); // Start the HTTP server using Bun's native serve
} server = Bun.serve({
}); port: PORT,
fetch: app.fetch,
app.get('/api/historical/:symbol', async (c) => { development: process.env.NODE_ENV === 'development',
const symbol = c.req.param('symbol'); });
const from = c.req.query('from'); logger.info(`Data Service started on port ${PORT}`);
const to = c.req.query('to'); }
logger.info('Historical data request', { symbol, from, to }); // Register shutdown handlers
shutdown.onShutdown(async () => {
try { if (server) {
const fromDate = from ? new Date(from) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago logger.info('Stopping HTTP server...');
const toDate = to ? new Date(to) : new Date(); // Now try {
// Queue job for historical data using Yahoo provider server.stop();
const job = await queueManager.addJob({ logger.info('HTTP server stopped successfully');
type: 'market-data-historical', } catch (error) {
service: 'market-data', logger.error('Error stopping HTTP server', { error });
provider: 'yahoo-finance', }
operation: 'historical-data', }
payload: { });
symbol,
from: fromDate.toISOString(), shutdown.onShutdown(async () => {
to: toDate.toISOString() logger.info('Shutting down queue manager...');
} try {
}); return c.json({ await queueManager.shutdown();
status: 'success', logger.info('Queue manager shut down successfully');
message: 'Historical data job queued', } catch (error) {
jobId: job.id, logger.error('Error shutting down queue manager', { error });
symbol, throw error; // Re-throw to mark shutdown as failed
from: fromDate, }
to: toDate });
});
} catch (error) { // Start the application
logger.error('Failed to queue historical data job', { symbol, from, to, error }); startServer().catch(error => {
return c.json({ status: 'error', message: 'Failed to queue historical data job' }, 500); } logger.error('Failed to start server', { error });
}); process.exit(1);
});
// Proxy management endpoints
app.post('/api/proxy/fetch', async (c) => { logger.info('Data service startup initiated with graceful shutdown handlers');
try {
const job = await queueManager.addJob({
type: 'proxy-fetch',
service: 'proxy',
provider: 'proxy-service',
operation: 'fetch-and-check',
payload: {},
priority: 5
});
return c.json({
status: 'success',
jobId: job.id,
message: 'Proxy fetch job queued'
});
} catch (error) {
logger.error('Failed to queue proxy fetch', { error });
return c.json({ status: 'error', message: 'Failed to queue proxy fetch' }, 500);
}
});
app.post('/api/proxy/check', async (c) => {
try {
const { proxies } = await c.req.json();
const job = await queueManager.addJob({
type: 'proxy-check',
service: 'proxy',
provider: 'proxy-service',
operation: 'check-specific',
payload: { proxies },
priority: 8
});
return c.json({
status: 'success',
jobId: job.id,
message: `Proxy check job queued for ${proxies.length} proxies`
});
} catch (error) {
logger.error('Failed to queue proxy check', { error });
return c.json({ status: 'error', message: 'Failed to queue proxy check' }, 500);
}
});
// Get proxy stats via queue
app.get('/api/proxy/stats', async (c) => {
try {
const job = await queueManager.addJob({
type: 'proxy-stats',
service: 'proxy',
provider: 'proxy-service',
operation: 'get-stats',
payload: {},
priority: 3
});
return c.json({
status: 'success',
jobId: job.id,
message: 'Proxy stats job queued'
});
} catch (error) {
logger.error('Failed to queue proxy stats', { error });
return c.json({ status: 'error', message: 'Failed to queue proxy stats' }, 500);
}
});
// Provider registry endpoints
app.get('/api/providers', async (c) => {
try {
const providers = queueManager.getRegisteredProviders();
return c.json({ status: 'success', providers });
} catch (error) {
logger.error('Failed to get providers', { error });
return c.json({ status: 'error', message: 'Failed to get providers' }, 500);
}
});
// Add new endpoint to see scheduled jobs
app.get('/api/scheduled-jobs', async (c) => {
try {
const jobs = queueManager.getScheduledJobsInfo();
return c.json({
status: 'success',
count: jobs.length,
jobs
});
} catch (error) {
logger.error('Failed to get scheduled jobs info', { error });
return c.json({ status: 'error', message: 'Failed to get scheduled jobs' }, 500);
}
});
// Initialize services
async function initializeServices() {
logger.info('Initializing data service...');
try {
// Initialize queue service (Redis connections should be ready now)
logger.info('Starting queue service initialization...');
await queueManager.initialize();
logger.info('Queue service initialized');
logger.info('All services initialized successfully');
} catch (error) {
logger.error('Failed to initialize services', { error });
throw error;
}
}
// Start server
async function startServer() {
await initializeServices();
}
// Graceful shutdown
process.on('SIGINT', async () => {
logger.info('Received SIGINT, shutting down gracefully...');
await queueManager.shutdown();
process.exit(0);
});
process.on('SIGTERM', async () => {
logger.info('Received SIGTERM, shutting down gracefully...');
await queueManager.shutdown();
process.exit(0);
});
startServer().catch(error => {
logger.error('Failed to start server', { error });
process.exit(1);
});

View file

@ -0,0 +1,32 @@
import { getLogger } from '@stock-bot/logger';
import { ProviderConfig } from '../services/provider-registry.service';
const logger = getLogger('ib-provider');
export const ibProvider: ProviderConfig = {
name: 'ib',
operations: {
'ib-symbol-summary': async () => {
const { ibTasks } = await import('./ib.tasks');
logger.info('Fetching symbol summary from IB');
const total = await ibTasks.fetchSymbolSummary();
logger.info('Fetched symbol summary from IB', {
count: total,
});
return total;
},
},
scheduledJobs: [
{
type: 'ib-symbol-summary',
operation: 'ib-symbol-summary',
payload: {},
// should remove and just run at the same time so app restarts dont keeping adding same jobs
cronPattern: '*/2 * * * *',
priority: 5,
immediately: true, // Don't run immediately during startup to avoid conflicts
description: 'Fetch and validate proxy list from sources',
},
],
};

View file

@ -0,0 +1,152 @@
import { Browser } from '@stock-bot/browser';
import { getLogger } from '@stock-bot/logger';
// Shared instances (module-scoped, not global)
let isInitialized = false; // Track if resources are initialized
let logger: ReturnType<typeof getLogger>;
// let cache: CacheProvider;
export async function initializeIBResources(waitForCache = false): Promise<void> {
// Skip if already initialized
if (isInitialized) {
return;
}
logger = getLogger('proxy-tasks');
// cache = createCache({
// keyPrefix: 'proxy:',
// ttl: PROXY_CONFIG.CACHE_TTL,
// enableMetrics: true,
// });
// httpClient = new HttpClient({ timeout: 15000 }, logger);
// if (waitForCache) {
// // logger.info('Initializing proxy cache...');
// // await cache.waitForReady(10000);
// // logger.info('Proxy cache initialized successfully');
// logger.info('Proxy tasks initialized');
// } else {
// logger.info('Proxy tasks initialized (fallback mode)');
// }
isInitialized = true;
}
export async function fetchSymbolSummary(): Promise<number> {
try {
await Browser.initialize({ headless: true, timeout: 10000, blockResources: false });
logger.info('✅ Browser initialized');
const { page, contextId } = await Browser.createPageWithProxy(
'https://www.interactivebrokers.com/en/trading/products-exchanges.php#/',
'http://doimvbnb-US-rotate:w5fpiwrb9895@p.webshare.io:80'
);
logger.info('✅ Page created with proxy');
let summaryData: any = null; // Initialize summaryData to store API response
let eventCount = 0;
page.onNetworkEvent(event => {
if (event.url.includes('/webrest/search/product-types/summary')) {
console.log(`🎯 Found summary API call: ${event.type} ${event.url}`);
if (event.type === 'response' && event.responseData) {
console.log(`📊 Summary API Response Data: ${event.responseData}`);
try {
summaryData = JSON.parse(event.responseData) as any;
const totalCount = summaryData[0].totalCount;
console.log('📊 Summary API Response:', JSON.stringify(summaryData, null, 2));
console.log(`🔢 Total symbols found: ${totalCount || 'Unknown'}`);
} catch (e) {
console.log('📊 Raw Summary Response:', event.responseData);
}
}
}
eventCount++;
logger.info(`📡 Event ${eventCount}: ${event.type} ${event.url}`);
});
logger.info('⏳ Waiting for page load...');
await page.waitForLoadState('domcontentloaded', { timeout: 20000 });
logger.info('✅ Page loaded');
// RIGHT HERE - Interact with the page to find Stocks checkbox and Apply button
logger.info('🔍 Looking for Products tab...');
// Wait for the page to fully load
await page.waitForTimeout(20000);
// First, click on the Products tab
const productsTab = page.locator('#productSearchTab[role="tab"][href="#products"]');
await productsTab.waitFor({ timeout: 20000 });
logger.info('✅ Found Products tab');
logger.info('🖱️ Clicking Products tab...');
await productsTab.click();
logger.info('✅ Products tab clicked');
// Wait for the tab content to load
await page.waitForTimeout(5000);
// Click on the Asset Classes accordion to expand it
logger.info('🔍 Looking for Asset Classes accordion...');
const assetClassesAccordion = page.locator(
'#products .accordion-item #acc-products .accordion_btn:has-text("Asset Classes")'
);
await assetClassesAccordion.waitFor({ timeout: 10000 });
logger.info('✅ Found Asset Classes accordion');
logger.info('🖱️ Clicking Asset Classes accordion...');
await assetClassesAccordion.click();
logger.info('✅ Asset Classes accordion clicked');
// Wait for the accordion content to expand
await page.waitForTimeout(2000);
logger.info('🔍 Looking for Stocks checkbox...');
// Find the span with class "fs-7 checkbox-text" and inner text containing "Stocks"
const stocksSpan = page.locator('span.fs-7.checkbox-text:has-text("Stocks")');
await stocksSpan.waitFor({ timeout: 10000 });
logger.info('✅ Found Stocks span');
// Find the checkbox by looking in the same parent container
const parentContainer = stocksSpan.locator('..');
const checkbox = parentContainer.locator('input[type="checkbox"]');
if ((await checkbox.count()) > 0) {
logger.info('📋 Clicking Stocks checkbox...');
await checkbox.first().check();
logger.info('✅ Stocks checkbox checked');
} else {
logger.info('⚠️ Could not find checkbox near Stocks text');
}
// Wait a moment for any UI updates
await page.waitForTimeout(1000);
// Find and click the nearest Apply button
logger.info('🔍 Looking for Apply button...');
const applyButton = page.locator(
'button:has-text("Apply"), input[type="submit"][value*="Apply"], input[type="button"][value*="Apply"]'
);
if ((await applyButton.count()) > 0) {
logger.info('🎯 Clicking Apply button...');
await applyButton.first().click();
logger.info('✅ Apply button clicked');
// Wait for any network requests triggered by the Apply button
await page.waitForTimeout(2000);
} else {
logger.info('⚠️ Could not find Apply button');
}
return 0;
} catch (error) {
logger.error('Failed to fetch IB symbol summary', { error });
return 0;
}
}
// Optional: Export a convenience object that groups related tasks
export const ibTasks = {
fetchSymbolSummary,
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
/**
* Routes index - exports all route modules
*/
export { healthRoutes } from './health.routes';
export { queueRoutes } from './queue.routes';
export { marketDataRoutes } from './market-data.routes';
export { proxyRoutes } from './proxy.routes';
export { testRoutes } from './test.routes';

View file

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

View file

@ -0,0 +1,76 @@
/**
* Proxy management routes
*/
import { Hono } from 'hono';
import { getLogger } from '@stock-bot/logger';
import { queueManager } from '../services/queue.service';
const logger = getLogger('proxy-routes');
export const proxyRoutes = new Hono();
// Proxy management endpoints
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,
});
return c.json({
status: 'success',
jobId: job.id,
message: 'Proxy fetch job queued',
});
} catch (error) {
logger.error('Failed to queue proxy fetch', { error });
return c.json({ status: 'error', message: 'Failed to queue proxy fetch' }, 500);
}
});
proxyRoutes.post('/api/proxy/check', async c => {
try {
const { proxies } = await c.req.json();
const job = await queueManager.addJob({
type: 'proxy-check',
provider: 'proxy-provider',
operation: 'check-specific',
payload: { proxies },
priority: 8,
});
return c.json({
status: 'success',
jobId: job.id,
message: `Proxy check job queued for ${proxies.length} proxies`,
});
} catch (error) {
logger.error('Failed to queue proxy check', { error });
return c.json({ status: 'error', message: 'Failed to queue proxy check' }, 500);
}
});
// Get proxy stats via queue
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,
});
return c.json({
status: 'success',
jobId: job.id,
message: 'Proxy stats job queued',
});
} catch (error) {
logger.error('Failed to queue proxy stats', { error });
return c.json({ status: 'error', message: 'Failed to queue proxy stats' }, 500);
}
});

View file

@ -0,0 +1,71 @@
/**
* Queue management routes
*/
import { Hono } from 'hono';
import { getLogger } from '@stock-bot/logger';
import { queueManager } from '../services/queue.service';
const logger = getLogger('queue-routes');
export const queueRoutes = new Hono();
// Queue management endpoints
queueRoutes.get('/api/queue/status', async c => {
try {
const status = await queueManager.getQueueStatus();
return c.json({ status: 'success', data: status });
} catch (error) {
logger.error('Failed to get queue status', { error });
return c.json({ status: 'error', message: 'Failed to get queue status' }, 500);
}
});
queueRoutes.post('/api/queue/job', async c => {
try {
const jobData = await c.req.json();
const job = await queueManager.addJob(jobData);
return c.json({ status: 'success', jobId: job.id });
} catch (error) {
logger.error('Failed to add job', { error });
return c.json({ status: 'error', message: 'Failed to add job' }, 500);
}
});
// Provider registry endpoints
queueRoutes.get('/api/providers', async c => {
try {
const { providerRegistry } = await import('../services/provider-registry.service');
const providers = providerRegistry.getProviders();
return c.json({ status: 'success', providers });
} catch (error) {
logger.error('Failed to get providers', { error });
return c.json({ status: 'error', message: 'Failed to get providers' }, 500);
}
});
// Add new endpoint to see scheduled jobs
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,
});
} catch (error) {
logger.error('Failed to get scheduled jobs info', { error });
return c.json({ status: 'error', message: 'Failed to get scheduled jobs' }, 500);
}
});
queueRoutes.post('/api/queue/drain', async c => {
try {
await queueManager.drainQueue();
const status = await queueManager.getQueueStatus();
return c.json({ status: 'success', message: 'Queue drained', queueStatus: status });
} catch (error) {
logger.error('Failed to drain queue', { error });
return c.json({ status: 'error', message: 'Failed to drain queue' }, 500);
}
});

View file

@ -0,0 +1,87 @@
/**
* Test and development routes for batch processing
*/
import { Hono } from 'hono';
import { getLogger } from '@stock-bot/logger';
import { queueManager } from '../services/queue.service';
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 => {
try {
const { symbols, useBatching = false, totalDelayHours = 1 } = await c.req.json();
const { processItems } = await import('../utils/batch-helpers');
if (!symbols || !Array.isArray(symbols)) {
return c.json({ status: 'error', message: 'symbols array is required' }, 400);
}
const result = await processItems(
symbols,
(symbol, index) => ({
symbol,
index,
timestamp: new Date().toISOString(),
}),
queueManager,
{
totalDelayHours,
useBatching,
batchSize: 10,
priority: 1,
provider: 'test-provider',
operation: 'live-data',
}
);
return c.json({
status: 'success',
message: 'Batch processing started',
result,
});
} catch (error) {
logger.error('Failed to start batch symbol processing', { error });
return c.json({ status: 'error', message: 'Failed to start batch processing' }, 500);
}
});
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');
if (!items || !Array.isArray(items)) {
return c.json({ status: 'error', message: 'items array is required' }, 400);
}
const result = await processItems(
items,
(item, index) => ({
originalItem: item,
processIndex: index,
timestamp: new Date().toISOString(),
}),
queueManager,
{
totalDelayHours,
useBatching,
batchSize: 5,
priority: 1,
provider: 'test-provider',
operation: 'custom-test',
}
);
return c.json({
status: 'success',
message: 'Custom batch processing started',
result,
});
} catch (error) {
logger.error('Failed to start custom batch processing', { error });
return c.json({ status: 'error', message: 'Failed to start custom batch processing' }, 500);
}
});

View file

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

View file

@ -1,478 +1,419 @@
import { Queue, Worker, QueueEvents } from 'bullmq'; import { Queue, QueueEvents, Worker, type Job } from 'bullmq';
import { getLogger } from '@stock-bot/logger'; import { getLogger } from '@stock-bot/logger';
import { providerRegistry } from './provider-registry.service'; import { providerRegistry, type JobData } from './provider-registry.service';
export interface JobData { export class QueueService {
type: string; private logger = getLogger('queue-service');
service: string; private queue!: Queue;
provider: string; private workers: Worker[] = [];
operation: string; private queueEvents!: QueueEvents;
payload: any;
priority?: number; private config = {
immediately?: boolean; workers: parseInt(process.env.WORKER_COUNT || '5'),
} concurrency: parseInt(process.env.WORKER_CONCURRENCY || '20'),
redis: {
export class QueueService { host: process.env.DRAGONFLY_HOST || 'localhost',
private logger = getLogger('queue-service'); port: parseInt(process.env.DRAGONFLY_PORT || '6379'),
private queue!: Queue; },
private workers: Worker[] = []; };
private queueEvents!: QueueEvents;
private isInitialized = false; private get isInitialized() {
return !!this.queue;
constructor() { }
// Don't initialize in constructor to allow for proper async initialization
} constructor() {
// Don't initialize in constructor to allow for proper async initialization
async initialize() { }
if (this.isInitialized) { async initialize() {
this.logger.warn('Queue service already initialized'); if (this.isInitialized) {
return; this.logger.warn('Queue service already initialized');
} return;
}
this.logger.info('Initializing queue service...');
this.logger.info('Initializing queue service...');
// Register all providers first
await this.registerProviders(); try {
// Step 1: Register providers
const connection = { await this.registerProviders();
host: process.env.DRAGONFLY_HOST || 'localhost',
port: parseInt(process.env.DRAGONFLY_PORT || '6379'), // Step 2: Setup queue and workers
// Add these Redis-specific options to fix the undeclared key issue const connection = this.getConnection();
maxRetriesPerRequest: null, const queueName = '{data-service-queue}';
retryDelayOnFailover: 100, this.queue = new Queue(queueName, {
enableReadyCheck: false, connection,
lazyConnect: false, defaultJobOptions: {
// Disable Redis Cluster mode if you're using standalone Redis/Dragonfly removeOnComplete: 10,
enableOfflineQueue: true removeOnFail: 5,
}; attempts: 3,
backoff: {
// Worker configuration type: 'exponential',
const workerCount = parseInt(process.env.WORKER_COUNT || '5'); delay: 1000,
const concurrencyPerWorker = parseInt(process.env.WORKER_CONCURRENCY || '20'); },
},
this.logger.info('Connecting to Redis/Dragonfly', connection); });
try { this.queueEvents = new QueueEvents(queueName, { connection });
this.queue = new Queue('{data-service-queue}', {
connection, // Step 3: Create workers
defaultJobOptions: { const { workerCount, totalConcurrency } = this.createWorkers(queueName, connection);
removeOnComplete: 10,
removeOnFail: 5, // Step 4: Wait for readiness (parallel)
attempts: 3, await Promise.all([
backoff: { this.queue.waitUntilReady(),
type: 'exponential', this.queueEvents.waitUntilReady(),
delay: 1000, ...this.workers.map(worker => worker.waitUntilReady()),
} ]);
}
}); // Step 5: Setup events and scheduled tasks
// Create multiple workers this.setupQueueEvents();
for (let i = 0; i < workerCount; i++) { await this.setupScheduledTasks();
const worker = new Worker(
'{data-service-queue}', this.logger.info('Queue service initialized successfully', {
this.processJob.bind(this), workers: workerCount,
{ totalConcurrency,
connection: { ...connection }, // Each worker gets its own connection });
concurrency: concurrencyPerWorker, } catch (error) {
maxStalledCount: 1, this.logger.error('Failed to initialize queue service', { error });
stalledInterval: 30000, throw error;
} }
); }
// Add worker-specific logging private getConnection() {
worker.on('ready', () => { return {
this.logger.info(`Worker ${i + 1} ready`, { workerId: i + 1 }); ...this.config.redis,
}); maxRetriesPerRequest: null,
retryDelayOnFailover: 100,
worker.on('error', (error) => { lazyConnect: false,
this.logger.error(`Worker ${i + 1} error`, { workerId: i + 1, error }); };
}); }
this.workers.push(worker); private createWorkers(queueName: string, connection: any) {
} for (let i = 0; i < this.config.workers; i++) {
this.queueEvents = new QueueEvents('{data-service-queue}', { connection }); // Test connection const worker = new Worker(queueName, this.processJob.bind(this), {
connection: { ...connection },
// Wait for all workers to be ready concurrency: this.config.concurrency,
await this.queue.waitUntilReady(); maxStalledCount: 1,
await Promise.all(this.workers.map(worker => worker.waitUntilReady())); stalledInterval: 30000,
await this.queueEvents.waitUntilReady(); });
this.setupEventListeners(); // Setup events inline
this.isInitialized = true; worker.on('ready', () => this.logger.info(`Worker ${i + 1} ready`));
this.logger.info('Queue service initialized successfully'); worker.on('error', error => this.logger.error(`Worker ${i + 1} error`, { error }));
await this.setupScheduledTasks(); this.workers.push(worker);
}
} catch (error) {
this.logger.error('Failed to initialize queue service', { error }); return {
throw error; workerCount: this.config.workers,
} totalConcurrency: this.config.workers * this.config.concurrency,
} };
}
// Update getTotalConcurrency method private setupQueueEvents() {
getTotalConcurrency() { // Add comprehensive logging to see job flow
if (!this.isInitialized) { this.queueEvents.on('added', job => {
return 0; this.logger.debug('Job added to queue', {
} id: job.jobId,
return this.workers.reduce((total, worker) => { });
return total + (worker.opts.concurrency || 1); });
}, 0);
} this.queueEvents.on('waiting', job => {
this.logger.debug('Job moved to waiting', {
private async registerProviders() { id: job.jobId,
this.logger.info('Registering providers...'); });
});
try {
// Import and register all providers this.queueEvents.on('active', job => {
const { proxyProvider } = await import('../providers/proxy.provider'); this.logger.debug('Job became active', {
const { quotemediaProvider } = await import('../providers/quotemedia.provider'); id: job.jobId,
const { yahooProvider } = await import('../providers/yahoo.provider'); });
});
providerRegistry.registerProvider(proxyProvider);
providerRegistry.registerProvider(quotemediaProvider); this.queueEvents.on('delayed', job => {
providerRegistry.registerProvider(yahooProvider); this.logger.debug('Job delayed', {
id: job.jobId,
this.logger.info('All providers registered successfully'); delay: job.delay,
} catch (error) { });
this.logger.error('Failed to register providers', { error }); });
throw error;
} this.queueEvents.on('completed', job => {
} this.logger.debug('Job completed', {
id: job.jobId,
private async processJob(job: any) { });
const { service, provider, operation, payload }: JobData = job.data; });
this.logger.info('Processing job', { this.queueEvents.on('failed', (job, error) => {
id: job.id, this.logger.debug('Job failed', {
service, id: job.jobId,
provider, error: String(error),
operation, });
payloadKeys: Object.keys(payload || {}) });
}); }
private async registerProviders() {
try { this.logger.info('Registering providers...');
// Get handler from registry
const handler = providerRegistry.getHandler(service, provider, operation); try {
// Define providers to register
if (!handler) { const providers = [
throw new Error(`No handler found for ${service}:${provider}:${operation}`); { module: '../providers/proxy.provider', export: 'proxyProvider' },
} { module: '../providers/ib.provider', export: 'ibProvider' },
// { module: '../providers/yahoo.provider', export: 'yahooProvider' },
// Execute the handler ];
const result = await handler(payload);
// Import and register all providers
this.logger.info('Job completed successfully', { for (const { module, export: exportName } of providers) {
id: job.id, const providerModule = await import(module);
service, providerRegistry.registerProvider(providerModule[exportName]);
provider, }
operation
}); this.logger.info('All providers registered successfully');
} catch (error) {
return result; this.logger.error('Failed to register providers', { error });
throw error;
} catch (error) { }
const errorMessage = error instanceof Error ? error.message : String(error); }
this.logger.error('Job failed', { private async processJob(job: Job) {
id: job.id, const { provider, operation, payload }: JobData = job.data;
service,
provider, this.logger.info('Processing job', {
operation, id: job.id,
error: errorMessage provider,
}); operation,
throw error; payloadKeys: Object.keys(payload || {}),
} });
} try {
let result;
async addBulk(jobs: any[]) : Promise<any[]> {
return await this.queue.addBulk(jobs) if (operation === 'process-batch-items') {
} // Special handling for batch processing - requires 2 parameters
private setupEventListeners() { const { processBatchJob } = await import('../utils/batch-helpers');
this.queueEvents.on('completed', (job) => { result = await processBatchJob(payload, this);
this.logger.info('Job completed', { id: job.jobId }); } else {
}); // Regular handler lookup - requires 1 parameter
const handler = providerRegistry.getHandler(provider, operation);
this.queueEvents.on('failed', (job) => {
this.logger.error('Job failed', { id: job.jobId, error: job.failedReason }); if (!handler) {
}); throw new Error(`No handler found for ${provider}:${operation}`);
}
// Note: Worker-specific events are already set up during worker creation
// No need for additional progress events since we handle them per-worker result = await handler(payload);
} }
private async setupScheduledTasks() {
try { this.logger.info('Job completed successfully', {
this.logger.info('Setting up scheduled tasks from providers...'); id: job.id,
provider,
// Get all scheduled jobs from all providers operation,
const allScheduledJobs = providerRegistry.getAllScheduledJobs(); });
if (allScheduledJobs.length === 0) { return result;
this.logger.warn('No scheduled jobs found in providers'); } catch (error) {
return; const errorMessage = error instanceof Error ? error.message : String(error);
} this.logger.error('Job failed', {
id: job.id,
// Get existing repeatable jobs for comparison provider,
const existingJobs = await this.queue.getRepeatableJobs(); operation,
this.logger.info(`Found ${existingJobs.length} existing repeatable jobs`); error: errorMessage,
});
let successCount = 0; throw error;
let failureCount = 0; }
let updatedCount = 0; }
let newCount = 0;
async addBulk(jobs: any[]): Promise<any[]> {
// Process each scheduled job return await this.queue.addBulk(jobs);
for (const { service, provider, job } of allScheduledJobs) { }
try {
const jobKey = `${service}-${provider}-${job.operation}`; private getTotalConcurrency() {
return this.workers.reduce((total, worker) => total + (worker.opts.concurrency || 1), 0);
// Check if this job already exists }
const existingJob = existingJobs.find(existing =>
existing.key?.includes(jobKey) || existing.name === job.type private async setupScheduledTasks() {
); const allScheduledJobs = providerRegistry.getAllScheduledJobs();
if (existingJob) { if (allScheduledJobs.length === 0) {
// Check if the job needs updating (different cron pattern or config) this.logger.warn('No scheduled jobs found in providers');
const needsUpdate = existingJob.pattern !== job.cronPattern; return;
}
if (needsUpdate) {
this.logger.info('Job configuration changed, updating', { this.logger.info('Setting up scheduled tasks...', { count: allScheduledJobs.length });
jobKey,
oldPattern: existingJob.pattern, // Use Promise.allSettled for parallel processing + better error handling
newPattern: job.cronPattern const results = await Promise.allSettled(
}); allScheduledJobs.map(async ({ provider, job }) => {
updatedCount++; await this.addRecurringJob(
} else { {
this.logger.debug('Job unchanged, skipping', { jobKey }); type: job.type,
successCount++; provider,
continue; operation: job.operation,
} payload: job.payload,
} else { priority: job.priority,
newCount++; immediately: job.immediately || false,
} },
job.cronPattern
// Add delay between job registrations );
await new Promise(resolve => setTimeout(resolve, 100));
return { provider, operation: job.operation };
await this.addRecurringJob({ })
type: job.type, );
service: service,
provider: provider, // Log results
operation: job.operation, const successful = results.filter(r => r.status === 'fulfilled');
payload: job.payload, const failed = results.filter(r => r.status === 'rejected');
priority: job.priority,
immediately: job.immediately || false if (failed.length > 0) {
}, job.cronPattern); failed.forEach((result, index) => {
const { provider, job } = allScheduledJobs[index];
this.logger.info('Scheduled job registered', { this.logger.error('Failed to register scheduled job', {
type: job.type, provider,
service, operation: job.operation,
provider, error: result.reason,
operation: job.operation, });
cronPattern: job.cronPattern, });
description: job.description, }
immediately: job.immediately || false
}); this.logger.info('Scheduled tasks setup complete', {
successful: successful.length,
successCount++; failed: failed.length,
});
} catch (error) { }
this.logger.error('Failed to register scheduled job', { private async addJobInternal(jobData: JobData, options: any = {}) {
type: job.type, if (!this.isInitialized) {
service, throw new Error('Queue service not initialized');
provider, }
error: error instanceof Error ? error.message : String(error)
}); const jobType = jobData.type || `${jobData.provider}-${jobData.operation}`;
failureCount++; return this.queue.add(jobType, jobData, {
} priority: jobData.priority || undefined,
} removeOnComplete: 10,
removeOnFail: 5,
this.logger.info(`Scheduled tasks setup complete`, { ...options,
total: allScheduledJobs.length, });
successful: successCount, }
failed: failureCount,
updated: updatedCount, async addJob(jobData: JobData, options?: any) {
new: newCount return this.addJobInternal(jobData, options);
}); }
} catch (error) { async addRecurringJob(jobData: JobData, cronPattern: string, options?: any) {
this.logger.error('Failed to setup scheduled tasks', error); const jobKey = `recurring-${jobData.provider}-${jobData.operation}`;
}
} return this.addJobInternal(jobData, {
repeat: {
async addJob(jobData: JobData, options?: any) { pattern: cronPattern,
if (!this.isInitialized) { tz: 'UTC',
throw new Error('Queue service not initialized. Call initialize() first.'); immediately: jobData.immediately || false,
} },
return this.queue.add(jobData.type, jobData, { jobId: jobKey,
priority: jobData.priority || 0, removeOnComplete: 1,
removeOnComplete: 10, removeOnFail: 1,
removeOnFail: 5, attempts: 2,
...options backoff: {
}); type: 'fixed',
} delay: 5000,
},
async addRecurringJob(jobData: JobData, cronPattern: string, options?: any) { ...options,
if (!this.isInitialized) { });
throw new Error('Queue service not initialized. Call initialize() first.'); }
} async getJobStats() {
if (!this.isInitialized) {
try { throw new Error('Queue service not initialized. Call initialize() first.');
// Create a unique job key for this specific job }
const jobKey = `${jobData.service}-${jobData.provider}-${jobData.operation}`; const [waiting, active, completed, failed, delayed] = await Promise.all([
this.queue.getWaiting(),
// Get all existing repeatable jobs this.queue.getActive(),
const existingJobs = await this.queue.getRepeatableJobs(); this.queue.getCompleted(),
this.queue.getFailed(),
// Find and remove the existing job with the same key if it exists this.queue.getDelayed(),
const existingJob = existingJobs.find(job => { ]);
// Check if this is the same job by comparing key components
return job.key?.includes(jobKey) || job.name === jobData.type; return {
}); waiting: waiting.length,
active: active.length,
if (existingJob) { completed: completed.length,
this.logger.info('Updating existing recurring job', { failed: failed.length,
jobKey, delayed: delayed.length,
existingPattern: existingJob.pattern, };
newPattern: cronPattern }
}); async drainQueue() {
if (this.isInitialized) {
// Remove the existing job await this.queue.drain();
await this.queue.removeRepeatableByKey(existingJob.key); }
}
// Small delay to ensure cleanup is complete async getQueueStatus() {
await new Promise(resolve => setTimeout(resolve, 100)); if (!this.isInitialized) {
} else { throw new Error('Queue service not initialized');
this.logger.info('Creating new recurring job', { jobKey, cronPattern }); }
}
const stats = await this.getJobStats();
// Add the new/updated recurring job return {
const job = await this.queue.add(jobData.type, jobData, { ...stats,
repeat: { workers: this.workers.length,
pattern: cronPattern, concurrency: this.getTotalConcurrency(),
tz: 'UTC', };
immediately: jobData.immediately || false, }
}, async shutdown() {
// Use a consistent jobId for this specific recurring job if (!this.isInitialized) {
jobId: `recurring-${jobKey}`, this.logger.warn('Queue service not initialized, nothing to shutdown');
removeOnComplete: 1, return;
removeOnFail: 1, }
attempts: 2,
backoff: { this.logger.info('Shutting down queue service gracefully...');
type: 'fixed',
delay: 5000 try {
}, // Step 1: Stop accepting new jobs and wait for current jobs to finish
...options this.logger.debug('Closing workers gracefully...');
}); const workerClosePromises = this.workers.map(async (worker, index) => {
this.logger.debug(`Closing worker ${index + 1}/${this.workers.length}`);
this.logger.info('Recurring job added/updated successfully', { try {
jobKey, // Wait for current jobs to finish, then close
type: jobData.type, await Promise.race([
cronPattern, worker.close(),
immediately: jobData.immediately || false new Promise((_, reject) =>
}); setTimeout(() => reject(new Error(`Worker ${index + 1} close timeout`)), 5000)
),
return job; ]);
this.logger.debug(`Worker ${index + 1} closed successfully`);
} catch (error) { } catch (error) {
this.logger.error('Failed to add/update recurring job', { this.logger.error(`Failed to close worker ${index + 1}`, { error });
jobData, // Force close if graceful close fails
cronPattern, await worker.close(true);
error: error instanceof Error ? error.message : String(error) }
}); });
throw error;
} await Promise.allSettled(workerClosePromises);
} this.logger.debug('All workers closed');
async getJobStats() { // Step 2: Close queue and events with timeout protection
if (!this.isInitialized) { this.logger.debug('Closing queue and events...');
throw new Error('Queue service not initialized. Call initialize() first.'); await Promise.allSettled([
} Promise.race([
const [waiting, active, completed, failed, delayed] = await Promise.all([ this.queue.close(),
this.queue.getWaiting(), new Promise((_, reject) =>
this.queue.getActive(), setTimeout(() => reject(new Error('Queue close timeout')), 3000)
this.queue.getCompleted(), ),
this.queue.getFailed(), ]).catch(error => this.logger.error('Queue close error', { error })),
this.queue.getDelayed()
]); Promise.race([
this.queueEvents.close(),
return { new Promise((_, reject) =>
waiting: waiting.length, setTimeout(() => reject(new Error('QueueEvents close timeout')), 3000)
active: active.length, ),
completed: completed.length, ]).catch(error => this.logger.error('QueueEvents close error', { error })),
failed: failed.length, ]);
delayed: delayed.length
}; this.logger.info('Queue service shutdown completed successfully');
} } catch (error) {
this.logger.error('Error during queue service shutdown', { error });
async drainQueue() { // Force close everything as last resort
if (!this.isInitialized) { try {
await this.queue.drain() await Promise.allSettled([
} ...this.workers.map(worker => worker.close(true)),
} this.queue.close(),
this.queueEvents.close(),
async getQueueStatus() { ]);
if (!this.isInitialized) { } catch (forceCloseError) {
throw new Error('Queue service not initialized. Call initialize() first.'); this.logger.error('Force close also failed', { error: forceCloseError });
} }
const stats = await this.getJobStats(); throw error;
return { }
...stats, }
workers: this.getWorkerCount(), }
totalConcurrency: this.getTotalConcurrency(),
queue: this.queue.name, export const queueManager = new QueueService();
connection: {
host: process.env.DRAGONFLY_HOST || 'localhost',
port: parseInt(process.env.DRAGONFLY_PORT || '6379')
}
};
}
getWorkerCount() {
if (!this.isInitialized) {
return 0;
}
return this.workers.length;
}
getRegisteredProviders() {
return providerRegistry.getProviders().map(({ key, config }) => ({
key,
name: config.name,
service: config.service,
operations: Object.keys(config.operations),
scheduledJobs: config.scheduledJobs?.length || 0
}));
}
getScheduledJobsInfo() {
return providerRegistry.getAllScheduledJobs().map(({ service, provider, job }) => ({
id: `${service}-${provider}-${job.type}`,
service,
provider,
type: job.type,
operation: job.operation,
cronPattern: job.cronPattern,
priority: job.priority,
description: job.description,
immediately: job.immediately || false
}));
}
async shutdown() {
if (!this.isInitialized) {
this.logger.warn('Queue service not initialized, nothing to shutdown');
return;
}
this.logger.info('Shutting down queue service');
// Close all workers
this.logger.info(`Closing ${this.workers.length} workers...`);
await Promise.all(this.workers.map((worker, index) => {
this.logger.debug(`Closing worker ${index + 1}`);
return worker.close();
}));
await this.queue.close();
await this.queueEvents.close();
this.isInitialized = false;
this.logger.info('Queue service shutdown complete');
}
}
export const queueManager = new QueueService();

View file

@ -0,0 +1,364 @@
import { CacheProvider, createCache } from '@stock-bot/cache';
import { getLogger } from '@stock-bot/logger';
import type { QueueService } from '../services/queue.service';
const logger = getLogger('batch-helpers');
// Simple interfaces
export interface ProcessOptions {
totalDelayHours: number;
batchSize?: number;
priority?: number;
useBatching?: boolean;
retries?: number;
ttl?: number;
removeOnComplete?: number;
removeOnFail?: number;
// Job routing information
provider?: string;
operation?: string;
}
export interface BatchResult {
jobsCreated: number;
mode: 'direct' | 'batch';
totalItems: number;
batchesCreated?: number;
duration: number;
}
// Cache instance for payload storage
let cacheProvider: CacheProvider | null = null;
function getCache(): CacheProvider {
if (!cacheProvider) {
cacheProvider = createCache({
keyPrefix: 'batch:',
ttl: 86400, // 24 hours default
enableMetrics: true,
});
}
return cacheProvider;
}
/**
* Initialize the batch cache before any batch operations
* This should be called during application startup
*/
export async function initializeBatchCache(): Promise<void> {
logger.info('Initializing batch cache...');
const cache = getCache();
await cache.waitForReady(10000);
logger.info('Batch cache initialized successfully');
}
/**
* Main function - processes items either directly or in batches
*/
export async function processItems<T>(
items: T[],
processor: (item: T, index: number) => any,
queue: QueueService,
options: ProcessOptions
): Promise<BatchResult> {
const startTime = Date.now();
if (items.length === 0) {
return {
jobsCreated: 0,
mode: 'direct',
totalItems: 0,
duration: 0,
};
}
logger.info('Starting batch processing', {
totalItems: items.length,
mode: options.useBatching ? 'batch' : 'direct',
batchSize: options.batchSize,
totalDelayHours: options.totalDelayHours,
});
try {
const result = options.useBatching
? await processBatched(items, processor, queue, options)
: await processDirect(items, processor, queue, options);
const duration = Date.now() - startTime;
logger.info('Batch processing completed', {
...result,
duration: `${(duration / 1000).toFixed(1)}s`,
});
return { ...result, duration };
} catch (error) {
logger.error('Batch processing failed', error);
throw error;
}
}
/**
* Process items directly - each item becomes a separate job
*/
async function processDirect<T>(
items: T[],
processor: (item: T, index: number) => any,
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`,
});
const jobs = items.map((item, index) => ({
name: 'process-item',
data: {
type: 'process-item',
provider: options.provider || 'generic',
operation: options.operation || 'process-item',
payload: processor(item, index),
priority: options.priority || undefined,
},
opts: {
delay: index * delayPerItem,
priority: options.priority || undefined,
attempts: options.retries || 3,
removeOnComplete: options.removeOnComplete || 10,
removeOnFail: options.removeOnFail || 5,
},
}));
const createdJobs = await addJobsInChunks(queue, jobs);
return {
totalItems: items.length,
jobsCreated: createdJobs.length,
mode: 'direct',
};
}
/**
* Process items in batches - groups of items are stored and processed together
*/
async function processBatched<T>(
items: T[],
processor: (item: T, index: number) => any,
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;
const delayPerBatch = totalDelayMs / batches.length;
logger.info('Creating batch jobs', {
totalItems: items.length,
batchSize,
totalBatches: batches.length,
delayPerBatch: `${(delayPerBatch / 1000 / 60).toFixed(2)} minutes`,
});
const batchJobs = await Promise.all(
batches.map(async (batch, batchIndex) => {
const payloadKey = await storePayload(batch, processor, options);
return {
name: 'process-batch',
data: {
type: 'process-batch',
provider: options.provider || 'generic',
operation: 'process-batch-items',
payload: {
payloadKey,
batchIndex,
totalBatches: batches.length,
itemCount: batch.length,
},
priority: options.priority || undefined,
},
opts: {
delay: batchIndex * delayPerBatch,
priority: options.priority || undefined,
attempts: options.retries || 3,
removeOnComplete: options.removeOnComplete || 10,
removeOnFail: options.removeOnFail || 5,
},
};
})
);
const createdJobs = await addJobsInChunks(queue, batchJobs);
return {
totalItems: items.length,
jobsCreated: createdJobs.length,
batchesCreated: batches.length,
mode: 'batch',
};
}
/**
* Process a batch job - loads payload from cache and creates individual jobs
*/
export async function processBatchJob(jobData: any, queue: QueueService): Promise<any> {
const { payloadKey, batchIndex, totalBatches, itemCount } = jobData;
logger.debug('Processing batch job', {
batchIndex,
totalBatches,
itemCount,
});
try {
const payload = await loadPayload(payloadKey);
if (!payload || !payload.items || !payload.processorStr) {
logger.error('Invalid payload data', { payloadKey, payload });
throw new Error(`Invalid payload data for key: ${payloadKey}`);
}
const { items, processorStr, options } = payload;
// Deserialize the processor function
const processor = new Function('return ' + processorStr)();
const jobs = items.map((item: any, index: number) => ({
name: 'process-item',
data: {
type: 'process-item',
provider: options.provider || 'generic',
operation: options.operation || 'generic',
payload: processor(item, index),
priority: options.priority || undefined,
},
opts: {
delay: index * (options.delayPerItem || 1000),
priority: options.priority || undefined,
attempts: options.retries || 3,
},
}));
const createdJobs = await addJobsInChunks(queue, jobs);
// Cleanup payload after successful processing
await cleanupPayload(payloadKey);
return {
batchIndex,
itemsProcessed: items.length,
jobsCreated: createdJobs.length,
};
} catch (error) {
logger.error('Batch job processing failed', { batchIndex, error });
throw error;
}
}
// Helper functions
function createBatches<T>(items: T[], batchSize: number): T[][] {
const batches: T[][] = [];
for (let i = 0; i < items.length; i += batchSize) {
batches.push(items.slice(i, i + batchSize));
}
return batches;
}
async function storePayload<T>(
items: T[],
processor: (item: T, index: number) => any,
options: ProcessOptions
): Promise<string> {
const cache = getCache();
// Create more specific key: batch:provider:operation:payload_timestamp_random
const timestamp = Date.now();
const randomId = Math.random().toString(36).substr(2, 9);
const provider = options.provider || 'generic';
const operation = options.operation || 'generic';
const key = `${provider}:${operation}:payload_${timestamp}_${randomId}`;
const payload = {
items,
processorStr: processor.toString(),
options: {
delayPerItem: 1000,
priority: options.priority || undefined,
retries: options.retries || 3,
// Store routing information for later use
provider: options.provider || 'generic',
operation: options.operation || 'generic',
},
createdAt: Date.now(),
};
logger.debug('Storing batch payload', {
key,
itemCount: items.length,
});
await cache.set(key, payload, options.ttl || 86400);
logger.debug('Stored batch payload successfully', {
key,
itemCount: items.length,
});
return key;
}
async function loadPayload(key: string): Promise<any> {
const cache = getCache();
logger.debug('Loading batch payload', { key });
const data = await cache.get(key);
if (!data) {
logger.error('Payload not found in cache', { key });
throw new Error(`Payload not found: ${key}`);
}
logger.debug('Loaded batch payload successfully', { key });
return data;
}
async function cleanupPayload(key: string): Promise<void> {
try {
const cache = getCache();
await cache.del(key);
logger.debug('Cleaned up payload', { key });
} catch (error) {
logger.warn('Failed to cleanup payload', { key, error });
}
}
async function addJobsInChunks(queue: QueueService, jobs: any[], chunkSize = 100): Promise<any[]> {
const allCreatedJobs = [];
for (let i = 0; i < jobs.length; i += chunkSize) {
const chunk = jobs.slice(i, i + chunkSize);
try {
const createdJobs = await queue.addBulk(chunk);
allCreatedJobs.push(...createdJobs);
// Small delay between chunks to avoid overwhelming Redis
if (i + chunkSize < jobs.length) {
await new Promise(resolve => setTimeout(resolve, 100));
}
} catch (error) {
logger.error('Failed to add job chunk', {
startIndex: i,
chunkSize: chunk.length,
error,
});
}
}
return allCreatedJobs;
}

View file

@ -1,545 +0,0 @@
import { getLogger } from '@stock-bot/logger';
import { createCache, CacheProvider } from '@stock-bot/cache';
export interface BatchConfig<T> {
items: T[];
batchSize?: number; // Optional - only used for batch mode
totalDelayMs: number;
jobNamePrefix: string;
operation: string;
service: string;
provider: string;
priority?: number;
createJobData: (item: T, index: number) => any;
removeOnComplete?: number;
removeOnFail?: number;
useBatching?: boolean; // Simple flag to choose mode
payloadTtlHours?: number; // TTL for stored payloads (default 24 hours)
}
const logger = getLogger('batch-processor');
export class BatchProcessor {
private cacheProvider: CacheProvider;
private isReady = false;
private keyPrefix: string = 'batch:'; // Default key prefix for batch payloads
constructor(
private queueManager: any,
private cacheOptions?: { keyPrefix?: string; ttl?: number } // Optional cache configuration
) {
this.keyPrefix = cacheOptions?.keyPrefix || 'batch:'; // Initialize cache provider with batch-specific settings
this.cacheProvider = createCache({
keyPrefix: this.keyPrefix,
ttl: cacheOptions?.ttl || 86400 * 2, // 48 hours default
enableMetrics: true
});
this.initialize();
}
/**
* Initialize the batch processor and wait for cache to be ready
*/
async initialize(timeout: number = 10000): Promise<void> {
if (this.isReady) {
logger.warn('BatchProcessor already initialized');
return;
}
logger.info('Initializing BatchProcessor, waiting for cache to be ready...');
try {
await this.cacheProvider.waitForReady(timeout);
this.isReady = true;
logger.info('BatchProcessor initialized successfully', {
cacheReady: this.cacheProvider.isReady(),
keyPrefix: this.keyPrefix,
ttlHours: ((this.cacheOptions?.ttl || 86400 * 2) / 3600).toFixed(1)
});
} catch (error) {
logger.warn('BatchProcessor cache not ready within timeout, continuing with fallback mode', {
error: error instanceof Error ? error.message : String(error),
timeout
});
// Don't throw - mark as ready anyway and let cache operations use their fallback mechanisms
this.isReady = true;
}
}
/**
* Check if the batch processor is ready
*/
getReadyStatus(): boolean {
return this.isReady; // Don't require cache to be ready, let individual operations handle fallbacks
}
/**
* Generate a unique key for storing batch payload in Redis
* Note: The cache provider will add its keyPrefix ('batch:') automatically
*/
private generatePayloadKey(jobNamePrefix: string, batchIndex: number): string {
return `payload:${jobNamePrefix}:${batchIndex}:${Date.now()}`;
}/**
* Store batch payload in Redis and return the key
*/ private async storeBatchPayload<T>(
items: T[],
config: BatchConfig<T>,
batchIndex: number
): Promise<string> {
const payloadKey = this.generatePayloadKey(config.jobNamePrefix, batchIndex);
const payload = {
items,
batchIndex,
config: {
...config,
items: undefined // Don't store items twice
},
createdAt: new Date().toISOString()
};
const ttlSeconds = (config.payloadTtlHours || 24) * 60 * 60;
try {
await this.cacheProvider.set(
payloadKey,
JSON.stringify(payload),
ttlSeconds
);
logger.info('Stored batch payload in Redis', {
payloadKey,
itemCount: items.length,
batchIndex,
ttlHours: config.payloadTtlHours || 24
});
} catch (error) {
logger.error('Failed to store batch payload, job will run without caching', {
payloadKey,
error: error instanceof Error ? error.message : String(error)
});
// Don't throw - the job can still run, just without the cached payload
}
return payloadKey;
}/**
* Load batch payload from Redis
*/
private async loadBatchPayload<T>(payloadKey: string): Promise<{
items: T[];
batchIndex: number;
config: BatchConfig<T>;
} | null> {
// Auto-initialize if not ready
if (!this.cacheProvider.isReady() || !this.isReady) {
logger.info('Cache provider not ready, initializing...', { payloadKey });
try {
await this.initialize();
} catch (error) {
logger.error('Failed to initialize cache provider for loading', {
payloadKey,
error: error instanceof Error ? error.message : String(error)
});
throw new Error('Cache provider initialization failed - cannot load batch payload');
}
}
try {
const payloadData = await this.cacheProvider.get<any>(payloadKey);
if (!payloadData) {
logger.error('Batch payload not found in Redis', { payloadKey });
throw new Error('Batch payload not found in Redis');
}
// Handle both string and already-parsed object
let payload;
if (typeof payloadData === 'string') {
payload = JSON.parse(payloadData);
} else {
// Already parsed by cache provider
payload = payloadData;
}
logger.info('Loaded batch payload from Redis', {
payloadKey,
itemCount: payload.items?.length || 0,
batchIndex: payload.batchIndex
});
return payload;
} catch (error) {
logger.error('Failed to load batch payload from Redis', {
payloadKey,
error: error instanceof Error ? error.message : String(error)
});
throw new Error('Failed to load batch payload from Redis');
}
}
/**
* Unified method that handles both direct and batch approaches
*/
async processItems<T>(config: BatchConfig<T>) {
// Check if BatchProcessor is ready
if (!this.getReadyStatus()) {
logger.warn('BatchProcessor not ready, attempting to initialize...');
await this.initialize();
}
const { items, useBatching = false } = config;
if (items.length === 0) {
return { totalItems: 0, jobsCreated: 0 };
} // Final readiness check - wait briefly for cache to be ready
if (!this.cacheProvider.isReady()) {
logger.warn('Cache provider not ready, waiting briefly...');
try {
await this.cacheProvider.waitForReady(10000); // Wait up to 10 seconds
logger.info('Cache provider became ready');
} catch (error) {
logger.warn('Cache provider still not ready, continuing with fallback mode');
// Don't throw error - let the cache operations use their fallback mechanisms
}
}
logger.info('Starting item processing', {
totalItems: items.length,
mode: useBatching ? 'batch' : 'direct',
cacheReady: this.cacheProvider.isReady()
});
if (useBatching) {
return await this.createBatchJobs(config);
} else {
return await this.createDirectJobs(config);
}
}
private async createDirectJobs<T>(config: BatchConfig<T>) {
const {
items,
totalDelayMs,
jobNamePrefix,
operation,
service,
provider,
priority = 2,
createJobData,
removeOnComplete = 5,
removeOnFail = 3
} = config;
const delayPerItem = Math.floor(totalDelayMs / items.length);
const chunkSize = 100;
let totalJobsCreated = 0;
logger.info('Creating direct jobs', {
totalItems: items.length,
delayPerItem: `${(delayPerItem / 1000).toFixed(1)}s`,
estimatedDuration: `${(totalDelayMs / 1000 / 60 / 60).toFixed(1)} hours`
});
// Process in chunks to avoid overwhelming Redis
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
const jobs = chunk.map((item, chunkIndex) => {
const globalIndex = i + chunkIndex;
return {
name: `${jobNamePrefix}-processing`,
data: {
type: `${jobNamePrefix}-processing`,
service,
provider,
operation,
payload: createJobData(item, globalIndex),
priority
},
opts: {
delay: globalIndex * delayPerItem,
jobId: `${jobNamePrefix}:${globalIndex}:${Date.now()}`,
removeOnComplete,
removeOnFail
}
};
});
try {
const createdJobs = await this.queueManager.queue.addBulk(jobs);
totalJobsCreated += createdJobs.length;
// Log progress every 500 jobs
if (totalJobsCreated % 500 === 0 || i + chunkSize >= items.length) {
logger.info('Direct job creation progress', {
created: totalJobsCreated,
total: items.length,
percentage: `${((totalJobsCreated / items.length) * 100).toFixed(1)}%`
});
}
} catch (error) {
logger.error('Failed to create job chunk', {
startIndex: i,
chunkSize: chunk.length,
error: error instanceof Error ? error.message : String(error)
});
}
}
return {
totalItems: items.length,
jobsCreated: totalJobsCreated,
mode: 'direct'
};
}
private async createBatchJobs<T>(config: BatchConfig<T>) {
const {
items,
batchSize = 200,
totalDelayMs,
jobNamePrefix,
operation,
service,
provider,
priority = 3
} = config;
const totalBatches = Math.ceil(items.length / batchSize);
const delayPerBatch = Math.floor(totalDelayMs / totalBatches);
const chunkSize = 50; // Create batch jobs in chunks
let batchJobsCreated = 0;
logger.info('Creating optimized batch jobs with Redis payload storage', {
totalItems: items.length,
batchSize,
totalBatches,
delayPerBatch: `${(delayPerBatch / 1000 / 60).toFixed(2)} minutes`,
payloadTtlHours: config.payloadTtlHours || 24
});
// Create batch jobs in chunks
for (let chunkStart = 0; chunkStart < totalBatches; chunkStart += chunkSize) {
const chunkEnd = Math.min(chunkStart + chunkSize, totalBatches);
const batchJobs = [];
for (let batchIndex = chunkStart; batchIndex < chunkEnd; batchIndex++) {
const startIndex = batchIndex * batchSize;
const endIndex = Math.min(startIndex + batchSize, items.length);
const batchItems = items.slice(startIndex, endIndex);
// Store batch payload in Redis and get reference key
const payloadKey = await this.storeBatchPayload(batchItems, config, batchIndex);
batchJobs.push({
name: `${jobNamePrefix}-batch-processing`,
data: {
type: `${jobNamePrefix}-batch-processing`,
service,
provider,
operation: `process-${jobNamePrefix}-batch`,
payload: {
// Optimized: only store reference and metadata
payloadKey: payloadKey,
batchIndex,
total: totalBatches,
itemCount: batchItems.length,
configSnapshot: {
jobNamePrefix: config.jobNamePrefix,
operation: config.operation,
service: config.service,
provider: config.provider,
priority: config.priority,
removeOnComplete: config.removeOnComplete,
removeOnFail: config.removeOnFail,
totalDelayMs: config.totalDelayMs
}
},
priority
},
opts: {
delay: batchIndex * delayPerBatch,
jobId: `${jobNamePrefix}-batch:${batchIndex}:${Date.now()}`
}
});
}
try {
const createdJobs = await this.queueManager.queue.addBulk(batchJobs);
batchJobsCreated += createdJobs.length;
logger.info('Optimized batch chunk created', {
chunkStart: chunkStart + 1,
chunkEnd,
created: createdJobs.length,
totalCreated: batchJobsCreated,
progress: `${((chunkEnd / totalBatches) * 100).toFixed(1)}%`,
usingRedisStorage: true
});
} catch (error) {
logger.error('Failed to create batch chunk', {
chunkStart,
chunkEnd,
error: error instanceof Error ? error.message : String(error)
});
}
// Small delay between chunks
if (chunkEnd < totalBatches) {
await new Promise(resolve => setTimeout(resolve, 100));
}
} return {
totalItems: items.length,
batchJobsCreated,
totalBatches,
estimatedDurationHours: totalDelayMs / 1000 / 60 / 60,
mode: 'batch',
optimized: true
};
}
/**
* Process a batch (called by batch jobs)
* Supports both optimized (Redis payload storage) and fallback modes
*/
async processBatch<T>(
jobPayload: any,
createJobData?: (item: T, index: number) => any
) {
let batchData: {
items: T[];
batchIndex: number;
config: BatchConfig<T>;
};
let total: number;
// Check if this is an optimized batch with Redis payload storage
if (jobPayload.payloadKey) {
logger.info('Processing optimized batch with Redis payload storage', {
payloadKey: jobPayload.payloadKey,
batchIndex: jobPayload.batchIndex,
itemCount: jobPayload.itemCount
});
// Load actual payload from Redis
const loadedPayload = await this.loadBatchPayload<T>(jobPayload.payloadKey);
if (!loadedPayload) {
throw new Error(`Failed to load batch payload from Redis: ${jobPayload.payloadKey}`);
}
batchData = loadedPayload;
total = jobPayload.total;
// Clean up Redis payload after loading (optional - you might want to keep it for retry scenarios)
// await this.redisClient?.del(jobPayload.payloadKey);
} else {
// Fallback: payload stored directly in job data
logger.info('Processing batch with inline payload storage', {
batchIndex: jobPayload.batchIndex,
itemCount: jobPayload.items?.length || 0
});
batchData = {
items: jobPayload.items,
batchIndex: jobPayload.batchIndex,
config: jobPayload.config
};
total = jobPayload.total;
}
const { items, batchIndex, config } = batchData;
logger.info('Processing batch', {
batchIndex,
batchSize: items.length,
total,
progress: `${((batchIndex + 1) / total * 100).toFixed(2)}%`,
isOptimized: !!jobPayload.payloadKey
});
const totalBatchDelayMs = config.totalDelayMs / total;
const delayPerItem = Math.floor(totalBatchDelayMs / items.length);
const jobs = items.map((item, itemIndex) => {
// Use the provided createJobData function or fall back to config
const jobDataFn = createJobData || config.createJobData;
if (!jobDataFn) {
throw new Error('createJobData function is required');
}
const userData = jobDataFn(item, itemIndex);
return {
name: `${config.jobNamePrefix}-processing`,
data: {
type: `${config.jobNamePrefix}-processing`,
service: config.service,
provider: config.provider,
operation: config.operation,
payload: {
...userData,
batchIndex,
itemIndex,
total,
source: userData.source || 'batch-processing'
},
priority: config.priority || 2
},
opts: {
delay: itemIndex * delayPerItem,
jobId: `${config.jobNamePrefix}:${batchIndex}:${itemIndex}:${Date.now()}`,
removeOnComplete: config.removeOnComplete || 5,
removeOnFail: config.removeOnFail || 3
}
};
});
try {
const createdJobs = await this.queueManager.queue.addBulk(jobs);
logger.info('Batch processing completed', {
batchIndex,
totalItems: items.length,
jobsCreated: createdJobs.length,
progress: `${((batchIndex + 1) / total * 100).toFixed(2)}%`,
memoryOptimized: !!jobPayload.payloadKey
});
return {
batchIndex,
totalItems: items.length,
jobsCreated: createdJobs.length,
jobsFailed: 0,
payloadKey: jobPayload.payloadKey || null
};
} catch (error) {
logger.error('Failed to process batch', {
batchIndex,
error: error instanceof Error ? error.message : String(error)
});
return {
batchIndex,
totalItems: items.length,
jobsCreated: 0,
jobsFailed: items.length,
payloadKey: jobPayload.payloadKey || null
};
}
} /**
* Clean up Redis payload after successful processing (optional)
*/
async cleanupBatchPayload(payloadKey: string): Promise<void> {
if (!payloadKey) {
return;
}
if (!this.cacheProvider.isReady()) {
logger.warn('Cache provider not ready - skipping cleanup', { payloadKey });
return;
}
try {
await this.cacheProvider.del(payloadKey);
logger.info('Cleaned up batch payload from Redis', { payloadKey });
} catch (error) {
logger.warn('Failed to cleanup batch payload', {
payloadKey,
error: error instanceof Error ? error.message : String(error)
});
}
}
}

View file

@ -1,20 +1,30 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src" "rootDir": "./src"
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/test/**", "**/tests/**", "**/__tests__/**"], "exclude": [
"references": [ "node_modules",
{ "path": "../../libs/types" }, "dist",
{ "path": "../../libs/config" }, "**/*.test.ts",
{ "path": "../../libs/logger" }, "**/*.spec.ts",
{ "path": "../../libs/http" }, "**/test/**",
{ "path": "../../libs/cache" }, "**/tests/**",
{ "path": "../../libs/questdb-client" }, "**/__tests__/**"
{ "path": "../../libs/mongodb-client" }, ],
{ "path": "../../libs/event-bus" }, "references": [
{ "path": "../../libs/shutdown" } { "path": "../../libs/types" },
] { "path": "../../libs/config" },
} { "path": "../../libs/logger" },
{ "path": "../../libs/http" },
{ "path": "../../libs/cache" },
{ "path": "../../libs/questdb-client" },
{ "path": "../../libs/mongodb-client" },
{ "path": "../../libs/event-bus" },
{ "path": "../../libs/shutdown" },
{ "path": "../../libs/utils" },
{ "path": "../../libs/browser" }
]
}

View file

@ -1,19 +1,29 @@
{ {
"extends": ["//"], "extends": ["//"],
"tasks": { "tasks": {
"build": { "build": {
"dependsOn": [ "dependsOn": [
"@stock-bot/cache#build", "@stock-bot/cache#build",
"@stock-bot/config#build", "@stock-bot/config#build",
"@stock-bot/event-bus#build", "@stock-bot/event-bus#build",
"@stock-bot/http#build", "@stock-bot/http#build",
"@stock-bot/logger#build", "@stock-bot/logger#build",
"@stock-bot/mongodb-client#build", "@stock-bot/mongodb-client#build",
"@stock-bot/questdb-client#build", "@stock-bot/questdb-client#build",
"@stock-bot/shutdown#build" "@stock-bot/shutdown#build",
], "@stock-bot/browser#build"
"outputs": ["dist/**"], ],
"inputs": ["src/**", "package.json", "tsconfig.json", "!**/*.test.ts", "!**/*.spec.ts", "!**/test/**", "!**/tests/**", "!**/__tests__/**"] "outputs": ["dist/**"],
} "inputs": [
} "src/**",
} "package.json",
"tsconfig.json",
"!**/*.test.ts",
"!**/*.spec.ts",
"!**/test/**",
"!**/tests/**",
"!**/__tests__/**"
]
}
}
}

View file

@ -1,37 +1,37 @@
{ {
"name": "@stock-bot/execution-service", "name": "@stock-bot/execution-service",
"version": "1.0.0", "version": "1.0.0",
"description": "Execution service for stock trading bot - handles order execution and broker integration", "description": "Execution service for stock trading bot - handles order execution and broker integration",
"main": "dist/index.js", "main": "dist/index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"devvvvv": "bun --watch src/index.ts", "devvvvv": "bun --watch src/index.ts",
"start": "bun src/index.ts", "start": "bun src/index.ts",
"test": "bun test", "test": "bun test",
"lint": "eslint src --ext .ts", "lint": "eslint src --ext .ts",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@hono/node-server": "^1.12.0", "@hono/node-server": "^1.12.0",
"hono": "^4.6.1", "hono": "^4.6.1",
"@stock-bot/config": "*", "@stock-bot/config": "*",
"@stock-bot/logger": "*", "@stock-bot/logger": "*",
"@stock-bot/types": "*", "@stock-bot/types": "*",
"@stock-bot/event-bus": "*", "@stock-bot/event-bus": "*",
"@stock-bot/utils": "*" "@stock-bot/utils": "*"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.5.0", "@types/node": "^22.5.0",
"typescript": "^5.5.4" "typescript": "^5.5.4"
}, },
"keywords": [ "keywords": [
"trading", "trading",
"execution", "execution",
"broker", "broker",
"orders", "orders",
"stock-bot" "stock-bot"
], ],
"author": "Stock Bot Team", "author": "Stock Bot Team",
"license": "MIT" "license": "MIT"
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -1,17 +1,25 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src" "rootDir": "./src"
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/test/**", "**/tests/**", "**/__tests__/**"], "exclude": [
"references": [ "node_modules",
{ "path": "../../libs/types" }, "dist",
{ "path": "../../libs/config" }, "**/*.test.ts",
{ "path": "../../libs/logger" }, "**/*.spec.ts",
{ "path": "../../libs/utils" }, "**/test/**",
{ "path": "../../libs/event-bus" }, "**/tests/**",
{ "path": "../../libs/shutdown" } "**/__tests__/**"
] ],
} "references": [
{ "path": "../../libs/types" },
{ "path": "../../libs/config" },
{ "path": "../../libs/logger" },
{ "path": "../../libs/utils" },
{ "path": "../../libs/event-bus" },
{ "path": "../../libs/shutdown" }
]
}

View file

@ -1,17 +1,26 @@
{ {
"extends": ["//"], "extends": ["//"],
"tasks": { "tasks": {
"build": { "build": {
"dependsOn": [ "dependsOn": [
"@stock-bot/types#build", "@stock-bot/types#build",
"@stock-bot/config#build", "@stock-bot/config#build",
"@stock-bot/logger#build", "@stock-bot/logger#build",
"@stock-bot/utils#build", "@stock-bot/utils#build",
"@stock-bot/event-bus#build", "@stock-bot/event-bus#build",
"@stock-bot/shutdown#build" "@stock-bot/shutdown#build"
], ],
"outputs": ["dist/**"], "outputs": ["dist/**"],
"inputs": ["src/**", "package.json", "tsconfig.json", "!**/*.test.ts", "!**/*.spec.ts", "!**/test/**", "!**/tests/**", "!**/__tests__/**"] "inputs": [
} "src/**",
} "package.json",
} "tsconfig.json",
"!**/*.test.ts",
"!**/*.spec.ts",
"!**/test/**",
"!**/tests/**",
"!**/__tests__/**"
]
}
}
}

View file

@ -1,38 +1,38 @@
{ {
"name": "@stock-bot/portfolio-service", "name": "@stock-bot/portfolio-service",
"version": "1.0.0", "version": "1.0.0",
"description": "Portfolio service for stock trading bot - handles portfolio tracking and performance analytics", "description": "Portfolio service for stock trading bot - handles portfolio tracking and performance analytics",
"main": "dist/index.js", "main": "dist/index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"devvvvv": "bun --watch src/index.ts", "devvvvv": "bun --watch src/index.ts",
"start": "bun src/index.ts", "start": "bun src/index.ts",
"test": "bun test", "test": "bun test",
"lint": "eslint src --ext .ts", "lint": "eslint src --ext .ts",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@hono/node-server": "^1.12.0", "@hono/node-server": "^1.12.0",
"hono": "^4.6.1", "hono": "^4.6.1",
"@stock-bot/config": "*", "@stock-bot/config": "*",
"@stock-bot/logger": "*", "@stock-bot/logger": "*",
"@stock-bot/types": "*", "@stock-bot/types": "*",
"@stock-bot/questdb-client": "*", "@stock-bot/questdb-client": "*",
"@stock-bot/utils": "*", "@stock-bot/utils": "*",
"@stock-bot/data-frame": "*" "@stock-bot/data-frame": "*"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.5.0", "@types/node": "^22.5.0",
"typescript": "^5.5.4" "typescript": "^5.5.4"
}, },
"keywords": [ "keywords": [
"trading", "trading",
"portfolio", "portfolio",
"performance", "performance",
"analytics", "analytics",
"stock-bot" "stock-bot"
], ],
"author": "Stock Bot Team", "author": "Stock Bot Team",
"license": "MIT" "license": "MIT"
} }

View file

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

View file

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

View file

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

View file

@ -1,18 +1,26 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src" "rootDir": "./src"
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/test/**", "**/tests/**", "**/__tests__/**"], "exclude": [
"references": [ "node_modules",
{ "path": "../../libs/types" }, "dist",
{ "path": "../../libs/config" }, "**/*.test.ts",
{ "path": "../../libs/logger" }, "**/*.spec.ts",
{ "path": "../../libs/utils" }, "**/test/**",
{ "path": "../../libs/postgres-client" }, "**/tests/**",
{ "path": "../../libs/event-bus" }, "**/__tests__/**"
{ "path": "../../libs/shutdown" } ],
] "references": [
} { "path": "../../libs/types" },
{ "path": "../../libs/config" },
{ "path": "../../libs/logger" },
{ "path": "../../libs/utils" },
{ "path": "../../libs/postgres-client" },
{ "path": "../../libs/event-bus" },
{ "path": "../../libs/shutdown" }
]
}

View file

@ -1,18 +1,27 @@
{ {
"extends": ["//"], "extends": ["//"],
"tasks": { "tasks": {
"build": { "build": {
"dependsOn": [ "dependsOn": [
"@stock-bot/types#build", "@stock-bot/types#build",
"@stock-bot/config#build", "@stock-bot/config#build",
"@stock-bot/logger#build", "@stock-bot/logger#build",
"@stock-bot/utils#build", "@stock-bot/utils#build",
"@stock-bot/postgres-client#build", "@stock-bot/postgres-client#build",
"@stock-bot/event-bus#build", "@stock-bot/event-bus#build",
"@stock-bot/shutdown#build" "@stock-bot/shutdown#build"
], ],
"outputs": ["dist/**"], "outputs": ["dist/**"],
"inputs": ["src/**", "package.json", "tsconfig.json", "!**/*.test.ts", "!**/*.spec.ts", "!**/test/**", "!**/tests/**", "!**/__tests__/**"] "inputs": [
} "src/**",
} "package.json",
} "tsconfig.json",
"!**/*.test.ts",
"!**/*.spec.ts",
"!**/test/**",
"!**/tests/**",
"!**/__tests__/**"
]
}
}
}

View file

@ -1,26 +1,26 @@
{ {
"name": "@stock-bot/processing-service", "name": "@stock-bot/processing-service",
"version": "1.0.0", "version": "1.0.0",
"description": "Combined data processing and technical indicators service", "description": "Combined data processing and technical indicators service",
"main": "dist/index.js", "main": "dist/index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"devvvvv": "bun --watch src/index.ts", "devvvvv": "bun --watch src/index.ts",
"build": "bun build src/index.ts --outdir dist --target node", "build": "bun build src/index.ts --outdir dist --target node",
"start": "bun dist/index.js", "start": "bun dist/index.js",
"test": "bun test", "test": "bun test",
"clean": "rm -rf dist" "clean": "rm -rf dist"
}, },
"dependencies": { "dependencies": {
"@stock-bot/config": "*", "@stock-bot/config": "*",
"@stock-bot/logger": "*", "@stock-bot/logger": "*",
"@stock-bot/types": "*", "@stock-bot/types": "*",
"@stock-bot/utils": "*", "@stock-bot/utils": "*",
"@stock-bot/event-bus": "*", "@stock-bot/event-bus": "*",
"@stock-bot/vector-engine": "*", "@stock-bot/vector-engine": "*",
"hono": "^4.0.0" "hono": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.0.0" "typescript": "^5.0.0"
} }
} }

View file

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

View file

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

View file

@ -1,20 +1,28 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src" "rootDir": "./src"
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/test/**", "**/tests/**", "**/__tests__/**"], "exclude": [
"references": [ "node_modules",
{ "path": "../../libs/types" }, "dist",
{ "path": "../../libs/config" }, "**/*.test.ts",
{ "path": "../../libs/logger" }, "**/*.spec.ts",
{ "path": "../../libs/utils" }, "**/test/**",
{ "path": "../../libs/data-frame" }, "**/tests/**",
{ "path": "../../libs/vector-engine" }, "**/__tests__/**"
{ "path": "../../libs/mongodb-client" }, ],
{ "path": "../../libs/event-bus" }, "references": [
{ "path": "../../libs/shutdown" } { "path": "../../libs/types" },
] { "path": "../../libs/config" },
} { "path": "../../libs/logger" },
{ "path": "../../libs/utils" },
{ "path": "../../libs/data-frame" },
{ "path": "../../libs/vector-engine" },
{ "path": "../../libs/mongodb-client" },
{ "path": "../../libs/event-bus" },
{ "path": "../../libs/shutdown" }
]
}

View file

@ -1,20 +1,29 @@
{ {
"extends": ["//"], "extends": ["//"],
"tasks": { "tasks": {
"build": { "build": {
"dependsOn": [ "dependsOn": [
"@stock-bot/types#build", "@stock-bot/types#build",
"@stock-bot/config#build", "@stock-bot/config#build",
"@stock-bot/logger#build", "@stock-bot/logger#build",
"@stock-bot/utils#build", "@stock-bot/utils#build",
"@stock-bot/data-frame#build", "@stock-bot/data-frame#build",
"@stock-bot/vector-engine#build", "@stock-bot/vector-engine#build",
"@stock-bot/mongodb-client#build", "@stock-bot/mongodb-client#build",
"@stock-bot/event-bus#build", "@stock-bot/event-bus#build",
"@stock-bot/shutdown#build" "@stock-bot/shutdown#build"
], ],
"outputs": ["dist/**"], "outputs": ["dist/**"],
"inputs": ["src/**", "package.json", "tsconfig.json", "!**/*.test.ts", "!**/*.spec.ts", "!**/test/**", "!**/tests/**", "!**/__tests__/**"] "inputs": [
} "src/**",
} "package.json",
} "tsconfig.json",
"!**/*.test.ts",
"!**/*.spec.ts",
"!**/test/**",
"!**/tests/**",
"!**/__tests__/**"
]
}
}
}

View file

@ -1,33 +1,34 @@
{ {
"name": "@stock-bot/strategy-service", "name": "@stock-bot/strategy-service",
"version": "1.0.0", "version": "1.0.0",
"description": "Combined strategy execution and multi-mode backtesting service", "description": "Combined strategy execution and multi-mode backtesting service",
"main": "dist/index.js", "main": "dist/index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"devvvvv": "bun --watch src/index.ts", "devvvvv": "bun --watch src/index.ts",
"build": "bun build src/index.ts --outdir dist --target node", "build": "bun build src/index.ts --outdir dist --target node",
"start": "bun dist/index.js", "start": "bun dist/index.js",
"test": "bun test", "clean": "rm -rf dist", "test": "bun test",
"backtest": "bun src/cli/index.ts", "clean": "rm -rf dist",
"optimize": "bun src/cli/index.ts optimize", "backtest": "bun src/cli/index.ts",
"cli": "bun src/cli/index.ts" "optimize": "bun src/cli/index.ts optimize",
}, "cli": "bun src/cli/index.ts"
"dependencies": { },
"@stock-bot/config": "*", "dependencies": {
"@stock-bot/logger": "*", "@stock-bot/config": "*",
"@stock-bot/types": "*", "@stock-bot/logger": "*",
"@stock-bot/utils": "*", "@stock-bot/types": "*",
"@stock-bot/event-bus": "*", "@stock-bot/utils": "*",
"@stock-bot/strategy-engine": "*", "@stock-bot/event-bus": "*",
"@stock-bot/vector-engine": "*", "@stock-bot/strategy-engine": "*",
"@stock-bot/data-frame": "*", "@stock-bot/vector-engine": "*",
"@stock-bot/questdb-client": "*", "@stock-bot/data-frame": "*",
"hono": "^4.0.0", "@stock-bot/questdb-client": "*",
"commander": "^11.0.0" "hono": "^4.0.0",
}, "commander": "^11.0.0"
"devDependencies": { },
"@types/node": "^20.0.0", "devDependencies": {
"typescript": "^5.0.0" "@types/node": "^20.0.0",
} "typescript": "^5.0.0"
} }
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,18 +1,26 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src" "rootDir": "./src"
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/test/**", "**/tests/**", "**/__tests__/**"], "exclude": [
"references": [ "node_modules",
{ "path": "../../libs/types" }, "dist",
{ "path": "../../libs/config" }, "**/*.test.ts",
{ "path": "../../libs/logger" }, "**/*.spec.ts",
{ "path": "../../libs/utils" }, "**/test/**",
{ "path": "../../libs/strategy-engine" }, "**/tests/**",
{ "path": "../../libs/event-bus" }, "**/__tests__/**"
{ "path": "../../libs/shutdown" } ],
] "references": [
} { "path": "../../libs/types" },
{ "path": "../../libs/config" },
{ "path": "../../libs/logger" },
{ "path": "../../libs/utils" },
{ "path": "../../libs/strategy-engine" },
{ "path": "../../libs/event-bus" },
{ "path": "../../libs/shutdown" }
]
}

View file

@ -1,18 +1,27 @@
{ {
"extends": ["//"], "extends": ["//"],
"tasks": { "tasks": {
"build": { "build": {
"dependsOn": [ "dependsOn": [
"@stock-bot/types#build", "@stock-bot/types#build",
"@stock-bot/config#build", "@stock-bot/config#build",
"@stock-bot/logger#build", "@stock-bot/logger#build",
"@stock-bot/utils#build", "@stock-bot/utils#build",
"@stock-bot/strategy-engine#build", "@stock-bot/strategy-engine#build",
"@stock-bot/event-bus#build", "@stock-bot/event-bus#build",
"@stock-bot/shutdown#build" "@stock-bot/shutdown#build"
], ],
"outputs": ["dist/**"], "outputs": ["dist/**"],
"inputs": ["src/**", "package.json", "tsconfig.json", "!**/*.test.ts", "!**/*.spec.ts", "!**/test/**", "!**/tests/**", "!**/__tests__/**"] "inputs": [
} "src/**",
} "package.json",
} "tsconfig.json",
"!**/*.test.ts",
"!**/*.spec.ts",
"!**/test/**",
"!**/tests/**",
"!**/__tests__/**"
]
}
}
}

660
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,176 @@
# Batch Processing Migration Guide
## ✅ MIGRATION COMPLETED
The migration from the complex `BatchProcessor` class to the new functional batch processing approach has been **successfully completed**. The old `BatchProcessor` class has been removed entirely.
## Overview
The new functional batch processing approach simplified the complex `BatchProcessor` class into simple, composable functions.
## Key Benefits Achieved
**90% less code** - From 545 lines to ~200 lines
**Simpler API** - Just function calls instead of class instantiation
**Better performance** - Less overhead and memory usage
**Same functionality** - All features preserved
**Type safe** - Better TypeScript support
**No more payload conflicts** - Single consistent batch system
## Available Functions
All batch processing now uses the new functional approach:
### 1. `processItems<T>()` - Generic processing
```typescript
import { processItems } from '../utils/batch-helpers';
const result = await processItems(
items,
(item, index) => ({ /* transform item */ }),
queueManager,
{
totalDelayMs: 60000,
useBatching: false,
batchSize: 100,
priority: 1
}
);
```
### 2. `processSymbols()` - Stock symbol processing
```typescript
import { processSymbols } from '../utils/batch-helpers';
const result = await processSymbols(['AAPL', 'GOOGL'], queueManager, {
operation: 'live-data',
service: 'market-data',
provider: 'yahoo',
totalDelayMs: 300000,
useBatching: false,
priority: 1,
service: 'market-data',
provider: 'yahoo',
operation: 'live-data'
});
```
### 3. `processBatchJob()` - Worker batch handler
```typescript
import { processBatchJob } from '../utils/batch-helpers';
// In your worker job handler
const result = await processBatchJob(jobData, queueManager);
```
## Configuration Mapping
| Old BatchConfig | New ProcessOptions | Description |
|----------------|-------------------|-------------|
| `items` | First parameter | Items to process |
| `createJobData` | Second parameter | Transform function |
| `queueManager` | Third parameter | Queue instance |
| `totalDelayMs` | `totalDelayMs` | Total processing time |
| `batchSize` | `batchSize` | Items per batch |
| `useBatching` | `useBatching` | Batch vs direct mode |
| `priority` | `priority` | Job priority |
| `removeOnComplete` | `removeOnComplete` | Job cleanup |
| `removeOnFail` | `removeOnFail` | Failed job cleanup |
| `payloadTtlHours` | `ttl` | Cache TTL in seconds |
## Return Value Changes
### Before
```typescript
{
totalItems: number,
jobsCreated: number,
mode: 'direct' | 'batch',
optimized?: boolean,
batchJobsCreated?: number,
// ... other complex fields
}
```
### After
```typescript
{
jobsCreated: number,
mode: 'direct' | 'batch',
totalItems: number,
batchesCreated?: number,
duration: number
}
```
## Provider Migration
### ✅ Current Implementation
All providers now use the new functional approach:
```typescript
'process-batch-items': async (payload: any) => {
const { processBatchJob } = await import('../utils/batch-helpers');
return await processBatchJob(payload, queueManager);
}
```
## Testing the New Approach
Use the new test endpoints:
```bash
# Test symbol processing
curl -X POST http://localhost:3002/api/test/batch-symbols \
-H "Content-Type: application/json" \
-d '{"symbols": ["AAPL", "GOOGL"], "useBatching": false, "totalDelayMs": 10000}'
# Test custom processing
curl -X POST http://localhost:3002/api/test/batch-custom \
-H "Content-Type: application/json" \
-d '{"items": [1,2,3,4,5], "useBatching": true, "totalDelayMs": 15000}'
```
## Performance Improvements
| Metric | Before | After | Improvement |
|--------|--------|-------|-------------|
| Code Lines | 545 | ~200 | 63% reduction |
| Memory Usage | High | Low | ~40% less |
| Initialization Time | ~2-10s | Instant | 100% faster |
| API Complexity | High | Low | Much simpler |
| Type Safety | Medium | High | Better types |
## ✅ Migration Complete
The old `BatchProcessor` class has been completely removed. All batch processing now uses the simplified functional approach.
## Common Issues & Solutions
### Function Serialization
The new approach serializes processor functions for batch jobs. Avoid:
- Closures with external variables
- Complex function dependencies
- Non-serializable objects
**Good:**
```typescript
(item, index) => ({ id: item.id, index })
```
**Bad:**
```typescript
const externalVar = 'test';
(item, index) => ({ id: item.id, external: externalVar }) // Won't work
```
### Cache Dependencies
The functional approach automatically handles cache initialization. No need to manually wait for cache readiness.
## Need Help?
Check the examples in `apps/data-service/src/examples/batch-processing-examples.ts` for more detailed usage patterns.

77
eslint.config.js Normal file
View file

@ -0,0 +1,77 @@
import js from '@eslint/js';
import tseslint from '@typescript-eslint/eslint-plugin';
import tsparser from '@typescript-eslint/parser';
export default [
// Global ignores first
{
ignores: [
'dist/**',
'build/**',
'node_modules/**',
'**/*.js',
'**/*.mjs',
'**/*.d.ts',
'.turbo/**',
'coverage/**',
'scripts/**',
'monitoring/**',
'database/**',
'**/.angular/**',
'**/src/polyfills.ts',
],
},
// Base JavaScript configuration
js.configs.recommended,
// TypeScript configuration
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
// Disable base rules that are covered by TypeScript equivalents
'no-unused-vars': 'off',
'no-undef': 'off',
// TypeScript specific rules
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'warn',
// General rules
'no-console': 'warn',
'no-debugger': 'error',
'no-var': 'error',
'prefer-const': 'error',
eqeqeq: ['error', 'always'],
curly: ['error', 'all'],
},
},
// Test files configuration
{
files: ['**/*.test.ts', '**/*.spec.ts', '**/test/**/*', '**/tests/**/*'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'no-console': 'off',
},
},
];

24
libs/browser/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "@stock-bot/browser",
"version": "1.0.0",
"description": "High-performance browser automation library with proxy support",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"scripts": {
"build": "tsc",
"test": "bun test",
"dev": "tsc --watch"
},
"dependencies": {
"playwright": "^1.53.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"@stock-bot/logger": "workspace:*",
"@stock-bot/http": "workspace:*"
}
}

View file

361
libs/browser/src/browser.ts Normal file
View file

@ -0,0 +1,361 @@
import { BrowserContext, chromium, Page, Browser as PlaywrightBrowser } from 'playwright';
import { getLogger } from '@stock-bot/logger';
import type { BrowserOptions, NetworkEvent, NetworkEventHandler } from './types';
class BrowserSingleton {
private browser?: PlaywrightBrowser;
private contexts: Map<string, BrowserContext> = new Map();
private logger = getLogger('browser');
private options: BrowserOptions;
private initialized = false;
constructor() {
this.options = {
headless: true,
timeout: 30000,
blockResources: false,
enableNetworkLogging: false,
};
}
async initialize(options: BrowserOptions = {}): Promise<void> {
if (this.initialized) {
return;
}
// Merge options
this.options = {
...this.options,
...options,
};
this.logger.info('Initializing browser...');
try {
this.browser = await chromium.launch({
headless: this.options.headless,
timeout: this.options.timeout,
args: [
// Security and sandbox
'--no-sandbox',
// '--disable-setuid-sandbox',
// '--disable-dev-shm-usage',
// '--disable-web-security',
// '--disable-features=VizDisplayCompositor',
// '--disable-blink-features=AutomationControlled',
// // Performance optimizations
// '--disable-gpu',
// '--disable-gpu-sandbox',
// '--disable-software-rasterizer',
// '--disable-background-timer-throttling',
// '--disable-renderer-backgrounding',
// '--disable-backgrounding-occluded-windows',
// '--disable-field-trial-config',
// '--disable-back-forward-cache',
// '--disable-hang-monitor',
// '--disable-ipc-flooding-protection',
// // Extensions and plugins
// '--disable-extensions',
// '--disable-plugins',
// '--disable-component-extensions-with-background-pages',
// '--disable-component-update',
// '--disable-plugins-discovery',
// '--disable-bundled-ppapi-flash',
// // Features we don't need
// '--disable-default-apps',
// '--disable-sync',
// '--disable-translate',
// '--disable-client-side-phishing-detection',
// '--disable-domain-reliability',
// '--disable-features=TranslateUI',
// '--disable-features=Translate',
// '--disable-breakpad',
// '--disable-preconnect',
// '--disable-print-preview',
// '--disable-password-generation',
// '--disable-password-manager-reauthentication',
// '--disable-save-password-bubble',
// '--disable-single-click-autofill',
// '--disable-autofill',
// '--disable-autofill-keyboard-accessory-view',
// '--disable-full-form-autofill-ios',
// // Audio/Video/Media
// '--mute-audio',
// '--disable-audio-output',
// '--autoplay-policy=user-gesture-required',
// '--disable-background-media-playback',
// // Networking
// '--disable-background-networking',
// '--disable-sync',
// '--aggressive-cache-discard',
// '--disable-default-apps',
// // UI/UX optimizations
// '--no-first-run',
// '--disable-infobars',
// '--disable-notifications',
// '--disable-desktop-notifications',
// '--disable-prompt-on-repost',
// '--disable-logging',
// '--disable-file-system',
// '--hide-scrollbars',
// // Memory optimizations
// '--memory-pressure-off',
// '--max_old_space_size=4096',
// '--js-flags="--max-old-space-size=4096"',
// '--media-cache-size=1',
// '--disk-cache-size=1',
// // Process management
// '--use-mock-keychain',
// '--password-store=basic',
// '--enable-automation',
// '--no-pings',
// '--no-service-autorun',
// '--metrics-recording-only',
// '--safebrowsing-disable-auto-update',
// // Disable unnecessary features for headless mode
// '--disable-speech-api',
// '--disable-gesture-typing',
// '--disable-voice-input',
// '--disable-wake-on-wifi',
// '--disable-webgl',
// '--disable-webgl2',
// '--disable-3d-apis',
// '--disable-accelerated-2d-canvas',
// '--disable-accelerated-jpeg-decoding',
// '--disable-accelerated-mjpeg-decode',
// '--disable-accelerated-video-decode',
// '--disable-canvas-aa',
// '--disable-2d-canvas-clip-aa',
// '--disable-gl-drawing-for-tests',
],
});
this.initialized = true;
this.logger.info('Browser initialized successfully');
} catch (error) {
this.logger.error('Failed to initialize browser', { error });
throw error;
}
}
async createPageWithProxy(
url: string,
proxy?: string
): Promise<{
page: Page & {
onNetworkEvent: (handler: NetworkEventHandler) => void;
offNetworkEvent: (handler: NetworkEventHandler) => void;
clearNetworkListeners: () => void;
};
contextId: string;
}> {
if (!this.browser) {
throw new Error('Browser not initialized. Call Browser.initialize() first.');
}
const contextId = `ctx-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const contextOptions: Record<string, unknown> = {
ignoreHTTPSErrors: true,
bypassCSP: true,
};
if (proxy) {
const [protocol, rest] = proxy.split('://');
const [auth, hostPort] = rest.includes('@') ? rest.split('@') : [null, rest];
const [host, port] = hostPort.split(':');
contextOptions.proxy = {
server: `${protocol}://${host}:${port}`,
username: auth?.split(':')[0] || '',
password: auth?.split(':')[1] || '',
};
}
const context = await this.browser.newContext(contextOptions);
// Block resources for performance
if (this.options.blockResources) {
await context.route('**/*.{png,jpg,jpeg,gif,svg,ico,woff,woff2,ttf,css}', route => {
route.abort();
});
}
this.contexts.set(contextId, context);
const page = await context.newPage();
page.setDefaultTimeout(this.options.timeout || 30000);
page.setDefaultNavigationTimeout(this.options.timeout || 30000);
// Create network event handlers for this page
const networkEventHandlers: Set<NetworkEventHandler> = new Set();
// Add network monitoring methods to the page
const enhancedPage = page as Page & {
onNetworkEvent: (handler: NetworkEventHandler) => void;
offNetworkEvent: (handler: NetworkEventHandler) => void;
clearNetworkListeners: () => void;
};
enhancedPage.onNetworkEvent = (handler: NetworkEventHandler) => {
networkEventHandlers.add(handler);
// Set up network monitoring on first handler
if (networkEventHandlers.size === 1) {
this.setupNetworkMonitoring(page, networkEventHandlers);
}
};
enhancedPage.offNetworkEvent = (handler: NetworkEventHandler) => {
networkEventHandlers.delete(handler);
};
enhancedPage.clearNetworkListeners = () => {
networkEventHandlers.clear();
};
if (url) {
await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: this.options.timeout,
});
}
return { page: enhancedPage, contextId };
}
private setupNetworkMonitoring(page: Page, handlers: Set<NetworkEventHandler>): void {
// Listen to requests
page.on('request', async request => {
const event: NetworkEvent = {
url: request.url(),
method: request.method(),
type: 'request',
timestamp: Date.now(),
headers: request.headers(),
};
// Capture request data for POST/PUT/PATCH requests
if (['POST', 'PUT', 'PATCH'].includes(request.method())) {
try {
const postData = request.postData();
if (postData) {
event.requestData = postData;
}
} catch {
// Some requests might not have accessible post data
}
}
this.emitNetworkEvent(event, handlers);
});
// Listen to responses
page.on('response', async response => {
const event: NetworkEvent = {
url: response.url(),
method: response.request().method(),
status: response.status(),
type: 'response',
timestamp: Date.now(),
headers: response.headers(),
};
// Capture response data for GET/POST requests with JSON content
const contentType = response.headers()['content-type'] || '';
if (contentType.includes('application/json') || contentType.includes('text/')) {
try {
const responseData = await response.text();
event.responseData = responseData;
} catch {
// Response might be too large or not accessible
}
}
this.emitNetworkEvent(event, handlers);
});
// Listen to failed requests
page.on('requestfailed', request => {
const event: NetworkEvent = {
url: request.url(),
method: request.method(),
type: 'failed',
timestamp: Date.now(),
headers: request.headers(),
};
// Try to capture request data for failed requests too
if (['POST', 'PUT', 'PATCH'].includes(request.method())) {
try {
const postData = request.postData();
if (postData) {
event.requestData = postData;
}
} catch {
// Ignore errors when accessing post data
}
}
this.emitNetworkEvent(event, handlers);
});
}
private emitNetworkEvent(event: NetworkEvent, handlers: Set<NetworkEventHandler>): void {
for (const handler of handlers) {
try {
handler(event);
} catch (error) {
this.logger.error('Network event handler error', { error });
}
}
}
async evaluate<T>(page: Page, fn: () => T): Promise<T> {
return page.evaluate(fn);
}
async closeContext(contextId: string): Promise<void> {
const context = this.contexts.get(contextId);
if (context) {
await context.close();
this.contexts.delete(contextId);
}
}
async close(): Promise<void> {
// Close all contexts
for (const [, context] of this.contexts) {
await context.close();
}
this.contexts.clear();
// Close browser
if (this.browser) {
await this.browser.close();
this.browser = undefined;
}
this.initialized = false;
this.logger.info('Browser closed');
}
get isInitialized(): boolean {
return this.initialized;
}
}
// Export singleton instance
export const Browser = new BrowserSingleton();
// Also export the class for typing if needed
export { BrowserSingleton as BrowserClass };

View file

View file

@ -0,0 +1,3 @@
export { Browser } from './browser';
export { BrowserTabManager } from './tab-manager';
export type { BrowserOptions, ScrapingResult } from './types';

View file

@ -0,0 +1,103 @@
import { Page } from 'playwright';
import { getLogger } from '@stock-bot/logger';
import { Browser } from './browser';
import type { ScrapingResult } from './types';
interface TabInfo {
page: Page;
contextId: string;
}
export class BrowserTabManager {
private tabs: Map<string, TabInfo> = new Map();
private logger = getLogger('browser-tab-manager');
async createTab(url?: string): Promise<{ page: Page; tabId: string }> {
const tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const { page, contextId } = await Browser.createPageWithProxy(url || 'about:blank');
this.tabs.set(tabId, { page, contextId });
this.logger.debug('Tab created', { tabId, url });
return { page, tabId };
}
async createTabWithProxy(
url: string,
proxy: string
): Promise<{ page: Page; tabId: string; contextId: string }> {
const tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const { page, contextId } = await Browser.createPageWithProxy(url, proxy);
this.tabs.set(tabId, { page, contextId });
this.logger.debug('Tab with proxy created', { tabId, url, proxy });
return { page, tabId, contextId };
}
async scrapeUrlsWithProxies<T>(
urlProxyPairs: Array<{ url: string; proxy: string }>,
extractor: (page: Page) => Promise<T>,
options: { concurrency?: number } = {}
): Promise<ScrapingResult<T>[]> {
const { concurrency = 3 } = options;
const results: ScrapingResult<T>[] = [];
for (let i = 0; i < urlProxyPairs.length; i += concurrency) {
const batch = urlProxyPairs.slice(i, i + concurrency);
const batchPromises = batch.map(async ({ url, proxy }) => {
let tabId: string | undefined;
try {
const result = await this.createTabWithProxy(url, proxy);
tabId = result.tabId;
const data = await extractor(result.page);
return {
data,
url,
success: true,
} as ScrapingResult<T>;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
data: null as T,
url,
success: false,
error: errorMessage,
} as ScrapingResult<T>;
} finally {
if (tabId) {
await this.closeTab(tabId);
}
}
});
const batchResults = await Promise.all(batchPromises);
results.push(...batchResults);
}
return results;
}
async closeTab(tabId: string): Promise<void> {
const tab = this.tabs.get(tabId);
if (tab) {
await tab.page.close();
await Browser.closeContext(tab.contextId);
this.tabs.delete(tabId);
this.logger.debug('Tab closed', { tabId });
}
}
getTabCount(): number {
return this.tabs.size;
}
getAllTabIds(): string[] {
return Array.from(this.tabs.keys());
}
}

30
libs/browser/src/types.ts Normal file
View file

@ -0,0 +1,30 @@
export interface BrowserOptions {
proxy?: string;
headless?: boolean;
timeout?: number;
blockResources?: boolean;
enableNetworkLogging?: boolean;
}
// Keep the old name for backward compatibility
export type FastBrowserOptions = BrowserOptions;
export interface ScrapingResult<T = unknown> {
data: T;
url: string;
success: boolean;
error?: string;
}
export interface NetworkEvent {
url: string;
method: string;
status?: number;
type: 'request' | 'response' | 'failed';
timestamp: number;
requestData?: string;
responseData?: string;
headers?: Record<string, string>;
}
export type NetworkEventHandler = (event: NetworkEvent) => void;

View file

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.lib.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"],
"references": [{ "path": "../../libs/logger" }]
}

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