Skip to content

Commit 329da0d

Browse files
authored
Merge pull request #25 from davedumto/feat/data-normalization
feat: add normalizers for Alpha Vantage etc
2 parents 83ec629 + 77435be commit 329da0d

23 files changed

Lines changed: 1758 additions & 1 deletion

apps/aggregator/README.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,100 @@ export class MyCustomAggregator implements IAggregator {
426426
}
427427
}
428428
```
429+
apps/aggregator/
430+
├── src/
431+
│ ├── main.ts # Application entry point
432+
│ ├── app.module.ts # Root module
433+
│ ├── interfaces/ # Type definitions
434+
│ │ ├── normalized-price.interface.ts
435+
│ │ └── normalizer.interface.ts
436+
│ ├── normalizers/ # Source-specific normalizers
437+
│ │ ├── base.normalizer.ts
438+
│ │ ├── alpha-vantage.normalizer.ts
439+
│ │ ├── finnhub.normalizer.ts
440+
│ │ ├── yahoo-finance.normalizer.ts
441+
│ │ └── mock.normalizer.ts
442+
│ ├── services/ # Business logic
443+
│ │ └── normalization.service.ts
444+
│ ├── modules/ # Feature modules
445+
│ │ └── normalization.module.ts
446+
│ └── exceptions/ # Custom exceptions
447+
│ └── normalization.exception.ts
448+
├── .env.example # Example environment variables
449+
├── nest-cli.json # NestJS CLI configuration
450+
├── package.json # Dependencies and scripts
451+
├── tsconfig.json # TypeScript configuration
452+
└── README.md # This file
453+
```
454+
455+
## Data Normalization
456+
457+
### NormalizedPrice Interface
458+
459+
The standard internal format for normalized price data:
460+
461+
```typescript
462+
interface NormalizedPrice {
463+
symbol: string; // Normalized ticker (e.g., 'AAPL')
464+
price: number; // Price rounded to 4 decimal places
465+
timestamp: string; // ISO 8601 UTC (e.g., '2024-01-15T14:30:00.000Z')
466+
originalTimestamp: number; // Original Unix timestamp in milliseconds
467+
source: NormalizedSource; // Enum: 'alpha_vantage' | 'finnhub' | 'yahoo_finance' | 'mock'
468+
metadata: {
469+
originalSource: string; // Original source string
470+
originalSymbol: string; // Original symbol before normalization
471+
normalizedAt: string; // When normalization occurred
472+
normalizerVersion: string; // Version of normalizer used
473+
wasTransformed: boolean; // Whether transformations were applied
474+
transformations: string[]; // List of transformations applied
475+
};
476+
}
477+
```
478+
479+
### Supported Sources and Transformations
480+
481+
| Source | Detected By | Symbol Transformations |
482+
|--------|-------------|------------------------|
483+
| **Alpha Vantage** | `alphavantage`, `alpha_vantage`, `alpha-vantage` | Removes `.US`, `.NYSE`, `.NASDAQ`, `.LSE`, `.TSX`, `.ASX`, `.HK` suffixes |
484+
| **Finnhub** | `finnhub` | Removes `US-`, `CRYPTO-`, `FX-`, `INDICES-` prefixes |
485+
| **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 |
486+
| **Mock** | `mock` | Basic cleanup (trim, uppercase) |
487+
488+
### Common Transformations
489+
490+
All normalizers apply these transformations:
491+
- **Symbol**: Trimmed and uppercased
492+
- **Price**: Rounded to 4 decimal places
493+
- **Timestamp**: Converted to ISO 8601 UTC format
494+
495+
### Usage Example
496+
497+
```typescript
498+
import { NormalizationService } from './services/normalization.service';
499+
500+
// Inject via NestJS DI
501+
constructor(private readonly normalizationService: NormalizationService) {}
502+
503+
// Normalize a single price
504+
const rawPrice = {
505+
symbol: 'AAPL.US',
506+
price: 150.123456,
507+
timestamp: Date.now(),
508+
source: 'AlphaVantage',
509+
};
510+
const normalized = this.normalizationService.normalize(rawPrice);
511+
// Result: { symbol: 'AAPL', price: 150.1235, timestamp: '2024-01-15T14:30:00.000Z', ... }
512+
513+
// Normalize multiple prices (skips failures)
514+
const results = this.normalizationService.normalizeMany(rawPrices);
515+
516+
// Normalize with error tracking
517+
const { successful, failed } = this.normalizationService.normalizeManyWithErrors(rawPrices);
518+
```
519+
520+
## Status
521+
522+
🚧 Under construction - Aggregation and filtering logic will be implemented in subsequent issues.
429523

430524
2. Register in `AggregationService` constructor:
431525

