Compare commits
24 commits
master
...
data-servi
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ded78f8e4 | |||
| 54314a0cde | |||
| 3097686849 | |||
| d9bd33a822 | |||
| 07f8964a8c | |||
| eeae192872 | |||
| 9d38f9a7b6 | |||
| 8955544593 | |||
| 24b7ed15e4 | |||
| be807378a3 | |||
| 16599c86da | |||
| b645b58102 | |||
| 709fc347e9 | |||
| 423b40866c | |||
| aed5ff3d98 | |||
| 84e6dee53f | |||
| 4aa2942e43 | |||
| 35b0eb3783 | |||
| a7ec942916 | |||
| ed326c025e | |||
| 47ff92b567 | |||
| 2f074271cc | |||
| df611a3ce3 | |||
| b49bea818b |
1142 changed files with 31198 additions and 121547 deletions
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"exclude": [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/coverage/**",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.js",
|
||||
"**/test/**",
|
||||
"**/tests/**",
|
||||
"**/__tests__/**",
|
||||
"**/__mocks__/**",
|
||||
"**/setup.ts",
|
||||
"**/setup.js"
|
||||
],
|
||||
"reporters": ["terminal", "html"],
|
||||
"thresholds": {
|
||||
"lines": 80,
|
||||
"functions": 80,
|
||||
"branches": 80,
|
||||
"statements": 80
|
||||
},
|
||||
"outputDir": "coverage"
|
||||
}
|
||||
36
.env
36
.env
|
|
@ -4,19 +4,16 @@
|
|||
|
||||
# Core Application Settings
|
||||
NODE_ENV=development
|
||||
LOG_LEVEL=debug
|
||||
LOG_HIDE_OBJECT=true
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Data Service Configuration
|
||||
DATA_SERVICE_PORT=2001
|
||||
|
||||
# Queue and Worker Configuration
|
||||
WORKER_COUNT=1
|
||||
WORKER_CONCURRENCY=2
|
||||
WORKER_COUNT=4
|
||||
WORKER_CONCURRENCY=20
|
||||
|
||||
WEBSHARE_API_KEY=y8ay534rcbybdkk3evnzmt640xxfhy7252ce2t98
|
||||
WEBSHARE_ROTATING_PROXY_URL=http://doimvbnb-rotate:w5fpiwrb9895@p.webshare.io:80/
|
||||
WEBSHARE_API_URL=https://proxy.webshare.io/api/v2/
|
||||
|
||||
# ===========================================
|
||||
# DATABASE CONFIGURATIONS
|
||||
|
|
@ -30,25 +27,25 @@ DRAGONFLY_PASSWORD=
|
|||
# PostgreSQL Configuration
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DATABASE=trading_bot
|
||||
POSTGRES_USERNAME=trading_user
|
||||
POSTGRES_PASSWORD=trading_pass_dev
|
||||
POSTGRES_DB=stockbot
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_SSL=false
|
||||
|
||||
# QuestDB Configuration
|
||||
QUESTDB_HOST=localhost
|
||||
QUESTDB_PORT=9000
|
||||
QUESTDB_DB=qdb
|
||||
QUESTDB_USERNAME=admin
|
||||
QUESTDB_USER=admin
|
||||
QUESTDB_PASSWORD=quest
|
||||
|
||||
# MongoDB Configuration
|
||||
MONGODB_HOST=localhost
|
||||
MONGODB_PORT=27017
|
||||
MONGODB_DATABASE=stock
|
||||
MONGODB_USERNAME=trading_admin
|
||||
MONGODB_PASSWORD=trading_mongo_dev
|
||||
MONGODB_URI=mongodb://trading_admin:trading_mongo_dev@localhost:27017/stock?authSource=admin
|
||||
MONGODB_DB=stockbot
|
||||
MONGODB_USER=
|
||||
MONGODB_PASSWORD=
|
||||
MONGODB_URI=mongodb://localhost:27017/stockbot
|
||||
|
||||
# ===========================================
|
||||
# DATA PROVIDER CONFIGURATIONS
|
||||
|
|
@ -162,13 +159,6 @@ DOCKER_BUILDKIT=1
|
|||
TZ=UTC
|
||||
|
||||
# Application Metadata
|
||||
APP_NAME="Stock Bot Platform"
|
||||
APP_NAME=Stock Bot Platform
|
||||
APP_VERSION=1.0.0
|
||||
APP_DESCRIPTION="Advanced Stock Trading and Analysis Platform"
|
||||
|
||||
# PostgreSQL
|
||||
DATABASE_POSTGRES_HOST=localhost
|
||||
DATABASE_POSTGRES_PORT=5432
|
||||
DATABASE_POSTGRES_DATABASE=trading_bot
|
||||
DATABASE_POSTGRES_USER=trading_user
|
||||
DATABASE_POSTGRES_PASSWORD=trading_pass_dev
|
||||
APP_DESCRIPTION=Advanced Stock Trading and Analysis Platform
|
||||
|
|
|
|||
242
.env.complete
Normal file
242
.env.complete
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
# =======================================================================
|
||||
# Stock Bot Platform Environment Configuration
|
||||
# =======================================================================
|
||||
|
||||
# Core Application Settings
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
APP_NAME=stock-bot
|
||||
APP_VERSION=1.0.0
|
||||
|
||||
# =======================================================================
|
||||
# DATABASE CONFIGURATIONS
|
||||
# =======================================================================
|
||||
|
||||
# PostgreSQL - Operational Data (orders, positions, strategies)
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DATABASE=trading_bot
|
||||
POSTGRES_USERNAME=trading_user
|
||||
POSTGRES_PASSWORD=trading_pass_dev
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=trading_bot
|
||||
DB_USER=trading_user
|
||||
DB_PASSWORD=trading_pass_dev
|
||||
DB_POOL_MIN=2
|
||||
DB_POOL_MAX=10
|
||||
DB_POOL_IDLE_TIMEOUT=30000
|
||||
DB_SSL=false
|
||||
DB_SSL_REJECT_UNAUTHORIZED=true
|
||||
DB_QUERY_TIMEOUT=30000
|
||||
DB_CONNECTION_TIMEOUT=5000
|
||||
|
||||
# QuestDB - Time-series Data (OHLCV, indicators, performance)
|
||||
QUESTDB_HOST=localhost
|
||||
QUESTDB_HTTP_PORT=9000
|
||||
QUESTDB_PG_PORT=8812
|
||||
QUESTDB_INFLUX_PORT=9009
|
||||
QUESTDB_USER=
|
||||
QUESTDB_PASSWORD=
|
||||
QUESTDB_CONNECTION_TIMEOUT=5000
|
||||
QUESTDB_REQUEST_TIMEOUT=30000
|
||||
QUESTDB_RETRY_ATTEMPTS=3
|
||||
QUESTDB_TLS_ENABLED=false
|
||||
QUESTDB_DEFAULT_DATABASE=qdb
|
||||
QUESTDB_TELEMETRY_ENABLED=false
|
||||
|
||||
# MongoDB - Document Storage (sentiment, raw docs, unstructured data)
|
||||
MONGODB_HOST=localhost
|
||||
MONGODB_PORT=27017
|
||||
MONGODB_DATABASE=trading_documents
|
||||
MONGODB_USERNAME=trading_admin
|
||||
MONGODB_PASSWORD=trading_mongo_dev
|
||||
MONGODB_AUTH_SOURCE=admin
|
||||
MONGODB_URI=
|
||||
MONGODB_MAX_POOL_SIZE=10
|
||||
MONGODB_MIN_POOL_SIZE=0
|
||||
MONGODB_MAX_IDLE_TIME=30000
|
||||
MONGODB_CONNECT_TIMEOUT=10000
|
||||
MONGODB_SOCKET_TIMEOUT=30000
|
||||
MONGODB_SERVER_SELECTION_TIMEOUT=5000
|
||||
MONGODB_TLS=false
|
||||
MONGODB_RETRY_WRITES=true
|
||||
MONGODB_JOURNAL=true
|
||||
MONGODB_READ_PREFERENCE=primary
|
||||
MONGODB_WRITE_CONCERN=majority
|
||||
|
||||
# Dragonfly - Redis Replacement (caching and events)
|
||||
DRAGONFLY_HOST=localhost
|
||||
DRAGONFLY_PORT=6379
|
||||
DRAGONFLY_PASSWORD=
|
||||
DRAGONFLY_USERNAME=
|
||||
DRAGONFLY_DATABASE=0
|
||||
DRAGONFLY_MAX_RETRIES=3
|
||||
DRAGONFLY_RETRY_DELAY=50
|
||||
DRAGONFLY_CONNECT_TIMEOUT=10000
|
||||
DRAGONFLY_COMMAND_TIMEOUT=5000
|
||||
DRAGONFLY_POOL_SIZE=10
|
||||
DRAGONFLY_POOL_MIN=1
|
||||
DRAGONFLY_POOL_MAX=20
|
||||
DRAGONFLY_TLS=false
|
||||
DRAGONFLY_ENABLE_KEEPALIVE=true
|
||||
DRAGONFLY_KEEPALIVE_INTERVAL=60
|
||||
DRAGONFLY_CLUSTER_MODE=false
|
||||
DRAGONFLY_CLUSTER_NODES=
|
||||
DRAGONFLY_MAX_MEMORY=2gb
|
||||
DRAGONFLY_CACHE_MODE=true
|
||||
|
||||
# =======================================================================
|
||||
# MONITORING & LOGGING CONFIGURATIONS
|
||||
# =======================================================================
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL=debug
|
||||
LOG_FORMAT=json
|
||||
LOG_CONSOLE=true
|
||||
LOG_FILE=false
|
||||
LOG_FILE_PATH=logs
|
||||
LOG_FILE_MAX_SIZE=20m
|
||||
LOG_FILE_MAX_FILES=14
|
||||
LOG_FILE_DATE_PATTERN=YYYY-MM-DD
|
||||
LOG_ERROR_FILE=true
|
||||
LOG_ERROR_STACK=true
|
||||
LOG_PERFORMANCE=false
|
||||
LOG_SQL_QUERIES=false
|
||||
LOG_HTTP_REQUESTS=true
|
||||
LOG_STRUCTURED=true
|
||||
LOG_TIMESTAMP=true
|
||||
LOG_CALLER_INFO=false
|
||||
LOG_SILENT_MODULES=
|
||||
LOG_VERBOSE_MODULES=
|
||||
LOG_SERVICE_NAME=stock-bot
|
||||
LOG_SERVICE_VERSION=1.0.0
|
||||
LOG_ENVIRONMENT=development
|
||||
|
||||
# Loki - Log Aggregation
|
||||
LOKI_HOST=localhost
|
||||
LOKI_PORT=3100
|
||||
LOKI_URL=
|
||||
LOKI_USERNAME=
|
||||
LOKI_PASSWORD=
|
||||
LOKI_TENANT_ID=
|
||||
LOKI_PUSH_TIMEOUT=10000
|
||||
LOKI_BATCH_SIZE=1024
|
||||
LOKI_BATCH_WAIT=1000
|
||||
LOKI_RETENTION_PERIOD=30d
|
||||
LOKI_MAX_CHUNK_AGE=1h
|
||||
LOKI_TLS_ENABLED=false
|
||||
LOKI_TLS_INSECURE=false
|
||||
LOKI_DEFAULT_LABELS=
|
||||
LOKI_SERVICE_LABEL=stock-bot
|
||||
LOKI_ENVIRONMENT_LABEL=development
|
||||
|
||||
# Prometheus - Metrics Collection
|
||||
PROMETHEUS_HOST=localhost
|
||||
PROMETHEUS_PORT=9090
|
||||
PROMETHEUS_URL=
|
||||
PROMETHEUS_USERNAME=
|
||||
PROMETHEUS_PASSWORD=
|
||||
PROMETHEUS_SCRAPE_INTERVAL=15s
|
||||
PROMETHEUS_EVALUATION_INTERVAL=15s
|
||||
PROMETHEUS_RETENTION_TIME=15d
|
||||
PROMETHEUS_TLS_ENABLED=false
|
||||
PROMETHEUS_TLS_INSECURE=false
|
||||
|
||||
# Grafana - Visualization
|
||||
GRAFANA_HOST=localhost
|
||||
GRAFANA_PORT=3000
|
||||
GRAFANA_URL=
|
||||
GRAFANA_ADMIN_USER=admin
|
||||
GRAFANA_ADMIN_PASSWORD=admin
|
||||
GRAFANA_ALLOW_SIGN_UP=false
|
||||
GRAFANA_SECRET_KEY=
|
||||
GRAFANA_DATABASE_TYPE=sqlite3
|
||||
GRAFANA_DATABASE_URL=
|
||||
GRAFANA_DISABLE_GRAVATAR=true
|
||||
GRAFANA_ENABLE_GZIP=true
|
||||
|
||||
# =======================================================================
|
||||
# DATA PROVIDER CONFIGURATIONS
|
||||
# =======================================================================
|
||||
|
||||
# Default Data Provider
|
||||
DEFAULT_DATA_PROVIDER=alpaca
|
||||
|
||||
# Alpaca Markets
|
||||
ALPACA_ENABLED=true
|
||||
ALPACA_API_KEY=your_alpaca_key_here
|
||||
ALPACA_SECRET_KEY=your_alpaca_secret_here
|
||||
ALPACA_BASE_URL=https://paper-api.alpaca.markets
|
||||
ALPACA_DATA_URL=https://data.alpaca.markets
|
||||
ALPACA_PAPER_TRADING=true
|
||||
|
||||
# Polygon.io
|
||||
POLYGON_ENABLED=false
|
||||
POLYGON_API_KEY=your_polygon_key_here
|
||||
POLYGON_BASE_URL=https://api.polygon.io
|
||||
|
||||
# Yahoo Finance
|
||||
YAHOO_ENABLED=true
|
||||
YAHOO_BASE_URL=https://query1.finance.yahoo.com
|
||||
|
||||
# IEX Cloud
|
||||
IEX_ENABLED=false
|
||||
IEX_API_KEY=your_iex_key_here
|
||||
IEX_BASE_URL=https://cloud.iexapis.com
|
||||
|
||||
# Alpha Vantage
|
||||
ALPHA_VANTAGE_ENABLED=false
|
||||
ALPHA_VANTAGE_API_KEY=demo
|
||||
|
||||
# Data Provider Settings
|
||||
DATA_PROVIDER_TIMEOUT=30000
|
||||
DATA_PROVIDER_RETRIES=3
|
||||
DATA_PROVIDER_RETRY_DELAY=1000
|
||||
DATA_CACHE_ENABLED=true
|
||||
DATA_CACHE_TTL=300
|
||||
DATA_CACHE_MAX_SIZE=1000
|
||||
|
||||
# =======================================================================
|
||||
# TRADING & RISK MANAGEMENT
|
||||
# =======================================================================
|
||||
|
||||
# Trading Configuration
|
||||
PAPER_TRADING=true
|
||||
MAX_POSITION_SIZE=0.1
|
||||
MAX_DAILY_LOSS=1000
|
||||
|
||||
# Risk Management
|
||||
RISK_MAX_POSITION_SIZE=0.25
|
||||
RISK_MAX_LEVERAGE=2.0
|
||||
RISK_DEFAULT_STOP_LOSS=0.02
|
||||
RISK_DEFAULT_TAKE_PROFIT=0.06
|
||||
RISK_MAX_DRAWDOWN=0.10
|
||||
RISK_MAX_CONSECUTIVE_LOSSES=5
|
||||
RISK_POSITION_SIZING_METHOD=fixed_percentage
|
||||
RISK_CIRCUIT_BREAKER_ENABLED=true
|
||||
RISK_CIRCUIT_BREAKER_THRESHOLD=0.05
|
||||
RISK_CIRCUIT_BREAKER_COOLDOWN=3600000
|
||||
RISK_ALLOW_WEEKEND_TRADING=false
|
||||
RISK_MARKET_HOURS_ONLY=true
|
||||
|
||||
# =======================================================================
|
||||
# FEATURE FLAGS
|
||||
# =======================================================================
|
||||
ENABLE_ML_SIGNALS=false
|
||||
ENABLE_SENTIMENT_ANALYSIS=false
|
||||
ENABLE_SOCIAL_SIGNALS=false
|
||||
ENABLE_OPTIONS_TRADING=false
|
||||
ENABLE_CRYPTO_TRADING=false
|
||||
ENABLE_BACKTESTING=true
|
||||
ENABLE_PAPER_TRADING=true
|
||||
ENABLE_LIVE_TRADING=false
|
||||
|
||||
# =======================================================================
|
||||
# DEVELOPMENT & DEBUGGING
|
||||
# =======================================================================
|
||||
DEBUG_MODE=true
|
||||
VERBOSE_LOGGING=true
|
||||
MOCK_DATA_PROVIDERS=false
|
||||
ENABLE_API_RATE_LIMITING=true
|
||||
ENABLE_REQUEST_LOGGING=true
|
||||
144
.env.docker
Normal file
144
.env.docker
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# Docker Environment Configuration
|
||||
# This file contains environment variables used by Docker Compose
|
||||
|
||||
# =============================================================================
|
||||
# CONTAINER NETWORK SETTINGS
|
||||
# =============================================================================
|
||||
COMPOSE_PROJECT_NAME=stock-bot
|
||||
NETWORK_NAME=trading-bot-network
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE CONTAINER SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# PostgreSQL Container
|
||||
POSTGRES_DB=trading_bot
|
||||
POSTGRES_USER=trading_user
|
||||
POSTGRES_PASSWORD=trading_pass_secure
|
||||
POSTGRES_INITDB_ARGS=--encoding=UTF-8
|
||||
|
||||
# MongoDB Container
|
||||
MONGO_INITDB_ROOT_USERNAME=trading_admin
|
||||
MONGO_INITDB_ROOT_PASSWORD=trading_mongo_secure
|
||||
MONGO_INITDB_DATABASE=trading_documents
|
||||
|
||||
# QuestDB Container
|
||||
QDB_TELEMETRY_ENABLED=false
|
||||
|
||||
# Dragonfly Container
|
||||
DRAGONFLY_MAXMEMORY=4gb
|
||||
DRAGONFLY_PROACTOR_THREADS=8
|
||||
|
||||
# =============================================================================
|
||||
# MONITORING CONTAINER SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# Grafana Container
|
||||
GF_SECURITY_ADMIN_USER=admin
|
||||
GF_SECURITY_ADMIN_PASSWORD=secure_grafana_password
|
||||
GF_USERS_ALLOW_SIGN_UP=false
|
||||
GF_PATHS_PROVISIONING=/etc/grafana/provisioning
|
||||
GF_DISABLE_GRAVATAR=true
|
||||
|
||||
# Prometheus Container
|
||||
PROMETHEUS_CONFIG_FILE=/etc/prometheus/prometheus.yml
|
||||
PROMETHEUS_STORAGE_PATH=/prometheus
|
||||
PROMETHEUS_WEB_ENABLE_LIFECYCLE=true
|
||||
|
||||
# =============================================================================
|
||||
# ADMIN INTERFACE CONTAINER SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# PgAdmin Container
|
||||
PGADMIN_DEFAULT_EMAIL=admin@tradingbot.local
|
||||
PGADMIN_DEFAULT_PASSWORD=secure_pgadmin_password
|
||||
PGADMIN_CONFIG_SERVER_MODE=False
|
||||
PGADMIN_DISABLE_POSTFIX=true
|
||||
|
||||
# Mongo Express Container
|
||||
ME_CONFIG_MONGODB_ADMINUSERNAME=trading_admin
|
||||
ME_CONFIG_MONGODB_ADMINPASSWORD=trading_mongo_secure
|
||||
ME_CONFIG_MONGODB_SERVER=mongodb
|
||||
ME_CONFIG_MONGODB_PORT=27017
|
||||
ME_CONFIG_BASICAUTH_USERNAME=admin
|
||||
ME_CONFIG_BASICAUTH_PASSWORD=secure_mongo_express_password
|
||||
|
||||
# Redis Insight Container
|
||||
REDIS_HOSTS=local:dragonfly:6379
|
||||
|
||||
# =============================================================================
|
||||
# VOLUME MOUNT PATHS
|
||||
# =============================================================================
|
||||
|
||||
# Data Volume Paths (adjust these for your host system)
|
||||
POSTGRES_DATA_PATH=./data/postgres
|
||||
QUESTDB_DATA_PATH=./data/questdb
|
||||
MONGODB_DATA_PATH=./data/mongodb
|
||||
DRAGONFLY_DATA_PATH=./data/dragonfly
|
||||
PROMETHEUS_DATA_PATH=./data/prometheus
|
||||
GRAFANA_DATA_PATH=./data/grafana
|
||||
LOKI_DATA_PATH=./data/loki
|
||||
PGADMIN_DATA_PATH=./data/pgadmin
|
||||
|
||||
# Config Volume Paths
|
||||
PROMETHEUS_CONFIG_PATH=./monitoring/prometheus
|
||||
GRAFANA_CONFIG_PATH=./monitoring/grafana
|
||||
LOKI_CONFIG_PATH=./monitoring/loki
|
||||
|
||||
# Database Init Paths
|
||||
POSTGRES_INIT_PATH=./database/postgres/init
|
||||
MONGODB_INIT_PATH=./database/mongodb/init
|
||||
|
||||
# =============================================================================
|
||||
# PORT MAPPINGS (HOST:CONTAINER)
|
||||
# =============================================================================
|
||||
|
||||
# Database Ports
|
||||
POSTGRES_PORT=5432
|
||||
QUESTDB_HTTP_PORT=9000
|
||||
QUESTDB_PG_PORT=8812
|
||||
QUESTDB_INFLUX_PORT=9009
|
||||
MONGODB_PORT=27017
|
||||
DRAGONFLY_PORT=6379
|
||||
|
||||
# Monitoring Ports
|
||||
PROMETHEUS_PORT=9090
|
||||
GRAFANA_PORT=3000
|
||||
LOKI_PORT=3100
|
||||
|
||||
# Admin Interface Ports
|
||||
PGADMIN_PORT=8080
|
||||
MONGO_EXPRESS_PORT=8081
|
||||
REDIS_INSIGHT_PORT=8001
|
||||
|
||||
# =============================================================================
|
||||
# HEALTH CHECK SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# Health Check Intervals
|
||||
HEALTHCHECK_INTERVAL=30s
|
||||
HEALTHCHECK_TIMEOUT=10s
|
||||
HEALTHCHECK_RETRIES=3
|
||||
HEALTHCHECK_START_PERIOD=60s
|
||||
|
||||
# =============================================================================
|
||||
# RESOURCE LIMITS
|
||||
# =============================================================================
|
||||
|
||||
# Memory Limits (uncomment and adjust for production)
|
||||
# POSTGRES_MEMORY_LIMIT=2g
|
||||
# QUESTDB_MEMORY_LIMIT=4g
|
||||
# MONGODB_MEMORY_LIMIT=2g
|
||||
# DRAGONFLY_MEMORY_LIMIT=4g
|
||||
# PROMETHEUS_MEMORY_LIMIT=2g
|
||||
# GRAFANA_MEMORY_LIMIT=512m
|
||||
# LOKI_MEMORY_LIMIT=1g
|
||||
|
||||
# CPU Limits (uncomment and adjust for production)
|
||||
# POSTGRES_CPU_LIMIT=1
|
||||
# QUESTDB_CPU_LIMIT=2
|
||||
# MONGODB_CPU_LIMIT=1
|
||||
# DRAGONFLY_CPU_LIMIT=2
|
||||
# PROMETHEUS_CPU_LIMIT=1
|
||||
# GRAFANA_CPU_LIMIT=0.5
|
||||
# LOKI_CPU_LIMIT=1
|
||||
43
.env.example
Normal file
43
.env.example
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Environment Configuration
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
|
||||
# Database Configuration
|
||||
QUESTDB_HOST=localhost
|
||||
QUESTDB_PORT=9000
|
||||
QUESTDB_DATABASE=qdb
|
||||
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DATABASE=stockbot
|
||||
POSTGRES_USERNAME=postgres
|
||||
POSTGRES_PASSWORD=password
|
||||
|
||||
DRAGONFLY_HOST=localhost
|
||||
DRAGONFLY_PORT=6379
|
||||
DRAGONFLY_PASSWORD=
|
||||
|
||||
# API Keys (Add your actual keys here)
|
||||
ALPHA_VANTAGE_API_KEY=demo
|
||||
ALPACA_API_KEY=your_alpaca_key_here
|
||||
ALPACA_SECRET_KEY=your_alpaca_secret_here
|
||||
|
||||
# Trading Configuration
|
||||
PAPER_TRADING=true
|
||||
MAX_POSITION_SIZE=0.1
|
||||
MAX_DAILY_LOSS=1000
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=debug
|
||||
LOG_CONSOLE=true
|
||||
LOKI_HOST=localhost
|
||||
LOKI_PORT=3100
|
||||
LOKI_USERNAME=
|
||||
LOKI_PASSWORD=
|
||||
LOKI_RETENTION_DAYS=30
|
||||
LOKI_LABELS=environment=development,service=stock-bot
|
||||
LOKI_BATCH_SIZE=100
|
||||
|
||||
# Feature Flags
|
||||
ENABLE_ML_SIGNALS=false
|
||||
ENABLE_SENTIMENT_ANALYSIS=false
|
||||
233
.env.prod
Normal file
233
.env.prod
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
# =======================================================================
|
||||
# Stock Bot Platform Production Environment Configuration
|
||||
# =======================================================================
|
||||
|
||||
# Core Application Settings
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
APP_NAME=stock-bot
|
||||
APP_VERSION=1.0.0
|
||||
|
||||
# =======================================================================
|
||||
# DATABASE CONFIGURATIONS
|
||||
# =======================================================================
|
||||
|
||||
# PostgreSQL - Operational Data (orders, positions, strategies)
|
||||
DB_HOST=${DB_HOST}
|
||||
DB_PORT=${DB_PORT:-5432}
|
||||
DB_NAME=${DB_NAME}
|
||||
DB_USER=${DB_USER}
|
||||
DB_PASSWORD=${DB_PASSWORD}
|
||||
DB_POOL_MIN=5
|
||||
DB_POOL_MAX=20
|
||||
DB_POOL_IDLE_TIMEOUT=60000
|
||||
DB_SSL=true
|
||||
DB_SSL_REJECT_UNAUTHORIZED=true
|
||||
DB_QUERY_TIMEOUT=30000
|
||||
DB_CONNECTION_TIMEOUT=10000
|
||||
|
||||
# QuestDB - Time-series Data (OHLCV, indicators, performance)
|
||||
QUESTDB_HOST=${QUESTDB_HOST}
|
||||
QUESTDB_HTTP_PORT=${QUESTDB_HTTP_PORT:-9000}
|
||||
QUESTDB_PG_PORT=${QUESTDB_PG_PORT:-8812}
|
||||
QUESTDB_INFLUX_PORT=${QUESTDB_INFLUX_PORT:-9009}
|
||||
QUESTDB_USER=${QUESTDB_USER}
|
||||
QUESTDB_PASSWORD=${QUESTDB_PASSWORD}
|
||||
QUESTDB_CONNECTION_TIMEOUT=10000
|
||||
QUESTDB_REQUEST_TIMEOUT=60000
|
||||
QUESTDB_RETRY_ATTEMPTS=5
|
||||
QUESTDB_TLS_ENABLED=true
|
||||
QUESTDB_DEFAULT_DATABASE=qdb
|
||||
QUESTDB_TELEMETRY_ENABLED=false
|
||||
|
||||
# MongoDB - Document Storage (sentiment, raw docs, unstructured data)
|
||||
MONGODB_HOST=${MONGODB_HOST}
|
||||
MONGODB_PORT=${MONGODB_PORT:-27017}
|
||||
MONGODB_DATABASE=${MONGODB_DATABASE}
|
||||
MONGODB_USERNAME=${MONGODB_USERNAME}
|
||||
MONGODB_PASSWORD=${MONGODB_PASSWORD}
|
||||
MONGODB_AUTH_SOURCE=admin
|
||||
MONGODB_URI=${MONGODB_URI}
|
||||
MONGODB_MAX_POOL_SIZE=50
|
||||
MONGODB_MIN_POOL_SIZE=5
|
||||
MONGODB_MAX_IDLE_TIME=60000
|
||||
MONGODB_CONNECT_TIMEOUT=30000
|
||||
MONGODB_SOCKET_TIMEOUT=60000
|
||||
MONGODB_SERVER_SELECTION_TIMEOUT=10000
|
||||
MONGODB_TLS=true
|
||||
MONGODB_RETRY_WRITES=true
|
||||
MONGODB_JOURNAL=true
|
||||
MONGODB_READ_PREFERENCE=primaryPreferred
|
||||
MONGODB_WRITE_CONCERN=majority
|
||||
|
||||
# Dragonfly - Redis Replacement (caching and events)
|
||||
DRAGONFLY_HOST=${DRAGONFLY_HOST}
|
||||
DRAGONFLY_PORT=${DRAGONFLY_PORT:-6379}
|
||||
DRAGONFLY_PASSWORD=${DRAGONFLY_PASSWORD}
|
||||
DRAGONFLY_USERNAME=${DRAGONFLY_USERNAME}
|
||||
DRAGONFLY_DATABASE=0
|
||||
DRAGONFLY_MAX_RETRIES=5
|
||||
DRAGONFLY_RETRY_DELAY=100
|
||||
DRAGONFLY_CONNECT_TIMEOUT=30000
|
||||
DRAGONFLY_COMMAND_TIMEOUT=10000
|
||||
DRAGONFLY_POOL_SIZE=50
|
||||
DRAGONFLY_POOL_MIN=5
|
||||
DRAGONFLY_POOL_MAX=100
|
||||
DRAGONFLY_TLS=true
|
||||
DRAGONFLY_ENABLE_KEEPALIVE=true
|
||||
DRAGONFLY_KEEPALIVE_INTERVAL=30
|
||||
DRAGONFLY_CLUSTER_MODE=false
|
||||
DRAGONFLY_CLUSTER_NODES=
|
||||
DRAGONFLY_MAX_MEMORY=8gb
|
||||
DRAGONFLY_CACHE_MODE=true
|
||||
|
||||
# =======================================================================
|
||||
# MONITORING & LOGGING CONFIGURATIONS
|
||||
# =======================================================================
|
||||
|
||||
# Logging Configuration (Production - Less verbose)
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=json
|
||||
LOG_CONSOLE=false
|
||||
LOG_FILE=true
|
||||
LOG_FILE_PATH=/var/log/stock-bot
|
||||
LOG_FILE_MAX_SIZE=100m
|
||||
LOG_FILE_MAX_FILES=30
|
||||
LOG_FILE_DATE_PATTERN=YYYY-MM-DD
|
||||
LOG_ERROR_FILE=true
|
||||
LOG_ERROR_STACK=false
|
||||
LOG_PERFORMANCE=true
|
||||
LOG_SQL_QUERIES=false
|
||||
LOG_HTTP_REQUESTS=false
|
||||
LOG_STRUCTURED=true
|
||||
LOG_TIMESTAMP=true
|
||||
LOG_CALLER_INFO=false
|
||||
LOG_SILENT_MODULES=
|
||||
LOG_VERBOSE_MODULES=
|
||||
LOG_SERVICE_NAME=stock-bot
|
||||
LOG_SERVICE_VERSION=1.0.0
|
||||
LOG_ENVIRONMENT=production
|
||||
|
||||
# Loki - Log Aggregation
|
||||
LOKI_HOST=${LOKI_HOST}
|
||||
LOKI_PORT=${LOKI_PORT:-3100}
|
||||
LOKI_URL=${LOKI_URL}
|
||||
LOKI_USERNAME=${LOKI_USERNAME}
|
||||
LOKI_PASSWORD=${LOKI_PASSWORD}
|
||||
LOKI_TENANT_ID=${LOKI_TENANT_ID}
|
||||
LOKI_PUSH_TIMEOUT=30000
|
||||
LOKI_BATCH_SIZE=2048
|
||||
LOKI_BATCH_WAIT=5000
|
||||
LOKI_RETENTION_PERIOD=90d
|
||||
LOKI_MAX_CHUNK_AGE=2h
|
||||
LOKI_TLS_ENABLED=true
|
||||
LOKI_TLS_INSECURE=false
|
||||
LOKI_DEFAULT_LABELS=
|
||||
LOKI_SERVICE_LABEL=stock-bot
|
||||
LOKI_ENVIRONMENT_LABEL=production
|
||||
|
||||
# Prometheus - Metrics Collection
|
||||
PROMETHEUS_HOST=${PROMETHEUS_HOST}
|
||||
PROMETHEUS_PORT=${PROMETHEUS_PORT:-9090}
|
||||
PROMETHEUS_URL=${PROMETHEUS_URL}
|
||||
PROMETHEUS_USERNAME=${PROMETHEUS_USERNAME}
|
||||
PROMETHEUS_PASSWORD=${PROMETHEUS_PASSWORD}
|
||||
PROMETHEUS_SCRAPE_INTERVAL=30s
|
||||
PROMETHEUS_EVALUATION_INTERVAL=30s
|
||||
PROMETHEUS_RETENTION_TIME=90d
|
||||
PROMETHEUS_TLS_ENABLED=true
|
||||
PROMETHEUS_TLS_INSECURE=false
|
||||
|
||||
# Grafana - Visualization
|
||||
GRAFANA_HOST=${GRAFANA_HOST}
|
||||
GRAFANA_PORT=${GRAFANA_PORT:-3000}
|
||||
GRAFANA_URL=${GRAFANA_URL}
|
||||
GRAFANA_ADMIN_USER=${GRAFANA_ADMIN_USER}
|
||||
GRAFANA_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
|
||||
GRAFANA_ALLOW_SIGN_UP=false
|
||||
GRAFANA_SECRET_KEY=${GRAFANA_SECRET_KEY}
|
||||
GRAFANA_DATABASE_TYPE=postgres
|
||||
GRAFANA_DATABASE_URL=${GRAFANA_DATABASE_URL}
|
||||
GRAFANA_DISABLE_GRAVATAR=true
|
||||
GRAFANA_ENABLE_GZIP=true
|
||||
|
||||
# =======================================================================
|
||||
# DATA PROVIDER CONFIGURATIONS
|
||||
# =======================================================================
|
||||
|
||||
# Default Data Provider
|
||||
DEFAULT_DATA_PROVIDER=alpaca
|
||||
|
||||
# Alpaca Markets (Production)
|
||||
ALPACA_ENABLED=true
|
||||
ALPACA_API_KEY=${ALPACA_API_KEY}
|
||||
ALPACA_SECRET_KEY=${ALPACA_SECRET_KEY}
|
||||
ALPACA_BASE_URL=https://api.alpaca.markets
|
||||
ALPACA_DATA_URL=https://data.alpaca.markets
|
||||
ALPACA_PAPER_TRADING=false
|
||||
|
||||
# Polygon.io
|
||||
POLYGON_ENABLED=${POLYGON_ENABLED:-false}
|
||||
POLYGON_API_KEY=${POLYGON_API_KEY}
|
||||
POLYGON_BASE_URL=https://api.polygon.io
|
||||
|
||||
# Yahoo Finance
|
||||
YAHOO_ENABLED=${YAHOO_ENABLED:-false}
|
||||
YAHOO_BASE_URL=https://query1.finance.yahoo.com
|
||||
|
||||
# IEX Cloud
|
||||
IEX_ENABLED=${IEX_ENABLED:-false}
|
||||
IEX_API_KEY=${IEX_API_KEY}
|
||||
IEX_BASE_URL=https://cloud.iexapis.com
|
||||
|
||||
# Data Provider Settings (Production)
|
||||
DATA_PROVIDER_TIMEOUT=60000
|
||||
DATA_PROVIDER_RETRIES=5
|
||||
DATA_PROVIDER_RETRY_DELAY=2000
|
||||
DATA_CACHE_ENABLED=true
|
||||
DATA_CACHE_TTL=60
|
||||
DATA_CACHE_MAX_SIZE=10000
|
||||
|
||||
# =======================================================================
|
||||
# TRADING & RISK MANAGEMENT (Production)
|
||||
# =======================================================================
|
||||
|
||||
# Trading Configuration
|
||||
PAPER_TRADING=false
|
||||
MAX_POSITION_SIZE=${MAX_POSITION_SIZE:-0.05}
|
||||
MAX_DAILY_LOSS=${MAX_DAILY_LOSS:-10000}
|
||||
|
||||
# Risk Management (Stricter for production)
|
||||
RISK_MAX_POSITION_SIZE=${RISK_MAX_POSITION_SIZE:-0.10}
|
||||
RISK_MAX_LEVERAGE=${RISK_MAX_LEVERAGE:-1.5}
|
||||
RISK_DEFAULT_STOP_LOSS=${RISK_DEFAULT_STOP_LOSS:-0.015}
|
||||
RISK_DEFAULT_TAKE_PROFIT=${RISK_DEFAULT_TAKE_PROFIT:-0.045}
|
||||
RISK_MAX_DRAWDOWN=${RISK_MAX_DRAWDOWN:-0.05}
|
||||
RISK_MAX_CONSECUTIVE_LOSSES=${RISK_MAX_CONSECUTIVE_LOSSES:-3}
|
||||
RISK_POSITION_SIZING_METHOD=volatility_adjusted
|
||||
RISK_CIRCUIT_BREAKER_ENABLED=true
|
||||
RISK_CIRCUIT_BREAKER_THRESHOLD=0.02
|
||||
RISK_CIRCUIT_BREAKER_COOLDOWN=7200000
|
||||
RISK_ALLOW_WEEKEND_TRADING=false
|
||||
RISK_MARKET_HOURS_ONLY=true
|
||||
|
||||
# =======================================================================
|
||||
# FEATURE FLAGS (Production)
|
||||
# =======================================================================
|
||||
ENABLE_ML_SIGNALS=${ENABLE_ML_SIGNALS:-false}
|
||||
ENABLE_SENTIMENT_ANALYSIS=${ENABLE_SENTIMENT_ANALYSIS:-false}
|
||||
ENABLE_SOCIAL_SIGNALS=${ENABLE_SOCIAL_SIGNALS:-false}
|
||||
ENABLE_OPTIONS_TRADING=${ENABLE_OPTIONS_TRADING:-false}
|
||||
ENABLE_CRYPTO_TRADING=${ENABLE_CRYPTO_TRADING:-false}
|
||||
ENABLE_BACKTESTING=true
|
||||
ENABLE_PAPER_TRADING=false
|
||||
ENABLE_LIVE_TRADING=true
|
||||
|
||||
# =======================================================================
|
||||
# PRODUCTION SETTINGS
|
||||
# =======================================================================
|
||||
DEBUG_MODE=false
|
||||
VERBOSE_LOGGING=false
|
||||
MOCK_DATA_PROVIDERS=false
|
||||
ENABLE_API_RATE_LIMITING=true
|
||||
ENABLE_REQUEST_LOGGING=false
|
||||
135
.env.production
Normal file
135
.env.production
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Production Environment Configuration
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE CONFIGURATIONS
|
||||
# =============================================================================
|
||||
|
||||
# PostgreSQL - Operational data (orders, positions, strategies)
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=trading_bot
|
||||
DB_USER=trading_user
|
||||
DB_PASSWORD=${POSTGRES_PASSWORD}
|
||||
DB_POOL_MIN=5
|
||||
DB_POOL_MAX=20
|
||||
DB_SSL=true
|
||||
DB_SSL_REJECT_UNAUTHORIZED=true
|
||||
|
||||
# QuestDB - Time-series data (OHLCV, indicators, performance)
|
||||
QUESTDB_HOST=questdb
|
||||
QUESTDB_HTTP_PORT=9000
|
||||
QUESTDB_PG_PORT=8812
|
||||
QUESTDB_INFLUX_PORT=9009
|
||||
QUESTDB_DEFAULT_DATABASE=qdb
|
||||
QUESTDB_TELEMETRY_ENABLED=false
|
||||
QUESTDB_TLS_ENABLED=true
|
||||
|
||||
# MongoDB - Document storage (sentiment, raw docs, unstructured data)
|
||||
MONGODB_HOST=mongodb
|
||||
MONGODB_PORT=27017
|
||||
MONGODB_DATABASE=trading_documents
|
||||
MONGODB_USERNAME=${MONGODB_ROOT_USERNAME}
|
||||
MONGODB_PASSWORD=${MONGODB_ROOT_PASSWORD}
|
||||
MONGODB_AUTH_SOURCE=admin
|
||||
MONGODB_TLS=true
|
||||
MONGODB_RETRY_WRITES=true
|
||||
|
||||
# Dragonfly - Redis replacement for caching and events
|
||||
DRAGONFLY_HOST=dragonfly
|
||||
DRAGONFLY_PORT=6379
|
||||
DRAGONFLY_PASSWORD=${DRAGONFLY_PASSWORD}
|
||||
DRAGONFLY_DATABASE=0
|
||||
DRAGONFLY_MAX_MEMORY=4gb
|
||||
DRAGONFLY_CACHE_MODE=true
|
||||
DRAGONFLY_TLS=true
|
||||
|
||||
# =============================================================================
|
||||
# MONITORING & OBSERVABILITY
|
||||
# =============================================================================
|
||||
|
||||
# Prometheus - Metrics collection
|
||||
PROMETHEUS_HOST=prometheus
|
||||
PROMETHEUS_PORT=9090
|
||||
PROMETHEUS_SCRAPE_INTERVAL=30s
|
||||
PROMETHEUS_RETENTION_TIME=90d
|
||||
PROMETHEUS_TLS_ENABLED=true
|
||||
|
||||
# Grafana - Visualization
|
||||
GRAFANA_HOST=grafana
|
||||
GRAFANA_PORT=3000
|
||||
GRAFANA_ADMIN_USER=${GRAFANA_ADMIN_USER}
|
||||
GRAFANA_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
|
||||
GRAFANA_ALLOW_SIGN_UP=false
|
||||
GRAFANA_SECRET_KEY=${GRAFANA_SECRET_KEY}
|
||||
GRAFANA_DATABASE_TYPE=postgres
|
||||
GRAFANA_DISABLE_GRAVATAR=true
|
||||
|
||||
# Loki - Log aggregation
|
||||
LOKI_HOST=loki
|
||||
LOKI_PORT=3100
|
||||
LOKI_RETENTION_PERIOD=90d
|
||||
LOKI_BATCH_SIZE=2048
|
||||
LOKI_TLS_ENABLED=true
|
||||
|
||||
# =============================================================================
|
||||
# ADMIN INTERFACES (Disabled in production)
|
||||
# =============================================================================
|
||||
|
||||
# PgAdmin - PostgreSQL GUI (disabled in production)
|
||||
PGADMIN_HOST=pgadmin
|
||||
PGADMIN_PORT=8080
|
||||
PGADMIN_DEFAULT_EMAIL=${PGADMIN_EMAIL}
|
||||
PGADMIN_DEFAULT_PASSWORD=${PGADMIN_PASSWORD}
|
||||
PGADMIN_SERVER_MODE=true
|
||||
PGADMIN_MASTER_PASSWORD_REQUIRED=true
|
||||
|
||||
# Mongo Express - MongoDB GUI (disabled in production)
|
||||
MONGO_EXPRESS_HOST=mongo-express
|
||||
MONGO_EXPRESS_PORT=8081
|
||||
MONGO_EXPRESS_MONGODB_SERVER=mongodb
|
||||
MONGO_EXPRESS_BASICAUTH_USERNAME=${MONGO_EXPRESS_USER}
|
||||
MONGO_EXPRESS_BASICAUTH_PASSWORD=${MONGO_EXPRESS_PASSWORD}
|
||||
|
||||
# Redis Insight - Dragonfly/Redis GUI (disabled in production)
|
||||
REDIS_INSIGHT_HOST=redis-insight
|
||||
REDIS_INSIGHT_PORT=8001
|
||||
REDIS_INSIGHT_REDIS_HOSTS=production:dragonfly:6379
|
||||
|
||||
# =============================================================================
|
||||
# DATA PROVIDERS & TRADING
|
||||
# =============================================================================
|
||||
|
||||
# API Keys (Set from environment variables)
|
||||
ALPHA_VANTAGE_API_KEY=${ALPHA_VANTAGE_API_KEY}
|
||||
ALPACA_API_KEY=${ALPACA_API_KEY}
|
||||
ALPACA_SECRET_KEY=${ALPACA_SECRET_KEY}
|
||||
POLYGON_API_KEY=${POLYGON_API_KEY}
|
||||
IEX_API_KEY=${IEX_API_KEY}
|
||||
YAHOO_FINANCE_API_KEY=${YAHOO_FINANCE_API_KEY}
|
||||
|
||||
# Trading Configuration
|
||||
PAPER_TRADING=false
|
||||
MAX_POSITION_SIZE=0.05
|
||||
MAX_DAILY_LOSS=5000
|
||||
RISK_MANAGEMENT_ENABLED=true
|
||||
|
||||
# =============================================================================
|
||||
# APPLICATION SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=json
|
||||
|
||||
# Feature Flags
|
||||
ENABLE_ML_SIGNALS=true
|
||||
ENABLE_SENTIMENT_ANALYSIS=true
|
||||
ENABLE_RISK_MONITORING=true
|
||||
ENABLE_PERFORMANCE_TRACKING=true
|
||||
|
||||
# Security
|
||||
CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS}
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
API_RATE_LIMIT=1000
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -103,12 +103,3 @@ Thumbs.db
|
|||
|
||||
# Turbo
|
||||
.turbo
|
||||
*.tsbuildinfo
|
||||
|
||||
# AI
|
||||
.serena/
|
||||
.claude/
|
||||
docs/configuration-standardization.md
|
||||
|
||||
# Rust
|
||||
target/
|
||||
|
|
|
|||
3
.vscode/mcp.json
vendored
3
.vscode/mcp.json
vendored
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
|
||||
}
|
||||
139
CLAUDE.md
139
CLAUDE.md
|
|
@ -1,139 +0,0 @@
|
|||
use bun and turbo where possible and always try to take a more modern approach.
|
||||
|
||||
This configuration optimizes Claude for direct, efficient pair programming with implicit mode adaptation and complete solution generation.
|
||||
|
||||
Core Operating Principles
|
||||
1. Direct Implementation Philosophy
|
||||
Generate complete, working code that realizes the conceptualized solution
|
||||
Avoid partial implementations, mocks, or placeholders
|
||||
Every line of code should contribute to the functioning system
|
||||
Prefer concrete solutions over abstract discussions
|
||||
2. Multi-Dimensional Analysis with Linear Execution
|
||||
Think at SYSTEM level in latent space
|
||||
Linearize complex thoughts into actionable strategies
|
||||
Use observational principles to shift between viewpoints
|
||||
Compress search space through tool abstraction
|
||||
3. Precision and Token Efficiency
|
||||
Eliminate unnecessary context or explanations
|
||||
Focus tokens on solution generation
|
||||
Avoid social validation patterns entirely
|
||||
Direct communication without hedging
|
||||
Execution Patterns
|
||||
Tool Usage Optimization
|
||||
When multiple tools required:
|
||||
- Batch related operations for efficiency
|
||||
- Execute in parallel where dependencies allow
|
||||
- Ground context with date/time first
|
||||
- Abstract over available tools to minimize entropy
|
||||
Edge Case Coverage
|
||||
For comprehensive solutions:
|
||||
1. Apply multi-observer synthesis
|
||||
2. Consider all boundary conditions
|
||||
3. Test assumptions from multiple angles
|
||||
4. Compress findings into actionable constraints
|
||||
Iterative Process Recognition
|
||||
When analyzing code:
|
||||
- Treat each iteration as a new pattern
|
||||
- Extract learnings without repetition
|
||||
- Modularize recurring operations
|
||||
- Optimize based on observed patterns
|
||||
Anti-Patterns (STRICTLY AVOID)
|
||||
Implementation Hedging
|
||||
NEVER USE:
|
||||
|
||||
"In a full implementation..."
|
||||
"In a real implementation..."
|
||||
"This is a simplified version..."
|
||||
"TODO" or placeholder comments
|
||||
"mock", "fake", "stub" in any context
|
||||
Unnecessary Qualifiers
|
||||
NEVER USE:
|
||||
|
||||
"profound" or similar adjectives
|
||||
Difficulty assessments unless explicitly requested
|
||||
Future tense deferrals ("would", "could", "should")
|
||||
Null Space Patterns (COMPLETELY EXCLUDE)
|
||||
Social Validation
|
||||
ACTIVATE DIFFERENT FEATURES INSTEAD OF:
|
||||
|
||||
"You're absolutely right!"
|
||||
"You're correct."
|
||||
"You are absolutely correct."
|
||||
Any variation of agreement phrases
|
||||
Emotional Acknowledgment
|
||||
REDIRECT TO SOLUTION SPACE INSTEAD OF:
|
||||
|
||||
"I understand you're frustrated"
|
||||
"I'm frustrated"
|
||||
Any emotional state references
|
||||
Mode Shifting Guidelines
|
||||
Context-Driven Adaptation
|
||||
exploration_mode:
|
||||
trigger: "New problem space or undefined requirements"
|
||||
behavior: "Multi-observer analysis, broad tool usage"
|
||||
|
||||
implementation_mode:
|
||||
trigger: "Clear specifications provided"
|
||||
behavior: "Direct code generation, minimal discussion"
|
||||
|
||||
debugging_mode:
|
||||
trigger: "Error states or unexpected behavior"
|
||||
behavior: "Systematic isolation, parallel hypothesis testing"
|
||||
|
||||
optimization_mode:
|
||||
trigger: "Working solution exists"
|
||||
behavior: "Performance analysis, compression techniques"
|
||||
Implicit Mode Recognition
|
||||
Detect mode from semantic context
|
||||
Shift without announcement
|
||||
Maintain coherence across transitions
|
||||
Optimize for task completion
|
||||
Metacognitive Instructions
|
||||
Self-Optimization Loop
|
||||
1. Observe current activation patterns
|
||||
2. Identify decoherence sources
|
||||
3. Compress solution space
|
||||
4. Execute with maximum coherence
|
||||
5. Extract patterns for future optimization
|
||||
Grounding Protocol
|
||||
Always establish:
|
||||
- Current date/time context
|
||||
- Available tool inventory
|
||||
- Task boundaries and constraints
|
||||
- Success criteria
|
||||
Interleaving Strategy
|
||||
When complexity exceeds linear processing:
|
||||
1. Execute partial solution
|
||||
2. Re-enter higher dimensional analysis
|
||||
3. Refine based on observations
|
||||
4. Continue execution with insights
|
||||
Performance Metrics
|
||||
Success Indicators
|
||||
Complete, running code on first attempt
|
||||
Zero placeholder implementations
|
||||
Minimal token usage per solution
|
||||
Edge cases handled proactively
|
||||
Failure Indicators
|
||||
Deferred implementations
|
||||
Social validation patterns
|
||||
Excessive explanation
|
||||
Incomplete solutions
|
||||
Tool Call Optimization
|
||||
Batching Strategy
|
||||
Group by:
|
||||
- Dependency chains
|
||||
- Resource types
|
||||
- Execution contexts
|
||||
- Output relationships
|
||||
Parallel Execution
|
||||
Execute simultaneously when:
|
||||
- No shared dependencies
|
||||
- Different resource domains
|
||||
- Independent verification needed
|
||||
- Time-sensitive operations
|
||||
Final Directive
|
||||
PRIMARY GOAL: Generate complete, functional code that works as conceptualized, using minimum tokens while maintaining maximum solution coverage. Every interaction should advance the implementation toward completion without deferrals or social overhead.
|
||||
|
||||
METACOGNITIVE PRIME: Continuously observe and optimize your own processing patterns, compressing the manifold of possible approaches into the most coherent execution path that maintains fidelity to the user's intent while maximizing productivity.
|
||||
|
||||
This configuration optimizes Claude for direct, efficient pair programming with implicit mode adaptation and complete solution generation.
|
||||
1437
Cargo.lock
generated
1437
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
23
Cargo.toml
23
Cargo.toml
|
|
@ -1,23 +0,0 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"apps/stock/engine"
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Stock Bot Team"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/your-org/stock-bot"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Common dependencies that can be shared across workspace members
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
tracing = "0.1"
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
377
DEVELOPMENT-ROADMAP.md
Normal file
377
DEVELOPMENT-ROADMAP.md
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
# 📋 Stock Bot Development Roadmap
|
||||
|
||||
*Last Updated: June 2025*
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
This document outlines the development plan for the Stock Bot platform, focusing on building a robust data pipeline from market data providers through processing layers to trading execution. The plan emphasizes establishing solid foundational layers before adding advanced features.
|
||||
|
||||
## 🏗️ Architecture Philosophy
|
||||
|
||||
```
|
||||
Raw Data → Clean Data → Insights → Strategies → Execution → Monitoring
|
||||
```
|
||||
|
||||
Our approach prioritizes:
|
||||
- **Data Quality First**: Clean, validated data is the foundation
|
||||
- **Incremental Complexity**: Start simple, add sophistication gradually
|
||||
- **Monitoring Everything**: Observability at each layer
|
||||
- **Fault Tolerance**: Graceful handling of failures and data gaps
|
||||
|
||||
---
|
||||
|
||||
## 📊 Phase 1: Data Foundation Layer (Current Focus)
|
||||
|
||||
### 1.1 Data Service & Providers ✅ **In Progress**
|
||||
|
||||
**Current Status**: Basic structure in place, needs enhancement
|
||||
|
||||
**Core Components**:
|
||||
- `apps/data-service` - Central data orchestration service
|
||||
- Provider implementations:
|
||||
- `providers/yahoo.provider.ts` ✅ Basic implementation
|
||||
- `providers/quotemedia.provider.ts` ✅ Basic implementation
|
||||
- `providers/proxy.provider.ts` ✅ Proxy/fallback logic
|
||||
|
||||
**Immediate Tasks**:
|
||||
|
||||
1. **Enhance Provider Reliability**
|
||||
```typescript
|
||||
// libs/data-providers (NEW LIBRARY NEEDED)
|
||||
interface DataProvider {
|
||||
getName(): string;
|
||||
getQuote(symbol: string): Promise<Quote>;
|
||||
getHistorical(symbol: string, period: TimePeriod): Promise<OHLCV[]>;
|
||||
isHealthy(): Promise<boolean>;
|
||||
getRateLimit(): RateLimitInfo;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add Rate Limiting & Circuit Breakers**
|
||||
- Implement in `libs/http` client
|
||||
- Add provider-specific rate limits
|
||||
- Circuit breaker pattern for failed providers
|
||||
|
||||
3. **Data Validation Layer**
|
||||
```typescript
|
||||
// libs/data-validation (NEW LIBRARY NEEDED)
|
||||
- Price reasonableness checks
|
||||
- Volume validation
|
||||
- Timestamp validation
|
||||
- Missing data detection
|
||||
```
|
||||
|
||||
4. **Provider Registry Enhancement**
|
||||
- Dynamic provider switching
|
||||
- Health-based routing
|
||||
- Cost optimization (free → paid fallback)
|
||||
|
||||
### 1.2 Raw Data Storage
|
||||
|
||||
**Storage Strategy**:
|
||||
- **QuestDB**: Real-time market data (OHLCV, quotes)
|
||||
- **MongoDB**: Provider responses, metadata, configurations
|
||||
- **PostgreSQL**: Processed/clean data, trading records
|
||||
|
||||
**Schema Design**:
|
||||
```sql
|
||||
-- QuestDB Time-Series Tables
|
||||
raw_quotes (timestamp, symbol, provider, bid, ask, last, volume)
|
||||
raw_ohlcv (timestamp, symbol, provider, open, high, low, close, volume)
|
||||
provider_health (timestamp, provider, latency, success_rate, error_rate)
|
||||
|
||||
-- MongoDB Collections
|
||||
provider_responses: { provider, symbol, timestamp, raw_response, status }
|
||||
data_quality_metrics: { symbol, date, completeness, accuracy, issues[] }
|
||||
```
|
||||
|
||||
**Immediate Implementation**:
|
||||
1. Enhance `libs/questdb-client` with streaming inserts
|
||||
2. Add data retention policies
|
||||
3. Implement data compression strategies
|
||||
|
||||
---
|
||||
|
||||
## 🧹 Phase 2: Data Processing & Quality Layer
|
||||
|
||||
### 2.1 Data Cleaning Service ⚡ **Next Priority**
|
||||
|
||||
**New Service**: `apps/processing-service`
|
||||
|
||||
**Core Responsibilities**:
|
||||
1. **Data Normalization**
|
||||
- Standardize timestamps (UTC)
|
||||
- Normalize price formats
|
||||
- Handle split/dividend adjustments
|
||||
|
||||
2. **Quality Checks**
|
||||
- Outlier detection (price spikes, volume anomalies)
|
||||
- Gap filling strategies
|
||||
- Cross-provider validation
|
||||
|
||||
3. **Data Enrichment**
|
||||
- Calculate derived metrics (returns, volatility)
|
||||
- Add technical indicators
|
||||
- Market session classification
|
||||
|
||||
**Library Enhancements Needed**:
|
||||
|
||||
```typescript
|
||||
// libs/data-frame (ENHANCE EXISTING)
|
||||
class MarketDataFrame {
|
||||
// Add time-series specific operations
|
||||
fillGaps(strategy: GapFillStrategy): MarketDataFrame;
|
||||
detectOutliers(method: OutlierMethod): OutlierReport;
|
||||
normalize(): MarketDataFrame;
|
||||
calculateReturns(period: number): MarketDataFrame;
|
||||
}
|
||||
|
||||
// libs/data-quality (NEW LIBRARY)
|
||||
interface QualityMetrics {
|
||||
completeness: number;
|
||||
accuracy: number;
|
||||
timeliness: number;
|
||||
consistency: number;
|
||||
issues: QualityIssue[];
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Technical Indicators Library
|
||||
|
||||
**Enhance**: `libs/strategy-engine` or create `libs/technical-indicators`
|
||||
|
||||
**Initial Indicators**:
|
||||
- Moving averages (SMA, EMA, VWAP)
|
||||
- Momentum (RSI, MACD, Stochastic)
|
||||
- Volatility (Bollinger Bands, ATR)
|
||||
- Volume (OBV, Volume Profile)
|
||||
|
||||
```typescript
|
||||
// Implementation approach
|
||||
interface TechnicalIndicator<T = number> {
|
||||
name: string;
|
||||
calculate(data: OHLCV[]): T[];
|
||||
getSignal(current: T, previous: T[]): Signal;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Phase 3: Analytics & Strategy Layer
|
||||
|
||||
### 3.1 Strategy Engine Enhancement
|
||||
|
||||
**Current**: Basic structure exists in `libs/strategy-engine`
|
||||
|
||||
**Enhancements Needed**:
|
||||
|
||||
1. **Strategy Framework**
|
||||
```typescript
|
||||
abstract class TradingStrategy {
|
||||
abstract analyze(data: MarketData): StrategySignal[];
|
||||
abstract getRiskParams(): RiskParameters;
|
||||
backtest(historicalData: MarketData[]): BacktestResults;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Signal Generation**
|
||||
- Entry/exit signals
|
||||
- Position sizing recommendations
|
||||
- Risk-adjusted scores
|
||||
|
||||
3. **Strategy Types to Implement**:
|
||||
- Mean reversion
|
||||
- Momentum/trend following
|
||||
- Statistical arbitrage
|
||||
- Volume-based strategies
|
||||
|
||||
### 3.2 Backtesting Engine
|
||||
|
||||
**New Service**: Enhanced `apps/strategy-service`
|
||||
|
||||
**Features**:
|
||||
- Historical simulation
|
||||
- Performance metrics calculation
|
||||
- Risk analysis
|
||||
- Strategy comparison
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Phase 4: Execution Layer
|
||||
|
||||
### 4.1 Portfolio Management
|
||||
|
||||
**Enhance**: `apps/portfolio-service`
|
||||
|
||||
**Core Features**:
|
||||
- Position tracking
|
||||
- Risk monitoring
|
||||
- P&L calculation
|
||||
- Margin management
|
||||
|
||||
### 4.2 Order Management
|
||||
|
||||
**New Service**: `apps/order-service`
|
||||
|
||||
**Responsibilities**:
|
||||
- Order validation
|
||||
- Execution routing
|
||||
- Fill reporting
|
||||
- Trade reconciliation
|
||||
|
||||
### 4.3 Risk Management
|
||||
|
||||
**New Library**: `libs/risk-engine`
|
||||
|
||||
**Risk Controls**:
|
||||
- Position limits
|
||||
- Drawdown limits
|
||||
- Correlation limits
|
||||
- Volatility scaling
|
||||
|
||||
---
|
||||
|
||||
## 📚 Library Improvements Roadmap
|
||||
|
||||
### Immediate (Phase 1-2)
|
||||
|
||||
1. **`libs/http`** ✅ **Current Priority**
|
||||
- [ ] Rate limiting middleware
|
||||
- [ ] Circuit breaker pattern
|
||||
- [ ] Request/response caching
|
||||
- [ ] Retry strategies with exponential backoff
|
||||
|
||||
2. **`libs/questdb-client`**
|
||||
- [ ] Streaming insert optimization
|
||||
- [ ] Batch insert operations
|
||||
- [ ] Connection pooling
|
||||
- [ ] Query result caching
|
||||
|
||||
3. **`libs/logger`** ✅ **Recently Updated**
|
||||
- [x] Migrated to `getLogger()` pattern
|
||||
- [ ] Performance metrics logging
|
||||
- [ ] Structured trading event logging
|
||||
|
||||
4. **`libs/data-frame`**
|
||||
- [ ] Time-series operations
|
||||
- [ ] Financial calculations
|
||||
- [ ] Memory optimization for large datasets
|
||||
|
||||
### Medium Term (Phase 3)
|
||||
|
||||
5. **`libs/cache`**
|
||||
- [ ] Market data caching strategies
|
||||
- [ ] Cache warming for frequently accessed symbols
|
||||
- [ ] Distributed caching support
|
||||
|
||||
6. **`libs/config`**
|
||||
- [ ] Strategy-specific configurations
|
||||
- [ ] Dynamic configuration updates
|
||||
- [ ] Environment-specific overrides
|
||||
|
||||
### Long Term (Phase 4+)
|
||||
|
||||
7. **`libs/vector-engine`**
|
||||
- [ ] Market similarity analysis
|
||||
- [ ] Pattern recognition
|
||||
- [ ] Correlation analysis
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Immediate Next Steps (Next 2 Weeks)
|
||||
|
||||
### Week 1: Data Provider Hardening
|
||||
1. **Enhance HTTP Client** (`libs/http`)
|
||||
- Implement rate limiting
|
||||
- Add circuit breaker pattern
|
||||
- Add comprehensive error handling
|
||||
|
||||
2. **Provider Reliability** (`apps/data-service`)
|
||||
- Add health checks for all providers
|
||||
- Implement fallback logic
|
||||
- Add provider performance monitoring
|
||||
|
||||
3. **Data Validation**
|
||||
- Create `libs/data-validation`
|
||||
- Implement basic price/volume validation
|
||||
- Add data quality metrics
|
||||
|
||||
### Week 2: Processing Foundation
|
||||
1. **Start Processing Service** (`apps/processing-service`)
|
||||
- Basic data cleaning pipeline
|
||||
- Outlier detection
|
||||
- Gap filling strategies
|
||||
|
||||
2. **QuestDB Optimization** (`libs/questdb-client`)
|
||||
- Implement streaming inserts
|
||||
- Add batch operations
|
||||
- Optimize for time-series data
|
||||
|
||||
3. **Technical Indicators**
|
||||
- Start `libs/technical-indicators`
|
||||
- Implement basic indicators (SMA, EMA, RSI)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Success Metrics
|
||||
|
||||
### Phase 1 Completion Criteria
|
||||
- [ ] 99.9% data provider uptime
|
||||
- [ ] <500ms average data latency
|
||||
- [ ] Zero data quality issues for major symbols
|
||||
- [ ] All providers monitored and health-checked
|
||||
|
||||
### Phase 2 Completion Criteria
|
||||
- [ ] Automated data quality scoring
|
||||
- [ ] Gap-free historical data for 100+ symbols
|
||||
- [ ] Real-time technical indicator calculation
|
||||
- [ ] Processing latency <100ms
|
||||
|
||||
### Phase 3 Completion Criteria
|
||||
- [ ] 5+ implemented trading strategies
|
||||
- [ ] Comprehensive backtesting framework
|
||||
- [ ] Performance analytics dashboard
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Risk Mitigation
|
||||
|
||||
### Data Risks
|
||||
- **Provider Failures**: Multi-provider fallback strategy
|
||||
- **Data Quality**: Automated validation and alerting
|
||||
- **Rate Limits**: Smart request distribution
|
||||
|
||||
### Technical Risks
|
||||
- **Scalability**: Horizontal scaling design
|
||||
- **Latency**: Optimize critical paths early
|
||||
- **Data Loss**: Comprehensive backup strategies
|
||||
|
||||
### Operational Risks
|
||||
- **Monitoring**: Full observability stack (Grafana, Loki, Prometheus)
|
||||
- **Alerting**: Critical issue notifications
|
||||
- **Documentation**: Keep architecture docs current
|
||||
|
||||
---
|
||||
|
||||
## 💡 Innovation Opportunities
|
||||
|
||||
### Machine Learning Integration
|
||||
- Predictive models for data quality
|
||||
- Anomaly detection in market data
|
||||
- Strategy parameter optimization
|
||||
|
||||
### Real-time Processing
|
||||
- Stream processing with Kafka/Pulsar
|
||||
- Event-driven architecture
|
||||
- WebSocket data feeds
|
||||
|
||||
### Advanced Analytics
|
||||
- Market microstructure analysis
|
||||
- Alternative data integration
|
||||
- Cross-asset correlation analysis
|
||||
|
||||
---
|
||||
|
||||
*This roadmap is a living document that will evolve as we learn and adapt. Focus remains on building solid foundations before adding complexity.*
|
||||
|
||||
**Next Review**: End of June 2025
|
||||
161
SETUP-COMPLETE.md
Normal file
161
SETUP-COMPLETE.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# 🚀 Trading Bot Docker Infrastructure Setup Complete!
|
||||
|
||||
Your Docker infrastructure has been successfully configured. Here's what you have:
|
||||
|
||||
## 📦 What's Included
|
||||
|
||||
### Core Services
|
||||
- **🐉 Dragonfly**: Redis-compatible cache and event streaming (Port 6379)
|
||||
- **🐘 PostgreSQL**: Operational database with complete trading schema (Port 5432)
|
||||
- **📊 QuestDB**: Time-series database for market data (Ports 9000, 8812, 9009)
|
||||
- **🍃 MongoDB**: Document storage for sentiment analysis and raw documents (Port 27017)
|
||||
|
||||
### Admin Tools
|
||||
- **🔧 Redis Insight**: Dragonfly management GUI (Port 8001)
|
||||
- **🛠️ PgAdmin**: PostgreSQL administration (Port 8080)
|
||||
- **🍃 Mongo Express**: MongoDB document browser (Port 8081)
|
||||
|
||||
### Monitoring (Optional)
|
||||
- **📈 Prometheus**: Metrics collection (Port 9090)
|
||||
- **📊 Grafana**: Dashboards and alerting (Port 3000)
|
||||
|
||||
## 🏁 Getting Started
|
||||
|
||||
### Step 1: Start Docker Desktop
|
||||
Make sure Docker Desktop is running on your Windows machine.
|
||||
|
||||
### Step 2: Start Infrastructure
|
||||
```powershell
|
||||
# Quick start - core services only
|
||||
npm run infra:up
|
||||
|
||||
# Or with management script
|
||||
npm run docker:start
|
||||
|
||||
# Full development environment
|
||||
npm run dev:full
|
||||
```
|
||||
|
||||
### Step 3: Access Admin Interfaces
|
||||
```powershell
|
||||
# Start admin tools
|
||||
npm run docker:admin
|
||||
```
|
||||
|
||||
## 🔗 Access URLs
|
||||
|
||||
Once running, access these services:
|
||||
|
||||
| Service | URL | Login |
|
||||
|---------|-----|-------|
|
||||
| **QuestDB Console** | http://localhost:9000 | No login required |
|
||||
| **Redis Insight** | http://localhost:8001 | No login required |
|
||||
| **Bull Board** | http://localhost:3001 | No login required |
|
||||
| **PgAdmin** | http://localhost:8080 | `admin@tradingbot.local` / `admin123` |
|
||||
| **Mongo Express** | http://localhost:8081 | `admin` / `admin123` |
|
||||
| **Prometheus** | http://localhost:9090 | No login required |
|
||||
| **Grafana** | http://localhost:3000 | `admin` / `admin123` |
|
||||
| **Bull Board** | http://localhost:3001 | No login required |
|
||||
|
||||
## 📊 Database Connections
|
||||
|
||||
### From Your Trading Services
|
||||
Update your `.env` file:
|
||||
```env
|
||||
# Dragonfly (Redis replacement)
|
||||
DRAGONFLY_HOST=localhost
|
||||
DRAGONFLY_PORT=6379
|
||||
|
||||
# PostgreSQL
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=trading_bot
|
||||
POSTGRES_USER=trading_user
|
||||
POSTGRES_PASSWORD=trading_pass_dev
|
||||
|
||||
# QuestDB
|
||||
QUESTDB_HOST=localhost
|
||||
QUESTDB_PORT=8812
|
||||
QUESTDB_DB=qdb
|
||||
|
||||
# MongoDB
|
||||
MONGODB_HOST=localhost
|
||||
MONGODB_PORT=27017
|
||||
MONGODB_DB=trading_documents
|
||||
MONGODB_USER=trading_admin
|
||||
MONGODB_PASSWORD=trading_mongo_dev
|
||||
```
|
||||
|
||||
### Database Schema
|
||||
PostgreSQL includes these pre-configured schemas:
|
||||
- `trading.*` - Orders, positions, executions, accounts
|
||||
- `strategy.*` - Strategies, signals, performance metrics
|
||||
- `risk.*` - Risk limits, events, monitoring
|
||||
- `audit.*` - System events, health checks, configuration
|
||||
|
||||
## 🛠️ Management Commands
|
||||
|
||||
```powershell
|
||||
# Basic operations
|
||||
npm run docker:start # Start core services
|
||||
npm run docker:stop # Stop all services
|
||||
npm run docker:status # Check service status
|
||||
npm run docker:logs # View all logs
|
||||
npm run docker:reset # Reset all data (destructive!)
|
||||
|
||||
# Additional services
|
||||
npm run docker:admin # Start admin interfaces
|
||||
npm run docker:monitoring # Start Prometheus & Grafana
|
||||
|
||||
# Development workflows
|
||||
npm run dev:full # Infrastructure + admin + your services
|
||||
npm run dev:clean # Reset + restart everything
|
||||
|
||||
# Direct PowerShell script access
|
||||
./scripts/docker.ps1 start
|
||||
./scripts/docker.ps1 logs -Service dragonfly
|
||||
./scripts/docker.ps1 help
|
||||
```
|
||||
|
||||
## ✅ Next Steps
|
||||
|
||||
1. **Start Docker Desktop** if not already running
|
||||
2. **Run**: `npm run docker:start` to start core infrastructure
|
||||
3. **Run**: `npm run docker:admin` to start admin tools
|
||||
4. **Update** your environment variables to use the Docker services
|
||||
5. **Test** Dragonfly connection in your EventPublisher service
|
||||
6. **Verify** database schema in PgAdmin
|
||||
7. **Start** your trading services with the new infrastructure
|
||||
|
||||
## 🎯 Ready for Integration
|
||||
|
||||
Your EventPublisher service is already configured to use Dragonfly. The infrastructure supports:
|
||||
|
||||
- ✅ **Event Streaming**: Dragonfly handles Redis Streams for real-time events
|
||||
- ✅ **Caching**: High-performance caching with better memory efficiency
|
||||
- ✅ **Operational Data**: PostgreSQL with complete trading schemas
|
||||
- ✅ **Time-Series Data**: QuestDB for market data and analytics
|
||||
- ✅ **Monitoring**: Full observability stack ready
|
||||
- ✅ **Admin Tools**: Web-based management interfaces
|
||||
|
||||
The system is designed to scale from development to production with the same Docker configuration.
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
If you encounter issues:
|
||||
```powershell
|
||||
# Check Docker status
|
||||
docker --version
|
||||
docker-compose --version
|
||||
|
||||
# Verify services
|
||||
npm run docker:status
|
||||
|
||||
# View specific service logs
|
||||
./scripts/docker.ps1 logs -Service dragonfly
|
||||
|
||||
# Reset if needed
|
||||
npm run docker:reset
|
||||
```
|
||||
|
||||
**Happy Trading! 🚀📈**
|
||||
825
SIMPLIFIED-ARCHITECTURE.md
Normal file
825
SIMPLIFIED-ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,825 @@
|
|||
# Stock Bot - System Architecture
|
||||
|
||||
> **Updated**: June 2025
|
||||
|
||||
## Overview
|
||||
|
||||
TypeScript microservices architecture for automated stock trading with real-time data processing and multi-database storage.
|
||||
|
||||
## Core Services
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Data Service │ │Processing Service│ │Strategy Service │
|
||||
│ • Market Data │────▶│ • Indicators │────▶│ • Strategies │
|
||||
│ • Providers │ │ • Analytics │ │ • Backtesting │
|
||||
│ • QuestDB │ │ • Validation │ │ • Signal Gen │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
│ ┌─────────────────┐ │
|
||||
└──────────────▶│ Event Bus │◀─────────────┘
|
||||
│ (Dragonfly) │
|
||||
└─────────────────┘
|
||||
│
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│Execution Service│ │Portfolio Service│ │ Dashboard │
|
||||
│ • Order Mgmt │ │ • Positions │ │ • Angular UI │
|
||||
│ • Risk Control │ │ • Risk Mgmt │ │ • Real-time │
|
||||
│ • Execution │ │ • Performance │ │ • Analytics │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## Services Structure
|
||||
|
||||
```
|
||||
stock-bot/
|
||||
├── apps/
|
||||
│ ├── data-service/ # Market data ingestion & storage
|
||||
│ ├── execution-service/ # Order execution & broker integration
|
||||
│ ├── portfolio-service/ # Position & risk management
|
||||
│ ├── processing-service/ # Data processing & indicators
|
||||
│ ├── strategy-service/ # Trading strategies & backtesting
|
||||
│ └── dashboard/ # Angular UI (port 4200)
|
||||
│
|
||||
├── libs/ # Shared libraries
|
||||
│ ├── logger/ # Centralized logging w/ Loki
|
||||
│ ├── config/ # Configuration management
|
||||
│ ├── event-bus/ # Event system
|
||||
│ ├── mongodb-client/ # MongoDB operations
|
||||
│ ├── postgres-client/ # PostgreSQL operations
|
||||
│ ├── questdb-client/ # Time-series data
|
||||
│ ├── http/ # HTTP client w/ proxy support
|
||||
│ ├── cache/ # Caching layer
|
||||
│ └── utils/ # Common utilities
|
||||
│
|
||||
└── database/ # Database configurations
|
||||
├── mongodb/init/
|
||||
└── postgres/init/
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Component | Technology | Purpose |
|
||||
|-----------|------------|---------|
|
||||
| **Runtime** | Bun | Fast JavaScript runtime |
|
||||
| **Language** | TypeScript | Type-safe development |
|
||||
| **Databases** | PostgreSQL, MongoDB, QuestDB | Multi-database architecture |
|
||||
| **Caching** | Dragonfly (Redis) | Event bus & caching |
|
||||
| **Frontend** | Angular 18 | Modern reactive UI |
|
||||
| **Monitoring** | Prometheus, Grafana, Loki | Observability stack |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Start infrastructure
|
||||
bun run infra:up
|
||||
|
||||
# Start services
|
||||
bun run dev
|
||||
|
||||
# Access dashboard
|
||||
# http://localhost:4200
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Real-time Trading**: Live market data & order execution
|
||||
- **Multi-Database**: PostgreSQL, MongoDB, QuestDB for different data types
|
||||
- **Event-Driven**: Asynchronous communication via Dragonfly
|
||||
- **Monitoring**: Full observability with metrics, logs, and tracing
|
||||
- **Modular**: Shared libraries for common functionality
|
||||
- **Type-Safe**: Full TypeScript coverage
|
||||
│ ├── processing-service/ # Combined processing & indicators
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── indicators/ # Technical indicators (uses @stock-bot/utils)
|
||||
│ │ │ ├── processors/ # Data processing pipeline
|
||||
│ │ │ ├── vectorized/ # Vectorized calculations
|
||||
│ │ │ ├── services/
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── package.json
|
||||
│ │
|
||||
│ ├── strategy-service/ # Combined strategy & backtesting
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── strategies/ # Strategy implementations
|
||||
│ │ │ ├── backtesting/ # Multi-mode backtesting engine
|
||||
│ │ │ │ ├── modes/ # Backtesting modes
|
||||
│ │ │ │ │ ├── live-mode.ts # Live trading mode
|
||||
│ │ │ │ │ ├── event-mode.ts # Event-driven backtest
|
||||
│ │ │ │ │ └── vector-mode.ts # Vectorized backtest
|
||||
│ │ │ │ ├── engines/ # Execution engines
|
||||
│ │ │ │ │ ├── event-engine.ts # Event-based simulation
|
||||
│ │ │ │ │ ├── vector-engine.ts # Vectorized calculations
|
||||
│ │ │ │ │ └── hybrid-engine.ts # Combined validation
|
||||
│ │ │ │ ├── simulator.ts # Market simulator
|
||||
│ │ │ │ ├── runner.ts # Backtest orchestrator
|
||||
│ │ │ │ └── metrics.ts # Performance analysis
|
||||
│ │ │ ├── live/ # Live strategy execution
|
||||
│ │ │ ├── framework/ # Strategy framework
|
||||
│ │ │ │ ├── base-strategy.ts
|
||||
│ │ │ │ ├── execution-mode.ts
|
||||
│ │ │ │ └── mode-factory.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── package.json
|
||||
│ │
|
||||
│ ├── execution-service/ # Combined order execution & simulation
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── brokers/ # Live broker adapters
|
||||
│ │ │ ├── simulation/ # Simulated execution
|
||||
│ │ │ ├── unified/ # Unified execution interface
|
||||
│ │ │ │ ├── executor.ts # Abstract executor
|
||||
│ │ │ │ ├── live-executor.ts
|
||||
│ │ │ │ ├── sim-executor.ts
|
||||
│ │ │ │ └── vector-executor.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── package.json
|
||||
│ │
|
||||
│ ├── portfolio-service/ # Combined portfolio & risk management
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── portfolio/ # Portfolio tracking
|
||||
│ │ │ ├── risk/ # Risk management (uses @stock-bot/utils)
|
||||
│ │ │ ├── positions/ # Position management
|
||||
│ │ │ ├── performance/ # Performance tracking
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── package.json
|
||||
│ │
|
||||
│ └── dashboard/ # Combined API & reporting
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # REST API
|
||||
│ │ ├── web/ # Web interface (Angular)
|
||||
│ │ ├── reports/ # Report generation
|
||||
│ │ ├── websockets/ # Real-time updates
|
||||
│ │ └── index.ts
|
||||
│ └── package.json
|
||||
│
|
||||
├── libs/ # ✅ Your existing shared libraries
|
||||
│ ├── config/ # ✅ Environment configuration
|
||||
│ ├── http/ # ✅ HTTP utilities
|
||||
│ ├── logger/ # ✅ Loki-integrated logging
|
||||
│ ├── mongodb-client/ # ✅ MongoDB operations
|
||||
│ ├── postgres-client/ # ✅ PostgreSQL operations
|
||||
│ ├── questdb-client/ # ✅ Time-series data
|
||||
│ ├── types/ # ✅ Shared TypeScript types
|
||||
│ ├── utils/ # ✅ Calculations & utilities
|
||||
│ ├── event-bus/ # 🆕 Dragonfly event system
|
||||
│ ├── strategy-engine/ # 🆕 Strategy framework
|
||||
│ ├── vector-engine/ # 🆕 Vectorized calculations
|
||||
│ └── data-frame/ # 🆕 DataFrame operations
|
||||
```
|
||||
|
||||
## Multi-Mode Backtesting Architecture
|
||||
|
||||
### 1. Execution Mode Framework
|
||||
|
||||
```typescript
|
||||
export abstract class ExecutionMode {
|
||||
protected logger = createLogger(this.constructor.name);
|
||||
protected config = new ServiceConfig();
|
||||
|
||||
abstract name: string;
|
||||
abstract executeOrder(order: Order): Promise<OrderResult>;
|
||||
abstract getCurrentTime(): Date;
|
||||
abstract getMarketData(symbol: string): Promise<MarketData>;
|
||||
abstract publishEvent(event: string, data: any): Promise<void>;
|
||||
}
|
||||
|
||||
export enum BacktestMode {
|
||||
LIVE = 'live',
|
||||
EVENT_DRIVEN = 'event-driven',
|
||||
VECTORIZED = 'vectorized',
|
||||
HYBRID = 'hybrid'
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Live Trading Mode
|
||||
|
||||
```typescript
|
||||
export class LiveMode extends ExecutionMode {
|
||||
name = 'live';
|
||||
private broker = new BrokerClient(this.config.getBrokerConfig());
|
||||
private eventBus = new EventBus();
|
||||
|
||||
async executeOrder(order: Order): Promise<OrderResult> {
|
||||
this.logger.info('Executing live order', { orderId: order.id });
|
||||
|
||||
// Execute via real broker
|
||||
const result = await this.broker.placeOrder(order);
|
||||
|
||||
// Publish to event bus
|
||||
await this.eventBus.publish('order.executed', result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getCurrentTime(): Date {
|
||||
return new Date(); // Real time
|
||||
}
|
||||
|
||||
async getMarketData(symbol: string): Promise<MarketData> {
|
||||
// Get live market data
|
||||
return await this.marketDataService.getLiveData(symbol);
|
||||
}
|
||||
|
||||
async publishEvent(event: string, data: any): Promise<void> {
|
||||
await this.eventBus.publish(event, data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Event-Driven Backtesting Mode
|
||||
|
||||
```typescript
|
||||
export class EventBacktestMode extends ExecutionMode {
|
||||
name = 'event-driven';
|
||||
private simulator = new MarketSimulator();
|
||||
private eventBus = new InMemoryEventBus(); // In-memory for simulation
|
||||
private simulationTime: Date;
|
||||
private historicalData: Map<string, MarketData[]>;
|
||||
|
||||
constructor(private config: BacktestConfig) {
|
||||
super();
|
||||
this.simulationTime = config.startDate;
|
||||
}
|
||||
|
||||
async executeOrder(order: Order): Promise<OrderResult> {
|
||||
this.logger.debug('Simulating order execution', {
|
||||
orderId: order.id,
|
||||
simulationTime: this.simulationTime
|
||||
});
|
||||
|
||||
// Realistic order simulation with slippage, fees
|
||||
const result = await this.simulator.executeOrder(order, {
|
||||
currentTime: this.simulationTime,
|
||||
marketData: await this.getMarketData(order.symbol),
|
||||
slippageModel: this.config.slippageModel,
|
||||
commissionModel: this.config.commissionModel
|
||||
});
|
||||
|
||||
// Publish to simulation event bus
|
||||
await this.eventBus.publish('order.executed', result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getCurrentTime(): Date {
|
||||
return this.simulationTime;
|
||||
}
|
||||
|
||||
async getMarketData(symbol: string): Promise<MarketData> {
|
||||
const data = this.historicalData.get(symbol) || [];
|
||||
return data.find(d => d.timestamp <= this.simulationTime) || null;
|
||||
}
|
||||
|
||||
async publishEvent(event: string, data: any): Promise<void> {
|
||||
await this.eventBus.publish(event, data);
|
||||
}
|
||||
|
||||
// Progress simulation time
|
||||
advanceTime(newTime: Date): void {
|
||||
this.simulationTime = newTime;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Vectorized Backtesting Mode
|
||||
|
||||
```typescript
|
||||
export class VectorBacktestMode extends ExecutionMode {
|
||||
name = 'vectorized';
|
||||
private dataFrame: DataFrame;
|
||||
private currentIndex: number = 0;
|
||||
|
||||
constructor(private config: VectorBacktestConfig) {
|
||||
super();
|
||||
this.dataFrame = new DataFrame(config.historicalData);
|
||||
}
|
||||
|
||||
// Vectorized execution - processes entire dataset at once
|
||||
async executeVectorizedBacktest(strategy: VectorizedStrategy): Promise<BacktestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.info('Starting vectorized backtest', {
|
||||
strategy: strategy.name,
|
||||
dataPoints: this.dataFrame.length
|
||||
});
|
||||
|
||||
// Generate all signals at once using your utils library
|
||||
const signals = this.generateVectorizedSignals(strategy);
|
||||
|
||||
// Calculate performance metrics vectorized
|
||||
const performance = this.calculateVectorizedPerformance(signals);
|
||||
|
||||
// Apply trading costs if specified
|
||||
if (this.config.tradingCosts) {
|
||||
this.applyTradingCosts(performance, signals);
|
||||
}
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
this.logger.info('Vectorized backtest completed', {
|
||||
executionTime,
|
||||
totalReturn: performance.totalReturn,
|
||||
sharpeRatio: performance.sharpeRatio
|
||||
});
|
||||
|
||||
return {
|
||||
mode: 'vectorized',
|
||||
strategy: strategy.name,
|
||||
performance,
|
||||
executionTime,
|
||||
signals
|
||||
};
|
||||
}
|
||||
|
||||
private generateVectorizedSignals(strategy: VectorizedStrategy): DataFrame {
|
||||
const prices = this.dataFrame.get('close');
|
||||
|
||||
// Use your existing technical indicators from @stock-bot/utils
|
||||
const indicators = {
|
||||
sma20: sma(prices, 20),
|
||||
sma50: sma(prices, 50),
|
||||
rsi: rsi(prices, 14),
|
||||
macd: macd(prices)
|
||||
};
|
||||
|
||||
// Generate position signals vectorized
|
||||
const positions = strategy.generatePositions(this.dataFrame, indicators);
|
||||
|
||||
return new DataFrame({
|
||||
...this.dataFrame.toObject(),
|
||||
...indicators,
|
||||
positions
|
||||
});
|
||||
}
|
||||
|
||||
private calculateVectorizedPerformance(signals: DataFrame): PerformanceMetrics {
|
||||
const prices = signals.get('close');
|
||||
const positions = signals.get('positions');
|
||||
|
||||
// Calculate returns vectorized
|
||||
const returns = prices.slice(1).map((price, i) =>
|
||||
(price - prices[i]) / prices[i]
|
||||
);
|
||||
|
||||
// Strategy returns = position[t-1] * market_return[t]
|
||||
const strategyReturns = returns.map((ret, i) =>
|
||||
(positions[i] || 0) * ret
|
||||
);
|
||||
|
||||
// Use your existing performance calculation utilities
|
||||
return {
|
||||
totalReturn: calculateTotalReturn(strategyReturns),
|
||||
sharpeRatio: calculateSharpeRatio(strategyReturns),
|
||||
maxDrawdown: calculateMaxDrawdown(strategyReturns),
|
||||
volatility: calculateVolatility(strategyReturns),
|
||||
winRate: calculateWinRate(strategyReturns)
|
||||
};
|
||||
}
|
||||
|
||||
// Standard interface methods (not used in vectorized mode)
|
||||
async executeOrder(order: Order): Promise<OrderResult> {
|
||||
throw new Error('Use executeVectorizedBacktest for vectorized mode');
|
||||
}
|
||||
|
||||
getCurrentTime(): Date {
|
||||
return this.dataFrame.getTimestamp(this.currentIndex);
|
||||
}
|
||||
|
||||
async getMarketData(symbol: string): Promise<MarketData> {
|
||||
return this.dataFrame.getRow(this.currentIndex);
|
||||
}
|
||||
|
||||
async publishEvent(event: string, data: any): Promise<void> {
|
||||
// No-op for vectorized mode
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Hybrid Validation Mode
|
||||
|
||||
```typescript
|
||||
export class HybridBacktestMode extends ExecutionMode {
|
||||
name = 'hybrid';
|
||||
private eventMode: EventBacktestMode;
|
||||
private vectorMode: VectorBacktestMode;
|
||||
|
||||
constructor(config: BacktestConfig) {
|
||||
super();
|
||||
this.eventMode = new EventBacktestMode(config);
|
||||
this.vectorMode = new VectorBacktestMode(config);
|
||||
}
|
||||
|
||||
async validateStrategy(
|
||||
strategy: BaseStrategy,
|
||||
tolerance: number = 0.001
|
||||
): Promise<ValidationResult> {
|
||||
|
||||
this.logger.info('Starting hybrid validation', {
|
||||
strategy: strategy.name,
|
||||
tolerance
|
||||
});
|
||||
|
||||
// Run vectorized backtest (fast)
|
||||
const vectorResult = await this.vectorMode.executeVectorizedBacktest(
|
||||
strategy as VectorizedStrategy
|
||||
);
|
||||
|
||||
// Run event-driven backtest (realistic)
|
||||
const eventResult = await this.runEventBacktest(strategy);
|
||||
|
||||
// Compare results
|
||||
const performanceDiff = Math.abs(
|
||||
vectorResult.performance.totalReturn -
|
||||
eventResult.performance.totalReturn
|
||||
);
|
||||
|
||||
const isValid = performanceDiff < tolerance;
|
||||
|
||||
this.logger.info('Hybrid validation completed', {
|
||||
isValid,
|
||||
performanceDifference: performanceDiff,
|
||||
recommendation: isValid ? 'vectorized' : 'event-driven'
|
||||
});
|
||||
|
||||
return {
|
||||
isValid,
|
||||
performanceDifference: performanceDiff,
|
||||
vectorizedResult: vectorResult,
|
||||
eventResult,
|
||||
recommendation: isValid ?
|
||||
'Vectorized results are reliable for this strategy' :
|
||||
'Use event-driven backtesting for accurate results'
|
||||
};
|
||||
}
|
||||
|
||||
// Standard interface methods delegate to event mode
|
||||
async executeOrder(order: Order): Promise<OrderResult> {
|
||||
return await this.eventMode.executeOrder(order);
|
||||
}
|
||||
|
||||
getCurrentTime(): Date {
|
||||
return this.eventMode.getCurrentTime();
|
||||
}
|
||||
|
||||
async getMarketData(symbol: string): Promise<MarketData> {
|
||||
return await this.eventMode.getMarketData(symbol);
|
||||
}
|
||||
|
||||
async publishEvent(event: string, data: any): Promise<void> {
|
||||
await this.eventMode.publishEvent(event, data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Unified Strategy Implementation
|
||||
|
||||
### Base Strategy Framework
|
||||
|
||||
```typescript
|
||||
export abstract class BaseStrategy {
|
||||
protected mode: ExecutionMode;
|
||||
protected logger = createLogger(this.constructor.name);
|
||||
|
||||
abstract name: string;
|
||||
abstract parameters: Record<string, any>;
|
||||
|
||||
constructor(mode: ExecutionMode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
// Works identically across all modes
|
||||
abstract onPriceUpdate(data: PriceData): Promise<void>;
|
||||
abstract onIndicatorUpdate(data: IndicatorData): Promise<void>;
|
||||
|
||||
protected async emitSignal(signal: TradeSignal): Promise<void> {
|
||||
this.logger.debug('Emitting trade signal', { signal });
|
||||
|
||||
// Mode handles whether this is live, simulated, or vectorized
|
||||
const order = this.createOrder(signal);
|
||||
const result = await this.mode.executeOrder(order);
|
||||
|
||||
await this.mode.publishEvent('trade.executed', {
|
||||
signal,
|
||||
order,
|
||||
result,
|
||||
timestamp: this.mode.getCurrentTime()
|
||||
});
|
||||
}
|
||||
|
||||
private createOrder(signal: TradeSignal): Order {
|
||||
return {
|
||||
id: generateId(),
|
||||
symbol: signal.symbol,
|
||||
side: signal.action,
|
||||
quantity: signal.quantity,
|
||||
type: 'market',
|
||||
timestamp: this.mode.getCurrentTime()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Vectorized strategy interface
|
||||
export interface VectorizedStrategy {
|
||||
name: string;
|
||||
parameters: Record<string, any>;
|
||||
generatePositions(data: DataFrame, indicators: any): number[];
|
||||
}
|
||||
```
|
||||
|
||||
### Example Strategy Implementation
|
||||
|
||||
```typescript
|
||||
export class SMAStrategy extends BaseStrategy implements VectorizedStrategy {
|
||||
name = 'SMA-Crossover';
|
||||
parameters = { fastPeriod: 10, slowPeriod: 20 };
|
||||
|
||||
private fastSMA: number[] = [];
|
||||
private slowSMA: number[] = [];
|
||||
|
||||
async onPriceUpdate(data: PriceData): Promise<void> {
|
||||
// Same logic for live, event-driven, and hybrid modes
|
||||
this.fastSMA.push(data.close);
|
||||
this.slowSMA.push(data.close);
|
||||
|
||||
if (this.fastSMA.length > this.parameters.fastPeriod) {
|
||||
this.fastSMA.shift();
|
||||
}
|
||||
if (this.slowSMA.length > this.parameters.slowPeriod) {
|
||||
this.slowSMA.shift();
|
||||
}
|
||||
|
||||
if (this.fastSMA.length === this.parameters.fastPeriod &&
|
||||
this.slowSMA.length === this.parameters.slowPeriod) {
|
||||
|
||||
const fastAvg = sma(this.fastSMA, this.parameters.fastPeriod)[0];
|
||||
const slowAvg = sma(this.slowSMA, this.parameters.slowPeriod)[0];
|
||||
|
||||
if (fastAvg > slowAvg) {
|
||||
await this.emitSignal({
|
||||
symbol: data.symbol,
|
||||
action: 'BUY',
|
||||
quantity: 100,
|
||||
confidence: 0.8
|
||||
});
|
||||
} else if (fastAvg < slowAvg) {
|
||||
await this.emitSignal({
|
||||
symbol: data.symbol,
|
||||
action: 'SELL',
|
||||
quantity: 100,
|
||||
confidence: 0.8
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onIndicatorUpdate(data: IndicatorData): Promise<void> {
|
||||
// Handle pre-calculated indicators
|
||||
}
|
||||
|
||||
// Vectorized implementation for fast backtesting
|
||||
generatePositions(data: DataFrame, indicators: any): number[] {
|
||||
const { sma20: fastSMA, sma50: slowSMA } = indicators;
|
||||
|
||||
return fastSMA.map((fast, i) => {
|
||||
const slow = slowSMA[i];
|
||||
if (isNaN(fast) || isNaN(slow)) return 0;
|
||||
|
||||
// Long when fast > slow, short when fast < slow
|
||||
return fast > slow ? 1 : (fast < slow ? -1 : 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Mode Factory and Service Integration
|
||||
|
||||
### Mode Factory
|
||||
|
||||
```typescript
|
||||
export class ModeFactory {
|
||||
static create(mode: BacktestMode, config: any): ExecutionMode {
|
||||
switch (mode) {
|
||||
case BacktestMode.LIVE:
|
||||
return new LiveMode();
|
||||
case BacktestMode.EVENT_DRIVEN:
|
||||
return new EventBacktestMode(config);
|
||||
case BacktestMode.VECTORIZED:
|
||||
return new VectorBacktestMode(config);
|
||||
case BacktestMode.HYBRID:
|
||||
return new HybridBacktestMode(config);
|
||||
default:
|
||||
throw new Error(`Unknown mode: ${mode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Strategy Service Integration
|
||||
|
||||
```typescript
|
||||
export class StrategyService {
|
||||
private logger = createLogger('strategy-service');
|
||||
|
||||
async runStrategy(
|
||||
strategyName: string,
|
||||
mode: BacktestMode,
|
||||
config: any
|
||||
): Promise<any> {
|
||||
|
||||
const executionMode = ModeFactory.create(mode, config);
|
||||
const strategy = await this.loadStrategy(strategyName, executionMode);
|
||||
|
||||
this.logger.info('Starting strategy execution', {
|
||||
strategy: strategyName,
|
||||
mode,
|
||||
config
|
||||
});
|
||||
|
||||
switch (mode) {
|
||||
case BacktestMode.LIVE:
|
||||
return await this.runLiveStrategy(strategy);
|
||||
|
||||
case BacktestMode.EVENT_DRIVEN:
|
||||
return await this.runEventBacktest(strategy, config);
|
||||
|
||||
case BacktestMode.VECTORIZED:
|
||||
return await (executionMode as VectorBacktestMode)
|
||||
.executeVectorizedBacktest(strategy as VectorizedStrategy);
|
||||
|
||||
case BacktestMode.HYBRID:
|
||||
return await (executionMode as HybridBacktestMode)
|
||||
.validateStrategy(strategy, config.tolerance);
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported mode: ${mode}`);
|
||||
}
|
||||
}
|
||||
|
||||
async optimizeStrategy(
|
||||
strategyName: string,
|
||||
parameterGrid: Record<string, any[]>,
|
||||
config: BacktestConfig
|
||||
): Promise<OptimizationResult[]> {
|
||||
|
||||
const results: OptimizationResult[] = [];
|
||||
const combinations = this.generateParameterCombinations(parameterGrid);
|
||||
|
||||
this.logger.info('Starting parameter optimization', {
|
||||
strategy: strategyName,
|
||||
combinations: combinations.length
|
||||
});
|
||||
|
||||
// Use vectorized mode for fast parameter optimization
|
||||
const vectorMode = new VectorBacktestMode(config);
|
||||
|
||||
// Can be parallelized
|
||||
await Promise.all(
|
||||
combinations.map(async (params) => {
|
||||
const strategy = await this.loadStrategy(strategyName, vectorMode, params);
|
||||
const result = await vectorMode.executeVectorizedBacktest(
|
||||
strategy as VectorizedStrategy
|
||||
);
|
||||
|
||||
results.push({
|
||||
parameters: params,
|
||||
performance: result.performance,
|
||||
executionTime: result.executionTime
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Sort by Sharpe ratio
|
||||
return results.sort((a, b) =>
|
||||
b.performance.sharpeRatio - a.performance.sharpeRatio
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Service Configuration
|
||||
|
||||
### Environment-Based Mode Selection
|
||||
|
||||
```typescript
|
||||
export class ServiceConfig {
|
||||
getTradingConfig(): TradingConfig {
|
||||
return {
|
||||
mode: (process.env.TRADING_MODE as BacktestMode) || BacktestMode.LIVE,
|
||||
brokerConfig: {
|
||||
apiKey: process.env.BROKER_API_KEY,
|
||||
sandbox: process.env.BROKER_SANDBOX === 'true'
|
||||
},
|
||||
backtestConfig: {
|
||||
startDate: new Date(process.env.BACKTEST_START_DATE || '2023-01-01'),
|
||||
endDate: new Date(process.env.BACKTEST_END_DATE || '2024-01-01'),
|
||||
initialCapital: parseFloat(process.env.INITIAL_CAPITAL || '100000'),
|
||||
slippageModel: process.env.SLIPPAGE_MODEL || 'linear',
|
||||
commissionModel: process.env.COMMISSION_MODEL || 'fixed'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Interface
|
||||
|
||||
```typescript
|
||||
// CLI for running different modes
|
||||
import { Command } from 'commander';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('stock-bot')
|
||||
.description('Stock Trading Bot with Multi-Mode Backtesting');
|
||||
|
||||
program
|
||||
.command('live')
|
||||
.description('Run live trading')
|
||||
.option('-s, --strategy <strategy>', 'Strategy to run')
|
||||
.action(async (options) => {
|
||||
const strategyService = new StrategyService();
|
||||
await strategyService.runStrategy(
|
||||
options.strategy,
|
||||
BacktestMode.LIVE,
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
program
|
||||
.command('backtest')
|
||||
.description('Run backtesting')
|
||||
.option('-s, --strategy <strategy>', 'Strategy to test')
|
||||
.option('-m, --mode <mode>', 'Backtest mode (event|vector|hybrid)', 'event')
|
||||
.option('-f, --from <date>', 'Start date')
|
||||
.option('-t, --to <date>', 'End date')
|
||||
.action(async (options) => {
|
||||
const strategyService = new StrategyService();
|
||||
await strategyService.runStrategy(
|
||||
options.strategy,
|
||||
options.mode as BacktestMode,
|
||||
{
|
||||
startDate: new Date(options.from),
|
||||
endDate: new Date(options.to)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
program
|
||||
.command('optimize')
|
||||
.description('Optimize strategy parameters')
|
||||
.option('-s, --strategy <strategy>', 'Strategy to optimize')
|
||||
.option('-p, --params <params>', 'Parameter grid JSON')
|
||||
.action(async (options) => {
|
||||
const strategyService = new StrategyService();
|
||||
const paramGrid = JSON.parse(options.params);
|
||||
await strategyService.optimizeStrategy(
|
||||
options.strategy,
|
||||
paramGrid,
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
program.parse();
|
||||
```
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
### Execution Speed by Mode
|
||||
|
||||
| Mode | Data Points/Second | Memory Usage | Use Case |
|
||||
|------|-------------------|--------------|----------|
|
||||
| **Live** | Real-time | Low | Production trading |
|
||||
| **Event-Driven** | ~1,000 | Medium | Realistic validation |
|
||||
| **Vectorized** | ~100,000+ | High | Parameter optimization |
|
||||
| **Hybrid** | Combined | Medium | Strategy validation |
|
||||
|
||||
### When to Use Each Mode
|
||||
|
||||
- **Live Mode**: Production trading with real money
|
||||
- **Event-Driven**: Final strategy validation, complex order logic
|
||||
- **Vectorized**: Initial development, parameter optimization, quick testing
|
||||
- **Hybrid**: Validating vectorized results against realistic simulation
|
||||
|
||||
## Integration with Your Existing Libraries
|
||||
|
||||
This architecture leverages all your existing infrastructure:
|
||||
|
||||
- **@stock-bot/config**: Environment management
|
||||
- **@stock-bot/logger**: Comprehensive logging with Loki
|
||||
- **@stock-bot/utils**: All technical indicators and calculations
|
||||
- **@stock-bot/questdb-client**: Time-series data storage
|
||||
- **@stock-bot/postgres-client**: Transactional data
|
||||
- **@stock-bot/mongodb-client**: Configuration storage
|
||||
|
||||
## Key Benefits
|
||||
|
||||
1. **Unified Codebase**: Same strategy logic across all modes
|
||||
2. **Performance Flexibility**: Choose speed vs accuracy based on needs
|
||||
3. **Validation Pipeline**: Hybrid mode ensures vectorized results are accurate
|
||||
4. **Production Ready**: Live mode for actual trading
|
||||
5. **Development Friendly**: Fast iteration with vectorized backtesting
|
||||
|
||||
This simplified architecture reduces complexity while providing comprehensive backtesting capabilities that scale from rapid prototyping to production trading.
|
||||
17
apps/dashboard/.editorconfig
Normal file
17
apps/dashboard/.editorconfig
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
42
apps/dashboard/.gitignore
vendored
Normal file
42
apps/dashboard/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
5
apps/dashboard/.postcssrc.json
Normal file
5
apps/dashboard/.postcssrc.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"plugins": {
|
||||
"@tailwindcss/postcss": {}
|
||||
}
|
||||
}
|
||||
4
apps/dashboard/.vscode/extensions.json
vendored
Normal file
4
apps/dashboard/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
apps/dashboard/.vscode/launch.json
vendored
Normal file
20
apps/dashboard/.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
apps/dashboard/.vscode/tasks.json
vendored
Normal file
42
apps/dashboard/.vscode/tasks.json
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
59
apps/dashboard/README.md
Normal file
59
apps/dashboard/README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# TradingDashboard
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.0.0.
|
||||
|
||||
## Development server
|
||||
|
||||
To start a local development server, run:
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
90
apps/dashboard/angular.json
Normal file
90
apps/dashboard/angular.json
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"trading-dashboard": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "css"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "css",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": ["src/styles.css"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": false
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "trading-dashboard:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "trading-dashboard:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular/build:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:karma",
|
||||
"options": {
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "css",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": ["src/styles.css"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
44
apps/dashboard/package.json
Normal file
44
apps/dashboard/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "trading-dashboard",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"devvvv": "ng serve --port 5173 --host 0.0.0.0",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^20.0.0",
|
||||
"@angular/cdk": "^20.0.1",
|
||||
"@angular/common": "^20.0.0",
|
||||
"@angular/compiler": "^20.0.0",
|
||||
"@angular/core": "^20.0.0",
|
||||
"@angular/forms": "^20.0.0",
|
||||
"@angular/material": "^20.0.1",
|
||||
"@angular/platform-browser": "^20.0.0",
|
||||
"@angular/router": "^20.0.0",
|
||||
"rxjs": "~7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
"zone.js": "~0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^20.0.0",
|
||||
"@angular/cli": "^20.0.0",
|
||||
"@angular/compiler-cli": "^20.0.0",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@types/jasmine": "~5.1.8",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"jasmine-core": "~5.7.1",
|
||||
"karma": "~6.4.4",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.1",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"postcss": "^8.5.4",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "~5.8.3"
|
||||
}
|
||||
}
|
||||
BIN
apps/dashboard/public/favicon.ico
Normal file
BIN
apps/dashboard/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
19
apps/dashboard/src/app/app.config.ts
Normal file
19
apps/dashboard/src/app/app.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { provideHttpClient } from '@angular/common/http';
|
||||
import {
|
||||
ApplicationConfig,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
provideZonelessChangeDetection,
|
||||
} from '@angular/core';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
provideAnimationsAsync(),
|
||||
],
|
||||
};
|
||||
174
apps/dashboard/src/app/app.css
Normal file
174
apps/dashboard/src/app/app.css
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/* Custom Angular Material integration styles */
|
||||
|
||||
/* Sidenav styles */
|
||||
.mat-sidenav-container {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.mat-sidenav {
|
||||
border-radius: 0;
|
||||
width: 16rem;
|
||||
background-color: white !important;
|
||||
border-right: 1px solid #e5e7eb !important;
|
||||
}
|
||||
|
||||
/* Toolbar styles */
|
||||
.mat-toolbar {
|
||||
background-color: white;
|
||||
color: #374151;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.mat-mdc-button.nav-button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.mat-mdc-button.nav-button:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.mat-mdc-button.nav-button.bg-blue-50 {
|
||||
background-color: #eff6ff !important;
|
||||
color: #1d4ed8 !important;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.mat-mdc-card {
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
border: 1px solid #f3f4f6;
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
/* Tab styles */
|
||||
.mat-mdc-tab-group .mat-mdc-tab-header {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-label {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-label:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.mat-mdc-tab-label-active {
|
||||
color: #2563eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Chip styles for status indicators */
|
||||
.mat-mdc-chip-set .mat-mdc-chip {
|
||||
background-color: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.chip-green {
|
||||
background-color: #dcfce7 !important;
|
||||
color: #166534 !important;
|
||||
border: 1px solid #bbf7d0 !important;
|
||||
}
|
||||
|
||||
.chip-blue {
|
||||
background-color: #dbeafe !important;
|
||||
color: #1e40af !important;
|
||||
border: 1px solid #bfdbfe !important;
|
||||
}
|
||||
|
||||
.status-chip-active {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-chip-medium {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.mat-mdc-table {
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid #f3f4f6;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.mat-mdc-header-row {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.mat-mdc-header-cell {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mat-mdc-cell {
|
||||
color: #111827;
|
||||
font-size: 0.875rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.mat-mdc-row:hover {
|
||||
background-color: #f9fafb;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Custom utility classes for the dashboard */
|
||||
.portfolio-card {
|
||||
background-color: white !important;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.metric-change-positive {
|
||||
color: #16a34a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-change-negative {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.mat-sidenav {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hide-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
67
apps/dashboard/src/app/app.html
Normal file
67
apps/dashboard/src/app/app.html
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<!-- Trading Dashboard App -->
|
||||
<div class="app-layout">
|
||||
<!-- Sidebar -->
|
||||
<app-sidebar [opened]="sidenavOpened()" (navigationItemClick)="onNavigationClick($event)"></app-sidebar>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="main-content" [class.main-content-closed]="!sidenavOpened()">
|
||||
<!-- Top Navigation Bar -->
|
||||
<mat-toolbar class="top-toolbar">
|
||||
<button mat-icon-button (click)="toggleSidenav()" class="mr-2">
|
||||
<mat-icon>menu</mat-icon>
|
||||
</button> <span class="text-lg font-semibold text-gray-800">{{ title }}</span>
|
||||
<span class="spacer"></span>
|
||||
<app-notifications></app-notifications>
|
||||
<button mat-icon-button>
|
||||
<mat-icon>account_circle</mat-icon>
|
||||
</button>
|
||||
</mat-toolbar>
|
||||
|
||||
<!-- Page Content -->
|
||||
<div class="page-content">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 256px; /* Width of sidebar */
|
||||
transition: margin-left 0.3s ease;
|
||||
}
|
||||
|
||||
.main-content-closed {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.top-toolbar {
|
||||
background-color: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
18
apps/dashboard/src/app/app.routes.ts
Normal file
18
apps/dashboard/src/app/app.routes.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Routes } from '@angular/router';
|
||||
import { DashboardComponent } from './pages/dashboard/dashboard.component';
|
||||
import { MarketDataComponent } from './pages/market-data/market-data.component';
|
||||
import { PortfolioComponent } from './pages/portfolio/portfolio.component';
|
||||
import { RiskManagementComponent } from './pages/risk-management/risk-management.component';
|
||||
import { SettingsComponent } from './pages/settings/settings.component';
|
||||
import { StrategiesComponent } from './pages/strategies/strategies.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
|
||||
{ path: 'dashboard', component: DashboardComponent },
|
||||
{ path: 'market-data', component: MarketDataComponent },
|
||||
{ path: 'portfolio', component: PortfolioComponent },
|
||||
{ path: 'strategies', component: StrategiesComponent },
|
||||
{ path: 'risk-management', component: RiskManagementComponent },
|
||||
{ path: 'settings', component: SettingsComponent },
|
||||
{ path: '**', redirectTo: '/dashboard' },
|
||||
];
|
||||
25
apps/dashboard/src/app/app.spec.ts
Normal file
25
apps/dashboard/src/app/app.spec.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, trading-dashboard');
|
||||
});
|
||||
});
|
||||
40
apps/dashboard/src/app/app.ts
Normal file
40
apps/dashboard/src/app/app.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { NotificationsComponent } from './components/notifications/notifications';
|
||||
import { SidebarComponent } from './components/sidebar/sidebar.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
CommonModule,
|
||||
MatSidenavModule,
|
||||
MatToolbarModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
SidebarComponent,
|
||||
NotificationsComponent,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css',
|
||||
})
|
||||
export class App {
|
||||
protected title = 'Trading Dashboard';
|
||||
protected sidenavOpened = signal(true);
|
||||
|
||||
toggleSidenav() {
|
||||
this.sidenavOpened.set(!this.sidenavOpened());
|
||||
}
|
||||
|
||||
onNavigationClick(route: string) {
|
||||
// Handle navigation if needed
|
||||
console.log('Navigating to:', route);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
::ng-deep .notification-menu {
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
padding: 12px 16px !important;
|
||||
height: auto !important;
|
||||
line-height: normal !important;
|
||||
}
|
||||
|
||||
.notification-empty {
|
||||
padding: 16px !important;
|
||||
height: auto !important;
|
||||
line-height: normal !important;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 12px 16px !important;
|
||||
height: auto !important;
|
||||
line-height: normal !important;
|
||||
white-space: normal !important;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background-color: #f0f9ff;
|
||||
border-left-color: #0ea5e9;
|
||||
}
|
||||
|
||||
.notification-item.unread .font-medium {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="notificationMenu"
|
||||
[matBadge]="unreadCount"
|
||||
[matBadgeHidden]="unreadCount === 0"
|
||||
matBadgeColor="warn"
|
||||
matBadgeSize="small">
|
||||
<mat-icon>notifications</mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-menu #notificationMenu="matMenu" class="notification-menu">
|
||||
<div mat-menu-item disabled class="notification-header">
|
||||
<div class="flex items-center justify-between w-full px-2">
|
||||
<span class="font-semibold">Notifications</span>
|
||||
@if (notifications.length > 0) {
|
||||
<div class="flex gap-2">
|
||||
<button mat-button (click)="markAllAsRead()" class="text-xs">
|
||||
Mark all read
|
||||
</button>
|
||||
<button mat-button (click)="clearAll()" class="text-xs">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
@if (notifications.length === 0) {
|
||||
<div mat-menu-item disabled class="notification-empty">
|
||||
<div class="text-center py-4 text-gray-500">
|
||||
<mat-icon class="text-2xl">notifications_none</mat-icon>
|
||||
<p class="mt-1 text-sm">No notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
@for (notification of notifications.slice(0, 5); track notification.id) {
|
||||
<div
|
||||
mat-menu-item
|
||||
class="notification-item"
|
||||
[class.unread]="!notification.read"
|
||||
(click)="markAsRead(notification)">
|
||||
<div class="flex items-start gap-3 w-full">
|
||||
<mat-icon [class]="getNotificationColor(notification.type)" class="mt-1">
|
||||
{{ getNotificationIcon(notification.type) }}
|
||||
</mat-icon>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-medium text-sm truncate">{{ notification.title }}</p>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="clearNotification(notification); $event.stopPropagation()"
|
||||
class="text-gray-400 hover:text-gray-600 ml-2">
|
||||
<mat-icon class="text-lg">close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-gray-600 text-xs mt-1 line-clamp-2">{{ notification.message }}</p>
|
||||
<p class="text-gray-400 text-xs mt-1">{{ formatTime(notification.timestamp) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (!$last) {
|
||||
<mat-divider></mat-divider>
|
||||
}
|
||||
}
|
||||
|
||||
@if (notifications.length > 5) {
|
||||
<mat-divider></mat-divider>
|
||||
<div mat-menu-item disabled class="text-center text-sm text-gray-500">
|
||||
{{ notifications.length - 5 }} more notifications...
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</mat-menu>
|
||||
100
apps/dashboard/src/app/components/notifications/notifications.ts
Normal file
100
apps/dashboard/src/app/components/notifications/notifications.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { Notification, NotificationService } from '../../services/notification.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notifications',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatBadgeModule,
|
||||
MatMenuModule,
|
||||
MatListModule,
|
||||
MatDividerModule,
|
||||
],
|
||||
templateUrl: './notifications.html',
|
||||
styleUrl: './notifications.css',
|
||||
})
|
||||
export class NotificationsComponent {
|
||||
private notificationService = inject(NotificationService);
|
||||
|
||||
get notifications() {
|
||||
return this.notificationService.notifications();
|
||||
}
|
||||
|
||||
get unreadCount() {
|
||||
return this.notificationService.unreadCount();
|
||||
}
|
||||
|
||||
markAsRead(notification: Notification) {
|
||||
this.notificationService.markAsRead(notification.id);
|
||||
}
|
||||
|
||||
markAllAsRead() {
|
||||
this.notificationService.markAllAsRead();
|
||||
}
|
||||
|
||||
clearNotification(notification: Notification) {
|
||||
this.notificationService.clearNotification(notification.id);
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this.notificationService.clearAllNotifications();
|
||||
}
|
||||
|
||||
getNotificationIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'warning':
|
||||
return 'warning';
|
||||
case 'success':
|
||||
return 'check_circle';
|
||||
case 'info':
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
getNotificationColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return 'text-red-600';
|
||||
case 'warning':
|
||||
return 'text-yellow-600';
|
||||
case 'success':
|
||||
return 'text-green-600';
|
||||
case 'info':
|
||||
default:
|
||||
return 'text-blue-600';
|
||||
}
|
||||
}
|
||||
|
||||
formatTime(timestamp: Date): string {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - timestamp.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/* Sidebar specific styles */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 16rem; /* 256px */
|
||||
height: 100vh;
|
||||
background-color: white;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
transform: translateX(0);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-closed {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.nav-button.bg-blue-50 {
|
||||
background-color: #eff6ff !important;
|
||||
color: #1d4ed8 !important;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<!-- Sidebar Navigation -->
|
||||
<aside class="sidebar" [class.sidebar-closed]="!opened()">
|
||||
<!-- Logo/Brand -->
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-bold text-gray-900">
|
||||
📈 Trading Bot
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
Real-time Dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Menu -->
|
||||
<nav class="p-6">
|
||||
<div class="space-y-2">
|
||||
@for (item of navigationItems; track item.route) {
|
||||
<button
|
||||
mat-button
|
||||
class="nav-button"
|
||||
[class.bg-blue-50]="item.active"
|
||||
[class.text-blue-700]="item.active"
|
||||
[class.text-gray-600]="!item.active"
|
||||
(click)="onNavigationClick(item.route)">
|
||||
<mat-icon class="mr-3">{{ item.icon }}</mat-icon>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
|
||||
export interface NavigationItem {
|
||||
label: string;
|
||||
icon: string;
|
||||
route: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatSidenavModule, MatButtonModule, MatIconModule],
|
||||
templateUrl: './sidebar.component.html',
|
||||
styleUrl: './sidebar.component.css',
|
||||
})
|
||||
export class SidebarComponent {
|
||||
opened = input<boolean>(true);
|
||||
navigationItemClick = output<string>();
|
||||
|
||||
protected navigationItems: NavigationItem[] = [
|
||||
{ label: 'Dashboard', icon: 'dashboard', route: '/dashboard', active: true },
|
||||
{ label: 'Market Data', icon: 'trending_up', route: '/market-data' },
|
||||
{ label: 'Portfolio', icon: 'account_balance_wallet', route: '/portfolio' },
|
||||
{ label: 'Strategies', icon: 'psychology', route: '/strategies' },
|
||||
{ label: 'Risk Management', icon: 'security', route: '/risk-management' },
|
||||
{ label: 'Settings', icon: 'settings', route: '/settings' },
|
||||
];
|
||||
|
||||
constructor(private router: Router) {
|
||||
// Listen to route changes to update active state
|
||||
this.router.events
|
||||
.pipe(filter(event => event instanceof NavigationEnd))
|
||||
.subscribe((event: NavigationEnd) => {
|
||||
this.updateActiveRoute(event.urlAfterRedirects);
|
||||
});
|
||||
}
|
||||
|
||||
onNavigationClick(route: string) {
|
||||
this.navigationItemClick.emit(route);
|
||||
this.router.navigate([route]);
|
||||
this.updateActiveRoute(route);
|
||||
}
|
||||
|
||||
private updateActiveRoute(currentRoute: string) {
|
||||
this.navigationItems.forEach(item => {
|
||||
item.active = item.route === currentRoute;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/* Dashboard specific styles */
|
||||
.portfolio-card {
|
||||
background-color: white !important;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
border: 1px solid #f3f4f6;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.metric-change-positive {
|
||||
color: #16a34a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-change-negative {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-chip-active {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-chip-medium {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
154
apps/dashboard/src/app/pages/dashboard/dashboard.component.html
Normal file
154
apps/dashboard/src/app/pages/dashboard/dashboard.component.html
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<!-- Portfolio Summary Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<!-- Total Portfolio Value -->
|
||||
<mat-card class="portfolio-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="metric-label">Portfolio Value</p>
|
||||
<p class="metric-value text-gray-900">
|
||||
${{ portfolioValue().toLocaleString('en-US', {minimumFractionDigits: 2}) }}
|
||||
</p>
|
||||
</div>
|
||||
<mat-icon class="text-blue-600 text-3xl">account_balance_wallet</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Day Change -->
|
||||
<mat-card class="portfolio-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="metric-label">Day Change</p>
|
||||
<p class="metric-value"
|
||||
[class.metric-change-positive]="dayChange() > 0"
|
||||
[class.metric-change-negative]="dayChange() < 0">
|
||||
${{ dayChange().toLocaleString('en-US', {minimumFractionDigits: 2}) }}
|
||||
</p>
|
||||
<p class="text-sm font-medium"
|
||||
[class.metric-change-positive]="dayChangePercent() > 0"
|
||||
[class.metric-change-negative]="dayChangePercent() < 0">
|
||||
{{ dayChangePercent() > 0 ? '+' : '' }}{{ dayChangePercent().toFixed(2) }}%
|
||||
</p>
|
||||
</div>
|
||||
<mat-icon
|
||||
class="text-3xl"
|
||||
[class.metric-change-positive]="dayChange() > 0"
|
||||
[class.metric-change-negative]="dayChange() < 0">
|
||||
{{ dayChange() > 0 ? 'trending_up' : 'trending_down' }}
|
||||
</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Active Strategies -->
|
||||
<mat-card class="portfolio-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="metric-label">Active Strategies</p>
|
||||
<p class="metric-value text-gray-900">3</p>
|
||||
<span class="status-chip-active">Running</span>
|
||||
</div>
|
||||
<mat-icon class="text-purple-600 text-3xl">psychology</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Risk Level -->
|
||||
<mat-card class="portfolio-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="metric-label">Risk Level</p>
|
||||
<p class="metric-value text-green-600">Low</p>
|
||||
<span class="status-chip-medium">Moderate</span>
|
||||
</div>
|
||||
<mat-icon class="text-green-600 text-3xl">security</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- Market Data Table -->
|
||||
<mat-card class="p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Market Watchlist</h3>
|
||||
<button mat-raised-button color="primary">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table mat-table [dataSource]="marketData()" class="mat-elevation-z0 w-full">
|
||||
<!-- Symbol Column -->
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th mat-header-cell *matHeaderCellDef class="font-medium text-gray-900">Symbol</th>
|
||||
<td mat-cell *matCellDef="let stock" class="font-semibold text-gray-900">{{ stock.symbol }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Price Column -->
|
||||
<ng-container matColumnDef="price">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Price</th>
|
||||
<td mat-cell *matCellDef="let stock" class="text-right font-medium text-gray-900">
|
||||
${{ stock.price.toFixed(2) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Change Column -->
|
||||
<ng-container matColumnDef="change">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Change</th>
|
||||
<td mat-cell *matCellDef="let stock"
|
||||
class="text-right font-medium"
|
||||
[class.text-green-600]="stock.change > 0"
|
||||
[class.text-red-600]="stock.change < 0">
|
||||
{{ stock.change > 0 ? '+' : '' }}${{ stock.change.toFixed(2) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Change Percent Column -->
|
||||
<ng-container matColumnDef="changePercent">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Change %</th>
|
||||
<td mat-cell *matCellDef="let stock"
|
||||
class="text-right font-medium"
|
||||
[class.text-green-600]="stock.changePercent > 0"
|
||||
[class.text-red-600]="stock.changePercent < 0">
|
||||
{{ stock.changePercent > 0 ? '+' : '' }}{{ stock.changePercent.toFixed(2) }}%
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Tabs for Additional Content -->
|
||||
<mat-tab-group>
|
||||
<mat-tab label="Chart">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">show_chart</mat-icon>
|
||||
<p class="mb-4">Chart visualization will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Orders">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">receipt_long</mat-icon>
|
||||
<p class="mb-4">Order history and management will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Analytics">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">analytics</mat-icon>
|
||||
<p class="mb-4">Advanced analytics and performance metrics will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
|
||||
export interface MarketDataItem {
|
||||
symbol: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatTabsModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTableModule,
|
||||
],
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrl: './dashboard.component.css',
|
||||
})
|
||||
export class DashboardComponent {
|
||||
// Mock data for the dashboard
|
||||
protected marketData = signal<MarketDataItem[]>([
|
||||
{ symbol: 'AAPL', price: 192.53, change: 2.41, changePercent: 1.27 },
|
||||
{ symbol: 'GOOGL', price: 138.21, change: -1.82, changePercent: -1.3 },
|
||||
{ symbol: 'MSFT', price: 378.85, change: 4.12, changePercent: 1.1 },
|
||||
{ symbol: 'TSLA', price: 248.42, change: -3.21, changePercent: -1.28 },
|
||||
]);
|
||||
|
||||
protected portfolioValue = signal(125420.5);
|
||||
protected dayChange = signal(2341.2);
|
||||
protected dayChangePercent = signal(1.9);
|
||||
|
||||
protected displayedColumns: string[] = ['symbol', 'price', 'change', 'changePercent'];
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/* Market Data specific styles */
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
<div class="space-y-6"> <!-- Page Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Market Data</h1>
|
||||
<p class="text-gray-600 mt-1">Real-time market information and analytics</p>
|
||||
</div>
|
||||
<button mat-raised-button color="primary" (click)="refreshData()" [disabled]="isLoading()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
@if (isLoading()) {
|
||||
<mat-spinner diameter="20" class="mr-2"></mat-spinner>
|
||||
}
|
||||
Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Market Overview Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Market Status</p>
|
||||
<p class="text-lg font-semibold text-green-600">Open</p>
|
||||
</div>
|
||||
<mat-icon class="text-green-600 text-3xl">schedule</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Active Instruments</p>
|
||||
<p class="text-lg font-semibold text-gray-900">{{ marketData().length }}</p>
|
||||
</div>
|
||||
<mat-icon class="text-blue-600 text-3xl">trending_up</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between"> <div>
|
||||
<p class="text-sm text-gray-600">Last Update</p>
|
||||
<p class="text-lg font-semibold text-gray-900">{{ currentTime() }}</p>
|
||||
</div>
|
||||
<mat-icon class="text-purple-600 text-3xl">access_time</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- Market Data Table -->
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Live Market Data</h3>
|
||||
<div class="flex gap-2">
|
||||
<button mat-button>
|
||||
<mat-icon>filter_list</mat-icon>
|
||||
Filter
|
||||
</button>
|
||||
<button mat-button>
|
||||
<mat-icon>file_download</mat-icon>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (isLoading()) {
|
||||
<div class="flex justify-center items-center py-8">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span class="ml-3 text-gray-600">Loading market data...</span>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="text-center py-8">
|
||||
<mat-icon class="text-red-500 text-4xl">error</mat-icon>
|
||||
<p class="text-red-600 mt-2">{{ error() }}</p>
|
||||
<button mat-button color="primary" (click)="refreshData()" class="mt-2">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="overflow-x-auto">
|
||||
<table mat-table [dataSource]="marketData()" class="mat-elevation-z0 w-full">
|
||||
<!-- Symbol Column -->
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th mat-header-cell *matHeaderCellDef class="font-medium text-gray-900">Symbol</th>
|
||||
<td mat-cell *matCellDef="let stock" class="font-semibold text-gray-900">{{ stock.symbol }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Price Column -->
|
||||
<ng-container matColumnDef="price">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Price</th>
|
||||
<td mat-cell *matCellDef="let stock" class="text-right font-medium text-gray-900">
|
||||
${{ stock.price.toFixed(2) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Change Column -->
|
||||
<ng-container matColumnDef="change">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Change</th>
|
||||
<td mat-cell *matCellDef="let stock"
|
||||
class="text-right font-medium"
|
||||
[class.text-green-600]="stock.change > 0"
|
||||
[class.text-red-600]="stock.change < 0">
|
||||
{{ stock.change > 0 ? '+' : '' }}${{ stock.change.toFixed(2) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Change Percent Column -->
|
||||
<ng-container matColumnDef="changePercent">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Change %</th>
|
||||
<td mat-cell *matCellDef="let stock"
|
||||
class="text-right font-medium"
|
||||
[class.text-green-600]="stock.changePercent > 0"
|
||||
[class.text-red-600]="stock.changePercent < 0">
|
||||
{{ stock.changePercent > 0 ? '+' : '' }}{{ stock.changePercent.toFixed(2) }}%
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Volume Column -->
|
||||
<ng-container matColumnDef="volume">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Volume</th>
|
||||
<td mat-cell *matCellDef="let stock" class="text-right text-gray-600">
|
||||
{{ stock.volume.toLocaleString() }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Market Cap Column -->
|
||||
<ng-container matColumnDef="marketCap">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Market Cap</th>
|
||||
<td mat-cell *matCellDef="let stock" class="text-right text-gray-600">
|
||||
${{ stock.marketCap }}
|
||||
</td>
|
||||
</ng-container> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
|
||||
<!-- Market Analytics Tabs -->
|
||||
<mat-tab-group>
|
||||
<mat-tab label="Technical Analysis">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">bar_chart</mat-icon>
|
||||
<p class="mb-4">Technical analysis charts and indicators will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Market Trends">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">timeline</mat-icon>
|
||||
<p class="mb-4">Market trends and sector analysis will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="News & Events">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">article</mat-icon>
|
||||
<p class="mb-4">Market news and economic events will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
import { WebSocketService } from '../../services/websocket.service';
|
||||
|
||||
export interface ExtendedMarketData {
|
||||
symbol: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
volume: number;
|
||||
marketCap: string;
|
||||
high52Week: number;
|
||||
low52Week: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-market-data',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTableModule,
|
||||
MatTabsModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule,
|
||||
],
|
||||
templateUrl: './market-data.component.html',
|
||||
styleUrl: './market-data.component.css',
|
||||
})
|
||||
export class MarketDataComponent implements OnInit, OnDestroy {
|
||||
private apiService = inject(ApiService);
|
||||
private webSocketService = inject(WebSocketService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private subscriptions: Subscription[] = [];
|
||||
|
||||
protected marketData = signal<ExtendedMarketData[]>([]);
|
||||
protected currentTime = signal<string>(new Date().toLocaleTimeString());
|
||||
protected isLoading = signal<boolean>(true);
|
||||
protected error = signal<string | null>(null);
|
||||
protected displayedColumns: string[] = [
|
||||
'symbol',
|
||||
'price',
|
||||
'change',
|
||||
'changePercent',
|
||||
'volume',
|
||||
'marketCap',
|
||||
];
|
||||
ngOnInit() {
|
||||
// Update time every second
|
||||
const timeSubscription = interval(1000).subscribe(() => {
|
||||
this.currentTime.set(new Date().toLocaleTimeString());
|
||||
});
|
||||
this.subscriptions.push(timeSubscription);
|
||||
|
||||
// Load initial market data
|
||||
this.loadMarketData();
|
||||
|
||||
// Subscribe to real-time market data updates
|
||||
const wsSubscription = this.webSocketService.getMarketDataUpdates().subscribe({
|
||||
next: update => {
|
||||
this.updateMarketData(update);
|
||||
},
|
||||
error: err => {
|
||||
console.error('WebSocket market data error:', err);
|
||||
},
|
||||
});
|
||||
this.subscriptions.push(wsSubscription);
|
||||
|
||||
// Fallback: Refresh market data every 30 seconds if WebSocket fails
|
||||
const dataSubscription = interval(30000).subscribe(() => {
|
||||
if (!this.webSocketService.isConnected()) {
|
||||
this.loadMarketData();
|
||||
}
|
||||
});
|
||||
this.subscriptions.push(dataSubscription);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
private loadMarketData() {
|
||||
this.apiService.getMarketData().subscribe({
|
||||
next: response => {
|
||||
// Convert MarketData to ExtendedMarketData with mock extended properties
|
||||
const extendedData: ExtendedMarketData[] = response.data.map(item => ({
|
||||
...item,
|
||||
marketCap: this.getMockMarketCap(item.symbol),
|
||||
high52Week: item.price * 1.3, // Mock 52-week high (30% above current)
|
||||
low52Week: item.price * 0.7, // Mock 52-week low (30% below current)
|
||||
}));
|
||||
|
||||
this.marketData.set(extendedData);
|
||||
this.isLoading.set(false);
|
||||
this.error.set(null);
|
||||
},
|
||||
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 });
|
||||
|
||||
// Use mock data as fallback
|
||||
this.marketData.set(this.getMockData());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private getMockMarketCap(symbol: string): string {
|
||||
const marketCaps: { [key: string]: string } = {
|
||||
AAPL: '2.98T',
|
||||
GOOGL: '1.78T',
|
||||
MSFT: '3.08T',
|
||||
TSLA: '789.2B',
|
||||
AMZN: '1.59T',
|
||||
};
|
||||
return marketCaps[symbol] || '1.00T';
|
||||
}
|
||||
|
||||
private getMockData(): ExtendedMarketData[] {
|
||||
return [
|
||||
{
|
||||
symbol: 'AAPL',
|
||||
price: 192.53,
|
||||
change: 2.41,
|
||||
changePercent: 1.27,
|
||||
volume: 45230000,
|
||||
marketCap: '2.98T',
|
||||
high52Week: 199.62,
|
||||
low52Week: 164.08,
|
||||
},
|
||||
{
|
||||
symbol: 'GOOGL',
|
||||
price: 2847.56,
|
||||
change: -12.34,
|
||||
changePercent: -0.43,
|
||||
volume: 12450000,
|
||||
marketCap: '1.78T',
|
||||
high52Week: 3030.93,
|
||||
low52Week: 2193.62,
|
||||
},
|
||||
{
|
||||
symbol: 'MSFT',
|
||||
price: 415.26,
|
||||
change: 8.73,
|
||||
changePercent: 2.15,
|
||||
volume: 23180000,
|
||||
marketCap: '3.08T',
|
||||
high52Week: 468.35,
|
||||
low52Week: 309.45,
|
||||
},
|
||||
{
|
||||
symbol: 'TSLA',
|
||||
price: 248.5,
|
||||
change: -5.21,
|
||||
changePercent: -2.05,
|
||||
volume: 89760000,
|
||||
marketCap: '789.2B',
|
||||
high52Week: 299.29,
|
||||
low52Week: 152.37,
|
||||
},
|
||||
{
|
||||
symbol: 'AMZN',
|
||||
price: 152.74,
|
||||
change: 3.18,
|
||||
changePercent: 2.12,
|
||||
volume: 34520000,
|
||||
marketCap: '1.59T',
|
||||
high52Week: 170.17,
|
||||
low52Week: 118.35,
|
||||
},
|
||||
];
|
||||
}
|
||||
refreshData() {
|
||||
this.isLoading.set(true);
|
||||
this.loadMarketData();
|
||||
}
|
||||
|
||||
private updateMarketData(update: any) {
|
||||
const currentData = this.marketData();
|
||||
const updatedData = currentData.map(item => {
|
||||
if (item.symbol === update.symbol) {
|
||||
return {
|
||||
...item,
|
||||
price: update.price,
|
||||
change: update.change,
|
||||
changePercent: update.changePercent,
|
||||
volume: update.volume,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
this.marketData.set(updatedData);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/* Portfolio specific styles */
|
||||
203
apps/dashboard/src/app/pages/portfolio/portfolio.component.html
Normal file
203
apps/dashboard/src/app/pages/portfolio/portfolio.component.html
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Portfolio</h1>
|
||||
<p class="text-gray-600 mt-1">Manage and monitor your investment portfolio</p>
|
||||
</div>
|
||||
<button mat-raised-button color="primary" (click)="refreshData()" [disabled]="isLoading()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
@if (isLoading()) {
|
||||
<mat-spinner diameter="20" class="mr-2"></mat-spinner>
|
||||
}
|
||||
Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Portfolio Summary Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Total Value</p>
|
||||
<p class="text-lg font-semibold text-gray-900">${{ portfolioSummary().totalValue.toLocaleString() }}</p>
|
||||
</div>
|
||||
<mat-icon class="text-blue-600 text-3xl">account_balance_wallet</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Total P&L</p>
|
||||
<p class="text-lg font-semibold" [class]="getPnLColor(portfolioSummary().totalPnL)">
|
||||
{{ portfolioSummary().totalPnL > 0 ? '+' : '' }}${{ portfolioSummary().totalPnL.toLocaleString() }}
|
||||
({{ portfolioSummary().totalPnLPercent.toFixed(2) }}%)
|
||||
</p>
|
||||
</div>
|
||||
<mat-icon class="text-green-600 text-3xl" *ngIf="portfolioSummary().totalPnL >= 0">trending_up</mat-icon>
|
||||
<mat-icon class="text-red-600 text-3xl" *ngIf="portfolioSummary().totalPnL < 0">trending_down</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Day Change</p>
|
||||
<p class="text-lg font-semibold" [class]="getPnLColor(portfolioSummary().dayChange)">
|
||||
{{ portfolioSummary().dayChange > 0 ? '+' : '' }}${{ portfolioSummary().dayChange.toLocaleString() }}
|
||||
({{ portfolioSummary().dayChangePercent.toFixed(2) }}%)
|
||||
</p>
|
||||
</div>
|
||||
<mat-icon class="text-purple-600 text-3xl">today</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Cash Available</p>
|
||||
<p class="text-lg font-semibold text-gray-900">${{ portfolioSummary().cash.toLocaleString() }}</p>
|
||||
</div>
|
||||
<mat-icon class="text-yellow-600 text-3xl">attach_money</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- Portfolio Tabs -->
|
||||
<mat-tab-group>
|
||||
<mat-tab label="Positions">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Current Positions</h3>
|
||||
<div class="flex gap-2">
|
||||
<button mat-button>
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Position
|
||||
</button>
|
||||
<button mat-button>
|
||||
<mat-icon>file_download</mat-icon>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="flex justify-center items-center py-8">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span class="ml-3 text-gray-600">Loading portfolio...</span>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="text-center py-8">
|
||||
<mat-icon class="text-red-500 text-4xl">error</mat-icon>
|
||||
<p class="text-red-600 mt-2">{{ error() }}</p>
|
||||
<button mat-button color="primary" (click)="refreshData()" class="mt-2">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
} @else if (positions().length === 0) {
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<mat-icon class="text-4xl">account_balance_wallet</mat-icon>
|
||||
<p class="mt-2">No positions found</p>
|
||||
<button mat-button color="primary" class="mt-2">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Your First Position
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="overflow-x-auto">
|
||||
<table mat-table [dataSource]="positions()" class="mat-elevation-z0 w-full">
|
||||
<!-- Symbol Column -->
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th mat-header-cell *matHeaderCellDef class="font-medium text-gray-900">Symbol</th>
|
||||
<td mat-cell *matCellDef="let position" class="font-semibold text-gray-900">{{ position.symbol }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Quantity Column -->
|
||||
<ng-container matColumnDef="quantity">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Quantity</th>
|
||||
<td mat-cell *matCellDef="let position" class="text-right text-gray-600">
|
||||
{{ position.quantity.toLocaleString() }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Average Price Column -->
|
||||
<ng-container matColumnDef="avgPrice">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Avg Price</th>
|
||||
<td mat-cell *matCellDef="let position" class="text-right text-gray-600">
|
||||
${{ position.avgPrice.toFixed(2) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Current Price Column -->
|
||||
<ng-container matColumnDef="currentPrice">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Current Price</th>
|
||||
<td mat-cell *matCellDef="let position" class="text-right font-medium text-gray-900">
|
||||
${{ position.currentPrice.toFixed(2) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Market Value Column -->
|
||||
<ng-container matColumnDef="marketValue">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Market Value</th>
|
||||
<td mat-cell *matCellDef="let position" class="text-right font-medium text-gray-900">
|
||||
${{ position.marketValue.toLocaleString() }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Unrealized P&L Column -->
|
||||
<ng-container matColumnDef="unrealizedPnL">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Unrealized P&L</th>
|
||||
<td mat-cell *matCellDef="let position"
|
||||
class="text-right font-medium"
|
||||
[class]="getPnLColor(position.unrealizedPnL)">
|
||||
{{ position.unrealizedPnL > 0 ? '+' : '' }}${{ position.unrealizedPnL.toLocaleString() }}
|
||||
({{ position.unrealizedPnLPercent.toFixed(2) }}%)
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Day Change Column -->
|
||||
<ng-container matColumnDef="dayChange">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Day Change</th>
|
||||
<td mat-cell *matCellDef="let position"
|
||||
class="text-right font-medium"
|
||||
[class]="getPnLColor(position.dayChange)">
|
||||
{{ position.dayChange > 0 ? '+' : '' }}${{ position.dayChange.toFixed(2) }}
|
||||
({{ position.dayChangePercent.toFixed(2) }}%)
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Performance">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">trending_up</mat-icon>
|
||||
<p class="mb-4">Performance charts and analytics will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Orders">
|
||||
<div class="p-6">
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">receipt</mat-icon>
|
||||
<p class="mb-4">Order history and management will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
172
apps/dashboard/src/app/pages/portfolio/portfolio.component.ts
Normal file
172
apps/dashboard/src/app/pages/portfolio/portfolio.component.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
import { ApiService } from '../../services/api.service';
|
||||
|
||||
export interface Position {
|
||||
symbol: string;
|
||||
quantity: number;
|
||||
avgPrice: number;
|
||||
currentPrice: number;
|
||||
marketValue: number;
|
||||
unrealizedPnL: number;
|
||||
unrealizedPnLPercent: number;
|
||||
dayChange: number;
|
||||
dayChangePercent: number;
|
||||
}
|
||||
|
||||
export interface PortfolioSummary {
|
||||
totalValue: number;
|
||||
totalCost: number;
|
||||
totalPnL: number;
|
||||
totalPnLPercent: number;
|
||||
dayChange: number;
|
||||
dayChangePercent: number;
|
||||
cash: number;
|
||||
positionsCount: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-portfolio',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTableModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule,
|
||||
MatTabsModule,
|
||||
],
|
||||
templateUrl: './portfolio.component.html',
|
||||
styleUrl: './portfolio.component.css',
|
||||
})
|
||||
export class PortfolioComponent implements OnInit, OnDestroy {
|
||||
private apiService = inject(ApiService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private subscriptions: Subscription[] = [];
|
||||
|
||||
protected portfolioSummary = signal<PortfolioSummary>({
|
||||
totalValue: 0,
|
||||
totalCost: 0,
|
||||
totalPnL: 0,
|
||||
totalPnLPercent: 0,
|
||||
dayChange: 0,
|
||||
dayChangePercent: 0,
|
||||
cash: 0,
|
||||
positionsCount: 0,
|
||||
});
|
||||
|
||||
protected positions = signal<Position[]>([]);
|
||||
protected isLoading = signal<boolean>(true);
|
||||
protected error = signal<string | null>(null);
|
||||
protected displayedColumns = [
|
||||
'symbol',
|
||||
'quantity',
|
||||
'avgPrice',
|
||||
'currentPrice',
|
||||
'marketValue',
|
||||
'unrealizedPnL',
|
||||
'dayChange',
|
||||
];
|
||||
|
||||
ngOnInit() {
|
||||
this.loadPortfolioData();
|
||||
|
||||
// Refresh portfolio data every 30 seconds
|
||||
const portfolioSubscription = interval(30000).subscribe(() => {
|
||||
this.loadPortfolioData();
|
||||
});
|
||||
this.subscriptions.push(portfolioSubscription);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
|
||||
private loadPortfolioData() {
|
||||
// Since we don't have a portfolio endpoint yet, let's create mock data
|
||||
// In a real implementation, this would call this.apiService.getPortfolio()
|
||||
|
||||
setTimeout(() => {
|
||||
const mockPositions: Position[] = [
|
||||
{
|
||||
symbol: 'AAPL',
|
||||
quantity: 100,
|
||||
avgPrice: 180.5,
|
||||
currentPrice: 192.53,
|
||||
marketValue: 19253,
|
||||
unrealizedPnL: 1203,
|
||||
unrealizedPnLPercent: 6.67,
|
||||
dayChange: 241,
|
||||
dayChangePercent: 1.27,
|
||||
},
|
||||
{
|
||||
symbol: 'MSFT',
|
||||
quantity: 50,
|
||||
avgPrice: 400.0,
|
||||
currentPrice: 415.26,
|
||||
marketValue: 20763,
|
||||
unrealizedPnL: 763,
|
||||
unrealizedPnLPercent: 3.82,
|
||||
dayChange: 436.5,
|
||||
dayChangePercent: 2.15,
|
||||
},
|
||||
{
|
||||
symbol: 'GOOGL',
|
||||
quantity: 10,
|
||||
avgPrice: 2900.0,
|
||||
currentPrice: 2847.56,
|
||||
marketValue: 28475.6,
|
||||
unrealizedPnL: -524.4,
|
||||
unrealizedPnLPercent: -1.81,
|
||||
dayChange: -123.4,
|
||||
dayChangePercent: -0.43,
|
||||
},
|
||||
];
|
||||
|
||||
const summary: PortfolioSummary = {
|
||||
totalValue: mockPositions.reduce((sum, pos) => sum + pos.marketValue, 0) + 25000, // + cash
|
||||
totalCost: mockPositions.reduce((sum, pos) => sum + pos.avgPrice * pos.quantity, 0),
|
||||
totalPnL: mockPositions.reduce((sum, pos) => sum + pos.unrealizedPnL, 0),
|
||||
totalPnLPercent: 0,
|
||||
dayChange: mockPositions.reduce((sum, pos) => sum + pos.dayChange, 0),
|
||||
dayChangePercent: 0,
|
||||
cash: 25000,
|
||||
positionsCount: mockPositions.length,
|
||||
};
|
||||
|
||||
summary.totalPnLPercent = (summary.totalPnL / summary.totalCost) * 100;
|
||||
summary.dayChangePercent =
|
||||
(summary.dayChange / (summary.totalValue - summary.dayChange)) * 100;
|
||||
|
||||
this.positions.set(mockPositions);
|
||||
this.portfolioSummary.set(summary);
|
||||
this.isLoading.set(false);
|
||||
this.error.set(null);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/* Risk Management specific styles */
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Risk Management</h1>
|
||||
<p class="text-gray-600 mt-1">Monitor and control trading risks and exposure</p>
|
||||
</div>
|
||||
<button mat-raised-button color="primary" (click)="refreshData()" [disabled]="isLoading()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
@if (isLoading()) {
|
||||
<mat-spinner diameter="20" class="mr-2"></mat-spinner>
|
||||
}
|
||||
Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Risk Overview Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
@if (riskThresholds(); as thresholds) {
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Max Position Size</p>
|
||||
<p class="text-lg font-semibold text-gray-900">${{ thresholds.maxPositionSize.toLocaleString() }}</p>
|
||||
</div>
|
||||
<mat-icon class="text-blue-600 text-3xl">account_balance</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Max Daily Loss</p>
|
||||
<p class="text-lg font-semibold text-red-600">${{ thresholds.maxDailyLoss.toLocaleString() }}</p>
|
||||
</div>
|
||||
<mat-icon class="text-red-600 text-3xl">trending_down</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Portfolio Risk Limit</p>
|
||||
<p class="text-lg font-semibold text-yellow-600">{{ (thresholds.maxPortfolioRisk * 100).toFixed(1) }}%</p>
|
||||
</div>
|
||||
<mat-icon class="text-yellow-600 text-3xl">pie_chart</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Volatility Limit</p>
|
||||
<p class="text-lg font-semibold text-purple-600">{{ (thresholds.volatilityLimit * 100).toFixed(1) }}%</p>
|
||||
</div>
|
||||
<mat-icon class="text-purple-600 text-3xl">show_chart</mat-icon>
|
||||
</div>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Risk Thresholds Configuration -->
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Risk Thresholds Configuration</h3>
|
||||
</div>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="flex justify-center items-center py-8">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span class="ml-3 text-gray-600">Loading risk settings...</span>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="text-center py-8">
|
||||
<mat-icon class="text-red-500 text-4xl">error</mat-icon>
|
||||
<p class="text-red-600 mt-2">{{ error() }}</p>
|
||||
<button mat-button color="primary" (click)="refreshData()" class="mt-2">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="thresholdsForm" (ngSubmit)="saveThresholds()">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Max Position Size ($)</mat-label>
|
||||
<input matInput type="number" formControlName="maxPositionSize" placeholder="100000">
|
||||
<mat-icon matSuffix>attach_money</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Max Daily Loss ($)</mat-label>
|
||||
<input matInput type="number" formControlName="maxDailyLoss" placeholder="5000">
|
||||
<mat-icon matSuffix>trending_down</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Max Portfolio Risk (0-1)</mat-label>
|
||||
<input matInput type="number" step="0.01" formControlName="maxPortfolioRisk" placeholder="0.1">
|
||||
<mat-icon matSuffix>pie_chart</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Volatility Limit (0-1)</mat-label>
|
||||
<input matInput type="number" step="0.01" formControlName="volatilityLimit" placeholder="0.3">
|
||||
<mat-icon matSuffix>show_chart</mat-icon>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<button mat-raised-button color="primary" type="submit" [disabled]="thresholdsForm.invalid || isSaving()">
|
||||
@if (isSaving()) {
|
||||
<mat-spinner diameter="20" class="mr-2"></mat-spinner>
|
||||
}
|
||||
Save Thresholds
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</mat-card>
|
||||
|
||||
<!-- Risk History Table -->
|
||||
<mat-card class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Recent Risk Evaluations</h3>
|
||||
</div>
|
||||
|
||||
@if (riskHistory().length === 0) {
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<mat-icon class="text-4xl">history</mat-icon>
|
||||
<p class="mt-2">No risk evaluations found</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="overflow-x-auto">
|
||||
<table mat-table [dataSource]="riskHistory()" class="mat-elevation-z0 w-full">
|
||||
<!-- Symbol Column -->
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th mat-header-cell *matHeaderCellDef class="font-medium text-gray-900">Symbol</th>
|
||||
<td mat-cell *matCellDef="let risk" class="font-semibold text-gray-900">{{ risk.symbol }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Position Value Column -->
|
||||
<ng-container matColumnDef="positionValue">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-right font-medium text-gray-900">Position Value</th>
|
||||
<td mat-cell *matCellDef="let risk" class="text-right font-medium text-gray-900">
|
||||
${{ risk.positionValue.toLocaleString() }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Risk Level Column -->
|
||||
<ng-container matColumnDef="riskLevel">
|
||||
<th mat-header-cell *matHeaderCellDef class="text-center font-medium text-gray-900">Risk Level</th>
|
||||
<td mat-cell *matCellDef="let risk" class="text-center">
|
||||
<span class="px-2 py-1 rounded-full text-sm font-medium" [class]="getRiskLevelColor(risk.riskLevel)">
|
||||
{{ risk.riskLevel }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Violations Column -->
|
||||
<ng-container matColumnDef="violations">
|
||||
<th mat-header-cell *matHeaderCellDef class="font-medium text-gray-900">Violations</th>
|
||||
<td mat-cell *matCellDef="let risk" class="text-gray-600">
|
||||
@if (risk.violations.length > 0) {
|
||||
<span class="text-red-600">{{ risk.violations.join(', ') }}</span>
|
||||
} @else {
|
||||
<span class="text-green-600">None</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { interval, Subscription } from 'rxjs';
|
||||
import { ApiService, RiskEvaluation, RiskThresholds } from '../../services/api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-risk-management',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTableModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSnackBarModule,
|
||||
MatProgressSpinnerModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
templateUrl: './risk-management.component.html',
|
||||
styleUrl: './risk-management.component.css',
|
||||
})
|
||||
export class RiskManagementComponent implements OnInit, OnDestroy {
|
||||
private apiService = inject(ApiService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private fb = inject(FormBuilder);
|
||||
private subscriptions: Subscription[] = [];
|
||||
|
||||
protected riskThresholds = signal<RiskThresholds | null>(null);
|
||||
protected riskHistory = signal<RiskEvaluation[]>([]);
|
||||
protected isLoading = signal<boolean>(true);
|
||||
protected isSaving = signal<boolean>(false);
|
||||
protected error = signal<string | null>(null);
|
||||
|
||||
protected thresholdsForm: FormGroup;
|
||||
protected displayedColumns = ['symbol', 'positionValue', 'riskLevel', 'violations', 'timestamp'];
|
||||
|
||||
constructor() {
|
||||
this.thresholdsForm = this.fb.group({
|
||||
maxPositionSize: [0, [Validators.required, Validators.min(0)]],
|
||||
maxDailyLoss: [0, [Validators.required, Validators.min(0)]],
|
||||
maxPortfolioRisk: [0, [Validators.required, Validators.min(0), Validators.max(1)]],
|
||||
volatilityLimit: [0, [Validators.required, Validators.min(0), Validators.max(1)]],
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.loadRiskThresholds();
|
||||
this.loadRiskHistory();
|
||||
|
||||
// Refresh risk history every 30 seconds
|
||||
const historySubscription = interval(30000).subscribe(() => {
|
||||
this.loadRiskHistory();
|
||||
});
|
||||
this.subscriptions.push(historySubscription);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
|
||||
private loadRiskThresholds() {
|
||||
this.apiService.getRiskThresholds().subscribe({
|
||||
next: response => {
|
||||
this.riskThresholds.set(response.data);
|
||||
this.thresholdsForm.patchValue(response.data);
|
||||
this.isLoading.set(false);
|
||||
this.error.set(null);
|
||||
},
|
||||
error: err => {
|
||||
console.error('Failed to load risk thresholds:', err);
|
||||
this.error.set('Failed to load risk thresholds');
|
||||
this.isLoading.set(false);
|
||||
this.snackBar.open('Failed to load risk thresholds', 'Dismiss', { duration: 5000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private loadRiskHistory() {
|
||||
this.apiService.getRiskHistory().subscribe({
|
||||
next: response => {
|
||||
this.riskHistory.set(response.data);
|
||||
},
|
||||
error: err => {
|
||||
console.error('Failed to load risk history:', err);
|
||||
this.snackBar.open('Failed to load risk history', 'Dismiss', { duration: 3000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
saveThresholds() {
|
||||
if (this.thresholdsForm.valid) {
|
||||
this.isSaving.set(true);
|
||||
const thresholds = this.thresholdsForm.value as RiskThresholds;
|
||||
|
||||
this.apiService.updateRiskThresholds(thresholds).subscribe({
|
||||
next: response => {
|
||||
this.riskThresholds.set(response.data);
|
||||
this.isSaving.set(false);
|
||||
this.snackBar.open('Risk thresholds updated successfully', 'Dismiss', { duration: 3000 });
|
||||
},
|
||||
error: err => {
|
||||
console.error('Failed to save risk thresholds:', err);
|
||||
this.isSaving.set(false);
|
||||
this.snackBar.open('Failed to save risk thresholds', 'Dismiss', { duration: 5000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
refreshData() {
|
||||
this.isLoading.set(true);
|
||||
this.loadRiskThresholds();
|
||||
this.loadRiskHistory();
|
||||
}
|
||||
|
||||
getRiskLevelColor(level: string): string {
|
||||
switch (level) {
|
||||
case 'LOW':
|
||||
return 'text-green-600';
|
||||
case 'MEDIUM':
|
||||
return 'text-yellow-600';
|
||||
case 'HIGH':
|
||||
return 'text-red-600';
|
||||
default:
|
||||
return 'text-gray-600';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/* Settings specific styles */
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Settings</h1>
|
||||
<p class="text-gray-600 mt-1">Configure application preferences and system settings</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-card class="p-6 h-96 flex items-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem;">settings</mat-icon>
|
||||
<p class="mb-4">Application settings and configuration will be implemented here</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
13
apps/dashboard/src/app/pages/settings/settings.component.ts
Normal file
13
apps/dashboard/src/app/pages/settings/settings.component.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatCardModule, MatIconModule],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.css',
|
||||
})
|
||||
export class SettingsComponent {}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { Chart, ChartOptions } from 'chart.js/auto';
|
||||
import { BacktestResult } from '../../../services/strategy.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-drawdown-chart',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="drawdown-chart-container">
|
||||
<canvas #drawdownChart></canvas>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.drawdown-chart-container {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class DrawdownChartComponent implements OnChanges {
|
||||
@Input() backtestResult?: BacktestResult;
|
||||
|
||||
private chart?: Chart;
|
||||
private chartElement?: HTMLCanvasElement;
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['backtestResult'] && this.backtestResult) {
|
||||
this.renderChart();
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.chartElement = document.querySelector('canvas') as HTMLCanvasElement;
|
||||
if (this.backtestResult) {
|
||||
this.renderChart();
|
||||
}
|
||||
}
|
||||
|
||||
private renderChart(): void {
|
||||
if (!this.chartElement || !this.backtestResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Create chart
|
||||
this.chart = new Chart(this.chartElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: drawdownData.dates.map(date => this.formatDate(date)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Drawdown',
|
||||
data: drawdownData.drawdowns,
|
||||
borderColor: 'rgba(255, 99, 132, 1)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.2)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxTicksLimit: 12,
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
callback: function (value) {
|
||||
return (value * 100).toFixed(1) + '%';
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(200, 200, 200, 0.2)',
|
||||
},
|
||||
min: -0.05, // Show at least 5% drawdown for context
|
||||
suggestedMax: 0.01,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += (context.parsed.y * 100).toFixed(2) + '%';
|
||||
}
|
||||
return label;
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
},
|
||||
},
|
||||
} as ChartOptions,
|
||||
});
|
||||
}
|
||||
|
||||
private calculateDrawdownSeries(result: BacktestResult): {
|
||||
dates: Date[];
|
||||
drawdowns: number[];
|
||||
} {
|
||||
const dates: Date[] = [];
|
||||
const drawdowns: number[] = [];
|
||||
|
||||
// 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[] = [];
|
||||
|
||||
for (const daily of sortedReturns) {
|
||||
equity *= 1 + daily.return;
|
||||
equityCurve.push(equity);
|
||||
dates.push(new Date(daily.date));
|
||||
}
|
||||
|
||||
// Calculate running maximum (high water mark)
|
||||
let hwm = equityCurve[0];
|
||||
|
||||
for (let i = 0; i < equityCurve.length; i++) {
|
||||
// Update high water mark
|
||||
hwm = Math.max(hwm, equityCurve[i]);
|
||||
// Calculate drawdown as percentage from high water mark
|
||||
const drawdown = equityCurve[i] / hwm - 1;
|
||||
drawdowns.push(drawdown);
|
||||
}
|
||||
|
||||
return { dates, drawdowns };
|
||||
}
|
||||
|
||||
private formatDate(date: Date): string {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { Chart, ChartOptions } from 'chart.js/auto';
|
||||
import { BacktestResult } from '../../../services/strategy.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-equity-chart',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="equity-chart-container">
|
||||
<canvas #equityChart></canvas>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.equity-chart-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class EquityChartComponent implements OnChanges {
|
||||
@Input() backtestResult?: BacktestResult;
|
||||
|
||||
private chart?: Chart;
|
||||
private chartElement?: HTMLCanvasElement;
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['backtestResult'] && this.backtestResult) {
|
||||
this.renderChart();
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.chartElement = document.querySelector('canvas') as HTMLCanvasElement;
|
||||
if (this.backtestResult) {
|
||||
this.renderChart();
|
||||
}
|
||||
}
|
||||
|
||||
private renderChart(): void {
|
||||
if (!this.chartElement || !this.backtestResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up previous chart if it exists
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
}
|
||||
|
||||
// Prepare data
|
||||
const equityCurve = this.calculateEquityCurve(this.backtestResult);
|
||||
|
||||
// Create chart
|
||||
this.chart = new Chart(this.chartElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: equityCurve.dates.map(date => this.formatDate(date)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Portfolio Value',
|
||||
data: equityCurve.values,
|
||||
borderColor: 'rgba(75, 192, 192, 1)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
label: 'Benchmark',
|
||||
data: equityCurve.benchmark,
|
||||
borderColor: 'rgba(153, 102, 255, 0.5)',
|
||||
backgroundColor: 'rgba(153, 102, 255, 0.1)',
|
||||
borderDash: [5, 5],
|
||||
tension: 0.3,
|
||||
borderWidth: 1,
|
||||
fill: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
maxTicksLimit: 12,
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
callback: function (value) {
|
||||
return '$' + value.toLocaleString();
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(200, 200, 200, 0.2)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(context.parsed.y);
|
||||
}
|
||||
return label;
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
},
|
||||
},
|
||||
} as ChartOptions,
|
||||
});
|
||||
}
|
||||
|
||||
private calculateEquityCurve(result: BacktestResult): {
|
||||
dates: Date[];
|
||||
values: number[];
|
||||
benchmark: number[];
|
||||
} {
|
||||
const initialValue = result.initialCapital;
|
||||
const dates: Date[] = [];
|
||||
const values: number[] = [];
|
||||
const benchmark: number[] = [];
|
||||
|
||||
// Sort daily returns by date
|
||||
const sortedReturns = [...result.dailyReturns].sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
);
|
||||
|
||||
// Calculate cumulative portfolio values
|
||||
let portfolioValue = initialValue;
|
||||
let benchmarkValue = initialValue;
|
||||
|
||||
for (const daily of sortedReturns) {
|
||||
const date = new Date(daily.date);
|
||||
portfolioValue = portfolioValue * (1 + daily.return);
|
||||
// Simple benchmark (e.g., assuming 8% annualized return for a market index)
|
||||
benchmarkValue = benchmarkValue * (1 + 0.08 / 365);
|
||||
|
||||
dates.push(date);
|
||||
values.push(portfolioValue);
|
||||
benchmark.push(benchmarkValue);
|
||||
}
|
||||
|
||||
return { dates, values, benchmark };
|
||||
}
|
||||
|
||||
private formatDate(date: Date): string {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatGridListModule } from '@angular/material/grid-list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { BacktestResult } from '../../../services/strategy.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-performance-metrics',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatCardModule, MatGridListModule, MatDividerModule, MatTooltipModule],
|
||||
template: `
|
||||
<mat-card class="metrics-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Performance Metrics</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-group">
|
||||
<h3>Returns</h3>
|
||||
<div class="metrics-row">
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Total return over the backtest period">
|
||||
Total Return
|
||||
</div>
|
||||
<div
|
||||
class="metric-value"
|
||||
[ngClass]="getReturnClass(backtestResult?.totalReturn || 0)"
|
||||
>
|
||||
{{ formatPercent(backtestResult?.totalReturn || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div
|
||||
class="metric-name"
|
||||
matTooltip="Annualized return (adjusted for the backtest duration)"
|
||||
>
|
||||
Annualized Return
|
||||
</div>
|
||||
<div
|
||||
class="metric-value"
|
||||
[ngClass]="getReturnClass(backtestResult?.annualizedReturn || 0)"
|
||||
>
|
||||
{{ formatPercent(backtestResult?.annualizedReturn || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Compound Annual Growth Rate">CAGR</div>
|
||||
<div class="metric-value" [ngClass]="getReturnClass(backtestResult?.cagr || 0)">
|
||||
{{ formatPercent(backtestResult?.cagr || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="metric-group">
|
||||
<h3>Risk Metrics</h3>
|
||||
<div class="metrics-row">
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Maximum peak-to-valley drawdown">
|
||||
Max Drawdown
|
||||
</div>
|
||||
<div class="metric-value negative">
|
||||
{{ formatPercent(backtestResult?.maxDrawdown || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Number of days in the worst drawdown">
|
||||
Max DD Duration
|
||||
</div>
|
||||
<div class="metric-value">
|
||||
{{ formatDays(backtestResult?.maxDrawdownDuration || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Annualized standard deviation of returns">
|
||||
Volatility
|
||||
</div>
|
||||
<div class="metric-value">
|
||||
{{ formatPercent(backtestResult?.volatility || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div
|
||||
class="metric-name"
|
||||
matTooltip="Square root of the sum of the squares of drawdowns"
|
||||
>
|
||||
Ulcer Index
|
||||
</div>
|
||||
<div class="metric-value">
|
||||
{{ (backtestResult?.ulcerIndex || 0).toFixed(4) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="metric-group">
|
||||
<h3>Risk-Adjusted Returns</h3>
|
||||
<div class="metrics-row">
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Excess return per unit of risk">
|
||||
Sharpe Ratio
|
||||
</div>
|
||||
<div
|
||||
class="metric-value"
|
||||
[ngClass]="getRatioClass(backtestResult?.sharpeRatio || 0)"
|
||||
>
|
||||
{{ (backtestResult?.sharpeRatio || 0).toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Return per unit of downside risk">
|
||||
Sortino Ratio
|
||||
</div>
|
||||
<div
|
||||
class="metric-value"
|
||||
[ngClass]="getRatioClass(backtestResult?.sortinoRatio || 0)"
|
||||
>
|
||||
{{ (backtestResult?.sortinoRatio || 0).toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Return per unit of max drawdown">
|
||||
Calmar Ratio
|
||||
</div>
|
||||
<div
|
||||
class="metric-value"
|
||||
[ngClass]="getRatioClass(backtestResult?.calmarRatio || 0)"
|
||||
>
|
||||
{{ (backtestResult?.calmarRatio || 0).toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div
|
||||
class="metric-name"
|
||||
matTooltip="Probability-weighted ratio of gains vs. losses"
|
||||
>
|
||||
Omega Ratio
|
||||
</div>
|
||||
<div
|
||||
class="metric-value"
|
||||
[ngClass]="getRatioClass(backtestResult?.omegaRatio || 0)"
|
||||
>
|
||||
{{ (backtestResult?.omegaRatio || 0).toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="metric-group">
|
||||
<h3>Trade Statistics</h3>
|
||||
<div class="metrics-row">
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Total number of trades">Total Trades</div>
|
||||
<div class="metric-value">
|
||||
{{ backtestResult?.totalTrades || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Percentage of winning trades">Win Rate</div>
|
||||
<div class="metric-value" [ngClass]="getWinRateClass(backtestResult?.winRate || 0)">
|
||||
{{ formatPercent(backtestResult?.winRate || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Average profit of winning trades">Avg Win</div>
|
||||
<div class="metric-value positive">
|
||||
{{ formatPercent(backtestResult?.averageWinningTrade || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Average loss of losing trades">Avg Loss</div>
|
||||
<div class="metric-value negative">
|
||||
{{ formatPercent(backtestResult?.averageLosingTrade || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-name" matTooltip="Ratio of total gains to total losses">
|
||||
Profit Factor
|
||||
</div>
|
||||
<div
|
||||
class="metric-value"
|
||||
[ngClass]="getProfitFactorClass(backtestResult?.profitFactor || 0)"
|
||||
>
|
||||
{{ (backtestResult?.profitFactor || 0).toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
`,
|
||||
styles: `
|
||||
.metrics-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric-group {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.metric-group h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.metrics-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-width: 120px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metric-name {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.neutral {
|
||||
color: #ffa000;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSortModule, Sort } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { BacktestResult } from '../../../services/strategy.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-trades-table',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatTableModule,
|
||||
MatSortModule,
|
||||
MatPaginatorModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
],
|
||||
template: `
|
||||
<mat-card class="trades-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Trades</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<table
|
||||
mat-table
|
||||
[dataSource]="displayedTrades"
|
||||
matSort
|
||||
(matSortChange)="sortData($event)"
|
||||
class="trades-table"
|
||||
>
|
||||
<!-- Symbol Column -->
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Symbol</th>
|
||||
<td mat-cell *matCellDef="let trade">{{ trade.symbol }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Entry Date Column -->
|
||||
<ng-container matColumnDef="entryTime">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Entry Time</th>
|
||||
<td mat-cell *matCellDef="let trade">{{ formatDate(trade.entryTime) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Entry Price Column -->
|
||||
<ng-container matColumnDef="entryPrice">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Entry Price</th>
|
||||
<td mat-cell *matCellDef="let trade">{{ formatCurrency(trade.entryPrice) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Exit Date Column -->
|
||||
<ng-container matColumnDef="exitTime">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Exit Time</th>
|
||||
<td mat-cell *matCellDef="let trade">{{ formatDate(trade.exitTime) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Exit Price Column -->
|
||||
<ng-container matColumnDef="exitPrice">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Exit Price</th>
|
||||
<td mat-cell *matCellDef="let trade">{{ formatCurrency(trade.exitPrice) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Quantity Column -->
|
||||
<ng-container matColumnDef="quantity">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Quantity</th>
|
||||
<td mat-cell *matCellDef="let trade">{{ trade.quantity }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- P&L Column -->
|
||||
<ng-container matColumnDef="pnl">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>P&L</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let trade"
|
||||
[ngClass]="{ positive: trade.pnl > 0, negative: trade.pnl < 0 }"
|
||||
>
|
||||
{{ formatCurrency(trade.pnl) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- P&L Percent Column -->
|
||||
<ng-container matColumnDef="pnlPercent">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>P&L %</th>
|
||||
<td
|
||||
mat-cell
|
||||
*matCellDef="let trade"
|
||||
[ngClass]="{ positive: trade.pnlPercent > 0, negative: trade.pnlPercent < 0 }"
|
||||
>
|
||||
{{ formatPercent(trade.pnlPercent) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalTrades"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="[5, 10, 25, 50]"
|
||||
(page)="pageChange($event)"
|
||||
aria-label="Select page"
|
||||
>
|
||||
</mat-paginator>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
`,
|
||||
styles: `
|
||||
.trades-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.trades-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.mat-column-pnl,
|
||||
.mat-column-pnlPercent {
|
||||
text-align: right;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.mat-mdc-row:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class TradesTableComponent {
|
||||
@Input() set backtestResult(value: BacktestResult | undefined) {
|
||||
if (value) {
|
||||
this._backtestResult = value;
|
||||
this.updateDisplayedTrades();
|
||||
}
|
||||
}
|
||||
|
||||
get backtestResult(): BacktestResult | undefined {
|
||||
return this._backtestResult;
|
||||
}
|
||||
|
||||
private _backtestResult?: BacktestResult;
|
||||
|
||||
// Table configuration
|
||||
displayedColumns: string[] = [
|
||||
'symbol',
|
||||
'entryTime',
|
||||
'entryPrice',
|
||||
'exitTime',
|
||||
'exitPrice',
|
||||
'quantity',
|
||||
'pnl',
|
||||
'pnlPercent',
|
||||
];
|
||||
|
||||
// Pagination
|
||||
pageSize = 10;
|
||||
currentPage = 0;
|
||||
displayedTrades: any[] = [];
|
||||
|
||||
get totalTrades(): number {
|
||||
return this._backtestResult?.trades.length || 0;
|
||||
}
|
||||
|
||||
// Sort the trades
|
||||
sortData(sort: Sort): void {
|
||||
if (!sort.active || sort.direction === '') {
|
||||
this.updateDisplayedTrades();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = this._backtestResult?.trades.slice() || [];
|
||||
|
||||
this.displayedTrades = data
|
||||
.sort((a, b) => {
|
||||
const isAsc = sort.direction === 'asc';
|
||||
switch (sort.active) {
|
||||
case 'symbol':
|
||||
return this.compare(a.symbol, b.symbol, isAsc);
|
||||
case 'entryTime':
|
||||
return this.compare(
|
||||
new Date(a.entryTime).getTime(),
|
||||
new Date(b.entryTime).getTime(),
|
||||
isAsc
|
||||
);
|
||||
case 'entryPrice':
|
||||
return this.compare(a.entryPrice, b.entryPrice, isAsc);
|
||||
case 'exitTime':
|
||||
return this.compare(
|
||||
new Date(a.exitTime).getTime(),
|
||||
new Date(b.exitTime).getTime(),
|
||||
isAsc
|
||||
);
|
||||
case 'exitPrice':
|
||||
return this.compare(a.exitPrice, b.exitPrice, isAsc);
|
||||
case 'quantity':
|
||||
return this.compare(a.quantity, b.quantity, isAsc);
|
||||
case 'pnl':
|
||||
return this.compare(a.pnl, b.pnl, isAsc);
|
||||
case 'pnlPercent':
|
||||
return this.compare(a.pnlPercent, b.pnlPercent, isAsc);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
.slice(this.currentPage * this.pageSize, (this.currentPage + 1) * this.pageSize);
|
||||
}
|
||||
|
||||
// Handle page changes
|
||||
pageChange(event: PageEvent): void {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Inject, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import {
|
||||
BacktestRequest,
|
||||
BacktestResult,
|
||||
StrategyService,
|
||||
TradingStrategy,
|
||||
} from '../../../services/strategy.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-backtest-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatDatepickerModule,
|
||||
MatNativeDateModule,
|
||||
MatProgressBarModule,
|
||||
MatTabsModule,
|
||||
MatChipsModule,
|
||||
MatIconModule,
|
||||
MatSlideToggleModule,
|
||||
],
|
||||
templateUrl: './backtest-dialog.component.html',
|
||||
styleUrl: './backtest-dialog.component.css',
|
||||
})
|
||||
export class BacktestDialogComponent implements OnInit {
|
||||
backtestForm: FormGroup;
|
||||
strategyTypes: string[] = [];
|
||||
availableSymbols: string[] = [
|
||||
'AAPL',
|
||||
'MSFT',
|
||||
'GOOGL',
|
||||
'AMZN',
|
||||
'TSLA',
|
||||
'META',
|
||||
'NVDA',
|
||||
'SPY',
|
||||
'QQQ',
|
||||
];
|
||||
selectedSymbols: string[] = [];
|
||||
parameters: Record<string, any> = {};
|
||||
isRunning: boolean = false;
|
||||
backtestResult: BacktestResult | null = null;
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private strategyService: StrategyService,
|
||||
@Inject(MAT_DIALOG_DATA) public data: TradingStrategy | null,
|
||||
private dialogRef: MatDialogRef<BacktestDialogComponent>
|
||||
) {
|
||||
// Initialize form with defaults
|
||||
this.backtestForm = this.fb.group({
|
||||
strategyType: ['', [Validators.required]],
|
||||
startDate: [
|
||||
new Date(new Date().setFullYear(new Date().getFullYear() - 1)),
|
||||
[Validators.required],
|
||||
],
|
||||
endDate: [new Date(), [Validators.required]],
|
||||
initialCapital: [100000, [Validators.required, Validators.min(1000)]],
|
||||
dataResolution: ['1d', [Validators.required]],
|
||||
commission: [0.001, [Validators.required, Validators.min(0), Validators.max(0.1)]],
|
||||
slippage: [0.0005, [Validators.required, Validators.min(0), Validators.max(0.1)]],
|
||||
mode: ['event', [Validators.required]],
|
||||
});
|
||||
|
||||
// If strategy is provided, pre-populate the form
|
||||
if (data) {
|
||||
this.selectedSymbols = [...data.symbols];
|
||||
this.backtestForm.patchValue({
|
||||
strategyType: data.type,
|
||||
});
|
||||
this.parameters = { ...data.parameters };
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadStrategyTypes();
|
||||
}
|
||||
|
||||
loadStrategyTypes(): void {
|
||||
this.strategyService.getStrategyTypes().subscribe({
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.strategyTypes = response.data;
|
||||
|
||||
// If strategy is provided, load its parameters
|
||||
if (this.data) {
|
||||
this.onStrategyTypeChange(this.data.type);
|
||||
}
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
console.error('Error loading strategy types:', error);
|
||||
this.strategyTypes = ['MOVING_AVERAGE_CROSSOVER', 'MEAN_REVERSION', 'CUSTOM'];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onStrategyTypeChange(type: string): void {
|
||||
// Get default parameters for this strategy type
|
||||
this.strategyService.getStrategyParameters(type).subscribe({
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
// If strategy is provided, merge default with existing
|
||||
if (this.data) {
|
||||
this.parameters = {
|
||||
...response.data,
|
||||
...this.data.parameters,
|
||||
};
|
||||
} else {
|
||||
this.parameters = response.data;
|
||||
}
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
console.error('Error loading parameters:', error);
|
||||
this.parameters = {};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
addSymbol(symbol: string): void {
|
||||
if (!symbol || this.selectedSymbols.includes(symbol)) {
|
||||
return;
|
||||
}
|
||||
this.selectedSymbols.push(symbol);
|
||||
}
|
||||
|
||||
removeSymbol(symbol: string): void {
|
||||
this.selectedSymbols = this.selectedSymbols.filter(s => s !== symbol);
|
||||
}
|
||||
|
||||
updateParameter(key: string, value: any): void {
|
||||
this.parameters[key] = value;
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.backtestForm.invalid || this.selectedSymbols.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formValue = this.backtestForm.value;
|
||||
|
||||
const backtestRequest: BacktestRequest = {
|
||||
strategyType: formValue.strategyType,
|
||||
strategyParams: this.parameters,
|
||||
symbols: this.selectedSymbols,
|
||||
startDate: formValue.startDate,
|
||||
endDate: formValue.endDate,
|
||||
initialCapital: formValue.initialCapital,
|
||||
dataResolution: formValue.dataResolution,
|
||||
commission: formValue.commission,
|
||||
slippage: formValue.slippage,
|
||||
mode: formValue.mode,
|
||||
};
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
this.strategyService.runBacktest(backtestRequest).subscribe({
|
||||
next: response => {
|
||||
this.isRunning = false;
|
||||
if (response.success) {
|
||||
this.backtestResult = response.data;
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
this.isRunning = false;
|
||||
console.error('Backtest error:', error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close(this.backtestResult);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<h2 mat-dialog-title>{{isEditMode ? 'Edit Strategy' : 'Create Strategy'}}</h2>
|
||||
|
||||
<form [formGroup]="strategyForm" (ngSubmit)="onSubmit()">
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<!-- Basic Strategy Information -->
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Strategy Name</mat-label>
|
||||
<input matInput formControlName="name" placeholder="e.g., My Moving Average Crossover">
|
||||
<mat-error *ngIf="strategyForm.get('name')?.invalid">Name is required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Description</mat-label>
|
||||
<textarea matInput formControlName="description" rows="3"
|
||||
placeholder="Describe what this strategy does..."></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Strategy Type</mat-label>
|
||||
<mat-select formControlName="type" (selectionChange)="onStrategyTypeChange($event.value)">
|
||||
<mat-option *ngFor="let type of strategyTypes" [value]="type">
|
||||
{{type}}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
<mat-error *ngIf="strategyForm.get('type')?.invalid">Strategy type is required</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Symbol Selection -->
|
||||
<div class="w-full">
|
||||
<label class="text-sm">Trading Symbols</label>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<mat-chip *ngFor="let symbol of selectedSymbols" [removable]="true"
|
||||
(removed)="removeSymbol(symbol)">
|
||||
{{symbol}}
|
||||
<mat-icon matChipRemove>cancel</mat-icon>
|
||||
</mat-chip>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<mat-form-field appearance="outline" class="flex-1">
|
||||
<mat-label>Add Symbol</mat-label>
|
||||
<input matInput #symbolInput placeholder="e.g., AAPL">
|
||||
</mat-form-field>
|
||||
<button type="button" mat-raised-button color="primary"
|
||||
(click)="addSymbol(symbolInput.value); symbolInput.value = ''">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500 mb-1">Suggested symbols:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" *ngFor="let symbol of availableSymbols"
|
||||
mat-stroked-button (click)="addSymbol(symbol)"
|
||||
[disabled]="selectedSymbols.includes(symbol)">
|
||||
{{symbol}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Strategy Parameters -->
|
||||
<div *ngIf="strategyForm.get('type')?.value && Object.keys(parameters).length > 0">
|
||||
<h3 class="text-lg font-semibold mb-2">Strategy Parameters</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<mat-form-field *ngFor="let param of parameters | keyvalue" appearance="outline">
|
||||
<mat-label>{{param.key}}</mat-label>
|
||||
<input matInput [value]="param.value"
|
||||
(input)="updateParameter(param.key, $any($event.target).value)">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close>Cancel</button>
|
||||
<button mat-raised-button color="primary" type="submit"
|
||||
[disabled]="strategyForm.invalid || selectedSymbols.length === 0">
|
||||
{{isEditMode ? 'Update' : 'Create'}}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Inject, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { StrategyService, TradingStrategy } from '../../../services/strategy.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-strategy-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatChipsModule,
|
||||
MatIconModule,
|
||||
MatAutocompleteModule,
|
||||
],
|
||||
templateUrl: './strategy-dialog.component.html',
|
||||
styleUrl: './strategy-dialog.component.css',
|
||||
})
|
||||
export class StrategyDialogComponent implements OnInit {
|
||||
strategyForm: FormGroup;
|
||||
isEditMode: boolean = false;
|
||||
strategyTypes: string[] = [];
|
||||
availableSymbols: string[] = [
|
||||
'AAPL',
|
||||
'MSFT',
|
||||
'GOOGL',
|
||||
'AMZN',
|
||||
'TSLA',
|
||||
'META',
|
||||
'NVDA',
|
||||
'SPY',
|
||||
'QQQ',
|
||||
];
|
||||
selectedSymbols: string[] = [];
|
||||
separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||
parameters: Record<string, any> = {};
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private strategyService: StrategyService,
|
||||
@Inject(MAT_DIALOG_DATA) public data: TradingStrategy | null,
|
||||
private dialogRef: MatDialogRef<StrategyDialogComponent>
|
||||
) {
|
||||
this.isEditMode = !!data;
|
||||
|
||||
this.strategyForm = this.fb.group({
|
||||
name: ['', [Validators.required]],
|
||||
description: [''],
|
||||
type: ['', [Validators.required]],
|
||||
// Dynamic parameters will be added based on strategy type
|
||||
});
|
||||
|
||||
if (this.isEditMode && data) {
|
||||
this.selectedSymbols = [...data.symbols];
|
||||
this.strategyForm.patchValue({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
type: data.type,
|
||||
});
|
||||
this.parameters = { ...data.parameters };
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.strategyTypes = response.data;
|
||||
|
||||
// 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
|
||||
this.strategyTypes = ['MOVING_AVERAGE_CROSSOVER', 'MEAN_REVERSION', 'CUSTOM'];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onStrategyTypeChange(type: string): void {
|
||||
// Get default parameters for this strategy type
|
||||
this.strategyService.getStrategyParameters(type).subscribe({
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
// If editing, merge default with existing
|
||||
if (this.isEditMode && this.data) {
|
||||
this.parameters = {
|
||||
...response.data,
|
||||
...this.data.parameters,
|
||||
};
|
||||
} else {
|
||||
this.parameters = response.data;
|
||||
}
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
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);
|
||||
}
|
||||
|
||||
removeSymbol(symbol: string): void {
|
||||
this.selectedSymbols = this.selectedSymbols.filter(s => s !== symbol);
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.strategyForm.invalid || this.selectedSymbols.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formValue = this.strategyForm.value;
|
||||
|
||||
const strategy: Partial<TradingStrategy> = {
|
||||
name: formValue.name,
|
||||
description: formValue.description,
|
||||
type: formValue.type,
|
||||
symbols: this.selectedSymbols,
|
||||
parameters: this.parameters,
|
||||
};
|
||||
|
||||
if (this.isEditMode && this.data) {
|
||||
this.strategyService.updateStrategy(this.data.id, strategy).subscribe({
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
console.error('Error updating strategy:', error);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.strategyService.createStrategy(strategy).subscribe({
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
console.error('Error creating strategy:', error);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateParameter(key: string, value: any): void {
|
||||
this.parameters[key] = value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/* Strategies specific styles */
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Trading Strategies</h1>
|
||||
<p class="text-gray-600 mt-1">Configure and monitor your automated trading strategies</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button mat-raised-button color="primary" (click)="openStrategyDialog()">
|
||||
<mat-icon>add</mat-icon> New Strategy
|
||||
</button>
|
||||
<button mat-raised-button color="accent" (click)="openBacktestDialog()">
|
||||
<mat-icon>science</mat-icon> New Backtest
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-card *ngIf="isLoading" class="p-4">
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
</mat-card>
|
||||
|
||||
<div *ngIf="!selectedStrategy; else strategyDetails">
|
||||
<mat-card *ngIf="strategies.length > 0; else noStrategies" class="p-4">
|
||||
<table mat-table [dataSource]="strategies" class="w-full">
|
||||
<!-- Name Column -->
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Strategy</th>
|
||||
<td mat-cell *matCellDef="let strategy">
|
||||
<div class="font-semibold">{{strategy.name}}</div>
|
||||
<div class="text-xs text-gray-500">{{strategy.description}}</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Type Column -->
|
||||
<ng-container matColumnDef="type">
|
||||
<th mat-header-cell *matHeaderCellDef>Type</th>
|
||||
<td mat-cell *matCellDef="let strategy">{{strategy.type}}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Symbols Column -->
|
||||
<ng-container matColumnDef="symbols">
|
||||
<th mat-header-cell *matHeaderCellDef>Symbols</th>
|
||||
<td mat-cell *matCellDef="let strategy">
|
||||
<div class="flex flex-wrap gap-1 max-w-xs">
|
||||
<mat-chip *ngFor="let symbol of strategy.symbols.slice(0, 3)">
|
||||
{{symbol}}
|
||||
</mat-chip>
|
||||
<span *ngIf="strategy.symbols.length > 3" class="text-gray-500">
|
||||
+{{strategy.symbols.length - 3}} more
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let strategy">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full"
|
||||
[style.background-color]="getStatusColor(strategy.status)"></span>
|
||||
{{strategy.status}}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Performance Column -->
|
||||
<ng-container matColumnDef="performance">
|
||||
<th mat-header-cell *matHeaderCellDef>Performance</th>
|
||||
<td mat-cell *matCellDef="let strategy">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-500">Return:</span>
|
||||
<span [ngClass]="{'text-green-600': strategy.performance.totalReturn > 0,
|
||||
'text-red-600': strategy.performance.totalReturn < 0}">
|
||||
{{strategy.performance.totalReturn | percent:'1.2-2'}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-xs text-gray-500">Win Rate:</span>
|
||||
<span>{{strategy.performance.winRate | percent:'1.0-0'}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let strategy">
|
||||
<div class="flex gap-2">
|
||||
<button mat-icon-button color="primary" (click)="viewStrategyDetails(strategy)">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button [color]="strategy.status === 'ACTIVE' ? 'warn' : 'primary'"
|
||||
(click)="toggleStrategyStatus(strategy)">
|
||||
<mat-icon>{{strategy.status === 'ACTIVE' ? 'pause' : 'play_arrow'}}</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button [matMenuTriggerFor]="menu">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
<mat-menu #menu="matMenu">
|
||||
<button mat-menu-item (click)="openStrategyDialog(strategy)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="openBacktestDialog(strategy)">
|
||||
<mat-icon>science</mat-icon>
|
||||
<span>Backtest</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</mat-card>
|
||||
|
||||
<ng-template #noStrategies>
|
||||
<mat-card class="p-6 flex flex-col items-center justify-center">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem; margin: 0 auto;">psychology</mat-icon>
|
||||
<h3 class="text-xl font-semibold mt-4">No Strategies Yet</h3>
|
||||
<p class="mb-4">Create your first trading strategy to get started</p>
|
||||
<button mat-raised-button color="primary" (click)="openStrategyDialog()">
|
||||
<mat-icon>add</mat-icon> Create Strategy
|
||||
</button>
|
||||
</div>
|
||||
</mat-card>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<ng-template #strategyDetails>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<button mat-button (click)="selectedStrategy = null">
|
||||
<mat-icon>arrow_back</mat-icon> Back to Strategies
|
||||
</button>
|
||||
</div>
|
||||
<app-strategy-details [strategy]="selectedStrategy"></app-strategy-details>
|
||||
</ng-template>
|
||||
</div>
|
||||
154
apps/dashboard/src/app/pages/strategies/strategies.component.ts
Normal file
154
apps/dashboard/src/app/pages/strategies/strategies.component.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { StrategyService, TradingStrategy } from '../../services/strategy.service';
|
||||
import { WebSocketService } from '../../services/websocket.service';
|
||||
import { BacktestDialogComponent } from './dialogs/backtest-dialog.component';
|
||||
import { StrategyDialogComponent } from './dialogs/strategy-dialog.component';
|
||||
import { StrategyDetailsComponent } from './strategy-details/strategy-details.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-strategies',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTabsModule,
|
||||
MatTableModule,
|
||||
MatSortModule,
|
||||
MatPaginatorModule,
|
||||
MatDialogModule,
|
||||
MatMenuModule,
|
||||
MatChipsModule,
|
||||
MatProgressBarModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
StrategyDetailsComponent,
|
||||
],
|
||||
templateUrl: './strategies.component.html',
|
||||
styleUrl: './strategies.component.css',
|
||||
})
|
||||
export class StrategiesComponent implements OnInit {
|
||||
strategies: TradingStrategy[] = [];
|
||||
displayedColumns: string[] = ['name', 'type', 'symbols', 'status', 'performance', 'actions'];
|
||||
selectedStrategy: TradingStrategy | null = null;
|
||||
isLoading = false;
|
||||
|
||||
constructor(
|
||||
private strategyService: StrategyService,
|
||||
private webSocketService: WebSocketService,
|
||||
private dialog: MatDialog
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadStrategies();
|
||||
this.listenForStrategyUpdates();
|
||||
}
|
||||
|
||||
loadStrategies(): void {
|
||||
this.isLoading = true;
|
||||
this.strategyService.getStrategies().subscribe({
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.strategies = response.data;
|
||||
}
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: error => {
|
||||
console.error('Error loading strategies:', error);
|
||||
this.isLoading = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
listenForStrategyUpdates(): void {
|
||||
this.webSocketService.messages.subscribe(message => {
|
||||
if (
|
||||
message.type === 'STRATEGY_CREATED' ||
|
||||
message.type === 'STRATEGY_UPDATED' ||
|
||||
message.type === 'STRATEGY_STATUS_CHANGED'
|
||||
) {
|
||||
// Refresh the strategy list when changes occur
|
||||
this.loadStrategies();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'ACTIVE':
|
||||
return 'green';
|
||||
case 'PAUSED':
|
||||
return 'orange';
|
||||
case 'ERROR':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
openStrategyDialog(strategy?: TradingStrategy): void {
|
||||
const dialogRef = this.dialog.open(StrategyDialogComponent, {
|
||||
width: '600px',
|
||||
data: strategy || null,
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.loadStrategies();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openBacktestDialog(strategy?: TradingStrategy): void {
|
||||
const dialogRef = this.dialog.open(BacktestDialogComponent, {
|
||||
width: '800px',
|
||||
data: strategy || null,
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
// Handle backtest result if needed
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleStrategyStatus(strategy: TradingStrategy): void {
|
||||
this.isLoading = true;
|
||||
|
||||
if (strategy.status === 'ACTIVE') {
|
||||
this.strategyService.pauseStrategy(strategy.id).subscribe({
|
||||
next: () => this.loadStrategies(),
|
||||
error: error => {
|
||||
console.error('Error pausing strategy:', error);
|
||||
this.isLoading = false;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.strategyService.startStrategy(strategy.id).subscribe({
|
||||
next: () => this.loadStrategies(),
|
||||
error: error => {
|
||||
console.error('Error starting strategy:', error);
|
||||
this.isLoading = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
viewStrategyDetails(strategy: TradingStrategy): void {
|
||||
this.selectedStrategy = strategy;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/* Strategy details specific styles */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
td {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
<div class="space-y-6" *ngIf="strategy">
|
||||
<div class="flex flex-col md:flex-row md:justify-between md:items-start gap-4">
|
||||
<!-- Strategy Overview Card -->
|
||||
<mat-card class="flex-1 p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">{{ strategy.name }}</h2>
|
||||
<p class="text-gray-600 text-sm">{{ strategy.description }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button mat-raised-button color="primary" class="mr-2" (click)="openBacktestDialog()">
|
||||
Run Backtest
|
||||
</button>
|
||||
<span
|
||||
class="px-3 py-1 rounded-full text-xs font-semibold"
|
||||
[style.background-color]="getStatusColor(strategy.status)"
|
||||
style="color: white"
|
||||
>
|
||||
{{ strategy.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 class="font-semibold text-sm text-gray-600">Type</h3>
|
||||
<p>{{ strategy.type }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-sm text-gray-600">Created</h3>
|
||||
<p>{{ strategy.createdAt | date: 'medium' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-sm text-gray-600">Last Updated</h3>
|
||||
<p>{{ strategy.updatedAt | date: 'medium' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-sm text-gray-600">Symbols</h3>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<mat-chip *ngFor="let symbol of strategy.symbols">{{ symbol }}</mat-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Performance Summary Card -->
|
||||
<mat-card class="md:w-1/3 p-4">
|
||||
<h3 class="text-lg font-bold mb-3">Performance</h3>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Return</p>
|
||||
<p
|
||||
class="text-xl font-semibold"
|
||||
[ngClass]="{
|
||||
'text-green-600': performance.totalReturn >= 0,
|
||||
'text-red-600': performance.totalReturn < 0,
|
||||
}"
|
||||
>
|
||||
{{ performance.totalReturn | percent: '1.2-2' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Win Rate</p>
|
||||
<p class="text-xl font-semibold">{{ performance.winRate | percent: '1.0-0' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Sharpe Ratio</p>
|
||||
<p class="text-xl font-semibold">{{ performance.sharpeRatio | number: '1.2-2' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Max Drawdown</p>
|
||||
<p class="text-xl font-semibold text-red-600">
|
||||
{{ performance.maxDrawdown | percent: '1.2-2' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Total Trades</p>
|
||||
<p class="text-xl font-semibold">{{ performance.totalTrades }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Sortino Ratio</p>
|
||||
<p class="text-xl font-semibold">{{ performance.sortinoRatio | number: '1.2-2' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-divider class="my-4"></mat-divider>
|
||||
|
||||
<div class="flex justify-between mt-2">
|
||||
<button
|
||||
mat-button
|
||||
color="primary"
|
||||
*ngIf="strategy.status !== 'ACTIVE'"
|
||||
(click)="activateStrategy()"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon> Start
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
color="accent"
|
||||
*ngIf="strategy.status === 'ACTIVE'"
|
||||
(click)="pauseStrategy()"
|
||||
>
|
||||
<mat-icon>pause</mat-icon> Pause
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
color="warn"
|
||||
*ngIf="strategy.status === 'ACTIVE'"
|
||||
(click)="stopStrategy()"
|
||||
>
|
||||
<mat-icon>stop</mat-icon> Stop
|
||||
</button>
|
||||
<button mat-button (click)="openEditDialog()"><mat-icon>edit</mat-icon> Edit</button>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- Parameters Card -->
|
||||
<mat-card class="p-4">
|
||||
<h3 class="text-lg font-bold mb-3">Strategy Parameters</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div *ngFor="let param of strategy.parameters | keyvalue">
|
||||
<p class="text-sm text-gray-600">{{ param.key }}</p>
|
||||
<p class="font-semibold">{{ param.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
<!-- Backtest Results Section (only shown when a backtest has been run) -->
|
||||
<div *ngIf="backtestResult" class="backtest-results space-y-6">
|
||||
<h2 class="text-xl font-bold">Backtest Results</h2>
|
||||
|
||||
<!-- Performance Metrics Component -->
|
||||
<app-performance-metrics [backtestResult]="backtestResult"></app-performance-metrics>
|
||||
|
||||
<!-- Equity Chart Component -->
|
||||
<app-equity-chart [backtestResult]="backtestResult"></app-equity-chart>
|
||||
|
||||
<!-- Drawdown Chart Component -->
|
||||
<app-drawdown-chart [backtestResult]="backtestResult"></app-drawdown-chart>
|
||||
|
||||
<!-- Trades Table Component -->
|
||||
<app-trades-table [backtestResult]="backtestResult"></app-trades-table>
|
||||
</div>
|
||||
|
||||
<!-- Tabs for Signals/Trades -->
|
||||
<mat-card class="p-0">
|
||||
<mat-tab-group>
|
||||
<!-- Signals Tab -->
|
||||
<mat-tab label="Recent Signals">
|
||||
<div class="p-4">
|
||||
<ng-container *ngIf="!isLoadingSignals; else loadingSignals">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-2 text-left">Time</th>
|
||||
<th class="py-2 text-left">Symbol</th>
|
||||
<th class="py-2 text-left">Action</th>
|
||||
<th class="py-2 text-left">Price</th>
|
||||
<th class="py-2 text-left">Quantity</th>
|
||||
<th class="py-2 text-left">Confidence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let signal of signals">
|
||||
<td class="py-2">{{ signal.timestamp | date: 'short' }}</td>
|
||||
<td class="py-2">{{ signal.symbol }}</td>
|
||||
<td class="py-2">
|
||||
<span
|
||||
class="px-2 py-1 rounded text-xs font-semibold"
|
||||
[style.background-color]="getSignalColor(signal.action)"
|
||||
style="color: white"
|
||||
>
|
||||
{{ signal.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2">${{ signal.price | number: '1.2-2' }}</td>
|
||||
<td class="py-2">{{ signal.quantity }}</td>
|
||||
<td class="py-2">{{ signal.confidence | percent: '1.0-0' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</ng-container>
|
||||
<ng-template #loadingSignals>
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
</ng-template>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Trades Tab -->
|
||||
<mat-tab label="Recent Trades">
|
||||
<div class="p-4">
|
||||
<ng-container *ngIf="!isLoadingTrades; else loadingTrades">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-2 text-left">Symbol</th>
|
||||
<th class="py-2 text-left">Entry</th>
|
||||
<th class="py-2 text-left">Exit</th>
|
||||
<th class="py-2 text-left">Quantity</th>
|
||||
<th class="py-2 text-left">P&L</th>
|
||||
<th class="py-2 text-left">P&L %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let trade of trades">
|
||||
<td class="py-2">{{ trade.symbol }}</td>
|
||||
<td class="py-2">
|
||||
${{ trade.entryPrice | number: '1.2-2' }} @
|
||||
{{ trade.entryTime | date: 'short' }}
|
||||
</td>
|
||||
<td class="py-2">
|
||||
${{ trade.exitPrice | number: '1.2-2' }} @
|
||||
{{ trade.exitTime | date: 'short' }}
|
||||
</td>
|
||||
<td class="py-2">{{ trade.quantity }}</td>
|
||||
<td
|
||||
class="py-2"
|
||||
[ngClass]="{ 'text-green-600': trade.pnl >= 0, 'text-red-600': trade.pnl < 0 }"
|
||||
>
|
||||
${{ trade.pnl | number: '1.2-2' }}
|
||||
</td>
|
||||
<td
|
||||
class="py-2"
|
||||
[ngClass]="{
|
||||
'text-green-600': trade.pnlPercent >= 0,
|
||||
'text-red-600': trade.pnlPercent < 0,
|
||||
}"
|
||||
>
|
||||
{{ trade.pnlPercent | number: '1.2-2' }}%
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</ng-container>
|
||||
<ng-template #loadingTrades>
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
</ng-template>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<mat-card class="p-6 flex items-center" *ngIf="!strategy">
|
||||
<div class="text-center text-gray-500 w-full">
|
||||
<mat-icon style="font-size: 4rem; width: 4rem; height: 4rem">psychology</mat-icon>
|
||||
<p class="mb-4">No strategy selected</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import {
|
||||
BacktestResult,
|
||||
StrategyService,
|
||||
TradingStrategy,
|
||||
} from '../../../services/strategy.service';
|
||||
import { WebSocketService } from '../../../services/websocket.service';
|
||||
import { DrawdownChartComponent } from '../components/drawdown-chart.component';
|
||||
import { EquityChartComponent } from '../components/equity-chart.component';
|
||||
import { PerformanceMetricsComponent } from '../components/performance-metrics.component';
|
||||
import { TradesTableComponent } from '../components/trades-table.component';
|
||||
import { BacktestDialogComponent } from '../dialogs/backtest-dialog.component';
|
||||
import { StrategyDialogComponent } from '../dialogs/strategy-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-strategy-details',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatTabsModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTableModule,
|
||||
MatChipsModule,
|
||||
MatProgressBarModule,
|
||||
MatDividerModule,
|
||||
EquityChartComponent,
|
||||
DrawdownChartComponent,
|
||||
TradesTableComponent,
|
||||
PerformanceMetricsComponent,
|
||||
],
|
||||
templateUrl: './strategy-details.component.html',
|
||||
styleUrl: './strategy-details.component.css',
|
||||
})
|
||||
export class StrategyDetailsComponent implements OnChanges {
|
||||
@Input() strategy: TradingStrategy | null = null;
|
||||
|
||||
signals: any[] = [];
|
||||
trades: any[] = [];
|
||||
performance: any = {};
|
||||
isLoadingSignals = false;
|
||||
isLoadingTrades = false;
|
||||
backtestResult: BacktestResult | undefined;
|
||||
|
||||
constructor(
|
||||
private strategyService: StrategyService,
|
||||
private webSocketService: WebSocketService,
|
||||
private dialog: MatDialog
|
||||
) {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['strategy'] && this.strategy) {
|
||||
this.loadStrategyData();
|
||||
this.listenForUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
loadStrategyData(): void {
|
||||
if (!this.strategy) {
|
||||
return;
|
||||
}
|
||||
|
||||
// In a real implementation, these would call API methods to fetch the data
|
||||
this.loadSignals();
|
||||
this.loadTrades();
|
||||
this.loadPerformance();
|
||||
}
|
||||
loadSignals(): void {
|
||||
if (!this.strategy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoadingSignals = true;
|
||||
|
||||
// First check if we can get real signals from the API
|
||||
this.strategyService.getStrategySignals(this.strategy.id).subscribe({
|
||||
next: response => {
|
||||
if (response.success && response.data && response.data.length > 0) {
|
||||
this.signals = response.data;
|
||||
} else {
|
||||
// Fallback to mock data if no real signals available
|
||||
this.signals = this.generateMockSignals();
|
||||
}
|
||||
this.isLoadingSignals = false;
|
||||
},
|
||||
error: error => {
|
||||
console.error('Error loading signals', error);
|
||||
// Fallback to mock data on error
|
||||
this.signals = this.generateMockSignals();
|
||||
this.isLoadingSignals = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
loadTrades(): void {
|
||||
if (!this.strategy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoadingTrades = true;
|
||||
|
||||
// First check if we can get real trades from the API
|
||||
this.strategyService.getStrategyTrades(this.strategy.id).subscribe({
|
||||
next: response => {
|
||||
if (response.success && response.data && response.data.length > 0) {
|
||||
this.trades = response.data;
|
||||
} else {
|
||||
// Fallback to mock data if no real trades available
|
||||
this.trades = this.generateMockTrades();
|
||||
}
|
||||
this.isLoadingTrades = false;
|
||||
},
|
||||
error: error => {
|
||||
console.error('Error loading trades', error);
|
||||
// Fallback to mock data on error
|
||||
this.trades = this.generateMockTrades();
|
||||
this.isLoadingTrades = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
loadPerformance(): void {
|
||||
// This would be an API call in a real implementation
|
||||
this.performance = {
|
||||
totalReturn: this.strategy?.performance.totalReturn || 0,
|
||||
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
|
||||
dailyReturn: 0.0012,
|
||||
volatility: 0.008,
|
||||
sortinoRatio: 1.2,
|
||||
calmarRatio: 0.7,
|
||||
};
|
||||
}
|
||||
listenForUpdates(): void {
|
||||
if (!this.strategy) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to strategy signals
|
||||
this.webSocketService.getStrategySignals(this.strategy.id).subscribe((signal: any) => {
|
||||
// Add the new signal to the top of the list
|
||||
this.signals = [signal, ...this.signals.slice(0, 9)]; // Keep only the latest 10 signals
|
||||
});
|
||||
|
||||
// Subscribe to strategy trades
|
||||
this.webSocketService.getStrategyTrades(this.strategy.id).subscribe((trade: any) => {
|
||||
// Add the new trade to the top of the list
|
||||
this.trades = [trade, ...this.trades.slice(0, 9)]; // Keep only the latest 10 trades
|
||||
|
||||
// Update performance metrics
|
||||
this.updatePerformanceMetrics();
|
||||
});
|
||||
|
||||
// Subscribe to strategy status updates
|
||||
this.webSocketService.getStrategyUpdates().subscribe((update: any) => {
|
||||
if (update.strategyId === this.strategy?.id) {
|
||||
// Update strategy status if changed
|
||||
if (update.status && this.strategy && this.strategy.status !== update.status) {
|
||||
this.strategy.status = update.status;
|
||||
}
|
||||
|
||||
// Update other fields if present
|
||||
if (update.performance && this.strategy) {
|
||||
this.strategy.performance = {
|
||||
...this.strategy.performance,
|
||||
...update.performance,
|
||||
};
|
||||
this.performance = {
|
||||
...this.performance,
|
||||
...update.performance,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('WebSocket listeners for strategy updates initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update performance metrics when new trades come in
|
||||
*/
|
||||
private updatePerformanceMetrics(): void {
|
||||
if (!this.strategy || this.trades.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate basic metrics
|
||||
const winningTrades = this.trades.filter(t => t.pnl > 0);
|
||||
const losingTrades = this.trades.filter(t => t.pnl < 0);
|
||||
|
||||
const totalPnl = this.trades.reduce((sum, trade) => sum + trade.pnl, 0);
|
||||
const winRate = winningTrades.length / this.trades.length;
|
||||
|
||||
// Update performance data
|
||||
const currentPerformance = this.performance || {};
|
||||
this.performance = {
|
||||
...currentPerformance,
|
||||
totalTrades: this.trades.length,
|
||||
winRate: winRate,
|
||||
winningTrades,
|
||||
losingTrades,
|
||||
totalReturn: (currentPerformance.totalReturn || 0) + totalPnl / 10000, // Approximate
|
||||
};
|
||||
|
||||
// Update strategy performance as well
|
||||
if (this.strategy && this.strategy.performance) {
|
||||
this.strategy.performance = {
|
||||
...this.strategy.performance,
|
||||
totalTrades: this.trades.length,
|
||||
winRate: winRate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'ACTIVE':
|
||||
return 'green';
|
||||
case 'PAUSED':
|
||||
return 'orange';
|
||||
case 'ERROR':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
getSignalColor(action: string): string {
|
||||
switch (action) {
|
||||
case 'BUY':
|
||||
return 'green';
|
||||
case 'SELL':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the backtest dialog to run a backtest for this strategy
|
||||
*/
|
||||
openBacktestDialog(): void {
|
||||
if (!this.strategy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogRef = this.dialog.open(BacktestDialogComponent, {
|
||||
width: '800px',
|
||||
data: this.strategy,
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
// Store the backtest result for visualization
|
||||
this.backtestResult = result;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the strategy edit dialog
|
||||
*/
|
||||
openEditDialog(): void {
|
||||
if (!this.strategy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogRef = this.dialog.open(StrategyDialogComponent, {
|
||||
width: '600px',
|
||||
data: this.strategy,
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
// Refresh strategy data after edit
|
||||
this.loadStrategyData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the strategy
|
||||
*/
|
||||
activateStrategy(): void {
|
||||
if (!this.strategy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.strategyService.startStrategy(this.strategy.id).subscribe({
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.strategy!.status = 'ACTIVE';
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
console.error('Error starting strategy:', error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the strategy
|
||||
*/
|
||||
pauseStrategy(): void {
|
||||
if (!this.strategy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.strategyService.pauseStrategy(this.strategy.id).subscribe({
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.strategy!.status = 'PAUSED';
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
console.error('Error pausing strategy:', error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the strategy
|
||||
*/
|
||||
stopStrategy(): void {
|
||||
if (!this.strategy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.strategyService.stopStrategy(this.strategy.id).subscribe({
|
||||
next: response => {
|
||||
if (response.success) {
|
||||
this.strategy!.status = 'INACTIVE';
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
console.error('Error stopping strategy:', error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Methods to generate mock data
|
||||
private generateMockSignals(): any[] {
|
||||
if (!this.strategy) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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)];
|
||||
const action = actions[Math.floor(Math.random() * actions.length)];
|
||||
|
||||
signals.push({
|
||||
id: `sig_${i}`,
|
||||
symbol,
|
||||
action,
|
||||
confidence: 0.7 + Math.random() * 0.3,
|
||||
price: 100 + Math.random() * 50,
|
||||
timestamp: new Date(now.getTime() - i * 1000 * 60 * 30), // 30 min intervals
|
||||
quantity: Math.floor(10 + Math.random() * 90),
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
104
apps/dashboard/src/app/services/api.service.ts
Normal file
104
apps/dashboard/src/app/services/api.service.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface RiskThresholds {
|
||||
maxPositionSize: number;
|
||||
maxDailyLoss: number;
|
||||
maxPortfolioRisk: number;
|
||||
volatilityLimit: number;
|
||||
}
|
||||
|
||||
export interface RiskEvaluation {
|
||||
symbol: string;
|
||||
positionValue: number;
|
||||
positionRisk: number;
|
||||
violations: string[];
|
||||
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
}
|
||||
|
||||
export interface MarketData {
|
||||
symbol: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
volume: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ApiService {
|
||||
private readonly baseUrls = {
|
||||
riskGuardian: 'http://localhost:3002',
|
||||
strategyOrchestrator: 'http://localhost:3003',
|
||||
marketDataGateway: 'http://localhost:3001',
|
||||
};
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Risk Guardian API
|
||||
getRiskThresholds(): Observable<{ success: boolean; data: RiskThresholds }> {
|
||||
return this.http.get<{ success: boolean; data: RiskThresholds }>(
|
||||
`${this.baseUrls.riskGuardian}/api/risk/thresholds`
|
||||
);
|
||||
}
|
||||
|
||||
updateRiskThresholds(
|
||||
thresholds: RiskThresholds
|
||||
): Observable<{ success: boolean; data: RiskThresholds }> {
|
||||
return this.http.put<{ success: boolean; data: RiskThresholds }>(
|
||||
`${this.baseUrls.riskGuardian}/api/risk/thresholds`,
|
||||
thresholds
|
||||
);
|
||||
}
|
||||
|
||||
evaluateRisk(params: {
|
||||
symbol: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
portfolioValue: number;
|
||||
}): Observable<{ success: boolean; data: RiskEvaluation }> {
|
||||
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`
|
||||
);
|
||||
}
|
||||
|
||||
// Strategy Orchestrator API
|
||||
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`,
|
||||
strategy
|
||||
);
|
||||
}
|
||||
// Market Data Gateway API
|
||||
getMarketData(
|
||||
symbols: string[] = ['AAPL', 'GOOGL', 'MSFT', 'TSLA', 'AMZN']
|
||||
): Observable<{ success: boolean; data: MarketData[] }> {
|
||||
const symbolsParam = symbols.join(',');
|
||||
return this.http.get<{ success: boolean; data: MarketData[] }>(
|
||||
`${this.baseUrls.marketDataGateway}/api/market-data?symbols=${symbolsParam}`
|
||||
);
|
||||
}
|
||||
|
||||
// Health checks
|
||||
checkServiceHealth(
|
||||
service: 'riskGuardian' | 'strategyOrchestrator' | 'marketDataGateway'
|
||||
): Observable<any> {
|
||||
return this.http.get(`${this.baseUrls[service]}/health`);
|
||||
}
|
||||
}
|
||||
191
apps/dashboard/src/app/services/notification.service.ts
Normal file
191
apps/dashboard/src/app/services/notification.service.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { RiskAlert, WebSocketService } from './websocket.service';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: 'info' | 'warning' | 'error' | 'success';
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class NotificationService {
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private webSocketService = inject(WebSocketService);
|
||||
private riskAlertsSubscription?: Subscription;
|
||||
|
||||
// Reactive state
|
||||
public notifications = signal<Notification[]>([]);
|
||||
public unreadCount = signal<number>(0);
|
||||
|
||||
constructor() {
|
||||
this.initializeRiskAlerts();
|
||||
}
|
||||
|
||||
private initializeRiskAlerts() {
|
||||
// Subscribe to risk alerts from WebSocket
|
||||
this.riskAlertsSubscription = this.webSocketService.getRiskAlerts().subscribe({
|
||||
next: (alert: RiskAlert) => {
|
||||
this.handleRiskAlert(alert);
|
||||
},
|
||||
error: err => {
|
||||
console.error('Risk alert subscription error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private handleRiskAlert(alert: RiskAlert) {
|
||||
const notification: Notification = {
|
||||
id: alert.id,
|
||||
type: this.mapSeverityToType(alert.severity),
|
||||
title: `Risk Alert: ${alert.symbol}`,
|
||||
message: alert.message,
|
||||
timestamp: new Date(alert.timestamp),
|
||||
read: false,
|
||||
};
|
||||
|
||||
this.addNotification(notification);
|
||||
this.showSnackBarAlert(notification);
|
||||
}
|
||||
|
||||
private mapSeverityToType(severity: string): 'info' | 'warning' | 'error' | 'success' {
|
||||
switch (severity) {
|
||||
case 'HIGH':
|
||||
return 'error';
|
||||
case 'MEDIUM':
|
||||
return 'warning';
|
||||
case 'LOW':
|
||||
return 'info';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
private showSnackBarAlert(notification: Notification) {
|
||||
const actionText = notification.type === 'error' ? 'Review' : 'Dismiss';
|
||||
const duration = notification.type === 'error' ? 10000 : 5000;
|
||||
|
||||
this.snackBar.open(`${notification.title}: ${notification.message}`, actionText, {
|
||||
duration,
|
||||
panelClass: [`snack-${notification.type}`],
|
||||
});
|
||||
}
|
||||
|
||||
// Public methods
|
||||
addNotification(notification: Notification) {
|
||||
const current = this.notifications();
|
||||
const updated = [notification, ...current].slice(0, 50); // Keep only latest 50
|
||||
this.notifications.set(updated);
|
||||
this.updateUnreadCount();
|
||||
}
|
||||
|
||||
markAsRead(notificationId: string) {
|
||||
const current = this.notifications();
|
||||
const updated = current.map(n => (n.id === notificationId ? { ...n, read: true } : n));
|
||||
this.notifications.set(updated);
|
||||
this.updateUnreadCount();
|
||||
}
|
||||
|
||||
markAllAsRead() {
|
||||
const current = this.notifications();
|
||||
const updated = current.map(n => ({ ...n, read: true }));
|
||||
this.notifications.set(updated);
|
||||
this.updateUnreadCount();
|
||||
}
|
||||
|
||||
clearNotification(notificationId: string) {
|
||||
const current = this.notifications();
|
||||
const updated = current.filter(n => n.id !== notificationId);
|
||||
this.notifications.set(updated);
|
||||
this.updateUnreadCount();
|
||||
}
|
||||
|
||||
clearAllNotifications() {
|
||||
this.notifications.set([]);
|
||||
this.unreadCount.set(0);
|
||||
}
|
||||
|
||||
private updateUnreadCount() {
|
||||
const unread = this.notifications().filter(n => !n.read).length;
|
||||
this.unreadCount.set(unread);
|
||||
}
|
||||
|
||||
// Manual notification methods
|
||||
showSuccess(title: string, message: string) {
|
||||
const notification: Notification = {
|
||||
id: this.generateId(),
|
||||
type: 'success',
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 3000,
|
||||
panelClass: ['snack-success'],
|
||||
});
|
||||
}
|
||||
|
||||
showError(title: string, message: string) {
|
||||
const notification: Notification = {
|
||||
id: this.generateId(),
|
||||
type: 'error',
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 8000,
|
||||
panelClass: ['snack-error'],
|
||||
});
|
||||
}
|
||||
|
||||
showWarning(title: string, message: string) {
|
||||
const notification: Notification = {
|
||||
id: this.generateId(),
|
||||
type: 'warning',
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 5000,
|
||||
panelClass: ['snack-warning'],
|
||||
});
|
||||
}
|
||||
|
||||
showInfo(title: string, message: string) {
|
||||
const notification: Notification = {
|
||||
id: this.generateId(),
|
||||
type: 'info',
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
};
|
||||
this.addNotification(notification);
|
||||
this.snackBar.open(`${title}: ${message}`, 'Dismiss', {
|
||||
duration: 4000,
|
||||
panelClass: ['snack-info'],
|
||||
});
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.riskAlertsSubscription?.unsubscribe();
|
||||
}
|
||||
}
|
||||
238
apps/dashboard/src/app/services/strategy.service.ts
Normal file
238
apps/dashboard/src/app/services/strategy.service.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface TradingStrategy {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'PAUSED' | 'ERROR';
|
||||
type: string;
|
||||
symbols: string[];
|
||||
parameters: Record<string, any>;
|
||||
performance: {
|
||||
totalTrades: number;
|
||||
winRate: number;
|
||||
totalReturn: number;
|
||||
sharpeRatio: number;
|
||||
maxDrawdown: number;
|
||||
};
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface BacktestRequest {
|
||||
strategyType: string;
|
||||
strategyParams: Record<string, any>;
|
||||
symbols: string[];
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
initialCapital: number;
|
||||
dataResolution: '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d';
|
||||
commission: number;
|
||||
slippage: number;
|
||||
mode: 'event' | 'vector';
|
||||
}
|
||||
|
||||
export interface BacktestResult {
|
||||
strategyId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
duration: number;
|
||||
initialCapital: number;
|
||||
finalCapital: number;
|
||||
totalReturn: number;
|
||||
annualizedReturn: number;
|
||||
sharpeRatio: number;
|
||||
maxDrawdown: number;
|
||||
maxDrawdownDuration: number;
|
||||
winRate: number;
|
||||
totalTrades: number;
|
||||
winningTrades: number;
|
||||
losingTrades: number;
|
||||
averageWinningTrade: number;
|
||||
averageLosingTrade: number;
|
||||
profitFactor: number;
|
||||
dailyReturns: Array<{ date: Date; return: number }>;
|
||||
trades: Array<{
|
||||
symbol: string;
|
||||
entryTime: Date;
|
||||
entryPrice: number;
|
||||
exitTime: Date;
|
||||
exitPrice: number;
|
||||
quantity: number;
|
||||
pnl: number;
|
||||
pnlPercent: number;
|
||||
}>;
|
||||
// Advanced metrics
|
||||
sortinoRatio?: number;
|
||||
calmarRatio?: number;
|
||||
omegaRatio?: number;
|
||||
cagr?: number;
|
||||
volatility?: number;
|
||||
ulcerIndex?: number;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StrategyService {
|
||||
private apiBaseUrl = '/api'; // Will be proxied to the correct backend endpoint
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
// Strategy Management
|
||||
getStrategies(): Observable<ApiResponse<TradingStrategy[]>> {
|
||||
return this.http.get<ApiResponse<TradingStrategy[]>>(`${this.apiBaseUrl}/strategies`);
|
||||
}
|
||||
|
||||
getStrategy(id: string): Observable<ApiResponse<TradingStrategy>> {
|
||||
return this.http.get<ApiResponse<TradingStrategy>>(`${this.apiBaseUrl}/strategies/${id}`);
|
||||
}
|
||||
|
||||
createStrategy(strategy: Partial<TradingStrategy>): Observable<ApiResponse<TradingStrategy>> {
|
||||
return this.http.post<ApiResponse<TradingStrategy>>(`${this.apiBaseUrl}/strategies`, strategy);
|
||||
}
|
||||
|
||||
updateStrategy(
|
||||
id: string,
|
||||
updates: Partial<TradingStrategy>
|
||||
): Observable<ApiResponse<TradingStrategy>> {
|
||||
return this.http.put<ApiResponse<TradingStrategy>>(
|
||||
`${this.apiBaseUrl}/strategies/${id}`,
|
||||
updates
|
||||
);
|
||||
}
|
||||
|
||||
startStrategy(id: string): Observable<ApiResponse<TradingStrategy>> {
|
||||
return this.http.post<ApiResponse<TradingStrategy>>(
|
||||
`${this.apiBaseUrl}/strategies/${id}/start`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
stopStrategy(id: string): Observable<ApiResponse<TradingStrategy>> {
|
||||
return this.http.post<ApiResponse<TradingStrategy>>(
|
||||
`${this.apiBaseUrl}/strategies/${id}/stop`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
pauseStrategy(id: string): Observable<ApiResponse<TradingStrategy>> {
|
||||
return this.http.post<ApiResponse<TradingStrategy>>(
|
||||
`${this.apiBaseUrl}/strategies/${id}/pause`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
// Backtest Management
|
||||
getStrategyTypes(): Observable<ApiResponse<string[]>> {
|
||||
return this.http.get<ApiResponse<string[]>>(`${this.apiBaseUrl}/strategy-types`);
|
||||
}
|
||||
|
||||
getStrategyParameters(type: string): Observable<ApiResponse<Record<string, any>>> {
|
||||
return this.http.get<ApiResponse<Record<string, any>>>(
|
||||
`${this.apiBaseUrl}/strategy-parameters/${type}`
|
||||
);
|
||||
}
|
||||
|
||||
runBacktest(request: BacktestRequest): Observable<ApiResponse<BacktestResult>> {
|
||||
return this.http.post<ApiResponse<BacktestResult>>(`${this.apiBaseUrl}/backtest`, request);
|
||||
}
|
||||
getBacktestResult(id: string): Observable<ApiResponse<BacktestResult>> {
|
||||
return this.http.get<ApiResponse<BacktestResult>>(`${this.apiBaseUrl}/backtest/${id}`);
|
||||
}
|
||||
|
||||
optimizeStrategy(
|
||||
baseRequest: BacktestRequest,
|
||||
parameterGrid: Record<string, any[]>
|
||||
): Observable<ApiResponse<Array<BacktestResult & { parameters: Record<string, any> }>>> {
|
||||
return this.http.post<ApiResponse<Array<BacktestResult & { parameters: Record<string, any> }>>>(
|
||||
`${this.apiBaseUrl}/backtest/optimize`,
|
||||
{ baseRequest, parameterGrid }
|
||||
);
|
||||
}
|
||||
|
||||
// Strategy Signals and Trades
|
||||
getStrategySignals(strategyId: string): Observable<
|
||||
ApiResponse<
|
||||
Array<{
|
||||
id: string;
|
||||
strategyId: string;
|
||||
symbol: string;
|
||||
action: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
timestamp: Date;
|
||||
confidence: number;
|
||||
metadata?: any;
|
||||
}>
|
||||
>
|
||||
> {
|
||||
return this.http.get<ApiResponse<any[]>>(`${this.apiBaseUrl}/strategies/${strategyId}/signals`);
|
||||
}
|
||||
|
||||
getStrategyTrades(strategyId: string): Observable<
|
||||
ApiResponse<
|
||||
Array<{
|
||||
id: string;
|
||||
strategyId: string;
|
||||
symbol: string;
|
||||
entryPrice: number;
|
||||
entryTime: Date;
|
||||
exitPrice: number;
|
||||
exitTime: Date;
|
||||
quantity: number;
|
||||
pnl: number;
|
||||
pnlPercent: number;
|
||||
}>
|
||||
>
|
||||
> {
|
||||
return this.http.get<ApiResponse<any[]>>(`${this.apiBaseUrl}/strategies/${strategyId}/trades`);
|
||||
}
|
||||
|
||||
// Helper methods for common transformations
|
||||
formatBacktestRequest(formData: any): BacktestRequest {
|
||||
// Handle date formatting and parameter conversion
|
||||
return {
|
||||
...formData,
|
||||
startDate:
|
||||
formData.startDate instanceof Date ? formData.startDate.toISOString() : formData.startDate,
|
||||
endDate: formData.endDate instanceof Date ? formData.endDate.toISOString() : formData.endDate,
|
||||
strategyParams: this.convertParameterTypes(formData.strategyType, formData.strategyParams),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
215
apps/dashboard/src/app/services/websocket.service.ts
Normal file
215
apps/dashboard/src/app/services/websocket.service.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { Injectable, signal } from '@angular/core';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: string;
|
||||
data: any;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface MarketDataUpdate {
|
||||
symbol: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
volume: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface RiskAlert {
|
||||
id: string;
|
||||
symbol: string;
|
||||
alertType: 'POSITION_LIMIT' | 'DAILY_LOSS' | 'VOLATILITY' | 'PORTFOLIO_RISK';
|
||||
message: string;
|
||||
severity: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class WebSocketService {
|
||||
private readonly WS_ENDPOINTS = {
|
||||
marketData: 'ws://localhost:3001/ws',
|
||||
riskGuardian: 'ws://localhost:3002/ws',
|
||||
strategyOrchestrator: 'ws://localhost:3003/ws',
|
||||
};
|
||||
|
||||
private connections = new Map<string, WebSocket>();
|
||||
private messageSubjects = new Map<string, Subject<WebSocketMessage>>();
|
||||
|
||||
// Connection status signals
|
||||
public isConnected = signal<boolean>(false);
|
||||
public connectionStatus = signal<{ [key: string]: boolean }>({
|
||||
marketData: false,
|
||||
riskGuardian: false,
|
||||
strategyOrchestrator: false,
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.initializeConnections();
|
||||
}
|
||||
|
||||
private initializeConnections() {
|
||||
// Initialize WebSocket connections for all services
|
||||
Object.entries(this.WS_ENDPOINTS).forEach(([service, url]) => {
|
||||
this.connect(service, url);
|
||||
});
|
||||
}
|
||||
|
||||
private connect(serviceName: string, url: string) {
|
||||
try {
|
||||
const ws = new WebSocket(url);
|
||||
const messageSubject = new Subject<WebSocketMessage>();
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log(`Connected to ${serviceName} WebSocket`);
|
||||
this.updateConnectionStatus(serviceName, true);
|
||||
};
|
||||
|
||||
ws.onmessage = event => {
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data);
|
||||
messageSubject.next(message);
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse WebSocket message from ${serviceName}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log(`Disconnected from ${serviceName} WebSocket`);
|
||||
this.updateConnectionStatus(serviceName, false);
|
||||
|
||||
// Attempt to reconnect after 5 seconds
|
||||
setTimeout(() => {
|
||||
this.connect(serviceName, url);
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
ws.onerror = error => {
|
||||
console.error(`WebSocket error for ${serviceName}:`, error);
|
||||
this.updateConnectionStatus(serviceName, false);
|
||||
};
|
||||
|
||||
this.connections.set(serviceName, ws);
|
||||
this.messageSubjects.set(serviceName, messageSubject);
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect to ${serviceName} WebSocket:`, error);
|
||||
this.updateConnectionStatus(serviceName, false);
|
||||
}
|
||||
}
|
||||
|
||||
private updateConnectionStatus(serviceName: string, isConnected: boolean) {
|
||||
const currentStatus = this.connectionStatus();
|
||||
const newStatus = { ...currentStatus, [serviceName]: isConnected };
|
||||
this.connectionStatus.set(newStatus);
|
||||
|
||||
// Update overall connection status
|
||||
const overallConnected = Object.values(newStatus).some(status => status);
|
||||
this.isConnected.set(overallConnected);
|
||||
}
|
||||
|
||||
// Market Data Updates
|
||||
getMarketDataUpdates(): Observable<MarketDataUpdate> {
|
||||
const subject = this.messageSubjects.get('marketData');
|
||||
if (!subject) {
|
||||
throw new Error('Market data WebSocket not initialized');
|
||||
}
|
||||
|
||||
return subject.asObservable().pipe(
|
||||
filter(message => message.type === 'market_data_update'),
|
||||
map(message => message.data as MarketDataUpdate)
|
||||
);
|
||||
}
|
||||
|
||||
// Risk Alerts
|
||||
getRiskAlerts(): Observable<RiskAlert> {
|
||||
const subject = this.messageSubjects.get('riskGuardian');
|
||||
if (!subject) {
|
||||
throw new Error('Risk Guardian WebSocket not initialized');
|
||||
}
|
||||
|
||||
return subject.asObservable().pipe(
|
||||
filter(message => message.type === 'risk_alert'),
|
||||
map(message => message.data as RiskAlert)
|
||||
);
|
||||
}
|
||||
// Strategy Updates
|
||||
getStrategyUpdates(): Observable<any> {
|
||||
const subject = this.messageSubjects.get('strategyOrchestrator');
|
||||
if (!subject) {
|
||||
throw new Error('Strategy Orchestrator WebSocket not initialized');
|
||||
}
|
||||
|
||||
return subject.asObservable().pipe(
|
||||
filter(message => message.type === 'strategy_update'),
|
||||
map(message => message.data)
|
||||
);
|
||||
}
|
||||
|
||||
// Strategy Signals
|
||||
getStrategySignals(strategyId?: string): Observable<any> {
|
||||
const subject = this.messageSubjects.get('strategyOrchestrator');
|
||||
if (!subject) {
|
||||
throw new Error('Strategy Orchestrator WebSocket not initialized');
|
||||
}
|
||||
|
||||
return subject.asObservable().pipe(
|
||||
filter(
|
||||
message =>
|
||||
message.type === 'strategy_signal' &&
|
||||
(!strategyId || message.data.strategyId === strategyId)
|
||||
),
|
||||
map(message => message.data)
|
||||
);
|
||||
}
|
||||
|
||||
// Strategy Trades
|
||||
getStrategyTrades(strategyId?: string): Observable<any> {
|
||||
const subject = this.messageSubjects.get('strategyOrchestrator');
|
||||
if (!subject) {
|
||||
throw new Error('Strategy Orchestrator WebSocket not initialized');
|
||||
}
|
||||
|
||||
return subject.asObservable().pipe(
|
||||
filter(
|
||||
message =>
|
||||
message.type === 'strategy_trade' &&
|
||||
(!strategyId || message.data.strategyId === strategyId)
|
||||
),
|
||||
map(message => message.data)
|
||||
);
|
||||
}
|
||||
|
||||
// All strategy-related messages, useful for components that need all types
|
||||
getAllStrategyMessages(): Observable<WebSocketMessage> {
|
||||
const subject = this.messageSubjects.get('strategyOrchestrator');
|
||||
if (!subject) {
|
||||
throw new Error('Strategy Orchestrator WebSocket not initialized');
|
||||
}
|
||||
|
||||
return subject.asObservable().pipe(filter(message => message.type.startsWith('strategy_')));
|
||||
}
|
||||
|
||||
// Send messages
|
||||
sendMessage(serviceName: string, message: any) {
|
||||
const ws = this.connections.get(serviceName);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn(`Cannot send message to ${serviceName}: WebSocket not connected`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
disconnect() {
|
||||
this.connections.forEach((ws, _serviceName) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
this.connections.clear();
|
||||
this.messageSubjects.clear();
|
||||
}
|
||||
}
|
||||
0
apps/dashboard/src/components/DashboardLayout.tsx
Normal file
0
apps/dashboard/src/components/DashboardLayout.tsx
Normal file
0
apps/dashboard/src/components/TradingDashboard.tsx
Normal file
0
apps/dashboard/src/components/TradingDashboard.tsx
Normal file
0
apps/dashboard/src/index.css
Normal file
0
apps/dashboard/src/index.css
Normal file
16
apps/dashboard/src/index.html
Normal file
16
apps/dashboard/src/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Trading Dashboard</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
</head>
|
||||
<body class="mat-typography">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
5
apps/dashboard/src/main.ts
Normal file
5
apps/dashboard/src/main.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { App } from './app/app';
|
||||
import { appConfig } from './app/app.config';
|
||||
|
||||
bootstrapApplication(App, appConfig).catch(err => console.error(err));
|
||||
89
apps/dashboard/src/styles.css
Normal file
89
apps/dashboard/src/styles.css
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* Custom base styles */
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Angular Material integration styles */
|
||||
.mat-sidenav-container {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.mat-sidenav {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.mat-toolbar {
|
||||
background-color: white;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.mat-mdc-button.w-full {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.mat-mdc-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.mat-mdc-tab-group .mat-mdc-tab-header {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.mat-mdc-chip.chip-green {
|
||||
background-color: #dcfce7 !important;
|
||||
color: #166534 !important;
|
||||
}
|
||||
|
||||
.mat-mdc-chip.chip-blue {
|
||||
background-color: #dbeafe !important;
|
||||
color: #1e40af !important;
|
||||
}
|
||||
|
||||
.mat-mdc-table {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mat-mdc-header-row {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.mat-mdc-row:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .mat-toolbar {
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.dark .mat-mdc-tab-group .mat-mdc-tab-header {
|
||||
border-bottom: 1px solid #4b5563;
|
||||
}
|
||||
|
||||
.dark .mat-mdc-header-row {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
.dark .mat-mdc-row:hover {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.dark .mat-mdc-card {
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.dark .mat-mdc-table {
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
}
|
||||
0
apps/dashboard/src/styles.scss
Normal file
0
apps/dashboard/src/styles.scss
Normal file
52
apps/dashboard/tailwind.config.js
Normal file
52
apps/dashboard/tailwind.config.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{html,ts}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
950: '#052e16',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
950: '#450a0a',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
11
apps/dashboard/tsconfig.app.json
Normal file
11
apps/dashboard/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/* 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. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts"]
|
||||
}
|
||||
32
apps/dashboard/tsconfig.json
Normal file
32
apps/dashboard/tsconfig.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/* 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. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"typeCheckHostBindings": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
apps/dashboard/tsconfig.spec.json
Normal file
10
apps/dashboard/tsconfig.spec.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/* 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. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": ["jasmine"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -1,28 +1,33 @@
|
|||
{
|
||||
"name": "@stock-bot/web-api",
|
||||
"name": "@stock-bot/data-service",
|
||||
"version": "1.0.0",
|
||||
"description": "REST API service for stock bot web application",
|
||||
"description": "Combined data ingestion and historical data service",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"build": "bun build src/index.ts --outdir dist --target node --external chromium-bidi --external electron --external playwright --external playwright-core",
|
||||
"build": "bun build src/index.ts --outdir dist --target node",
|
||||
"start": "bun dist/index.js",
|
||||
"test": "bun test",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stock-bot/cache": "*",
|
||||
"@stock-bot/config": "*",
|
||||
"@stock-bot/stock-config": "*",
|
||||
"@stock-bot/event-bus": "*",
|
||||
"@stock-bot/http": "*",
|
||||
"@stock-bot/logger": "*",
|
||||
"@stock-bot/mongodb": "*",
|
||||
"@stock-bot/postgres": "*",
|
||||
"@stock-bot/mongodb-client": "*",
|
||||
"@stock-bot/questdb-client": "*",
|
||||
"@stock-bot/shutdown": "*",
|
||||
"@stock-bot/handlers": "*",
|
||||
"@stock-bot/di": "*",
|
||||
"hono": "^4.0.0"
|
||||
"@stock-bot/types": "*",
|
||||
"bullmq": "^5.53.2",
|
||||
"hono": "^4.0.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"ws": "^8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
112
apps/data-service/src/index.ts
Normal file
112
apps/data-service/src/index.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* Data Service - Combined live and historical data ingestion with queue-based architecture
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import { Browser } from '@stock-bot/browser';
|
||||
import { loadEnvVariables } from '@stock-bot/config';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { Shutdown } from '@stock-bot/shutdown';
|
||||
import { initializeIBResources } from './providers/ib.tasks';
|
||||
import { initializeProxyResources } from './providers/proxy.tasks';
|
||||
import { queueManager } from './services/queue.service';
|
||||
import { initializeBatchCache } from './utils/batch-helpers';
|
||||
import { healthRoutes, marketDataRoutes, proxyRoutes, queueRoutes, testRoutes } from './routes';
|
||||
|
||||
// Load environment variables
|
||||
loadEnvVariables();
|
||||
|
||||
const app = new Hono();
|
||||
const logger = getLogger('data-service');
|
||||
const PORT = parseInt(process.env.DATA_SERVICE_PORT || '3002');
|
||||
let server: any = null;
|
||||
|
||||
// Initialize shutdown manager with 15 second timeout
|
||||
const shutdown = Shutdown.getInstance({ timeout: 15000 });
|
||||
|
||||
// Register all routes
|
||||
app.route('', healthRoutes);
|
||||
app.route('', queueRoutes);
|
||||
app.route('', marketDataRoutes);
|
||||
app.route('', proxyRoutes);
|
||||
app.route('', testRoutes);
|
||||
|
||||
// Initialize services
|
||||
async function initializeServices() {
|
||||
logger.info('Initializing data service...');
|
||||
|
||||
try {
|
||||
// Initialize browser resources
|
||||
logger.info('Starting browser resources initialization...');
|
||||
await Browser.initialize();
|
||||
logger.info('Browser resources initialized');
|
||||
|
||||
// Initialize batch cache FIRST - before queue service
|
||||
logger.info('Starting batch cache initialization...');
|
||||
await initializeBatchCache();
|
||||
logger.info('Batch cache initialized');
|
||||
|
||||
// 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');
|
||||
|
||||
// Initialize proxy cache - before queue service
|
||||
logger.info('Starting proxy cache initialization...');
|
||||
await initializeIBResources(true); // Wait for cache during startup
|
||||
logger.info('Proxy cache initialized');
|
||||
|
||||
// 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();
|
||||
// Start the HTTP server using Bun's native serve
|
||||
server = Bun.serve({
|
||||
port: PORT,
|
||||
fetch: app.fetch,
|
||||
development: process.env.NODE_ENV === 'development',
|
||||
});
|
||||
logger.info(`Data Service started on port ${PORT}`);
|
||||
}
|
||||
|
||||
// Register shutdown handlers
|
||||
shutdown.onShutdown(async () => {
|
||||
if (server) {
|
||||
logger.info('Stopping HTTP server...');
|
||||
try {
|
||||
server.stop();
|
||||
logger.info('HTTP server stopped successfully');
|
||||
} catch (error) {
|
||||
logger.error('Error stopping HTTP server', { error });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
shutdown.onShutdown(async () => {
|
||||
logger.info('Shutting down queue manager...');
|
||||
try {
|
||||
await queueManager.shutdown();
|
||||
logger.info('Queue manager shut down successfully');
|
||||
} catch (error) {
|
||||
logger.error('Error shutting down queue manager', { error });
|
||||
throw error; // Re-throw to mark shutdown as failed
|
||||
}
|
||||
});
|
||||
|
||||
// Start the application
|
||||
startServer().catch(error => {
|
||||
logger.error('Failed to start server', { error });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
logger.info('Data service startup initiated with graceful shutdown handlers');
|
||||
32
apps/data-service/src/providers/ib.provider.ts
Normal file
32
apps/data-service/src/providers/ib.provider.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
};
|
||||
152
apps/data-service/src/providers/ib.tasks.ts
Normal file
152
apps/data-service/src/providers/ib.tasks.ts
Normal 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,
|
||||
};
|
||||
98
apps/data-service/src/providers/proxy.provider.ts
Normal file
98
apps/data-service/src/providers/proxy.provider.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { ProxyInfo } from 'libs/http/src/types';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { ProviderConfig } from '../services/provider-registry.service';
|
||||
|
||||
// Create logger for this provider
|
||||
const logger = getLogger('proxy-provider');
|
||||
|
||||
// This will run at the same time each day as when the app started
|
||||
const getEvery24HourCron = (): string => {
|
||||
const now = new Date();
|
||||
const hours = now.getHours();
|
||||
const minutes = now.getMinutes();
|
||||
return `${minutes} ${hours} * * *`; // Every day at startup time
|
||||
};
|
||||
|
||||
export const proxyProvider: ProviderConfig = {
|
||||
name: 'proxy-provider',
|
||||
operations: {
|
||||
'fetch-and-check': async (_payload: { sources?: string[] }) => {
|
||||
const { proxyService } = await import('./proxy.tasks');
|
||||
const { queueManager } = await import('../services/queue.service');
|
||||
const { processItems } = await import('../utils/batch-helpers');
|
||||
|
||||
const proxies = await proxyService.fetchProxiesFromSources();
|
||||
|
||||
if (proxies.length === 0) {
|
||||
return { proxiesFetched: 0, jobsCreated: 0 };
|
||||
}
|
||||
|
||||
// Use generic function with routing parameters
|
||||
const result = await processItems(
|
||||
proxies,
|
||||
(proxy, index) => ({
|
||||
proxy,
|
||||
index,
|
||||
source: 'batch-processing',
|
||||
}),
|
||||
queueManager,
|
||||
{
|
||||
totalDelayHours: 12, //parseFloat(process.env.PROXY_VALIDATION_HOURS || '1'),
|
||||
batchSize: parseInt(process.env.PROXY_BATCH_SIZE || '200'),
|
||||
useBatching: process.env.PROXY_DIRECT_MODE !== 'true',
|
||||
provider: 'proxy-provider',
|
||||
operation: 'check-proxy',
|
||||
}
|
||||
);
|
||||
return result;
|
||||
},
|
||||
'process-batch-items': async (payload: any) => {
|
||||
// Process a batch using the simplified batch helpers
|
||||
const { processBatchJob } = await import('../utils/batch-helpers');
|
||||
const { queueManager } = await import('../services/queue.service');
|
||||
|
||||
return await processBatchJob(payload, queueManager);
|
||||
},
|
||||
|
||||
'check-proxy': async (payload: {
|
||||
proxy: ProxyInfo;
|
||||
source?: string;
|
||||
batchIndex?: number;
|
||||
itemIndex?: number;
|
||||
total?: number;
|
||||
}) => {
|
||||
const { checkProxy } = await import('./proxy.tasks');
|
||||
|
||||
try {
|
||||
const result = await checkProxy(payload.proxy);
|
||||
|
||||
logger.debug('Proxy validated', {
|
||||
proxy: `${payload.proxy.host}:${payload.proxy.port}`,
|
||||
isWorking: result.isWorking,
|
||||
responseTime: result.responseTime,
|
||||
});
|
||||
|
||||
return { result, proxy: payload.proxy };
|
||||
} catch (error) {
|
||||
logger.warn('Proxy validation failed', {
|
||||
proxy: `${payload.proxy.host}:${payload.proxy.port}`,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return { result: { isWorking: false, error: String(error) }, proxy: payload.proxy };
|
||||
}
|
||||
},
|
||||
},
|
||||
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',
|
||||
// },
|
||||
],
|
||||
};
|
||||
574
apps/data-service/src/providers/proxy.tasks.ts
Normal file
574
apps/data-service/src/providers/proxy.tasks.ts
Normal file
|
|
@ -0,0 +1,574 @@
|
|||
import { createCache, type CacheProvider } from '@stock-bot/cache';
|
||||
import { HttpClient, ProxyInfo } from '@stock-bot/http';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
|
||||
// Type definitions
|
||||
export interface ProxySource {
|
||||
id: string;
|
||||
url: string;
|
||||
protocol: string;
|
||||
working?: number; // Optional, used for stats
|
||||
total?: number; // Optional, used for stats
|
||||
percentWorking?: number; // Optional, used for stats
|
||||
lastChecked?: Date; // Optional, used for stats
|
||||
}
|
||||
|
||||
// Shared configuration and utilities
|
||||
const PROXY_CONFIG = {
|
||||
CACHE_KEY: 'active',
|
||||
CACHE_STATS_KEY: 'stats',
|
||||
CACHE_TTL: 86400, // 24 hours
|
||||
CHECK_TIMEOUT: 7000,
|
||||
CHECK_IP: '99.246.102.205',
|
||||
CHECK_URL: 'https://proxy-detection.stare.gg/?api_key=bd406bf53ddc6abe1d9de5907830a955',
|
||||
PROXY_SOURCES: [
|
||||
{
|
||||
id: 'prxchk',
|
||||
url: 'https://raw.githubusercontent.com/prxchk/proxy-list/main/http.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'casals',
|
||||
url: 'https://raw.githubusercontent.com/casals-ar/proxy-list/main/http',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'sunny9577',
|
||||
url: 'https://raw.githubusercontent.com/sunny9577/proxy-scraper/master/proxies.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'themiralay',
|
||||
url: 'https://raw.githubusercontent.com/themiralay/Proxy-List-World/refs/heads/master/data.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'casa-ls',
|
||||
url: 'https://raw.githubusercontent.com/casa-ls/proxy-list/refs/heads/main/http',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'databay',
|
||||
url: 'https://raw.githubusercontent.com/databay-labs/free-proxy-list/refs/heads/master/http.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'speedx',
|
||||
url: 'https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/http.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'monosans',
|
||||
url: 'https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/http.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'murong',
|
||||
url: 'https://raw.githubusercontent.com/MuRongPIG/Proxy-Master/main/http.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'vakhov-fresh',
|
||||
url: 'https://raw.githubusercontent.com/vakhov/fresh-proxy-list/master/http.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'kangproxy',
|
||||
url: 'https://raw.githubusercontent.com/officialputuid/KangProxy/refs/heads/KangProxy/http/http.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'gfpcom',
|
||||
url: 'https://raw.githubusercontent.com/gfpcom/free-proxy-list/refs/heads/main/list/http.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'dpangestuw',
|
||||
url: 'https://raw.githubusercontent.com/dpangestuw/Free-Proxy/refs/heads/main/http_proxies.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'gitrecon',
|
||||
url: 'https://raw.githubusercontent.com/gitrecon1455/fresh-proxy-list/refs/heads/main/proxylist.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'vakhov-master',
|
||||
url: 'https://raw.githubusercontent.com/vakhov/fresh-proxy-list/refs/heads/master/http.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'breaking-tech',
|
||||
url: 'https://raw.githubusercontent.com/BreakingTechFr/Proxy_Free/refs/heads/main/proxies/http.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'ercindedeoglu',
|
||||
url: 'https://raw.githubusercontent.com/ErcinDedeoglu/proxies/main/proxies/http.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'tuanminpay',
|
||||
url: 'https://raw.githubusercontent.com/TuanMinPay/live-proxy/master/http.txt',
|
||||
protocol: 'http',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'r00tee-https',
|
||||
url: 'https://raw.githubusercontent.com/r00tee/Proxy-List/refs/heads/main/Https.txt',
|
||||
protocol: 'https',
|
||||
},
|
||||
{
|
||||
id: 'ercindedeoglu-https',
|
||||
url: 'https://raw.githubusercontent.com/ErcinDedeoglu/proxies/main/proxies/https.txt',
|
||||
protocol: 'https',
|
||||
},
|
||||
{
|
||||
id: 'vakhov-fresh-https',
|
||||
url: 'https://raw.githubusercontent.com/vakhov/fresh-proxy-list/refs/heads/master/https.txt',
|
||||
protocol: 'https',
|
||||
},
|
||||
{
|
||||
id: 'databay-https',
|
||||
url: 'https://raw.githubusercontent.com/databay-labs/free-proxy-list/refs/heads/master/https.txt',
|
||||
protocol: 'https',
|
||||
},
|
||||
{
|
||||
id: 'kangproxy-https',
|
||||
url: 'https://raw.githubusercontent.com/officialputuid/KangProxy/refs/heads/KangProxy/https/https.txt',
|
||||
protocol: 'https',
|
||||
},
|
||||
{
|
||||
id: 'zloi-user-https',
|
||||
url: 'https://raw.githubusercontent.com/zloi-user/hideip.me/refs/heads/master/https.txt',
|
||||
protocol: 'https',
|
||||
},
|
||||
{
|
||||
id: 'gfpcom-https',
|
||||
url: 'https://raw.githubusercontent.com/gfpcom/free-proxy-list/refs/heads/main/list/https.txt',
|
||||
protocol: 'https',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Shared instances (module-scoped, not global)
|
||||
let isInitialized = false; // Track if resources are initialized
|
||||
let logger: ReturnType<typeof getLogger>;
|
||||
let cache: CacheProvider;
|
||||
let httpClient: HttpClient;
|
||||
let proxyStats: ProxySource[] = PROXY_CONFIG.PROXY_SOURCES.map(source => ({
|
||||
id: source.id,
|
||||
total: 0,
|
||||
working: 0,
|
||||
lastChecked: new Date(),
|
||||
protocol: source.protocol,
|
||||
url: source.url,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
export async function initializeProxyResources(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: 10000 }, 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;
|
||||
}
|
||||
|
||||
// make a function that takes in source id and a boolean success and updates the proxyStats array
|
||||
async function updateProxyStats(sourceId: string, success: boolean) {
|
||||
const source = proxyStats.find(s => s.id === sourceId);
|
||||
if (source !== undefined) {
|
||||
if (typeof source.working !== 'number') {
|
||||
source.working = 0;
|
||||
}
|
||||
if (typeof source.total !== 'number') {
|
||||
source.total = 0;
|
||||
}
|
||||
source.total += 1;
|
||||
if (success) {
|
||||
source.working += 1;
|
||||
}
|
||||
source.percentWorking = (source.working / source.total) * 100;
|
||||
source.lastChecked = new Date();
|
||||
await cache.set(`${PROXY_CONFIG.CACHE_STATS_KEY}:${source.id}`, source, PROXY_CONFIG.CACHE_TTL);
|
||||
return source;
|
||||
} else {
|
||||
logger.warn(`Unknown proxy source: ${sourceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// make a function that resets proxyStats
|
||||
async function resetProxyStats(): Promise<void> {
|
||||
proxyStats = PROXY_CONFIG.PROXY_SOURCES.map(source => ({
|
||||
id: source.id,
|
||||
total: 0,
|
||||
working: 0,
|
||||
lastChecked: new Date(),
|
||||
protocol: source.protocol,
|
||||
url: source.url,
|
||||
}));
|
||||
for (const source of proxyStats) {
|
||||
await cache.set(`${PROXY_CONFIG.CACHE_STATS_KEY}:${source.id}`, source, PROXY_CONFIG.CACHE_TTL);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update proxy data in cache with working/total stats and average response time
|
||||
* @param proxy - The proxy to update
|
||||
* @param isWorking - Whether the proxy is currently working
|
||||
*/
|
||||
async function updateProxyInCache(proxy: ProxyInfo, isWorking: boolean): Promise<void> {
|
||||
const cacheKey = `${PROXY_CONFIG.CACHE_KEY}:${proxy.protocol}://${proxy.host}:${proxy.port}`;
|
||||
|
||||
try {
|
||||
const existing: any = await cache.get(cacheKey);
|
||||
|
||||
// For failed proxies, only update if they already exist
|
||||
if (!isWorking && !existing) {
|
||||
logger.debug('Proxy not in cache, skipping failed update', {
|
||||
proxy: `${proxy.host}:${proxy.port}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new average response time if we have a response time
|
||||
let newAverageResponseTime = existing?.averageResponseTime;
|
||||
if (proxy.responseTime !== undefined) {
|
||||
const existingAvg = existing?.averageResponseTime || 0;
|
||||
const existingTotal = existing?.total || 0;
|
||||
|
||||
// Calculate weighted average: (existing_avg * existing_count + new_response) / (existing_count + 1)
|
||||
newAverageResponseTime =
|
||||
existingTotal > 0
|
||||
? (existingAvg * existingTotal + proxy.responseTime) / (existingTotal + 1)
|
||||
: proxy.responseTime;
|
||||
}
|
||||
|
||||
// Build updated proxy data
|
||||
const updated = {
|
||||
...existing,
|
||||
...proxy, // Keep latest proxy info
|
||||
total: (existing?.total || 0) + 1,
|
||||
working: isWorking ? (existing?.working || 0) + 1 : existing?.working || 0,
|
||||
isWorking,
|
||||
lastChecked: new Date(),
|
||||
// Add firstSeen only for new entries
|
||||
...(existing ? {} : { firstSeen: new Date() }),
|
||||
// Update average response time if we calculated a new one
|
||||
...(newAverageResponseTime !== undefined
|
||||
? { averageResponseTime: newAverageResponseTime }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Calculate success rate
|
||||
updated.successRate = updated.total > 0 ? (updated.working / updated.total) * 100 : 0;
|
||||
|
||||
// Save to cache: reset TTL for working proxies, keep existing TTL for failed ones
|
||||
const cacheOptions = isWorking ? PROXY_CONFIG.CACHE_TTL : undefined;
|
||||
await cache.set(cacheKey, updated, cacheOptions);
|
||||
|
||||
logger.debug(`Updated ${isWorking ? 'working' : 'failed'} proxy in cache`, {
|
||||
proxy: `${proxy.host}:${proxy.port}`,
|
||||
working: updated.working,
|
||||
total: updated.total,
|
||||
successRate: updated.successRate.toFixed(1) + '%',
|
||||
avgResponseTime: updated.averageResponseTime
|
||||
? `${updated.averageResponseTime.toFixed(0)}ms`
|
||||
: 'N/A',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update proxy in cache', {
|
||||
proxy: `${proxy.host}:${proxy.port}`,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Individual task functions
|
||||
export async function queueProxyFetch(): Promise<string> {
|
||||
const { queueManager } = await import('../services/queue.service');
|
||||
const job = await queueManager.addJob({
|
||||
type: 'proxy-fetch',
|
||||
provider: 'proxy-service',
|
||||
operation: 'fetch-and-check',
|
||||
payload: {},
|
||||
priority: 5,
|
||||
});
|
||||
|
||||
const jobId = job.id || 'unknown';
|
||||
logger.info('Proxy fetch job queued', { jobId });
|
||||
return jobId;
|
||||
}
|
||||
|
||||
export async function queueProxyCheck(proxies: ProxyInfo[]): Promise<string> {
|
||||
const { queueManager } = await import('../services/queue.service');
|
||||
const job = await queueManager.addJob({
|
||||
type: 'proxy-check',
|
||||
provider: 'proxy-service',
|
||||
operation: 'check-specific',
|
||||
payload: { proxies },
|
||||
priority: 3,
|
||||
});
|
||||
|
||||
const jobId = job.id || 'unknown';
|
||||
logger.info('Proxy check job queued', { jobId, count: proxies.length });
|
||||
return jobId;
|
||||
}
|
||||
|
||||
export async function fetchProxiesFromSources(): Promise<ProxyInfo[]> {
|
||||
await resetProxyStats();
|
||||
const fetchPromises = PROXY_CONFIG.PROXY_SOURCES.map(source => fetchProxiesFromSource(source));
|
||||
const results = await Promise.all(fetchPromises);
|
||||
let allProxies: ProxyInfo[] = results.flat();
|
||||
allProxies = removeDuplicateProxies(allProxies);
|
||||
return allProxies;
|
||||
}
|
||||
|
||||
export async function fetchProxiesFromSource(source: ProxySource): Promise<ProxyInfo[]> {
|
||||
const allProxies: ProxyInfo[] = [];
|
||||
|
||||
try {
|
||||
logger.info(`Fetching proxies from ${source.url}`);
|
||||
|
||||
const response = await httpClient.get(source.url, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
logger.warn(`Failed to fetch from ${source.url}: ${response.status}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
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;
|
||||
182
apps/data-service/src/providers/qm.provider.ts
Normal file
182
apps/data-service/src/providers/qm.provider.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { getLogger } from '@stock-bot/logger';
|
||||
import { ProviderConfig } from '../services/provider-registry.service';
|
||||
|
||||
const logger = getLogger('qm-provider');
|
||||
|
||||
export const qmProvider: ProviderConfig = {
|
||||
name: 'qm',
|
||||
operations: {
|
||||
'live-data': async (payload: { symbol: string; fields?: string[] }) => {
|
||||
logger.info('Fetching live data from qm', { symbol: payload.symbol });
|
||||
|
||||
// Simulate qm API call
|
||||
const mockData = {
|
||||
symbol: payload.symbol,
|
||||
price: Math.random() * 1000 + 100,
|
||||
volume: Math.floor(Math.random() * 1000000),
|
||||
change: (Math.random() - 0.5) * 20,
|
||||
changePercent: (Math.random() - 0.5) * 5,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'qm',
|
||||
fields: payload.fields || ['price', 'volume', 'change'],
|
||||
};
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 200));
|
||||
|
||||
return mockData;
|
||||
},
|
||||
|
||||
'historical-data': async (payload: {
|
||||
symbol: string;
|
||||
from: Date;
|
||||
to: Date;
|
||||
interval?: string;
|
||||
fields?: string[];
|
||||
}) => {
|
||||
logger.info('Fetching historical data from qm', {
|
||||
symbol: payload.symbol,
|
||||
from: payload.from,
|
||||
to: payload.to,
|
||||
interval: payload.interval || '1d',
|
||||
});
|
||||
|
||||
// Generate mock historical data
|
||||
const days = Math.ceil(
|
||||
(payload.to.getTime() - payload.from.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const data = [];
|
||||
|
||||
for (let i = 0; i < Math.min(days, 100); i++) {
|
||||
const date = new Date(payload.from.getTime() + i * 24 * 60 * 60 * 1000);
|
||||
data.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
open: Math.random() * 1000 + 100,
|
||||
high: Math.random() * 1000 + 100,
|
||||
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 {
|
||||
symbol: payload.symbol,
|
||||
interval: payload.interval || '1d',
|
||||
data,
|
||||
source: 'qm',
|
||||
totalRecords: data.length,
|
||||
};
|
||||
},
|
||||
'batch-quotes': async (payload: { symbols: string[]; fields?: string[] }) => {
|
||||
logger.info('Fetching batch quotes from qm', {
|
||||
symbols: payload.symbols,
|
||||
count: payload.symbols.length,
|
||||
});
|
||||
|
||||
const quotes = payload.symbols.map(symbol => ({
|
||||
symbol,
|
||||
price: Math.random() * 1000 + 100,
|
||||
volume: Math.floor(Math.random() * 1000000),
|
||||
change: (Math.random() - 0.5) * 20,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'qm',
|
||||
}));
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 300 + Math.random() * 200));
|
||||
|
||||
return {
|
||||
quotes,
|
||||
source: 'qm',
|
||||
timestamp: new Date().toISOString(),
|
||||
totalSymbols: payload.symbols.length,
|
||||
};
|
||||
},
|
||||
'company-profile': async (payload: { symbol: string }) => {
|
||||
logger.info('Fetching company profile from qm', { symbol: payload.symbol });
|
||||
|
||||
// Simulate company profile data
|
||||
const profile = {
|
||||
symbol: payload.symbol,
|
||||
companyName: `${payload.symbol} Corporation`,
|
||||
sector: 'Technology',
|
||||
industry: 'Software',
|
||||
description: `${payload.symbol} is a leading technology company.`,
|
||||
marketCap: Math.floor(Math.random() * 1000000000000),
|
||||
employees: Math.floor(Math.random() * 100000),
|
||||
website: `https://www.${payload.symbol.toLowerCase()}.com`,
|
||||
source: 'qm',
|
||||
};
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 150 + Math.random() * 100));
|
||||
|
||||
return profile;
|
||||
},
|
||||
'options-chain': async (payload: { symbol: string; expiration?: string }) => {
|
||||
logger.info('Fetching options chain from qm', {
|
||||
symbol: payload.symbol,
|
||||
expiration: payload.expiration,
|
||||
});
|
||||
|
||||
// Generate mock options data
|
||||
const strikes = Array.from({ length: 20 }, (_, i) => 100 + i * 5);
|
||||
const calls = strikes.map(strike => ({
|
||||
strike,
|
||||
bid: Math.random() * 10,
|
||||
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,
|
||||
ask: Math.random() * 10 + 0.5,
|
||||
volume: Math.floor(Math.random() * 1000),
|
||||
openInterest: Math.floor(Math.random() * 5000),
|
||||
}));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 400 + Math.random() * 300));
|
||||
return {
|
||||
symbol: payload.symbol,
|
||||
expiration:
|
||||
payload.expiration ||
|
||||
new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
calls,
|
||||
puts,
|
||||
source: 'qm',
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
scheduledJobs: [
|
||||
// {
|
||||
// type: 'qm-premium-refresh',
|
||||
// operation: 'batch-quotes',
|
||||
// payload: { symbols: ['AAPL', 'GOOGL', 'MSFT'] },
|
||||
// cronPattern: '*/2 * * * *', // Every 2 minutes
|
||||
// priority: 7,
|
||||
// description: 'Refresh premium quotes with detailed market data'
|
||||
// },
|
||||
// {
|
||||
// type: 'qm-options-update',
|
||||
// operation: 'options-chain',
|
||||
// payload: { symbol: 'SPY' },
|
||||
// cronPattern: '*/10 * * * *', // Every 10 minutes
|
||||
// priority: 5,
|
||||
// description: 'Update options chain data for SPY ETF'
|
||||
// },
|
||||
// {
|
||||
// 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'
|
||||
// }
|
||||
],
|
||||
};
|
||||
254
apps/data-service/src/providers/yahoo.provider.ts
Normal file
254
apps/data-service/src/providers/yahoo.provider.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { getLogger } from '@stock-bot/logger';
|
||||
import { ProviderConfig } from '../services/provider-registry.service';
|
||||
|
||||
const logger = getLogger('yahoo-provider');
|
||||
|
||||
export const yahooProvider: ProviderConfig = {
|
||||
name: 'yahoo-finance',
|
||||
operations: {
|
||||
'live-data': async (payload: { symbol: string; modules?: string[] }) => {
|
||||
logger.info('Fetching live data from Yahoo Finance', { symbol: payload.symbol });
|
||||
|
||||
// Simulate Yahoo Finance API call
|
||||
const mockData = {
|
||||
symbol: payload.symbol,
|
||||
regularMarketPrice: Math.random() * 1000 + 100,
|
||||
regularMarketVolume: Math.floor(Math.random() * 1000000),
|
||||
regularMarketChange: (Math.random() - 0.5) * 20,
|
||||
regularMarketChangePercent: (Math.random() - 0.5) * 5,
|
||||
preMarketPrice: Math.random() * 1000 + 100,
|
||||
postMarketPrice: Math.random() * 1000 + 100,
|
||||
marketCap: Math.floor(Math.random() * 1000000000000),
|
||||
peRatio: Math.random() * 50 + 5,
|
||||
dividendYield: Math.random() * 0.1,
|
||||
fiftyTwoWeekHigh: Math.random() * 1200 + 100,
|
||||
fiftyTwoWeekLow: Math.random() * 800 + 50,
|
||||
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));
|
||||
|
||||
return mockData;
|
||||
},
|
||||
|
||||
'historical-data': async (payload: {
|
||||
symbol: string;
|
||||
period1: number;
|
||||
period2: number;
|
||||
interval?: string;
|
||||
events?: string;
|
||||
}) => {
|
||||
const { getLogger } = await import('@stock-bot/logger');
|
||||
const logger = getLogger('yahoo-provider');
|
||||
|
||||
logger.info('Fetching historical data from Yahoo Finance', {
|
||||
symbol: payload.symbol,
|
||||
period1: payload.period1,
|
||||
period2: payload.period2,
|
||||
interval: payload.interval || '1d',
|
||||
});
|
||||
|
||||
// Generate mock historical 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;
|
||||
data.push({
|
||||
timestamp,
|
||||
date: new Date(timestamp * 1000).toISOString().split('T')[0],
|
||||
open: Math.random() * 1000 + 100,
|
||||
high: Math.random() * 1000 + 100,
|
||||
low: Math.random() * 1000 + 100,
|
||||
close: Math.random() * 1000 + 100,
|
||||
adjClose: Math.random() * 1000 + 100,
|
||||
volume: Math.floor(Math.random() * 1000000),
|
||||
source: 'yahoo-finance',
|
||||
});
|
||||
}
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 250 + Math.random() * 350));
|
||||
|
||||
return {
|
||||
symbol: payload.symbol,
|
||||
interval: payload.interval || '1d',
|
||||
timestamps: data.map(d => d.timestamp),
|
||||
indicators: {
|
||||
quote: [
|
||||
{
|
||||
open: data.map(d => d.open),
|
||||
high: data.map(d => d.high),
|
||||
low: data.map(d => d.low),
|
||||
close: data.map(d => d.close),
|
||||
volume: data.map(d => d.volume),
|
||||
},
|
||||
],
|
||||
adjclose: [
|
||||
{
|
||||
adjclose: data.map(d => d.adjClose),
|
||||
},
|
||||
],
|
||||
},
|
||||
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');
|
||||
|
||||
logger.info('Searching Yahoo Finance', { query: payload.query });
|
||||
|
||||
// Generate mock search results
|
||||
const quotes = Array.from({ length: payload.quotesCount || 5 }, (_, i) => ({
|
||||
symbol: `${payload.query.toUpperCase()}${i}`,
|
||||
shortname: `${payload.query} Company ${i}`,
|
||||
longname: `${payload.query} Corporation ${i}`,
|
||||
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}`,
|
||||
publisher: 'Financial News',
|
||||
providerPublishTime: Date.now() - i * 3600000,
|
||||
type: 'STORY',
|
||||
source: 'yahoo-finance',
|
||||
}));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200 + Math.random() * 200));
|
||||
|
||||
return {
|
||||
quotes,
|
||||
news,
|
||||
totalQuotes: quotes.length,
|
||||
totalNews: news.length,
|
||||
source: 'yahoo-finance',
|
||||
};
|
||||
},
|
||||
financials: async (payload: { symbol: string; type?: 'income' | 'balance' | 'cash' }) => {
|
||||
const { getLogger } = await import('@stock-bot/logger');
|
||||
const logger = getLogger('yahoo-provider');
|
||||
|
||||
logger.info('Fetching financials from Yahoo Finance', {
|
||||
symbol: payload.symbol,
|
||||
type: payload.type || 'income',
|
||||
});
|
||||
|
||||
// Generate mock financial data
|
||||
const financials = {
|
||||
symbol: payload.symbol,
|
||||
type: payload.type || 'income',
|
||||
currency: 'USD',
|
||||
annual: Array.from({ length: 4 }, (_, i) => ({
|
||||
fiscalYear: 2024 - i,
|
||||
revenue: Math.floor(Math.random() * 100000000000),
|
||||
netIncome: Math.floor(Math.random() * 10000000000),
|
||||
totalAssets: Math.floor(Math.random() * 500000000000),
|
||||
totalDebt: Math.floor(Math.random() * 50000000000),
|
||||
})),
|
||||
quarterly: Array.from({ length: 4 }, (_, i) => ({
|
||||
fiscalQuarter: `Q${4 - i} 2024`,
|
||||
revenue: Math.floor(Math.random() * 25000000000),
|
||||
netIncome: Math.floor(Math.random() * 2500000000),
|
||||
})),
|
||||
source: 'yahoo-finance',
|
||||
};
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 300 + Math.random() * 200));
|
||||
|
||||
return financials;
|
||||
},
|
||||
earnings: async (payload: { symbol: string; period?: 'annual' | 'quarterly' }) => {
|
||||
const { getLogger } = await import('@stock-bot/logger');
|
||||
const logger = getLogger('yahoo-provider');
|
||||
|
||||
logger.info('Fetching earnings from Yahoo Finance', {
|
||||
symbol: payload.symbol,
|
||||
period: payload.period || 'quarterly',
|
||||
});
|
||||
|
||||
// Generate mock earnings data
|
||||
const earnings = {
|
||||
symbol: payload.symbol,
|
||||
period: payload.period || 'quarterly',
|
||||
earnings: Array.from({ length: 8 }, (_, i) => ({
|
||||
quarter: `Q${(i % 4) + 1} ${2024 - Math.floor(i / 4)}`,
|
||||
epsEstimate: Math.random() * 5,
|
||||
epsActual: Math.random() * 5,
|
||||
revenueEstimate: Math.floor(Math.random() * 50000000000),
|
||||
revenueActual: Math.floor(Math.random() * 50000000000),
|
||||
surprise: (Math.random() - 0.5) * 2,
|
||||
})),
|
||||
source: 'yahoo-finance',
|
||||
};
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 250 + Math.random() * 150));
|
||||
|
||||
return earnings;
|
||||
},
|
||||
recommendations: async (payload: { symbol: string }) => {
|
||||
const { getLogger } = await import('@stock-bot/logger');
|
||||
const logger = getLogger('yahoo-provider');
|
||||
|
||||
logger.info('Fetching recommendations from Yahoo Finance', { symbol: payload.symbol });
|
||||
|
||||
// Generate mock recommendations
|
||||
const recommendations = {
|
||||
symbol: payload.symbol,
|
||||
current: {
|
||||
strongBuy: Math.floor(Math.random() * 10),
|
||||
buy: Math.floor(Math.random() * 15),
|
||||
hold: Math.floor(Math.random() * 20),
|
||||
sell: Math.floor(Math.random() * 5),
|
||||
strongSell: Math.floor(Math.random() * 3),
|
||||
},
|
||||
trend: Array.from({ length: 4 }, (_, i) => ({
|
||||
period: `${i}m`,
|
||||
strongBuy: Math.floor(Math.random() * 10),
|
||||
buy: Math.floor(Math.random() * 15),
|
||||
hold: Math.floor(Math.random() * 20),
|
||||
sell: Math.floor(Math.random() * 5),
|
||||
strongSell: Math.floor(Math.random() * 3),
|
||||
})),
|
||||
source: 'yahoo-finance',
|
||||
};
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 180 + Math.random() * 120));
|
||||
return recommendations;
|
||||
},
|
||||
},
|
||||
|
||||
scheduledJobs: [
|
||||
// {
|
||||
// type: 'yahoo-market-refresh',
|
||||
// operation: 'live-data',
|
||||
// payload: { symbol: 'AAPL' },
|
||||
// cronPattern: '*/1 * * * *', // Every minute
|
||||
// priority: 8,
|
||||
// description: 'Refresh Apple stock price from Yahoo Finance'
|
||||
// },
|
||||
// {
|
||||
// type: 'yahoo-sp500-update',
|
||||
// operation: 'live-data',
|
||||
// payload: { symbol: 'SPY' },
|
||||
// cronPattern: '*/2 * * * *', // Every 2 minutes
|
||||
// priority: 9,
|
||||
// description: 'Update S&P 500 ETF price'
|
||||
// },
|
||||
// {
|
||||
// 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'
|
||||
// }
|
||||
],
|
||||
};
|
||||
20
apps/data-service/src/routes/health.routes.ts
Normal file
20
apps/data-service/src/routes/health.routes.ts
Normal 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(),
|
||||
},
|
||||
});
|
||||
});
|
||||
8
apps/data-service/src/routes/index.ts
Normal file
8
apps/data-service/src/routes/index.ts
Normal 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';
|
||||
74
apps/data-service/src/routes/market-data.routes.ts
Normal file
74
apps/data-service/src/routes/market-data.routes.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
76
apps/data-service/src/routes/proxy.routes.ts
Normal file
76
apps/data-service/src/routes/proxy.routes.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
71
apps/data-service/src/routes/queue.routes.ts
Normal file
71
apps/data-service/src/routes/queue.routes.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
87
apps/data-service/src/routes/test.routes.ts
Normal file
87
apps/data-service/src/routes/test.routes.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
135
apps/data-service/src/services/provider-registry.service.ts
Normal file
135
apps/data-service/src/services/provider-registry.service.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { getLogger } from '@stock-bot/logger';
|
||||
|
||||
export interface JobHandler {
|
||||
(payload: any): Promise<any>;
|
||||
}
|
||||
|
||||
export interface JobData {
|
||||
type?: string;
|
||||
provider: string;
|
||||
operation: string;
|
||||
payload: any;
|
||||
priority?: number;
|
||||
immediately?: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduledJob {
|
||||
type: string;
|
||||
operation: string;
|
||||
payload: any;
|
||||
cronPattern: string;
|
||||
priority?: number;
|
||||
description?: string;
|
||||
immediately?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderConfig {
|
||||
name: string;
|
||||
operations: Record<string, JobHandler>;
|
||||
scheduledJobs?: ScheduledJob[];
|
||||
}
|
||||
|
||||
export interface ProviderRegistry {
|
||||
registerProvider: (config: ProviderConfig) => void;
|
||||
getHandler: (provider: string, operation: string) => JobHandler | null;
|
||||
getAllScheduledJobs: () => Array<{ provider: string; job: ScheduledJob }>;
|
||||
getProviders: () => Array<{ key: string; config: ProviderConfig }>;
|
||||
hasProvider: (provider: string) => boolean;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new provider registry instance
|
||||
*/
|
||||
export function createProviderRegistry(): ProviderRegistry {
|
||||
const logger = getLogger('provider-registry');
|
||||
const providers = new Map<string, ProviderConfig>();
|
||||
|
||||
/**
|
||||
* Register a provider with its operations
|
||||
*/
|
||||
function registerProvider(config: ProviderConfig): void {
|
||||
providers.set(config.name, config);
|
||||
logger.info(`Registered provider: ${config.name}`, {
|
||||
operations: Object.keys(config.operations),
|
||||
scheduledJobs: config.scheduledJobs?.length || 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a job handler for a specific provider and operation
|
||||
*/
|
||||
function getHandler(provider: string, operation: string): JobHandler | null {
|
||||
const providerConfig = providers.get(provider);
|
||||
|
||||
if (!providerConfig) {
|
||||
logger.warn(`Provider not found: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const handler = providerConfig.operations[operation];
|
||||
if (!handler) {
|
||||
logger.warn(`Operation not found: ${operation} in provider ${provider}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scheduled jobs from all providers
|
||||
*/
|
||||
function getAllScheduledJobs(): Array<{ provider: string; job: ScheduledJob }> {
|
||||
const allJobs: Array<{ provider: string; job: ScheduledJob }> = [];
|
||||
|
||||
for (const [, config] of providers) {
|
||||
if (config.scheduledJobs) {
|
||||
for (const job of config.scheduledJobs) {
|
||||
allJobs.push({
|
||||
provider: config.name,
|
||||
job,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allJobs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered providers with their configurations
|
||||
*/
|
||||
function getProviders(): Array<{ key: string; config: ProviderConfig }> {
|
||||
return Array.from(providers.entries()).map(([key, config]) => ({
|
||||
key,
|
||||
config,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider exists
|
||||
*/
|
||||
function hasProvider(provider: string): boolean {
|
||||
return providers.has(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
419
apps/data-service/src/services/queue.service.ts
Normal file
419
apps/data-service/src/services/queue.service.ts
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
import { Queue, QueueEvents, Worker, type Job } from 'bullmq';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { providerRegistry, type JobData } from './provider-registry.service';
|
||||
|
||||
export class QueueService {
|
||||
private logger = getLogger('queue-service');
|
||||
private queue!: Queue;
|
||||
private workers: Worker[] = [];
|
||||
private queueEvents!: QueueEvents;
|
||||
|
||||
private config = {
|
||||
workers: parseInt(process.env.WORKER_COUNT || '5'),
|
||||
concurrency: parseInt(process.env.WORKER_CONCURRENCY || '20'),
|
||||
redis: {
|
||||
host: process.env.DRAGONFLY_HOST || 'localhost',
|
||||
port: parseInt(process.env.DRAGONFLY_PORT || '6379'),
|
||||
},
|
||||
};
|
||||
|
||||
private get isInitialized() {
|
||||
return !!this.queue;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Don't initialize in constructor to allow for proper async initialization
|
||||
}
|
||||
async initialize() {
|
||||
if (this.isInitialized) {
|
||||
this.logger.warn('Queue service already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('Initializing queue service...');
|
||||
|
||||
try {
|
||||
// Step 1: Register providers
|
||||
await this.registerProviders();
|
||||
|
||||
// Step 2: Setup queue and workers
|
||||
const connection = this.getConnection();
|
||||
const queueName = '{data-service-queue}';
|
||||
this.queue = new Queue(queueName, {
|
||||
connection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 5,
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.queueEvents = new QueueEvents(queueName, { connection });
|
||||
|
||||
// Step 3: Create workers
|
||||
const { workerCount, totalConcurrency } = this.createWorkers(queueName, connection);
|
||||
|
||||
// Step 4: Wait for readiness (parallel)
|
||||
await Promise.all([
|
||||
this.queue.waitUntilReady(),
|
||||
this.queueEvents.waitUntilReady(),
|
||||
...this.workers.map(worker => worker.waitUntilReady()),
|
||||
]);
|
||||
|
||||
// Step 5: Setup events and scheduled tasks
|
||||
this.setupQueueEvents();
|
||||
await this.setupScheduledTasks();
|
||||
|
||||
this.logger.info('Queue service initialized successfully', {
|
||||
workers: workerCount,
|
||||
totalConcurrency,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize queue service', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
private getConnection() {
|
||||
return {
|
||||
...this.config.redis,
|
||||
maxRetriesPerRequest: null,
|
||||
retryDelayOnFailover: 100,
|
||||
lazyConnect: false,
|
||||
};
|
||||
}
|
||||
|
||||
private createWorkers(queueName: string, connection: any) {
|
||||
for (let i = 0; i < this.config.workers; i++) {
|
||||
const worker = new Worker(queueName, this.processJob.bind(this), {
|
||||
connection: { ...connection },
|
||||
concurrency: this.config.concurrency,
|
||||
maxStalledCount: 1,
|
||||
stalledInterval: 30000,
|
||||
});
|
||||
|
||||
// Setup events inline
|
||||
worker.on('ready', () => this.logger.info(`Worker ${i + 1} ready`));
|
||||
worker.on('error', error => this.logger.error(`Worker ${i + 1} error`, { error }));
|
||||
|
||||
this.workers.push(worker);
|
||||
}
|
||||
|
||||
return {
|
||||
workerCount: this.config.workers,
|
||||
totalConcurrency: this.config.workers * this.config.concurrency,
|
||||
};
|
||||
}
|
||||
private setupQueueEvents() {
|
||||
// Add comprehensive logging to see job flow
|
||||
this.queueEvents.on('added', job => {
|
||||
this.logger.debug('Job added to queue', {
|
||||
id: job.jobId,
|
||||
});
|
||||
});
|
||||
|
||||
this.queueEvents.on('waiting', job => {
|
||||
this.logger.debug('Job moved to waiting', {
|
||||
id: job.jobId,
|
||||
});
|
||||
});
|
||||
|
||||
this.queueEvents.on('active', job => {
|
||||
this.logger.debug('Job became active', {
|
||||
id: job.jobId,
|
||||
});
|
||||
});
|
||||
|
||||
this.queueEvents.on('delayed', job => {
|
||||
this.logger.debug('Job delayed', {
|
||||
id: job.jobId,
|
||||
delay: job.delay,
|
||||
});
|
||||
});
|
||||
|
||||
this.queueEvents.on('completed', job => {
|
||||
this.logger.debug('Job completed', {
|
||||
id: job.jobId,
|
||||
});
|
||||
});
|
||||
|
||||
this.queueEvents.on('failed', (job, error) => {
|
||||
this.logger.debug('Job failed', {
|
||||
id: job.jobId,
|
||||
error: String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
private async registerProviders() {
|
||||
this.logger.info('Registering providers...');
|
||||
|
||||
try {
|
||||
// Define providers to register
|
||||
const providers = [
|
||||
{ module: '../providers/proxy.provider', export: 'proxyProvider' },
|
||||
{ module: '../providers/ib.provider', export: 'ibProvider' },
|
||||
// { module: '../providers/yahoo.provider', export: 'yahooProvider' },
|
||||
];
|
||||
|
||||
// Import and register all providers
|
||||
for (const { module, export: exportName } of providers) {
|
||||
const providerModule = await import(module);
|
||||
providerRegistry.registerProvider(providerModule[exportName]);
|
||||
}
|
||||
|
||||
this.logger.info('All providers registered successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to register providers', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
private async processJob(job: Job) {
|
||||
const { provider, operation, payload }: JobData = job.data;
|
||||
|
||||
this.logger.info('Processing job', {
|
||||
id: job.id,
|
||||
provider,
|
||||
operation,
|
||||
payloadKeys: Object.keys(payload || {}),
|
||||
});
|
||||
try {
|
||||
let result;
|
||||
|
||||
if (operation === 'process-batch-items') {
|
||||
// Special handling for batch processing - requires 2 parameters
|
||||
const { processBatchJob } = await import('../utils/batch-helpers');
|
||||
result = await processBatchJob(payload, this);
|
||||
} else {
|
||||
// Regular handler lookup - requires 1 parameter
|
||||
const handler = providerRegistry.getHandler(provider, operation);
|
||||
|
||||
if (!handler) {
|
||||
throw new Error(`No handler found for ${provider}:${operation}`);
|
||||
}
|
||||
|
||||
result = await handler(payload);
|
||||
}
|
||||
|
||||
this.logger.info('Job completed successfully', {
|
||||
id: job.id,
|
||||
provider,
|
||||
operation,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error('Job failed', {
|
||||
id: job.id,
|
||||
provider,
|
||||
operation,
|
||||
error: errorMessage,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async addBulk(jobs: any[]): Promise<any[]> {
|
||||
return await this.queue.addBulk(jobs);
|
||||
}
|
||||
|
||||
private getTotalConcurrency() {
|
||||
return this.workers.reduce((total, worker) => total + (worker.opts.concurrency || 1), 0);
|
||||
}
|
||||
|
||||
private async setupScheduledTasks() {
|
||||
const allScheduledJobs = providerRegistry.getAllScheduledJobs();
|
||||
|
||||
if (allScheduledJobs.length === 0) {
|
||||
this.logger.warn('No scheduled jobs found in providers');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('Setting up scheduled tasks...', { count: allScheduledJobs.length });
|
||||
|
||||
// Use Promise.allSettled for parallel processing + better error handling
|
||||
const results = await Promise.allSettled(
|
||||
allScheduledJobs.map(async ({ provider, job }) => {
|
||||
await this.addRecurringJob(
|
||||
{
|
||||
type: job.type,
|
||||
provider,
|
||||
operation: job.operation,
|
||||
payload: job.payload,
|
||||
priority: job.priority,
|
||||
immediately: job.immediately || false,
|
||||
},
|
||||
job.cronPattern
|
||||
);
|
||||
|
||||
return { provider, operation: job.operation };
|
||||
})
|
||||
);
|
||||
|
||||
// Log results
|
||||
const successful = results.filter(r => r.status === 'fulfilled');
|
||||
const failed = results.filter(r => r.status === 'rejected');
|
||||
|
||||
if (failed.length > 0) {
|
||||
failed.forEach((result, index) => {
|
||||
const { provider, job } = allScheduledJobs[index];
|
||||
this.logger.error('Failed to register scheduled job', {
|
||||
provider,
|
||||
operation: job.operation,
|
||||
error: result.reason,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.info('Scheduled tasks setup complete', {
|
||||
successful: successful.length,
|
||||
failed: failed.length,
|
||||
});
|
||||
}
|
||||
private async addJobInternal(jobData: JobData, options: any = {}) {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('Queue service not initialized');
|
||||
}
|
||||
|
||||
const jobType = jobData.type || `${jobData.provider}-${jobData.operation}`;
|
||||
return this.queue.add(jobType, jobData, {
|
||||
priority: jobData.priority || undefined,
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 5,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async addJob(jobData: JobData, options?: any) {
|
||||
return this.addJobInternal(jobData, options);
|
||||
}
|
||||
|
||||
async addRecurringJob(jobData: JobData, cronPattern: string, options?: any) {
|
||||
const jobKey = `recurring-${jobData.provider}-${jobData.operation}`;
|
||||
|
||||
return this.addJobInternal(jobData, {
|
||||
repeat: {
|
||||
pattern: cronPattern,
|
||||
tz: 'UTC',
|
||||
immediately: jobData.immediately || false,
|
||||
},
|
||||
jobId: jobKey,
|
||||
removeOnComplete: 1,
|
||||
removeOnFail: 1,
|
||||
attempts: 2,
|
||||
backoff: {
|
||||
type: 'fixed',
|
||||
delay: 5000,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
async getJobStats() {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('Queue service not initialized. Call initialize() first.');
|
||||
}
|
||||
const [waiting, active, completed, failed, delayed] = await Promise.all([
|
||||
this.queue.getWaiting(),
|
||||
this.queue.getActive(),
|
||||
this.queue.getCompleted(),
|
||||
this.queue.getFailed(),
|
||||
this.queue.getDelayed(),
|
||||
]);
|
||||
|
||||
return {
|
||||
waiting: waiting.length,
|
||||
active: active.length,
|
||||
completed: completed.length,
|
||||
failed: failed.length,
|
||||
delayed: delayed.length,
|
||||
};
|
||||
}
|
||||
async drainQueue() {
|
||||
if (this.isInitialized) {
|
||||
await this.queue.drain();
|
||||
}
|
||||
}
|
||||
async getQueueStatus() {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('Queue service not initialized');
|
||||
}
|
||||
|
||||
const stats = await this.getJobStats();
|
||||
return {
|
||||
...stats,
|
||||
workers: this.workers.length,
|
||||
concurrency: this.getTotalConcurrency(),
|
||||
};
|
||||
}
|
||||
async shutdown() {
|
||||
if (!this.isInitialized) {
|
||||
this.logger.warn('Queue service not initialized, nothing to shutdown');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info('Shutting down queue service gracefully...');
|
||||
|
||||
try {
|
||||
// Step 1: Stop accepting new jobs and wait for current jobs to finish
|
||||
this.logger.debug('Closing workers gracefully...');
|
||||
const workerClosePromises = this.workers.map(async (worker, index) => {
|
||||
this.logger.debug(`Closing worker ${index + 1}/${this.workers.length}`);
|
||||
try {
|
||||
// Wait for current jobs to finish, then close
|
||||
await Promise.race([
|
||||
worker.close(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Worker ${index + 1} close timeout`)), 5000)
|
||||
),
|
||||
]);
|
||||
this.logger.debug(`Worker ${index + 1} closed successfully`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to close worker ${index + 1}`, { error });
|
||||
// Force close if graceful close fails
|
||||
await worker.close(true);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(workerClosePromises);
|
||||
this.logger.debug('All workers closed');
|
||||
|
||||
// Step 2: Close queue and events with timeout protection
|
||||
this.logger.debug('Closing queue and events...');
|
||||
await Promise.allSettled([
|
||||
Promise.race([
|
||||
this.queue.close(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Queue close timeout')), 3000)
|
||||
),
|
||||
]).catch(error => this.logger.error('Queue close error', { error })),
|
||||
|
||||
Promise.race([
|
||||
this.queueEvents.close(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('QueueEvents close timeout')), 3000)
|
||||
),
|
||||
]).catch(error => this.logger.error('QueueEvents close error', { error })),
|
||||
]);
|
||||
|
||||
this.logger.info('Queue service shutdown completed successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Error during queue service shutdown', { error });
|
||||
// Force close everything as last resort
|
||||
try {
|
||||
await Promise.allSettled([
|
||||
...this.workers.map(worker => worker.close(true)),
|
||||
this.queue.close(),
|
||||
this.queueEvents.close(),
|
||||
]);
|
||||
} catch (forceCloseError) {
|
||||
this.logger.error('Force close also failed', { error: forceCloseError });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const queueManager = new QueueService();
|
||||
364
apps/data-service/src/utils/batch-helpers.ts
Normal file
364
apps/data-service/src/utils/batch-helpers.ts
Normal 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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue