Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/investment/portfolio/Portfolio.exception.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
63 changes: 63 additions & 0 deletions src/investment/portfolio/dto/create-portfolio.dto.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
7 changes: 7 additions & 0 deletions src/investment/portfolio/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Check failure on line 7 in src/investment/portfolio/index.ts

View workflow job for this annotation

GitHub Actions / build

Cannot find module './Portfolio-not-found.exception' or its corresponding type declarations.
32 changes: 32 additions & 0 deletions src/investment/portfolio/insufficient-balance.exception.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
52 changes: 52 additions & 0 deletions src/investment/portfolio/optimization-failed.exception.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

constructor(
reason: OptimizationFailureReason = 'UNKNOWN',
context?: Record<string, unknown>,
) {
const reasonMessages: Record<OptimizationFailureReason, string> = {
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;
}
}
44 changes: 44 additions & 0 deletions src/investment/portfolio/portfolio-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -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<Response>();
const request = ctx.getRequest<Request>();

const status = exception.getStatus() ?? HttpStatus.INTERNAL_SERVER_ERROR;
const body = exception.getResponse() as Record<string, unknown>;

this.logger.warn(
`[PortfolioException] ${body['errorCode']} — ${body['message']} | ` +
`${request.method} ${request.url}`,
);

response.status(status).json({
...body,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
16 changes: 16 additions & 0 deletions src/investment/portfolio/portfolio-not-found.exception.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
}
50 changes: 50 additions & 0 deletions src/investment/portfolio/rebalancing-failed.exception.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

constructor(
reason: RebalancingFailureReason = 'UNKNOWN',
context?: Record<string, unknown>,
) {
const reasonMessages: Record<RebalancingFailureReason, string> = {
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;
}
}
Loading