apps/aggregator/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@nestjs/core": "^10.0.0",
2222
"@nestjs/event-emitter": "^3.0.1",
2323
"@nestjs/platform-express": "^10.0.0",
24+
"@oracle-stocks/shared": "*",
2425
"@nestjs/terminus": "^10.0.0",
2526
"axios": "^1.6.0",
2627
"ioredis": "^5.3.2",
@@ -69,6 +70,9 @@
6970
"**/*.(t|j)s"
7071
],
7172
"coverageDirectory": "../coverage",
72-
"testEnvironment": "node"
73+
"testEnvironment": "node",
74+
"moduleNameMapper": {
75+
"^@oracle-stocks/shared$": "<rootDir>/../../packages/shared/src"
76+
}
7377
}
7478
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { RawPrice } from '@oracle-stocks/shared';
2+
3+
/**
4+
* Test fixtures for raw price data from different sources
5+
*/
6+
export const mockRawPrices: Record<string, RawPrice> = {
7+
alphaVantage: {
8+
symbol: 'AAPL.US',
9+
price: 150.1234567,
10+
timestamp: 1705329000000, // 2024-01-15T14:30:00.000Z
11+
source: 'AlphaVantage',
12+
},
13+
alphaVantageNYSE: {
14+
symbol: 'MSFT.NYSE',
15+
price: 380.5,
16+
timestamp: 1705330200000,
17+
source: 'alpha_vantage',
18+
},
19+
finnhub: {
20+
symbol: 'US-GOOGL',
21+
price: 140.999,
22+
timestamp: 1705330200000,
23+
source: 'Finnhub',
24+
},
25+
finnhubCrypto: {
26+
symbol: 'CRYPTO-BTC',
27+
price: 42000.0,
28+
timestamp: 1705330200000,
29+
source: 'finnhub',
30+
},
31+
yahooFinance: {
32+
symbol: 'MSFT.L',
33+
price: 380.12345,
34+
timestamp: 1705330200000,
35+
source: 'Yahoo Finance',
36+
},
37+
yahooFinanceIndex: {
38+
symbol: '^DJI',
39+
price: 37500.0,
40+
timestamp: 1705330200000,
41+
source: 'yahoo_finance',
42+
},
43+
yahooFinanceAustralia: {
44+
symbol: 'BHP.AX',
45+
price: 45.67,
46+
timestamp: 1705330200000,
47+
source: 'YahooFinance',
48+
},
49+
mock: {
50+
symbol: 'TSLA',
51+
price: 250.5,
52+
timestamp: 1705330200000,
53+
source: 'MockProvider',
54+
},
55+
mockLowercase: {
56+
symbol: ' aapl ',
57+
price: 150.0,
58+
timestamp: 1705330200000,
59+
source: 'mock',
60+
},
61+
unknown: {
62+
symbol: 'BTC',
63+
price: 42000.0,
64+
timestamp: 1705330200000,
65+
source: 'UnknownSource',
66+
},
67+
};
68+
69+
/**
70+
* Malformed price data for testing validation
71+
*/
72+
export const malformedPrices: Array<Partial<RawPrice> | null | undefined> = [
73+
{ symbol: '', price: 100, timestamp: Date.now(), source: 'Test' },
74+
{ symbol: 'TEST', price: NaN, timestamp: Date.now(), source: 'Test' },
75+
{ symbol: 'TEST', price: -100, timestamp: Date.now(), source: 'Test' },
76+
{ symbol: 'TEST', price: 100, timestamp: null as unknown as number, source: 'Test' },
77+
{ symbol: 'TEST', price: 100, timestamp: Date.now(), source: '' },
78+
{ price: 100, timestamp: Date.now(), source: 'Test' } as Partial<RawPrice>,
79+
{ symbol: 'TEST', timestamp: Date.now(), source: 'Test' } as Partial<RawPrice>,
80+
null,
81+
undefined,
82+
];
83+
84+
/**
85+
* Valid raw prices for batch testing
86+
*/
87+
export const validRawPrices: RawPrice[] = [
88+
mockRawPrices.alphaVantage,
89+
mockRawPrices.finnhub,
90+
mockRawPrices.yahooFinance,
91+
mockRawPrices.mock,
92+
];
93+
94+
/**
95+
* Mixed valid and invalid prices for error handling tests
96+
*/
97+
export const mixedRawPrices: RawPrice[] = [
98+
mockRawPrices.alphaVantage,
99+
mockRawPrices.unknown,
100+
mockRawPrices.finnhub,
101+
];

apps/aggregator/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Module } from '@nestjs/common';
2+
import { NormalizationModule } from './modules/normalization.module';
23
import { ConfigModule } from '@nestjs/config';
34
import { AggregationService } from './services/aggregation.service';
45
import { WeightedAverageAggregator } from './strategies/aggregators/weighted-average.aggregator';
@@ -10,6 +11,7 @@ import { DebugModule } from './debug/debug.module';
1011

1112
@Module({
1213
imports: [
14+
NormalizationModule,
1315
ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }),
1416
HealthModule,
1517
MetricsModule,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './normalization.exception';
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { RawPrice } from '@oracle-stocks/shared';
2+
3+
/**
4+
* Base exception for normalization errors
5+
*/
6+
export class NormalizationException extends Error {
7+
constructor(
8+
message: string,
9+
public readonly rawPrice?: RawPrice,
10+
public readonly cause?: Error,
11+
) {
12+
super(message);
13+
this.name = 'NormalizationException';
14+
Error.captureStackTrace(this, this.constructor);
15+
}
16+
}
17+
18+
/**
19+
* Exception for validation failures
20+
*/
21+
export class ValidationException extends NormalizationException {
22+
constructor(message: string, rawPrice?: RawPrice) {
23+
super(message, rawPrice);
24+
this.name = 'ValidationException';
25+
}
26+
}
27+
28+
/**
29+
* Exception when no normalizer is found for a source
30+
*/
31+
export class NoNormalizerFoundException extends NormalizationException {
32+
constructor(source: string, rawPrice?: RawPrice) {
33+
super(`No normalizer found for source: ${source}`, rawPrice);
34+
this.name = 'NoNormalizerFoundException';
35+
}
36+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './normalized-price.interface';
2+
export * from './normalizer.interface';

apps/aggregator/src/interfaces/normalized-price.interface.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,62 @@
1+
/**
2+
* Enum for standardized source identifiers
3+
*/
4+
export enum NormalizedSource {
5+
ALPHA_VANTAGE = 'alpha_vantage',
6+
FINNHUB = 'finnhub',
7+
YAHOO_FINANCE = 'yahoo_finance',
8+
MOCK = 'mock',
9+
UNKNOWN = 'unknown',
10+
}
11+
12+
/**
13+
* Metadata tracking normalization processing
14+
*/
15+
export interface NormalizationMetadata {
16+
/** Original source string before normalization */
17+
originalSource: string;
18+
19+
/** Original symbol before normalization (e.g., 'AAPL.US') */
20+
originalSymbol: string;
21+
22+
/** ISO 8601 timestamp when normalization was performed */
23+
normalizedAt: string;
24+
25+
/** Version of the normalization logic used */
26+
normalizerVersion: string;
27+
28+
/** Whether any transformations were applied */
29+
wasTransformed: boolean;
30+
31+
/** List of transformations applied (for debugging/audit) */
32+
transformations: string[];
33+
}
34+
35+
/**
36+
* Represents a fully normalized price record with standard formatting
37+
* and metadata for audit/tracking purposes.
38+
* Used as output from the normalization service.
39+
*/
40+
export interface NormalizedPriceRecord {
41+
/** Normalized ticker symbol (e.g., 'AAPL' - stripped of exchange suffixes) */
42+
symbol: string;
43+
44+
/** Price value normalized to minimum 4 decimal places */
45+
price: number;
46+
47+
/** ISO 8601 UTC timestamp string (e.g., '2024-01-15T14:30:00.000Z') */
48+
timestamp: string;
49+
50+
/** Original Unix timestamp in milliseconds (preserved for precision) */
51+
originalTimestamp: number;
52+
53+
/** Normalized source identifier */
54+
source: NormalizedSource;
55+
56+
/** Metadata for tracking and audit purposes */
57+
metadata: NormalizationMetadata;
58+
}
59+
160
/**
261
* Normalized price data structure from various sources
362
* This is the input to the aggregation engine
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { RawPrice } from '@oracle-stocks/shared';
2+
import { NormalizedPriceRecord, NormalizedSource } from './normalized-price.interface';
3+
4+
/**
5+
* Interface for source-specific normalization strategies.
6+
* Each data source should implement this interface.
7+
*/
8+
export interface Normalizer {
9+
/** Unique identifier for this normalizer */
10+
readonly name: string;
11+
12+
/** The source this normalizer handles */
13+
readonly source: NormalizedSource;
14+
15+
/** Version string for tracking normalization logic changes */
16+
readonly version: string;
17+
18+
/**
19+
* Check if this normalizer can handle the given raw price
20+
* @param rawPrice - The raw price to check
21+
* @returns true if this normalizer supports the source
22+
*/
23+
canNormalize(rawPrice: RawPrice): boolean;
24+
25+
/**
26+
* Normalize a single raw price record
27+
* @param rawPrice - The raw price to normalize
28+
* @returns Normalized price or throws NormalizationException
29+
*/
30+
normalize(rawPrice: RawPrice): NormalizedPriceRecord;
31+
32+
/**
33+
* Normalize multiple raw price records
34+
* @param rawPrices - Array of raw prices to normalize
35+
* @returns Array of normalized prices (invalid entries filtered out)
36+
*/
37+
normalizeMany(rawPrices: RawPrice[]): NormalizedPriceRecord[];
38+
}
39+
40+
/**
41+
* Result type for batch normalization with error tracking
42+
*/
43+
export interface NormalizationResult {
44+
successful: NormalizedPriceRecord[];
45+
failed: NormalizationFailure[];
46+
}
47+
48+
/**
49+
* Represents a failed normalization attempt
50+
*/
51+
export interface NormalizationFailure {
52+
rawPrice: RawPrice;
53+
error: string;
54+
timestamp: string;
55+
}

0 commit comments

Comments
 (0)