clean up
This commit is contained in:
parent
7993148a95
commit
528be93804
38 changed files with 4617 additions and 1081 deletions
23
libs/data-frame/package.json
Normal file
23
libs/data-frame/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "@stock-bot/data-frame",
|
||||
"version": "1.0.0",
|
||||
"description": "DataFrame library for time series data manipulation",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stock-bot/logger": "workspace:*",
|
||||
"@stock-bot/utils": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bun-types": "*"
|
||||
}
|
||||
}
|
||||
485
libs/data-frame/src/index.ts
Normal file
485
libs/data-frame/src/index.ts
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
import { createLogger } from '@stock-bot/logger';
|
||||
|
||||
export interface DataFrameRow {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface DataFrameOptions {
|
||||
index?: string;
|
||||
columns?: string[];
|
||||
dtypes?: Record<string, 'number' | 'string' | 'boolean' | 'date'>;
|
||||
}
|
||||
|
||||
export interface GroupByResult {
|
||||
[key: string]: DataFrame;
|
||||
}
|
||||
|
||||
export interface AggregationFunction {
|
||||
(values: any[]): any;
|
||||
}
|
||||
|
||||
export class DataFrame {
|
||||
private data: DataFrameRow[];
|
||||
private _columns: string[];
|
||||
private _index: string;
|
||||
private _dtypes: Record<string, 'number' | 'string' | 'boolean' | 'date'>;
|
||||
private logger = createLogger('dataframe');
|
||||
|
||||
constructor(data: DataFrameRow[] = [], options: DataFrameOptions = {}) {
|
||||
this.data = [...data];
|
||||
this._index = options.index || 'index';
|
||||
this._columns = options.columns || this.inferColumns();
|
||||
this._dtypes = options.dtypes || {};
|
||||
|
||||
this.validateAndCleanData();
|
||||
}
|
||||
|
||||
private inferColumns(): string[] {
|
||||
if (this.data.length === 0) return [];
|
||||
|
||||
const columns = new Set<string>();
|
||||
for (const row of this.data) {
|
||||
Object.keys(row).forEach(key => columns.add(key));
|
||||
}
|
||||
|
||||
return Array.from(columns).sort();
|
||||
}
|
||||
|
||||
private validateAndCleanData(): void {
|
||||
if (this.data.length === 0) return;
|
||||
|
||||
// Ensure all rows have the same columns
|
||||
for (let i = 0; i < this.data.length; i++) {
|
||||
const row = this.data[i];
|
||||
|
||||
// Add missing columns with null values
|
||||
for (const col of this._columns) {
|
||||
if (!(col in row)) {
|
||||
row[col] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply data type conversions
|
||||
for (const [col, dtype] of Object.entries(this._dtypes)) {
|
||||
if (col in row && row[col] !== null) {
|
||||
row[col] = this.convertValue(row[col], dtype);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private convertValue(value: any, dtype: string): any {
|
||||
switch (dtype) {
|
||||
case 'number':
|
||||
return typeof value === 'number' ? value : parseFloat(value);
|
||||
case 'string':
|
||||
return String(value);
|
||||
case 'boolean':
|
||||
return Boolean(value);
|
||||
case 'date':
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Basic properties
|
||||
get columns(): string[] {
|
||||
return [...this._columns];
|
||||
}
|
||||
|
||||
get index(): string {
|
||||
return this._index;
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.data.length;
|
||||
}
|
||||
|
||||
get shape(): [number, number] {
|
||||
return [this.data.length, this._columns.length];
|
||||
}
|
||||
|
||||
get empty(): boolean {
|
||||
return this.data.length === 0;
|
||||
}
|
||||
|
||||
// Data access methods
|
||||
head(n: number = 5): DataFrame {
|
||||
return new DataFrame(this.data.slice(0, n), {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
});
|
||||
}
|
||||
|
||||
tail(n: number = 5): DataFrame {
|
||||
return new DataFrame(this.data.slice(-n), {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
});
|
||||
}
|
||||
|
||||
iloc(start: number, end?: number): DataFrame {
|
||||
const slice = end !== undefined ? this.data.slice(start, end) : this.data.slice(start);
|
||||
return new DataFrame(slice, {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
});
|
||||
}
|
||||
|
||||
at(index: number, column: string): any {
|
||||
if (index < 0 || index >= this.data.length) {
|
||||
throw new Error(`Index ${index} out of bounds`);
|
||||
}
|
||||
return this.data[index][column];
|
||||
}
|
||||
|
||||
// Column operations
|
||||
select(columns: string[]): DataFrame {
|
||||
const validColumns = columns.filter(col => this._columns.includes(col));
|
||||
const newData = this.data.map(row => {
|
||||
const newRow: DataFrameRow = {};
|
||||
for (const col of validColumns) {
|
||||
newRow[col] = row[col];
|
||||
}
|
||||
return newRow;
|
||||
});
|
||||
|
||||
return new DataFrame(newData, {
|
||||
columns: validColumns,
|
||||
index: this._index,
|
||||
dtypes: this.filterDtypes(validColumns)
|
||||
});
|
||||
}
|
||||
|
||||
drop(columns: string[]): DataFrame {
|
||||
const remainingColumns = this._columns.filter(col => !columns.includes(col));
|
||||
return this.select(remainingColumns);
|
||||
}
|
||||
|
||||
getColumn(column: string): any[] {
|
||||
if (!this._columns.includes(column)) {
|
||||
throw new Error(`Column '${column}' not found`);
|
||||
}
|
||||
return this.data.map(row => row[column]);
|
||||
}
|
||||
|
||||
setColumn(column: string, values: any[]): DataFrame {
|
||||
if (values.length !== this.data.length) {
|
||||
throw new Error('Values length must match DataFrame length');
|
||||
}
|
||||
|
||||
const newData = this.data.map((row, index) => ({
|
||||
...row,
|
||||
[column]: values[index]
|
||||
}));
|
||||
|
||||
const newColumns = this._columns.includes(column)
|
||||
? this._columns
|
||||
: [...this._columns, column];
|
||||
|
||||
return new DataFrame(newData, {
|
||||
columns: newColumns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
});
|
||||
}
|
||||
|
||||
// Filtering
|
||||
filter(predicate: (row: DataFrameRow, index: number) => boolean): DataFrame {
|
||||
const filteredData = this.data.filter(predicate);
|
||||
return new DataFrame(filteredData, {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
});
|
||||
}
|
||||
|
||||
where(column: string, operator: '>' | '<' | '>=' | '<=' | '==' | '!=', value: any): DataFrame {
|
||||
return this.filter(row => {
|
||||
const cellValue = row[column];
|
||||
switch (operator) {
|
||||
case '>': return cellValue > value;
|
||||
case '<': return cellValue < value;
|
||||
case '>=': return cellValue >= value;
|
||||
case '<=': return cellValue <= value;
|
||||
case '==': return cellValue === value;
|
||||
case '!=': return cellValue !== value;
|
||||
default: return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sorting
|
||||
sort(column: string, ascending: boolean = true): DataFrame {
|
||||
const sortedData = [...this.data].sort((a, b) => {
|
||||
const aVal = a[column];
|
||||
const bVal = b[column];
|
||||
|
||||
if (aVal === bVal) return 0;
|
||||
|
||||
const comparison = aVal > bVal ? 1 : -1;
|
||||
return ascending ? comparison : -comparison;
|
||||
});
|
||||
|
||||
return new DataFrame(sortedData, {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
});
|
||||
}
|
||||
|
||||
// Aggregation
|
||||
groupBy(column: string): GroupByResult {
|
||||
const groups: Record<string, DataFrameRow[]> = {};
|
||||
|
||||
for (const row of this.data) {
|
||||
const key = String(row[column]);
|
||||
if (!groups[key]) {
|
||||
groups[key] = [];
|
||||
}
|
||||
groups[key].push(row);
|
||||
}
|
||||
|
||||
const result: GroupByResult = {};
|
||||
for (const [key, rows] of Object.entries(groups)) {
|
||||
result[key] = new DataFrame(rows, {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
agg(aggregations: Record<string, AggregationFunction>): DataFrameRow {
|
||||
const result: DataFrameRow = {};
|
||||
|
||||
for (const [column, func] of Object.entries(aggregations)) {
|
||||
if (!this._columns.includes(column)) {
|
||||
throw new Error(`Column '${column}' not found`);
|
||||
}
|
||||
|
||||
const values = this.getColumn(column).filter(val => val !== null && val !== undefined);
|
||||
result[column] = func(values);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Statistical methods
|
||||
mean(column: string): number {
|
||||
const values = this.getColumn(column).filter(val => typeof val === 'number');
|
||||
return values.reduce((sum, val) => sum + val, 0) / values.length;
|
||||
}
|
||||
|
||||
sum(column: string): number {
|
||||
const values = this.getColumn(column).filter(val => typeof val === 'number');
|
||||
return values.reduce((sum, val) => sum + val, 0);
|
||||
}
|
||||
|
||||
min(column: string): number {
|
||||
const values = this.getColumn(column).filter(val => typeof val === 'number');
|
||||
return Math.min(...values);
|
||||
}
|
||||
|
||||
max(column: string): number {
|
||||
const values = this.getColumn(column).filter(val => typeof val === 'number');
|
||||
return Math.max(...values);
|
||||
}
|
||||
|
||||
std(column: string): number {
|
||||
const values = this.getColumn(column).filter(val => typeof val === 'number');
|
||||
const mean = values.reduce((sum, val) => sum + val, 0) / values.length;
|
||||
const variance = values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / values.length;
|
||||
return Math.sqrt(variance);
|
||||
}
|
||||
|
||||
// Time series specific methods
|
||||
resample(timeColumn: string, frequency: string): DataFrame {
|
||||
// Simple resampling implementation
|
||||
// For production, you'd want more sophisticated time-based grouping
|
||||
const sorted = this.sort(timeColumn);
|
||||
|
||||
switch (frequency) {
|
||||
case '1H':
|
||||
return this.resampleByHour(sorted, timeColumn);
|
||||
case '1D':
|
||||
return this.resampleByDay(sorted, timeColumn);
|
||||
default:
|
||||
throw new Error(`Unsupported frequency: ${frequency}`);
|
||||
}
|
||||
}
|
||||
|
||||
private resampleByHour(sorted: DataFrame, timeColumn: string): DataFrame {
|
||||
const groups: Record<string, DataFrameRow[]> = {};
|
||||
|
||||
for (const row of sorted.data) {
|
||||
const date = new Date(row[timeColumn]);
|
||||
const hourKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getHours()}`;
|
||||
|
||||
if (!groups[hourKey]) {
|
||||
groups[hourKey] = [];
|
||||
}
|
||||
groups[hourKey].push(row);
|
||||
}
|
||||
|
||||
const aggregatedData: DataFrameRow[] = [];
|
||||
for (const [key, rows] of Object.entries(groups)) {
|
||||
const tempDf = new DataFrame(rows, {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
});
|
||||
|
||||
// Create OHLCV aggregation
|
||||
const aggregated: DataFrameRow = {
|
||||
[timeColumn]: rows[0][timeColumn],
|
||||
open: rows[0].close || rows[0].price,
|
||||
high: tempDf.max('high') || tempDf.max('close') || tempDf.max('price'),
|
||||
low: tempDf.min('low') || tempDf.min('close') || tempDf.min('price'),
|
||||
close: rows[rows.length - 1].close || rows[rows.length - 1].price,
|
||||
volume: tempDf.sum('volume') || 0
|
||||
};
|
||||
|
||||
aggregatedData.push(aggregated);
|
||||
}
|
||||
|
||||
return new DataFrame(aggregatedData);
|
||||
}
|
||||
|
||||
private resampleByDay(sorted: DataFrame, timeColumn: string): DataFrame {
|
||||
// Similar to resampleByHour but group by day
|
||||
const groups: Record<string, DataFrameRow[]> = {};
|
||||
|
||||
for (const row of sorted.data) {
|
||||
const date = new Date(row[timeColumn]);
|
||||
const dayKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
||||
|
||||
if (!groups[dayKey]) {
|
||||
groups[dayKey] = [];
|
||||
}
|
||||
groups[dayKey].push(row);
|
||||
}
|
||||
|
||||
const aggregatedData: DataFrameRow[] = [];
|
||||
for (const [key, rows] of Object.entries(groups)) {
|
||||
const tempDf = new DataFrame(rows, {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: this._dtypes
|
||||
});
|
||||
|
||||
const aggregated: DataFrameRow = {
|
||||
[timeColumn]: rows[0][timeColumn],
|
||||
open: rows[0].close || rows[0].price,
|
||||
high: tempDf.max('high') || tempDf.max('close') || tempDf.max('price'),
|
||||
low: tempDf.min('low') || tempDf.min('close') || tempDf.min('price'),
|
||||
close: rows[rows.length - 1].close || rows[rows.length - 1].price,
|
||||
volume: tempDf.sum('volume') || 0
|
||||
};
|
||||
|
||||
aggregatedData.push(aggregated);
|
||||
}
|
||||
|
||||
return new DataFrame(aggregatedData);
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
copy(): DataFrame {
|
||||
return new DataFrame(this.data.map(row => ({ ...row })), {
|
||||
columns: this._columns,
|
||||
index: this._index,
|
||||
dtypes: { ...this._dtypes }
|
||||
});
|
||||
}
|
||||
|
||||
concat(other: DataFrame): DataFrame {
|
||||
const combinedData = [...this.data, ...other.data];
|
||||
const combinedColumns = Array.from(new Set([...this._columns, ...other._columns]));
|
||||
|
||||
return new DataFrame(combinedData, {
|
||||
columns: combinedColumns,
|
||||
index: this._index,
|
||||
dtypes: { ...this._dtypes, ...other._dtypes }
|
||||
});
|
||||
}
|
||||
|
||||
toArray(): DataFrameRow[] {
|
||||
return this.data.map(row => ({ ...row }));
|
||||
}
|
||||
|
||||
toJSON(): string {
|
||||
return JSON.stringify(this.data);
|
||||
}
|
||||
|
||||
private filterDtypes(columns: string[]): Record<string, 'number' | 'string' | 'boolean' | 'date'> {
|
||||
const filtered: Record<string, 'number' | 'string' | 'boolean' | 'date'> = {};
|
||||
for (const col of columns) {
|
||||
if (this._dtypes[col]) {
|
||||
filtered[col] = this._dtypes[col];
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// Display method
|
||||
toString(): string {
|
||||
if (this.empty) {
|
||||
return 'Empty DataFrame';
|
||||
}
|
||||
|
||||
const maxRows = 10;
|
||||
const displayData = this.data.slice(0, maxRows);
|
||||
|
||||
let result = `DataFrame (${this.length} rows x ${this._columns.length} columns)\n`;
|
||||
result += this._columns.join('\t') + '\n';
|
||||
result += '-'.repeat(this._columns.join('\t').length) + '\n';
|
||||
|
||||
for (const row of displayData) {
|
||||
const values = this._columns.map(col => String(row[col] ?? 'null'));
|
||||
result += values.join('\t') + '\n';
|
||||
}
|
||||
|
||||
if (this.length > maxRows) {
|
||||
result += `... (${this.length - maxRows} more rows)\n`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Factory functions
|
||||
export function createDataFrame(data: DataFrameRow[], options?: DataFrameOptions): DataFrame {
|
||||
return new DataFrame(data, options);
|
||||
}
|
||||
|
||||
export function readCSV(csvData: string, options?: DataFrameOptions): DataFrame {
|
||||
const lines = csvData.trim().split('\n');
|
||||
if (lines.length === 0) {
|
||||
return new DataFrame();
|
||||
}
|
||||
|
||||
const headers = lines[0].split(',').map(h => h.trim());
|
||||
const data: DataFrameRow[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = lines[i].split(',').map(v => v.trim());
|
||||
const row: DataFrameRow = {};
|
||||
|
||||
for (let j = 0; j < headers.length; j++) {
|
||||
row[headers[j]] = values[j] || null;
|
||||
}
|
||||
|
||||
data.push(row);
|
||||
}
|
||||
|
||||
return new DataFrame(data, {
|
||||
columns: headers,
|
||||
...options
|
||||
});
|
||||
}
|
||||
16
libs/data-frame/tsconfig.json
Normal file
16
libs/data-frame/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"],
|
||||
"references": [
|
||||
{ "path": "../logger" },
|
||||
{ "path": "../types" }
|
||||
]
|
||||
}
|
||||
25
libs/event-bus/package.json
Normal file
25
libs/event-bus/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "@stock-bot/event-bus",
|
||||
"version": "1.0.0",
|
||||
"description": "Event bus library for inter-service communication",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stock-bot/logger": "workspace:*",
|
||||
"@stock-bot/config": "workspace:*",
|
||||
"ioredis": "^5.3.2",
|
||||
"eventemitter3": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bun-types": "*"
|
||||
}
|
||||
}
|
||||
169
libs/event-bus/src/index.ts
Normal file
169
libs/event-bus/src/index.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { EventEmitter } from 'eventemitter3';
|
||||
import Redis from 'ioredis';
|
||||
import { createLogger } from '@stock-bot/logger';
|
||||
import { dragonflyConfig } from '@stock-bot/config';
|
||||
|
||||
export interface EventBusMessage {
|
||||
id: string;
|
||||
type: string;
|
||||
source: string;
|
||||
timestamp: number;
|
||||
data: any;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface EventHandler<T = any> {
|
||||
(message: EventBusMessage & { data: T }): Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface EventBusOptions {
|
||||
|
||||
serviceName: string;
|
||||
enablePersistence?: boolean;
|
||||
}
|
||||
|
||||
export class EventBus extends EventEmitter {
|
||||
private redis?: Redis;
|
||||
private subscriber?: Redis;
|
||||
private serviceName: string;
|
||||
private logger
|
||||
private enablePersistence: boolean;
|
||||
|
||||
constructor(options: EventBusOptions) {
|
||||
super();
|
||||
this.serviceName = options.serviceName;
|
||||
this.enablePersistence = options.enablePersistence ?? true;
|
||||
this.logger = createLogger(`event-bus:${this.serviceName}`);
|
||||
|
||||
this.redis = new Redis({
|
||||
host: dragonflyConfig.DRAGONFLY_HOST,
|
||||
port: dragonflyConfig.DRAGONFLY_PORT,
|
||||
password: dragonflyConfig.DRAGONFLY_PASSWORD,
|
||||
db: dragonflyConfig.DRAGONFLY_DATABASE || 0,
|
||||
maxRetriesPerRequest: dragonflyConfig.DRAGONFLY_MAX_RETRIES,
|
||||
});
|
||||
|
||||
this.subscriber = new Redis({
|
||||
host: dragonflyConfig.DRAGONFLY_HOST,
|
||||
port: dragonflyConfig.DRAGONFLY_PORT,
|
||||
password: dragonflyConfig.DRAGONFLY_PASSWORD,
|
||||
db: dragonflyConfig.DRAGONFLY_DATABASE || 0,
|
||||
});
|
||||
|
||||
this.subscriber.on('message', this.handleRedisMessage.bind(this));
|
||||
this.logger.info('Redis event bus initialized');
|
||||
}
|
||||
|
||||
private handleRedisMessage(channel: string, message: string) {
|
||||
try {
|
||||
const eventMessage: EventBusMessage = JSON.parse(message);
|
||||
|
||||
// Don't process our own messages
|
||||
if (eventMessage.source === this.serviceName) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit(eventMessage.type, eventMessage);
|
||||
this.logger.debug(`Received event: ${eventMessage.type} from ${eventMessage.source}`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to parse Redis message', { error, message });
|
||||
}
|
||||
}
|
||||
|
||||
async publish<T = any>(type: string, data: T, metadata?: Record<string, any>): Promise<void> {
|
||||
const message: EventBusMessage = {
|
||||
id: this.generateId(),
|
||||
type,
|
||||
source: this.serviceName,
|
||||
timestamp: Date.now(),
|
||||
data,
|
||||
metadata,
|
||||
};
|
||||
|
||||
// Emit locally first
|
||||
this.emit(type, message);
|
||||
|
||||
// Publish to Redis if available
|
||||
if (this.redis && this.enablePersistence) {
|
||||
try {
|
||||
await this.redis.publish(`events:${type}`, JSON.stringify(message));
|
||||
this.logger.debug(`Published event: ${type}`, { messageId: message.id });
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to publish event: ${type}`, { error, messageId: message.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async subscribe<T = any>(eventType: string, handler: EventHandler<T>): Promise<void> {
|
||||
// Subscribe locally
|
||||
this.on(eventType, handler);
|
||||
|
||||
// Subscribe to Redis channel if available
|
||||
if (this.subscriber && this.enablePersistence) {
|
||||
try {
|
||||
await this.subscriber.subscribe(`events:${eventType}`);
|
||||
this.logger.debug(`Subscribed to event: ${eventType}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to subscribe to event: ${eventType}`, { error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async unsubscribe(eventType: string, handler?: EventHandler): Promise<void> {
|
||||
if (handler) {
|
||||
this.off(eventType, handler);
|
||||
} else {
|
||||
this.removeAllListeners(eventType);
|
||||
}
|
||||
|
||||
if (this.subscriber && this.enablePersistence) {
|
||||
try {
|
||||
await this.subscriber.unsubscribe(`events:${eventType}`);
|
||||
this.logger.debug(`Unsubscribed from event: ${eventType}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to unsubscribe from event: ${eventType}`, { error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.redis) {
|
||||
await this.redis.quit();
|
||||
}
|
||||
if (this.subscriber) {
|
||||
await this.subscriber.quit();
|
||||
}
|
||||
this.removeAllListeners();
|
||||
this.logger.info('Event bus closed');
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return `${this.serviceName}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Utility methods for common event patterns
|
||||
async publishMarketData(symbol: string, data: any): Promise<void> {
|
||||
await this.publish('market.data', { symbol, ...data });
|
||||
}
|
||||
|
||||
async publishOrderUpdate(orderId: string, status: string, data: any): Promise<void> {
|
||||
await this.publish('order.update', { orderId, status, ...data });
|
||||
}
|
||||
|
||||
async publishStrategySignal(strategyId: string, signal: any): Promise<void> {
|
||||
await this.publish('strategy.signal', { strategyId, ...signal });
|
||||
}
|
||||
|
||||
async publishPortfolioUpdate(portfolioId: string, data: any): Promise<void> {
|
||||
await this.publish('portfolio.update', { portfolioId, ...data });
|
||||
}
|
||||
|
||||
async publishBacktestUpdate(backtestId: string, progress: number, data?: any): Promise<void> {
|
||||
await this.publish('backtest.update', { backtestId, progress, ...data });
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function for easy initialization
|
||||
export function createEventBus(options: EventBusOptions): EventBus {
|
||||
return new EventBus(options);
|
||||
}
|
||||
16
libs/event-bus/tsconfig.json
Normal file
16
libs/event-bus/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"],
|
||||
"references": [
|
||||
{ "path": "../logger" },
|
||||
{ "path": "../types" }
|
||||
]
|
||||
}
|
||||
27
libs/strategy-engine/package.json
Normal file
27
libs/strategy-engine/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "@stock-bot/strategy-engine",
|
||||
"version": "1.0.0",
|
||||
"description": "Strategy execution engine with multi-mode support",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stock-bot/logger": "workspace:*",
|
||||
"@stock-bot/config": "workspace:*",
|
||||
"@stock-bot/utils": "workspace:*",
|
||||
"@stock-bot/event-bus": "workspace:*",
|
||||
"@stock-bot/data-frame": "workspace:*",
|
||||
"eventemitter3": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bun-types": "*"
|
||||
}
|
||||
}
|
||||
370
libs/strategy-engine/src/index.ts
Normal file
370
libs/strategy-engine/src/index.ts
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import { EventEmitter } from 'eventemitter3';
|
||||
import { createLogger, Logger } from '@stock-bot/logger';
|
||||
import { EventBus } from '@stock-bot/event-bus';
|
||||
import { DataFrame } from '@stock-bot/data-frame';
|
||||
|
||||
// Core types
|
||||
export interface MarketData {
|
||||
symbol: string;
|
||||
timestamp: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface TradingSignal {
|
||||
type: 'BUY' | 'SELL' | 'HOLD';
|
||||
symbol: string;
|
||||
timestamp: number;
|
||||
price: number;
|
||||
quantity: number;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface StrategyContext {
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
data: DataFrame;
|
||||
indicators: Record<string, any>;
|
||||
position?: Position;
|
||||
portfolio: PortfolioSummary;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
symbol: string;
|
||||
quantity: number;
|
||||
averagePrice: number;
|
||||
currentPrice: number;
|
||||
unrealizedPnL: number;
|
||||
side: 'LONG' | 'SHORT';
|
||||
}
|
||||
|
||||
export interface PortfolioSummary {
|
||||
totalValue: number;
|
||||
cash: number;
|
||||
positions: Position[];
|
||||
totalPnL: number;
|
||||
dayPnL: number;
|
||||
}
|
||||
|
||||
export interface StrategyConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
symbols: string[];
|
||||
timeframes: string[];
|
||||
parameters: Record<string, any>;
|
||||
riskLimits: RiskLimits;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface RiskLimits {
|
||||
maxPositionSize: number;
|
||||
maxDailyLoss: number;
|
||||
maxDrawdown: number;
|
||||
stopLoss?: number;
|
||||
takeProfit?: number;
|
||||
}
|
||||
|
||||
// Abstract base strategy class
|
||||
export abstract class BaseStrategy extends EventEmitter {
|
||||
protected logger;
|
||||
protected eventBus: EventBus;
|
||||
protected config: StrategyConfig;
|
||||
protected isActive: boolean = false;
|
||||
|
||||
constructor(config: StrategyConfig, eventBus: EventBus) {
|
||||
super();
|
||||
this.config = config;
|
||||
this.eventBus = eventBus;
|
||||
this.logger = createLogger(`strategy:${config.id}`);
|
||||
}
|
||||
|
||||
// Abstract methods that must be implemented by concrete strategies
|
||||
abstract initialize(): Promise<void>;
|
||||
abstract onMarketData(context: StrategyContext): Promise<TradingSignal[]>;
|
||||
abstract onSignal(signal: TradingSignal): Promise<void>;
|
||||
abstract cleanup(): Promise<void>;
|
||||
|
||||
// Optional lifecycle methods
|
||||
onStart?(): Promise<void>;
|
||||
onStop?(): Promise<void>;
|
||||
onError?(error: Error): Promise<void>;
|
||||
|
||||
// Control methods
|
||||
async start(): Promise<void> {
|
||||
if (this.isActive) {
|
||||
this.logger.warn('Strategy already active');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.initialize();
|
||||
|
||||
if (this.onStart) {
|
||||
await this.onStart();
|
||||
}
|
||||
|
||||
this.isActive = true;
|
||||
this.logger.info('Strategy started', { strategyId: this.config.id });
|
||||
this.emit('started');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start strategy', { error, strategyId: this.config.id });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.isActive) {
|
||||
this.logger.warn('Strategy not active');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.onStop) {
|
||||
await this.onStop();
|
||||
}
|
||||
|
||||
await this.cleanup();
|
||||
this.isActive = false;
|
||||
this.logger.info('Strategy stopped', { strategyId: this.config.id });
|
||||
this.emit('stopped');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to stop strategy', { error, strategyId: this.config.id });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
protected async emitSignal(signal: TradingSignal): Promise<void> {
|
||||
await this.eventBus.publishStrategySignal(this.config.id, signal);
|
||||
this.emit('signal', signal);
|
||||
this.logger.info('Signal generated', {
|
||||
signal: signal.type,
|
||||
symbol: signal.symbol,
|
||||
confidence: signal.confidence
|
||||
});
|
||||
}
|
||||
|
||||
protected checkRiskLimits(signal: TradingSignal, context: StrategyContext): boolean {
|
||||
const limits = this.config.riskLimits;
|
||||
|
||||
// Check position size limit
|
||||
if (signal.quantity > limits.maxPositionSize) {
|
||||
this.logger.warn('Signal exceeds max position size', {
|
||||
requested: signal.quantity,
|
||||
limit: limits.maxPositionSize
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check daily loss limit
|
||||
if (context.portfolio.dayPnL <= -limits.maxDailyLoss) {
|
||||
this.logger.warn('Daily loss limit reached', {
|
||||
dayPnL: context.portfolio.dayPnL,
|
||||
limit: -limits.maxDailyLoss
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Getters
|
||||
get id(): string {
|
||||
return this.config.id;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.config.name;
|
||||
}
|
||||
|
||||
get active(): boolean {
|
||||
return this.isActive;
|
||||
}
|
||||
|
||||
get configuration(): StrategyConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy execution engine
|
||||
export class StrategyEngine extends EventEmitter {
|
||||
private strategies: Map<string, BaseStrategy> = new Map();
|
||||
private logger;
|
||||
private eventBus: EventBus;
|
||||
private isRunning: boolean = false;
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
super();
|
||||
this.eventBus = eventBus;
|
||||
this.logger = createLogger('strategy-engine');
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Subscribe to market data events
|
||||
await this.eventBus.subscribe('market.data', this.handleMarketData.bind(this));
|
||||
await this.eventBus.subscribe('order.update', this.handleOrderUpdate.bind(this));
|
||||
await this.eventBus.subscribe('portfolio.update', this.handlePortfolioUpdate.bind(this));
|
||||
|
||||
this.logger.info('Strategy engine initialized');
|
||||
}
|
||||
|
||||
async registerStrategy(strategy: BaseStrategy): Promise<void> {
|
||||
if (this.strategies.has(strategy.id)) {
|
||||
throw new Error(`Strategy ${strategy.id} already registered`);
|
||||
}
|
||||
|
||||
this.strategies.set(strategy.id, strategy);
|
||||
|
||||
// Forward strategy events
|
||||
strategy.on('signal', (signal) => this.emit('signal', signal));
|
||||
strategy.on('error', (error) => this.emit('error', error));
|
||||
|
||||
this.logger.info('Strategy registered', { strategyId: strategy.id });
|
||||
}
|
||||
|
||||
async unregisterStrategy(strategyId: string): Promise<void> {
|
||||
const strategy = this.strategies.get(strategyId);
|
||||
if (!strategy) {
|
||||
throw new Error(`Strategy ${strategyId} not found`);
|
||||
}
|
||||
|
||||
if (strategy.active) {
|
||||
await strategy.stop();
|
||||
}
|
||||
|
||||
strategy.removeAllListeners();
|
||||
this.strategies.delete(strategyId);
|
||||
|
||||
this.logger.info('Strategy unregistered', { strategyId });
|
||||
}
|
||||
|
||||
async startStrategy(strategyId: string): Promise<void> {
|
||||
const strategy = this.strategies.get(strategyId);
|
||||
if (!strategy) {
|
||||
throw new Error(`Strategy ${strategyId} not found`);
|
||||
}
|
||||
|
||||
await strategy.start();
|
||||
}
|
||||
|
||||
async stopStrategy(strategyId: string): Promise<void> {
|
||||
const strategy = this.strategies.get(strategyId);
|
||||
if (!strategy) {
|
||||
throw new Error(`Strategy ${strategyId} not found`);
|
||||
}
|
||||
|
||||
await strategy.stop();
|
||||
}
|
||||
|
||||
async startAll(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
this.logger.warn('Engine already running');
|
||||
return;
|
||||
}
|
||||
|
||||
const startPromises = Array.from(this.strategies.values())
|
||||
.filter(strategy => strategy.configuration.enabled)
|
||||
.map(strategy => strategy.start());
|
||||
|
||||
await Promise.all(startPromises);
|
||||
this.isRunning = true;
|
||||
this.logger.info('All strategies started');
|
||||
this.emit('started');
|
||||
}
|
||||
|
||||
async stopAll(): Promise<void> {
|
||||
if (!this.isRunning) {
|
||||
this.logger.warn('Engine not running');
|
||||
return;
|
||||
}
|
||||
|
||||
const stopPromises = Array.from(this.strategies.values())
|
||||
.filter(strategy => strategy.active)
|
||||
.map(strategy => strategy.stop());
|
||||
|
||||
await Promise.all(stopPromises);
|
||||
this.isRunning = false;
|
||||
this.logger.info('All strategies stopped');
|
||||
this.emit('stopped');
|
||||
}
|
||||
|
||||
private async handleMarketData(message: any): Promise<void> {
|
||||
const { symbol, ...data } = message.data;
|
||||
|
||||
// Find strategies that trade this symbol
|
||||
const relevantStrategies = Array.from(this.strategies.values())
|
||||
.filter(strategy =>
|
||||
strategy.active &&
|
||||
strategy.configuration.symbols.includes(symbol)
|
||||
);
|
||||
|
||||
for (const strategy of relevantStrategies) {
|
||||
try {
|
||||
// Create context for this strategy
|
||||
const context: StrategyContext = {
|
||||
symbol,
|
||||
timeframe: '1m', // TODO: Get from strategy config
|
||||
data: new DataFrame([data]), // TODO: Use historical data
|
||||
indicators: {},
|
||||
portfolio: {
|
||||
totalValue: 100000, // TODO: Get real portfolio data
|
||||
cash: 50000,
|
||||
positions: [],
|
||||
totalPnL: 0,
|
||||
dayPnL: 0
|
||||
},
|
||||
timestamp: data.timestamp
|
||||
};
|
||||
|
||||
const signals = await strategy.onMarketData(context);
|
||||
|
||||
for (const signal of signals) {
|
||||
await strategy.onSignal(signal);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Error processing market data for strategy', {
|
||||
error,
|
||||
strategyId: strategy.id,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOrderUpdate(message: any): Promise<void> {
|
||||
// Handle order updates - notify relevant strategies
|
||||
this.logger.debug('Order update received', { data: message.data });
|
||||
}
|
||||
|
||||
private async handlePortfolioUpdate(message: any): Promise<void> {
|
||||
// Handle portfolio updates - notify relevant strategies
|
||||
this.logger.debug('Portfolio update received', { data: message.data });
|
||||
}
|
||||
|
||||
getStrategy(strategyId: string): BaseStrategy | undefined {
|
||||
return this.strategies.get(strategyId);
|
||||
}
|
||||
|
||||
getStrategies(): BaseStrategy[] {
|
||||
return Array.from(this.strategies.values());
|
||||
}
|
||||
|
||||
getActiveStrategies(): BaseStrategy[] {
|
||||
return this.getStrategies().filter(strategy => strategy.active);
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await this.stopAll();
|
||||
this.strategies.clear();
|
||||
this.removeAllListeners();
|
||||
this.logger.info('Strategy engine shutdown');
|
||||
}
|
||||
}
|
||||
19
libs/strategy-engine/tsconfig.json
Normal file
19
libs/strategy-engine/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"],
|
||||
"references": [
|
||||
{ "path": "../logger" },
|
||||
{ "path": "../types" },
|
||||
{ "path": "../event-bus" },
|
||||
{ "path": "../data-frame" },
|
||||
{ "path": "../http-client" },
|
||||
]
|
||||
}
|
||||
24
libs/vector-engine/package.json
Normal file
24
libs/vector-engine/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@stock-bot/vector-engine",
|
||||
"version": "1.0.0",
|
||||
"description": "Vectorized computation engine for high-performance backtesting",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stock-bot/logger": "workspace:*",
|
||||
"@stock-bot/utils": "workspace:*",
|
||||
"@stock-bot/data-frame": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bun-types": "*"
|
||||
}
|
||||
}
|
||||
393
libs/vector-engine/src/index.ts
Normal file
393
libs/vector-engine/src/index.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import { createLogger } from '@stock-bot/logger';
|
||||
import { DataFrame } from '@stock-bot/data-frame';
|
||||
import { atr, sma, ema, rsi, macd, bollingerBands } from '@stock-bot/utils';
|
||||
|
||||
// Vector operations interface
|
||||
export interface VectorOperation {
|
||||
name: string;
|
||||
inputs: string[];
|
||||
output: string;
|
||||
operation: (inputs: number[][]) => number[];
|
||||
}
|
||||
|
||||
// Vectorized strategy context
|
||||
export interface VectorizedContext {
|
||||
data: DataFrame;
|
||||
lookback: number;
|
||||
indicators: Record<string, number[]>;
|
||||
signals: Record<string, number[]>;
|
||||
}
|
||||
|
||||
// Performance metrics for vectorized backtesting
|
||||
export interface VectorizedMetrics {
|
||||
totalReturns: number;
|
||||
sharpeRatio: number;
|
||||
maxDrawdown: number;
|
||||
winRate: number;
|
||||
profitFactor: number;
|
||||
totalTrades: number;
|
||||
avgTrade: number;
|
||||
returns: number[];
|
||||
drawdown: number[];
|
||||
equity: number[];
|
||||
}
|
||||
|
||||
// Vectorized backtest result
|
||||
export interface VectorizedBacktestResult {
|
||||
metrics: VectorizedMetrics;
|
||||
trades: VectorizedTrade[];
|
||||
equity: number[];
|
||||
timestamps: number[];
|
||||
signals: Record<string, number[]>;
|
||||
}
|
||||
|
||||
export interface VectorizedTrade {
|
||||
entryIndex: number;
|
||||
exitIndex: number;
|
||||
entryPrice: number;
|
||||
exitPrice: number;
|
||||
quantity: number;
|
||||
side: 'LONG' | 'SHORT';
|
||||
pnl: number;
|
||||
return: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
// Vectorized strategy engine
|
||||
export class VectorEngine {
|
||||
private logger = createLogger('vector-engine');
|
||||
private operations: Map<string, VectorOperation> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.registerDefaultOperations();
|
||||
}
|
||||
|
||||
private registerDefaultOperations(): void {
|
||||
// Register common mathematical operations
|
||||
this.registerOperation({
|
||||
name: 'add',
|
||||
inputs: ['a', 'b'],
|
||||
output: 'result',
|
||||
operation: ([a, b]) => a.map((val, i) => val + b[i])
|
||||
});
|
||||
|
||||
this.registerOperation({
|
||||
name: 'subtract',
|
||||
inputs: ['a', 'b'],
|
||||
output: 'result',
|
||||
operation: ([a, b]) => a.map((val, i) => val - b[i])
|
||||
});
|
||||
|
||||
this.registerOperation({
|
||||
name: 'multiply',
|
||||
inputs: ['a', 'b'],
|
||||
output: 'result',
|
||||
operation: ([a, b]) => a.map((val, i) => val * b[i])
|
||||
});
|
||||
|
||||
this.registerOperation({
|
||||
name: 'divide',
|
||||
inputs: ['a', 'b'],
|
||||
output: 'result',
|
||||
operation: ([a, b]) => a.map((val, i) => b[i] !== 0 ? val / b[i] : NaN)
|
||||
});
|
||||
|
||||
// Register comparison operations
|
||||
this.registerOperation({
|
||||
name: 'greater_than',
|
||||
inputs: ['a', 'b'],
|
||||
output: 'result',
|
||||
operation: ([a, b]) => a.map((val, i) => val > b[i] ? 1 : 0)
|
||||
});
|
||||
|
||||
this.registerOperation({
|
||||
name: 'less_than',
|
||||
inputs: ['a', 'b'],
|
||||
output: 'result',
|
||||
operation: ([a, b]) => a.map((val, i) => val < b[i] ? 1 : 0)
|
||||
});
|
||||
|
||||
this.registerOperation({
|
||||
name: 'crossover',
|
||||
inputs: ['a', 'b'],
|
||||
output: 'result',
|
||||
operation: ([a, b]) => {
|
||||
const result = new Array(a.length).fill(0);
|
||||
for (let i = 1; i < a.length; i++) {
|
||||
if (a[i] > b[i] && a[i - 1] <= b[i - 1]) {
|
||||
result[i] = 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
this.registerOperation({
|
||||
name: 'crossunder',
|
||||
inputs: ['a', 'b'],
|
||||
output: 'result',
|
||||
operation: ([a, b]) => {
|
||||
const result = new Array(a.length).fill(0);
|
||||
for (let i = 1; i < a.length; i++) {
|
||||
if (a[i] < b[i] && a[i - 1] >= b[i - 1]) {
|
||||
result[i] = 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
registerOperation(operation: VectorOperation): void {
|
||||
this.operations.set(operation.name, operation);
|
||||
this.logger.debug(`Registered operation: ${operation.name}`);
|
||||
}
|
||||
|
||||
// Execute vectorized strategy
|
||||
async executeVectorizedStrategy(
|
||||
data: DataFrame,
|
||||
strategyCode: string
|
||||
): Promise<VectorizedBacktestResult> {
|
||||
try {
|
||||
const context = this.prepareContext(data);
|
||||
const signals = this.executeStrategy(context, strategyCode);
|
||||
const trades = this.generateTrades(data, signals);
|
||||
const metrics = this.calculateMetrics(data, trades);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
trades,
|
||||
equity: metrics.equity,
|
||||
timestamps: data.getColumn('timestamp'),
|
||||
signals
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Vectorized strategy execution failed', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private prepareContext(data: DataFrame): VectorizedContext {
|
||||
const close = data.getColumn('close');
|
||||
const high = data.getColumn('high');
|
||||
const low = data.getColumn('low');
|
||||
const volume = data.getColumn('volume');
|
||||
|
||||
// Calculate common indicators
|
||||
const indicators: Record<string, number[]> = {
|
||||
sma_20: sma(close, 20),
|
||||
sma_50: sma(close, 50),
|
||||
ema_12: ema(close, 12),
|
||||
ema_26: ema(close, 26),
|
||||
rsi: rsi(close),
|
||||
};
|
||||
|
||||
const m = macd(close);
|
||||
indicators.macd = m.macd;
|
||||
indicators.macd_signal = m.signal;
|
||||
indicators.macd_histogram = m.histogram;
|
||||
|
||||
const bb = bollingerBands(close);
|
||||
indicators.bb_upper = bb.upper;
|
||||
indicators.bb_middle = bb.middle;
|
||||
indicators.bb_lower = bb.lower;
|
||||
|
||||
return {
|
||||
data,
|
||||
lookback: 100,
|
||||
indicators,
|
||||
signals: {}
|
||||
};
|
||||
}
|
||||
|
||||
private executeStrategy(context: VectorizedContext, strategyCode: string): Record<string, number[]> {
|
||||
// This is a simplified strategy execution
|
||||
// In production, you'd want a more sophisticated strategy compiler/interpreter
|
||||
const signals: Record<string, number[]> = {
|
||||
buy: new Array(context.data.length).fill(0),
|
||||
sell: new Array(context.data.length).fill(0)
|
||||
};
|
||||
|
||||
// Example: Simple moving average crossover strategy
|
||||
if (strategyCode.includes('sma_crossover')) {
|
||||
const sma20 = context.indicators.sma_20;
|
||||
const sma50 = context.indicators.sma_50;
|
||||
|
||||
for (let i = 1; i < sma20.length; i++) {
|
||||
// Buy signal: SMA20 crosses above SMA50
|
||||
if (!isNaN(sma20[i]) && !isNaN(sma50[i]) &&
|
||||
!isNaN(sma20[i-1]) && !isNaN(sma50[i-1])) {
|
||||
if (sma20[i] > sma50[i] && sma20[i-1] <= sma50[i-1]) {
|
||||
signals.buy[i] = 1;
|
||||
}
|
||||
// Sell signal: SMA20 crosses below SMA50
|
||||
else if (sma20[i] < sma50[i] && sma20[i-1] >= sma50[i-1]) {
|
||||
signals.sell[i] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return signals;
|
||||
}
|
||||
|
||||
private generateTrades(data: DataFrame, signals: Record<string, number[]>): VectorizedTrade[] {
|
||||
const trades: VectorizedTrade[] = [];
|
||||
const close = data.getColumn('close');
|
||||
const timestamps = data.getColumn('timestamp');
|
||||
|
||||
let position: { index: number; price: number; side: 'LONG' | 'SHORT' } | null = null;
|
||||
|
||||
for (let i = 0; i < close.length; i++) {
|
||||
if (signals.buy[i] === 1 && !position) {
|
||||
// Open long position
|
||||
position = {
|
||||
index: i,
|
||||
price: close[i],
|
||||
side: 'LONG'
|
||||
};
|
||||
} else if (signals.sell[i] === 1) {
|
||||
if (position && position.side === 'LONG') {
|
||||
// Close long position
|
||||
const trade: VectorizedTrade = {
|
||||
entryIndex: position.index,
|
||||
exitIndex: i,
|
||||
entryPrice: position.price,
|
||||
exitPrice: close[i],
|
||||
quantity: 1, // Simplified: always trade 1 unit
|
||||
side: 'LONG',
|
||||
pnl: close[i] - position.price,
|
||||
return: (close[i] - position.price) / position.price,
|
||||
duration: timestamps[i] - timestamps[position.index]
|
||||
};
|
||||
trades.push(trade);
|
||||
position = null;
|
||||
} else if (!position) {
|
||||
// Open short position
|
||||
position = {
|
||||
index: i,
|
||||
price: close[i],
|
||||
side: 'SHORT'
|
||||
};
|
||||
}
|
||||
} else if (signals.buy[i] === 1 && position && position.side === 'SHORT') {
|
||||
// Close short position
|
||||
const trade: VectorizedTrade = {
|
||||
entryIndex: position.index,
|
||||
exitIndex: i,
|
||||
entryPrice: position.price,
|
||||
exitPrice: close[i],
|
||||
quantity: 1,
|
||||
side: 'SHORT',
|
||||
pnl: position.price - close[i],
|
||||
return: (position.price - close[i]) / position.price,
|
||||
duration: timestamps[i] - timestamps[position.index]
|
||||
};
|
||||
trades.push(trade);
|
||||
position = null;
|
||||
}
|
||||
}
|
||||
|
||||
return trades;
|
||||
}
|
||||
|
||||
private calculateMetrics(data: DataFrame, trades: VectorizedTrade[]): VectorizedMetrics {
|
||||
if (trades.length === 0) {
|
||||
return {
|
||||
totalReturns: 0,
|
||||
sharpeRatio: 0,
|
||||
maxDrawdown: 0,
|
||||
winRate: 0,
|
||||
profitFactor: 0,
|
||||
totalTrades: 0,
|
||||
avgTrade: 0,
|
||||
returns: [],
|
||||
drawdown: [],
|
||||
equity: []
|
||||
};
|
||||
}
|
||||
|
||||
const returns = trades.map(t => t.return);
|
||||
const pnls = trades.map(t => t.pnl);
|
||||
|
||||
// Calculate equity curve
|
||||
const equity: number[] = [10000]; // Starting capital
|
||||
let currentEquity = 10000;
|
||||
|
||||
for (const trade of trades) {
|
||||
currentEquity += trade.pnl;
|
||||
equity.push(currentEquity);
|
||||
}
|
||||
|
||||
// Calculate drawdown
|
||||
const drawdown: number[] = [];
|
||||
let peak = equity[0];
|
||||
|
||||
for (const eq of equity) {
|
||||
if (eq > peak) peak = eq;
|
||||
drawdown.push((peak - eq) / peak);
|
||||
}
|
||||
|
||||
const totalReturns = (equity[equity.length - 1] - equity[0]) / equity[0];
|
||||
const avgReturn = returns.reduce((sum, r) => sum + r, 0) / returns.length;
|
||||
const returnStd = Math.sqrt(
|
||||
returns.reduce((sum, r) => sum + Math.pow(r - avgReturn, 2), 0) / returns.length
|
||||
);
|
||||
|
||||
const winningTrades = trades.filter(t => t.pnl > 0);
|
||||
const losingTrades = trades.filter(t => t.pnl < 0);
|
||||
|
||||
const grossProfit = winningTrades.reduce((sum, t) => sum + t.pnl, 0);
|
||||
const grossLoss = Math.abs(losingTrades.reduce((sum, t) => sum + t.pnl, 0));
|
||||
|
||||
return {
|
||||
totalReturns,
|
||||
sharpeRatio: returnStd !== 0 ? (avgReturn / returnStd) * Math.sqrt(252) : 0,
|
||||
maxDrawdown: Math.max(...drawdown),
|
||||
winRate: winningTrades.length / trades.length,
|
||||
profitFactor: grossLoss !== 0 ? grossProfit / grossLoss : Infinity,
|
||||
totalTrades: trades.length,
|
||||
avgTrade: pnls.reduce((sum, pnl) => sum + pnl, 0) / trades.length,
|
||||
returns,
|
||||
drawdown,
|
||||
equity
|
||||
};
|
||||
}
|
||||
|
||||
// Utility methods for vectorized operations
|
||||
applyOperation(operationName: string, inputs: Record<string, number[]>): number[] {
|
||||
const operation = this.operations.get(operationName);
|
||||
if (!operation) {
|
||||
throw new Error(`Operation '${operationName}' not found`);
|
||||
}
|
||||
|
||||
const inputArrays = operation.inputs.map(inputName => {
|
||||
if (!inputs[inputName]) {
|
||||
throw new Error(`Input '${inputName}' not provided for operation '${operationName}'`);
|
||||
}
|
||||
return inputs[inputName];
|
||||
});
|
||||
|
||||
return operation.operation(inputArrays);
|
||||
}
|
||||
|
||||
// Batch processing for multiple strategies
|
||||
async batchBacktest(
|
||||
data: DataFrame,
|
||||
strategies: Array<{ id: string; code: string }>
|
||||
): Promise<Record<string, VectorizedBacktestResult>> {
|
||||
const results: Record<string, VectorizedBacktestResult> = {};
|
||||
|
||||
for (const strategy of strategies) {
|
||||
try {
|
||||
this.logger.info(`Running vectorized backtest for strategy: ${strategy.id}`);
|
||||
results[strategy.id] = await this.executeVectorizedStrategy(data, strategy.code);
|
||||
} catch (error) {
|
||||
this.logger.error(`Backtest failed for strategy: ${strategy.id}`, { error });
|
||||
// Continue with other strategies
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
19
libs/vector-engine/tsconfig.json
Normal file
19
libs/vector-engine/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"],
|
||||
"references": [
|
||||
{ "path": "../logger" },
|
||||
{ "path": "../types" },
|
||||
{ "path": "../event-bus" },
|
||||
{ "path": "../data-frame" },
|
||||
{ "path": "../utils" },
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue