diff --git a/src/investment/portfolio/Portfolio.exception.ts b/src/investment/portfolio/Portfolio.exception.ts new file mode 100644 index 0000000..81abd28 --- /dev/null +++ b/src/investment/portfolio/Portfolio.exception.ts @@ -0,0 +1,23 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +/** + * Base class for all portfolio-domain exceptions. + * Every derived class must supply a machine-readable `errorCode` so API + * consumers can handle specific failure modes without parsing error messages. + */ +export abstract class PortfolioException extends HttpException { + readonly errorCode: string; + + constructor(message: string, errorCode: string, status: HttpStatus) { + super( + { + statusCode: status, + errorCode, + message, + domain: 'portfolio', + }, + status, + ); + this.errorCode = errorCode; + } +} \ No newline at end of file diff --git a/src/investment/portfolio/dto/create-portfolio.dto.ts b/src/investment/portfolio/dto/create-portfolio.dto.ts new file mode 100644 index 0000000..a816eae --- /dev/null +++ b/src/investment/portfolio/dto/create-portfolio.dto.ts @@ -0,0 +1,63 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + IsEnum, + IsNumber, + Min, + Max, + MaxLength, + IsArray, + ArrayMinSize, + ArrayMaxSize, + } from 'class-validator'; + import { Type } from 'class-transformer'; + + export enum RiskTolerance { + CONSERVATIVE = 'conservative', + MODERATE = 'moderate', + AGGRESSIVE = 'aggressive', + } + + export enum RebalancingFrequency { + DAILY = 'daily', + WEEKLY = 'weekly', + MONTHLY = 'monthly', + QUARTERLY = 'quarterly', + MANUAL = 'manual', + } + + export class CreatePortfolioDto { + @IsString() + @IsNotEmpty({ message: 'Portfolio name is required.' }) + @MaxLength(100, { message: 'Portfolio name must not exceed 100 characters.' }) + name: string; + + @IsOptional() + @IsString() + @MaxLength(500, { message: 'Description must not exceed 500 characters.' }) + description?: string; + + @IsEnum(RiskTolerance, { + message: `riskTolerance must be one of: ${Object.values(RiskTolerance).join(', ')}.`, + }) + riskTolerance: RiskTolerance; + + @IsNumber({}, { message: 'initialBalance must be a number.' }) + @Min(0.01, { message: 'initialBalance must be greater than 0.' }) + @Type(() => Number) + initialBalance: number; + + @IsOptional() + @IsEnum(RebalancingFrequency, { + message: `rebalancingFrequency must be one of: ${Object.values(RebalancingFrequency).join(', ')}.`, + }) + rebalancingFrequency?: RebalancingFrequency; + + @IsOptional() + @IsArray() + @ArrayMinSize(1, { message: 'At least one target asset must be provided.' }) + @ArrayMaxSize(50, { message: 'A portfolio may not exceed 50 target assets.' }) + @IsString({ each: true }) + targetAssetIds?: string[]; + } \ No newline at end of file diff --git a/src/investment/portfolio/index.ts b/src/investment/portfolio/index.ts new file mode 100644 index 0000000..3ef40ef --- /dev/null +++ b/src/investment/portfolio/index.ts @@ -0,0 +1,7 @@ +export { PortfolioException } from './Portfolio.exception'; +export { InsufficientBalanceException } from './insufficient-balance.exception'; +export { OptimizationFailedException } from './optimization-failed.exception'; +export type { OptimizationFailureReason } from './optimization-failed.exception'; +export { RebalancingFailedException } from './rebalancing-failed.exception'; +export type { RebalancingFailureReason } from './rebalancing-failed.exception'; +export { PortfolioNotFoundException } from './Portfolio-not-found.exception'; \ No newline at end of file diff --git a/src/investment/portfolio/insufficient-balance.exception.ts b/src/investment/portfolio/insufficient-balance.exception.ts new file mode 100644 index 0000000..1588a74 --- /dev/null +++ b/src/investment/portfolio/insufficient-balance.exception.ts @@ -0,0 +1,32 @@ +import { HttpStatus } from '@nestjs/common'; +import { PortfolioException } from './Portfolio.exception'; + +/** + * Thrown when a financial operation (trade, rebalance, withdrawal) cannot + * proceed because the portfolio lacks sufficient balance. + * + * @example + * throw new InsufficientBalanceException({ + * required: 5000, + * available: 3200, + * currency: 'USD', + * }); + */ +export class InsufficientBalanceException extends PortfolioException { + readonly required: number; + readonly available: number; + readonly currency: string; + + constructor(params: { required: number; available: number; currency?: string }) { + const { required, available, currency = 'USD' } = params; + super( + `Insufficient balance: operation requires ${currency} ${required.toFixed(2)} ` + + `but only ${currency} ${available.toFixed(2)} is available.`, + 'PORTFOLIO_INSUFFICIENT_BALANCE', + HttpStatus.UNPROCESSABLE_ENTITY, + ); + this.required = required; + this.available = available; + this.currency = currency; + } +} \ No newline at end of file diff --git a/src/investment/portfolio/optimization-failed.exception.ts b/src/investment/portfolio/optimization-failed.exception.ts new file mode 100644 index 0000000..83aed61 --- /dev/null +++ b/src/investment/portfolio/optimization-failed.exception.ts @@ -0,0 +1,52 @@ +import { HttpStatus } from '@nestjs/common'; +import { PortfolioException } from './Portfolio.exception'; + +export type OptimizationFailureReason = + | 'CONVERGENCE_FAILURE' // Solver did not converge within iteration limit + | 'INFEASIBLE_CONSTRAINTS' // Constraints are mutually exclusive + | 'INSUFFICIENT_ASSETS' // Not enough eligible assets to optimise + | 'MODEL_UNAVAILABLE' // ML model endpoint unreachable / timed out + | 'INVALID_RISK_PARAMETERS' // Risk tolerance input outside supported range + | 'UNKNOWN'; + +/** + * Thrown when the portfolio optimisation engine (mean-variance, ML-based, etc.) + * cannot produce a valid allocation for the given inputs. + * + * @example + * throw new OptimizationFailedException( + * 'CONVERGENCE_FAILURE', + * { iterations: 1000, tolerance: 1e-6 }, + * ); + */ +export class OptimizationFailedException extends PortfolioException { + readonly reason: OptimizationFailureReason; + readonly context?: Record; + + constructor( + reason: OptimizationFailureReason = 'UNKNOWN', + context?: Record, + ) { + const reasonMessages: Record = { + CONVERGENCE_FAILURE: + 'Portfolio optimisation did not converge. Try relaxing constraints or increasing the iteration limit.', + INFEASIBLE_CONSTRAINTS: + 'The provided constraints cannot be satisfied simultaneously. Review allocation bounds.', + INSUFFICIENT_ASSETS: + 'Not enough eligible assets are available to run optimisation. A minimum of 2 assets is required.', + MODEL_UNAVAILABLE: + 'The ML optimisation model is currently unavailable. Please retry shortly.', + INVALID_RISK_PARAMETERS: + 'Risk tolerance parameters are outside the supported range [0, 1].', + UNKNOWN: 'Portfolio optimisation failed for an unknown reason.', + }; + + super( + reasonMessages[reason], + 'PORTFOLIO_OPTIMIZATION_FAILED', + HttpStatus.UNPROCESSABLE_ENTITY, + ); + this.reason = reason; + this.context = context; + } +} \ No newline at end of file diff --git a/src/investment/portfolio/portfolio-exception.filter.ts b/src/investment/portfolio/portfolio-exception.filter.ts new file mode 100644 index 0000000..c464ebd --- /dev/null +++ b/src/investment/portfolio/portfolio-exception.filter.ts @@ -0,0 +1,44 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpStatus, + Logger, + } from '@nestjs/common'; + import { Request, Response } from 'express'; +import { PortfolioException } from './Portfolio.exception'; + + /** + * Catches any `PortfolioException` (and subclasses) and serialises it into a + * consistent structured JSON envelope. + * + * Register globally in `main.ts`: + * ```ts + * app.useGlobalFilters(new PortfolioExceptionFilter()); + * ``` + * Or per-controller via `@UseFilters(PortfolioExceptionFilter)`. + */ + @Catch(PortfolioException) + export class PortfolioExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(PortfolioExceptionFilter.name); + + catch(exception: PortfolioException, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = exception.getStatus() ?? HttpStatus.INTERNAL_SERVER_ERROR; + const body = exception.getResponse() as Record; + + this.logger.warn( + `[PortfolioException] ${body['errorCode']} — ${body['message']} | ` + + `${request.method} ${request.url}`, + ); + + response.status(status).json({ + ...body, + timestamp: new Date().toISOString(), + path: request.url, + }); + } + } \ No newline at end of file diff --git a/src/investment/portfolio/portfolio-not-found.exception.ts b/src/investment/portfolio/portfolio-not-found.exception.ts new file mode 100644 index 0000000..28936c2 --- /dev/null +++ b/src/investment/portfolio/portfolio-not-found.exception.ts @@ -0,0 +1,16 @@ +import { HttpStatus } from '@nestjs/common'; +import { PortfolioException } from './Portfolio.exception'; + +/** + * Thrown when a portfolio lookup by ID yields no result. + * Keeps 404 semantics domain-specific so filters can handle it precisely. + */ +export class PortfolioNotFoundException extends PortfolioException { + constructor(portfolioId: string) { + super( + `Portfolio with id "${portfolioId}" was not found.`, + 'PORTFOLIO_NOT_FOUND', + HttpStatus.NOT_FOUND, + ); + } +} \ No newline at end of file diff --git a/src/investment/portfolio/rebalancing-failed.exception.ts b/src/investment/portfolio/rebalancing-failed.exception.ts new file mode 100644 index 0000000..dd3cf53 --- /dev/null +++ b/src/investment/portfolio/rebalancing-failed.exception.ts @@ -0,0 +1,50 @@ +import { HttpStatus } from '@nestjs/common'; +import { PortfolioException } from './Portfolio.exception'; + +export type RebalancingFailureReason = + | 'MARKET_CLOSED' // Cannot trade outside market hours + | 'PRICE_STALENESS' // Asset prices are too stale to rebalance safely + | 'ALLOCATION_DRIFT_SAFE' // Drift is within tolerance; rebalance not needed + | 'PARTIAL_EXECUTION' // Some legs of the rebalance failed to execute + | 'LOCK_CONFLICT' // Another rebalance is already in progress + | 'UNKNOWN'; + +/** + * Thrown when a portfolio rebalancing operation cannot be completed. + * Distinct from `OptimizationFailedException` — this covers execution-time + * failures rather than planning-time failures. + * + * @example + * throw new RebalancingFailedException('MARKET_CLOSED', { openAt: '09:30 EST' }); + */ +export class RebalancingFailedException extends PortfolioException { + readonly reason: RebalancingFailureReason; + readonly context?: Record; + + constructor( + reason: RebalancingFailureReason = 'UNKNOWN', + context?: Record, + ) { + const reasonMessages: Record = { + MARKET_CLOSED: + 'Rebalancing cannot proceed while the market is closed.', + PRICE_STALENESS: + 'Asset prices are too stale to execute a safe rebalance. Prices will be refreshed automatically.', + ALLOCATION_DRIFT_SAFE: + 'Portfolio allocation drift is within tolerance thresholds. No rebalancing is required.', + PARTIAL_EXECUTION: + 'Rebalancing partially executed. Some trade legs failed. Manual review is required.', + LOCK_CONFLICT: + 'A rebalancing operation is already in progress for this portfolio. Please wait.', + UNKNOWN: 'Rebalancing failed for an unknown reason.', + }; + + super( + reasonMessages[reason], + 'PORTFOLIO_REBALANCING_FAILED', + HttpStatus.UNPROCESSABLE_ENTITY, + ); + this.reason = reason; + this.context = context; + } +} \ No newline at end of file