diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/issue_template.md index 5be4136..40d7354 100644 --- a/.github/ISSUE_TEMPLATE/issue_template.md +++ b/.github/ISSUE_TEMPLATE/issue_template.md @@ -8,17 +8,17 @@ -- -- -- +- +- +- ## 🎯 Acceptance Criteria -- [ ] -- [ ] -- [ ] +- [ ] +- [ ] +- [ ] ## πŸ“ Expected files to change/structure @@ -26,8 +26,6 @@ - - -- - ---- +- *** Thank you for taking this issue! You are helping us make RWAs consumer friendly on Stellar. diff --git a/README.md b/README.md index 7688a93..2ad2cfa 100644 --- a/README.md +++ b/README.md @@ -122,12 +122,12 @@ Before pushing, verify that the full CI pipeline passes locally: npm ci && npm run lint && npm run check-types && npm run build && npm test ``` -| Command | What it does | -|----------------------|---------------------------------------------------| -| `npm run lint` | Runs ESLint across all workspaces via Turborepo | -| `npm run check-types`| Runs `tsc --noEmit` in all TypeScript workspaces | -| `npm run build` | Builds all apps and packages via Turborepo | -| `npm test` | Runs Jest tests in all workspaces | +| Command | What it does | +| --------------------- | ------------------------------------------------ | +| `npm run lint` | Runs ESLint across all workspaces via Turborepo | +| `npm run check-types` | Runs `tsc --noEmit` in all TypeScript workspaces | +| `npm run build` | Builds all apps and packages via Turborepo | +| `npm test` | Runs Jest tests in all workspaces | CI runs these same steps automatically on every push and PR to `main` and `develop`. diff --git a/apps/aggregator/.env.example b/apps/aggregator/.env.example index 67e0b3c..826bac8 100644 --- a/apps/aggregator/.env.example +++ b/apps/aggregator/.env.example @@ -10,3 +10,12 @@ INGESTOR_HTTP_URL=http://localhost:3000 # Signer Service (for publishing aggregated data) SIGNER_URL=http://localhost:3002 + +# Outlier Detection Configuration +OUTLIER_MIN_PRICE=0 +OUTLIER_MAX_CHANGE_PERCENT=50 +OUTLIER_MAX_CHANGE_WINDOW_SECONDS=10 +OUTLIER_IQR_MULTIPLIER=1.5 +OUTLIER_ZSCORE_THRESHOLD=3 +OUTLIER_MAD_THRESHOLD=3.5 +OUTLIER_MIN_SAMPLES=3 diff --git a/apps/aggregator/IMPLEMENTATION_SUMMARY.md b/apps/aggregator/IMPLEMENTATION_SUMMARY.md index 5da09a1..8570fc8 100644 --- a/apps/aggregator/IMPLEMENTATION_SUMMARY.md +++ b/apps/aggregator/IMPLEMENTATION_SUMMARY.md @@ -5,6 +5,7 @@ ### Core Implementation βœ… **AggregationService** - Main service for calculating consensus prices + - Configurable minimum source requirements - Time window filtering (configurable, default 30s) - Multiple aggregation method support @@ -12,17 +13,20 @@ - Comprehensive error handling and validation βœ… **Aggregation Strategies** (Strategy Pattern) + - **Weighted Average**: Rewards trusted sources, smooth consensus - **Median**: Resistant to outliers, manipulation-proof - **Trimmed Mean**: Balanced approach removing extremes βœ… **Confidence Metrics** + - Standard deviation calculation - Price spread (min/max percentage) - Variance measurement - Confidence score (0-100) based on source count, spread, and deviation βœ… **Configuration System** + - Source weight configuration (per-source reliability) - Environment variable support - Custom weight overrides at runtime @@ -31,12 +35,14 @@ ### Testing βœ… **Comprehensive Test Coverage: 88.88%** + - 69 test cases total - Services: 97.7% coverage - Strategies: 98.24% coverage - Config: 100% coverage Test Suites: + - `aggregation.service.spec.ts` - 37 tests - `weighted-average.aggregator.spec.ts` - 10 tests - `median.aggregator.spec.ts` - 12 tests @@ -45,6 +51,7 @@ Test Suites: ### Documentation βœ… **Comprehensive README.md** + - Detailed method explanations with use cases - Configuration guide - Usage examples @@ -52,11 +59,13 @@ Test Suites: - Performance considerations βœ… **Code Documentation** + - TSDoc comments on all public methods - Interface documentation - Inline comments explaining algorithms βœ… **Demo Script** + - 5 real-world scenarios - Shows all aggregation methods - Demonstrates confidence scoring @@ -65,49 +74,56 @@ Test Suites: ## πŸ“ Files Created/Modified ### Interfaces + - `src/interfaces/normalized-price.interface.ts` - Input data structure - `src/interfaces/aggregated-price.interface.ts` - Output data structure - `src/interfaces/aggregator.interface.ts` - Strategy pattern interface - `src/interfaces/aggregation-config.interface.ts` - Configuration types ### Strategies + - `src/strategies/aggregators/weighted-average.aggregator.ts` - Weighted average implementation - `src/strategies/aggregators/median.aggregator.ts` - Median implementation - `src/strategies/aggregators/trimmed-mean.aggregator.ts` - Trimmed mean implementation ### Services + - `src/services/aggregation.service.ts` - Main aggregation service - `src/services/aggregation.service.spec.ts` - Service tests ### Configuration + - `src/config/source-weights.config.ts` - Source reliability weights - `.env.example` - Environment configuration template ### Tests + - `src/strategies/aggregators/weighted-average.aggregator.spec.ts` - `src/strategies/aggregators/median.aggregator.spec.ts` - `src/strategies/aggregators/trimmed-mean.aggregator.spec.ts` ### Documentation + - `README.md` - Comprehensive documentation (updated) - `src/demo.ts` - Interactive demo script ### Module Configuration + - `src/app.module.ts` - Updated with new providers ## 🎯 Acceptance Criteria Status -| Criteria | Status | -|----------|--------| -| Service correctly calculates weighted average | βœ… | -| Service correctly calculates median | βœ… | -| Service calculates trimmed mean by discarding extremes | βœ… | -| Confidence metrics produced (std dev, spread, confidence 0-100) | βœ… | -| Minimum 3 sources required (configurable) | βœ… | -| Weights per source configurable | βœ… | -| Start and end timestamps added | βœ… | -| Unit tests exist with >85% coverage | βœ… 88.88% | -| Methods documented | βœ… | +| Criteria | Status | +| --------------------------------------------------------------- | --------- | +| Service correctly calculates weighted average | βœ… | +| Service correctly calculates median | βœ… | +| Service calculates trimmed mean by discarding extremes | βœ… | +| Confidence metrics produced (std dev, spread, confidence 0-100) | βœ… | +| Minimum 3 sources required (configurable) | βœ… | +| Weights per source configurable | βœ… | +| Start and end timestamps added | βœ… | +| Unit tests exist with >85% coverage | βœ… 88.88% | +| Methods documented | βœ… | ## πŸ“Š Demo Results diff --git a/apps/aggregator/README.md b/apps/aggregator/README.md index 9c054d7..df34a85 100644 --- a/apps/aggregator/README.md +++ b/apps/aggregator/README.md @@ -13,6 +13,13 @@ The Aggregator service is responsible for calculating a single consensus price p - Median - Trimmed Mean +- **Outlier Detection & Filtering** + - Range checks (non-positive and abrupt change detection) + - IQR-based detection + - Z-score-based detection + - MAD-based detection + - Per-source data quality metrics + - **Confidence Metrics** - Standard deviation - Price spread (min/max difference) @@ -28,14 +35,14 @@ The Aggregator service is responsible for calculating a single consensus price p ## API Endpoints (Health, Metrics & Debug) -| Endpoint | Method | Purpose | -|----------|--------|---------| -| `/health` | GET | Full health check. Returns **200** if all configured dependencies (Redis, Ingestor) are healthy, **503** otherwise. Used for overall service health. | -| `/ready` | GET | Readiness probe for Kubernetes. Same checks as `/health`; returns 200 when the service can accept traffic. | -| `/live` | GET | Liveness probe for Kubernetes. Returns 200 when the process is alive (no dependency checks). | -| `/status` | GET | Detailed system information: uptime, memory usage, dependency check results, and version. | -| `/metrics` | GET | Prometheus metrics in [exposition format](https://prometheus.io/docs/instrumenting/exposition_formats/). Scrape this endpoint for aggregation count, latency, errors, and default Node.js metrics. | -| `/debug/prices` | GET | Last aggregated and normalized prices held in memory. Useful for debugging without hitting external systems. | +| Endpoint | Method | Purpose | +| --------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/health` | GET | Full health check. Returns **200** if all configured dependencies (Redis, Ingestor) are healthy, **503** otherwise. Used for overall service health. | +| `/ready` | GET | Readiness probe for Kubernetes. Same checks as `/health`; returns 200 when the service can accept traffic. | +| `/live` | GET | Liveness probe for Kubernetes. Returns 200 when the process is alive (no dependency checks). | +| `/status` | GET | Detailed system information: uptime, memory usage, dependency check results, and version. | +| `/metrics` | GET | Prometheus metrics in [exposition format](https://prometheus.io/docs/instrumenting/exposition_formats/). Scrape this endpoint for aggregation count, latency, errors, and default Node.js metrics. | +| `/debug/prices` | GET | Last aggregated and normalized prices held in memory. Useful for debugging without hitting external systems. | **Health checks**: When `REDIS_URL` or `INGESTOR_URL` are set, the health check verifies connectivity. If a configured dependency is unreachable, `/health` and `/ready` return 503. If not set, that dependency is skipped (not included in the check). @@ -56,6 +63,15 @@ aggregator/ β”‚ β”‚ └── trimmed-mean.aggregator.ts β”‚ β”œβ”€β”€ services/ β”‚ β”‚ └── aggregation.service.ts # Main aggregation service +β”‚ β”‚ └── outlier-detection.service.ts +β”‚ β”œβ”€β”€ strategies/ +β”‚ β”‚ └── outlier-detectors/ +β”‚ β”‚ β”œβ”€β”€ range-detector.ts +β”‚ β”‚ β”œβ”€β”€ iqr-detector.ts +β”‚ β”‚ β”œβ”€β”€ zscore-detector.ts +β”‚ β”‚ └── mad-detector.ts +β”‚ β”œβ”€β”€ interfaces/ +β”‚ β”‚ └── outlier-result.interface.ts β”‚ β”œβ”€β”€ health/ # Health checks (Terminus) β”‚ β”‚ β”œβ”€β”€ health.controller.ts β”‚ β”‚ └── indicators/ @@ -79,16 +95,19 @@ aggregator/ **Formula**: `Ξ£(price_i Γ— weight_i) / Ξ£(weight_i)` **Use Cases**: + - When you trust certain sources more than others - Stable markets with reliable data providers - Want smooth, continuous price updates **Pros**: + - Rewards trusted sources - Produces smooth consensus prices - Good for stable markets **Cons**: + - Susceptible to manipulation if high-weight source is compromised - Not resistant to outliers @@ -101,17 +120,20 @@ aggregator/ **Formula**: Middle value (or average of two middle values) after sorting **Use Cases**: + - Volatile markets - When source reliability varies significantly - Protection against manipulation attempts - Suspicious of potential outliers **Pros**: + - Highly resistant to outliers - Not affected by a single manipulated source - Simple and robust **Cons**: + - Ignores majority of data points - Doesn't account for source reliability - Can be manipulated if >50% of sources are compromised @@ -125,17 +147,20 @@ aggregator/ **Formula**: Average after removing top X% and bottom X% of values **Use Cases**: + - Balanced approach between mean and median - Markets with occasional outliers but mostly reliable data - Want to use more data than median **Pros**: + - Resistant to outliers - Uses more data than median - Good balance between robustness and sensitivity - Can be weighted after trimming **Cons**: + - Requires sufficient data points - Trim percentage needs tuning - May discard valid extreme prices in volatile markets @@ -167,24 +192,49 @@ WEIGHT_ALPHAVANTAGE=1.5 WEIGHT_YAHOOFINANCE=1.2 WEIGHT_FINNHUB=1.2 WEIGHT_DEFAULT=1.0 + +# Outlier Detection Settings +OUTLIER_MIN_PRICE=0 +OUTLIER_MAX_CHANGE_PERCENT=50 +OUTLIER_MAX_CHANGE_WINDOW_SECONDS=10 +OUTLIER_IQR_MULTIPLIER=1.5 +OUTLIER_ZSCORE_THRESHOLD=3 +OUTLIER_MAD_THRESHOLD=3.5 +OUTLIER_MIN_SAMPLES=3 ``` +## Outlier Detection Strategies + +The outlier service marks suspect records instead of dropping input rows immediately. This preserves auditability and allows downstream consumers to decide whether to exclude suspicious data. + +- Range detector: + Flags non-positive prices and abrupt source-local jumps greater than `OUTLIER_MAX_CHANGE_PERCENT` within `OUTLIER_MAX_CHANGE_WINDOW_SECONDS`. +- IQR detector: + Uses quartiles and `OUTLIER_IQR_MULTIPLIER` to identify values outside Tukey-style bounds. +- Z-score detector: + Flags values whose absolute z-score exceeds `OUTLIER_ZSCORE_THRESHOLD`. +- MAD detector: + Uses robust modified z-score against median absolute deviation and `OUTLIER_MAD_THRESHOLD`. + +Each flagged record includes metadata with strategy and reason code. Source quality metrics are produced as `% outliers` per source. + ### Source Weights Edit [src/config/source-weights.config.ts](src/config/source-weights.config.ts) to customize source reliability weights: ```typescript export const SOURCE_WEIGHTS: Record = { - 'Bloomberg': 2.0, // Premium, highly reliable - 'Reuters': 2.0, - 'AlphaVantage': 1.5, // High reliability - 'YahooFinance': 1.2, // Standard reliability - 'Finnhub': 1.2, - 'default': 1.0, // Fallback for unknown sources + Bloomberg: 2.0, // Premium, highly reliable + Reuters: 2.0, + AlphaVantage: 1.5, // High reliability + YahooFinance: 1.2, // Standard reliability + Finnhub: 1.2, + default: 1.0, // Fallback for unknown sources }; ``` **Weight Guidelines**: + - `2.0`: Premium sources (Bloomberg, Reuters) - `1.2-1.5`: High reliability (major APIs) - `1.0`: Standard reliability @@ -196,51 +246,61 @@ export const SOURCE_WEIGHTS: Record = { ### Basic Usage ```typescript -import { AggregationService } from './services/aggregation.service'; -import { NormalizedPrice } from './interfaces/normalized-price.interface'; +import { AggregationService } from "./services/aggregation.service"; +import { NormalizedPrice } from "./interfaces/normalized-price.interface"; // Create service instance const aggregationService = new AggregationService(); // Prepare normalized prices const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 150.25, timestamp: Date.now(), source: 'AlphaVantage' }, - { symbol: 'AAPL', price: 150.30, timestamp: Date.now(), source: 'YahooFinance' }, - { symbol: 'AAPL', price: 150.20, timestamp: Date.now(), source: 'Finnhub' }, + { + symbol: "AAPL", + price: 150.25, + timestamp: Date.now(), + source: "AlphaVantage", + }, + { + symbol: "AAPL", + price: 150.3, + timestamp: Date.now(), + source: "YahooFinance", + }, + { symbol: "AAPL", price: 150.2, timestamp: Date.now(), source: "Finnhub" }, ]; // Aggregate with default settings -const result = aggregationService.aggregate('AAPL', prices); +const result = aggregationService.aggregate("AAPL", prices); console.log(`Consensus Price: $${result.price.toFixed(2)}`); console.log(`Confidence: ${result.confidence.toFixed(1)}%`); -console.log(`Sources: ${result.sources.join(', ')}`); +console.log(`Sources: ${result.sources.join(", ")}`); ``` ### Custom Options ```typescript // Use median method with custom settings -const result = aggregationService.aggregate('AAPL', prices, { - method: 'median', +const result = aggregationService.aggregate("AAPL", prices, { + method: "median", minSources: 5, - timeWindowMs: 60000, // 1 minute + timeWindowMs: 60000, // 1 minute }); // Use trimmed mean with custom trim percentage -const result = aggregationService.aggregate('GOOGL', prices, { - method: 'trimmed-mean', - trimPercentage: 0.25, // Remove 25% from each end +const result = aggregationService.aggregate("GOOGL", prices, { + method: "trimmed-mean", + trimPercentage: 0.25, // Remove 25% from each end }); // Use custom weights const customWeights = new Map([ - ['Bloomberg', 3.0], - ['AlphaVantage', 1.0], + ["Bloomberg", 3.0], + ["AlphaVantage", 1.0], ]); -const result = aggregationService.aggregate('MSFT', prices, { - method: 'weighted-average', +const result = aggregationService.aggregate("MSFT", prices, { + method: "weighted-average", customWeights, }); ``` @@ -249,13 +309,28 @@ const result = aggregationService.aggregate('MSFT', prices, { ```typescript const pricesBySymbol = new Map([ - ['AAPL', [/* prices for AAPL */]], - ['GOOGL', [/* prices for GOOGL */]], - ['MSFT', [/* prices for MSFT */]], + [ + "AAPL", + [ + /* prices for AAPL */ + ], + ], + [ + "GOOGL", + [ + /* prices for GOOGL */ + ], + ], + [ + "MSFT", + [ + /* prices for MSFT */ + ], + ], ]); const results = aggregationService.aggregateMultiple(pricesBySymbol, { - method: 'weighted-average', + method: "weighted-average", minSources: 3, }); @@ -306,13 +381,16 @@ Confidence score (0-100) is calculated based on: - Normalized by price scale **Example**: + - 5 sources, 1% spread, low deviation β†’ ~90% confidence - 3 sources, 10% spread, high deviation β†’ ~30% confidence ## Features ### Data Reception Layer + Implemented via `DataReceptionService`, this layer connects to Ingestor services to receive real-time and historical data. + - **WebSocket Client**: Real-time price streaming with exponential backoff reconnection. - **HTTP Fallback**: Retrieval of historical data and latest price snapshots. - **Event-Driven**: Emits `price.received` events using `EventEmitter2`. @@ -397,7 +475,7 @@ The service throws errors for invalid inputs: ```typescript try { - const result = aggregationService.aggregate('AAPL', prices); + const result = aggregationService.aggregate("AAPL", prices); } catch (error) { // Possible errors: // - "Symbol cannot be empty" @@ -419,38 +497,40 @@ try { ```typescript @Injectable() export class MyCustomAggregator implements IAggregator { - readonly name = 'my-custom'; + readonly name = "my-custom"; aggregate(prices: NormalizedPrice[], weights?: Map): number { // Your implementation } } ``` + apps/aggregator/ β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ main.ts # Application entry point -β”‚ β”œβ”€β”€ app.module.ts # Root module -β”‚ β”œβ”€β”€ interfaces/ # Type definitions -β”‚ β”‚ β”œβ”€β”€ normalized-price.interface.ts -β”‚ β”‚ └── normalizer.interface.ts -β”‚ β”œβ”€β”€ normalizers/ # Source-specific normalizers -β”‚ β”‚ β”œβ”€β”€ base.normalizer.ts -β”‚ β”‚ β”œβ”€β”€ alpha-vantage.normalizer.ts -β”‚ β”‚ β”œβ”€β”€ finnhub.normalizer.ts -β”‚ β”‚ β”œβ”€β”€ yahoo-finance.normalizer.ts -β”‚ β”‚ └── mock.normalizer.ts -β”‚ β”œβ”€β”€ services/ # Business logic -β”‚ β”‚ └── normalization.service.ts -β”‚ β”œβ”€β”€ modules/ # Feature modules -β”‚ β”‚ └── normalization.module.ts -β”‚ └── exceptions/ # Custom exceptions -β”‚ └── normalization.exception.ts -β”œβ”€β”€ .env.example # Example environment variables -β”œβ”€β”€ nest-cli.json # NestJS CLI configuration -β”œβ”€β”€ package.json # Dependencies and scripts -β”œβ”€β”€ tsconfig.json # TypeScript configuration -└── README.md # This file -``` +β”‚ β”œβ”€β”€ main.ts # Application entry point +β”‚ β”œβ”€β”€ app.module.ts # Root module +β”‚ β”œβ”€β”€ interfaces/ # Type definitions +β”‚ β”‚ β”œβ”€β”€ normalized-price.interface.ts +β”‚ β”‚ └── normalizer.interface.ts +β”‚ β”œβ”€β”€ normalizers/ # Source-specific normalizers +β”‚ β”‚ β”œβ”€β”€ base.normalizer.ts +β”‚ β”‚ β”œβ”€β”€ alpha-vantage.normalizer.ts +β”‚ β”‚ β”œβ”€β”€ finnhub.normalizer.ts +β”‚ β”‚ β”œβ”€β”€ yahoo-finance.normalizer.ts +β”‚ β”‚ └── mock.normalizer.ts +β”‚ β”œβ”€β”€ services/ # Business logic +β”‚ β”‚ └── normalization.service.ts +β”‚ β”œβ”€β”€ modules/ # Feature modules +β”‚ β”‚ └── normalization.module.ts +β”‚ └── exceptions/ # Custom exceptions +β”‚ └── normalization.exception.ts +β”œβ”€β”€ .env.example # Example environment variables +β”œβ”€β”€ nest-cli.json # NestJS CLI configuration +β”œβ”€β”€ package.json # Dependencies and scripts +β”œβ”€β”€ tsconfig.json # TypeScript configuration +└── README.md # This file + +```` ## Data Normalization @@ -474,20 +554,21 @@ interface NormalizedPrice { transformations: string[]; // List of transformations applied }; } -``` +```` ### Supported Sources and Transformations -| Source | Detected By | Symbol Transformations | -|--------|-------------|------------------------| -| **Alpha Vantage** | `alphavantage`, `alpha_vantage`, `alpha-vantage` | Removes `.US`, `.NYSE`, `.NASDAQ`, `.LSE`, `.TSX`, `.ASX`, `.HK` suffixes | -| **Finnhub** | `finnhub` | Removes `US-`, `CRYPTO-`, `FX-`, `INDICES-` prefixes | +| Source | Detected By | Symbol Transformations | +| ----------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| **Alpha Vantage** | `alphavantage`, `alpha_vantage`, `alpha-vantage` | Removes `.US`, `.NYSE`, `.NASDAQ`, `.LSE`, `.TSX`, `.ASX`, `.HK` suffixes | +| **Finnhub** | `finnhub` | Removes `US-`, `CRYPTO-`, `FX-`, `INDICES-` prefixes | | **Yahoo Finance** | `yahoo`, `yahoofinance`, `yahoo_finance`, `yahoo-finance` | Removes `.L`, `.T`, `.AX`, `.HK`, `.SI`, `.KS`, `.TW`, `.NS`, `.BO`, `.TO`, `.DE`, `.PA` suffixes; removes `^` index prefix | -| **Mock** | `mock` | Basic cleanup (trim, uppercase) | +| **Mock** | `mock` | Basic cleanup (trim, uppercase) | ### Common Transformations All normalizers apply these transformations: + - **Symbol**: Trimmed and uppercased - **Price**: Rounded to 4 decimal places - **Timestamp**: Converted to ISO 8601 UTC format @@ -524,7 +605,7 @@ const { successful, failed } = this.normalizationService.normalizeManyWithErrors 2. Register in `AggregationService` constructor: ```typescript -this.aggregators.set('my-custom', new MyCustomAggregator()); +this.aggregators.set("my-custom", new MyCustomAggregator()); ``` 3. Add comprehensive tests diff --git a/apps/aggregator/package.json b/apps/aggregator/package.json index a3f0792..68afa99 100644 --- a/apps/aggregator/package.json +++ b/apps/aggregator/package.json @@ -6,7 +6,7 @@ "author": "", "license": "MIT", "scripts": { - "start": "nest start", + "start": "node dist/apps/aggregator/src/main.js", "start:dev": "nest start --watch", "build": "nest build", "lint": "eslint \"src/**/*.ts\"", @@ -64,7 +64,12 @@ "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { - "^.+\\.(t|j)s$": "ts-jest" + "^.+\\.(t|j)s$": [ + "ts-jest", + { + "tsconfig": "tsconfig.spec.json" + } + ] }, "collectCoverageFrom": [ "**/*.(t|j)s" @@ -72,7 +77,7 @@ "coverageDirectory": "../coverage", "testEnvironment": "node", "moduleNameMapper": { - "^@oracle-stocks/shared$": "/../../packages/shared/src" + "^@oracle-stocks/shared$": "/../../../packages/shared/src" } } } diff --git a/apps/aggregator/src/__mocks__/raw-price.fixtures.ts b/apps/aggregator/src/__mocks__/raw-price.fixtures.ts index ee296f1..ebed936 100644 --- a/apps/aggregator/src/__mocks__/raw-price.fixtures.ts +++ b/apps/aggregator/src/__mocks__/raw-price.fixtures.ts @@ -1,68 +1,68 @@ -import { RawPrice } from '@oracle-stocks/shared'; +import type { RawPrice } from "@oracle-stocks/shared"; /** * Test fixtures for raw price data from different sources */ export const mockRawPrices: Record = { alphaVantage: { - symbol: 'AAPL.US', + symbol: "AAPL.US", price: 150.1234567, timestamp: 1705329000000, // 2024-01-15T14:30:00.000Z - source: 'AlphaVantage', + source: "AlphaVantage", }, alphaVantageNYSE: { - symbol: 'MSFT.NYSE', + symbol: "MSFT.NYSE", price: 380.5, timestamp: 1705330200000, - source: 'alpha_vantage', + source: "alpha_vantage", }, finnhub: { - symbol: 'US-GOOGL', + symbol: "US-GOOGL", price: 140.999, timestamp: 1705330200000, - source: 'Finnhub', + source: "Finnhub", }, finnhubCrypto: { - symbol: 'CRYPTO-BTC', + symbol: "CRYPTO-BTC", price: 42000.0, timestamp: 1705330200000, - source: 'finnhub', + source: "finnhub", }, yahooFinance: { - symbol: 'MSFT.L', + symbol: "MSFT.L", price: 380.12345, timestamp: 1705330200000, - source: 'Yahoo Finance', + source: "Yahoo Finance", }, yahooFinanceIndex: { - symbol: '^DJI', + symbol: "^DJI", price: 37500.0, timestamp: 1705330200000, - source: 'yahoo_finance', + source: "yahoo_finance", }, yahooFinanceAustralia: { - symbol: 'BHP.AX', + symbol: "BHP.AX", price: 45.67, timestamp: 1705330200000, - source: 'YahooFinance', + source: "YahooFinance", }, mock: { - symbol: 'TSLA', + symbol: "TSLA", price: 250.5, timestamp: 1705330200000, - source: 'MockProvider', + source: "MockProvider", }, mockLowercase: { - symbol: ' aapl ', + symbol: " aapl ", price: 150.0, timestamp: 1705330200000, - source: 'mock', + source: "mock", }, unknown: { - symbol: 'BTC', + symbol: "BTC", price: 42000.0, timestamp: 1705330200000, - source: 'UnknownSource', + source: "UnknownSource", }, }; @@ -70,13 +70,22 @@ export const mockRawPrices: Record = { * Malformed price data for testing validation */ export const malformedPrices: Array | null | undefined> = [ - { symbol: '', price: 100, timestamp: Date.now(), source: 'Test' }, - { symbol: 'TEST', price: NaN, timestamp: Date.now(), source: 'Test' }, - { symbol: 'TEST', price: -100, timestamp: Date.now(), source: 'Test' }, - { symbol: 'TEST', price: 100, timestamp: null as unknown as number, source: 'Test' }, - { symbol: 'TEST', price: 100, timestamp: Date.now(), source: '' }, - { price: 100, timestamp: Date.now(), source: 'Test' } as Partial, - { symbol: 'TEST', timestamp: Date.now(), source: 'Test' } as Partial, + { symbol: "", price: 100, timestamp: Date.now(), source: "Test" }, + { symbol: "TEST", price: NaN, timestamp: Date.now(), source: "Test" }, + { symbol: "TEST", price: -100, timestamp: Date.now(), source: "Test" }, + { + symbol: "TEST", + price: 100, + timestamp: null as unknown as number, + source: "Test", + }, + { symbol: "TEST", price: 100, timestamp: Date.now(), source: "" }, + { price: 100, timestamp: Date.now(), source: "Test" } as Partial, + { + symbol: "TEST", + timestamp: Date.now(), + source: "Test", + } as Partial, null, undefined, ]; diff --git a/apps/aggregator/src/app.module.ts b/apps/aggregator/src/app.module.ts index f527e73..32ff9d6 100644 --- a/apps/aggregator/src/app.module.ts +++ b/apps/aggregator/src/app.module.ts @@ -1,21 +1,19 @@ -import { Module } from '@nestjs/common'; -import { NormalizationModule } from './modules/normalization.module'; -import { ConfigModule } from '@nestjs/config'; -import { AggregationService } from './services/aggregation.service'; -import { WeightedAverageAggregator } from './strategies/aggregators/weighted-average.aggregator'; -import { MedianAggregator } from './strategies/aggregators/median.aggregator'; -import { TrimmedMeanAggregator } from './strategies/aggregators/trimmed-mean.aggregator'; -import { HealthModule } from './health/health.module'; -import { MetricsModule } from './metrics/metrics.module'; -import { DebugModule } from './debug/debug.module'; -import { HttpModule } from '@nestjs/axios'; -import { EventEmitterModule } from '@nestjs/event-emitter'; -import { DataReceptionService } from './services/data-reception.service'; +import { Module } from "@nestjs/common"; +import { NormalizationModule } from "./modules/normalization.module"; +import { ConfigModule } from "@nestjs/config"; +import { AggregationService } from "./services/aggregation.service"; +import { HealthModule } from "./health/health.module"; +import { MetricsModule } from "./metrics/metrics.module"; +import { DebugModule } from "./debug/debug.module"; +import { HttpModule } from "@nestjs/axios"; +import { EventEmitterModule } from "@nestjs/event-emitter"; +import { DataReceptionService } from "./services/data-reception.service"; +import { OutlierDetectionService } from "./services/outlier-detection.service"; @Module({ imports: [ NormalizationModule, - ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }), + ConfigModule.forRoot({ isGlobal: true, envFilePath: ".env" }), HealthModule, MetricsModule, DebugModule, @@ -28,11 +26,9 @@ import { DataReceptionService } from './services/data-reception.service'; controllers: [], providers: [ DataReceptionService, + OutlierDetectionService, AggregationService, - WeightedAverageAggregator, - MedianAggregator, - TrimmedMeanAggregator, ], exports: [AggregationService], }) -export class AppModule { } +export class AppModule {} diff --git a/apps/aggregator/src/config/source-weights.config.ts b/apps/aggregator/src/config/source-weights.config.ts index ba6d658..544a33d 100644 --- a/apps/aggregator/src/config/source-weights.config.ts +++ b/apps/aggregator/src/config/source-weights.config.ts @@ -1,9 +1,9 @@ /** * Source weight configuration - * + * * Higher weights = more trusted sources * These weights are used in weighted average and trimmed mean calculations - * + * * Weight Guidelines: * - 1.0: Standard reliability (default) * - 1.5-2.0: High reliability (major exchanges, premium APIs) @@ -12,31 +12,31 @@ */ export const SOURCE_WEIGHTS: Record = { // Premium financial data providers - 'Bloomberg': 2.0, - 'Reuters': 2.0, - 'AlphaVantage': 1.5, - + Bloomberg: 2.0, + Reuters: 2.0, + AlphaVantage: 1.5, + // Major financial data APIs - 'YahooFinance': 1.2, - 'Finnhub': 1.2, - 'IEX Cloud': 1.2, - + YahooFinance: 1.2, + Finnhub: 1.2, + "IEX Cloud": 1.2, + // Standard sources - 'Polygon': 1.0, - 'Twelve Data': 1.0, - 'MarketStack': 1.0, - + Polygon: 1.0, + "Twelve Data": 1.0, + MarketStack: 1.0, + // Lower priority sources (free tier, rate-limited) - 'WorldTradingData': 0.8, - 'CryptoCompare': 0.8, - + WorldTradingData: 0.8, + CryptoCompare: 0.8, + // Default weight for unknown sources - 'default': 1.0, + default: 1.0, }; /** * Get weight for a source, returns default if not found */ export function getSourceWeight(source: string): number { - return SOURCE_WEIGHTS[source] ?? SOURCE_WEIGHTS['default']; + return SOURCE_WEIGHTS[source] ?? SOURCE_WEIGHTS["default"]; } diff --git a/apps/aggregator/src/debug/debug.controller.spec.ts b/apps/aggregator/src/debug/debug.controller.spec.ts index b6abea5..9a76349 100644 --- a/apps/aggregator/src/debug/debug.controller.spec.ts +++ b/apps/aggregator/src/debug/debug.controller.spec.ts @@ -1,8 +1,8 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { DebugController } from './debug.controller'; -import { DebugService } from './debug.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { DebugController } from "./debug.controller"; +import { DebugService } from "./debug.service"; -describe('DebugController', () => { +describe("DebugController", () => { let controller: DebugController; let debugService: DebugService; @@ -16,12 +16,12 @@ describe('DebugController', () => { debugService = module.get(DebugService); }); - it('should be defined', () => { + it("should be defined", () => { expect(controller).toBeDefined(); }); - describe('GET /debug/prices', () => { - it('should return last prices from DebugService', () => { + describe("GET /debug/prices", () => { + it("should return last prices from DebugService", () => { const result = controller.getLastPrices(); expect(result).toMatchObject({ aggregated: expect.any(Object), @@ -32,11 +32,11 @@ describe('DebugController', () => { expect(result.normalized).toEqual({}); }); - it('should return stored prices after they are set', () => { - debugService.setLastAggregated('AAPL', { - symbol: 'AAPL', + it("should return stored prices after they are set", () => { + debugService.setLastAggregated("AAPL", { + symbol: "AAPL", price: 150.25, - method: 'weighted-average', + method: "weighted-average", confidence: 95, metrics: { standardDeviation: 0.05, @@ -46,12 +46,12 @@ describe('DebugController', () => { }, startTimestamp: 0, endTimestamp: 0, - sources: ['S1', 'S2', 'S3'], + sources: ["S1", "S2", "S3"], computedAt: Date.now(), }); const result = controller.getLastPrices(); - expect(Object.keys(result.aggregated)).toContain('AAPL'); - expect(result.aggregated['AAPL'].price).toBe(150.25); + expect(Object.keys(result.aggregated)).toContain("AAPL"); + expect(result.aggregated["AAPL"].price).toBe(150.25); }); }); }); diff --git a/apps/aggregator/src/debug/debug.controller.ts b/apps/aggregator/src/debug/debug.controller.ts index a3dde99..b56c42a 100644 --- a/apps/aggregator/src/debug/debug.controller.ts +++ b/apps/aggregator/src/debug/debug.controller.ts @@ -1,12 +1,12 @@ -import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common'; -import { DebugService } from './debug.service'; +import { Controller, Get, HttpCode, HttpStatus } from "@nestjs/common"; +import { DebugService } from "./debug.service"; /** * Debug controller for development and troubleshooting. * * - GET /debug/prices - Returns last aggregated and normalized prices held in memory. */ -@Controller('debug') +@Controller("debug") export class DebugController { constructor(private readonly debugService: DebugService) {} @@ -14,7 +14,7 @@ export class DebugController { * Returns the last aggregated prices and last normalized prices per symbol. * Useful for verifying recent aggregation results without hitting external systems. */ - @Get('prices') + @Get("prices") @HttpCode(HttpStatus.OK) getLastPrices() { return this.debugService.getLastPrices(); diff --git a/apps/aggregator/src/debug/debug.module.ts b/apps/aggregator/src/debug/debug.module.ts index d638c6f..44c1feb 100644 --- a/apps/aggregator/src/debug/debug.module.ts +++ b/apps/aggregator/src/debug/debug.module.ts @@ -1,6 +1,6 @@ -import { Module } from '@nestjs/common'; -import { DebugController } from './debug.controller'; -import { DebugService } from './debug.service'; +import { Module } from "@nestjs/common"; +import { DebugController } from "./debug.controller"; +import { DebugService } from "./debug.service"; @Module({ controllers: [DebugController], diff --git a/apps/aggregator/src/debug/debug.service.ts b/apps/aggregator/src/debug/debug.service.ts index d7cd52b..625805c 100644 --- a/apps/aggregator/src/debug/debug.service.ts +++ b/apps/aggregator/src/debug/debug.service.ts @@ -1,6 +1,6 @@ -import { Injectable } from '@nestjs/common'; -import { AggregatedPrice } from '../interfaces/aggregated-price.interface'; -import { NormalizedPrice } from '../interfaces/normalized-price.interface'; +import { Injectable } from "@nestjs/common"; +import { AggregatedPrice } from "../interfaces/aggregated-price.interface"; +import { NormalizedPrice } from "../interfaces/normalized-price.interface"; export interface LastPricesDto { aggregated: Record; diff --git a/apps/aggregator/src/demo.ts b/apps/aggregator/src/demo.ts index 3d2e047..e479044 100644 --- a/apps/aggregator/src/demo.ts +++ b/apps/aggregator/src/demo.ts @@ -1,63 +1,105 @@ -import { AggregationService } from './services/aggregation.service'; -import { NormalizedPrice } from './interfaces/normalized-price.interface'; +import { AggregationService } from "./services/aggregation.service"; +import { NormalizedPrice } from "./interfaces/normalized-price.interface"; /** * Demo script showcasing the Price Aggregation Engine - * + * * Run with: npx ts-node src/demo.ts */ async function demo() { - console.log('='.repeat(70)); - console.log('πŸš€ Price Aggregation Engine Demo'); - console.log('='.repeat(70)); + console.log("=".repeat(70)); + console.log("πŸš€ Price Aggregation Engine Demo"); + console.log("=".repeat(70)); console.log(); const aggregationService = new AggregationService(); // Scenario 1: Multiple sources with similar prices (high confidence) - console.log('πŸ“Š Scenario 1: High Confidence - Closely Agreeing Sources'); - console.log('-'.repeat(70)); - + console.log("πŸ“Š Scenario 1: High Confidence - Closely Agreeing Sources"); + console.log("-".repeat(70)); + const aaplPrices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 150.25, timestamp: Date.now(), source: 'Bloomberg' }, - { symbol: 'AAPL', price: 150.27, timestamp: Date.now(), source: 'AlphaVantage' }, - { symbol: 'AAPL', price: 150.23, timestamp: Date.now(), source: 'YahooFinance' }, - { symbol: 'AAPL', price: 150.26, timestamp: Date.now(), source: 'Finnhub' }, - { symbol: 'AAPL', price: 150.24, timestamp: Date.now(), source: 'Reuters' }, + { + symbol: "AAPL", + price: 150.25, + timestamp: Date.now(), + source: "Bloomberg", + }, + { + symbol: "AAPL", + price: 150.27, + timestamp: Date.now(), + source: "AlphaVantage", + }, + { + symbol: "AAPL", + price: 150.23, + timestamp: Date.now(), + source: "YahooFinance", + }, + { symbol: "AAPL", price: 150.26, timestamp: Date.now(), source: "Finnhub" }, + { symbol: "AAPL", price: 150.24, timestamp: Date.now(), source: "Reuters" }, ]; - const aaplResult = aggregationService.aggregate('AAPL', aaplPrices, { - method: 'weighted-average', + const aaplResult = aggregationService.aggregate("AAPL", aaplPrices, { + method: "weighted-average", }); console.log(`Symbol: ${aaplResult.symbol}`); console.log(`Consensus Price: $${aaplResult.price.toFixed(2)}`); console.log(`Method: ${aaplResult.method}`); console.log(`Confidence: ${aaplResult.confidence.toFixed(1)}%`); - console.log(`Sources: ${aaplResult.sources.join(', ')}`); - console.log(`Standard Deviation: $${aaplResult.metrics.standardDeviation.toFixed(4)}`); + console.log(`Sources: ${aaplResult.sources.join(", ")}`); + console.log( + `Standard Deviation: $${aaplResult.metrics.standardDeviation.toFixed(4)}`, + ); console.log(`Spread: ${aaplResult.metrics.spread.toFixed(2)}%`); console.log(); // Scenario 2: Multiple sources with outlier (using median) - console.log('πŸ“Š Scenario 2: Outlier Resistance - Using Median'); - console.log('-'.repeat(70)); - + console.log("πŸ“Š Scenario 2: Outlier Resistance - Using Median"); + console.log("-".repeat(70)); + const googlPrices: NormalizedPrice[] = [ - { symbol: 'GOOGL', price: 2800.50, timestamp: Date.now(), source: 'Bloomberg' }, - { symbol: 'GOOGL', price: 2801.00, timestamp: Date.now(), source: 'AlphaVantage' }, - { symbol: 'GOOGL', price: 2799.75, timestamp: Date.now(), source: 'YahooFinance' }, - { symbol: 'GOOGL', price: 3500.00, timestamp: Date.now(), source: 'BadSource' }, // Outlier! - { symbol: 'GOOGL', price: 2800.25, timestamp: Date.now(), source: 'Finnhub' }, + { + symbol: "GOOGL", + price: 2800.5, + timestamp: Date.now(), + source: "Bloomberg", + }, + { + symbol: "GOOGL", + price: 2801.0, + timestamp: Date.now(), + source: "AlphaVantage", + }, + { + symbol: "GOOGL", + price: 2799.75, + timestamp: Date.now(), + source: "YahooFinance", + }, + { + symbol: "GOOGL", + price: 3500.0, + timestamp: Date.now(), + source: "BadSource", + }, // Outlier! + { + symbol: "GOOGL", + price: 2800.25, + timestamp: Date.now(), + source: "Finnhub", + }, ]; - const googlMedian = aggregationService.aggregate('GOOGL', googlPrices, { - method: 'median', + const googlMedian = aggregationService.aggregate("GOOGL", googlPrices, { + method: "median", }); - const googlAvg = aggregationService.aggregate('GOOGL', googlPrices, { - method: 'weighted-average', + const googlAvg = aggregationService.aggregate("GOOGL", googlPrices, { + method: "weighted-average", }); console.log(`Median Method (resistant to outliers):`); @@ -72,19 +114,19 @@ async function demo() { console.log(); // Scenario 3: Using trimmed mean - console.log('πŸ“Š Scenario 3: Balanced Approach - Trimmed Mean'); - console.log('-'.repeat(70)); - + console.log("πŸ“Š Scenario 3: Balanced Approach - Trimmed Mean"); + console.log("-".repeat(70)); + const msftPrices: NormalizedPrice[] = [ - { symbol: 'MSFT', price: 380.00, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'MSFT', price: 385.00, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'MSFT', price: 390.00, timestamp: Date.now(), source: 'Source3' }, - { symbol: 'MSFT', price: 395.00, timestamp: Date.now(), source: 'Source4' }, - { symbol: 'MSFT', price: 400.00, timestamp: Date.now(), source: 'Source5' }, + { symbol: "MSFT", price: 380.0, timestamp: Date.now(), source: "Source1" }, + { symbol: "MSFT", price: 385.0, timestamp: Date.now(), source: "Source2" }, + { symbol: "MSFT", price: 390.0, timestamp: Date.now(), source: "Source3" }, + { symbol: "MSFT", price: 395.0, timestamp: Date.now(), source: "Source4" }, + { symbol: "MSFT", price: 400.0, timestamp: Date.now(), source: "Source5" }, ]; - const msftTrimmed = aggregationService.aggregate('MSFT', msftPrices, { - method: 'trimmed-mean', + const msftTrimmed = aggregationService.aggregate("MSFT", msftPrices, { + method: "trimmed-mean", trimPercentage: 0.2, // Remove top and bottom 20% }); @@ -97,36 +139,38 @@ async function demo() { console.log(); // Scenario 4: Multiple symbols at once - console.log('πŸ“Š Scenario 4: Batch Processing - Multiple Symbols'); - console.log('-'.repeat(70)); - + console.log("πŸ“Š Scenario 4: Batch Processing - Multiple Symbols"); + console.log("-".repeat(70)); + const pricesBySymbol = new Map([ - ['AAPL', aaplPrices], - ['GOOGL', googlPrices], - ['MSFT', msftPrices], + ["AAPL", aaplPrices], + ["GOOGL", googlPrices], + ["MSFT", msftPrices], ]); const batchResults = aggregationService.aggregateMultiple(pricesBySymbol, { - method: 'weighted-average', + method: "weighted-average", }); - console.log('Aggregated Prices:'); + console.log("Aggregated Prices:"); for (const [symbol, result] of batchResults) { - console.log(` ${symbol}: $${result.price.toFixed(2)} (confidence: ${result.confidence.toFixed(1)}%)`); + console.log( + ` ${symbol}: $${result.price.toFixed(2)} (confidence: ${result.confidence.toFixed(1)}%)`, + ); } console.log(); // Scenario 5: Low confidence demo - console.log('πŸ“Š Scenario 5: Low Confidence - Divergent Sources'); - console.log('-'.repeat(70)); - + console.log("πŸ“Š Scenario 5: Low Confidence - Divergent Sources"); + console.log("-".repeat(70)); + const tslaPrices: NormalizedPrice[] = [ - { symbol: 'TSLA', price: 200.00, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'TSLA', price: 250.00, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'TSLA', price: 180.00, timestamp: Date.now(), source: 'Source3' }, + { symbol: "TSLA", price: 200.0, timestamp: Date.now(), source: "Source1" }, + { symbol: "TSLA", price: 250.0, timestamp: Date.now(), source: "Source2" }, + { symbol: "TSLA", price: 180.0, timestamp: Date.now(), source: "Source3" }, ]; - const tslaResult = aggregationService.aggregate('TSLA', tslaPrices); + const tslaResult = aggregationService.aggregate("TSLA", tslaPrices); console.log(`Symbol: ${tslaResult.symbol}`); console.log(`Consensus Price: $${tslaResult.price.toFixed(2)}`); @@ -135,9 +179,9 @@ async function demo() { console.log(`⚠️ Low confidence due to high price variance!`); console.log(); - console.log('='.repeat(70)); - console.log('βœ… Demo completed successfully!'); - console.log('='.repeat(70)); + console.log("=".repeat(70)); + console.log("βœ… Demo completed successfully!"); + console.log("=".repeat(70)); } // Run demo diff --git a/apps/aggregator/src/dto/price-input.dto.ts b/apps/aggregator/src/dto/price-input.dto.ts index fad8df8..1385280 100644 --- a/apps/aggregator/src/dto/price-input.dto.ts +++ b/apps/aggregator/src/dto/price-input.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNumber, IsPositive, IsISO8601 } from 'class-validator'; +import { IsString, IsNumber, IsPositive, IsISO8601 } from "class-validator"; export class PriceInputDto { @IsString() diff --git a/apps/aggregator/src/exceptions/index.ts b/apps/aggregator/src/exceptions/index.ts index 7e5e647..3927961 100644 --- a/apps/aggregator/src/exceptions/index.ts +++ b/apps/aggregator/src/exceptions/index.ts @@ -1 +1 @@ -export * from './normalization.exception'; +export * from "./normalization.exception"; diff --git a/apps/aggregator/src/exceptions/normalization.exception.ts b/apps/aggregator/src/exceptions/normalization.exception.ts index c74e686..b80bcea 100644 --- a/apps/aggregator/src/exceptions/normalization.exception.ts +++ b/apps/aggregator/src/exceptions/normalization.exception.ts @@ -1,4 +1,4 @@ -import { RawPrice } from '@oracle-stocks/shared'; +import type { RawPrice } from "@oracle-stocks/shared"; /** * Base exception for normalization errors @@ -10,7 +10,7 @@ export class NormalizationException extends Error { public readonly cause?: Error, ) { super(message); - this.name = 'NormalizationException'; + this.name = "NormalizationException"; Error.captureStackTrace(this, this.constructor); } } @@ -21,7 +21,7 @@ export class NormalizationException extends Error { export class ValidationException extends NormalizationException { constructor(message: string, rawPrice?: RawPrice) { super(message, rawPrice); - this.name = 'ValidationException'; + this.name = "ValidationException"; } } @@ -31,6 +31,6 @@ export class ValidationException extends NormalizationException { export class NoNormalizerFoundException extends NormalizationException { constructor(source: string, rawPrice?: RawPrice) { super(`No normalizer found for source: ${source}`, rawPrice); - this.name = 'NoNormalizerFoundException'; + this.name = "NoNormalizerFoundException"; } } diff --git a/apps/aggregator/src/health/health.controller.spec.ts b/apps/aggregator/src/health/health.controller.spec.ts index 442ad53..1def1d1 100644 --- a/apps/aggregator/src/health/health.controller.spec.ts +++ b/apps/aggregator/src/health/health.controller.spec.ts @@ -1,28 +1,28 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ServiceUnavailableException } from '@nestjs/common'; -import { HealthCheckService, HealthCheckResult } from '@nestjs/terminus'; -import { HealthController } from './health.controller'; -import { RedisHealthIndicator } from './indicators/redis.health'; -import { IngestorHealthIndicator } from './indicators/ingestor.health'; +import { Test, TestingModule } from "@nestjs/testing"; +import { ServiceUnavailableException } from "@nestjs/common"; +import { HealthCheckService, HealthCheckResult } from "@nestjs/terminus"; +import { HealthController } from "./health.controller"; +import { RedisHealthIndicator } from "./indicators/redis.health"; +import { IngestorHealthIndicator } from "./indicators/ingestor.health"; -describe('HealthController', () => { +describe("HealthController", () => { let controller: HealthController; let healthCheckService: HealthCheckService; const mockRedisHealthy = { - redis: { status: 'up' as const, message: 'Redis is reachable' }, + redis: { status: "up" as const, message: "Redis is reachable" }, }; const mockIngestorHealthy = { - ingestor: { status: 'up' as const, message: 'Ingestor is reachable' }, + ingestor: { status: "up" as const, message: "Ingestor is reachable" }, }; const healthyResult: HealthCheckResult = { - status: 'ok', + status: "ok", info: { ...mockRedisHealthy, ...mockIngestorHealthy }, error: {}, details: { ...mockRedisHealthy, ...mockIngestorHealthy }, }; const unhealthyResult: HealthCheckResult = { - status: 'error', + status: "error", info: {}, error: mockRedisHealthy, details: { ...mockRedisHealthy }, @@ -40,7 +40,9 @@ describe('HealthController', () => { }, { provide: RedisHealthIndicator, - useValue: { isHealthy: jest.fn().mockResolvedValue(mockRedisHealthy) }, + useValue: { + isHealthy: jest.fn().mockResolvedValue(mockRedisHealthy), + }, }, { provide: IngestorHealthIndicator, @@ -56,62 +58,66 @@ describe('HealthController', () => { jest.mocked(healthCheckService.check).mockResolvedValue(healthyResult); }); - it('should be defined', () => { + it("should be defined", () => { expect(controller).toBeDefined(); }); - describe('GET /health', () => { - it('should return 200 and health result when all checks pass', async () => { + describe("GET /health", () => { + it("should return 200 and health result when all checks pass", async () => { const result = await controller.check(); expect(result).toEqual(healthyResult); - expect(result.status).toBe('ok'); + expect(result.status).toBe("ok"); expect(healthCheckService.check).toHaveBeenCalledWith([ expect.any(Function), expect.any(Function), ]); }); - it('should throw ServiceUnavailableException when a check fails', async () => { + it("should throw ServiceUnavailableException when a check fails", async () => { jest.mocked(healthCheckService.check).mockResolvedValue(unhealthyResult); - await expect(controller.check()).rejects.toThrow(ServiceUnavailableException); + await expect(controller.check()).rejects.toThrow( + ServiceUnavailableException, + ); }); }); - describe('GET /ready', () => { - it('should return 200 and health result when ready', async () => { + describe("GET /ready", () => { + it("should return 200 and health result when ready", async () => { const result = await controller.ready(); expect(result).toEqual(healthyResult); - expect(result.status).toBe('ok'); + expect(result.status).toBe("ok"); }); - it('should throw ServiceUnavailableException when not ready', async () => { + it("should throw ServiceUnavailableException when not ready", async () => { jest.mocked(healthCheckService.check).mockResolvedValue(unhealthyResult); - await expect(controller.ready()).rejects.toThrow(ServiceUnavailableException); + await expect(controller.ready()).rejects.toThrow( + ServiceUnavailableException, + ); }); }); - describe('GET /live', () => { - it('should return 200 with status ok', () => { + describe("GET /live", () => { + it("should return 200 with status ok", () => { const result = controller.live(); - expect(result).toEqual({ status: 'ok' }); + expect(result).toEqual({ status: "ok" }); }); - it('should not call any health indicators', () => { + it("should not call any health indicators", () => { controller.live(); expect(healthCheckService.check).not.toHaveBeenCalled(); }); }); - describe('GET /status', () => { - it('should return detailed status with uptime, memory, and checks', async () => { + describe("GET /status", () => { + it("should return detailed status with uptime, memory, and checks", async () => { const result = await controller.status(); expect(result).toMatchObject({ - status: 'ok', + status: "ok", checks: healthyResult, }); - expect(typeof result.uptimeSeconds).toBe('number'); + expect(typeof result.uptimeSeconds).toBe("number"); expect(result.uptimeSeconds).toBeGreaterThanOrEqual(0); - expect(typeof result.timestamp).toBe('number'); + expect(typeof result.timestamp).toBe("number"); expect(result.version).toBeDefined(); expect(result.memory).toMatchObject({ rss: expect.any(Number), diff --git a/apps/aggregator/src/health/health.controller.ts b/apps/aggregator/src/health/health.controller.ts index afe2e98..ea6ba28 100644 --- a/apps/aggregator/src/health/health.controller.ts +++ b/apps/aggregator/src/health/health.controller.ts @@ -4,14 +4,14 @@ import { HttpCode, HttpStatus, ServiceUnavailableException, -} from '@nestjs/common'; +} from "@nestjs/common"; import { HealthCheckService, HealthCheck, HealthCheckResult, -} from '@nestjs/terminus'; -import { RedisHealthIndicator } from './indicators/redis.health'; -import { IngestorHealthIndicator } from './indicators/ingestor.health'; +} from "@nestjs/terminus"; +import { RedisHealthIndicator } from "./indicators/redis.health"; +import { IngestorHealthIndicator } from "./indicators/ingestor.health"; /** Start time for uptime calculation */ const startTime = Date.now(); @@ -36,15 +36,15 @@ export class HealthController { * Full health check. Verifies connectivity to Redis (if configured) and Ingestor (if configured). * Returns 200 if all configured dependencies are healthy, 503 otherwise. */ - @Get('health') + @Get("health") @HealthCheck() @HttpCode(HttpStatus.OK) async check(): Promise { const result = await this.health.check([ - () => this.redis.isHealthy('redis'), - () => this.ingestor.isHealthy('ingestor'), + () => this.redis.isHealthy("redis"), + () => this.ingestor.isHealthy("ingestor"), ]); - if (result.status === 'ok') { + if (result.status === "ok") { return result; } throw new ServiceUnavailableException(result); @@ -54,15 +54,15 @@ export class HealthController { * Readiness probe. Used by Kubernetes to determine if the pod can receive traffic. * Runs the same checks as /health. */ - @Get('ready') + @Get("ready") @HealthCheck() @HttpCode(HttpStatus.OK) async ready(): Promise { const result = await this.health.check([ - () => this.redis.isHealthy('redis'), - () => this.ingestor.isHealthy('ingestor'), + () => this.redis.isHealthy("redis"), + () => this.ingestor.isHealthy("ingestor"), ]); - if (result.status === 'ok') { + if (result.status === "ok") { return result; } throw new ServiceUnavailableException(result); @@ -72,16 +72,16 @@ export class HealthController { * Liveness probe. Used by Kubernetes to determine if the pod should be restarted. * Returns 200 if the process is running (no dependency checks). */ - @Get('live') + @Get("live") @HttpCode(HttpStatus.OK) live(): { status: string } { - return { status: 'ok' }; + return { status: "ok" }; } /** * Detailed status endpoint with system information for debugging. */ - @Get('status') + @Get("status") @HttpCode(HttpStatus.OK) async status(): Promise<{ status: string; @@ -92,14 +92,14 @@ export class HealthController { checks: HealthCheckResult; }> { const checks = await this.health.check([ - () => this.redis.isHealthy('redis'), - () => this.ingestor.isHealthy('ingestor'), + () => this.redis.isHealthy("redis"), + () => this.ingestor.isHealthy("ingestor"), ]); return { status: checks.status, uptimeSeconds: (Date.now() - startTime) / 1000, timestamp: Date.now(), - version: process.env.npm_package_version ?? '0.0.0', + version: process.env.npm_package_version ?? "0.0.0", memory: process.memoryUsage(), checks, }; diff --git a/apps/aggregator/src/health/health.module.ts b/apps/aggregator/src/health/health.module.ts index 8869f47..cf31798 100644 --- a/apps/aggregator/src/health/health.module.ts +++ b/apps/aggregator/src/health/health.module.ts @@ -1,10 +1,10 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { TerminusModule } from '@nestjs/terminus'; -import { HttpModule } from '@nestjs/axios'; -import { HealthController } from './health.controller'; -import { RedisHealthIndicator } from './indicators/redis.health'; -import { IngestorHealthIndicator } from './indicators/ingestor.health'; +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { TerminusModule } from "@nestjs/terminus"; +import { HttpModule } from "@nestjs/axios"; +import { HealthController } from "./health.controller"; +import { RedisHealthIndicator } from "./indicators/redis.health"; +import { IngestorHealthIndicator } from "./indicators/ingestor.health"; @Module({ imports: [ diff --git a/apps/aggregator/src/health/indicators/ingestor.health.spec.ts b/apps/aggregator/src/health/indicators/ingestor.health.spec.ts index 5d01e6f..9e99773 100644 --- a/apps/aggregator/src/health/indicators/ingestor.health.spec.ts +++ b/apps/aggregator/src/health/indicators/ingestor.health.spec.ts @@ -1,11 +1,11 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; -import { of, throwError } from 'rxjs'; -import { HealthCheckError } from '@nestjs/terminus'; -import { IngestorHealthIndicator } from './ingestor.health'; +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; +import { of, throwError } from "rxjs"; +import { HealthCheckError } from "@nestjs/terminus"; +import { IngestorHealthIndicator } from "./ingestor.health"; -describe('IngestorHealthIndicator', () => { +describe("IngestorHealthIndicator", () => { let indicator: IngestorHealthIndicator; let configService: ConfigService; let httpService: HttpService; @@ -30,58 +30,65 @@ describe('IngestorHealthIndicator', () => { httpService = module.get(HttpService); }); - it('should be defined', () => { + it("should be defined", () => { expect(indicator).toBeDefined(); }); it('should return up with "not configured" when INGESTOR_URL is not set', async () => { jest.mocked(configService.get).mockReturnValue(undefined); - const result = await indicator.isHealthy('ingestor'); + const result = await indicator.isHealthy("ingestor"); expect(result).toEqual({ - ingestor: { status: 'up', message: 'Ingestor not configured (skipped)' }, + ingestor: { status: "up", message: "Ingestor not configured (skipped)" }, }); }); - it('should return up when ingestor responds with 200', async () => { - jest.mocked(configService.get).mockReturnValue('http://localhost:3000'); + it("should return up when ingestor responds with 200", async () => { + jest.mocked(configService.get).mockReturnValue("http://localhost:3000"); jest.mocked(httpService.get).mockReturnValue( of({ status: 200, data: [], - statusText: 'OK', + statusText: "OK", headers: {}, config: {} as never, }), ); - const result = await indicator.isHealthy('ingestor'); + const result = await indicator.isHealthy("ingestor"); expect(result).toEqual({ - ingestor: { status: 'up', message: 'Ingestor is reachable' }, + ingestor: { status: "up", message: "Ingestor is reachable" }, }); expect(httpService.get).toHaveBeenCalledWith( - 'http://localhost:3000/prices/raw', - expect.objectContaining({ timeout: 5000, validateStatus: expect.any(Function) }), + "http://localhost:3000/prices/raw", + expect.objectContaining({ + timeout: 5000, + validateStatus: expect.any(Function), + }), ); }); - it('should throw HealthCheckError when ingestor returns 5xx', async () => { - jest.mocked(configService.get).mockReturnValue('http://localhost:3000'); + it("should throw HealthCheckError when ingestor returns 5xx", async () => { + jest.mocked(configService.get).mockReturnValue("http://localhost:3000"); jest.mocked(httpService.get).mockReturnValue( of({ status: 503, data: null, - statusText: 'Service Unavailable', + statusText: "Service Unavailable", headers: {}, config: {} as never, }), ); - await expect(indicator.isHealthy('ingestor')).rejects.toThrow(HealthCheckError); + await expect(indicator.isHealthy("ingestor")).rejects.toThrow( + HealthCheckError, + ); }); - it('should throw HealthCheckError when HTTP request fails', async () => { - jest.mocked(configService.get).mockReturnValue('http://localhost:3000'); - jest.mocked(httpService.get).mockReturnValue( - throwError(() => new Error('ECONNREFUSED')), + it("should throw HealthCheckError when HTTP request fails", async () => { + jest.mocked(configService.get).mockReturnValue("http://localhost:3000"); + jest + .mocked(httpService.get) + .mockReturnValue(throwError(() => new Error("ECONNREFUSED"))); + await expect(indicator.isHealthy("ingestor")).rejects.toThrow( + HealthCheckError, ); - await expect(indicator.isHealthy('ingestor')).rejects.toThrow(HealthCheckError); }); }); diff --git a/apps/aggregator/src/health/indicators/ingestor.health.ts b/apps/aggregator/src/health/indicators/ingestor.health.ts index eb8ec5b..7f9271f 100644 --- a/apps/aggregator/src/health/indicators/ingestor.health.ts +++ b/apps/aggregator/src/health/indicators/ingestor.health.ts @@ -1,13 +1,12 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { HealthIndicator, HealthIndicatorResult, HealthCheckError, -} from '@nestjs/terminus'; -import { HttpService } from '@nestjs/axios'; -import { firstValueFrom, timeout } from 'rxjs'; - +} from "@nestjs/terminus"; +import { HttpService } from "@nestjs/axios"; +import { firstValueFrom, timeout } from "rxjs"; @Injectable() export class IngestorHealthIndicator extends HealthIndicator { @@ -21,12 +20,14 @@ export class IngestorHealthIndicator extends HealthIndicator { } async isHealthy(key: string): Promise { - const baseUrl = this.configService.get('INGESTOR_URL'); + const baseUrl = this.configService.get("INGESTOR_URL"); if (!baseUrl) { - return { [key]: { status: 'up', message: 'Ingestor not configured (skipped)' } }; + return { + [key]: { status: "up", message: "Ingestor not configured (skipped)" }, + }; } - const url = baseUrl.replace(/\/$/, '') + '/prices/raw'; + const url = baseUrl.replace(/\/$/, "") + "/prices/raw"; try { const response = await firstValueFrom( this.httpService @@ -37,15 +38,15 @@ export class IngestorHealthIndicator extends HealthIndicator { .pipe(timeout(IngestorHealthIndicator.TIMEOUT_MS)), ); if (response.status >= 200 && response.status < 400) { - return { [key]: { status: 'up', message: 'Ingestor is reachable' } }; + return { [key]: { status: "up", message: "Ingestor is reachable" } }; } - throw new HealthCheckError('Ingestor check failed', { - [key]: { status: 'down', message: `HTTP ${response.status}` }, + throw new HealthCheckError("Ingestor check failed", { + [key]: { status: "down", message: `HTTP ${response.status}` }, }); } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - throw new HealthCheckError('Ingestor check failed', { - [key]: { status: 'down', message }, + const message = err instanceof Error ? err.message : "Unknown error"; + throw new HealthCheckError("Ingestor check failed", { + [key]: { status: "down", message }, }); } } diff --git a/apps/aggregator/src/health/indicators/redis.health.spec.ts b/apps/aggregator/src/health/indicators/redis.health.spec.ts index 0a621c8..0d64fb1 100644 --- a/apps/aggregator/src/health/indicators/redis.health.spec.ts +++ b/apps/aggregator/src/health/indicators/redis.health.spec.ts @@ -1,9 +1,9 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { HealthCheckError } from '@nestjs/terminus'; -import { RedisHealthIndicator } from './redis.health'; +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { HealthCheckError } from "@nestjs/terminus"; +import { RedisHealthIndicator } from "./redis.health"; -describe('RedisHealthIndicator', () => { +describe("RedisHealthIndicator", () => { let indicator: RedisHealthIndicator; let configService: ConfigService; @@ -22,32 +22,32 @@ describe('RedisHealthIndicator', () => { configService = module.get(ConfigService); }); - it('should be defined', () => { + it("should be defined", () => { expect(indicator).toBeDefined(); }); it('should return up with "not configured" when REDIS_URL is not set', async () => { jest.mocked(configService.get).mockReturnValue(undefined); - const result = await indicator.isHealthy('redis'); + const result = await indicator.isHealthy("redis"); expect(result).toEqual({ - redis: { status: 'up', message: 'Redis not configured (skipped)' }, + redis: { status: "up", message: "Redis not configured (skipped)" }, }); }); it('should return up with "not configured" when REDIS_URL is empty string', async () => { - jest.mocked(configService.get).mockReturnValue(''); - const result = await indicator.isHealthy('redis'); + jest.mocked(configService.get).mockReturnValue(""); + const result = await indicator.isHealthy("redis"); expect(result).toEqual({ - redis: { status: 'up', message: 'Redis not configured (skipped)' }, + redis: { status: "up", message: "Redis not configured (skipped)" }, }); }); - it('should check Redis when REDIS_URL is set', async () => { - jest.mocked(configService.get).mockReturnValue('redis://localhost:6379'); + it("should check Redis when REDIS_URL is set", async () => { + jest.mocked(configService.get).mockReturnValue("redis://localhost:6379"); try { - const result = await indicator.isHealthy('redis'); + const result = await indicator.isHealthy("redis"); expect(result.redis).toBeDefined(); - expect(result.redis.status).toBe('up'); + expect(result.redis.status).toBe("up"); } catch (err) { expect(err).toBeInstanceOf(HealthCheckError); expect((err as HealthCheckError).causes).toBeDefined(); diff --git a/apps/aggregator/src/health/indicators/redis.health.ts b/apps/aggregator/src/health/indicators/redis.health.ts index a2114a1..8d3704d 100644 --- a/apps/aggregator/src/health/indicators/redis.health.ts +++ b/apps/aggregator/src/health/indicators/redis.health.ts @@ -1,11 +1,11 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { HealthIndicator, HealthIndicatorResult, HealthCheckError, -} from '@nestjs/terminus'; -import Redis from 'ioredis'; +} from "@nestjs/terminus"; +import Redis from "ioredis"; @Injectable() export class RedisHealthIndicator extends HealthIndicator { constructor(private readonly configService: ConfigService) { @@ -13,9 +13,11 @@ export class RedisHealthIndicator extends HealthIndicator { } async isHealthy(key: string): Promise { - const redisUrl = this.configService.get('REDIS_URL'); + const redisUrl = this.configService.get("REDIS_URL"); if (!redisUrl) { - return { [key]: { status: 'up', message: 'Redis not configured (skipped)' } }; + return { + [key]: { status: "up", message: "Redis not configured (skipped)" }, + }; } let redis: Redis | null = null; @@ -28,11 +30,11 @@ export class RedisHealthIndicator extends HealthIndicator { await redis.connect(); const pong = await redis.ping(); await redis.quit(); - if (pong === 'PONG') { - return { [key]: { status: 'up', message: 'Redis is reachable' } }; + if (pong === "PONG") { + return { [key]: { status: "up", message: "Redis is reachable" } }; } - throw new HealthCheckError('Redis check failed', { - [key]: { status: 'down', message: 'PING did not return PONG' }, + throw new HealthCheckError("Redis check failed", { + [key]: { status: "down", message: "PING did not return PONG" }, }); } catch (err) { if (redis) { @@ -42,9 +44,9 @@ export class RedisHealthIndicator extends HealthIndicator { // ignore } } - const message = err instanceof Error ? err.message : 'Unknown error'; - throw new HealthCheckError('Redis check failed', { - [key]: { status: 'down', message }, + const message = err instanceof Error ? err.message : "Unknown error"; + throw new HealthCheckError("Redis check failed", { + [key]: { status: "down", message }, }); } } diff --git a/apps/aggregator/src/interfaces/aggregated-price.interface.ts b/apps/aggregator/src/interfaces/aggregated-price.interface.ts index 8968cf6..31ee3b1 100644 --- a/apps/aggregator/src/interfaces/aggregated-price.interface.ts +++ b/apps/aggregator/src/interfaces/aggregated-price.interface.ts @@ -10,7 +10,7 @@ export interface AggregatedPrice { price: number; /** Method used for aggregation */ - method: 'weighted-average' | 'median' | 'trimmed-mean'; + method: "weighted-average" | "median" | "trimmed-mean"; /** Confidence score (0-100) based on agreement between sources */ confidence: number; diff --git a/apps/aggregator/src/interfaces/aggregation-config.interface.ts b/apps/aggregator/src/interfaces/aggregation-config.interface.ts index 3b30302..0f7b33e 100644 --- a/apps/aggregator/src/interfaces/aggregation-config.interface.ts +++ b/apps/aggregator/src/interfaces/aggregation-config.interface.ts @@ -9,7 +9,7 @@ export interface AggregationConfig { timeWindowMs: number; /** Default aggregation method to use */ - defaultMethod: 'weighted-average' | 'median' | 'trimmed-mean'; + defaultMethod: "weighted-average" | "median" | "trimmed-mean"; /** Percentage of extremes to trim in trimmed-mean (0.0 to 0.5) */ trimmedMeanPercentage: number; diff --git a/apps/aggregator/src/interfaces/aggregator.interface.ts b/apps/aggregator/src/interfaces/aggregator.interface.ts index f6f061c..d68c99e 100644 --- a/apps/aggregator/src/interfaces/aggregator.interface.ts +++ b/apps/aggregator/src/interfaces/aggregator.interface.ts @@ -1,4 +1,4 @@ -import { NormalizedPrice } from './normalized-price.interface'; +import { NormalizedPrice } from "./normalized-price.interface"; /** * Interface for aggregation strategy implementations diff --git a/apps/aggregator/src/interfaces/index.ts b/apps/aggregator/src/interfaces/index.ts index f90f132..373350d 100644 --- a/apps/aggregator/src/interfaces/index.ts +++ b/apps/aggregator/src/interfaces/index.ts @@ -1,2 +1,3 @@ -export * from './normalized-price.interface'; -export * from './normalizer.interface'; +export * from "./normalized-price.interface"; +export * from "./normalizer.interface"; +export * from "./outlier-result.interface"; diff --git a/apps/aggregator/src/interfaces/normalized-price.interface.ts b/apps/aggregator/src/interfaces/normalized-price.interface.ts index 4e45e80..2f25320 100644 --- a/apps/aggregator/src/interfaces/normalized-price.interface.ts +++ b/apps/aggregator/src/interfaces/normalized-price.interface.ts @@ -2,11 +2,11 @@ * Enum for standardized source identifiers */ export enum NormalizedSource { - ALPHA_VANTAGE = 'alpha_vantage', - FINNHUB = 'finnhub', - YAHOO_FINANCE = 'yahoo_finance', - MOCK = 'mock', - UNKNOWN = 'unknown', + ALPHA_VANTAGE = "alpha_vantage", + FINNHUB = "finnhub", + YAHOO_FINANCE = "yahoo_finance", + MOCK = "mock", + UNKNOWN = "unknown", } /** diff --git a/apps/aggregator/src/interfaces/normalizer.interface.ts b/apps/aggregator/src/interfaces/normalizer.interface.ts index 90cdff6..bb17ee7 100644 --- a/apps/aggregator/src/interfaces/normalizer.interface.ts +++ b/apps/aggregator/src/interfaces/normalizer.interface.ts @@ -1,5 +1,8 @@ -import { RawPrice } from '@oracle-stocks/shared'; -import { NormalizedPriceRecord, NormalizedSource } from './normalized-price.interface'; +import type { RawPrice } from "@oracle-stocks/shared"; +import { + NormalizedPriceRecord, + NormalizedSource, +} from "./normalized-price.interface"; /** * Interface for source-specific normalization strategies. diff --git a/apps/aggregator/src/interfaces/outlier-result.interface.ts b/apps/aggregator/src/interfaces/outlier-result.interface.ts new file mode 100644 index 0000000..3a98057 --- /dev/null +++ b/apps/aggregator/src/interfaces/outlier-result.interface.ts @@ -0,0 +1,51 @@ +import { NormalizedPrice } from "./normalized-price.interface"; + +export interface OutlierThresholdConfig { + minPrice: number; + maxChangePercent: number; + maxChangeWindowSeconds: number; + iqrMultiplier: number; + zScoreThreshold: number; + madThreshold: number; + minSamples: number; +} + +export interface OutlierReason { + strategy: "range" | "iqr" | "zscore" | "mad"; + code: + | "NON_POSITIVE_PRICE" + | "PRICE_CHANGE_TOO_LARGE" + | "OUTSIDE_IQR_BOUNDS" + | "ZSCORE_EXCEEDED" + | "MAD_SCORE_EXCEEDED"; + message: string; + value?: number; + threshold?: number; +} + +export interface OutlierResult { + price: NormalizedPrice; + isOutlier: boolean; + reasons: OutlierReason[]; +} + +export interface SourceQualityMetric { + source: string; + totalCount: number; + outlierCount: number; + outlierRate: number; +} + +export interface OutlierDetectionSummary { + analyzedCount: number; + outlierCount: number; + cleanCount: number; + thresholds: OutlierThresholdConfig; +} + +export interface OutlierDetectionBatchResult { + symbol: string; + results: OutlierResult[]; + sourceMetrics: Record; + summary: OutlierDetectionSummary; +} diff --git a/apps/aggregator/src/main.ts b/apps/aggregator/src/main.ts index a5c0c0e..db36275 100644 --- a/apps/aggregator/src/main.ts +++ b/apps/aggregator/src/main.ts @@ -1,10 +1,10 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "./app.module"; async function bootstrap() { const app = await NestFactory.create(AppModule); const port = process.env.PORT || 3001; - + await app.listen(port); console.log(`Aggregator service is running on: http://localhost:${port}`); } diff --git a/apps/aggregator/src/metrics/metrics.controller.spec.ts b/apps/aggregator/src/metrics/metrics.controller.spec.ts index 097f8f2..edc4355 100644 --- a/apps/aggregator/src/metrics/metrics.controller.spec.ts +++ b/apps/aggregator/src/metrics/metrics.controller.spec.ts @@ -1,8 +1,8 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { MetricsController } from './metrics.controller'; -import { MetricsService } from './metrics.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { MetricsController } from "./metrics.controller"; +import { MetricsService } from "./metrics.service"; -describe('MetricsController', () => { +describe("MetricsController", () => { let controller: MetricsController; let metricsService: MetricsService; @@ -13,8 +13,12 @@ describe('MetricsController', () => { { provide: MetricsService, useValue: { - getMetrics: jest.fn().mockResolvedValue('# HELP dummy\n# TYPE dummy counter\ndummy 0'), - getContentType: jest.fn().mockReturnValue('text/plain; version=0.0.4; charset=utf-8'), + getMetrics: jest + .fn() + .mockResolvedValue("# HELP dummy\n# TYPE dummy counter\ndummy 0"), + getContentType: jest + .fn() + .mockReturnValue("text/plain; version=0.0.4; charset=utf-8"), }, }, ], @@ -24,14 +28,14 @@ describe('MetricsController', () => { metricsService = module.get(MetricsService); }); - it('should be defined', () => { + it("should be defined", () => { expect(controller).toBeDefined(); }); - describe('GET /metrics', () => { - it('should return Prometheus metrics from MetricsService', async () => { + describe("GET /metrics", () => { + it("should return Prometheus metrics from MetricsService", async () => { const result = await controller.getMetrics(); - expect(result).toContain('# HELP dummy'); + expect(result).toContain("# HELP dummy"); expect(metricsService.getMetrics).toHaveBeenCalled(); }); }); diff --git a/apps/aggregator/src/metrics/metrics.controller.ts b/apps/aggregator/src/metrics/metrics.controller.ts index ca545f3..dacc0f7 100644 --- a/apps/aggregator/src/metrics/metrics.controller.ts +++ b/apps/aggregator/src/metrics/metrics.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Get, Header, HttpCode, HttpStatus } from '@nestjs/common'; -import { MetricsService } from './metrics.service'; +import { Controller, Get, Header, HttpCode, HttpStatus } from "@nestjs/common"; +import { MetricsService } from "./metrics.service"; /** * Metrics controller exposing Prometheus metrics. @@ -13,9 +13,9 @@ export class MetricsController { /** * Prometheus metrics endpoint. Returns metrics in text format for Prometheus server to scrape. */ - @Get('metrics') + @Get("metrics") @HttpCode(HttpStatus.OK) - @Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') + @Header("Content-Type", "text/plain; version=0.0.4; charset=utf-8") async getMetrics(): Promise { return this.metricsService.getMetrics(); } diff --git a/apps/aggregator/src/metrics/metrics.module.ts b/apps/aggregator/src/metrics/metrics.module.ts index 0403c34..2eb12e9 100644 --- a/apps/aggregator/src/metrics/metrics.module.ts +++ b/apps/aggregator/src/metrics/metrics.module.ts @@ -1,6 +1,6 @@ -import { Module } from '@nestjs/common'; -import { MetricsController } from './metrics.controller'; -import { MetricsService } from './metrics.service'; +import { Module } from "@nestjs/common"; +import { MetricsController } from "./metrics.controller"; +import { MetricsService } from "./metrics.service"; @Module({ controllers: [MetricsController], diff --git a/apps/aggregator/src/metrics/metrics.service.spec.ts b/apps/aggregator/src/metrics/metrics.service.spec.ts index 178731a..99573bb 100644 --- a/apps/aggregator/src/metrics/metrics.service.spec.ts +++ b/apps/aggregator/src/metrics/metrics.service.spec.ts @@ -1,7 +1,7 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { MetricsService } from './metrics.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { MetricsService } from "./metrics.service"; -describe('MetricsService', () => { +describe("MetricsService", () => { let service: MetricsService; beforeEach(async () => { @@ -12,20 +12,20 @@ describe('MetricsService', () => { service = module.get(MetricsService); }); - it('should be defined', () => { + it("should be defined", () => { expect(service).toBeDefined(); }); - describe('recordAggregation', () => { - it('should record aggregation count and latency', async () => { - service.recordAggregation('weighted-average', 'AAPL', 0.05); - service.recordAggregation('weighted-average', 'AAPL', 0.1); - service.recordAggregation('median', 'GOOGL', 0.02); + describe("recordAggregation", () => { + it("should record aggregation count and latency", async () => { + service.recordAggregation("weighted-average", "AAPL", 0.05); + service.recordAggregation("weighted-average", "AAPL", 0.1); + service.recordAggregation("median", "GOOGL", 0.02); const metrics = await service.getMetrics(); - expect(metrics).toContain('aggregator_aggregations_total'); - expect(metrics).toContain('aggregator_aggregation_duration_seconds'); - expect(metrics).toContain('aggregator_aggregations_by_symbol_total'); + expect(metrics).toContain("aggregator_aggregations_total"); + expect(metrics).toContain("aggregator_aggregation_duration_seconds"); + expect(metrics).toContain("aggregator_aggregations_by_symbol_total"); expect(metrics).toContain('method="weighted-average"'); expect(metrics).toContain('method="median"'); expect(metrics).toContain('symbol="AAPL"'); @@ -33,45 +33,45 @@ describe('MetricsService', () => { }); }); - describe('recordError', () => { - it('should record aggregation errors', async () => { - service.recordError('weighted-average'); - service.recordError('weighted-average'); - service.recordError('median'); + describe("recordError", () => { + it("should record aggregation errors", async () => { + service.recordError("weighted-average"); + service.recordError("weighted-average"); + service.recordError("median"); const metrics = await service.getMetrics(); - expect(metrics).toContain('aggregator_errors_total'); + expect(metrics).toContain("aggregator_errors_total"); expect(metrics).toContain('method="weighted-average"'); expect(metrics).toContain('method="median"'); }); }); - describe('getMetrics', () => { - it('should return Prometheus text format', async () => { + describe("getMetrics", () => { + it("should return Prometheus text format", async () => { const metrics = await service.getMetrics(); - expect(typeof metrics).toBe('string'); + expect(typeof metrics).toBe("string"); expect(metrics.length).toBeGreaterThan(0); // Default Node.js metrics are also collected expect( - metrics.includes('aggregator_') || metrics.includes('# HELP'), + metrics.includes("aggregator_") || metrics.includes("# HELP"), ).toBe(true); }); }); - describe('getContentType', () => { - it('should return Prometheus exposition content type', () => { + describe("getContentType", () => { + it("should return Prometheus exposition content type", () => { const contentType = service.getContentType(); - expect(contentType).toContain('text/plain'); - expect(contentType).toContain('charset=utf-8'); + expect(contentType).toContain("text/plain"); + expect(contentType).toContain("charset=utf-8"); }); }); - describe('getRegister', () => { - it('should return the Prometheus registry', () => { + describe("getRegister", () => { + it("should return the Prometheus registry", () => { const register = service.getRegister(); expect(register).toBeDefined(); expect(register.metrics).toBeDefined(); - expect(typeof register.metrics).toBe('function'); + expect(typeof register.metrics).toBe("function"); }); }); }); diff --git a/apps/aggregator/src/metrics/metrics.service.ts b/apps/aggregator/src/metrics/metrics.service.ts index 4d0e8c3..89db735 100644 --- a/apps/aggregator/src/metrics/metrics.service.ts +++ b/apps/aggregator/src/metrics/metrics.service.ts @@ -1,10 +1,10 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable } from "@nestjs/common"; import { Registry, Counter, Histogram, collectDefaultMetrics, -} from 'prom-client'; +} from "prom-client"; /** * Service that registers and updates Prometheus metrics for the aggregator. @@ -29,37 +29,41 @@ export class MetricsService { constructor() { this.register = new Registry(); this.aggregationCount = new Counter({ - name: 'aggregator_aggregations_total', - help: 'Total number of aggregation operations', - labelNames: ['method'], + name: "aggregator_aggregations_total", + help: "Total number of aggregation operations", + labelNames: ["method"], registers: [this.register], }); this.aggregationLatency = new Histogram({ - name: 'aggregator_aggregation_duration_seconds', - help: 'Aggregation operation duration in seconds', - labelNames: ['method'], + name: "aggregator_aggregation_duration_seconds", + help: "Aggregation operation duration in seconds", + labelNames: ["method"], buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1], registers: [this.register], }); this.aggregationErrors = new Counter({ - name: 'aggregator_errors_total', - help: 'Total number of aggregation errors', - labelNames: ['method'], + name: "aggregator_errors_total", + help: "Total number of aggregation errors", + labelNames: ["method"], registers: [this.register], }); this.aggregationsBySymbol = new Counter({ - name: 'aggregator_aggregations_by_symbol_total', - help: 'Total aggregations per symbol', - labelNames: ['symbol', 'method'], + name: "aggregator_aggregations_by_symbol_total", + help: "Total aggregations per symbol", + labelNames: ["symbol", "method"], registers: [this.register], }); - collectDefaultMetrics({ register: this.register, prefix: 'aggregator_' }); + collectDefaultMetrics({ register: this.register, prefix: "aggregator_" }); } /** * Record a successful aggregation with duration. */ - recordAggregation(method: string, symbol: string, durationSeconds: number): void { + recordAggregation( + method: string, + symbol: string, + durationSeconds: number, + ): void { this.aggregationCount.inc({ method }, 1); this.aggregationLatency.observe({ method }, durationSeconds); this.aggregationsBySymbol.inc({ symbol, method }, 1); diff --git a/apps/aggregator/src/modules/normalization.module.ts b/apps/aggregator/src/modules/normalization.module.ts index 7f6a8d7..5859cb4 100644 --- a/apps/aggregator/src/modules/normalization.module.ts +++ b/apps/aggregator/src/modules/normalization.module.ts @@ -1,5 +1,5 @@ -import { Module } from '@nestjs/common'; -import { NormalizationService } from '../services/normalization.service'; +import { Module } from "@nestjs/common"; +import { NormalizationService } from "../services/normalization.service"; @Module({ providers: [NormalizationService], diff --git a/apps/aggregator/src/normalizers/alpha-vantage.normalizer.spec.ts b/apps/aggregator/src/normalizers/alpha-vantage.normalizer.spec.ts index 770ca47..76da00c 100644 --- a/apps/aggregator/src/normalizers/alpha-vantage.normalizer.spec.ts +++ b/apps/aggregator/src/normalizers/alpha-vantage.normalizer.spec.ts @@ -1,15 +1,15 @@ -import { AlphaVantageNormalizer } from './alpha-vantage.normalizer'; -import { NormalizedSource } from '../interfaces/normalized-price.interface'; -import { RawPrice } from '@oracle-stocks/shared'; +import { AlphaVantageNormalizer } from "./alpha-vantage.normalizer"; +import { NormalizedSource } from "../interfaces/normalized-price.interface"; +import type { RawPrice } from "@oracle-stocks/shared"; -describe('AlphaVantageNormalizer', () => { +describe("AlphaVantageNormalizer", () => { let normalizer: AlphaVantageNormalizer; const createMockPrice = (overrides: Partial = {}): RawPrice => ({ - symbol: 'AAPL', + symbol: "AAPL", price: 150.0, timestamp: Date.now(), - source: 'AlphaVantage', + source: "AlphaVantage", ...overrides, }); @@ -17,119 +17,134 @@ describe('AlphaVantageNormalizer', () => { normalizer = new AlphaVantageNormalizer(); }); - describe('properties', () => { - it('should have correct name', () => { - expect(normalizer.name).toBe('AlphaVantageNormalizer'); + describe("properties", () => { + it("should have correct name", () => { + expect(normalizer.name).toBe("AlphaVantageNormalizer"); }); - it('should have correct source', () => { + it("should have correct source", () => { expect(normalizer.source).toBe(NormalizedSource.ALPHA_VANTAGE); }); - it('should have version', () => { - expect(normalizer.version).toBe('1.0.0'); + it("should have version", () => { + expect(normalizer.version).toBe("1.0.0"); }); }); - describe('canNormalize', () => { - it('should return true for AlphaVantage source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'AlphaVantage' }))).toBe(true); + describe("canNormalize", () => { + it("should return true for AlphaVantage source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "AlphaVantage" })), + ).toBe(true); }); - it('should return true for alpha_vantage source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'alpha_vantage' }))).toBe(true); + it("should return true for alpha_vantage source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "alpha_vantage" })), + ).toBe(true); }); - it('should return true for alpha-vantage source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'alpha-vantage' }))).toBe(true); + it("should return true for alpha-vantage source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "alpha-vantage" })), + ).toBe(true); }); - it('should return true for ALPHAVANTAGE source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'ALPHAVANTAGE' }))).toBe(true); + it("should return true for ALPHAVANTAGE source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "ALPHAVANTAGE" })), + ).toBe(true); }); - it('should return false for Finnhub source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'Finnhub' }))).toBe(false); + it("should return false for Finnhub source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "Finnhub" })), + ).toBe(false); }); - it('should return false for Yahoo source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'Yahoo Finance' }))).toBe(false); + it("should return false for Yahoo source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "Yahoo Finance" })), + ).toBe(false); }); }); - describe('normalizeSymbol', () => { - it('should remove .US suffix', () => { - expect(normalizer.normalizeSymbol('AAPL.US')).toBe('AAPL'); + describe("normalizeSymbol", () => { + it("should remove .US suffix", () => { + expect(normalizer.normalizeSymbol("AAPL.US")).toBe("AAPL"); }); - it('should remove .NYSE suffix', () => { - expect(normalizer.normalizeSymbol('MSFT.NYSE')).toBe('MSFT'); + it("should remove .NYSE suffix", () => { + expect(normalizer.normalizeSymbol("MSFT.NYSE")).toBe("MSFT"); }); - it('should remove .NASDAQ suffix', () => { - expect(normalizer.normalizeSymbol('GOOGL.NASDAQ')).toBe('GOOGL'); + it("should remove .NASDAQ suffix", () => { + expect(normalizer.normalizeSymbol("GOOGL.NASDAQ")).toBe("GOOGL"); }); - it('should remove .LSE suffix', () => { - expect(normalizer.normalizeSymbol('BP.LSE')).toBe('BP'); + it("should remove .LSE suffix", () => { + expect(normalizer.normalizeSymbol("BP.LSE")).toBe("BP"); }); - it('should remove .TSX suffix', () => { - expect(normalizer.normalizeSymbol('RY.TSX')).toBe('RY'); + it("should remove .TSX suffix", () => { + expect(normalizer.normalizeSymbol("RY.TSX")).toBe("RY"); }); - it('should remove .ASX suffix', () => { - expect(normalizer.normalizeSymbol('BHP.ASX')).toBe('BHP'); + it("should remove .ASX suffix", () => { + expect(normalizer.normalizeSymbol("BHP.ASX")).toBe("BHP"); }); - it('should remove .HK suffix', () => { - expect(normalizer.normalizeSymbol('0005.HK')).toBe('0005'); + it("should remove .HK suffix", () => { + expect(normalizer.normalizeSymbol("0005.HK")).toBe("0005"); }); - it('should handle already clean symbols', () => { - expect(normalizer.normalizeSymbol('GOOGL')).toBe('GOOGL'); + it("should handle already clean symbols", () => { + expect(normalizer.normalizeSymbol("GOOGL")).toBe("GOOGL"); }); - it('should uppercase symbols', () => { - expect(normalizer.normalizeSymbol('aapl.us')).toBe('AAPL'); + it("should uppercase symbols", () => { + expect(normalizer.normalizeSymbol("aapl.us")).toBe("AAPL"); }); - it('should trim whitespace', () => { - expect(normalizer.normalizeSymbol(' AAPL.US ')).toBe('AAPL'); + it("should trim whitespace", () => { + expect(normalizer.normalizeSymbol(" AAPL.US ")).toBe("AAPL"); }); }); - describe('normalize', () => { - it('should produce correct normalized price', () => { - const rawPrice = createMockPrice({ symbol: 'AAPL.US', price: 150.123456 }); + describe("normalize", () => { + it("should produce correct normalized price", () => { + const rawPrice = createMockPrice({ + symbol: "AAPL.US", + price: 150.123456, + }); const result = normalizer.normalize(rawPrice); - expect(result.symbol).toBe('AAPL'); + expect(result.symbol).toBe("AAPL"); expect(result.price).toBe(150.1235); expect(result.source).toBe(NormalizedSource.ALPHA_VANTAGE); - expect(result.metadata.originalSymbol).toBe('AAPL.US'); + expect(result.metadata.originalSymbol).toBe("AAPL.US"); expect(result.metadata.wasTransformed).toBe(true); }); }); - describe('normalizeMany', () => { - it('should normalize multiple prices', () => { + describe("normalizeMany", () => { + it("should normalize multiple prices", () => { const prices = [ - createMockPrice({ symbol: 'AAPL.US' }), - createMockPrice({ symbol: 'MSFT.NYSE' }), + createMockPrice({ symbol: "AAPL.US" }), + createMockPrice({ symbol: "MSFT.NYSE" }), ]; const results = normalizer.normalizeMany(prices); expect(results.length).toBe(2); - expect(results[0].symbol).toBe('AAPL'); - expect(results[1].symbol).toBe('MSFT'); + expect(results[0].symbol).toBe("AAPL"); + expect(results[1].symbol).toBe("MSFT"); }); - it('should skip prices from other sources', () => { + it("should skip prices from other sources", () => { const prices = [ - createMockPrice({ symbol: 'AAPL.US' }), - createMockPrice({ symbol: 'GOOGL', source: 'Finnhub' }), + createMockPrice({ symbol: "AAPL.US" }), + createMockPrice({ symbol: "GOOGL", source: "Finnhub" }), ]; const results = normalizer.normalizeMany(prices); diff --git a/apps/aggregator/src/normalizers/alpha-vantage.normalizer.ts b/apps/aggregator/src/normalizers/alpha-vantage.normalizer.ts index 0e60ca6..2fa95ff 100644 --- a/apps/aggregator/src/normalizers/alpha-vantage.normalizer.ts +++ b/apps/aggregator/src/normalizers/alpha-vantage.normalizer.ts @@ -1,6 +1,6 @@ -import { RawPrice } from '@oracle-stocks/shared'; -import { NormalizedSource } from '../interfaces/normalized-price.interface'; -import { BaseNormalizer } from './base.normalizer'; +import type { RawPrice } from "@oracle-stocks/shared"; +import { NormalizedSource } from "../interfaces/normalized-price.interface"; +import { BaseNormalizer } from "./base.normalizer"; /** * Normalizer for Alpha Vantage data source. @@ -10,20 +10,20 @@ import { BaseNormalizer } from './base.normalizer'; * - Case variations in source name */ export class AlphaVantageNormalizer extends BaseNormalizer { - readonly name = 'AlphaVantageNormalizer'; + readonly name = "AlphaVantageNormalizer"; readonly source = NormalizedSource.ALPHA_VANTAGE; - readonly version = '1.0.0'; + readonly version = "1.0.0"; private readonly SOURCE_IDENTIFIERS = [ - 'alphavantage', - 'alpha_vantage', - 'alpha-vantage', + "alphavantage", + "alpha_vantage", + "alpha-vantage", ]; canNormalize(rawPrice: RawPrice): boolean { - const sourceLower = rawPrice.source.toLowerCase().replace(/[\s_-]/g, ''); + const sourceLower = rawPrice.source.toLowerCase().replace(/[\s_-]/g, ""); return this.SOURCE_IDENTIFIERS.some((id) => - sourceLower.includes(id.replace(/[\s_-]/g, '')), + sourceLower.includes(id.replace(/[\s_-]/g, "")), ); } @@ -33,7 +33,7 @@ export class AlphaVantageNormalizer extends BaseNormalizer { // Remove common Alpha Vantage exchange suffixes // e.g., "AAPL.US" -> "AAPL", "MSFT.NYSE" -> "MSFT" const suffixPattern = /\.(US|NYSE|NASDAQ|LSE|TSX|ASX|HK|LON)$/i; - normalized = normalized.replace(suffixPattern, ''); + normalized = normalized.replace(suffixPattern, ""); return normalized; } diff --git a/apps/aggregator/src/normalizers/base.normalizer.ts b/apps/aggregator/src/normalizers/base.normalizer.ts index 79114f5..af8661d 100644 --- a/apps/aggregator/src/normalizers/base.normalizer.ts +++ b/apps/aggregator/src/normalizers/base.normalizer.ts @@ -1,12 +1,12 @@ -import { Logger } from '@nestjs/common'; -import { RawPrice } from '@oracle-stocks/shared'; +import { Logger } from "@nestjs/common"; +import type { RawPrice } from "@oracle-stocks/shared"; import { NormalizedPriceRecord, NormalizedSource, NormalizationMetadata, -} from '../interfaces/normalized-price.interface'; -import { Normalizer } from '../interfaces/normalizer.interface'; -import { ValidationException } from '../exceptions'; +} from "../interfaces/normalized-price.interface"; +import { Normalizer } from "../interfaces/normalizer.interface"; +import { ValidationException } from "../exceptions"; /** * Abstract base class for all normalizers providing common functionality. @@ -101,42 +101,42 @@ export abstract class BaseNormalizer implements Normalizer { */ protected validateRawPrice(rawPrice: RawPrice): void { if (!rawPrice) { - throw new ValidationException('Raw price cannot be null or undefined'); + throw new ValidationException("Raw price cannot be null or undefined"); } - if (!rawPrice.symbol || typeof rawPrice.symbol !== 'string') { + if (!rawPrice.symbol || typeof rawPrice.symbol !== "string") { throw new ValidationException( - 'Symbol is required and must be a string', + "Symbol is required and must be a string", rawPrice, ); } if ( rawPrice.price === null || rawPrice.price === undefined || - typeof rawPrice.price !== 'number' + typeof rawPrice.price !== "number" ) { throw new ValidationException( - 'Price is required and must be a number', + "Price is required and must be a number", rawPrice, ); } if (isNaN(rawPrice.price) || !isFinite(rawPrice.price)) { throw new ValidationException( - 'Price must be a valid finite number', + "Price must be a valid finite number", rawPrice, ); } if (rawPrice.price < 0) { - throw new ValidationException('Price cannot be negative', rawPrice); + throw new ValidationException("Price cannot be negative", rawPrice); } - if (!rawPrice.timestamp || typeof rawPrice.timestamp !== 'number') { + if (!rawPrice.timestamp || typeof rawPrice.timestamp !== "number") { throw new ValidationException( - 'Timestamp is required and must be a number', + "Timestamp is required and must be a number", rawPrice, ); } - if (!rawPrice.source || typeof rawPrice.source !== 'string') { + if (!rawPrice.source || typeof rawPrice.source !== "string") { throw new ValidationException( - 'Source is required and must be a string', + "Source is required and must be a string", rawPrice, ); } diff --git a/apps/aggregator/src/normalizers/finnhub.normalizer.spec.ts b/apps/aggregator/src/normalizers/finnhub.normalizer.spec.ts index c7d3c9b..f2e2798 100644 --- a/apps/aggregator/src/normalizers/finnhub.normalizer.spec.ts +++ b/apps/aggregator/src/normalizers/finnhub.normalizer.spec.ts @@ -1,15 +1,15 @@ -import { FinnhubNormalizer } from './finnhub.normalizer'; -import { NormalizedSource } from '../interfaces/normalized-price.interface'; -import { RawPrice } from '@oracle-stocks/shared'; +import { FinnhubNormalizer } from "./finnhub.normalizer"; +import { NormalizedSource } from "../interfaces/normalized-price.interface"; +import type { RawPrice } from "@oracle-stocks/shared"; -describe('FinnhubNormalizer', () => { +describe("FinnhubNormalizer", () => { let normalizer: FinnhubNormalizer; const createMockPrice = (overrides: Partial = {}): RawPrice => ({ - symbol: 'AAPL', + symbol: "AAPL", price: 150.0, timestamp: Date.now(), - source: 'Finnhub', + source: "Finnhub", ...overrides, }); @@ -17,96 +17,106 @@ describe('FinnhubNormalizer', () => { normalizer = new FinnhubNormalizer(); }); - describe('properties', () => { - it('should have correct name', () => { - expect(normalizer.name).toBe('FinnhubNormalizer'); + describe("properties", () => { + it("should have correct name", () => { + expect(normalizer.name).toBe("FinnhubNormalizer"); }); - it('should have correct source', () => { + it("should have correct source", () => { expect(normalizer.source).toBe(NormalizedSource.FINNHUB); }); - it('should have version', () => { - expect(normalizer.version).toBe('1.0.0'); + it("should have version", () => { + expect(normalizer.version).toBe("1.0.0"); }); }); - describe('canNormalize', () => { - it('should return true for Finnhub source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'Finnhub' }))).toBe(true); + describe("canNormalize", () => { + it("should return true for Finnhub source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "Finnhub" })), + ).toBe(true); }); - it('should return true for finnhub lowercase', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'finnhub' }))).toBe(true); + it("should return true for finnhub lowercase", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "finnhub" })), + ).toBe(true); }); - it('should return true for FINNHUB uppercase', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'FINNHUB' }))).toBe(true); + it("should return true for FINNHUB uppercase", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "FINNHUB" })), + ).toBe(true); }); - it('should return false for AlphaVantage source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'AlphaVantage' }))).toBe(false); + it("should return false for AlphaVantage source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "AlphaVantage" })), + ).toBe(false); }); - it('should return false for Yahoo source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'Yahoo Finance' }))).toBe(false); + it("should return false for Yahoo source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "Yahoo Finance" })), + ).toBe(false); }); }); - describe('normalizeSymbol', () => { - it('should remove US- prefix', () => { - expect(normalizer.normalizeSymbol('US-AAPL')).toBe('AAPL'); + describe("normalizeSymbol", () => { + it("should remove US- prefix", () => { + expect(normalizer.normalizeSymbol("US-AAPL")).toBe("AAPL"); }); - it('should remove CRYPTO- prefix', () => { - expect(normalizer.normalizeSymbol('CRYPTO-BTC')).toBe('BTC'); + it("should remove CRYPTO- prefix", () => { + expect(normalizer.normalizeSymbol("CRYPTO-BTC")).toBe("BTC"); }); - it('should remove FX- prefix', () => { - expect(normalizer.normalizeSymbol('FX-EURUSD')).toBe('EURUSD'); + it("should remove FX- prefix", () => { + expect(normalizer.normalizeSymbol("FX-EURUSD")).toBe("EURUSD"); }); - it('should remove INDICES- prefix', () => { - expect(normalizer.normalizeSymbol('INDICES-SPX')).toBe('SPX'); + it("should remove INDICES- prefix", () => { + expect(normalizer.normalizeSymbol("INDICES-SPX")).toBe("SPX"); }); - it('should handle already clean symbols', () => { - expect(normalizer.normalizeSymbol('AAPL')).toBe('AAPL'); + it("should handle already clean symbols", () => { + expect(normalizer.normalizeSymbol("AAPL")).toBe("AAPL"); }); - it('should uppercase symbols', () => { - expect(normalizer.normalizeSymbol('us-aapl')).toBe('AAPL'); + it("should uppercase symbols", () => { + expect(normalizer.normalizeSymbol("us-aapl")).toBe("AAPL"); }); - it('should trim whitespace', () => { - expect(normalizer.normalizeSymbol(' US-AAPL ')).toBe('AAPL'); + it("should trim whitespace", () => { + expect(normalizer.normalizeSymbol(" US-AAPL ")).toBe("AAPL"); }); }); - describe('normalize', () => { - it('should produce correct normalized price', () => { - const rawPrice = createMockPrice({ symbol: 'US-GOOGL', price: 140.5678 }); + describe("normalize", () => { + it("should produce correct normalized price", () => { + const rawPrice = createMockPrice({ symbol: "US-GOOGL", price: 140.5678 }); const result = normalizer.normalize(rawPrice); - expect(result.symbol).toBe('GOOGL'); + expect(result.symbol).toBe("GOOGL"); expect(result.price).toBe(140.5678); expect(result.source).toBe(NormalizedSource.FINNHUB); - expect(result.metadata.originalSymbol).toBe('US-GOOGL'); + expect(result.metadata.originalSymbol).toBe("US-GOOGL"); }); }); - describe('normalizeMany', () => { - it('should normalize multiple prices', () => { + describe("normalizeMany", () => { + it("should normalize multiple prices", () => { const prices = [ - createMockPrice({ symbol: 'US-AAPL' }), - createMockPrice({ symbol: 'CRYPTO-ETH' }), + createMockPrice({ symbol: "US-AAPL" }), + createMockPrice({ symbol: "CRYPTO-ETH" }), ]; const results = normalizer.normalizeMany(prices); expect(results.length).toBe(2); - expect(results[0].symbol).toBe('AAPL'); - expect(results[1].symbol).toBe('ETH'); + expect(results[0].symbol).toBe("AAPL"); + expect(results[1].symbol).toBe("ETH"); }); }); }); diff --git a/apps/aggregator/src/normalizers/finnhub.normalizer.ts b/apps/aggregator/src/normalizers/finnhub.normalizer.ts index 5a982c3..505bc83 100644 --- a/apps/aggregator/src/normalizers/finnhub.normalizer.ts +++ b/apps/aggregator/src/normalizers/finnhub.normalizer.ts @@ -1,6 +1,6 @@ -import { RawPrice } from '@oracle-stocks/shared'; -import { NormalizedSource } from '../interfaces/normalized-price.interface'; -import { BaseNormalizer } from './base.normalizer'; +import type { RawPrice } from "@oracle-stocks/shared"; +import { NormalizedSource } from "../interfaces/normalized-price.interface"; +import { BaseNormalizer } from "./base.normalizer"; /** * Normalizer for Finnhub data source. @@ -9,11 +9,11 @@ import { BaseNormalizer } from './base.normalizer'; * - Exchange prefix format like "US-AAPL", "CRYPTO-BTC" */ export class FinnhubNormalizer extends BaseNormalizer { - readonly name = 'FinnhubNormalizer'; + readonly name = "FinnhubNormalizer"; readonly source = NormalizedSource.FINNHUB; - readonly version = '1.0.0'; + readonly version = "1.0.0"; - private readonly SOURCE_IDENTIFIERS = ['finnhub']; + private readonly SOURCE_IDENTIFIERS = ["finnhub"]; canNormalize(rawPrice: RawPrice): boolean { const sourceLower = rawPrice.source.toLowerCase(); @@ -26,7 +26,7 @@ export class FinnhubNormalizer extends BaseNormalizer { // Remove Finnhub exchange prefix format // e.g., "US-AAPL" -> "AAPL", "CRYPTO-BTC" -> "BTC" const prefixPattern = /^(US|CRYPTO|FX|INDICES)-/i; - normalized = normalized.replace(prefixPattern, ''); + normalized = normalized.replace(prefixPattern, ""); return normalized; } diff --git a/apps/aggregator/src/normalizers/index.ts b/apps/aggregator/src/normalizers/index.ts index 6fdc2db..dcb8c07 100644 --- a/apps/aggregator/src/normalizers/index.ts +++ b/apps/aggregator/src/normalizers/index.ts @@ -1,5 +1,5 @@ -export * from './base.normalizer'; -export * from './alpha-vantage.normalizer'; -export * from './finnhub.normalizer'; -export * from './yahoo-finance.normalizer'; -export * from './mock.normalizer'; +export * from "./base.normalizer"; +export * from "./alpha-vantage.normalizer"; +export * from "./finnhub.normalizer"; +export * from "./yahoo-finance.normalizer"; +export * from "./mock.normalizer"; diff --git a/apps/aggregator/src/normalizers/mock.normalizer.spec.ts b/apps/aggregator/src/normalizers/mock.normalizer.spec.ts index ebdd412..c551cf3 100644 --- a/apps/aggregator/src/normalizers/mock.normalizer.spec.ts +++ b/apps/aggregator/src/normalizers/mock.normalizer.spec.ts @@ -1,15 +1,15 @@ -import { MockNormalizer } from './mock.normalizer'; -import { NormalizedSource } from '../interfaces/normalized-price.interface'; -import { RawPrice } from '@oracle-stocks/shared'; +import { MockNormalizer } from "./mock.normalizer"; +import { NormalizedSource } from "../interfaces/normalized-price.interface"; +import type { RawPrice } from "@oracle-stocks/shared"; -describe('MockNormalizer', () => { +describe("MockNormalizer", () => { let normalizer: MockNormalizer; const createMockPrice = (overrides: Partial = {}): RawPrice => ({ - symbol: 'AAPL', + symbol: "AAPL", price: 150.0, timestamp: Date.now(), - source: 'MockProvider', + source: "MockProvider", ...overrides, }); @@ -17,72 +17,84 @@ describe('MockNormalizer', () => { normalizer = new MockNormalizer(); }); - describe('properties', () => { - it('should have correct name', () => { - expect(normalizer.name).toBe('MockNormalizer'); + describe("properties", () => { + it("should have correct name", () => { + expect(normalizer.name).toBe("MockNormalizer"); }); - it('should have correct source', () => { + it("should have correct source", () => { expect(normalizer.source).toBe(NormalizedSource.MOCK); }); - it('should have version', () => { - expect(normalizer.version).toBe('1.0.0'); + it("should have version", () => { + expect(normalizer.version).toBe("1.0.0"); }); }); - describe('canNormalize', () => { - it('should return true for MockProvider source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'MockProvider' }))).toBe(true); + describe("canNormalize", () => { + it("should return true for MockProvider source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "MockProvider" })), + ).toBe(true); }); - it('should return true for mock source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'mock' }))).toBe(true); + it("should return true for mock source", () => { + expect(normalizer.canNormalize(createMockPrice({ source: "mock" }))).toBe( + true, + ); }); - it('should return true for MOCK uppercase', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'MOCK' }))).toBe(true); + it("should return true for MOCK uppercase", () => { + expect(normalizer.canNormalize(createMockPrice({ source: "MOCK" }))).toBe( + true, + ); }); - it('should return true for MockData source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'MockData' }))).toBe(true); + it("should return true for MockData source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "MockData" })), + ).toBe(true); }); - it('should return false for AlphaVantage source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'AlphaVantage' }))).toBe(false); + it("should return false for AlphaVantage source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "AlphaVantage" })), + ).toBe(false); }); - it('should return false for Finnhub source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'Finnhub' }))).toBe(false); + it("should return false for Finnhub source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "Finnhub" })), + ).toBe(false); }); }); - describe('normalizeSymbol', () => { - it('should uppercase symbols', () => { - expect(normalizer.normalizeSymbol('aapl')).toBe('AAPL'); + describe("normalizeSymbol", () => { + it("should uppercase symbols", () => { + expect(normalizer.normalizeSymbol("aapl")).toBe("AAPL"); }); - it('should trim whitespace', () => { - expect(normalizer.normalizeSymbol(' AAPL ')).toBe('AAPL'); + it("should trim whitespace", () => { + expect(normalizer.normalizeSymbol(" AAPL ")).toBe("AAPL"); }); - it('should handle already clean symbols', () => { - expect(normalizer.normalizeSymbol('AAPL')).toBe('AAPL'); + it("should handle already clean symbols", () => { + expect(normalizer.normalizeSymbol("AAPL")).toBe("AAPL"); }); }); - describe('normalize', () => { - it('should produce correct normalized price', () => { - const rawPrice = createMockPrice({ symbol: 'tsla', price: 250.5 }); + describe("normalize", () => { + it("should produce correct normalized price", () => { + const rawPrice = createMockPrice({ symbol: "tsla", price: 250.5 }); const result = normalizer.normalize(rawPrice); - expect(result.symbol).toBe('TSLA'); + expect(result.symbol).toBe("TSLA"); expect(result.price).toBe(250.5); expect(result.source).toBe(NormalizedSource.MOCK); }); - it('should track transformation when symbol is uppercased', () => { - const rawPrice = createMockPrice({ symbol: 'aapl' }); + it("should track transformation when symbol is uppercased", () => { + const rawPrice = createMockPrice({ symbol: "aapl" }); const result = normalizer.normalize(rawPrice); expect(result.metadata.wasTransformed).toBe(true); @@ -90,11 +102,11 @@ describe('MockNormalizer', () => { }); }); - describe('normalizeMany', () => { - it('should normalize multiple prices', () => { + describe("normalizeMany", () => { + it("should normalize multiple prices", () => { const prices = [ - createMockPrice({ symbol: 'AAPL' }), - createMockPrice({ symbol: 'GOOGL' }), + createMockPrice({ symbol: "AAPL" }), + createMockPrice({ symbol: "GOOGL" }), ]; const results = normalizer.normalizeMany(prices); @@ -102,10 +114,10 @@ describe('MockNormalizer', () => { expect(results.length).toBe(2); }); - it('should skip prices from other sources', () => { + it("should skip prices from other sources", () => { const prices = [ - createMockPrice({ symbol: 'AAPL' }), - createMockPrice({ symbol: 'GOOGL', source: 'AlphaVantage' }), + createMockPrice({ symbol: "AAPL" }), + createMockPrice({ symbol: "GOOGL", source: "AlphaVantage" }), ]; const results = normalizer.normalizeMany(prices); diff --git a/apps/aggregator/src/normalizers/mock.normalizer.ts b/apps/aggregator/src/normalizers/mock.normalizer.ts index 52f1d97..b7539a8 100644 --- a/apps/aggregator/src/normalizers/mock.normalizer.ts +++ b/apps/aggregator/src/normalizers/mock.normalizer.ts @@ -1,6 +1,6 @@ -import { RawPrice } from '@oracle-stocks/shared'; -import { NormalizedSource } from '../interfaces/normalized-price.interface'; -import { BaseNormalizer } from './base.normalizer'; +import type { RawPrice } from "@oracle-stocks/shared"; +import { NormalizedSource } from "../interfaces/normalized-price.interface"; +import { BaseNormalizer } from "./base.normalizer"; /** * Normalizer for Mock data source (used for testing/development). @@ -8,12 +8,12 @@ import { BaseNormalizer } from './base.normalizer'; * Performs basic pass-through normalization with standard cleaning. */ export class MockNormalizer extends BaseNormalizer { - readonly name = 'MockNormalizer'; + readonly name = "MockNormalizer"; readonly source = NormalizedSource.MOCK; - readonly version = '1.0.0'; + readonly version = "1.0.0"; canNormalize(rawPrice: RawPrice): boolean { - return rawPrice.source.toLowerCase().includes('mock'); + return rawPrice.source.toLowerCase().includes("mock"); } normalizeSymbol(symbol: string): string { diff --git a/apps/aggregator/src/normalizers/yahoo-finance.normalizer.spec.ts b/apps/aggregator/src/normalizers/yahoo-finance.normalizer.spec.ts index 51d6a9a..baa5d4d 100644 --- a/apps/aggregator/src/normalizers/yahoo-finance.normalizer.spec.ts +++ b/apps/aggregator/src/normalizers/yahoo-finance.normalizer.spec.ts @@ -1,15 +1,15 @@ -import { YahooFinanceNormalizer } from './yahoo-finance.normalizer'; -import { NormalizedSource } from '../interfaces/normalized-price.interface'; -import { RawPrice } from '@oracle-stocks/shared'; +import { YahooFinanceNormalizer } from "./yahoo-finance.normalizer"; +import { NormalizedSource } from "../interfaces/normalized-price.interface"; +import type { RawPrice } from "@oracle-stocks/shared"; -describe('YahooFinanceNormalizer', () => { +describe("YahooFinanceNormalizer", () => { let normalizer: YahooFinanceNormalizer; const createMockPrice = (overrides: Partial = {}): RawPrice => ({ - symbol: 'AAPL', + symbol: "AAPL", price: 150.0, timestamp: Date.now(), - source: 'Yahoo Finance', + source: "Yahoo Finance", ...overrides, }); @@ -17,150 +17,164 @@ describe('YahooFinanceNormalizer', () => { normalizer = new YahooFinanceNormalizer(); }); - describe('properties', () => { - it('should have correct name', () => { - expect(normalizer.name).toBe('YahooFinanceNormalizer'); + describe("properties", () => { + it("should have correct name", () => { + expect(normalizer.name).toBe("YahooFinanceNormalizer"); }); - it('should have correct source', () => { + it("should have correct source", () => { expect(normalizer.source).toBe(NormalizedSource.YAHOO_FINANCE); }); - it('should have version', () => { - expect(normalizer.version).toBe('1.0.0'); + it("should have version", () => { + expect(normalizer.version).toBe("1.0.0"); }); }); - describe('canNormalize', () => { - it('should return true for Yahoo Finance source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'Yahoo Finance' }))).toBe(true); + describe("canNormalize", () => { + it("should return true for Yahoo Finance source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "Yahoo Finance" })), + ).toBe(true); }); - it('should return true for yahoo_finance source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'yahoo_finance' }))).toBe(true); + it("should return true for yahoo_finance source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "yahoo_finance" })), + ).toBe(true); }); - it('should return true for yahoo-finance source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'yahoo-finance' }))).toBe(true); + it("should return true for yahoo-finance source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "yahoo-finance" })), + ).toBe(true); }); - it('should return true for YahooFinance source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'YahooFinance' }))).toBe(true); + it("should return true for YahooFinance source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "YahooFinance" })), + ).toBe(true); }); - it('should return true for yahoo source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'yahoo' }))).toBe(true); + it("should return true for yahoo source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "yahoo" })), + ).toBe(true); }); - it('should return false for AlphaVantage source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'AlphaVantage' }))).toBe(false); + it("should return false for AlphaVantage source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "AlphaVantage" })), + ).toBe(false); }); - it('should return false for Finnhub source', () => { - expect(normalizer.canNormalize(createMockPrice({ source: 'Finnhub' }))).toBe(false); + it("should return false for Finnhub source", () => { + expect( + normalizer.canNormalize(createMockPrice({ source: "Finnhub" })), + ).toBe(false); }); }); - describe('normalizeSymbol', () => { - it('should remove .L suffix (London)', () => { - expect(normalizer.normalizeSymbol('BP.L')).toBe('BP'); + describe("normalizeSymbol", () => { + it("should remove .L suffix (London)", () => { + expect(normalizer.normalizeSymbol("BP.L")).toBe("BP"); }); - it('should remove .T suffix (Tokyo)', () => { - expect(normalizer.normalizeSymbol('7203.T')).toBe('7203'); + it("should remove .T suffix (Tokyo)", () => { + expect(normalizer.normalizeSymbol("7203.T")).toBe("7203"); }); - it('should remove .AX suffix (Australia)', () => { - expect(normalizer.normalizeSymbol('BHP.AX')).toBe('BHP'); + it("should remove .AX suffix (Australia)", () => { + expect(normalizer.normalizeSymbol("BHP.AX")).toBe("BHP"); }); - it('should remove .HK suffix (Hong Kong)', () => { - expect(normalizer.normalizeSymbol('0005.HK')).toBe('0005'); + it("should remove .HK suffix (Hong Kong)", () => { + expect(normalizer.normalizeSymbol("0005.HK")).toBe("0005"); }); - it('should remove .SI suffix (Singapore)', () => { - expect(normalizer.normalizeSymbol('D05.SI')).toBe('D05'); + it("should remove .SI suffix (Singapore)", () => { + expect(normalizer.normalizeSymbol("D05.SI")).toBe("D05"); }); - it('should remove .KS suffix (Korea)', () => { - expect(normalizer.normalizeSymbol('005930.KS')).toBe('005930'); + it("should remove .KS suffix (Korea)", () => { + expect(normalizer.normalizeSymbol("005930.KS")).toBe("005930"); }); - it('should remove .TW suffix (Taiwan)', () => { - expect(normalizer.normalizeSymbol('2330.TW')).toBe('2330'); + it("should remove .TW suffix (Taiwan)", () => { + expect(normalizer.normalizeSymbol("2330.TW")).toBe("2330"); }); - it('should remove .NS suffix (India NSE)', () => { - expect(normalizer.normalizeSymbol('RELIANCE.NS')).toBe('RELIANCE'); + it("should remove .NS suffix (India NSE)", () => { + expect(normalizer.normalizeSymbol("RELIANCE.NS")).toBe("RELIANCE"); }); - it('should remove .BO suffix (India BSE)', () => { - expect(normalizer.normalizeSymbol('RELIANCE.BO')).toBe('RELIANCE'); + it("should remove .BO suffix (India BSE)", () => { + expect(normalizer.normalizeSymbol("RELIANCE.BO")).toBe("RELIANCE"); }); - it('should remove .TO suffix (Toronto)', () => { - expect(normalizer.normalizeSymbol('RY.TO')).toBe('RY'); + it("should remove .TO suffix (Toronto)", () => { + expect(normalizer.normalizeSymbol("RY.TO")).toBe("RY"); }); - it('should remove .DE suffix (Germany)', () => { - expect(normalizer.normalizeSymbol('SAP.DE')).toBe('SAP'); + it("should remove .DE suffix (Germany)", () => { + expect(normalizer.normalizeSymbol("SAP.DE")).toBe("SAP"); }); - it('should remove .PA suffix (Paris)', () => { - expect(normalizer.normalizeSymbol('MC.PA')).toBe('MC'); + it("should remove .PA suffix (Paris)", () => { + expect(normalizer.normalizeSymbol("MC.PA")).toBe("MC"); }); - it('should remove ^ prefix for indices', () => { - expect(normalizer.normalizeSymbol('^DJI')).toBe('DJI'); + it("should remove ^ prefix for indices", () => { + expect(normalizer.normalizeSymbol("^DJI")).toBe("DJI"); }); - it('should remove ^ prefix for S&P 500', () => { - expect(normalizer.normalizeSymbol('^GSPC')).toBe('GSPC'); + it("should remove ^ prefix for S&P 500", () => { + expect(normalizer.normalizeSymbol("^GSPC")).toBe("GSPC"); }); - it('should remove ^ prefix for NASDAQ', () => { - expect(normalizer.normalizeSymbol('^IXIC')).toBe('IXIC'); + it("should remove ^ prefix for NASDAQ", () => { + expect(normalizer.normalizeSymbol("^IXIC")).toBe("IXIC"); }); - it('should handle already clean symbols', () => { - expect(normalizer.normalizeSymbol('AAPL')).toBe('AAPL'); + it("should handle already clean symbols", () => { + expect(normalizer.normalizeSymbol("AAPL")).toBe("AAPL"); }); - it('should uppercase symbols', () => { - expect(normalizer.normalizeSymbol('aapl.l')).toBe('AAPL'); + it("should uppercase symbols", () => { + expect(normalizer.normalizeSymbol("aapl.l")).toBe("AAPL"); }); - it('should trim whitespace', () => { - expect(normalizer.normalizeSymbol(' AAPL.L ')).toBe('AAPL'); + it("should trim whitespace", () => { + expect(normalizer.normalizeSymbol(" AAPL.L ")).toBe("AAPL"); }); }); - describe('normalize', () => { - it('should produce correct normalized price', () => { - const rawPrice = createMockPrice({ symbol: '^DJI', price: 37500.5 }); + describe("normalize", () => { + it("should produce correct normalized price", () => { + const rawPrice = createMockPrice({ symbol: "^DJI", price: 37500.5 }); const result = normalizer.normalize(rawPrice); - expect(result.symbol).toBe('DJI'); + expect(result.symbol).toBe("DJI"); expect(result.price).toBe(37500.5); expect(result.source).toBe(NormalizedSource.YAHOO_FINANCE); - expect(result.metadata.originalSymbol).toBe('^DJI'); + expect(result.metadata.originalSymbol).toBe("^DJI"); }); }); - describe('normalizeMany', () => { - it('should normalize multiple prices', () => { + describe("normalizeMany", () => { + it("should normalize multiple prices", () => { const prices = [ - createMockPrice({ symbol: 'AAPL.L' }), - createMockPrice({ symbol: '^GSPC' }), - createMockPrice({ symbol: 'BHP.AX' }), + createMockPrice({ symbol: "AAPL.L" }), + createMockPrice({ symbol: "^GSPC" }), + createMockPrice({ symbol: "BHP.AX" }), ]; const results = normalizer.normalizeMany(prices); expect(results.length).toBe(3); - expect(results[0].symbol).toBe('AAPL'); - expect(results[1].symbol).toBe('GSPC'); - expect(results[2].symbol).toBe('BHP'); + expect(results[0].symbol).toBe("AAPL"); + expect(results[1].symbol).toBe("GSPC"); + expect(results[2].symbol).toBe("BHP"); }); }); }); diff --git a/apps/aggregator/src/normalizers/yahoo-finance.normalizer.ts b/apps/aggregator/src/normalizers/yahoo-finance.normalizer.ts index 25cd7c5..5c20086 100644 --- a/apps/aggregator/src/normalizers/yahoo-finance.normalizer.ts +++ b/apps/aggregator/src/normalizers/yahoo-finance.normalizer.ts @@ -1,6 +1,6 @@ -import { RawPrice } from '@oracle-stocks/shared'; -import { NormalizedSource } from '../interfaces/normalized-price.interface'; -import { BaseNormalizer } from './base.normalizer'; +import type { RawPrice } from "@oracle-stocks/shared"; +import { NormalizedSource } from "../interfaces/normalized-price.interface"; +import { BaseNormalizer } from "./base.normalizer"; /** * Normalizer for Yahoo Finance data source. @@ -10,21 +10,21 @@ import { BaseNormalizer } from './base.normalizer'; * - Index prefix "^" (e.g., "^DJI", "^GSPC") */ export class YahooFinanceNormalizer extends BaseNormalizer { - readonly name = 'YahooFinanceNormalizer'; + readonly name = "YahooFinanceNormalizer"; readonly source = NormalizedSource.YAHOO_FINANCE; - readonly version = '1.0.0'; + readonly version = "1.0.0"; private readonly SOURCE_IDENTIFIERS = [ - 'yahoo', - 'yahoofinance', - 'yahoo_finance', - 'yahoo-finance', + "yahoo", + "yahoofinance", + "yahoo_finance", + "yahoo-finance", ]; canNormalize(rawPrice: RawPrice): boolean { - const sourceLower = rawPrice.source.toLowerCase().replace(/[\s_-]/g, ''); + const sourceLower = rawPrice.source.toLowerCase().replace(/[\s_-]/g, ""); return this.SOURCE_IDENTIFIERS.some((id) => - sourceLower.includes(id.replace(/[\s_-]/g, '')), + sourceLower.includes(id.replace(/[\s_-]/g, "")), ); } @@ -35,10 +35,10 @@ export class YahooFinanceNormalizer extends BaseNormalizer { // e.g., "AAPL.L" -> "AAPL", "BHP.AX" -> "BHP", "7203.T" -> "7203" const suffixPattern = /\.(L|T|AX|HK|SI|KS|TW|NS|BO|TO|V|F|DE|PA|AS|BR|MC|MI|SW|CO|MX|SA|JK|KL)$/i; - normalized = normalized.replace(suffixPattern, ''); + normalized = normalized.replace(suffixPattern, ""); // Remove index prefix (^DJI -> DJI, ^GSPC -> GSPC) - if (normalized.startsWith('^')) { + if (normalized.startsWith("^")) { normalized = normalized.substring(1); } diff --git a/apps/aggregator/src/services/aggregation.service.spec.ts b/apps/aggregator/src/services/aggregation.service.spec.ts index 67029e7..014bb8e 100644 --- a/apps/aggregator/src/services/aggregation.service.spec.ts +++ b/apps/aggregator/src/services/aggregation.service.spec.ts @@ -1,8 +1,8 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AggregationService } from './aggregation.service'; -import { NormalizedPrice } from '../interfaces/normalized-price.interface'; +import { Test, TestingModule } from "@nestjs/testing"; +import { AggregationService } from "./aggregation.service"; +import { NormalizedPrice } from "../interfaces/normalized-price.interface"; -describe('AggregationService', () => { +describe("AggregationService", () => { let service: AggregationService; beforeEach(async () => { @@ -13,13 +13,22 @@ describe('AggregationService', () => { service = module.get(AggregationService); }); - it('should be defined', () => { + it("should be defined", () => { expect(service).toBeDefined(); }); - describe('aggregate', () => { - const createMockPrices = (symbol: string, basePrices: number[]): NormalizedPrice[] => { - const sources = ['AlphaVantage', 'YahooFinance', 'Finnhub', 'Bloomberg', 'Reuters']; + describe("aggregate", () => { + const createMockPrices = ( + symbol: string, + basePrices: number[], + ): NormalizedPrice[] => { + const sources = [ + "AlphaVantage", + "YahooFinance", + "Finnhub", + "Bloomberg", + "Reuters", + ]; return basePrices.map((price, index) => ({ symbol, price, @@ -28,27 +37,29 @@ describe('AggregationService', () => { })); }; - describe('weighted-average method', () => { - it('should calculate weighted average correctly with equal weights', () => { - const prices = createMockPrices('AAPL', [100, 102, 98]); - const result = service.aggregate('AAPL', prices, { method: 'weighted-average' }); + describe("weighted-average method", () => { + it("should calculate weighted average correctly with equal weights", () => { + const prices = createMockPrices("AAPL", [100, 102, 98]); + const result = service.aggregate("AAPL", prices, { + method: "weighted-average", + }); - expect(result.symbol).toBe('AAPL'); - expect(result.method).toBe('weighted-average'); + expect(result.symbol).toBe("AAPL"); + expect(result.method).toBe("weighted-average"); expect(result.price).toBeCloseTo(100, 1); expect(result.confidence).toBeGreaterThan(0); expect(result.metrics.sourceCount).toBe(3); }); - it('should apply source weights correctly', () => { - const prices = createMockPrices('GOOGL', [100, 110]); + it("should apply source weights correctly", () => { + const prices = createMockPrices("GOOGL", [100, 110]); const weights = new Map([ - ['AlphaVantage', 3], // 100 with weight 3 - ['YahooFinance', 1], // 110 with weight 1 + ["AlphaVantage", 3], // 100 with weight 3 + ["YahooFinance", 1], // 110 with weight 1 ]); - const result = service.aggregate('GOOGL', prices, { - method: 'weighted-average', + const result = service.aggregate("GOOGL", prices, { + method: "weighted-average", customWeights: weights, minSources: 2, }); @@ -57,10 +68,10 @@ describe('AggregationService', () => { expect(result.price).toBeCloseTo(102.5, 1); }); - it('should handle single source with sufficient min sources set to 1', () => { - const prices = createMockPrices('TSLA', [250]); - const result = service.aggregate('TSLA', prices, { - method: 'weighted-average', + it("should handle single source with sufficient min sources set to 1", () => { + const prices = createMockPrices("TSLA", [250]); + const result = service.aggregate("TSLA", prices, { + method: "weighted-average", minSources: 1, }); @@ -68,20 +79,20 @@ describe('AggregationService', () => { expect(result.metrics.sourceCount).toBe(1); }); - it('should calculate high confidence for closely agreeing prices', () => { - const prices = createMockPrices('MSFT', [100, 100.5, 99.5, 100, 100.2]); - const result = service.aggregate('MSFT', prices, { - method: 'weighted-average', + it("should calculate high confidence for closely agreeing prices", () => { + const prices = createMockPrices("MSFT", [100, 100.5, 99.5, 100, 100.2]); + const result = service.aggregate("MSFT", prices, { + method: "weighted-average", minSources: 3, }); expect(result.confidence).toBeGreaterThan(80); }); - it('should calculate low confidence for divergent prices', () => { - const prices = createMockPrices('AAPL', [100, 150, 80, 120, 90]); - const result = service.aggregate('AAPL', prices, { - method: 'weighted-average', + it("should calculate low confidence for divergent prices", () => { + const prices = createMockPrices("AAPL", [100, 150, 80, 120, 90]); + const result = service.aggregate("AAPL", prices, { + method: "weighted-average", minSources: 3, }); @@ -89,46 +100,46 @@ describe('AggregationService', () => { }); }); - describe('median method', () => { - it('should calculate median correctly with odd number of prices', () => { - const prices = createMockPrices('AAPL', [98, 100, 102]); - const result = service.aggregate('AAPL', prices, { method: 'median' }); + describe("median method", () => { + it("should calculate median correctly with odd number of prices", () => { + const prices = createMockPrices("AAPL", [98, 100, 102]); + const result = service.aggregate("AAPL", prices, { method: "median" }); expect(result.price).toBe(100); - expect(result.method).toBe('median'); + expect(result.method).toBe("median"); }); - it('should calculate median correctly with even number of prices', () => { - const prices = createMockPrices('GOOGL', [98, 100, 102, 104]); - const result = service.aggregate('GOOGL', prices, { method: 'median' }); + it("should calculate median correctly with even number of prices", () => { + const prices = createMockPrices("GOOGL", [98, 100, 102, 104]); + const result = service.aggregate("GOOGL", prices, { method: "median" }); // Median of [98, 100, 102, 104] = (100 + 102) / 2 = 101 expect(result.price).toBe(101); }); - it('should be resistant to outliers', () => { - const prices = createMockPrices('TSLA', [100, 101, 99, 1000]); // 1000 is outlier - const result = service.aggregate('TSLA', prices, { method: 'median' }); + it("should be resistant to outliers", () => { + const prices = createMockPrices("TSLA", [100, 101, 99, 1000]); // 1000 is outlier + const result = service.aggregate("TSLA", prices, { method: "median" }); // Median should be close to 100, not affected by outlier expect(result.price).toBeCloseTo(100.5, 1); }); }); - describe('trimmed-mean method', () => { - it('should calculate trimmed mean correctly', () => { - const prices = createMockPrices('AAPL', [90, 95, 100, 105, 110]); - + describe("trimmed-mean method", () => { + it("should calculate trimmed mean correctly", () => { + const prices = createMockPrices("AAPL", [90, 95, 100, 105, 110]); + // Disable weights for predictable results - const result = service.aggregate('AAPL', prices, { - method: 'trimmed-mean', + const result = service.aggregate("AAPL", prices, { + method: "trimmed-mean", trimPercentage: 0.2, customWeights: new Map([ - ['AlphaVantage', 1], - ['YahooFinance', 1], - ['Finnhub', 1], - ['Bloomberg', 1], - ['Reuters', 1], + ["AlphaVantage", 1], + ["YahooFinance", 1], + ["Finnhub", 1], + ["Bloomberg", 1], + ["Reuters", 1], ]), }); @@ -137,15 +148,15 @@ describe('AggregationService', () => { expect(result.price).toBeCloseTo(100, 1); }); - it('should handle small datasets by falling back to average', () => { - const prices = createMockPrices('MSFT', [100, 102]); - - const result = service.aggregate('MSFT', prices, { - method: 'trimmed-mean', + it("should handle small datasets by falling back to average", () => { + const prices = createMockPrices("MSFT", [100, 102]); + + const result = service.aggregate("MSFT", prices, { + method: "trimmed-mean", minSources: 2, customWeights: new Map([ - ['AlphaVantage', 1], - ['YahooFinance', 1], + ["AlphaVantage", 1], + ["YahooFinance", 1], ]), }); @@ -153,18 +164,18 @@ describe('AggregationService', () => { expect(result.price).toBeCloseTo(101, 1); }); - it('should remove outliers effectively', () => { - const prices = createMockPrices('GOOGL', [50, 98, 100, 102, 150]); - - const result = service.aggregate('GOOGL', prices, { - method: 'trimmed-mean', + it("should remove outliers effectively", () => { + const prices = createMockPrices("GOOGL", [50, 98, 100, 102, 150]); + + const result = service.aggregate("GOOGL", prices, { + method: "trimmed-mean", trimPercentage: 0.2, customWeights: new Map([ - ['AlphaVantage', 1], - ['YahooFinance', 1], - ['Finnhub', 1], - ['Bloomberg', 1], - ['Reuters', 1], + ["AlphaVantage", 1], + ["YahooFinance", 1], + ["Finnhub", 1], + ["Bloomberg", 1], + ["Reuters", 1], ]), }); @@ -174,17 +185,37 @@ describe('AggregationService', () => { }); }); - describe('time window filtering', () => { - it('should filter out old prices outside time window', () => { + describe("time window filtering", () => { + it("should filter out old prices outside time window", () => { const now = Date.now(); const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: now - 1000, source: 'Source1' }, - { symbol: 'AAPL', price: 101, timestamp: now - 2000, source: 'Source2' }, - { symbol: 'AAPL', price: 102, timestamp: now - 50000, source: 'Source3' }, // Too old - { symbol: 'AAPL', price: 103, timestamp: now - 60000, source: 'Source4' }, // Too old + { + symbol: "AAPL", + price: 100, + timestamp: now - 1000, + source: "Source1", + }, + { + symbol: "AAPL", + price: 101, + timestamp: now - 2000, + source: "Source2", + }, + { + symbol: "AAPL", + price: 102, + timestamp: now - 50000, + source: "Source3", + }, // Too old + { + symbol: "AAPL", + price: 103, + timestamp: now - 60000, + source: "Source4", + }, // Too old ]; - const result = service.aggregate('AAPL', prices, { + const result = service.aggregate("AAPL", prices, { timeWindowMs: 30000, // 30 seconds minSources: 2, }); @@ -194,15 +225,25 @@ describe('AggregationService', () => { expect(result.price).toBeCloseTo(100.5, 1); }); - it('should throw error if insufficient recent sources', () => { + it("should throw error if insufficient recent sources", () => { const now = Date.now(); const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: now - 50000, source: 'Source1' }, // Too old - { symbol: 'AAPL', price: 101, timestamp: now - 60000, source: 'Source2' }, // Too old + { + symbol: "AAPL", + price: 100, + timestamp: now - 50000, + source: "Source1", + }, // Too old + { + symbol: "AAPL", + price: 101, + timestamp: now - 60000, + source: "Source2", + }, // Too old ]; expect(() => { - service.aggregate('AAPL', prices, { + service.aggregate("AAPL", prices, { timeWindowMs: 30000, minSources: 2, }); @@ -210,119 +251,149 @@ describe('AggregationService', () => { }); }); - describe('validation', () => { - it('should throw error for empty symbol', () => { - const prices = createMockPrices('AAPL', [100, 101, 102]); + describe("validation", () => { + it("should throw error for empty symbol", () => { + const prices = createMockPrices("AAPL", [100, 101, 102]); expect(() => { - service.aggregate('', prices); + service.aggregate("", prices); }).toThrow(/Symbol cannot be empty/); }); - it('should throw error for empty prices array', () => { + it("should throw error for empty prices array", () => { expect(() => { - service.aggregate('AAPL', []); + service.aggregate("AAPL", []); }).toThrow(/Prices array cannot be empty/); }); - it('should throw error if below minimum sources', () => { - const prices = createMockPrices('AAPL', [100, 101]); + it("should throw error if below minimum sources", () => { + const prices = createMockPrices("AAPL", [100, 101]); expect(() => { - service.aggregate('AAPL', prices, { minSources: 3 }); + service.aggregate("AAPL", prices, { minSources: 3 }); }).toThrow(/Insufficient sources/); }); - it('should throw error for mismatched symbols', () => { + it("should throw error for mismatched symbols", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'GOOGL', price: 101, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 102, timestamp: Date.now(), source: 'Source3' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "GOOGL", + price: 101, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 102, + timestamp: Date.now(), + source: "Source3", + }, ]; expect(() => { - service.aggregate('AAPL', prices); + service.aggregate("AAPL", prices); }).toThrow(/All prices must be for symbol AAPL/); }); - it('should throw error for invalid price values', () => { + it("should throw error for invalid price values", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: -50, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 102, timestamp: Date.now(), source: 'Source3' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: -50, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 102, + timestamp: Date.now(), + source: "Source3", + }, ]; expect(() => { - service.aggregate('AAPL', prices); + service.aggregate("AAPL", prices); }).toThrow(/invalid price values/); }); - it('should throw error for unknown aggregation method', () => { - const prices = createMockPrices('AAPL', [100, 101, 102]); + it("should throw error for unknown aggregation method", () => { + const prices = createMockPrices("AAPL", [100, 101, 102]); expect(() => { - service.aggregate('AAPL', prices, { method: 'unknown' as any }); + service.aggregate("AAPL", prices, { method: "unknown" as any }); }).toThrow(/Unknown aggregation method/); }); }); - describe('metrics calculation', () => { - it('should calculate standard deviation correctly', () => { - const prices = createMockPrices('AAPL', [100, 100, 100]); - const result = service.aggregate('AAPL', prices); + describe("metrics calculation", () => { + it("should calculate standard deviation correctly", () => { + const prices = createMockPrices("AAPL", [100, 100, 100]); + const result = service.aggregate("AAPL", prices); // All same prices = 0 standard deviation expect(result.metrics.standardDeviation).toBe(0); }); - it('should calculate spread correctly', () => { - const prices = createMockPrices('AAPL', [90, 100, 110]); - const result = service.aggregate('AAPL', prices); + it("should calculate spread correctly", () => { + const prices = createMockPrices("AAPL", [90, 100, 110]); + const result = service.aggregate("AAPL", prices); // Spread = (110 - 90) / 100 * 100 = 20% expect(result.metrics.spread).toBeCloseTo(20, 0); }); - it('should include source count', () => { - const prices = createMockPrices('AAPL', [100, 101, 102, 103, 104]); - const result = service.aggregate('AAPL', prices); + it("should include source count", () => { + const prices = createMockPrices("AAPL", [100, 101, 102, 103, 104]); + const result = service.aggregate("AAPL", prices); expect(result.metrics.sourceCount).toBe(5); }); - it('should calculate variance', () => { - const prices = createMockPrices('AAPL', [100, 100, 100]); - const result = service.aggregate('AAPL', prices); + it("should calculate variance", () => { + const prices = createMockPrices("AAPL", [100, 100, 100]); + const result = service.aggregate("AAPL", prices); expect(result.metrics.variance).toBe(0); }); }); - describe('result structure', () => { - it('should include all required fields', () => { - const prices = createMockPrices('AAPL', [100, 101, 102]); - const result = service.aggregate('AAPL', prices); - - expect(result).toHaveProperty('symbol'); - expect(result).toHaveProperty('price'); - expect(result).toHaveProperty('method'); - expect(result).toHaveProperty('confidence'); - expect(result).toHaveProperty('metrics'); - expect(result).toHaveProperty('startTimestamp'); - expect(result).toHaveProperty('endTimestamp'); - expect(result).toHaveProperty('sources'); - expect(result).toHaveProperty('computedAt'); + describe("result structure", () => { + it("should include all required fields", () => { + const prices = createMockPrices("AAPL", [100, 101, 102]); + const result = service.aggregate("AAPL", prices); + + expect(result).toHaveProperty("symbol"); + expect(result).toHaveProperty("price"); + expect(result).toHaveProperty("method"); + expect(result).toHaveProperty("confidence"); + expect(result).toHaveProperty("metrics"); + expect(result).toHaveProperty("startTimestamp"); + expect(result).toHaveProperty("endTimestamp"); + expect(result).toHaveProperty("sources"); + expect(result).toHaveProperty("computedAt"); }); - it('should include unique sources list', () => { - const prices = createMockPrices('AAPL', [100, 101, 102]); - const result = service.aggregate('AAPL', prices); + it("should include unique sources list", () => { + const prices = createMockPrices("AAPL", [100, 101, 102]); + const result = service.aggregate("AAPL", prices); expect(result.sources).toBeInstanceOf(Array); expect(result.sources.length).toBeGreaterThan(0); expect(new Set(result.sources).size).toBe(result.sources.length); }); - it('should have timestamps in correct order', () => { - const prices = createMockPrices('AAPL', [100, 101, 102]); - const result = service.aggregate('AAPL', prices); + it("should have timestamps in correct order", () => { + const prices = createMockPrices("AAPL", [100, 101, 102]); + const result = service.aggregate("AAPL", prices); expect(result.startTimestamp).toBeLessThanOrEqual(result.endTimestamp); expect(result.computedAt).toBeGreaterThanOrEqual(result.endTimestamp); @@ -330,23 +401,53 @@ describe('AggregationService', () => { }); }); - describe('aggregateMultiple', () => { - it('should aggregate multiple symbols successfully', () => { + describe("aggregateMultiple", () => { + it("should aggregate multiple symbols successfully", () => { const pricesBySymbol = new Map([ [ - 'AAPL', + "AAPL", [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 101, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 102, timestamp: Date.now(), source: 'Source3' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 101, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 102, + timestamp: Date.now(), + source: "Source3", + }, ], ], [ - 'GOOGL', + "GOOGL", [ - { symbol: 'GOOGL', price: 200, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'GOOGL', price: 201, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'GOOGL', price: 202, timestamp: Date.now(), source: 'Source3' }, + { + symbol: "GOOGL", + price: 200, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "GOOGL", + price: 201, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "GOOGL", + price: 202, + timestamp: Date.now(), + source: "Source3", + }, ], ], ]); @@ -354,29 +455,59 @@ describe('AggregationService', () => { const results = service.aggregateMultiple(pricesBySymbol); expect(results.size).toBe(2); - expect(results.get('AAPL')).toBeDefined(); - expect(results.get('GOOGL')).toBeDefined(); - expect(results.get('AAPL')?.price).toBeCloseTo(101, 1); - expect(results.get('GOOGL')?.price).toBeCloseTo(201, 1); + expect(results.get("AAPL")).toBeDefined(); + expect(results.get("GOOGL")).toBeDefined(); + expect(results.get("AAPL")?.price).toBeCloseTo(101, 1); + expect(results.get("GOOGL")?.price).toBeCloseTo(201, 1); }); - it('should skip symbols with errors and continue', () => { + it("should skip symbols with errors and continue", () => { const pricesBySymbol = new Map([ [ - 'AAPL', + "AAPL", [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 101, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 102, timestamp: Date.now(), source: 'Source3' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 101, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 102, + timestamp: Date.now(), + source: "Source3", + }, ], ], - ['GOOGL', []], // Empty array - should fail + ["GOOGL", []], // Empty array - should fail [ - 'MSFT', + "MSFT", [ - { symbol: 'MSFT', price: 300, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'MSFT', price: 301, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'MSFT', price: 302, timestamp: Date.now(), source: 'Source3' }, + { + symbol: "MSFT", + price: 300, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "MSFT", + price: 301, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "MSFT", + price: 302, + timestamp: Date.now(), + source: "Source3", + }, ], ], ]); @@ -385,61 +516,103 @@ describe('AggregationService', () => { // Should have 2 results, GOOGL should be skipped expect(results.size).toBe(2); - expect(results.get('AAPL')).toBeDefined(); - expect(results.get('GOOGL')).toBeUndefined(); - expect(results.get('MSFT')).toBeDefined(); + expect(results.get("AAPL")).toBeDefined(); + expect(results.get("GOOGL")).toBeUndefined(); + expect(results.get("MSFT")).toBeDefined(); }); }); - describe('confidence scoring', () => { - it('should give higher confidence with more sources', () => { + describe("confidence scoring", () => { + it("should give higher confidence with more sources", () => { const prices3 = Array.from({ length: 3 }, (_, i) => ({ - symbol: 'AAPL', + symbol: "AAPL", price: 100, timestamp: Date.now(), source: `Source${i + 1}`, })); const prices10 = Array.from({ length: 10 }, (_, i) => ({ - symbol: 'AAPL', + symbol: "AAPL", price: 100, timestamp: Date.now(), source: `Source${i + 1}`, })); - const result3 = service.aggregate('AAPL', prices3); - const result10 = service.aggregate('AAPL', prices10, { minSources: 5 }); + const result3 = service.aggregate("AAPL", prices3); + const result10 = service.aggregate("AAPL", prices10, { minSources: 5 }); expect(result10.confidence).toBeGreaterThan(result3.confidence); }); - it('should give higher confidence with lower spread', () => { + it("should give higher confidence with lower spread", () => { const lowSpreadPrices = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 100.5, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 99.5, timestamp: Date.now(), source: 'Source3' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 100.5, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 99.5, + timestamp: Date.now(), + source: "Source3", + }, ]; const highSpreadPrices = [ - { symbol: 'AAPL', price: 80, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 120, timestamp: Date.now(), source: 'Source3' }, + { symbol: "AAPL", price: 80, timestamp: Date.now(), source: "Source1" }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 120, + timestamp: Date.now(), + source: "Source3", + }, ]; - const lowSpreadResult = service.aggregate('AAPL', lowSpreadPrices); - const highSpreadResult = service.aggregate('AAPL', highSpreadPrices); + const lowSpreadResult = service.aggregate("AAPL", lowSpreadPrices); + const highSpreadResult = service.aggregate("AAPL", highSpreadPrices); - expect(lowSpreadResult.confidence).toBeGreaterThan(highSpreadResult.confidence); + expect(lowSpreadResult.confidence).toBeGreaterThan( + highSpreadResult.confidence, + ); }); - it('should clamp confidence between 0 and 100', () => { + it("should clamp confidence between 0 and 100", () => { const prices = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source3' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source3", + }, ]; - const result = service.aggregate('AAPL', prices); + const result = service.aggregate("AAPL", prices); expect(result.confidence).toBeGreaterThanOrEqual(0); expect(result.confidence).toBeLessThanOrEqual(100); diff --git a/apps/aggregator/src/services/aggregation.service.ts b/apps/aggregator/src/services/aggregation.service.ts index ae419db..1ca0d0f 100644 --- a/apps/aggregator/src/services/aggregation.service.ts +++ b/apps/aggregator/src/services/aggregation.service.ts @@ -1,13 +1,14 @@ -import { Injectable, Logger, Optional } from '@nestjs/common'; -import { NormalizedPrice } from '../interfaces/normalized-price.interface'; -import { AggregatedPrice } from '../interfaces/aggregated-price.interface'; -import { IAggregator } from '../interfaces/aggregator.interface'; -import { WeightedAverageAggregator } from '../strategies/aggregators/weighted-average.aggregator'; -import { MedianAggregator } from '../strategies/aggregators/median.aggregator'; -import { TrimmedMeanAggregator } from '../strategies/aggregators/trimmed-mean.aggregator'; -import { getSourceWeight } from '../config/source-weights.config'; -import { MetricsService } from '../metrics/metrics.service'; -import { DebugService } from '../debug/debug.service'; +import { Injectable, Logger, Optional } from "@nestjs/common"; +import { NormalizedPrice } from "../interfaces/normalized-price.interface"; +import { AggregatedPrice } from "../interfaces/aggregated-price.interface"; +import { IAggregator } from "../interfaces/aggregator.interface"; +import { WeightedAverageAggregator } from "../strategies/aggregators/weighted-average.aggregator"; +import { MedianAggregator } from "../strategies/aggregators/median.aggregator"; +import { TrimmedMeanAggregator } from "../strategies/aggregators/trimmed-mean.aggregator"; +import { getSourceWeight } from "../config/source-weights.config"; +import { MetricsService } from "../metrics/metrics.service"; +import { DebugService } from "../debug/debug.service"; +import { OutlierDetectionService } from "./outlier-detection.service"; /** * Configuration options for the aggregation service @@ -15,23 +16,26 @@ import { DebugService } from '../debug/debug.service'; export interface AggregationOptions { /** Minimum number of sources required (default: 3) */ minSources?: number; - + /** Time window in milliseconds (default: 30000) */ timeWindowMs?: number; - + /** Aggregation method to use */ - method?: 'weighted-average' | 'median' | 'trimmed-mean'; - + method?: "weighted-average" | "median" | "trimmed-mean"; + /** Custom weights per source (overrides config) */ customWeights?: Map; - + /** Trim percentage for trimmed-mean (default: 0.2) */ trimPercentage?: number; + + /** Enable outlier filtering before aggregation (default: false) */ + applyOutlierFiltering?: boolean; } /** * Aggregation Service - * + * * Core service that calculates consensus prices from multiple sources. * Supports multiple aggregation strategies and provides confidence metrics. */ @@ -43,17 +47,19 @@ export class AggregationService { constructor( @Optional() private readonly metricsService?: MetricsService, @Optional() private readonly debugService?: DebugService, + @Optional() + private readonly outlierDetectionService?: OutlierDetectionService, ) { // Initialize all aggregation strategies this.aggregators = new Map(); - this.aggregators.set('weighted-average', new WeightedAverageAggregator()); - this.aggregators.set('median', new MedianAggregator()); - this.aggregators.set('trimmed-mean', new TrimmedMeanAggregator(0.2)); + this.aggregators.set("weighted-average", new WeightedAverageAggregator()); + this.aggregators.set("median", new MedianAggregator()); + this.aggregators.set("trimmed-mean", new TrimmedMeanAggregator(0.2)); } /** * Aggregate prices for a specific symbol - * + * * @param symbol Trading symbol (e.g., AAPL, GOOGL) * @param prices Array of normalized prices from different sources * @param options Configuration options for aggregation @@ -66,13 +72,14 @@ export class AggregationService { options: AggregationOptions = {}, ): AggregatedPrice { const startTime = Date.now(); - const method: 'weighted-average' | 'median' | 'trimmed-mean' = - options.method ?? 'weighted-average'; + const method: "weighted-average" | "median" | "trimmed-mean" = + options.method ?? "weighted-average"; const { minSources = 3, timeWindowMs = 30000, customWeights, trimPercentage = 0.2, + applyOutlierFiltering = false, } = options; try { @@ -82,7 +89,7 @@ export class AggregationService { // Filter prices within time window const now = Date.now(); const windowStart = now - timeWindowMs; - const recentPrices = prices.filter(p => p.timestamp >= windowStart); + const recentPrices = prices.filter((p) => p.timestamp >= windowStart); // Check minimum sources after filtering if (recentPrices.length < minSources) { @@ -91,11 +98,24 @@ export class AggregationService { ); } + const aggregationInput = + applyOutlierFiltering && this.outlierDetectionService + ? this.outlierDetectionService.getCleanPrices( + this.outlierDetectionService.detect(symbol, recentPrices), + ) + : recentPrices; + + if (aggregationInput.length < minSources) { + throw new Error( + `Insufficient non-outlier sources for ${symbol}. Required: ${minSources}, Found: ${aggregationInput.length}`, + ); + } + // Get aggregator strategy let aggregator = this.aggregators.get(method); // Special handling for trimmed-mean with custom percentage - if (method === 'trimmed-mean' && trimPercentage !== 0.2) { + if (method === "trimmed-mean" && trimPercentage !== 0.2) { aggregator = new TrimmedMeanAggregator(trimPercentage); } @@ -104,24 +124,27 @@ export class AggregationService { } // Prepare weights - const weights = this.prepareWeights(recentPrices, customWeights); + const weights = this.prepareWeights(aggregationInput, customWeights); // Calculate consensus price - const consensusPrice = aggregator.aggregate(recentPrices, weights); + const consensusPrice = aggregator.aggregate(aggregationInput, weights); // Calculate confidence metrics - const metrics = this.calculateMetrics(recentPrices); + const metrics = this.calculateMetrics(aggregationInput); // Calculate confidence score (0-100) - const confidence = this.calculateConfidence(metrics, recentPrices.length); + const confidence = this.calculateConfidence( + metrics, + aggregationInput.length, + ); // Get time range - const timestamps = recentPrices.map(p => p.timestamp); + const timestamps = aggregationInput.map((p) => p.timestamp); const startTimestamp = Math.min(...timestamps); const endTimestamp = Math.max(...timestamps); // Get unique sources - const sources = [...new Set(recentPrices.map(p => p.source))]; + const sources = [...new Set(aggregationInput.map((p) => p.source))]; const result: AggregatedPrice = { symbol, @@ -130,7 +153,7 @@ export class AggregationService { confidence, metrics: { ...metrics, - sourceCount: recentPrices.length, + sourceCount: aggregationInput.length, }, startTimestamp, endTimestamp, @@ -143,7 +166,7 @@ export class AggregationService { `(method: ${method}, confidence: ${confidence.toFixed(1)}%, sources: ${sources.length})`, ); - this.debugService?.setLastNormalized(symbol, recentPrices); + this.debugService?.setLastNormalized(symbol, aggregationInput); this.debugService?.setLastAggregated(symbol, result); this.metricsService?.recordAggregation( method, @@ -159,7 +182,7 @@ export class AggregationService { /** * Aggregate prices for multiple symbols - * + * * @param pricesBySymbol Map of symbol to array of normalized prices * @param options Configuration options * @returns Map of symbol to aggregated price @@ -175,9 +198,7 @@ export class AggregationService { const aggregated = this.aggregate(symbol, prices, options); results.set(symbol, aggregated); } catch (error) { - this.logger.error( - `Failed to aggregate ${symbol}: ${error.message}`, - ); + this.logger.error(`Failed to aggregate ${symbol}: ${error.message}`); } } @@ -192,16 +213,16 @@ export class AggregationService { prices: NormalizedPrice[], minSources: number, ): void { - if (!symbol || symbol.trim() === '') { - throw new Error('Symbol cannot be empty'); + if (!symbol || symbol.trim() === "") { + throw new Error("Symbol cannot be empty"); } if (!prices || prices.length === 0) { - throw new Error('Prices array cannot be empty'); + throw new Error("Prices array cannot be empty"); } if (minSources < 1) { - throw new Error('Minimum sources must be at least 1'); + throw new Error("Minimum sources must be at least 1"); } if (prices.length < minSources) { @@ -211,7 +232,7 @@ export class AggregationService { } // Validate all prices are for the same symbol - const invalidPrices = prices.filter(p => p.symbol !== symbol); + const invalidPrices = prices.filter((p) => p.symbol !== symbol); if (invalidPrices.length > 0) { throw new Error( `All prices must be for symbol ${symbol}, found ${invalidPrices.length} mismatched`, @@ -219,7 +240,9 @@ export class AggregationService { } // Validate price values - const invalidValues = prices.filter(p => !isFinite(p.price) || p.price <= 0); + const invalidValues = prices.filter( + (p) => !isFinite(p.price) || p.price <= 0, + ); if (invalidValues.length > 0) { throw new Error(`Found ${invalidValues.length} invalid price values`); } @@ -235,7 +258,7 @@ export class AggregationService { const weights = new Map(); // Get unique sources - const sources = new Set(prices.map(p => p.source)); + const sources = new Set(prices.map((p) => p.source)); for (const source of sources) { // Priority: custom weights > config weights @@ -254,12 +277,14 @@ export class AggregationService { spread: number; variance: number; } { - const priceValues = prices.map(p => p.price); - const mean = priceValues.reduce((sum, p) => sum + p, 0) / priceValues.length; + const priceValues = prices.map((p) => p.price); + const mean = + priceValues.reduce((sum, p) => sum + p, 0) / priceValues.length; // Calculate variance - const squaredDiffs = priceValues.map(p => Math.pow(p - mean, 2)); - const variance = squaredDiffs.reduce((sum, sq) => sum + sq, 0) / squaredDiffs.length; + const squaredDiffs = priceValues.map((p) => Math.pow(p - mean, 2)); + const variance = + squaredDiffs.reduce((sum, sq) => sum + sq, 0) / squaredDiffs.length; // Standard deviation const standardDeviation = Math.sqrt(variance); @@ -278,7 +303,7 @@ export class AggregationService { /** * Calculate confidence score (0-100) based on metrics - * + * * Higher confidence when: * - Low spread (prices agree) * - Low standard deviation (consistent prices) diff --git a/apps/aggregator/src/services/data-reception.service.spec.ts b/apps/aggregator/src/services/data-reception.service.spec.ts index b42de5f..de46b57 100644 --- a/apps/aggregator/src/services/data-reception.service.spec.ts +++ b/apps/aggregator/src/services/data-reception.service.spec.ts @@ -1,197 +1,244 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { DataReceptionService } from './data-reception.service'; -import { of, throwError } from 'rxjs'; -import { WebSocket } from 'ws'; - -jest.mock('ws'); - -describe('DataReceptionService', () => { - let service: DataReceptionService; - let httpService: HttpService; - let eventEmitter: EventEmitter2; - - const mockConfigService = { - get: jest.fn((key: string): any => { - if (key === 'INGESTOR_WS_URL') return 'ws://localhost:3000'; - if (key === 'INGESTOR_HTTP_URL') return 'http://localhost:3000'; - return null; - }), - }; - - const mockHttpService = { - get: jest.fn(), - }; - - const mockEventEmitter = { - emit: jest.fn(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - DataReceptionService, - { provide: ConfigService, useValue: mockConfigService }, - { provide: HttpService, useValue: mockHttpService }, - { provide: EventEmitter2, useValue: mockEventEmitter }, - ], - }).compile(); - - service = module.get(DataReceptionService); - httpService = module.get(HttpService); - eventEmitter = module.get(EventEmitter2); +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { DataReceptionService } from "./data-reception.service"; +import { of, throwError } from "rxjs"; +import { WebSocket } from "ws"; + +jest.mock("ws"); + +describe("DataReceptionService", () => { + let service: DataReceptionService; + let httpService: HttpService; + let eventEmitter: EventEmitter2; + + const mockConfigService = { + get: jest.fn((key: string): any => { + if (key === "INGESTOR_WS_URL") return "ws://localhost:3000"; + if (key === "INGESTOR_HTTP_URL") return "http://localhost:3000"; + return null; + }), + }; + + const mockHttpService = { + get: jest.fn(), + }; + + const mockEventEmitter = { + emit: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DataReceptionService, + { provide: ConfigService, useValue: mockConfigService }, + { provide: HttpService, useValue: mockHttpService }, + { provide: EventEmitter2, useValue: mockEventEmitter }, + ], + }).compile(); + + service = module.get(DataReceptionService); + httpService = module.get(HttpService); + eventEmitter = module.get(EventEmitter2); + }); + + afterEach(() => { + jest.clearAllMocks(); + service.onModuleDestroy(); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("WebSocket Connection", () => { + it("should initialize WebSocket connection on init", () => { + service.onModuleInit(); + expect(WebSocket).toHaveBeenCalledWith("ws://localhost:3000"); }); - afterEach(() => { - jest.clearAllMocks(); - service.onModuleDestroy(); + it("should log error if INGESTOR_WS_URL is missing", () => { + mockConfigService.get.mockReturnValueOnce(null); + const loggerSpy = jest.spyOn((service as any).logger, "error"); + service.onModuleInit(); + expect(loggerSpy).toHaveBeenCalledWith( + "INGESTOR_WS_URL is not defined in the configuration", + ); }); - - it('should be defined', () => { - expect(service).toBeDefined(); + }); + + describe("fetchHistoricalData", () => { + it("should fetch historical data successfully", async () => { + const mockData = [ + { + symbol: "BTC", + price: 50000, + source: "base", + timestamp: "2026-01-27T13:00:00Z", + }, + ]; + mockHttpService.get.mockReturnValue(of({ data: mockData })); + + const result = await service.fetchHistoricalData("BTC"); + expect(result[0].symbol).toBe("BTC"); + expect(httpService.get).toHaveBeenCalledWith( + "http://localhost:3000/prices/historical/BTC", + ); }); - describe('WebSocket Connection', () => { - it('should initialize WebSocket connection on init', () => { - service.onModuleInit(); - expect(WebSocket).toHaveBeenCalledWith('ws://localhost:3000'); - }); - - it('should log error if INGESTOR_WS_URL is missing', () => { - mockConfigService.get.mockReturnValueOnce(null); - const loggerSpy = jest.spyOn((service as any).logger, 'error'); - service.onModuleInit(); - expect(loggerSpy).toHaveBeenCalledWith('INGESTOR_WS_URL is not defined in the configuration'); - }); + it("should return empty array if response is not an array", async () => { + mockHttpService.get.mockReturnValue(of({ data: null })); + const result = await service.fetchHistoricalData("BTC"); + expect(result).toEqual([]); }); - describe('fetchHistoricalData', () => { - it('should fetch historical data successfully', async () => { - const mockData = [{ symbol: 'BTC', price: 50000, source: 'base', timestamp: '2026-01-27T13:00:00Z' }]; - mockHttpService.get.mockReturnValue(of({ data: mockData })); - - const result = await service.fetchHistoricalData('BTC'); - expect(result[0].symbol).toBe('BTC'); - expect(httpService.get).toHaveBeenCalledWith('http://localhost:3000/prices/historical/BTC'); - }); - - it('should return empty array if response is not an array', async () => { - mockHttpService.get.mockReturnValue(of({ data: null })); - const result = await service.fetchHistoricalData('BTC'); - expect(result).toEqual([]); - }); - - it('should throw error if INGESTOR_HTTP_URL is missing', async () => { - mockConfigService.get.mockImplementation((key) => key === 'INGESTOR_HTTP_URL' ? null : 'value'); - await expect(service.fetchHistoricalData('BTC')).rejects.toThrow('INGESTOR_HTTP_URL is not defined'); - }); - - it('should throw error if fetch fails', async () => { - mockConfigService.get.mockReturnValue('http://localhost:3000'); - mockHttpService.get.mockReturnValue(throwError(() => new Error('HTTP Error'))); - await expect(service.fetchHistoricalData('BTC')).rejects.toThrow('HTTP Error'); - }); + it("should throw error if INGESTOR_HTTP_URL is missing", async () => { + mockConfigService.get.mockImplementation((key) => + key === "INGESTOR_HTTP_URL" ? null : "value", + ); + await expect(service.fetchHistoricalData("BTC")).rejects.toThrow( + "INGESTOR_HTTP_URL is not defined", + ); }); - describe('getLatestSnapshot', () => { - it('should fetch latest snapshot successfully', async () => { - const mockData = { symbol: 'BTC', price: 50000, source: 'base', timestamp: '2026-01-27T13:00:00Z' }; - mockHttpService.get.mockReturnValue(of({ data: mockData })); - - const result = await service.getLatestSnapshot('BTC'); - expect(result.symbol).toBe('BTC'); - expect(httpService.get).toHaveBeenCalledWith('http://localhost:3000/prices/latest/BTC'); - }); - - it('should throw error if snapshot fetch fails', async () => { - mockHttpService.get.mockReturnValue(throwError(() => new Error('Snapshot Error'))); - await expect(service.getLatestSnapshot('BTC')).rejects.toThrow('Snapshot Error'); - }); + it("should throw error if fetch fails", async () => { + mockConfigService.get.mockReturnValue("http://localhost:3000"); + mockHttpService.get.mockReturnValue( + throwError(() => new Error("HTTP Error")), + ); + await expect(service.fetchHistoricalData("BTC")).rejects.toThrow( + "HTTP Error", + ); + }); + }); + + describe("getLatestSnapshot", () => { + it("should fetch latest snapshot successfully", async () => { + const mockData = { + symbol: "BTC", + price: 50000, + source: "base", + timestamp: "2026-01-27T13:00:00Z", + }; + mockHttpService.get.mockReturnValue(of({ data: mockData })); + + const result = await service.getLatestSnapshot("BTC"); + expect(result.symbol).toBe("BTC"); + expect(httpService.get).toHaveBeenCalledWith( + "http://localhost:3000/prices/latest/BTC", + ); }); - describe('WebSocket reconnection', () => { - let wsInstance: any; - - beforeEach(() => { - jest.useFakeTimers(); - service.onModuleInit(); - wsInstance = (WebSocket as any).mock.instances[0]; - }); - - afterEach(() => { - jest.useRealTimers(); - }); + it("should throw error if snapshot fetch fails", async () => { + mockHttpService.get.mockReturnValue( + throwError(() => new Error("Snapshot Error")), + ); + await expect(service.getLatestSnapshot("BTC")).rejects.toThrow( + "Snapshot Error", + ); + }); + }); - it('should attempt reconnection on close', () => { - const connectSpy = jest.spyOn(service as any, 'connectWebSocket'); + describe("WebSocket reconnection", () => { + let wsInstance: any; - // Get the close handler - const closeHandler = wsInstance.on.mock.calls.find((call: any) => call[0] === 'close')[1]; - closeHandler(); + beforeEach(() => { + jest.useFakeTimers(); + service.onModuleInit(); + wsInstance = (WebSocket as any).mock.instances[0]; + }); - expect((service as any).reconnectAttempts).toBe(1); + afterEach(() => { + jest.useRealTimers(); + }); - // Fast-forward time for backoff - jest.advanceTimersByTime(2000); + it("should attempt reconnection on close", () => { + const connectSpy = jest.spyOn(service as any, "connectWebSocket"); - expect(connectSpy).toHaveBeenCalledTimes(1); - }); + // Get the close handler + const closeHandler = wsInstance.on.mock.calls.find( + (call: any) => call[0] === "close", + )[1]; + closeHandler(); - it('should stop reconnecting after max attempts', () => { - (service as any).reconnectAttempts = 5; - const loggerSpy = jest.spyOn((service as any).logger, 'error'); + expect((service as any).reconnectAttempts).toBe(1); - (service as any).handleReconnection(); + // Fast-forward time for backoff + jest.advanceTimersByTime(2000); - expect(loggerSpy).toHaveBeenCalledWith('Max reconnection attempts reached or service stopping.'); - }); + expect(connectSpy).toHaveBeenCalledTimes(1); }); - describe('process incoming messages', () => { - let wsInstance: any; + it("should stop reconnecting after max attempts", () => { + (service as any).reconnectAttempts = 5; + const loggerSpy = jest.spyOn((service as any).logger, "error"); - beforeEach(() => { - service.onModuleInit(); - wsInstance = (WebSocket as any).mock.instances[0]; - }); + (service as any).handleReconnection(); - it('should validate and emit event for valid price data', async () => { - const validPayload = { - symbol: 'ETH', - price: 2500, - source: 'binance', - timestamp: '2026-01-27T13:00:00Z', - }; - - const messageHandler = wsInstance.on.mock.calls.find((call: any) => call[0] === 'message')[1]; - await messageHandler(JSON.stringify(validPayload)); + expect(loggerSpy).toHaveBeenCalledWith( + "Max reconnection attempts reached or service stopping.", + ); + }); + }); - expect(eventEmitter.emit).toHaveBeenCalledWith('price.received', expect.any(Object)); - }); + describe("process incoming messages", () => { + let wsInstance: any; - it('should log error for invalid JSON', async () => { - const loggerSpy = jest.spyOn((service as any).logger, 'error'); - const messageHandler = wsInstance.on.mock.calls.find((call: any) => call[0] === 'message')[1]; + beforeEach(() => { + service.onModuleInit(); + wsInstance = (WebSocket as any).mock.instances[0]; + }); - await messageHandler('invalid-json'); + it("should validate and emit event for valid price data", async () => { + const validPayload = { + symbol: "ETH", + price: 2500, + source: "binance", + timestamp: "2026-01-27T13:00:00Z", + }; + + const messageHandler = wsInstance.on.mock.calls.find( + (call: any) => call[0] === "message", + )[1]; + await messageHandler(JSON.stringify(validPayload)); + + expect(eventEmitter.emit).toHaveBeenCalledWith( + "price.received", + expect.any(Object), + ); + }); - expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('Error processing message')); - }); + it("should log error for invalid JSON", async () => { + const loggerSpy = jest.spyOn((service as any).logger, "error"); + const messageHandler = wsInstance.on.mock.calls.find( + (call: any) => call[0] === "message", + )[1]; - it('should log error for invalid price data', async () => { - const invalidPayload = { - symbol: 'ETH', - price: -100, - }; + await messageHandler("invalid-json"); - const loggerSpy = jest.spyOn((service as any).logger, 'error'); - const messageHandler = wsInstance.on.mock.calls.find((call: any) => call[0] === 'message')[1]; - await messageHandler(JSON.stringify(invalidPayload)); + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining("Error processing message"), + ); + }); - expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('Validation failed')); - expect(eventEmitter.emit).not.toHaveBeenCalled(); - }); + it("should log error for invalid price data", async () => { + const invalidPayload = { + symbol: "ETH", + price: -100, + }; + + const loggerSpy = jest.spyOn((service as any).logger, "error"); + const messageHandler = wsInstance.on.mock.calls.find( + (call: any) => call[0] === "message", + )[1]; + await messageHandler(JSON.stringify(invalidPayload)); + + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining("Validation failed"), + ); + expect(eventEmitter.emit).not.toHaveBeenCalled(); }); + }); }); diff --git a/apps/aggregator/src/services/data-reception.service.ts b/apps/aggregator/src/services/data-reception.service.ts index 92aea8e..58db7bc 100644 --- a/apps/aggregator/src/services/data-reception.service.ts +++ b/apps/aggregator/src/services/data-reception.service.ts @@ -1,135 +1,169 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { validate } from 'class-validator'; -import { plainToInstance } from 'class-transformer'; -import { WebSocket } from 'ws'; -import { firstValueFrom } from 'rxjs'; -import { PriceInputDto } from '../dto/price-input.dto'; +import { + Injectable, + Logger, + OnModuleInit, + OnModuleDestroy, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { validate } from "class-validator"; +import { plainToInstance } from "class-transformer"; +import { WebSocket } from "ws"; +import { firstValueFrom } from "rxjs"; +import { PriceInputDto } from "../dto/price-input.dto"; @Injectable() export class DataReceptionService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(DataReceptionService.name); - private ws: WebSocket; - private reconnectAttempts = 0; - private readonly maxReconnectAttempts = 5; - private isDestroyed = false; - - constructor( - private readonly configService: ConfigService, - private readonly httpService: HttpService, - private readonly eventEmitter: EventEmitter2, - ) { } - - onModuleInit() { - this.connectWebSocket(); + private readonly logger = new Logger(DataReceptionService.name); + private ws: WebSocket; + private reconnectAttempts = 0; + private readonly maxReconnectAttempts = 5; + private isDestroyed = false; + private reconnectTimer: NodeJS.Timeout | null = null; + + constructor( + private readonly configService: ConfigService, + private readonly httpService: HttpService, + private readonly eventEmitter: EventEmitter2, + ) {} + + onModuleInit() { + this.connectWebSocket(); + } + + onModuleDestroy() { + this.isDestroyed = true; + + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; } - onModuleDestroy() { - this.isDestroyed = true; - if (this.ws) { - this.ws.close(); - } + if (this.ws) { + this.ws.close(); } + } - private connectWebSocket() { - const wsUrl = this.configService.get('INGESTOR_WS_URL'); - if (!wsUrl) { - this.logger.error('INGESTOR_WS_URL is not defined in the configuration'); - return; - } - - this.logger.log(`Connecting to Ingestor WebSocket: ${wsUrl}`); - this.ws = new WebSocket(wsUrl); - - this.ws.on('open', () => { - this.logger.log('Connected to Ingestor WebSocket'); - this.reconnectAttempts = 0; - }); - - this.ws.on('message', async (data: string) => { - try { - const payload = JSON.parse(data.toString()); - await this.handleIncomingPrice(payload); - } catch (error) { - this.logger.error(`Error processing message: ${error.message}`); - } - }); - - this.ws.on('error', (error) => { - this.logger.error(`WebSocket error: ${error.message}`); - }); - - this.ws.on('close', () => { - this.logger.warn('Disconnected from Ingestor WebSocket'); - this.handleReconnection(); - }); + private connectWebSocket() { + const wsUrl = this.configService.get("INGESTOR_WS_URL"); + if (!wsUrl) { + this.logger.error("INGESTOR_WS_URL is not defined in the configuration"); + return; } - private handleReconnection() { - if (this.isDestroyed || this.reconnectAttempts >= this.maxReconnectAttempts) { - this.logger.error('Max reconnection attempts reached or service stopping.'); - return; - } - - this.reconnectAttempts++; - const delay = Math.pow(2, this.reconnectAttempts) * 1000; - this.logger.log(`Reconnecting in ${delay / 1000} seconds... (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); - - setTimeout(() => { - if (!this.isDestroyed) { - this.connectWebSocket(); - } - }, delay); + this.logger.log(`Connecting to Ingestor WebSocket: ${wsUrl}`); + this.ws = new WebSocket(wsUrl); + + this.ws.on("open", () => { + this.logger.log("Connected to Ingestor WebSocket"); + this.reconnectAttempts = 0; + }); + + this.ws.on("message", async (data: string) => { + try { + const payload = JSON.parse(data.toString()); + await this.handleIncomingPrice(payload); + } catch (error) { + this.logger.error(`Error processing message: ${error.message}`); + } + }); + + this.ws.on("error", (error) => { + this.logger.error(`WebSocket error: ${error.message}`); + }); + + this.ws.on("close", () => { + this.logger.warn("Disconnected from Ingestor WebSocket"); + this.handleReconnection(); + }); + } + + private handleReconnection() { + if ( + this.isDestroyed || + this.reconnectAttempts >= this.maxReconnectAttempts + ) { + this.logger.error( + "Max reconnection attempts reached or service stopping.", + ); + return; } - private async handleIncomingPrice(payload: any) { - const priceDto = plainToInstance(PriceInputDto, payload); - const errors = await validate(priceDto); + this.reconnectAttempts++; + const delay = Math.pow(2, this.reconnectAttempts) * 1000; + this.logger.log( + `Reconnecting in ${delay / 1000} seconds... (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`, + ); - if (errors.length > 0) { - this.logger.error(`Validation failed for incoming price: ${JSON.stringify(errors)}`); - return; - } + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } - this.logger.debug(`Received valid price: ${priceDto.symbol} - ${priceDto.price}`); - this.eventEmitter.emit('price.received', priceDto); + this.reconnectTimer = setTimeout(() => { + if (!this.isDestroyed) { + this.connectWebSocket(); + } + this.reconnectTimer = null; + }, delay); + + // Avoid keeping the process alive solely for backoff timers. + if (typeof this.reconnectTimer.unref === "function") { + this.reconnectTimer.unref(); } + } + + private async handleIncomingPrice(payload: any) { + const priceDto = plainToInstance(PriceInputDto, payload); + const errors = await validate(priceDto); - async fetchHistoricalData(symbol: string): Promise { - const baseUrl = this.configService.get('INGESTOR_HTTP_URL'); - if (!baseUrl) { - throw new Error('INGESTOR_HTTP_URL is not defined'); - } - - try { - this.logger.log(`Fetching historical data for ${symbol} via HTTP`); - const response = await firstValueFrom( - this.httpService.get(`${baseUrl}/prices/historical/${symbol}`), - ); - - const data = response.data; - if (Array.isArray(data)) { - return data.map(item => plainToInstance(PriceInputDto, item)); - } - return []; - } catch (error) { - this.logger.error(`Failed to fetch historical data: ${error.message}`); - throw error; - } + if (errors.length > 0) { + this.logger.error( + `Validation failed for incoming price: ${JSON.stringify(errors)}`, + ); + return; } - async getLatestSnapshot(symbol: string): Promise { - const baseUrl = this.configService.get('INGESTOR_HTTP_URL'); - try { - const response = await firstValueFrom( - this.httpService.get(`${baseUrl}/prices/latest/${symbol}`), - ); - return plainToInstance(PriceInputDto, response.data); - } catch (error) { - this.logger.error(`Failed to fetch latest snapshot: ${error.message}`); - throw error; - } + this.logger.debug( + `Received valid price: ${priceDto.symbol} - ${priceDto.price}`, + ); + this.eventEmitter.emit("price.received", priceDto); + } + + async fetchHistoricalData(symbol: string): Promise { + const baseUrl = this.configService.get("INGESTOR_HTTP_URL"); + if (!baseUrl) { + throw new Error("INGESTOR_HTTP_URL is not defined"); + } + + try { + this.logger.log(`Fetching historical data for ${symbol} via HTTP`); + const response = await firstValueFrom( + this.httpService.get(`${baseUrl}/prices/historical/${symbol}`), + ); + + const data = response.data; + if (Array.isArray(data)) { + return data.map((item) => plainToInstance(PriceInputDto, item)); + } + return []; + } catch (error) { + this.logger.error(`Failed to fetch historical data: ${error.message}`); + throw error; + } + } + + async getLatestSnapshot(symbol: string): Promise { + const baseUrl = this.configService.get("INGESTOR_HTTP_URL"); + try { + const response = await firstValueFrom( + this.httpService.get(`${baseUrl}/prices/latest/${symbol}`), + ); + return plainToInstance(PriceInputDto, response.data); + } catch (error) { + this.logger.error(`Failed to fetch latest snapshot: ${error.message}`); + throw error; } + } } diff --git a/apps/aggregator/src/services/normalization.service.spec.ts b/apps/aggregator/src/services/normalization.service.spec.ts index f069411..b14798f 100644 --- a/apps/aggregator/src/services/normalization.service.spec.ts +++ b/apps/aggregator/src/services/normalization.service.spec.ts @@ -1,16 +1,16 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NormalizationService } from './normalization.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { NormalizationService } from "./normalization.service"; import { mockRawPrices, malformedPrices, validRawPrices, mixedRawPrices, -} from '../__mocks__/raw-price.fixtures'; -import { NormalizedSource } from '../interfaces/normalized-price.interface'; -import { NormalizationException } from '../exceptions'; -import { RawPrice } from '@oracle-stocks/shared'; +} from "../__mocks__/raw-price.fixtures"; +import { NormalizedSource } from "../interfaces/normalized-price.interface"; +import { NormalizationException } from "../exceptions"; +import type { RawPrice } from "@oracle-stocks/shared"; -describe('NormalizationService', () => { +describe("NormalizationService", () => { let service: NormalizationService; beforeEach(async () => { @@ -22,17 +22,17 @@ describe('NormalizationService', () => { service.onModuleInit(); }); - describe('initialization', () => { - it('should be defined', () => { + describe("initialization", () => { + it("should be defined", () => { expect(service).toBeDefined(); }); - it('should register default normalizers', () => { + it("should register default normalizers", () => { const normalizers = service.getNormalizers(); expect(normalizers.length).toBeGreaterThanOrEqual(4); }); - it('should have normalizers for all expected sources', () => { + it("should have normalizers for all expected sources", () => { const normalizers = service.getNormalizers(); const sources = normalizers.map((n) => n.source); @@ -43,233 +43,234 @@ describe('NormalizationService', () => { }); }); - describe('findNormalizer', () => { - it('should find Alpha Vantage normalizer', () => { + describe("findNormalizer", () => { + it("should find Alpha Vantage normalizer", () => { const normalizer = service.findNormalizer(mockRawPrices.alphaVantage); expect(normalizer).not.toBeNull(); expect(normalizer?.source).toBe(NormalizedSource.ALPHA_VANTAGE); }); - it('should find Finnhub normalizer', () => { + it("should find Finnhub normalizer", () => { const normalizer = service.findNormalizer(mockRawPrices.finnhub); expect(normalizer).not.toBeNull(); expect(normalizer?.source).toBe(NormalizedSource.FINNHUB); }); - it('should find Yahoo Finance normalizer', () => { + it("should find Yahoo Finance normalizer", () => { const normalizer = service.findNormalizer(mockRawPrices.yahooFinance); expect(normalizer).not.toBeNull(); expect(normalizer?.source).toBe(NormalizedSource.YAHOO_FINANCE); }); - it('should find Mock normalizer', () => { + it("should find Mock normalizer", () => { const normalizer = service.findNormalizer(mockRawPrices.mock); expect(normalizer).not.toBeNull(); expect(normalizer?.source).toBe(NormalizedSource.MOCK); }); - it('should return null for unknown source', () => { + it("should return null for unknown source", () => { const normalizer = service.findNormalizer(mockRawPrices.unknown); expect(normalizer).toBeNull(); }); }); - describe('normalize - Alpha Vantage', () => { - it('should normalize Alpha Vantage price with .US suffix', () => { + describe("normalize - Alpha Vantage", () => { + it("should normalize Alpha Vantage price with .US suffix", () => { const result = service.normalize(mockRawPrices.alphaVantage); - expect(result.symbol).toBe('AAPL'); + expect(result.symbol).toBe("AAPL"); expect(result.source).toBe(NormalizedSource.ALPHA_VANTAGE); - expect(result.metadata.originalSymbol).toBe('AAPL.US'); - expect(result.metadata.originalSource).toBe('AlphaVantage'); + expect(result.metadata.originalSymbol).toBe("AAPL.US"); + expect(result.metadata.originalSource).toBe("AlphaVantage"); }); - it('should normalize Alpha Vantage price with .NYSE suffix', () => { + it("should normalize Alpha Vantage price with .NYSE suffix", () => { const result = service.normalize(mockRawPrices.alphaVantageNYSE); - expect(result.symbol).toBe('MSFT'); + expect(result.symbol).toBe("MSFT"); expect(result.source).toBe(NormalizedSource.ALPHA_VANTAGE); }); - it('should handle alpha_vantage source name variation', () => { + it("should handle alpha_vantage source name variation", () => { const result = service.normalize(mockRawPrices.alphaVantageNYSE); expect(result.source).toBe(NormalizedSource.ALPHA_VANTAGE); }); }); - describe('normalize - Finnhub', () => { - it('should normalize Finnhub price with US- prefix', () => { + describe("normalize - Finnhub", () => { + it("should normalize Finnhub price with US- prefix", () => { const result = service.normalize(mockRawPrices.finnhub); - expect(result.symbol).toBe('GOOGL'); + expect(result.symbol).toBe("GOOGL"); expect(result.source).toBe(NormalizedSource.FINNHUB); - expect(result.metadata.originalSymbol).toBe('US-GOOGL'); + expect(result.metadata.originalSymbol).toBe("US-GOOGL"); }); - it('should normalize Finnhub price with CRYPTO- prefix', () => { + it("should normalize Finnhub price with CRYPTO- prefix", () => { const result = service.normalize(mockRawPrices.finnhubCrypto); - expect(result.symbol).toBe('BTC'); + expect(result.symbol).toBe("BTC"); expect(result.source).toBe(NormalizedSource.FINNHUB); }); }); - describe('normalize - Yahoo Finance', () => { - it('should normalize Yahoo Finance price with .L suffix', () => { + describe("normalize - Yahoo Finance", () => { + it("should normalize Yahoo Finance price with .L suffix", () => { const result = service.normalize(mockRawPrices.yahooFinance); - expect(result.symbol).toBe('MSFT'); + expect(result.symbol).toBe("MSFT"); expect(result.source).toBe(NormalizedSource.YAHOO_FINANCE); }); - it('should normalize Yahoo Finance index with ^ prefix', () => { + it("should normalize Yahoo Finance index with ^ prefix", () => { const result = service.normalize(mockRawPrices.yahooFinanceIndex); - expect(result.symbol).toBe('DJI'); + expect(result.symbol).toBe("DJI"); expect(result.source).toBe(NormalizedSource.YAHOO_FINANCE); }); - it('should normalize Yahoo Finance price with .AX suffix', () => { + it("should normalize Yahoo Finance price with .AX suffix", () => { const result = service.normalize(mockRawPrices.yahooFinanceAustralia); - expect(result.symbol).toBe('BHP'); + expect(result.symbol).toBe("BHP"); expect(result.source).toBe(NormalizedSource.YAHOO_FINANCE); }); - it('should handle yahoo_finance source name variation', () => { + it("should handle yahoo_finance source name variation", () => { const result = service.normalize(mockRawPrices.yahooFinanceIndex); expect(result.source).toBe(NormalizedSource.YAHOO_FINANCE); }); }); - describe('normalize - Mock', () => { - it('should normalize Mock price', () => { + describe("normalize - Mock", () => { + it("should normalize Mock price", () => { const result = service.normalize(mockRawPrices.mock); - expect(result.symbol).toBe('TSLA'); + expect(result.symbol).toBe("TSLA"); expect(result.source).toBe(NormalizedSource.MOCK); }); - it('should uppercase and trim symbols', () => { + it("should uppercase and trim symbols", () => { const result = service.normalize(mockRawPrices.mockLowercase); - expect(result.symbol).toBe('AAPL'); + expect(result.symbol).toBe("AAPL"); }); }); - describe('normalize - timestamp', () => { - it('should convert timestamp to ISO 8601 UTC format', () => { + describe("normalize - timestamp", () => { + it("should convert timestamp to ISO 8601 UTC format", () => { const result = service.normalize(mockRawPrices.alphaVantage); expect(result.timestamp).toMatch( /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/, ); - expect(result.timestamp).toBe('2024-01-15T14:30:00.000Z'); + expect(result.timestamp).toBe("2024-01-15T14:30:00.000Z"); }); - it('should preserve original timestamp', () => { + it("should preserve original timestamp", () => { const result = service.normalize(mockRawPrices.alphaVantage); expect(result.originalTimestamp).toBe(1705329000000); }); }); - describe('normalize - price', () => { - it('should round price to 4 decimal places', () => { + describe("normalize - price", () => { + it("should round price to 4 decimal places", () => { const result = service.normalize(mockRawPrices.alphaVantage); expect(result.price).toBe(150.1235); - const decimalPlaces = (result.price.toString().split('.')[1] || '').length; + const decimalPlaces = (result.price.toString().split(".")[1] || "") + .length; expect(decimalPlaces).toBeLessThanOrEqual(4); }); - it('should handle prices with fewer decimals', () => { + it("should handle prices with fewer decimals", () => { const result = service.normalize(mockRawPrices.mock); expect(result.price).toBe(250.5); }); }); - describe('normalize - metadata', () => { - it('should include normalization metadata', () => { + describe("normalize - metadata", () => { + it("should include normalization metadata", () => { const result = service.normalize(mockRawPrices.alphaVantage); expect(result.metadata).toBeDefined(); - expect(result.metadata.originalSource).toBe('AlphaVantage'); - expect(result.metadata.originalSymbol).toBe('AAPL.US'); + expect(result.metadata.originalSource).toBe("AlphaVantage"); + expect(result.metadata.originalSymbol).toBe("AAPL.US"); expect(result.metadata.normalizedAt).toBeDefined(); - expect(result.metadata.normalizerVersion).toBe('1.0.0'); + expect(result.metadata.normalizerVersion).toBe("1.0.0"); }); - it('should track transformations when symbol changes', () => { + it("should track transformations when symbol changes", () => { const result = service.normalize(mockRawPrices.alphaVantage); expect(result.metadata.wasTransformed).toBe(true); expect(result.metadata.transformations.length).toBeGreaterThan(0); - expect(result.metadata.transformations[0]).toContain('symbol'); + expect(result.metadata.transformations[0]).toContain("symbol"); }); - it('should track transformations when price is rounded', () => { + it("should track transformations when price is rounded", () => { const result = service.normalize(mockRawPrices.alphaVantage); const priceTransformation = result.metadata.transformations.find((t) => - t.includes('price'), + t.includes("price"), ); expect(priceTransformation).toBeDefined(); }); }); - describe('normalize - error handling', () => { - it('should throw for unknown source', () => { + describe("normalize - error handling", () => { + it("should throw for unknown source", () => { expect(() => service.normalize(mockRawPrices.unknown)).toThrow( NormalizationException, ); }); - it('should include source in error message', () => { + it("should include source in error message", () => { expect(() => service.normalize(mockRawPrices.unknown)).toThrow( /UnknownSource/, ); }); }); - describe('normalizeMany', () => { - it('should normalize multiple prices', () => { + describe("normalizeMany", () => { + it("should normalize multiple prices", () => { const results = service.normalizeMany(validRawPrices); expect(results.length).toBe(4); }); - it('should skip invalid prices without throwing', () => { + it("should skip invalid prices without throwing", () => { const results = service.normalizeMany(mixedRawPrices); expect(results.length).toBe(2); }); - it('should return empty array for all invalid prices', () => { + it("should return empty array for all invalid prices", () => { const results = service.normalizeMany([mockRawPrices.unknown]); expect(results.length).toBe(0); }); }); - describe('normalizeManyWithErrors', () => { - it('should return both successful and failed normalizations', () => { + describe("normalizeManyWithErrors", () => { + it("should return both successful and failed normalizations", () => { const result = service.normalizeManyWithErrors(mixedRawPrices); expect(result.successful.length).toBe(2); expect(result.failed.length).toBe(1); }); - it('should include error details for failures', () => { + it("should include error details for failures", () => { const result = service.normalizeManyWithErrors(mixedRawPrices); - expect(result.failed[0].error).toContain('No normalizer found'); + expect(result.failed[0].error).toContain("No normalizer found"); expect(result.failed[0].rawPrice).toEqual(mockRawPrices.unknown); expect(result.failed[0].timestamp).toBeDefined(); }); - it('should handle all valid prices', () => { + it("should handle all valid prices", () => { const result = service.normalizeManyWithErrors(validRawPrices); expect(result.successful.length).toBe(4); @@ -277,8 +278,8 @@ describe('NormalizationService', () => { }); }); - describe('normalizeBySource', () => { - it('should group normalized prices by source', () => { + describe("normalizeBySource", () => { + it("should group normalized prices by source", () => { const result = service.normalizeBySource(validRawPrices); expect(result.get(NormalizedSource.ALPHA_VANTAGE)?.length).toBe(1); @@ -287,7 +288,7 @@ describe('NormalizationService', () => { expect(result.get(NormalizedSource.MOCK)?.length).toBe(1); }); - it('should skip invalid prices', () => { + it("should skip invalid prices", () => { const result = service.normalizeBySource(mixedRawPrices); expect(result.size).toBe(2); @@ -295,7 +296,7 @@ describe('NormalizationService', () => { }); }); - describe('validation', () => { + describe("validation", () => { malformedPrices.forEach((malformed, index) => { // Skip test case #5 (empty source) since it gets overwritten by 'MockProvider' // That case is tested separately below @@ -305,66 +306,66 @@ describe('NormalizationService', () => { // Create a mock price that will match a normalizer const testPrice = { ...malformed, - source: 'MockProvider', + source: "MockProvider", } as RawPrice; expect(() => service.normalize(testPrice)).toThrow(); }); }); - it('should reject empty symbol', () => { + it("should reject empty symbol", () => { const badPrice: RawPrice = { - symbol: '', + symbol: "", price: 100, timestamp: Date.now(), - source: 'MockProvider', + source: "MockProvider", }; expect(() => service.normalize(badPrice)).toThrow(/Symbol/); }); - it('should reject NaN price', () => { + it("should reject NaN price", () => { const badPrice: RawPrice = { - symbol: 'TEST', + symbol: "TEST", price: NaN, timestamp: Date.now(), - source: 'MockProvider', + source: "MockProvider", }; expect(() => service.normalize(badPrice)).toThrow(/number/); }); - it('should reject negative price', () => { + it("should reject negative price", () => { const badPrice: RawPrice = { - symbol: 'TEST', + symbol: "TEST", price: -100, timestamp: Date.now(), - source: 'MockProvider', + source: "MockProvider", }; expect(() => service.normalize(badPrice)).toThrow(/negative/); }); - it('should reject invalid timestamp', () => { + it("should reject invalid timestamp", () => { const badPrice: RawPrice = { - symbol: 'TEST', + symbol: "TEST", price: 100, timestamp: NaN, - source: 'MockProvider', + source: "MockProvider", }; expect(() => service.normalize(badPrice)).toThrow(); }); }); - describe('registerNormalizer', () => { - it('should allow registering custom normalizers', () => { + describe("registerNormalizer", () => { + it("should allow registering custom normalizers", () => { const initialCount = service.getNormalizers().length; const customNormalizer = { - name: 'CustomNormalizer', + name: "CustomNormalizer", source: NormalizedSource.UNKNOWN, - version: '1.0.0', + version: "1.0.0", canNormalize: () => false, normalize: jest.fn(), normalizeMany: jest.fn(), diff --git a/apps/aggregator/src/services/normalization.service.ts b/apps/aggregator/src/services/normalization.service.ts index 4789f89..b0e64b9 100644 --- a/apps/aggregator/src/services/normalization.service.ts +++ b/apps/aggregator/src/services/normalization.service.ts @@ -1,21 +1,21 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { RawPrice } from '@oracle-stocks/shared'; +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import type { RawPrice } from "@oracle-stocks/shared"; import { NormalizedPriceRecord, NormalizedSource, -} from '../interfaces/normalized-price.interface'; +} from "../interfaces/normalized-price.interface"; import { Normalizer, NormalizationResult, NormalizationFailure, -} from '../interfaces/normalizer.interface'; +} from "../interfaces/normalizer.interface"; import { AlphaVantageNormalizer, FinnhubNormalizer, YahooFinanceNormalizer, MockNormalizer, -} from '../normalizers'; -import { NormalizationException } from '../exceptions'; +} from "../normalizers"; +import { NormalizationException } from "../exceptions"; /** * Service for normalizing raw price data from multiple sources. diff --git a/apps/aggregator/src/services/outlier-detection.service.spec.ts b/apps/aggregator/src/services/outlier-detection.service.spec.ts new file mode 100644 index 0000000..bc0d13f --- /dev/null +++ b/apps/aggregator/src/services/outlier-detection.service.spec.ts @@ -0,0 +1,179 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { of, firstValueFrom } from "rxjs"; +import { NormalizedPrice } from "../interfaces/normalized-price.interface"; +import { OutlierDetectionService } from "./outlier-detection.service"; + +describe("OutlierDetectionService", () => { + let service: OutlierDetectionService; + let config: Record; + + beforeEach(async () => { + config = {}; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OutlierDetectionService, + { + provide: ConfigService, + useValue: { + get: (key: string): string | undefined => config[key], + }, + }, + ], + }).compile(); + + service = module.get(OutlierDetectionService); + }); + + const now = Date.now(); + + const buildPrices = ( + values: Array<{ source: string; price: number; offsetMs?: number }>, + ): NormalizedPrice[] => + values.map((value, index) => ({ + symbol: "AAPL", + source: value.source, + price: value.price, + timestamp: now + (value.offsetMs ?? index * 1000), + })); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + it("should detect non-positive prices", () => { + const prices = buildPrices([ + { source: "alpha_vantage", price: 120 }, + { source: "finnhub", price: 0 }, + { source: "yahoo_finance", price: -10 }, + ]); + + const result = service.detect("AAPL", prices); + const zeroPrice = result.results.find((item) => item.price.price === 0); + const negativePrice = result.results.find( + (item) => item.price.price === -10, + ); + + expect(zeroPrice?.isOutlier).toBe(true); + expect(negativePrice?.isOutlier).toBe(true); + expect( + zeroPrice?.reasons.some((reason) => reason.code === "NON_POSITIVE_PRICE"), + ).toBe(true); + expect( + negativePrice?.reasons.some( + (reason) => reason.code === "NON_POSITIVE_PRICE", + ), + ).toBe(true); + }); + + it("should detect abrupt source price changes within configured window", () => { + const prices = buildPrices([ + { source: "alpha_vantage", price: 100, offsetMs: 0 }, + { source: "alpha_vantage", price: 170, offsetMs: 2000 }, + { source: "finnhub", price: 101, offsetMs: 0 }, + ]); + + const result = service.detect("AAPL", prices); + const changed = result.results.find((item) => item.price.price === 170); + + expect(changed?.isOutlier).toBe(true); + expect( + changed?.reasons.some( + (reason) => reason.code === "PRICE_CHANGE_TOO_LARGE", + ), + ).toBe(true); + }); + + it("should implement IQR, Z-score and MAD detections", () => { + const strictThresholds = { + OUTLIER_ZSCORE_THRESHOLD: "1.9", + OUTLIER_MAD_THRESHOLD: "2", + }; + const localService = new OutlierDetectionService({ + get: (key: string): string | undefined => strictThresholds[key], + } as ConfigService); + + const prices = buildPrices([ + { source: "s1", price: 100 }, + { source: "s2", price: 101 }, + { source: "s3", price: 99 }, + { source: "s4", price: 100 }, + { source: "s5", price: 300 }, + ]); + + const result = localService.detect("AAPL", prices); + const extreme = result.results.find((item) => item.price.price === 300); + + expect(extreme?.isOutlier).toBe(true); + expect(extreme?.reasons.some((reason) => reason.strategy === "iqr")).toBe( + true, + ); + expect( + extreme?.reasons.some((reason) => reason.strategy === "zscore"), + ).toBe(true); + expect(extreme?.reasons.some((reason) => reason.strategy === "mad")).toBe( + true, + ); + }); + + it("should keep all records and mark outliers instead of dropping them", () => { + const prices = buildPrices([ + { source: "s1", price: 100 }, + { source: "s2", price: 101 }, + { source: "s3", price: -1 }, + ]); + + const result = service.detect("AAPL", prices); + const clean = service.getCleanPrices(result); + + expect(result.summary.analyzedCount).toBe(3); + expect(result.results).toHaveLength(3); + expect(clean).toHaveLength(2); + }); + + it("should calculate source quality metrics", () => { + const prices = buildPrices([ + { source: "alpha_vantage", price: 100 }, + { source: "alpha_vantage", price: -5 }, + { source: "finnhub", price: 102 }, + ]); + + const result = service.detect("AAPL", prices); + + expect(result.sourceMetrics.alpha_vantage.totalCount).toBe(2); + expect(result.sourceMetrics.alpha_vantage.outlierCount).toBe(1); + expect(result.sourceMetrics.alpha_vantage.outlierRate).toBeCloseTo(50, 3); + expect(result.sourceMetrics.finnhub.outlierRate).toBe(0); + }); + + it("should handle empty and insufficient samples without crashing", () => { + const empty = service.detect("AAPL", []); + const small = service.detect( + "AAPL", + buildPrices([ + { source: "s1", price: 100 }, + { source: "s2", price: 100.1 }, + ]), + ); + + expect(empty.summary.analyzedCount).toBe(0); + expect(empty.results).toHaveLength(0); + expect(small.summary.analyzedCount).toBe(2); + expect(() => small.results).not.toThrow(); + }); + + it("should support RxJS stream processing", async () => { + const prices = buildPrices([ + { source: "s1", price: 100 }, + { source: "s2", price: 105 }, + { source: "s3", price: -1 }, + ]); + + const stream$ = service.detectFromStream("AAPL", of(prices)); + const result = await firstValueFrom(stream$); + + expect(result.summary.analyzedCount).toBe(3); + expect(result.summary.outlierCount).toBe(1); + }); +}); diff --git a/apps/aggregator/src/services/outlier-detection.service.ts b/apps/aggregator/src/services/outlier-detection.service.ts new file mode 100644 index 0000000..37e4dad --- /dev/null +++ b/apps/aggregator/src/services/outlier-detection.service.ts @@ -0,0 +1,168 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Observable, map } from "rxjs"; +import { NormalizedPrice } from "../interfaces/normalized-price.interface"; +import { + OutlierDetectionBatchResult, + OutlierReason, + OutlierThresholdConfig, + SourceQualityMetric, +} from "../interfaces/outlier-result.interface"; +import { IqrDetector } from "../strategies/outlier-detectors/iqr-detector"; +import { MadDetector } from "../strategies/outlier-detectors/mad-detector"; +import { + OutlierDetector, + OutlierDetectorContext, +} from "../strategies/outlier-detectors/outlier-detector.interface"; +import { RangeDetector } from "../strategies/outlier-detectors/range-detector"; +import { ZscoreDetector } from "../strategies/outlier-detectors/zscore-detector"; + +@Injectable() +export class OutlierDetectionService { + private readonly logger = new Logger(OutlierDetectionService.name); + private readonly detectors: OutlierDetector[]; + private readonly thresholds: OutlierThresholdConfig; + + constructor(private readonly configService: ConfigService) { + this.detectors = [ + new RangeDetector(), + new IqrDetector(), + new ZscoreDetector(), + new MadDetector(), + ]; + + this.thresholds = { + minPrice: this.getNumberConfig("OUTLIER_MIN_PRICE", 0), + maxChangePercent: this.getNumberConfig("OUTLIER_MAX_CHANGE_PERCENT", 50), + maxChangeWindowSeconds: this.getNumberConfig( + "OUTLIER_MAX_CHANGE_WINDOW_SECONDS", + 10, + ), + iqrMultiplier: this.getNumberConfig("OUTLIER_IQR_MULTIPLIER", 1.5), + zScoreThreshold: this.getNumberConfig("OUTLIER_ZSCORE_THRESHOLD", 3), + madThreshold: this.getNumberConfig("OUTLIER_MAD_THRESHOLD", 3.5), + minSamples: this.getNumberConfig("OUTLIER_MIN_SAMPLES", 3), + }; + } + + detect( + symbol: string, + prices: NormalizedPrice[], + ): OutlierDetectionBatchResult { + if (!prices || prices.length === 0) { + return { + symbol, + results: [], + sourceMetrics: {}, + summary: { + analyzedCount: 0, + outlierCount: 0, + cleanCount: 0, + thresholds: this.thresholds, + }, + }; + } + + const context: OutlierDetectorContext = { + thresholds: this.thresholds, + }; + const reasonsByIndex = new Map(); + + for (const detector of this.detectors) { + const detectorReasons = detector.detect(prices, context); + for (const [index, reasons] of detectorReasons.entries()) { + const existing = reasonsByIndex.get(index) ?? []; + reasonsByIndex.set(index, [...existing, ...reasons]); + } + } + + const results = prices.map((price, index) => { + const reasons = reasonsByIndex.get(index) ?? []; + const isOutlier = reasons.length > 0; + + if (isOutlier) { + this.logger.warn( + `Outlier detected: symbol=${price.symbol} source=${price.source} ` + + `price=${price.price} reasons=${reasons + .map((reason) => reason.code) + .join(",")}`, + ); + } + + return { + price, + isOutlier, + reasons, + }; + }); + + const outlierCount = results.filter((result) => result.isOutlier).length; + + return { + symbol, + results, + sourceMetrics: this.calculateSourceMetrics(results), + summary: { + analyzedCount: results.length, + outlierCount, + cleanCount: results.length - outlierCount, + thresholds: this.thresholds, + }, + }; + } + + getCleanPrices(result: OutlierDetectionBatchResult): NormalizedPrice[] { + return result.results + .filter((item) => !item.isOutlier) + .map((item) => item.price); + } + + detectFromStream( + symbol: string, + priceStream$: Observable, + ): Observable { + return priceStream$.pipe(map((prices) => this.detect(symbol, prices))); + } + + private calculateSourceMetrics( + results: OutlierDetectionBatchResult["results"], + ): Record { + const metrics = new Map(); + + for (const result of results) { + const source = result.price.source; + const sourceMetric = metrics.get(source) ?? { + source, + totalCount: 0, + outlierCount: 0, + outlierRate: 0, + }; + + sourceMetric.totalCount += 1; + if (result.isOutlier) { + sourceMetric.outlierCount += 1; + } + + metrics.set(source, sourceMetric); + } + + for (const sourceMetric of metrics.values()) { + sourceMetric.outlierRate = + sourceMetric.totalCount === 0 + ? 0 + : (sourceMetric.outlierCount / sourceMetric.totalCount) * 100; + } + + return Object.fromEntries(Array.from(metrics.entries())); + } + + private getNumberConfig(key: string, defaultValue: number): number { + const value = this.configService.get(key); + if (value === undefined || value === null || value.trim() === "") { + return defaultValue; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : defaultValue; + } +} diff --git a/apps/aggregator/src/strategies/aggregators/median.aggregator.spec.ts b/apps/aggregator/src/strategies/aggregators/median.aggregator.spec.ts index 98991f0..6283f64 100644 --- a/apps/aggregator/src/strategies/aggregators/median.aggregator.spec.ts +++ b/apps/aggregator/src/strategies/aggregators/median.aggregator.spec.ts @@ -1,35 +1,70 @@ -import { MedianAggregator } from './median.aggregator'; -import { NormalizedPrice } from '../../interfaces/normalized-price.interface'; +import { MedianAggregator } from "./median.aggregator"; +import { NormalizedPrice } from "../../interfaces/normalized-price.interface"; -describe('MedianAggregator', () => { +describe("MedianAggregator", () => { let aggregator: MedianAggregator; beforeEach(() => { aggregator = new MedianAggregator(); }); - it('should have correct name', () => { - expect(aggregator.name).toBe('median'); + it("should have correct name", () => { + expect(aggregator.name).toBe("median"); }); - describe('aggregate', () => { - it('should calculate median for odd number of prices', () => { + describe("aggregate", () => { + it("should calculate median for odd number of prices", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 200, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 300, timestamp: Date.now(), source: 'Source3' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 200, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 300, + timestamp: Date.now(), + source: "Source3", + }, ]; const result = aggregator.aggregate(prices); expect(result).toBe(200); }); - it('should calculate median for even number of prices', () => { + it("should calculate median for even number of prices", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 200, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 300, timestamp: Date.now(), source: 'Source3' }, - { symbol: 'AAPL', price: 400, timestamp: Date.now(), source: 'Source4' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 200, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 300, + timestamp: Date.now(), + source: "Source3", + }, + { + symbol: "AAPL", + price: 400, + timestamp: Date.now(), + source: "Source4", + }, ]; const result = aggregator.aggregate(prices); @@ -37,23 +72,58 @@ describe('MedianAggregator', () => { expect(result).toBe(250); }); - it('should handle unsorted input', () => { + it("should handle unsorted input", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 300, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 200, timestamp: Date.now(), source: 'Source3' }, + { + symbol: "AAPL", + price: 300, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 200, + timestamp: Date.now(), + source: "Source3", + }, ]; const result = aggregator.aggregate(prices); expect(result).toBe(200); }); - it('should be resistant to outliers', () => { + it("should be resistant to outliers", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 101, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 102, timestamp: Date.now(), source: 'Source3' }, - { symbol: 'AAPL', price: 1000000, timestamp: Date.now(), source: 'Source4' }, // Outlier + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 101, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 102, + timestamp: Date.now(), + source: "Source3", + }, + { + symbol: "AAPL", + price: 1000000, + timestamp: Date.now(), + source: "Source4", + }, // Outlier ]; const result = aggregator.aggregate(prices); @@ -61,53 +131,98 @@ describe('MedianAggregator', () => { expect(result).toBe(101.5); }); - it('should handle single price', () => { + it("should handle single price", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 123.45, timestamp: Date.now(), source: 'Source1' }, + { + symbol: "AAPL", + price: 123.45, + timestamp: Date.now(), + source: "Source1", + }, ]; const result = aggregator.aggregate(prices); expect(result).toBe(123.45); }); - it('should handle two prices', () => { + it("should handle two prices", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 200, timestamp: Date.now(), source: 'Source2' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 200, + timestamp: Date.now(), + source: "Source2", + }, ]; const result = aggregator.aggregate(prices); expect(result).toBe(150); }); - it('should throw error for empty array', () => { + it("should throw error for empty array", () => { expect(() => { aggregator.aggregate([]); - }).toThrow('Cannot aggregate empty price array'); + }).toThrow("Cannot aggregate empty price array"); }); - it('should handle all same values', () => { + it("should handle all same values", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source3' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source3", + }, ]; const result = aggregator.aggregate(prices); expect(result).toBe(100); }); - it('should ignore weights parameter (median does not use weights)', () => { + it("should ignore weights parameter (median does not use weights)", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 200, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 300, timestamp: Date.now(), source: 'Source3' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 200, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 300, + timestamp: Date.now(), + source: "Source3", + }, ]; const weights = new Map([ - ['Source1', 100], - ['Source2', 1], - ['Source3', 1], + ["Source1", 100], + ["Source2", 1], + ["Source3", 1], ]); const result = aggregator.aggregate(prices, weights); @@ -115,24 +230,42 @@ describe('MedianAggregator', () => { expect(result).toBe(200); }); - it('should handle negative prices', () => { + it("should handle negative prices", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: -300, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: -100, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: -200, timestamp: Date.now(), source: 'Source3' }, + { + symbol: "AAPL", + price: -300, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: -100, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: -200, + timestamp: Date.now(), + source: "Source3", + }, ]; const result = aggregator.aggregate(prices); expect(result).toBe(-200); }); - it('should handle large datasets', () => { - const prices: NormalizedPrice[] = Array.from({ length: 1001 }, (_, i) => ({ - symbol: 'AAPL', - price: i, - timestamp: Date.now(), - source: `Source${i}`, - })); + it("should handle large datasets", () => { + const prices: NormalizedPrice[] = Array.from( + { length: 1001 }, + (_, i) => ({ + symbol: "AAPL", + price: i, + timestamp: Date.now(), + source: `Source${i}`, + }), + ); const result = aggregator.aggregate(prices); // Median of 0-1000 is 500 diff --git a/apps/aggregator/src/strategies/aggregators/median.aggregator.ts b/apps/aggregator/src/strategies/aggregators/median.aggregator.ts index 2be213e..87238fd 100644 --- a/apps/aggregator/src/strategies/aggregators/median.aggregator.ts +++ b/apps/aggregator/src/strategies/aggregators/median.aggregator.ts @@ -1,21 +1,21 @@ -import { Injectable } from '@nestjs/common'; -import { IAggregator } from '../../interfaces/aggregator.interface'; -import { NormalizedPrice } from '../../interfaces/normalized-price.interface'; +import { Injectable } from "@nestjs/common"; +import { IAggregator } from "../../interfaces/aggregator.interface"; +import { NormalizedPrice } from "../../interfaces/normalized-price.interface"; /** * Median Aggregator - * + * * Calculates the median (middle value) of all prices. If there's an even * number of prices, returns the average of the two middle values. - * + * * Use case: When you want to be resistant to outliers and potential * manipulation attempts. The median is not affected by extreme values. - * + * * Pros: * - Highly resistant to outliers * - Not affected by a single manipulated source * - Good for volatile markets or when source reliability varies - * + * * Cons: * - Ignores the majority of data points * - Doesn't account for source reliability/weights @@ -23,15 +23,15 @@ import { NormalizedPrice } from '../../interfaces/normalized-price.interface'; */ @Injectable() export class MedianAggregator implements IAggregator { - readonly name = 'median'; + readonly name = "median"; aggregate(prices: NormalizedPrice[], _weights?: Map): number { if (prices.length === 0) { - throw new Error('Cannot aggregate empty price array'); + throw new Error("Cannot aggregate empty price array"); } // Extract and sort prices - const sortedPrices = prices.map(p => p.price).sort((a, b) => a - b); + const sortedPrices = prices.map((p) => p.price).sort((a, b) => a - b); const length = sortedPrices.length; const middle = Math.floor(length / 2); diff --git a/apps/aggregator/src/strategies/aggregators/trimmed-mean.aggregator.spec.ts b/apps/aggregator/src/strategies/aggregators/trimmed-mean.aggregator.spec.ts index 6fc4b72..44f5bd2 100644 --- a/apps/aggregator/src/strategies/aggregators/trimmed-mean.aggregator.spec.ts +++ b/apps/aggregator/src/strategies/aggregators/trimmed-mean.aggregator.spec.ts @@ -1,48 +1,63 @@ -import { TrimmedMeanAggregator } from './trimmed-mean.aggregator'; -import { NormalizedPrice } from '../../interfaces/normalized-price.interface'; +import { TrimmedMeanAggregator } from "./trimmed-mean.aggregator"; +import { NormalizedPrice } from "../../interfaces/normalized-price.interface"; -describe('TrimmedMeanAggregator', () => { +describe("TrimmedMeanAggregator", () => { let aggregator: TrimmedMeanAggregator; beforeEach(() => { aggregator = new TrimmedMeanAggregator(0.2); }); - it('should have correct name', () => { - expect(aggregator.name).toBe('trimmed-mean'); + it("should have correct name", () => { + expect(aggregator.name).toBe("trimmed-mean"); }); - describe('constructor', () => { - it('should accept valid trim percentages', () => { + describe("constructor", () => { + it("should accept valid trim percentages", () => { expect(() => new TrimmedMeanAggregator(0)).not.toThrow(); expect(() => new TrimmedMeanAggregator(0.25)).not.toThrow(); expect(() => new TrimmedMeanAggregator(0.49)).not.toThrow(); }); - it('should throw error for negative trim percentage', () => { + it("should throw error for negative trim percentage", () => { expect(() => new TrimmedMeanAggregator(-0.1)).toThrow( - 'Trim percentage must be between 0 and 0.5', + "Trim percentage must be between 0 and 0.5", ); }); - it('should throw error for trim percentage >= 0.5', () => { + it("should throw error for trim percentage >= 0.5", () => { expect(() => new TrimmedMeanAggregator(0.5)).toThrow( - 'Trim percentage must be between 0 and 0.5', + "Trim percentage must be between 0 and 0.5", ); expect(() => new TrimmedMeanAggregator(0.6)).toThrow( - 'Trim percentage must be between 0 and 0.5', + "Trim percentage must be between 0 and 0.5", ); }); }); - describe('aggregate', () => { - it('should trim and average correctly', () => { + describe("aggregate", () => { + it("should trim and average correctly", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 90, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 95, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source3' }, - { symbol: 'AAPL', price: 105, timestamp: Date.now(), source: 'Source4' }, - { symbol: 'AAPL', price: 110, timestamp: Date.now(), source: 'Source5' }, + { symbol: "AAPL", price: 90, timestamp: Date.now(), source: "Source1" }, + { symbol: "AAPL", price: 95, timestamp: Date.now(), source: "Source2" }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source3", + }, + { + symbol: "AAPL", + price: 105, + timestamp: Date.now(), + source: "Source4", + }, + { + symbol: "AAPL", + price: 110, + timestamp: Date.now(), + source: "Source5", + }, ]; // 20% trim of 5 = 1 from each end @@ -52,10 +67,20 @@ describe('TrimmedMeanAggregator', () => { expect(result).toBeCloseTo(100, 1); }); - it('should handle datasets too small to trim', () => { + it("should handle datasets too small to trim", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 200, timestamp: Date.now(), source: 'Source2' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 200, + timestamp: Date.now(), + source: "Source2", + }, ]; // With 2 prices, should fall back to simple average @@ -63,13 +88,28 @@ describe('TrimmedMeanAggregator', () => { expect(result).toBe(150); }); - it('should remove outliers effectively', () => { + it("should remove outliers effectively", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 10, timestamp: Date.now(), source: 'Source1' }, // Outlier - { symbol: 'AAPL', price: 98, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source3' }, - { symbol: 'AAPL', price: 102, timestamp: Date.now(), source: 'Source4' }, - { symbol: 'AAPL', price: 500, timestamp: Date.now(), source: 'Source5' }, // Outlier + { symbol: "AAPL", price: 10, timestamp: Date.now(), source: "Source1" }, // Outlier + { symbol: "AAPL", price: 98, timestamp: Date.now(), source: "Source2" }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source3", + }, + { + symbol: "AAPL", + price: 102, + timestamp: Date.now(), + source: "Source4", + }, + { + symbol: "AAPL", + price: 500, + timestamp: Date.now(), + source: "Source5", + }, // Outlier ]; // 20% trim of 5 = 1 from each end @@ -79,19 +119,39 @@ describe('TrimmedMeanAggregator', () => { expect(result).toBeCloseTo(100, 1); }); - it('should apply weights after trimming', () => { + it("should apply weights after trimming", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 90, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 110, timestamp: Date.now(), source: 'Source3' }, - { symbol: 'AAPL', price: 120, timestamp: Date.now(), source: 'Source4' }, - { symbol: 'AAPL', price: 130, timestamp: Date.now(), source: 'Source5' }, + { symbol: "AAPL", price: 90, timestamp: Date.now(), source: "Source1" }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 110, + timestamp: Date.now(), + source: "Source3", + }, + { + symbol: "AAPL", + price: 120, + timestamp: Date.now(), + source: "Source4", + }, + { + symbol: "AAPL", + price: 130, + timestamp: Date.now(), + source: "Source5", + }, ]; const weights = new Map([ - ['Source2', 3], // 100 with weight 3 - ['Source3', 1], // 110 with weight 1 - ['Source4', 1], // 120 with weight 1 + ["Source2", 3], // 100 with weight 3 + ["Source3", 1], // 110 with weight 1 + ["Source4", 1], // 120 with weight 1 ]); // 20% trim of 5 = 1 from each end @@ -101,15 +161,20 @@ describe('TrimmedMeanAggregator', () => { expect(result).toBeCloseTo(106, 1); }); - it('should throw error for empty array', () => { + it("should throw error for empty array", () => { expect(() => { aggregator.aggregate([]); - }).toThrow('Cannot aggregate empty price array'); + }).toThrow("Cannot aggregate empty price array"); }); - it('should handle single price', () => { + it("should handle single price", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 123.45, timestamp: Date.now(), source: 'Source1' }, + { + symbol: "AAPL", + price: 123.45, + timestamp: Date.now(), + source: "Source1", + }, ]; // Falls back to average with 1 price @@ -117,12 +182,27 @@ describe('TrimmedMeanAggregator', () => { expect(result).toBe(123.45); }); - it('should handle 0% trim (equivalent to weighted average)', () => { + it("should handle 0% trim (equivalent to weighted average)", () => { const zeroTrimAggregator = new TrimmedMeanAggregator(0); const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 200, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 300, timestamp: Date.now(), source: 'Source3' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 200, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 300, + timestamp: Date.now(), + source: "Source3", + }, ]; // 0% trim = no trimming = average of all @@ -130,10 +210,10 @@ describe('TrimmedMeanAggregator', () => { expect(result).toBeCloseTo(200, 1); }); - it('should handle 40% trim (most aggressive)', () => { + it("should handle 40% trim (most aggressive)", () => { const heavyTrimAggregator = new TrimmedMeanAggregator(0.4); const prices: NormalizedPrice[] = Array.from({ length: 10 }, (_, i) => ({ - symbol: 'AAPL', + symbol: "AAPL", price: (i + 1) * 10, timestamp: Date.now(), source: `Source${i}`, @@ -147,9 +227,9 @@ describe('TrimmedMeanAggregator', () => { expect(result).toBe(55); }); - it('should handle large datasets', () => { + it("should handle large datasets", () => { const prices: NormalizedPrice[] = Array.from({ length: 100 }, (_, i) => ({ - symbol: 'AAPL', + symbol: "AAPL", price: 100 + i, timestamp: Date.now(), source: `Source${i}`, @@ -163,26 +243,72 @@ describe('TrimmedMeanAggregator', () => { expect(result).toBeCloseTo(149.5, 1); }); - it('should handle all same values', () => { + it("should handle all same values", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source2' }, - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source3' }, - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source4' }, - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source5' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source2", + }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source3", + }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source4", + }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source5", + }, ]; const result = aggregator.aggregate(prices); expect(result).toBe(100); }); - it('should respect individual price weights', () => { + it("should respect individual price weights", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 90, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source2', weight: 5 }, - { symbol: 'AAPL', price: 110, timestamp: Date.now(), source: 'Source3' }, - { symbol: 'AAPL', price: 120, timestamp: Date.now(), source: 'Source4' }, - { symbol: 'AAPL', price: 130, timestamp: Date.now(), source: 'Source5' }, + { symbol: "AAPL", price: 90, timestamp: Date.now(), source: "Source1" }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source2", + weight: 5, + }, + { + symbol: "AAPL", + price: 110, + timestamp: Date.now(), + source: "Source3", + }, + { + symbol: "AAPL", + price: 120, + timestamp: Date.now(), + source: "Source4", + }, + { + symbol: "AAPL", + price: 130, + timestamp: Date.now(), + source: "Source5", + }, ]; // 20% trim of 5 = 1 from each end diff --git a/apps/aggregator/src/strategies/aggregators/trimmed-mean.aggregator.ts b/apps/aggregator/src/strategies/aggregators/trimmed-mean.aggregator.ts index 98eee0d..6e8a568 100644 --- a/apps/aggregator/src/strategies/aggregators/trimmed-mean.aggregator.ts +++ b/apps/aggregator/src/strategies/aggregators/trimmed-mean.aggregator.ts @@ -1,23 +1,23 @@ -import { Injectable } from '@nestjs/common'; -import { IAggregator } from '../../interfaces/aggregator.interface'; -import { NormalizedPrice } from '../../interfaces/normalized-price.interface'; +import { Injectable } from "@nestjs/common"; +import { IAggregator } from "../../interfaces/aggregator.interface"; +import { NormalizedPrice } from "../../interfaces/normalized-price.interface"; /** * Trimmed Mean Aggregator - * + * * Calculates the mean after removing a percentage of the highest and * lowest values. For example, with a 20% trim, the highest 20% and * lowest 20% of prices are discarded before calculating the average. - * + * * Use case: Balanced approach between mean and median. Removes outliers * while still using most of the data. - * + * * Pros: * - Resistant to outliers (discards extremes) * - Uses more data than median * - Good balance between robustness and sensitivity * - Can be weighted after trimming - * + * * Cons: * - Requires sufficient data points to trim effectively * - Trim percentage needs careful tuning @@ -25,17 +25,17 @@ import { NormalizedPrice } from '../../interfaces/normalized-price.interface'; */ @Injectable() export class TrimmedMeanAggregator implements IAggregator { - readonly name = 'trimmed-mean'; + readonly name = "trimmed-mean"; constructor(private readonly trimPercentage: number = 0.2) { if (trimPercentage < 0 || trimPercentage >= 0.5) { - throw new Error('Trim percentage must be between 0 and 0.5'); + throw new Error("Trim percentage must be between 0 and 0.5"); } } aggregate(prices: NormalizedPrice[], weights?: Map): number { if (prices.length === 0) { - throw new Error('Cannot aggregate empty price array'); + throw new Error("Cannot aggregate empty price array"); } // Need at least 3 prices to trim meaningfully @@ -51,7 +51,10 @@ export class TrimmedMeanAggregator implements IAggregator { const trimCount = Math.floor(sortedPrices.length * this.trimPercentage); // Trim extreme values from both ends - const trimmedPrices = sortedPrices.slice(trimCount, sortedPrices.length - trimCount); + const trimmedPrices = sortedPrices.slice( + trimCount, + sortedPrices.length - trimCount, + ); // Calculate weighted average of trimmed prices return this.calculateWeightedAverage(trimmedPrices, weights); @@ -71,7 +74,7 @@ export class TrimmedMeanAggregator implements IAggregator { } if (totalWeight === 0) { - throw new Error('Total weight cannot be zero'); + throw new Error("Total weight cannot be zero"); } return weightedSum / totalWeight; diff --git a/apps/aggregator/src/strategies/aggregators/weighted-average.aggregator.spec.ts b/apps/aggregator/src/strategies/aggregators/weighted-average.aggregator.spec.ts index 9b8eede..463ca37 100644 --- a/apps/aggregator/src/strategies/aggregators/weighted-average.aggregator.spec.ts +++ b/apps/aggregator/src/strategies/aggregators/weighted-average.aggregator.spec.ts @@ -1,37 +1,57 @@ -import { WeightedAverageAggregator } from './weighted-average.aggregator'; -import { NormalizedPrice } from '../../interfaces/normalized-price.interface'; +import { WeightedAverageAggregator } from "./weighted-average.aggregator"; +import { NormalizedPrice } from "../../interfaces/normalized-price.interface"; -describe('WeightedAverageAggregator', () => { +describe("WeightedAverageAggregator", () => { let aggregator: WeightedAverageAggregator; beforeEach(() => { aggregator = new WeightedAverageAggregator(); }); - it('should have correct name', () => { - expect(aggregator.name).toBe('weighted-average'); + it("should have correct name", () => { + expect(aggregator.name).toBe("weighted-average"); }); - describe('aggregate', () => { - it('should calculate simple average with equal weights', () => { + describe("aggregate", () => { + it("should calculate simple average with equal weights", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 200, timestamp: Date.now(), source: 'Source2' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 200, + timestamp: Date.now(), + source: "Source2", + }, ]; const result = aggregator.aggregate(prices); expect(result).toBe(150); }); - it('should apply weights from map', () => { + it("should apply weights from map", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 200, timestamp: Date.now(), source: 'Source2' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 200, + timestamp: Date.now(), + source: "Source2", + }, ]; const weights = new Map([ - ['Source1', 3], - ['Source2', 1], + ["Source1", 3], + ["Source2", 1], ]); // (100*3 + 200*1) / (3+1) = 500/4 = 125 @@ -39,15 +59,26 @@ describe('WeightedAverageAggregator', () => { expect(result).toBe(125); }); - it('should use individual price weight if provided', () => { + it("should use individual price weight if provided", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1', weight: 5 }, - { symbol: 'AAPL', price: 200, timestamp: Date.now(), source: 'Source2' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + weight: 5, + }, + { + symbol: "AAPL", + price: 200, + timestamp: Date.now(), + source: "Source2", + }, ]; const weights = new Map([ - ['Source1', 1], // Should be overridden by individual weight - ['Source2', 1], + ["Source1", 1], // Should be overridden by individual weight + ["Source2", 1], ]); // (100*5 + 200*1) / (5+1) = 700/6 = 116.67 @@ -55,45 +86,72 @@ describe('WeightedAverageAggregator', () => { expect(result).toBeCloseTo(116.67, 2); }); - it('should default to weight 1 for unknown sources', () => { + it("should default to weight 1 for unknown sources", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'UnknownSource' }, - { symbol: 'AAPL', price: 200, timestamp: Date.now(), source: 'AnotherUnknown' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "UnknownSource", + }, + { + symbol: "AAPL", + price: 200, + timestamp: Date.now(), + source: "AnotherUnknown", + }, ]; const result = aggregator.aggregate(prices); expect(result).toBe(150); }); - it('should throw error for empty array', () => { + it("should throw error for empty array", () => { expect(() => { aggregator.aggregate([]); - }).toThrow('Cannot aggregate empty price array'); + }).toThrow("Cannot aggregate empty price array"); }); - it('should throw error if total weight is zero', () => { + it("should throw error if total weight is zero", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1', weight: 0 }, - { symbol: 'AAPL', price: 200, timestamp: Date.now(), source: 'Source2', weight: 0 }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + weight: 0, + }, + { + symbol: "AAPL", + price: 200, + timestamp: Date.now(), + source: "Source2", + weight: 0, + }, ]; expect(() => { aggregator.aggregate(prices); - }).toThrow('Total weight cannot be zero'); + }).toThrow("Total weight cannot be zero"); }); - it('should handle single price', () => { + it("should handle single price", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 123.45, timestamp: Date.now(), source: 'Source1' }, + { + symbol: "AAPL", + price: 123.45, + timestamp: Date.now(), + source: "Source1", + }, ]; const result = aggregator.aggregate(prices); expect(result).toBe(123.45); }); - it('should handle many prices', () => { + it("should handle many prices", () => { const prices: NormalizedPrice[] = Array.from({ length: 100 }, (_, i) => ({ - symbol: 'AAPL', + symbol: "AAPL", price: 100 + i, timestamp: Date.now(), source: `Source${i}`, @@ -104,15 +162,25 @@ describe('WeightedAverageAggregator', () => { expect(result).toBeCloseTo(149.5, 1); }); - it('should handle decimal weights', () => { + it("should handle decimal weights", () => { const prices: NormalizedPrice[] = [ - { symbol: 'AAPL', price: 100, timestamp: Date.now(), source: 'Source1' }, - { symbol: 'AAPL', price: 200, timestamp: Date.now(), source: 'Source2' }, + { + symbol: "AAPL", + price: 100, + timestamp: Date.now(), + source: "Source1", + }, + { + symbol: "AAPL", + price: 200, + timestamp: Date.now(), + source: "Source2", + }, ]; const weights = new Map([ - ['Source1', 0.25], - ['Source2', 0.75], + ["Source1", 0.25], + ["Source2", 0.75], ]); // (100*0.25 + 200*0.75) / (0.25+0.75) = 175/1 = 175 diff --git a/apps/aggregator/src/strategies/aggregators/weighted-average.aggregator.ts b/apps/aggregator/src/strategies/aggregators/weighted-average.aggregator.ts index 1381378..2c99939 100644 --- a/apps/aggregator/src/strategies/aggregators/weighted-average.aggregator.ts +++ b/apps/aggregator/src/strategies/aggregators/weighted-average.aggregator.ts @@ -1,34 +1,34 @@ -import { Injectable } from '@nestjs/common'; -import { IAggregator } from '../../interfaces/aggregator.interface'; -import { NormalizedPrice } from '../../interfaces/normalized-price.interface'; +import { Injectable } from "@nestjs/common"; +import { IAggregator } from "../../interfaces/aggregator.interface"; +import { NormalizedPrice } from "../../interfaces/normalized-price.interface"; /** * Weighted Average Aggregator - * + * * Calculates the weighted average of prices where more reliable sources * have higher weights. The formula is: - * + * * Weighted Average = Ξ£(price_i * weight_i) / Ξ£(weight_i) - * + * * Use case: When you trust certain sources more than others and want * their prices to have more influence on the final result. - * + * * Pros: * - Rewards trusted sources * - Smooth consensus price * - Good for stable markets - * + * * Cons: * - Susceptible to manipulation if a high-weight source is compromised * - Not resistant to outliers */ @Injectable() export class WeightedAverageAggregator implements IAggregator { - readonly name = 'weighted-average'; + readonly name = "weighted-average"; aggregate(prices: NormalizedPrice[], weights?: Map): number { if (prices.length === 0) { - throw new Error('Cannot aggregate empty price array'); + throw new Error("Cannot aggregate empty price array"); } let weightedSum = 0; @@ -37,13 +37,13 @@ export class WeightedAverageAggregator implements IAggregator { for (const priceData of prices) { // Use individual weight if provided, otherwise use source weight, default to 1 const weight = priceData.weight ?? weights?.get(priceData.source) ?? 1; - + weightedSum += priceData.price * weight; totalWeight += weight; } if (totalWeight === 0) { - throw new Error('Total weight cannot be zero'); + throw new Error("Total weight cannot be zero"); } return weightedSum / totalWeight; diff --git a/apps/aggregator/src/strategies/outlier-detectors/iqr-detector.ts b/apps/aggregator/src/strategies/outlier-detectors/iqr-detector.ts new file mode 100644 index 0000000..a0977f6 --- /dev/null +++ b/apps/aggregator/src/strategies/outlier-detectors/iqr-detector.ts @@ -0,0 +1,61 @@ +import { NormalizedPrice } from "../../interfaces/normalized-price.interface"; +import { OutlierReason } from "../../interfaces/outlier-result.interface"; +import { + OutlierDetector, + OutlierDetectorContext, +} from "./outlier-detector.interface"; + +export class IqrDetector implements OutlierDetector { + readonly strategy = "iqr" as const; + + detect( + prices: NormalizedPrice[], + context: OutlierDetectorContext, + ): Map { + const reasons = new Map(); + if (prices.length < context.thresholds.minSamples) { + return reasons; + } + + const values = prices.map((p) => p.price); + const sorted = values.slice().sort((a, b) => a - b); + const q1 = this.quantile(sorted, 0.25); + const q3 = this.quantile(sorted, 0.75); + const iqr = q3 - q1; + const lowerBound = q1 - context.thresholds.iqrMultiplier * iqr; + const upperBound = q3 + context.thresholds.iqrMultiplier * iqr; + + prices.forEach((price, index) => { + if (price.price < lowerBound || price.price > upperBound) { + reasons.set(index, [ + { + strategy: this.strategy, + code: "OUTSIDE_IQR_BOUNDS", + message: `Price outside IQR bounds [${lowerBound.toFixed(4)}, ${upperBound.toFixed(4)}]`, + value: price.price, + threshold: context.thresholds.iqrMultiplier, + }, + ]); + } + }); + + return reasons; + } + + private quantile(sortedValues: number[], q: number): number { + if (sortedValues.length === 0) { + return 0; + } + const pos = (sortedValues.length - 1) * q; + const base = Math.floor(pos); + const rest = pos - base; + + if (sortedValues[base + 1] !== undefined) { + return ( + sortedValues[base] + + rest * (sortedValues[base + 1] - sortedValues[base]) + ); + } + return sortedValues[base]; + } +} diff --git a/apps/aggregator/src/strategies/outlier-detectors/mad-detector.ts b/apps/aggregator/src/strategies/outlier-detectors/mad-detector.ts new file mode 100644 index 0000000..cd55138 --- /dev/null +++ b/apps/aggregator/src/strategies/outlier-detectors/mad-detector.ts @@ -0,0 +1,63 @@ +import { NormalizedPrice } from "../../interfaces/normalized-price.interface"; +import { OutlierReason } from "../../interfaces/outlier-result.interface"; +import { + OutlierDetector, + OutlierDetectorContext, +} from "./outlier-detector.interface"; + +export class MadDetector implements OutlierDetector { + readonly strategy = "mad" as const; + + detect( + prices: NormalizedPrice[], + context: OutlierDetectorContext, + ): Map { + const reasons = new Map(); + if (prices.length < context.thresholds.minSamples) { + return reasons; + } + + const values = prices.map((p) => p.price); + const median = this.median(values); + const absoluteDeviations = values.map((value) => Math.abs(value - median)); + const mad = this.median(absoluteDeviations); + if (mad === 0) { + return reasons; + } + + prices.forEach((price, index) => { + const modifiedZScore = (0.6745 * Math.abs(price.price - median)) / mad; + + if (modifiedZScore > context.thresholds.madThreshold) { + reasons.set(index, [ + { + strategy: this.strategy, + code: "MAD_SCORE_EXCEEDED", + message: + `Modified z-score ${modifiedZScore.toFixed(4)} exceeds threshold ` + + `${context.thresholds.madThreshold}`, + value: modifiedZScore, + threshold: context.thresholds.madThreshold, + }, + ]); + } + }); + + return reasons; + } + + private median(values: number[]): number { + if (values.length === 0) { + return 0; + } + + const sorted = values.slice().sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + + if (sorted.length % 2 === 0) { + return (sorted[middle - 1] + sorted[middle]) / 2; + } + + return sorted[middle]; + } +} diff --git a/apps/aggregator/src/strategies/outlier-detectors/outlier-detector.interface.ts b/apps/aggregator/src/strategies/outlier-detectors/outlier-detector.interface.ts new file mode 100644 index 0000000..c5ebaad --- /dev/null +++ b/apps/aggregator/src/strategies/outlier-detectors/outlier-detector.interface.ts @@ -0,0 +1,17 @@ +import { NormalizedPrice } from "../../interfaces/normalized-price.interface"; +import { + OutlierReason, + OutlierThresholdConfig, +} from "../../interfaces/outlier-result.interface"; + +export interface OutlierDetectorContext { + thresholds: OutlierThresholdConfig; +} + +export interface OutlierDetector { + readonly strategy: OutlierReason["strategy"]; + detect( + prices: NormalizedPrice[], + context: OutlierDetectorContext, + ): Map; +} diff --git a/apps/aggregator/src/strategies/outlier-detectors/range-detector.ts b/apps/aggregator/src/strategies/outlier-detectors/range-detector.ts new file mode 100644 index 0000000..b26879e --- /dev/null +++ b/apps/aggregator/src/strategies/outlier-detectors/range-detector.ts @@ -0,0 +1,90 @@ +import { NormalizedPrice } from "../../interfaces/normalized-price.interface"; +import { OutlierReason } from "../../interfaces/outlier-result.interface"; +import { + OutlierDetector, + OutlierDetectorContext, +} from "./outlier-detector.interface"; + +export class RangeDetector implements OutlierDetector { + readonly strategy = "range" as const; + + detect( + prices: NormalizedPrice[], + context: OutlierDetectorContext, + ): Map { + const reasons = new Map(); + + prices.forEach((price, index) => { + if (price.price <= context.thresholds.minPrice) { + this.addReason(reasons, index, { + strategy: this.strategy, + code: "NON_POSITIVE_PRICE", + message: `Price must be greater than ${context.thresholds.minPrice}`, + value: price.price, + threshold: context.thresholds.minPrice, + }); + } + }); + + const bySource = new Map< + string, + Array<{ index: number; item: NormalizedPrice }> + >(); + + prices.forEach((item, index) => { + const list = bySource.get(item.source) ?? []; + list.push({ index, item }); + bySource.set(item.source, list); + }); + + for (const entries of bySource.values()) { + const sorted = entries + .slice() + .sort((a, b) => a.item.timestamp - b.item.timestamp); + + for (let i = 1; i < sorted.length; i++) { + const previous = sorted[i - 1]; + const current = sorted[i]; + + if (previous.item.price <= 0) { + continue; + } + + const seconds = + Math.abs(current.item.timestamp - previous.item.timestamp) / 1000; + if (seconds > context.thresholds.maxChangeWindowSeconds) { + continue; + } + + const pctChange = + (Math.abs(current.item.price - previous.item.price) / + previous.item.price) * + 100; + + if (pctChange > context.thresholds.maxChangePercent) { + this.addReason(reasons, current.index, { + strategy: this.strategy, + code: "PRICE_CHANGE_TOO_LARGE", + message: + `Price changed ${pctChange.toFixed(2)}% in ${seconds.toFixed(2)}s ` + + `(max ${context.thresholds.maxChangePercent}%)`, + value: pctChange, + threshold: context.thresholds.maxChangePercent, + }); + } + } + } + + return reasons; + } + + private addReason( + reasons: Map, + index: number, + reason: OutlierReason, + ): void { + const existing = reasons.get(index) ?? []; + existing.push(reason); + reasons.set(index, existing); + } +} diff --git a/apps/aggregator/src/strategies/outlier-detectors/zscore-detector.ts b/apps/aggregator/src/strategies/outlier-detectors/zscore-detector.ts new file mode 100644 index 0000000..6b371a3 --- /dev/null +++ b/apps/aggregator/src/strategies/outlier-detectors/zscore-detector.ts @@ -0,0 +1,47 @@ +import { NormalizedPrice } from "../../interfaces/normalized-price.interface"; +import { OutlierReason } from "../../interfaces/outlier-result.interface"; +import { + OutlierDetector, + OutlierDetectorContext, +} from "./outlier-detector.interface"; + +export class ZscoreDetector implements OutlierDetector { + readonly strategy = "zscore" as const; + + detect( + prices: NormalizedPrice[], + context: OutlierDetectorContext, + ): Map { + const reasons = new Map(); + if (prices.length < context.thresholds.minSamples) { + return reasons; + } + + const values = prices.map((p) => p.price); + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + const variance = + values.reduce((sum, value) => sum + Math.pow(value - mean, 2), 0) / + values.length; + const stdDev = Math.sqrt(variance); + if (stdDev === 0) { + return reasons; + } + + prices.forEach((price, index) => { + const zScore = Math.abs((price.price - mean) / stdDev); + if (zScore > context.thresholds.zScoreThreshold) { + reasons.set(index, [ + { + strategy: this.strategy, + code: "ZSCORE_EXCEEDED", + message: `Absolute z-score ${zScore.toFixed(4)} exceeds threshold ${context.thresholds.zScoreThreshold}`, + value: zScore, + threshold: context.thresholds.zScoreThreshold, + }, + ]); + } + }); + + return reasons; + } +} diff --git a/apps/aggregator/tsconfig.json b/apps/aggregator/tsconfig.json index 38739b7..63f60c6 100644 --- a/apps/aggregator/tsconfig.json +++ b/apps/aggregator/tsconfig.json @@ -18,7 +18,9 @@ "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, "paths": { - "@/*": ["src/*"] + "@/*": ["src/*"], + "@oracle-stocks/shared": ["../../packages/shared/src/index.ts"], + "@oracle-stocks/shared/*": ["../../packages/shared/src/*"] } } } diff --git a/apps/aggregator/tsconfig.spec.json b/apps/aggregator/tsconfig.spec.json new file mode 100644 index 0000000..98fb7fc --- /dev/null +++ b/apps/aggregator/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@/*": ["src/*"], + "@oracle-stocks/shared": ["../../packages/shared/src/index.ts"], + "@oracle-stocks/shared/*": ["../../packages/shared/src/*"] + } + } +} diff --git a/apps/api/README.md b/apps/api/README.md index f0aa5f6..761e17d 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -5,6 +5,7 @@ Exposes REST/WebSocket endpoints for accessing signed stock price data. ## Overview The API service is responsible for: + - Exposing REST endpoints for querying current and historical prices - Providing WebSocket subscriptions for real-time price updates - Serving signed price proofs for verification @@ -98,6 +99,7 @@ GET /health Returns service health status and timestamp. **Response:** + ```json { "status": "ok", diff --git a/apps/api/src/app.controller.spec.ts b/apps/api/src/app.controller.spec.ts index 349d608..7782013 100644 --- a/apps/api/src/app.controller.spec.ts +++ b/apps/api/src/app.controller.spec.ts @@ -1,8 +1,8 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { AppController } from "./app.controller"; +import { AppService } from "./app.service"; -describe('AppController', () => { +describe("AppController", () => { let controller: AppController; beforeEach(async () => { @@ -14,17 +14,17 @@ describe('AppController', () => { controller = module.get(AppController); }); - it('should be defined', () => { + it("should be defined", () => { expect(controller).toBeDefined(); }); - it('should return hello message', () => { - expect(controller.getHello()).toBe('Oracle Stock Price API'); + it("should return hello message", () => { + expect(controller.getHello()).toBe("Oracle Stock Price API"); }); - it('should return health status', () => { + it("should return health status", () => { const health = controller.getHealth(); - expect(health.status).toBe('ok'); + expect(health.status).toBe("ok"); expect(health.timestamp).toBeGreaterThan(0); }); }); diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts index 67fee12..91f3089 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Get } from '@nestjs/common'; -import { AppService } from './app.service'; +import { Controller, Get } from "@nestjs/common"; +import { AppService } from "./app.service"; @Controller() export class AppController { @@ -10,7 +10,7 @@ export class AppController { return this.appService.getHello(); } - @Get('health') + @Get("health") getHealth(): { status: string; timestamp: number } { return this.appService.getHealth(); } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 8662803..e93d824 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,6 +1,6 @@ -import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { Module } from "@nestjs/common"; +import { AppController } from "./app.controller"; +import { AppService } from "./app.service"; @Module({ imports: [], diff --git a/apps/api/src/app.service.spec.ts b/apps/api/src/app.service.spec.ts index ff6ef8b..72c2608 100644 --- a/apps/api/src/app.service.spec.ts +++ b/apps/api/src/app.service.spec.ts @@ -1,7 +1,7 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppService } from './app.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { AppService } from "./app.service"; -describe('AppService', () => { +describe("AppService", () => { let service: AppService; beforeEach(async () => { @@ -12,17 +12,17 @@ describe('AppService', () => { service = module.get(AppService); }); - it('should be defined', () => { + it("should be defined", () => { expect(service).toBeDefined(); }); - it('should return hello message', () => { - expect(service.getHello()).toBe('Oracle Stock Price API'); + it("should return hello message", () => { + expect(service.getHello()).toBe("Oracle Stock Price API"); }); - it('should return health status', () => { + it("should return health status", () => { const health = service.getHealth(); - expect(health.status).toBe('ok'); + expect(health.status).toBe("ok"); expect(health.timestamp).toBeGreaterThan(0); }); }); diff --git a/apps/api/src/app.service.ts b/apps/api/src/app.service.ts index 26f0f92..84ad50b 100644 --- a/apps/api/src/app.service.ts +++ b/apps/api/src/app.service.ts @@ -1,14 +1,14 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable } from "@nestjs/common"; @Injectable() export class AppService { getHello(): string { - return 'Oracle Stock Price API'; + return "Oracle Stock Price API"; } getHealth(): { status: string; timestamp: number } { return { - status: 'ok', + status: "ok", timestamp: Date.now(), }; } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 85c5e2b..715457b 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,10 +1,10 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "./app.module"; async function bootstrap() { const app = await NestFactory.create(AppModule); const port = process.env.PORT || 3002; - + await app.listen(port); console.log(`API Publisher service is running on: http://localhost:${port}`); } diff --git a/apps/frontend/README.md b/apps/frontend/README.md index 4445dbf..bd4a44b 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -5,6 +5,7 @@ UI for visualizing stock price feeds and oracle status. ## Overview The frontend application provides: + - Real-time visualization of stock price feeds - Oracle health and status dashboard - Historical price charts diff --git a/apps/frontend/app/layout.tsx b/apps/frontend/app/layout.tsx index 50c406d..a5707be 100644 --- a/apps/frontend/app/layout.tsx +++ b/apps/frontend/app/layout.tsx @@ -1,9 +1,9 @@ -import type { Metadata } from 'next'; -import './globals.css'; +import type { Metadata } from "next"; +import "./globals.css"; export const metadata: Metadata = { - title: 'Oracle Stock Price Dashboard', - description: 'Real-time visualization of stock price feeds and oracle status', + title: "Oracle Stock Price Dashboard", + description: "Real-time visualization of stock price feeds and oracle status", }; export default function RootLayout({ diff --git a/apps/frontend/app/page.test.tsx b/apps/frontend/app/page.test.tsx index 7b80ea1..bcd63ba 100644 --- a/apps/frontend/app/page.test.tsx +++ b/apps/frontend/app/page.test.tsx @@ -1,9 +1,11 @@ -import { render, screen } from '@testing-library/react'; -import Home from './page'; +import { render, screen } from "@testing-library/react"; +import Home from "./page"; -describe('Home Page', () => { - it('renders the home page', () => { +describe("Home Page", () => { + it("renders the home page", () => { render(); - expect(screen.getByText(/Oracle Stock Price Dashboard/i)).toBeInTheDocument(); + expect( + screen.getByText(/Oracle Stock Price Dashboard/i), + ).toBeInTheDocument(); }); }); diff --git a/apps/frontend/jest.setup.d.ts b/apps/frontend/jest.setup.d.ts index 7b0828b..d0de870 100644 --- a/apps/frontend/jest.setup.d.ts +++ b/apps/frontend/jest.setup.d.ts @@ -1 +1 @@ -import '@testing-library/jest-dom'; +import "@testing-library/jest-dom"; diff --git a/apps/ingestor/README.md b/apps/ingestor/README.md index fc76ee0..403683f 100644 --- a/apps/ingestor/README.md +++ b/apps/ingestor/README.md @@ -51,12 +51,12 @@ FETCH_INTERVAL_MS=60000 STOCK_SYMBOLS=AAPL,GOOGL,MSFT,TSLA ``` -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `PORT` | No | `3000` | HTTP server port | -| `FINNHUB_API_KEY` | **Yes** | β€” | API key from Finnhub | -| `FETCH_INTERVAL_MS` | No | `60000` | Polling interval in milliseconds | -| `STOCK_SYMBOLS` | No | β€” | Comma-separated list of symbols to fetch | +| Variable | Required | Default | Description | +| ------------------- | -------- | ------- | ---------------------------------------- | +| `PORT` | No | `3000` | HTTP server port | +| `FINNHUB_API_KEY` | **Yes** | β€” | API key from Finnhub | +| `FETCH_INTERVAL_MS` | No | `60000` | Polling interval in milliseconds | +| `STOCK_SYMBOLS` | No | β€” | Comma-separated list of symbols to fetch | ### Running the Service @@ -112,19 +112,19 @@ curl http://localhost:3000/prices/AAPL { "source": "Finnhub", "symbol": "AAPL", - "price": 150.20, + "price": 150.2, "timestamp": 1706540400000 } ``` **Response Fields:** -| Field | Type | Description | -|-------------|----------|----------------------------------------| -| `source` | `string` | Data provider identifier | -| `symbol` | `string` | Stock ticker symbol (uppercase) | -| `price` | `number` | Current price in USD | -| `timestamp` | `number` | Unix timestamp in milliseconds | +| Field | Type | Description | +| ----------- | -------- | ------------------------------- | +| `source` | `string` | Data provider identifier | +| `symbol` | `string` | Stock ticker symbol (uppercase) | +| `price` | `number` | Current price in USD | +| `timestamp` | `number` | Unix timestamp in milliseconds | ## Architecture @@ -145,6 +145,7 @@ curl http://localhost:3000/prices/AAPL ``` **Flows:** + - **Scheduled:** The `SchedulerService` triggers `PriceFetcherService` at a configurable interval to fetch all configured symbols automatically. - **On-Demand:** REST endpoints allow fetching a specific symbol's price via HTTP. diff --git a/apps/ingestor/src/app.module.ts b/apps/ingestor/src/app.module.ts index 80bcf7b..5c0dd7c 100644 --- a/apps/ingestor/src/app.module.ts +++ b/apps/ingestor/src/app.module.ts @@ -1,13 +1,13 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { ScheduleModule } from '@nestjs/schedule'; -import { PricesModule } from './modules/prices.module'; +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { ScheduleModule } from "@nestjs/schedule"; +import { PricesModule } from "./modules/prices.module"; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - envFilePath: '.env', + envFilePath: ".env", }), ScheduleModule.forRoot(), PricesModule, diff --git a/apps/ingestor/src/controllers/prices.controller.ts b/apps/ingestor/src/controllers/prices.controller.ts index b01b8f3..d9875bb 100644 --- a/apps/ingestor/src/controllers/prices.controller.ts +++ b/apps/ingestor/src/controllers/prices.controller.ts @@ -1,16 +1,16 @@ -import { Controller, Get, Logger } from '@nestjs/common'; -import { RawPrice } from '@oracle-stocks/shared'; -import { PriceFetcherService } from '../services/price-fetcher.service'; +import { Controller, Get, Logger } from "@nestjs/common"; +import { RawPrice } from "@oracle-stocks/shared"; +import { PriceFetcherService } from "../services/price-fetcher.service"; -@Controller('prices') +@Controller("prices") export class PricesController { private readonly logger = new Logger(PricesController.name); constructor(private readonly priceFetcherService: PriceFetcherService) {} - @Get('raw') + @Get("raw") async getRawPrices(): Promise { - this.logger.log('GET /prices/raw endpoint called'); + this.logger.log("GET /prices/raw endpoint called"); await this.priceFetcherService.fetchRawPrices(); const rawPrices = this.priceFetcherService.getRawPrices(); this.logger.log(`Returning ${rawPrices.length} raw prices`); diff --git a/apps/ingestor/src/main.ts b/apps/ingestor/src/main.ts index 771595c..09c1b3a 100644 --- a/apps/ingestor/src/main.ts +++ b/apps/ingestor/src/main.ts @@ -1,10 +1,10 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "./app.module"; async function bootstrap() { const app = await NestFactory.create(AppModule); const port = process.env.PORT || 3000; - + await app.listen(port); console.log(`Ingestor service is running on: http://localhost:${port}`); } diff --git a/apps/ingestor/src/modules/prices.module.ts b/apps/ingestor/src/modules/prices.module.ts index a11e5a8..4eced06 100644 --- a/apps/ingestor/src/modules/prices.module.ts +++ b/apps/ingestor/src/modules/prices.module.ts @@ -1,18 +1,18 @@ -import { Module } from '@nestjs/common'; -import { PricesController } from '../controllers/prices.controller'; -import { PriceFetcherService } from '../services/price-fetcher.service'; -import { StockService } from '../services/stock.service'; -import { FinnhubAdapter } from '../providers/finnhub.adapter'; -import { SchedulerService } from '../services/scheduler.service'; +import { Module } from "@nestjs/common"; +import { PricesController } from "../controllers/prices.controller"; +import { PriceFetcherService } from "../services/price-fetcher.service"; +import { StockService } from "../services/stock.service"; +import { FinnhubAdapter } from "../providers/finnhub.adapter"; +import { SchedulerService } from "../services/scheduler.service"; @Module({ controllers: [PricesController], providers: [ - PriceFetcherService, - StockService, + PriceFetcherService, + StockService, FinnhubAdapter, - SchedulerService + SchedulerService, ], - exports: [PriceFetcherService, StockService, SchedulerService], + exports: [PriceFetcherService, StockService, SchedulerService], }) export class PricesModule {} diff --git a/apps/ingestor/src/providers/finnhub.adapter.ts b/apps/ingestor/src/providers/finnhub.adapter.ts index 4ffca9e..c904dcb 100644 --- a/apps/ingestor/src/providers/finnhub.adapter.ts +++ b/apps/ingestor/src/providers/finnhub.adapter.ts @@ -1,33 +1,31 @@ -import { Injectable } from '@nestjs/common'; -import { NormalizedStockPrice } from '@oracle-stocks/shared'; +import { Injectable } from "@nestjs/common"; +import { NormalizedStockPrice } from "@oracle-stocks/shared"; @Injectable() export class FinnhubAdapter { - private readonly baseUrl = 'https://finnhub.io/api/v1'; + private readonly baseUrl = "https://finnhub.io/api/v1"; private readonly apiKey = process.env.FINNHUB_API_KEY; async getLatestPrice(symbol: string): Promise { const response = await fetch( - `${this.baseUrl}/quote?symbol=${symbol.toUpperCase()}&token=${this.apiKey}` + `${this.baseUrl}/quote?symbol=${symbol.toUpperCase()}&token=${this.apiKey}`, ); if (!response.ok) { throw new Error(`Finnhub API error: ${response.statusText}`); } - const data = await response.json(); - if (!data.c || data.c === 0) { throw new Error(`No se encontrΓ³ precio real para el sΓ­mbolo: ${symbol}`); } return { - source: 'Finnhub', + source: "Finnhub", symbol: symbol.toUpperCase(), price: Number(data.c), timestamp: Date.now(), }; } -} \ No newline at end of file +} diff --git a/apps/ingestor/src/providers/index.ts b/apps/ingestor/src/providers/index.ts index 44050cd..d9612ae 100644 --- a/apps/ingestor/src/providers/index.ts +++ b/apps/ingestor/src/providers/index.ts @@ -1,2 +1,2 @@ -export { PriceProvider } from './provider.interface'; -export { MockProvider } from './mock.provider'; +export { PriceProvider } from "./provider.interface"; +export { MockProvider } from "./mock.provider"; diff --git a/apps/ingestor/src/providers/mock.provider.ts b/apps/ingestor/src/providers/mock.provider.ts index 985620e..7840435 100644 --- a/apps/ingestor/src/providers/mock.provider.ts +++ b/apps/ingestor/src/providers/mock.provider.ts @@ -1,11 +1,11 @@ -import { RawPrice } from '@oracle-stocks/shared'; -import { PriceProvider } from './provider.interface'; +import { RawPrice } from "@oracle-stocks/shared"; +import { PriceProvider } from "./provider.interface"; export class MockProvider implements PriceProvider { - readonly name = 'MockProvider'; + readonly name = "MockProvider"; async fetchPrices(symbols: string[]): Promise { - return symbols.map(symbol => ({ + return symbols.map((symbol) => ({ symbol, price: this.generateMockPrice(), timestamp: Date.now(), diff --git a/apps/ingestor/src/providers/provider.interface.ts b/apps/ingestor/src/providers/provider.interface.ts index d2f2ef1..084ac56 100644 --- a/apps/ingestor/src/providers/provider.interface.ts +++ b/apps/ingestor/src/providers/provider.interface.ts @@ -1,4 +1,4 @@ -import { RawPrice } from '@oracle-stocks/shared'; +import { RawPrice } from "@oracle-stocks/shared"; export interface PriceProvider { readonly name: string; diff --git a/apps/ingestor/src/services/price-fetcher.service.spec.ts b/apps/ingestor/src/services/price-fetcher.service.spec.ts index 5aea313..d03b941 100644 --- a/apps/ingestor/src/services/price-fetcher.service.spec.ts +++ b/apps/ingestor/src/services/price-fetcher.service.spec.ts @@ -1,8 +1,8 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { PriceFetcherService } from './price-fetcher.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { PriceFetcherService } from "./price-fetcher.service"; -describe('PriceFetcherService', () => { +describe("PriceFetcherService", () => { let service: PriceFetcherService; let configService: jest.Mocked; @@ -10,7 +10,7 @@ describe('PriceFetcherService', () => { const mockConfigService = { get: jest.fn((key: string, defaultValue?: unknown) => { const config: Record = { - STOCK_SYMBOLS: 'AAPL,GOOGL,MSFT', + STOCK_SYMBOLS: "AAPL,GOOGL,MSFT", }; return config[key] ?? defaultValue; }), @@ -27,44 +27,47 @@ describe('PriceFetcherService', () => { configService = module.get(ConfigService); }); - describe('constructor', () => { - it('should be defined', () => { + describe("constructor", () => { + it("should be defined", () => { expect(service).toBeDefined(); }); - it('should read symbols from config', () => { - expect(configService.get).toHaveBeenCalledWith('STOCK_SYMBOLS', 'AAPL,GOOGL,MSFT,TSLA'); + it("should read symbols from config", () => { + expect(configService.get).toHaveBeenCalledWith( + "STOCK_SYMBOLS", + "AAPL,GOOGL,MSFT,TSLA", + ); }); - it('should parse symbols correctly', () => { - expect(service.getSymbols()).toEqual(['AAPL', 'GOOGL', 'MSFT']); + it("should parse symbols correctly", () => { + expect(service.getSymbols()).toEqual(["AAPL", "GOOGL", "MSFT"]); }); }); - describe('fetchRawPrices', () => { - it('should fetch prices for all configured symbols', async () => { + describe("fetchRawPrices", () => { + it("should fetch prices for all configured symbols", async () => { const prices = await service.fetchRawPrices(); expect(prices).toHaveLength(3); // One price per symbol from MockProvider - expect(prices.map(p => p.symbol)).toEqual(['AAPL', 'GOOGL', 'MSFT']); + expect(prices.map((p) => p.symbol)).toEqual(["AAPL", "GOOGL", "MSFT"]); }); - it('should return prices with correct structure', async () => { + it("should return prices with correct structure", async () => { const prices = await service.fetchRawPrices(); - prices.forEach(price => { - expect(price).toHaveProperty('symbol'); - expect(price).toHaveProperty('price'); - expect(price).toHaveProperty('timestamp'); - expect(price).toHaveProperty('source'); - expect(typeof price.symbol).toBe('string'); - expect(typeof price.price).toBe('number'); - expect(typeof price.timestamp).toBe('number'); - expect(price.source).toBe('MockProvider'); + prices.forEach((price) => { + expect(price).toHaveProperty("symbol"); + expect(price).toHaveProperty("price"); + expect(price).toHaveProperty("timestamp"); + expect(price).toHaveProperty("source"); + expect(typeof price.symbol).toBe("string"); + expect(typeof price.price).toBe("number"); + expect(typeof price.timestamp).toBe("number"); + expect(price.source).toBe("MockProvider"); }); }); - it('should store fetched prices', async () => { + it("should store fetched prices", async () => { expect(service.getRawPrices()).toHaveLength(0); await service.fetchRawPrices(); @@ -73,12 +76,12 @@ describe('PriceFetcherService', () => { }); }); - describe('getRawPrices', () => { - it('should return empty array initially', () => { + describe("getRawPrices", () => { + it("should return empty array initially", () => { expect(service.getRawPrices()).toEqual([]); }); - it('should return fetched prices', async () => { + it("should return fetched prices", async () => { await service.fetchRawPrices(); const prices = service.getRawPrices(); @@ -86,17 +89,17 @@ describe('PriceFetcherService', () => { }); }); - describe('getSymbols', () => { - it('should return configured symbols', () => { + describe("getSymbols", () => { + it("should return configured symbols", () => { const symbols = service.getSymbols(); expect(Array.isArray(symbols)).toBe(true); - expect(symbols).toContain('AAPL'); - expect(symbols).toContain('GOOGL'); - expect(symbols).toContain('MSFT'); + expect(symbols).toContain("AAPL"); + expect(symbols).toContain("GOOGL"); + expect(symbols).toContain("MSFT"); }); - it('should return a copy of symbols array', () => { + it("should return a copy of symbols array", () => { const symbols1 = service.getSymbols(); const symbols2 = service.getSymbols(); @@ -105,12 +108,12 @@ describe('PriceFetcherService', () => { }); }); - describe('symbol parsing', () => { - it('should handle symbols with extra whitespace', async () => { + describe("symbol parsing", () => { + it("should handle symbols with extra whitespace", async () => { const mockConfigWithSpaces = { get: jest.fn((key: string, defaultValue?: unknown) => { - if (key === 'STOCK_SYMBOLS') { - return ' AAPL , GOOGL , MSFT '; + if (key === "STOCK_SYMBOLS") { + return " AAPL , GOOGL , MSFT "; } return defaultValue; }), @@ -123,15 +126,16 @@ describe('PriceFetcherService', () => { ], }).compile(); - const serviceWithSpaces = module.get(PriceFetcherService); - expect(serviceWithSpaces.getSymbols()).toEqual(['AAPL', 'GOOGL', 'MSFT']); + const serviceWithSpaces = + module.get(PriceFetcherService); + expect(serviceWithSpaces.getSymbols()).toEqual(["AAPL", "GOOGL", "MSFT"]); }); - it('should filter out empty symbols', async () => { + it("should filter out empty symbols", async () => { const mockConfigWithEmpty = { get: jest.fn((key: string, defaultValue?: unknown) => { - if (key === 'STOCK_SYMBOLS') { - return 'AAPL,,GOOGL,,,MSFT'; + if (key === "STOCK_SYMBOLS") { + return "AAPL,,GOOGL,,,MSFT"; } return defaultValue; }), @@ -144,11 +148,12 @@ describe('PriceFetcherService', () => { ], }).compile(); - const serviceWithEmpty = module.get(PriceFetcherService); - expect(serviceWithEmpty.getSymbols()).toEqual(['AAPL', 'GOOGL', 'MSFT']); + const serviceWithEmpty = + module.get(PriceFetcherService); + expect(serviceWithEmpty.getSymbols()).toEqual(["AAPL", "GOOGL", "MSFT"]); }); - it('should use default symbols when env var is not set', async () => { + it("should use default symbols when env var is not set", async () => { const mockConfigDefault = { get: jest.fn((key: string, defaultValue?: unknown) => defaultValue), }; @@ -160,8 +165,14 @@ describe('PriceFetcherService', () => { ], }).compile(); - const serviceDefault = module.get(PriceFetcherService); - expect(serviceDefault.getSymbols()).toEqual(['AAPL', 'GOOGL', 'MSFT', 'TSLA']); + const serviceDefault = + module.get(PriceFetcherService); + expect(serviceDefault.getSymbols()).toEqual([ + "AAPL", + "GOOGL", + "MSFT", + "TSLA", + ]); }); }); }); diff --git a/apps/ingestor/src/services/price-fetcher.service.ts b/apps/ingestor/src/services/price-fetcher.service.ts index e620ce1..10a0b84 100644 --- a/apps/ingestor/src/services/price-fetcher.service.ts +++ b/apps/ingestor/src/services/price-fetcher.service.ts @@ -1,7 +1,7 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { RawPrice } from '@oracle-stocks/shared'; -import { PriceProvider, MockProvider } from '../providers'; +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { RawPrice } from "@oracle-stocks/shared"; +import { PriceProvider, MockProvider } from "../providers"; @Injectable() export class PriceFetcherService { @@ -13,22 +13,29 @@ export class PriceFetcherService { constructor(private readonly configService: ConfigService) { this.providers.push(new MockProvider()); - const symbolsEnv = this.configService.get('STOCK_SYMBOLS', 'AAPL,GOOGL,MSFT,TSLA'); + const symbolsEnv = this.configService.get( + "STOCK_SYMBOLS", + "AAPL,GOOGL,MSFT,TSLA", + ); this.symbols = symbolsEnv - .split(',') - .map(s => s.trim()) - .filter(s => s.length > 0); + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); - this.logger.log(`Configured symbols: ${this.symbols.join(', ')}`); + this.logger.log(`Configured symbols: ${this.symbols.join(", ")}`); } async fetchRawPrices(): Promise { - const pricePromises = this.providers.map(provider => provider.fetchPrices(this.symbols)); + const pricePromises = this.providers.map((provider) => + provider.fetchPrices(this.symbols), + ); const results = await Promise.all(pricePromises); this.rawPrices = results.flat(); - this.logger.log(`Fetched ${this.rawPrices.length} raw prices from ${this.providers.length} provider(s)`); - this.rawPrices.forEach(price => { + this.logger.log( + `Fetched ${this.rawPrices.length} raw prices from ${this.providers.length} provider(s)`, + ); + this.rawPrices.forEach((price) => { this.logger.debug( `${price.source} - ${price.symbol}: $${price.price.toFixed(2)} at ${new Date(price.timestamp).toISOString()}`, ); diff --git a/apps/ingestor/src/services/scheduler.service.spec.ts b/apps/ingestor/src/services/scheduler.service.spec.ts index 9779960..752efe3 100644 --- a/apps/ingestor/src/services/scheduler.service.spec.ts +++ b/apps/ingestor/src/services/scheduler.service.spec.ts @@ -1,30 +1,40 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { SchedulerService } from './scheduler.service'; -import { PriceFetcherService } from './price-fetcher.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { SchedulerService } from "./scheduler.service"; +import { PriceFetcherService } from "./price-fetcher.service"; -describe('SchedulerService', () => { +describe("SchedulerService", () => { let service: SchedulerService; let priceFetcherService: jest.Mocked; let configService: jest.Mocked; const mockPrices = [ - { symbol: 'AAPL', price: 150.25, timestamp: Date.now(), source: 'MockProvider' }, - { symbol: 'GOOGL', price: 2800.5, timestamp: Date.now(), source: 'MockProvider' }, + { + symbol: "AAPL", + price: 150.25, + timestamp: Date.now(), + source: "MockProvider", + }, + { + symbol: "GOOGL", + price: 2800.5, + timestamp: Date.now(), + source: "MockProvider", + }, ]; beforeEach(async () => { const mockPriceFetcherService = { fetchRawPrices: jest.fn().mockResolvedValue(mockPrices), getRawPrices: jest.fn().mockReturnValue(mockPrices), - getSymbols: jest.fn().mockReturnValue(['AAPL', 'GOOGL']), + getSymbols: jest.fn().mockReturnValue(["AAPL", "GOOGL"]), }; const mockConfigService = { get: jest.fn((key: string, defaultValue?: unknown) => { const config: Record = { FETCH_INTERVAL_MS: 1000, - STOCK_SYMBOLS: 'AAPL,GOOGL', + STOCK_SYMBOLS: "AAPL,GOOGL", }; return config[key] ?? defaultValue; }), @@ -48,47 +58,55 @@ describe('SchedulerService', () => { jest.clearAllMocks(); }); - describe('constructor', () => { - it('should be defined', () => { + describe("constructor", () => { + it("should be defined", () => { expect(service).toBeDefined(); }); - it('should read fetch interval from config', () => { - expect(configService.get).toHaveBeenCalledWith('FETCH_INTERVAL_MS', 60000); + it("should read fetch interval from config", () => { + expect(configService.get).toHaveBeenCalledWith( + "FETCH_INTERVAL_MS", + 60000, + ); expect(service.getIntervalMs()).toBe(1000); }); }); - describe('startScheduler', () => { - it('should start the scheduler and execute fetch immediately', async () => { + describe("startScheduler", () => { + it("should start the scheduler and execute fetch immediately", async () => { service.startScheduler(); expect(service.isSchedulerRunning()).toBe(true); // Wait for immediate fetch - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)); expect(priceFetcherService.fetchRawPrices).toHaveBeenCalled(); }); - it('should not start if already running', () => { + it("should not start if already running", () => { service.startScheduler(); - const firstCallCount = priceFetcherService.fetchRawPrices.mock.calls.length; + const firstCallCount = + priceFetcherService.fetchRawPrices.mock.calls.length; service.startScheduler(); // Should not trigger another immediate fetch - expect(priceFetcherService.fetchRawPrices.mock.calls.length).toBe(firstCallCount); + expect(priceFetcherService.fetchRawPrices.mock.calls.length).toBe( + firstCallCount, + ); }); - it('should execute periodic fetches', async () => { + it("should execute periodic fetches", async () => { service.startScheduler(); // Wait for initial fetch + one interval - await new Promise(resolve => setTimeout(resolve, 1100)); - expect(priceFetcherService.fetchRawPrices.mock.calls.length).toBeGreaterThanOrEqual(2); + await new Promise((resolve) => setTimeout(resolve, 1100)); + expect( + priceFetcherService.fetchRawPrices.mock.calls.length, + ).toBeGreaterThanOrEqual(2); }); }); - describe('stopScheduler', () => { - it('should stop the scheduler', () => { + describe("stopScheduler", () => { + it("should stop the scheduler", () => { service.startScheduler(); expect(service.isSchedulerRunning()).toBe(true); @@ -96,52 +114,54 @@ describe('SchedulerService', () => { expect(service.isSchedulerRunning()).toBe(false); }); - it('should handle stopping when not running', () => { + it("should handle stopping when not running", () => { expect(() => service.stopScheduler()).not.toThrow(); }); }); - describe('isSchedulerRunning', () => { - it('should return false initially', () => { + describe("isSchedulerRunning", () => { + it("should return false initially", () => { expect(service.isSchedulerRunning()).toBe(false); }); - it('should return true when running', () => { + it("should return true when running", () => { service.startScheduler(); expect(service.isSchedulerRunning()).toBe(true); }); }); - describe('getIntervalMs', () => { - it('should return configured interval', () => { + describe("getIntervalMs", () => { + it("should return configured interval", () => { expect(service.getIntervalMs()).toBe(1000); }); }); - describe('error handling', () => { - it('should handle fetch errors gracefully', async () => { - priceFetcherService.fetchRawPrices.mockRejectedValueOnce(new Error('Fetch failed')); + describe("error handling", () => { + it("should handle fetch errors gracefully", async () => { + priceFetcherService.fetchRawPrices.mockRejectedValueOnce( + new Error("Fetch failed"), + ); service.startScheduler(); - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)); // Should not throw, scheduler should still be running expect(service.isSchedulerRunning()).toBe(true); }); }); - describe('onModuleInit', () => { - it('should start scheduler on module init', () => { - const startSpy = jest.spyOn(service, 'startScheduler'); + describe("onModuleInit", () => { + it("should start scheduler on module init", () => { + const startSpy = jest.spyOn(service, "startScheduler"); service.onModuleInit(); expect(startSpy).toHaveBeenCalled(); }); }); - describe('onModuleDestroy', () => { - it('should stop scheduler on module destroy', () => { + describe("onModuleDestroy", () => { + it("should stop scheduler on module destroy", () => { service.startScheduler(); - const stopSpy = jest.spyOn(service, 'stopScheduler'); + const stopSpy = jest.spyOn(service, "stopScheduler"); service.onModuleDestroy(); expect(stopSpy).toHaveBeenCalled(); }); diff --git a/apps/ingestor/src/services/scheduler.service.ts b/apps/ingestor/src/services/scheduler.service.ts index f62b944..9cd699a 100644 --- a/apps/ingestor/src/services/scheduler.service.ts +++ b/apps/ingestor/src/services/scheduler.service.ts @@ -1,6 +1,11 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { PriceFetcherService } from './price-fetcher.service'; +import { + Injectable, + Logger, + OnModuleInit, + OnModuleDestroy, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PriceFetcherService } from "./price-fetcher.service"; @Injectable() export class SchedulerService implements OnModuleInit, OnModuleDestroy { @@ -13,7 +18,10 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { private readonly configService: ConfigService, private readonly priceFetcherService: PriceFetcherService, ) { - this.fetchIntervalMs = this.configService.get('FETCH_INTERVAL_MS', 60000); + this.fetchIntervalMs = this.configService.get( + "FETCH_INTERVAL_MS", + 60000, + ); } onModuleInit(): void { @@ -26,11 +34,13 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { startScheduler(): void { if (this.isRunning) { - this.logger.warn('Scheduler is already running'); + this.logger.warn("Scheduler is already running"); return; } - this.logger.log(`Starting price fetch scheduler with interval: ${this.fetchIntervalMs}ms`); + this.logger.log( + `Starting price fetch scheduler with interval: ${this.fetchIntervalMs}ms`, + ); // Execute immediately on startup this.executeFetch(); @@ -41,7 +51,7 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { }, this.fetchIntervalMs); this.isRunning = true; - this.logger.log('Price fetch scheduler started successfully'); + this.logger.log("Price fetch scheduler started successfully"); } stopScheduler(): void { @@ -49,7 +59,7 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { clearInterval(this.intervalId); this.intervalId = null; this.isRunning = false; - this.logger.log('Price fetch scheduler stopped'); + this.logger.log("Price fetch scheduler stopped"); } } @@ -63,7 +73,7 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { private async executeFetch(): Promise { const startTime = Date.now(); - this.logger.log('Scheduled price fetch starting...'); + this.logger.log("Scheduled price fetch starting..."); try { const prices = await this.priceFetcherService.fetchRawPrices(); @@ -75,7 +85,7 @@ export class SchedulerService implements OnModuleInit, OnModuleDestroy { } catch (error) { const duration = Date.now() - startTime; this.logger.error( - `Scheduled price fetch failed after ${duration}ms: ${error instanceof Error ? error.message : 'Unknown error'}`, + `Scheduled price fetch failed after ${duration}ms: ${error instanceof Error ? error.message : "Unknown error"}`, ); } } diff --git a/apps/ingestor/src/services/stock.service.ts b/apps/ingestor/src/services/stock.service.ts index 6e80d99..eb86699 100644 --- a/apps/ingestor/src/services/stock.service.ts +++ b/apps/ingestor/src/services/stock.service.ts @@ -1,13 +1,12 @@ -import { Injectable } from '@nestjs/common'; -import { FinnhubAdapter } from '../providers/finnhub.adapter'; -import { NormalizedStockPrice } from '@oracle-stocks/shared'; +import { Injectable } from "@nestjs/common"; +import { FinnhubAdapter } from "../providers/finnhub.adapter"; +import { NormalizedStockPrice } from "@oracle-stocks/shared"; @Injectable() export class StockService { - constructor(private readonly finnhubAdapter: FinnhubAdapter) {} async getLatestPrice(symbol: string): Promise { return await this.finnhubAdapter.getLatestPrice(symbol); } -} \ No newline at end of file +} diff --git a/apps/smart-contracts/README.md b/apps/smart-contracts/README.md index 4ad0fcb..cc3883e 100644 --- a/apps/smart-contracts/README.md +++ b/apps/smart-contracts/README.md @@ -14,6 +14,7 @@ This is a demonstration repository showing how to interact with and build on top ## Purpose The demo contracts in this directory serve as examples of: + - How to read price data from the oracle - How to build applications that depend on stock price feeds - Integration patterns for Stellar smart contracts diff --git a/apps/transactor/README.md b/apps/transactor/README.md index a1646ae..dcf0999 100644 --- a/apps/transactor/README.md +++ b/apps/transactor/README.md @@ -5,6 +5,7 @@ Submits signed stock price data to the Stellar network oracle contract. ## Overview The transactor service is responsible for: + - Receiving signed price data from the signer - Submitting transactions to the Stellar oracle smart contract - Managing transaction retries and error handling diff --git a/apps/transactor/src/app.module.ts b/apps/transactor/src/app.module.ts index d708410..4bc1a8f 100644 --- a/apps/transactor/src/app.module.ts +++ b/apps/transactor/src/app.module.ts @@ -1,5 +1,5 @@ -import { Module } from '@nestjs/common'; -import { AppService } from './app.service'; +import { Module } from "@nestjs/common"; +import { AppService } from "./app.service"; @Module({ imports: [], diff --git a/apps/transactor/src/app.service.spec.ts b/apps/transactor/src/app.service.spec.ts index 666fc50..55291bb 100644 --- a/apps/transactor/src/app.service.spec.ts +++ b/apps/transactor/src/app.service.spec.ts @@ -1,7 +1,7 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppService } from './app.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { AppService } from "./app.service"; -describe('AppService', () => { +describe("AppService", () => { let service: AppService; beforeEach(async () => { @@ -12,13 +12,13 @@ describe('AppService', () => { service = module.get(AppService); }); - it('should be defined', () => { + it("should be defined", () => { expect(service).toBeDefined(); }); - it('should return status', () => { + it("should return status", () => { const status = service.getStatus(); - expect(status.status).toBe('ready'); + expect(status.status).toBe("ready"); expect(status.timestamp).toBeGreaterThan(0); }); }); diff --git a/apps/transactor/src/app.service.ts b/apps/transactor/src/app.service.ts index 850becf..0d3a419 100644 --- a/apps/transactor/src/app.service.ts +++ b/apps/transactor/src/app.service.ts @@ -1,10 +1,10 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable } from "@nestjs/common"; @Injectable() export class AppService { getStatus(): { status: string; timestamp: number } { return { - status: 'ready', + status: "ready", timestamp: Date.now(), }; } diff --git a/apps/transactor/src/main.ts b/apps/transactor/src/main.ts index 854c53c..c5b288b 100644 --- a/apps/transactor/src/main.ts +++ b/apps/transactor/src/main.ts @@ -1,10 +1,10 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "./app.module"; async function bootstrap() { const app = await NestFactory.create(AppModule); const port = process.env.PORT || 3003; - + await app.listen(port); console.log(`Transactor service is running on: http://localhost:${port}`); } diff --git a/docs/README.md b/docs/README.md index bf447b3..3858f34 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,4 +12,4 @@ This directory contains detailed documentation for the Stellar Stock Price Oracl --- -πŸ“ *Documentation will be added as the project develops.* +πŸ“ _Documentation will be added as the project develops._ diff --git a/infra/README.md b/infra/README.md index df4ae92..c0fb713 100644 --- a/infra/README.md +++ b/infra/README.md @@ -11,4 +11,4 @@ This directory contains infrastructure as code (IaC) for deploying the Stellar S --- -πŸ—οΈ *Infrastructure configurations will be added as deployment requirements are defined.* +πŸ—οΈ _Infrastructure configurations will be added as deployment requirements are defined._ diff --git a/packages/shared/README.md b/packages/shared/README.md index 7ada96a..0123fee 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -5,6 +5,7 @@ Shared utilities, types, and constants used across all apps. ## Overview This package contains: + - Type definitions for stock prices, feeds, and oracle data - Common utility functions - Constants and configuration types diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 24ead54..0736962 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,11 +1,11 @@ // Main entry point for the Shared package export interface NormalizedStockPrice { - source : string; - symbol: string; - price: number ; - timestamp: number; + source: string; + symbol: string; + price: number; + timestamp: number; } // Shared utilities and types will be exported here // Type exports -export * from './types'; +export * from "./types"; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 4837cae..db6dc45 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1 +1 @@ -export { RawPrice } from './raw-price'; +export { RawPrice } from "./raw-price"; diff --git a/packages/signer/README.md b/packages/signer/README.md index 78c12ea..0660476 100644 --- a/packages/signer/README.md +++ b/packages/signer/README.md @@ -5,6 +5,7 @@ Produces cryptographically signed proofs of aggregated stock prices. ## Overview This package provides: + - Cryptographic signing of price data - Proof generation for price attestations - Signature verification utilities @@ -68,7 +69,7 @@ npm run lint After building, import the package in your application: ```typescript -import { PriceData, SignedPriceProof } from '@oracle-stocks/signer'; +import { PriceData, SignedPriceProof } from "@oracle-stocks/signer"; ``` ## Project Structure diff --git a/packages/signer/src/index.ts b/packages/signer/src/index.ts index 3dff25a..e74d168 100644 --- a/packages/signer/src/index.ts +++ b/packages/signer/src/index.ts @@ -1,6 +1,6 @@ // Main entry point for the Signer package -export * from './types'; +export * from "./types"; // Signer functionality will be exported here // Example: export { signPriceData } from './signer'; diff --git a/tests/README.md b/tests/README.md index 8a781ca..74e214b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -10,4 +10,4 @@ This directory contains integration and end-to-end tests for the Stellar Stock P --- -πŸ§ͺ *Tests will be added as components are implemented.* +πŸ§ͺ _Tests will be added as components are implemented._