diff --git a/src/config/swagger.config.ts b/src/config/swagger.config.ts index a14a3409..ccde36a3 100644 --- a/src/config/swagger.config.ts +++ b/src/config/swagger.config.ts @@ -5,7 +5,18 @@ export function setupSwagger(app: INestApplication): void { const config = new DocumentBuilder() .setTitle("StellAIverse Backend API") .setDescription( - "Comprehensive API documentation for StellAIverse backend services including agent management, oracle submissions, compute operations, and audit trails", + "Comprehensive API documentation for StellAIverse backend services including " + + "portfolio management, agent management, oracle submissions, compute operations, " + + "and audit trails.\n\n" + + "## Portfolio Management\n" + + "Endpoints for portfolio CRUD operations, asset management, optimization, " + + "rebalancing, performance analytics, backtesting, and ML predictions.\n\n" + + "### Rate Limiting\n" + + "- **Global**: 100 requests/minute\n" + + "- **Trading (Portfolio)**: 20 requests/minute\n" + + "- **Auth**: 5 requests/minute\n\n" + + "### Authentication\n" + + "All portfolio endpoints require JWT Bearer token authentication.", ) .setVersion("1.0.0") .setContact( @@ -41,6 +52,44 @@ export function setupSwagger(app: INestApplication): void { .addTag("Oracle", "Oracle data submissions") .addTag("Audit", "Audit trail and logging") .addTag("Profile", "User profile management") + .addTag( + "Portfolio Management", + "Portfolio CRUD, optimization, rebalancing, and analytics. " + + "Includes endpoints for creating, reading, updating, and archiving portfolios, " + + "managing assets, running optimizations, and tracking performance.", + ) + .addTag( + "Portfolio Assets", + "Asset (holding) management within portfolios. " + + "Supports multi-chain assets with cost basis tracking and unrealized gain/loss.", + ) + .addTag( + "Portfolio Optimization", + "Portfolio optimization using Modern Portfolio Theory, " + + "Black-Litterman model, risk parity, and ML-based strategies.", + ) + .addTag( + "Portfolio Rebalancing", + "Portfolio rebalancing triggers, execution, and history. " + + "Supports manual, time-based, threshold-based, and ML-triggered rebalancing.", + ) + .addTag( + "Portfolio Analytics", + "Performance analytics including returns, volatility, Sharpe ratio, " + + "Sortino ratio, VaR, drawdown, and benchmark comparison.", + ) + .addTag( + "Portfolio Backtesting", + "Historical backtesting of portfolio strategies with performance metrics.", + ) + .addTag( + "ML Predictions", + "Machine learning-based price predictions for portfolio assets.", + ) + .addTag( + "Portfolio Transactions", + "Transaction recording, history, cost basis calculation, and export.", + ) .build(); const document = SwaggerModule.createDocument(app, config, { @@ -66,6 +115,8 @@ export function setupSwagger(app: INestApplication): void { defaultModelsExpandDepth: 2, defaultModelExpandDepth: 2, tryItOutEnabled: true, + tagsSorter: "alpha", + operationsSorter: "alpha", }, }); } \ No newline at end of file diff --git a/src/portfolio/dto/backtest.dto.ts b/src/portfolio/dto/backtest.dto.ts index acc494ce..6bd6f99d 100644 --- a/src/portfolio/dto/backtest.dto.ts +++ b/src/portfolio/dto/backtest.dto.ts @@ -5,63 +5,153 @@ import { IsDateString, IsEnum, IsArray, + Min, } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { BacktestStatus } from "../entities/backtest-result.entity"; export class CreateBacktestDto { + @ApiProperty({ + description: "Backtest name", + example: "BTC/ETH 60/40 Strategy Test", + }) @IsString() name: string; + @ApiPropertyOptional({ + description: "Backtest description", + example: "Testing balanced crypto allocation over 1 year", + }) @IsOptional() @IsString() description?: string; + @ApiProperty({ + description: "Backtest start date (ISO 8601)", + example: "2025-01-01", + }) @IsDateString() startDate: string; + @ApiProperty({ + description: "Backtest end date (ISO 8601)", + example: "2026-01-01", + }) @IsDateString() endDate: string; + @ApiProperty({ + description: "Initial capital in USD", + example: 100000, + minimum: 0, + }) @IsNumber() + @Min(0) initialCapital: number; + @ApiProperty({ + description: "Strategy name or description", + example: "equal_weight", + }) @IsString() strategy: string; + @ApiProperty({ + description: "Asset weights for backtest", + example: [ + { ticker: "BTC", weight: 60 }, + { ticker: "ETH", weight: 40 }, + ], + }) @IsArray() assets: Array<{ ticker: string; weight: number }>; + @ApiPropertyOptional({ + description: "Benchmark ticker for comparison", + example: "SPY", + }) @IsOptional() @IsString() benchmarkTicker?: string; + @ApiPropertyOptional({ + description: "Rebalancing frequency in months", + example: 3, + minimum: 1, + }) @IsOptional() @IsNumber() - rebalanceFrequency?: number; // months + @Min(1) + rebalanceFrequency?: number; } export class BacktestResultResponseDto { + @ApiProperty({ description: "Backtest UUID" }) id: string; + + @ApiProperty({ description: "Backtest name" }) name: string; + + @ApiPropertyOptional({ description: "Backtest description" }) description?: string; + + @ApiProperty({ description: "Backtest status", enum: BacktestStatus }) status: BacktestStatus; + + @ApiProperty({ description: "Backtest start date" }) startDate: Date; + + @ApiProperty({ description: "Backtest end date" }) endDate: Date; + + @ApiProperty({ description: "Initial capital in USD" }) initialCapital: number; + + @ApiPropertyOptional({ description: "Final portfolio value in USD" }) finalValue?: number; + + @ApiPropertyOptional({ description: "Total return percentage" }) totalReturn?: number; + + @ApiPropertyOptional({ description: "Annualized return percentage" }) annualizedReturn?: number; + + @ApiPropertyOptional({ description: "Annualized volatility" }) volatility?: number; + + @ApiPropertyOptional({ description: "Sharpe ratio" }) sharpeRatio?: number; + + @ApiPropertyOptional({ description: "Sortino ratio" }) sortinoRatio?: number; + + @ApiPropertyOptional({ description: "Maximum drawdown percentage" }) maxDrawdown?: number; + + @ApiPropertyOptional({ description: "Benchmark total return" }) benchmarkReturn?: number; + + @ApiPropertyOptional({ description: "Alpha vs benchmark" }) alpha?: number; + + @ApiPropertyOptional({ description: "Beta vs benchmark" }) beta?: number; + + @ApiPropertyOptional({ description: "Correlation with benchmark" }) Correlation?: number; + + @ApiPropertyOptional({ description: "Total number of trades" }) totalTrades?: number; + + @ApiPropertyOptional({ description: "Win rate (0-1)" }) winRate?: number; + + @ApiPropertyOptional({ description: "Profit factor" }) profitFactor?: number; + + @ApiProperty({ description: "Creation timestamp" }) createdAt: Date; + + @ApiPropertyOptional({ description: "Completion timestamp" }) completedAt?: Date; } diff --git a/src/portfolio/dto/optimization.dto.ts b/src/portfolio/dto/optimization.dto.ts index d2140841..6b133ae5 100644 --- a/src/portfolio/dto/optimization.dto.ts +++ b/src/portfolio/dto/optimization.dto.ts @@ -7,78 +7,159 @@ import { IsJSON, IsDateString, } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { OptimizationMethod, OptimizationStatus, } from "../entities/optimization-history.entity"; export class CreateOptimizationDto { + @ApiProperty({ + description: "Portfolio optimization method", + enum: OptimizationMethod, + example: OptimizationMethod.MEAN_VARIANCE, + }) @IsEnum(OptimizationMethod) method: OptimizationMethod; + @ApiProperty({ + description: "Portfolio UUID to optimize", + example: "550e8400-e29b-41d4-a716-446655440000", + }) @IsString() portfolioId: string; + @ApiPropertyOptional({ + description: "Custom optimization parameters", + example: { riskFreeRate: 0.02, maxIterations: 1000 }, + }) @IsOptional() @IsJSON() parameters?: Record; + @ApiPropertyOptional({ + description: "Risk profile UUID to apply", + example: "660e8400-e29b-41d4-a716-446655440001", + }) @IsOptional() @IsString() riskProfileId?: string; + @ApiPropertyOptional({ + description: "Target annual return (0-1)", + example: 0.12, + minimum: 0, + maximum: 1, + }) @IsOptional() @IsNumber() targetReturn?: number; + @ApiPropertyOptional({ + description: "Maximum allowable volatility", + example: 0.2, + minimum: 0, + }) @IsOptional() @IsNumber() maxVolatility?: number; + @ApiPropertyOptional({ + description: "Asset-level allocation constraints", + example: [{ asset: "BTC", min: 0.1, max: 0.4 }], + }) @IsOptional() @IsArray() constraints?: Array<{ asset: string; min: number; max: number }>; } export class ApproveOptimizationDto { + @ApiProperty({ + description: "Optimization UUID to approve", + example: "770e8400-e29b-41d4-a716-446655440002", + }) @IsString() optimizationId: string; + @ApiPropertyOptional({ + description: "Approval notes", + example: "Looks good, implementing allocation changes", + }) @IsOptional() @IsString() notes?: string; } export class RejectOptimizationDto { + @ApiProperty({ + description: "Optimization UUID to reject", + }) @IsString() optimizationId: string; + @ApiProperty({ + description: "Rejection reason", + example: "Too aggressive for current market conditions", + }) @IsString() rejectionReason: string; } export class ImplementOptimizationDto { + @ApiProperty({ + description: "Optimization UUID to implement", + }) @IsString() optimizationId: string; + @ApiPropertyOptional({ + description: "Execution notes", + }) @IsOptional() @IsString() executionNotes?: string; } export class OptimizationHistoryResponseDto { + @ApiProperty({ description: "Optimization UUID" }) id: string; + + @ApiProperty({ description: "Optimization method", enum: OptimizationMethod }) method: OptimizationMethod; + + @ApiProperty({ description: "Optimization status", enum: OptimizationStatus }) status: OptimizationStatus; + + @ApiProperty({ description: "Suggested allocation map" }) suggestedAllocation: Record; + + @ApiPropertyOptional({ description: "Expected annual return" }) expectedReturn?: number; + + @ApiPropertyOptional({ description: "Expected volatility" }) expectedVolatility?: number; + + @ApiPropertyOptional({ description: "Expected Sharpe ratio" }) expectedSharpeRatio?: number; + + @ApiPropertyOptional({ description: "Value at Risk (95%)" }) valueAtRisk?: number; + + @ApiPropertyOptional({ description: "Maximum drawdown" }) maxDrawdown?: number; + + @ApiPropertyOptional({ description: "Improvement score vs current allocation (%)" }) improvementScore?: number; + + @ApiPropertyOptional({ description: "Backtested performance metrics" }) backtestedMetrics?: Record; + + @ApiProperty({ description: "Creation timestamp" }) createdAt: Date; + + @ApiPropertyOptional({ description: "Completion timestamp" }) completedAt?: Date; + + @ApiPropertyOptional({ description: "Implementation timestamp" }) implementedAt?: Date; } diff --git a/src/portfolio/dto/portfolio-asset.dto.ts b/src/portfolio/dto/portfolio-asset.dto.ts index c6c227e0..ee41722b 100644 --- a/src/portfolio/dto/portfolio-asset.dto.ts +++ b/src/portfolio/dto/portfolio-asset.dto.ts @@ -5,10 +5,19 @@ import { Matches, Length, IsEnum, + Min, + Max, } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Chain } from "../entities/portfolio-asset.entity"; export class PortfolioAssetDto { + @ApiProperty({ + description: "Asset ticker symbol (3-10 uppercase alphanumeric characters)", + example: "BTC", + minLength: 3, + maxLength: 10, + }) @IsString() @Length(3, 10) @Matches(/^[A-Z0-9]+$/, { @@ -16,27 +25,57 @@ export class PortfolioAssetDto { }) ticker: string; + @ApiProperty({ + description: "Asset name", + example: "Bitcoin", + }) @IsString() name: string; + @ApiPropertyOptional({ + description: "Blockchain chain", + enum: Chain, + default: Chain.ETHEREUM, + }) @IsOptional() @IsEnum(Chain) chain?: Chain; + @ApiPropertyOptional({ + description: "Quantity held", + example: 1.5, + minimum: 0, + }) @IsOptional() @IsNumber() quantity?: number; + @ApiPropertyOptional({ + description: "Current price per unit in USD", + example: 45000, + minimum: 0, + }) @IsOptional() @IsNumber() currentPrice?: number; + @ApiPropertyOptional({ + description: "Total cost basis in USD", + example: 44000, + minimum: 0, + }) @IsOptional() @IsNumber() costBasis?: number; } export class AddAssetToPortfolioDto { + @ApiProperty({ + description: "Asset ticker symbol (3-10 uppercase alphanumeric characters)", + example: "ETH", + minLength: 3, + maxLength: 10, + }) @IsString() @Length(3, 10) @Matches(/^[A-Z0-9]+$/, { @@ -44,55 +83,126 @@ export class AddAssetToPortfolioDto { }) ticker: string; + @ApiProperty({ + description: "Asset name", + example: "Ethereum", + }) @IsString() name: string; + @ApiProperty({ + description: "Blockchain chain", + enum: Chain, + example: Chain.ETHEREUM, + }) @IsEnum(Chain) chain: Chain; + @ApiProperty({ + description: "Quantity to add (must be non-negative)", + example: 10, + minimum: 0, + }) @IsNumber() quantity: number; + @ApiPropertyOptional({ + description: "Current price per unit in USD", + example: 3000, + minimum: 0, + }) @IsOptional() @IsNumber() currentPrice?: number; + @ApiPropertyOptional({ + description: "Cost basis in USD", + example: 2900, + minimum: 0, + }) @IsOptional() @IsNumber() costBasis?: number; } export class UpdatePortfolioAssetDto { + @ApiPropertyOptional({ + description: "Updated quantity (must be non-negative)", + example: 15, + minimum: 0, + }) @IsOptional() @IsNumber() quantity?: number; + @ApiPropertyOptional({ + description: "Updated current price per unit", + example: 3200, + minimum: 0, + }) @IsOptional() @IsNumber() currentPrice?: number; + @ApiPropertyOptional({ + description: "Updated cost basis", + example: 3000, + minimum: 0, + }) @IsOptional() @IsNumber() costBasis?: number; + @ApiPropertyOptional({ + description: "Update blockchain chain", + enum: Chain, + example: Chain.POLYGON, + }) @IsOptional() @IsEnum(Chain) chain?: Chain; } export class PortfolioAssetResponseDto { + @ApiProperty({ description: "Asset UUID" }) id: string; + + @ApiProperty({ description: "Asset ticker", example: "BTC" }) ticker: string; + + @ApiProperty({ description: "Asset name", example: "Bitcoin" }) name: string; + + @ApiProperty({ description: "Asset type" }) type: string; + + @ApiProperty({ description: "Quantity held", example: 1.5 }) quantity: number; + + @ApiPropertyOptional({ description: "Current price per unit" }) currentPrice?: number; + + @ApiProperty({ description: "Total value in USD", example: 67500 }) value: number; + + @ApiProperty({ description: "Portfolio allocation percentage", example: 30.5 }) allocationPercentage: number; + + @ApiPropertyOptional({ description: "Optimization-suggested allocation" }) suggestedAllocation?: number; + + @ApiPropertyOptional({ description: "Expected annual return" }) expectedReturn?: number; + + @ApiPropertyOptional({ description: "Annual volatility" }) volatility?: number; + + @ApiPropertyOptional({ description: "Market beta" }) beta?: number; + + @ApiPropertyOptional({ description: "Unrealized gain/loss in USD" }) unrealizedGain?: number; + + @ApiProperty({ description: "Last update timestamp" }) updatedAt: Date; } diff --git a/src/portfolio/dto/portfolio.dto.ts b/src/portfolio/dto/portfolio.dto.ts index 60371fef..da498795 100644 --- a/src/portfolio/dto/portfolio.dto.ts +++ b/src/portfolio/dto/portfolio.dto.ts @@ -11,49 +11,100 @@ import { Length, } from "class-validator"; import { Type } from "class-transformer"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { PortfolioStatus, PortfolioType } from "../entities/portfolio.entity"; export class CreatePortfolioDto { + @ApiProperty({ + description: "Portfolio name (3-100 characters, must be unique)", + example: "My Growth Fund", + minLength: 3, + maxLength: 100, + }) @IsString() @Length(3, 100, { message: "Portfolio name must be between 3 and 100 characters", }) name: string; + @ApiPropertyOptional({ + description: "Portfolio description", + example: "Long-term growth portfolio focused on tech stocks", + }) @IsOptional() @IsString() description?: string; + @ApiPropertyOptional({ + description: "Portfolio type", + enum: PortfolioType, + default: PortfolioType.BALANCED, + example: PortfolioType.AGGRESSIVE, + }) @IsOptional() @IsEnum(PortfolioType) type?: PortfolioType; + @ApiPropertyOptional({ + description: "Initial total portfolio value", + example: 100000, + }) @IsOptional() @IsNumber() totalValue?: number; + @ApiPropertyOptional({ + description: "Initial asset allocation as ticker-to-percentage map", + example: { BTC: 60, ETH: 40 }, + }) @IsOptional() @IsObject() initialAllocation?: Record; + @ApiPropertyOptional({ + description: "Additional portfolio metadata", + example: { riskLevel: "moderate", benchmark: "SPY" }, + }) @IsOptional() @IsObject() metadata?: Record; + @ApiPropertyOptional({ + description: "Enable automatic rebalancing", + default: false, + }) @IsOptional() @IsBoolean() autoRebalanceEnabled?: boolean; + @ApiPropertyOptional({ + description: "Rebalancing frequency", + enum: ["daily", "weekly", "monthly", "quarterly"], + example: "monthly", + }) @IsOptional() @IsString() rebalanceFrequency?: "daily" | "weekly" | "monthly" | "quarterly"; + @ApiPropertyOptional({ + description: "Rebalancing drift threshold percentage (1-50)", + minimum: 1, + maximum: 50, + default: 5, + example: 10, + }) @IsOptional() @IsNumber() rebalanceThreshold?: number; } export class UpdatePortfolioDto { + @ApiPropertyOptional({ + description: "Updated portfolio name (3-100 characters, must be unique)", + example: "Renamed Growth Fund", + minLength: 3, + maxLength: 100, + }) @IsOptional() @IsString() @Length(3, 100, { @@ -61,58 +112,118 @@ export class UpdatePortfolioDto { }) name?: string; + @ApiPropertyOptional({ + description: "Updated portfolio description", + example: "Updated description for long-term growth", + }) @IsOptional() @IsString() description?: string; + @ApiPropertyOptional({ + description: "Updated portfolio status", + enum: PortfolioStatus, + example: PortfolioStatus.ACTIVE, + }) @IsOptional() @IsEnum(PortfolioStatus) status?: PortfolioStatus; + @ApiPropertyOptional({ + description: "Updated portfolio type", + enum: PortfolioType, + example: PortfolioType.CONSERVATIVE, + }) @IsOptional() @IsEnum(PortfolioType) type?: PortfolioType; + @ApiPropertyOptional({ + description: "Updated target allocation as ticker-to-percentage map", + example: { BTC: 50, ETH: 30, SOL: 20 }, + }) @IsOptional() @IsObject() targetAllocation?: Record; + @ApiPropertyOptional({ + description: "Enable/disable automatic rebalancing", + example: true, + }) @IsOptional() @IsBoolean() autoRebalanceEnabled?: boolean; + @ApiPropertyOptional({ + description: "Updated rebalancing frequency", + enum: ["daily", "weekly", "monthly", "quarterly"], + example: "weekly", + }) @IsOptional() @IsString() rebalanceFrequency?: "daily" | "weekly" | "monthly" | "quarterly"; + @ApiPropertyOptional({ + description: "Updated rebalancing drift threshold (1-50%)", + minimum: 1, + maximum: 50, + example: 8, + }) @IsOptional() @IsNumber() rebalanceThreshold?: number; + @ApiPropertyOptional({ + description: "Updated portfolio metadata", + example: { notes: "Adjusted risk tolerance" }, + }) @IsOptional() @IsObject() metadata?: Record; } export class QueryPortfolioDto { + @ApiPropertyOptional({ + description: "Filter by portfolio status", + enum: PortfolioStatus, + }) @IsOptional() @IsEnum(PortfolioStatus) status?: PortfolioStatus; + @ApiPropertyOptional({ + description: "Filter by portfolio type", + enum: PortfolioType, + }) @IsOptional() @IsEnum(PortfolioType) type?: PortfolioType; + @ApiPropertyOptional({ + description: "Case-insensitive name search", + example: "growth", + }) @IsOptional() @IsString() search?: string; + @ApiPropertyOptional({ + description: "Page number (1-indexed)", + minimum: 1, + default: 1, + }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number = 1; + @ApiPropertyOptional({ + description: "Items per page (1-100)", + minimum: 1, + maximum: 100, + default: 20, + }) @IsOptional() @Type(() => Number) @IsInt() @@ -122,27 +233,65 @@ export class QueryPortfolioDto { } export class PaginatedPortfoliosDto { + @ApiProperty({ description: "List of portfolios", type: [PortfolioResponseDto] }) data: PortfolioResponseDto[]; + + @ApiProperty({ description: "Total number of portfolios", example: 42 }) total: number; + + @ApiProperty({ description: "Current page number", example: 1 }) page: number; + + @ApiProperty({ description: "Items per page", example: 20 }) limit: number; + + @ApiProperty({ description: "Total number of pages", example: 3 }) totalPages: number; } export class PortfolioResponseDto { + @ApiProperty({ description: "Portfolio UUID", example: "550e8400-e29b-41d4-a716-446655440000" }) id: string; + + @ApiProperty({ description: "Portfolio name", example: "My Growth Fund" }) name: string; + + @ApiPropertyOptional({ description: "Portfolio description" }) description?: string; + + @ApiProperty({ description: "Portfolio status", enum: PortfolioStatus, example: PortfolioStatus.ACTIVE }) status: PortfolioStatus; + + @ApiProperty({ description: "Portfolio type", enum: PortfolioType, example: PortfolioType.BALANCED }) type: PortfolioType; + + @ApiProperty({ description: "Total portfolio value in USD", example: 50000 }) totalValue: number; + + @ApiProperty({ description: "Current asset allocation percentage map", example: { BTC: 60, ETH: 40 } }) currentAllocation: Record; + + @ApiPropertyOptional({ description: "Target allocation from optimization" }) targetAllocation?: Record; + + @ApiPropertyOptional({ description: "Initial allocation at creation" }) initialAllocation?: Record; + + @ApiProperty({ description: "Auto-rebalance enabled", example: false }) autoRebalanceEnabled: boolean; + + @ApiPropertyOptional({ description: "Rebalancing frequency", example: "monthly" }) rebalanceFrequency?: string; + + @ApiProperty({ description: "Rebalancing drift threshold", example: 5 }) rebalanceThreshold: number; + + @ApiPropertyOptional({ description: "Last rebalancing date" }) lastRebalanceDate?: Date; + + @ApiProperty({ description: "Creation timestamp" }) createdAt: Date; + + @ApiProperty({ description: "Last update timestamp" }) updatedAt: Date; } diff --git a/src/portfolio/dto/rebalancing.dto.ts b/src/portfolio/dto/rebalancing.dto.ts index f3a59b2b..e4613966 100644 --- a/src/portfolio/dto/rebalancing.dto.ts +++ b/src/portfolio/dto/rebalancing.dto.ts @@ -8,82 +8,169 @@ import { IsDateString, IsBoolean, } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { RebalanceTrigger, RebalanceStatus, } from "../entities/rebalancing-event.entity"; export class TriggerRebalancingDto { + @ApiProperty({ + description: "Portfolio UUID to rebalance", + example: "550e8400-e29b-41d4-a716-446655440000", + }) @IsString() portfolioId: string; + @ApiProperty({ + description: "Rebalancing trigger type", + enum: RebalanceTrigger, + example: RebalanceTrigger.MANUAL, + }) @IsEnum(RebalanceTrigger) trigger: RebalanceTrigger; + @ApiPropertyOptional({ + description: "Reason for rebalancing", + example: "Quarterly rebalance scheduled", + }) @IsOptional() @IsString() triggerReason?: string; + @ApiPropertyOptional({ + description: "Run as dry run without executing trades", + default: false, + }) @IsOptional() @IsBoolean() dryRun?: boolean; + @ApiPropertyOptional({ + description: "Custom target allocation override", + example: { BTC: 50, ETH: 30, SOL: 20 }, + }) @IsOptional() @IsJSON() customAllocation?: Record; } export class ApproveRebalancingDto { + @ApiProperty({ + description: "Rebalancing event UUID to approve", + }) @IsString() rebalancingEventId: string; + @ApiPropertyOptional({ + description: "Approval notes", + }) @IsOptional() @IsString() notes?: string; } export class ExecuteRebalancingDto { + @ApiProperty({ + description: "Rebalancing event UUID to execute", + }) @IsString() rebalancingEventId: string; + @ApiPropertyOptional({ + description: "Execution notes", + example: "Executed during market hours, low slippage", + }) @IsOptional() @IsString() executionNotes?: string; + @ApiPropertyOptional({ + description: "Actual transaction cost in USD", + example: 12.50, + minimum: 0, + }) @IsOptional() @IsNumber() actualCost?: number; + @ApiPropertyOptional({ + description: "Execution slippage percentage", + example: 0.05, + minimum: 0, + }) @IsOptional() @IsNumber() executionSlippage?: number; } export class CancelRebalancingDto { + @ApiProperty({ + description: "Rebalancing event UUID to cancel", + }) @IsString() rebalancingEventId: string; + @ApiProperty({ + description: "Cancellation reason", + example: "Market conditions changed, postponing rebalance", + }) @IsString() reason: string; } export class RebalancingEventResponseDto { + @ApiPropertyOptional({ description: "Rebalancing event UUID" }) id?: string; + + @ApiPropertyOptional({ description: "Trigger type", enum: RebalanceTrigger }) trigger?: RebalanceTrigger; + + @ApiPropertyOptional({ description: "Event status", enum: RebalanceStatus }) status?: RebalanceStatus; + + @ApiPropertyOptional({ description: "Trigger reason" }) triggerReason?: string; + + @ApiProperty({ description: "Allocation before rebalancing" }) allocationBefore: Record; + + @ApiProperty({ description: "Target allocation after rebalancing" }) allocationAfter: Record; + + @ApiProperty({ description: "Required trades" }) trades: Array; + + @ApiPropertyOptional({ description: "Estimated transaction cost in USD" }) estimatedCost?: number; + + @ApiPropertyOptional({ description: "Actual transaction cost in USD" }) actualCost?: number; + + @ApiPropertyOptional({ description: "Tax impact in USD" }) taxImpact?: number; + + @ApiPropertyOptional({ description: "Maximum allocation drift from target (%)" }) maxAllocationDrift?: number; + + @ApiPropertyOptional({ description: "Average allocation drift from target (%)" }) avgAllocationDrift?: number; + + @ApiPropertyOptional({ description: "Expected return improvement" }) expectedReturnImprovement?: number; + + @ApiPropertyOptional({ description: "Volatility change" }) volatilityChange?: number; + + @ApiPropertyOptional({ description: "Event creation timestamp" }) createdAt?: Date; + + @ApiPropertyOptional({ description: "Execution timestamp" }) executedAt?: Date; + + @ApiPropertyOptional({ description: "Completion timestamp" }) completedAt?: Date; + + @ApiPropertyOptional({ description: "Allocation drift per ticker" }) allocationDrift?: Record; } diff --git a/src/portfolio/exceptions/portfolio.exceptions.ts b/src/portfolio/exceptions/portfolio.exceptions.ts index 59af4252..de0047d5 100644 --- a/src/portfolio/exceptions/portfolio.exceptions.ts +++ b/src/portfolio/exceptions/portfolio.exceptions.ts @@ -2,6 +2,7 @@ import { BadRequestException, ConflictException, NotFoundException, + ForbiddenException, } from "@nestjs/common"; export class PortfolioNotFoundException extends NotFoundException { @@ -10,6 +11,14 @@ export class PortfolioNotFoundException extends NotFoundException { } } +export class PortfolioAccessDeniedException extends ForbiddenException { + constructor(portfolioId: string, userId: string) { + super( + `Access denied: User ${userId} does not have permission to access portfolio ${portfolioId}`, + ); + } +} + export class InsufficientBalanceException extends BadRequestException { constructor(asset: string) { super(`Insufficient balance for asset ${asset}`); @@ -33,3 +42,65 @@ export class InvalidPortfolioException extends BadRequestException { super(message); } } + +export class PortfolioHasNoAssetsException extends BadRequestException { + constructor(portfolioId: string) { + super( + `Portfolio ${portfolioId} has no assets. Add at least one asset before performing this operation.`, + ); + } +} + +export class AssetNotFoundException extends NotFoundException { + constructor(assetId: string, portfolioId: string) { + super(`Asset ${assetId} not found in portfolio ${portfolioId}`); + } +} + +export class DuplicateAssetException extends ConflictException { + constructor(ticker: string, chain: string, portfolioId: string) { + super( + `Asset ${ticker} on chain ${chain} already exists in portfolio ${portfolioId}`, + ); + } +} + +export class InvalidTickerException extends BadRequestException { + constructor(ticker: string) { + super( + `Invalid ticker symbol "${ticker}": must be 3-10 uppercase alphanumeric characters`, + ); + } +} + +export class UnsupportedChainException extends BadRequestException { + constructor(chain: string, supported: string[]) { + super( + `Unsupported chain "${chain}": must be one of ${supported.join(", ")}`, + ); + } +} + +export class OptimizationNotFoundException extends NotFoundException { + constructor(optimizationId: string) { + super(`Optimization not found: ${optimizationId}`); + } +} + +export class RebalancingEventNotFoundException extends NotFoundException { + constructor(eventId: string) { + super(`Rebalancing event not found: ${eventId}`); + } +} + +export class BacktestNotFoundException extends NotFoundException { + constructor(backtestId: string) { + super(`Backtest not found: ${backtestId}`); + } +} + +export class TransactionNotFoundException extends NotFoundException { + constructor(transactionId: string) { + super(`Transaction not found: ${transactionId}`); + } +} diff --git a/src/portfolio/portfolio.controller.ts b/src/portfolio/portfolio.controller.ts index 0438561a..0791d301 100644 --- a/src/portfolio/portfolio.controller.ts +++ b/src/portfolio/portfolio.controller.ts @@ -14,16 +14,21 @@ import { HttpStatus, Response, } from "@nestjs/common"; -import { ApiTags, ApiBearerAuth, ApiOperation } from "@nestjs/swagger"; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiParam, + ApiQuery, + ApiBody, +} from "@nestjs/swagger"; import { Throttle } from "@nestjs/throttler"; import { RateLimit } from "../common/decorators/rate-limit.decorator"; import { Response as ExpressResponse } from "express"; import { JwtAuthGuard } from "src/auth/jwt.guard"; import { PortfolioService } from "./services/portfolio.service"; -import { - RebalancingService, - RebalancingResult, -} from "./services/rebalancing.service"; +import { RebalancingService } from "./services/rebalancing.service"; import { PerformanceAnalyticsService } from "./services/performance-analytics.service"; import { BacktestingService } from "./services/backtesting.service"; import { MLPredictionService } from "./services/ml-prediction.service"; @@ -59,11 +64,10 @@ import { CreateBacktestDto } from "./dto/backtest.dto"; import { CreateTransactionDto, TransactionFilterDto, - TransactionExportDto, } from "./dto/transaction.dto"; @Controller("portfolio") -@ApiTags("Portfolio Optimization") +@ApiTags("Portfolio Management") @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Throttle({ trading: { ttl: 60_000, limit: 20 } }) @@ -79,18 +83,71 @@ export class PortfolioController { private transactionHistoryService: TransactionHistoryService, ) {} - // Portfolio Management Endpoints + // ─── Portfolio CRUD ────────────────────────────────────────────── - @Post("portfolios") + @Post() + @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: "Create a new portfolio" }) + @ApiBody({ type: CreatePortfolioDto, description: "Portfolio creation payload" }) + @ApiResponse({ + status: 201, + description: "Portfolio created successfully", + schema: { + example: { + id: "550e8400-e29b-41d4-a716-446655440000", + name: "My Portfolio", + description: "Long-term growth portfolio", + status: "active", + type: "balanced", + totalValue: 0, + currentAllocation: {}, + initialAllocation: { BTC: 60, ETH: 40 }, + autoRebalanceEnabled: false, + rebalanceThreshold: 5, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }, + }) + @ApiResponse({ status: 400, description: "Invalid input or validation error" }) + @ApiResponse({ status: 401, description: "Unauthorized – missing or invalid JWT" }) + @ApiResponse({ status: 409, description: "Portfolio name already exists" }) + @ApiResponse({ status: 500, description: "Internal server error" }) async createPortfolio(@Request() req: any, @Body() dto: CreatePortfolioDto) { return this.portfolioService.createPortfolio(req.user.id, dto); } - @Get("portfolios") - @ApiOperation({ - summary: "List portfolios for user with pagination and filtering", + @Get() + @ApiOperation({ summary: "List user portfolios with pagination and filtering" }) + @ApiQuery({ name: "status", required: false, enum: ["active", "inactive", "archived"], description: "Filter by portfolio status" }) + @ApiQuery({ name: "type", required: false, enum: ["balanced", "aggressive", "conservative"], description: "Filter by portfolio type" }) + @ApiQuery({ name: "search", required: false, type: String, description: "Case-insensitive name search" }) + @ApiQuery({ name: "page", required: false, type: Number, description: "Page number (default: 1)" }) + @ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page (default: 20, max: 100)" }) + @ApiResponse({ + status: 200, + description: "Paginated list of portfolios", + schema: { + example: { + data: [ + { + id: "550e8400-e29b-41d4-a716-446655440000", + name: "My Portfolio", + status: "active", + type: "balanced", + totalValue: 50000, + currentAllocation: { BTC: 60, ETH: 40 }, + }, + ], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }, + }, }) + @ApiResponse({ status: 401, description: "Unauthorized – missing or invalid JWT" }) + @ApiResponse({ status: 500, description: "Internal server error" }) async getUserPortfolios( @Request() req: any, @Query() query: QueryPortfolioDto, @@ -98,15 +155,60 @@ export class PortfolioController { return this.portfolioService.listPortfolios(req.user.id, query); } - @Get("portfolios/:id") - @ApiOperation({ summary: "Get portfolio details" }) + // IMPORTANT: "stats" must be defined BEFORE ":id" to avoid + // NestJS matching GET /portfolio/stats against the :id param. + @Get("stats") + @ApiOperation({ summary: "Get aggregate portfolio statistics for the authenticated user" }) + @ApiResponse({ + status: 200, + description: "Aggregate statistics across all active portfolios", + schema: { + example: { + totalPortfolios: 3, + activePortfolios: 2, + totalValue: 150000, + totalAssets: 12, + byType: { + balanced: { count: 2, totalValue: 100000 }, + aggressive: { count: 1, totalValue: 50000 }, + }, + topPortfolios: [ + { id: "...", name: "Growth Fund", totalValue: 80000, type: "aggressive" }, + ], + }, + }, + }) + @ApiResponse({ status: 401, description: "Unauthorized – missing or invalid JWT" }) + @ApiResponse({ status: 500, description: "Internal server error" }) + async getPortfolioStats(@Request() req: any) { + return this.portfolioService.getPortfolioStats(req.user.id); + } + + @Get(":id") + @ApiOperation({ summary: "Get portfolio details by ID" }) + @ApiParam({ name: "id", type: String, description: "Portfolio UUID" }) + @ApiResponse({ + status: 200, + description: "Portfolio details with assets, optimization history, and performance metrics", + }) + @ApiResponse({ status: 401, description: "Unauthorized – missing or invalid JWT" }) + @ApiResponse({ status: 403, description: "Forbidden – not the portfolio owner" }) + @ApiResponse({ status: 404, description: "Portfolio not found" }) @UseGuards(PortfolioOwnerGuard) async getPortfolio(@Param("id") portfolioId: string) { return this.portfolioService.getPortfolio(portfolioId); } - @Put("portfolios/:id") - @ApiOperation({ summary: "Update portfolio" }) + @Put(":id") + @ApiOperation({ summary: "Update portfolio details" }) + @ApiParam({ name: "id", type: String, description: "Portfolio UUID" }) + @ApiBody({ type: UpdatePortfolioDto, description: "Fields to update" }) + @ApiResponse({ status: 200, description: "Portfolio updated successfully" }) + @ApiResponse({ status: 400, description: "Invalid input or validation error" }) + @ApiResponse({ status: 401, description: "Unauthorized – missing or invalid JWT" }) + @ApiResponse({ status: 403, description: "Forbidden – not the portfolio owner" }) + @ApiResponse({ status: 404, description: "Portfolio not found" }) + @ApiResponse({ status: 409, description: "Portfolio name already exists" }) @UseGuards(PortfolioOwnerGuard) async updatePortfolio( @Param("id") portfolioId: string, @@ -115,25 +217,78 @@ export class PortfolioController { return this.portfolioService.updatePortfolio(portfolioId, dto); } - @Post("portfolios/:id/archive") + @Delete(":id") + @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Archive portfolio (soft delete via status)" }) + @ApiParam({ name: "id", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "Portfolio archived successfully" }) + @ApiResponse({ status: 401, description: "Unauthorized – missing or invalid JWT" }) + @ApiResponse({ status: 403, description: "Forbidden – not the portfolio owner" }) + @ApiResponse({ status: 404, description: "Portfolio not found" }) @UseGuards(PortfolioOwnerGuard) async archivePortfolio(@Param("id") portfolioId: string) { return this.portfolioService.archivePortfolio(portfolioId); } - @Delete("portfolios/:id") - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: "Soft-delete portfolio" }) + // ─── Portfolio Summary & Stats ─────────────────────────────────── + + @Get(":id/summary") + @ApiOperation({ summary: "Get portfolio summary with key metrics" }) + @ApiParam({ name: "id", type: String, description: "Portfolio UUID" }) + @ApiResponse({ + status: 200, + description: "Portfolio summary with value, asset count, allocation, and rebalance settings", + schema: { + example: { + id: "550e8400-e29b-41d4-a716-446655440000", + name: "My Portfolio", + status: "active", + type: "balanced", + totalValue: 50000, + assetCount: 5, + currentAllocation: { BTC: 40, ETH: 30, SOL: 20, USDC: 10 }, + autoRebalanceEnabled: true, + rebalanceFrequency: "monthly", + rebalanceThreshold: 5, + }, + }, + }) + @ApiResponse({ status: 401, description: "Unauthorized – missing or invalid JWT" }) + @ApiResponse({ status: 403, description: "Forbidden – not the portfolio owner" }) + @ApiResponse({ status: 404, description: "Portfolio not found" }) + @UseGuards(PortfolioOwnerGuard) + async getPortfolioSummary(@Param("id") portfolioId: string) { + return this.portfolioService.getPortfolioSummary(portfolioId); + } + + @Get(":id/export") + @ApiOperation({ summary: "Export full portfolio data as JSON" }) + @ApiParam({ name: "id", type: String, description: "Portfolio UUID" }) + @ApiResponse({ + status: 200, + description: "Full portfolio export including assets, optimization history, and metrics", + }) + @ApiResponse({ status: 401, description: "Unauthorized – missing or invalid JWT" }) + @ApiResponse({ status: 403, description: "Forbidden – not the portfolio owner" }) + @ApiResponse({ status: 404, description: "Portfolio not found" }) @UseGuards(PortfolioOwnerGuard) - async deletePortfolio(@Param("id") portfolioId: string) { - return this.portfolioService.deletePortfolio(portfolioId); + async exportPortfolio(@Param("id") portfolioId: string) { + return this.portfolioService.exportPortfolio(portfolioId); } - // Holding Management Endpoints + // ─── Holding (Asset) Management ────────────────────────────────── - @Post("portfolios/:portfolioId/assets") + @Post(":portfolioId/assets") + @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: "Add holding (asset) to portfolio" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiBody({ type: AddAssetToPortfolioDto, description: "Asset details" }) + @ApiResponse({ status: 201, description: "Asset added successfully" }) + @ApiResponse({ status: 400, description: "Invalid ticker, quantity, or chain" }) + @ApiResponse({ status: 401, description: "Unauthorized – missing or invalid JWT" }) + @ApiResponse({ status: 403, description: "Forbidden – not the portfolio owner" }) + @ApiResponse({ status: 404, description: "Portfolio not found" }) + @ApiResponse({ status: 409, description: "Asset with same ticker and chain already exists" }) @UseGuards(PortfolioOwnerGuard) async addAsset( @Param("portfolioId") portfolioId: string, @@ -150,8 +305,15 @@ export class PortfolioController { ); } - @Put("portfolios/:portfolioId/assets/:assetId") + @Put(":portfolioId/assets/:assetId") @ApiOperation({ summary: "Update holding (asset) in portfolio" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiParam({ name: "assetId", type: String, description: "Asset UUID" }) + @ApiBody({ type: UpdatePortfolioAssetDto, description: "Fields to update" }) + @ApiResponse({ status: 200, description: "Asset updated successfully" }) + @ApiResponse({ status: 400, description: "Invalid input or asset not found" }) + @ApiResponse({ status: 401, description: "Unauthorized – missing or invalid JWT" }) + @ApiResponse({ status: 403, description: "Forbidden – not the portfolio owner" }) @UseGuards(PortfolioOwnerGuard) async updateAsset( @Param("portfolioId") portfolioId: string, @@ -161,9 +323,15 @@ export class PortfolioController { return this.portfolioService.updateAsset(portfolioId, assetId, dto); } - @Delete("portfolios/:portfolioId/assets/:assetId") + @Delete(":portfolioId/assets/:assetId") @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: "Remove holding (asset) from portfolio" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiParam({ name: "assetId", type: String, description: "Asset UUID" }) + @ApiResponse({ status: 204, description: "Asset removed successfully" }) + @ApiResponse({ status: 400, description: "Asset not found in portfolio" }) + @ApiResponse({ status: 401, description: "Unauthorized – missing or invalid JWT" }) + @ApiResponse({ status: 403, description: "Forbidden – not the portfolio owner" }) @UseGuards(PortfolioOwnerGuard) async removeAsset( @Param("portfolioId") portfolioId: string, @@ -172,8 +340,12 @@ export class PortfolioController { return this.portfolioService.removeAsset(portfolioId, assetId); } - @Put("portfolios/:portfolioId/assets/:assetId/price") + @Put(":portfolioId/assets/:assetId/price") @ApiOperation({ summary: "Update asset price" }) + @ApiParam({ name: "assetId", type: String, description: "Asset UUID" }) + @ApiBody({ schema: { properties: { price: { type: "number", example: 45000 } } }, description: "New price" }) + @ApiResponse({ status: 200, description: "Asset price updated" }) + @ApiResponse({ status: 400, description: "Asset not found" }) async updateAssetPrice( @Param("assetId") assetId: string, @Body() body: { price: number }, @@ -181,23 +353,28 @@ export class PortfolioController { return this.portfolioService.updateAssetPrice(assetId, body.price); } - // Optimization Endpoints + // ─── Optimization ──────────────────────────────────────────────── - @Post("portfolios/:portfolioId/optimize") + @Post(":portfolioId/optimize") + @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: "Run portfolio optimization" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiBody({ type: CreateOptimizationDto, description: "Optimization parameters" }) + @ApiResponse({ status: 201, description: "Optimization started" }) + @ApiResponse({ status: 400, description: "Portfolio has no assets or invalid parameters" }) @UseGuards(PortfolioOwnerGuard) async runOptimization( @Param("portfolioId") portfolioId: string, @Body() dto: CreateOptimizationDto, ) { - dto.portfolioId = portfolioId; // Ensure portfolio ID matches + dto.portfolioId = portfolioId; return this.portfolioService.runOptimization(portfolioId, dto); } @Post("optimizations/:optimizationId/approve") - @ApiOperation({ - summary: "Approve optimization recommendation", - }) + @ApiOperation({ summary: "Approve optimization recommendation" }) + @ApiParam({ name: "optimizationId", type: String, description: "Optimization UUID" }) + @ApiResponse({ status: 200, description: "Optimization approved" }) async approveOptimization( @Param("optimizationId") optimizationId: string, @Body() dto: ApproveOptimizationDto, @@ -206,15 +383,18 @@ export class PortfolioController { } @Post("optimizations/:optimizationId/implement") - @ApiOperation({ - summary: "Implement optimization (apply to portfolio)", - }) + @ApiOperation({ summary: "Implement optimization (apply to portfolio)" }) + @ApiParam({ name: "optimizationId", type: String, description: "Optimization UUID" }) + @ApiResponse({ status: 200, description: "Optimization applied to portfolio" }) async implementOptimization(@Param("optimizationId") optimizationId: string) { return this.portfolioService.implementOptimization(optimizationId); } - @Get("portfolios/:portfolioId/optimization-history") + @Get(":portfolioId/optimization-history") @ApiOperation({ summary: "Get optimization history" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiQuery({ name: "limit", required: false, type: Number, description: "Max results (default: 10)" }) + @ApiResponse({ status: 200, description: "List of optimization records" }) @UseGuards(PortfolioOwnerGuard) async getOptimizationHistory( @Param("portfolioId") portfolioId: string, @@ -223,12 +403,12 @@ export class PortfolioController { return this.portfolioService.getOptimizationHistory(portfolioId, limit); } - // Rebalancing Endpoints + // ─── Rebalancing ───────────────────────────────────────────────── - @Get("portfolios/:portfolioId/rebalance-check") - @ApiOperation({ - summary: "Check if portfolio needs rebalancing", - }) + @Get(":portfolioId/rebalance-check") + @ApiOperation({ summary: "Check if portfolio needs rebalancing" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "Rebalancing status and allocation drift" }) @UseGuards(PortfolioOwnerGuard) async checkRebalancing(@Param("portfolioId") portfolioId: string) { const needsRebalancing = @@ -242,10 +422,12 @@ export class PortfolioController { }; } - @Post("portfolios/:portfolioId/rebalance") - @ApiOperation({ - summary: "Trigger portfolio rebalancing", - }) + @Post(":portfolioId/rebalance") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Trigger portfolio rebalancing" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiBody({ type: TriggerRebalancingDto, description: "Rebalancing parameters" }) + @ApiResponse({ status: 201, description: "Rebalancing triggered" }) @UseGuards(PortfolioOwnerGuard) async triggerRebalancing( @Param("portfolioId") portfolioId: string, @@ -261,17 +443,18 @@ export class PortfolioController { } @Post("rebalancing/:rebalancingId/approve") - @ApiOperation({ - summary: "Approve rebalancing event", - }) + @ApiOperation({ summary: "Approve rebalancing event" }) + @ApiParam({ name: "rebalancingId", type: String, description: "Rebalancing event UUID" }) + @ApiResponse({ status: 200, description: "Rebalancing approved" }) async approveRebalancing(@Param("rebalancingId") rebalancingId: string) { return this.rebalancingService.approveRebalancing(rebalancingId); } @Post("rebalancing/:rebalancingId/execute") - @ApiOperation({ - summary: "Execute approved rebalancing", - }) + @ApiOperation({ summary: "Execute approved rebalancing" }) + @ApiParam({ name: "rebalancingId", type: String, description: "Rebalancing event UUID" }) + @ApiBody({ type: ExecuteRebalancingDto, description: "Execution details" }) + @ApiResponse({ status: 200, description: "Rebalancing executed" }) async executeRebalancing( @Param("rebalancingId") rebalancingId: string, @Body() dto: ExecuteRebalancingDto, @@ -285,9 +468,10 @@ export class PortfolioController { } @Post("rebalancing/:rebalancingId/cancel") - @ApiOperation({ - summary: "Cancel rebalancing event", - }) + @ApiOperation({ summary: "Cancel rebalancing event" }) + @ApiParam({ name: "rebalancingId", type: String, description: "Rebalancing event UUID" }) + @ApiBody({ type: CancelRebalancingDto, description: "Cancellation reason" }) + @ApiResponse({ status: 200, description: "Rebalancing cancelled" }) async cancelRebalancing( @Param("rebalancingId") rebalancingId: string, @Body() dto: CancelRebalancingDto, @@ -295,8 +479,11 @@ export class PortfolioController { return this.rebalancingService.cancelRebalancing(rebalancingId, dto.reason); } - @Get("portfolios/:portfolioId/rebalancing-history") + @Get(":portfolioId/rebalancing-history") @ApiOperation({ summary: "Get rebalancing history" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiQuery({ name: "limit", required: false, type: Number, description: "Max results (default: 10)" }) + @ApiResponse({ status: 200, description: "Rebalancing history" }) @UseGuards(PortfolioOwnerGuard) async getRebalancingHistory( @Param("portfolioId") portfolioId: string, @@ -305,30 +492,30 @@ export class PortfolioController { return this.rebalancingService.getRebalancingHistory(portfolioId, limit); } - @Get("portfolios/:portfolioId/allocation-drift") - @ApiOperation({ - summary: "Get current allocation drift from target", - }) + @Get(":portfolioId/allocation-drift") + @ApiOperation({ summary: "Get current allocation drift from target" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "Allocation drift data" }) @UseGuards(PortfolioOwnerGuard) async getAllocationDrift(@Param("portfolioId") portfolioId: string) { return this.rebalancingService.calculateAllocationDrift(portfolioId); } - // Performance Analytics Endpoints + // ─── Performance Analytics ──────────────────────────────────────── - @Get("portfolios/:portfolioId/performance-summary") - @ApiOperation({ - summary: "Get portfolio performance summary", - }) + @Get(":portfolioId/performance-summary") + @ApiOperation({ summary: "Get portfolio performance summary" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "Performance summary" }) @UseGuards(PortfolioOwnerGuard) async getPerformanceSummary(@Param("portfolioId") portfolioId: string) { return this.performanceService.getPerformanceSummary(portfolioId); } - @Get("portfolios/:portfolioId/metrics") - @ApiOperation({ - summary: "Get performance metrics for date range", - }) + @Get(":portfolioId/metrics") + @ApiOperation({ summary: "Get performance metrics for date range" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "Performance metrics" }) @UseGuards(PortfolioOwnerGuard) async getMetrics( @Param("portfolioId") portfolioId: string, @@ -346,10 +533,12 @@ export class PortfolioController { ); } - @Get("portfolios/:portfolioId/metrics/attribution") - @ApiOperation({ - summary: "Get attribution analysis", - }) + @Get(":portfolioId/metrics/attribution") + @ApiOperation({ summary: "Get attribution analysis" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiQuery({ name: "startDate", required: true, type: String, description: "Start date (ISO 8601)" }) + @ApiQuery({ name: "endDate", required: true, type: String, description: "End date (ISO 8601)" }) + @ApiResponse({ status: 200, description: "Attribution analysis" }) @UseGuards(PortfolioOwnerGuard) async getAttributionAnalysis( @Param("portfolioId") portfolioId: string, @@ -367,11 +556,12 @@ export class PortfolioController { ); } - @Get("portfolios/:portfolioId/metrics/period") + @Get(":portfolioId/metrics/period") @ApiOperation({ - summary: - "Get performance metrics for a predefined period (1D/1W/1M/3M/6M/YTD/1Y/3Y/ALL)", + summary: "Get performance metrics for a predefined period (1D/1W/1M/3M/6M/YTD/1Y/3Y/ALL)", }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "Period metrics" }) @UseGuards(PortfolioOwnerGuard) async getMetricsByPeriod( @Param("portfolioId") portfolioId: string, @@ -380,10 +570,10 @@ export class PortfolioController { return this.performanceService.getMetricsForPeriod(portfolioId, dto.period); } - @Get("portfolios/:portfolioId/metrics/benchmark") - @ApiOperation({ - summary: "Compare portfolio performance against a benchmark ticker", - }) + @Get(":portfolioId/metrics/benchmark") + @ApiOperation({ summary: "Compare portfolio performance against a benchmark ticker" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "Benchmark comparison" }) @UseGuards(PortfolioOwnerGuard) async getBenchmarkComparison( @Param("portfolioId") portfolioId: string, @@ -397,10 +587,10 @@ export class PortfolioController { ); } - @Get("portfolios/:portfolioId/metrics/var") - @ApiOperation({ - summary: "Get Value at Risk (VaR) at a given confidence level", - }) + @Get(":portfolioId/metrics/var") + @ApiOperation({ summary: "Get Value at Risk (VaR) at a given confidence level" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "Value at Risk" }) @UseGuards(PortfolioOwnerGuard) async getValueAtRisk( @Param("portfolioId") portfolioId: string, @@ -414,10 +604,10 @@ export class PortfolioController { return { portfolioId, confidence, valueAtRisk: var_ }; } - @Get("portfolios/:portfolioId/metrics/calmar") - @ApiOperation({ - summary: "Get Calmar ratio (annualised return / max drawdown)", - }) + @Get(":portfolioId/metrics/calmar") + @ApiOperation({ summary: "Get Calmar ratio (annualised return / max drawdown)" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "Calmar ratio" }) @UseGuards(PortfolioOwnerGuard) async getCalmarRatio(@Param("portfolioId") portfolioId: string) { const calmarRatio = @@ -425,10 +615,12 @@ export class PortfolioController { return { portfolioId, calmarRatio }; } - @Post("portfolios/:portfolioId/metrics/snapshot") - @ApiOperation({ - summary: "Record a performance snapshot for the portfolio", - }) + @Post(":portfolioId/metrics/snapshot") + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Record a performance snapshot for the portfolio" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiBody({ type: RecordSnapshotDto, description: "Snapshot data" }) + @ApiResponse({ status: 201, description: "Snapshot recorded" }) @UseGuards(PortfolioOwnerGuard) async recordSnapshot( @Param("portfolioId") portfolioId: string, @@ -442,22 +634,20 @@ export class PortfolioController { ); } - @Get("portfolios/:portfolioId/metrics/roi") - @ApiOperation({ - summary: - "Get Return on Investment (ROI) relative to the invested cost basis", - }) + @Get(":portfolioId/metrics/roi") + @ApiOperation({ summary: "Get Return on Investment (ROI) relative to the invested cost basis" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "ROI percentage" }) @UseGuards(PortfolioOwnerGuard) async getROI(@Param("portfolioId") portfolioId: string) { const roi = await this.performanceService.calculateROI(portfolioId); return { portfolioId, roi }; } - @Get("portfolios/:portfolioId/metrics/drawdown") - @ApiOperation({ - summary: - "Get current drawdown relative to the all-time peak portfolio value", - }) + @Get(":portfolioId/metrics/drawdown") + @ApiOperation({ summary: "Get current drawdown relative to the all-time peak portfolio value" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "Current drawdown" }) @UseGuards(PortfolioOwnerGuard) async getCurrentDrawdown(@Param("portfolioId") portfolioId: string) { const currentDrawdown = @@ -465,42 +655,47 @@ export class PortfolioController { return { portfolioId, currentDrawdown }; } - @Get("portfolios/:portfolioId/metrics/periods") - @ApiOperation({ - summary: "Get standard period returns (YTD, 1Y, 3Y, 5Y) for the portfolio", - }) + @Get(":portfolioId/metrics/periods") + @ApiOperation({ summary: "Get standard period returns (YTD, 1Y, 3Y, 5Y) for the portfolio" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "Period returns" }) @UseGuards(PortfolioOwnerGuard) async getPeriodReturns(@Param("portfolioId") portfolioId: string) { return this.performanceService.calculatePeriodReturns(portfolioId); } - @Get("portfolios/:portfolioId/metrics/allocation") - @ApiOperation({ - summary: "Get the current allocation breakdown (ticker → percentage)", - }) + @Get(":portfolioId/metrics/allocation") + @ApiOperation({ summary: "Get the current allocation breakdown (ticker → percentage)" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "Allocation breakdown" }) @UseGuards(PortfolioOwnerGuard) async getAllocationBreakdown(@Param("portfolioId") portfolioId: string) { return this.performanceService.getAllocationBreakdown(portfolioId); } - // Backtesting Endpoints + // ─── Backtesting ───────────────────────────────────────────────── @Post("backtests") + @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: "Create and run backtest" }) + @ApiBody({ type: CreateBacktestDto, description: "Backtest parameters" }) + @ApiResponse({ status: 201, description: "Backtest created" }) async createBacktest(@Request() req: any, @Body() dto: CreateBacktestDto) { return this.backtestService.createBacktest(req.user.id, dto); } @Get("backtests/:backtestId") @ApiOperation({ summary: "Get backtest result" }) + @ApiParam({ name: "backtestId", type: String, description: "Backtest UUID" }) + @ApiResponse({ status: 200, description: "Backtest result" }) async getBacktest(@Param("backtestId") backtestId: string) { return this.backtestService.getBacktest(backtestId); } @Get("backtests") - @ApiOperation({ - summary: "Get backtests for user", - }) + @ApiOperation({ summary: "Get backtests for user" }) + @ApiQuery({ name: "limit", required: false, type: Number, description: "Max results (default: 10)" }) + @ApiResponse({ status: 200, description: "List of backtests" }) async getUserBacktests( @Request() req: any, @Query("limit") limit: number = 10, @@ -509,19 +704,21 @@ export class PortfolioController { } @Post("backtests/compare") - @ApiOperation({ - summary: "Compare multiple backtests", - }) + @ApiOperation({ summary: "Compare multiple backtests" }) + @ApiBody({ schema: { properties: { backtestIds: { type: "array", items: { type: "string" } } } }, description: "Backtest IDs to compare" }) + @ApiResponse({ status: 200, description: "Backtest comparison" }) async compareBacktests(@Body() body: { backtestIds: string[] }) { return this.backtestService.compareBacktests(body.backtestIds); } - // ML Prediction Endpoints + // ─── ML Predictions ────────────────────────────────────────────── @Post("predictions/train/:ticker") - @ApiOperation({ - summary: "Train ML model for asset", - }) + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Train ML model for asset" }) + @ApiParam({ name: "ticker", type: String, description: "Asset ticker symbol" }) + @ApiBody({ schema: { properties: { historicalPrices: { type: "array", items: { type: "number" } } } }, description: "Historical price data" }) + @ApiResponse({ status: 201, description: "Model trained" }) async trainPredictor( @Param("ticker") ticker: string, @Body() body: { historicalPrices: number[] }, @@ -530,9 +727,11 @@ export class PortfolioController { } @Post("predictions/forecast/:ticker") - @ApiOperation({ - summary: "Get ML price predictions for asset", - }) + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: "Get ML price predictions for asset" }) + @ApiParam({ name: "ticker", type: String, description: "Asset ticker symbol" }) + @ApiBody({ schema: { properties: { currentPrice: { type: "number" }, historicalPrices: { type: "array", items: { type: "number" } }, daysAhead: { type: "number" } } }, description: "Prediction parameters" }) + @ApiResponse({ status: 201, description: "Prediction result" }) async predictAssetReturns( @Param("ticker") ticker: string, @Body() @@ -551,17 +750,20 @@ export class PortfolioController { } @Get("predictions/stats") - @ApiOperation({ - summary: "Get ML predictor statistics", - }) + @ApiOperation({ summary: "Get ML predictor statistics" }) + @ApiResponse({ status: 200, description: "Predictor statistics" }) async getPredictorStats() { return this.mlService.getPredictorStats(); } - // Transaction Tracking Endpoints + // ─── Transaction Tracking ───────────────────────────────────────── - @Post("portfolios/:portfolioId/transactions") + @Post(":portfolioId/transactions") + @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: "Record a new transaction" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiBody({ type: CreateTransactionDto, description: "Transaction details" }) + @ApiResponse({ status: 201, description: "Transaction recorded" }) @UseGuards(PortfolioOwnerGuard) async recordTransaction( @Request() req: any, @@ -575,8 +777,10 @@ export class PortfolioController { ); } - @Get("portfolios/:portfolioId/transactions") + @Get(":portfolioId/transactions") @ApiOperation({ summary: "Get transaction history with filtering" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "Transaction history" }) @UseGuards(PortfolioOwnerGuard) async getTransactionHistory( @Request() req: any, @@ -590,23 +794,30 @@ export class PortfolioController { ); } - @Get("portfolios/:portfolioId/transactions/:transactionId") - @ApiOperation({ summary: "Get a single transaction" }) + // IMPORTANT: Specific routes MUST come before :transactionId param + // to avoid NestJS matching "stats", "cost-basis", "export" as a transactionId. + + @Get(":portfolioId/transactions/stats") + @ApiOperation({ summary: "Get transaction statistics" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "Transaction statistics" }) @UseGuards(PortfolioOwnerGuard) - async getTransaction( + async getTransactionStats( @Request() req: any, @Param("portfolioId") portfolioId: string, - @Param("transactionId") transactionId: string, ) { - return this.transactionHistoryService.getTransaction( - transactionId, + return this.transactionHistoryService.getTransactionStats( portfolioId, req.user.id, ); } - @Get("portfolios/:portfolioId/transactions/cost-basis/:ticker") + @Get(":portfolioId/transactions/cost-basis/:ticker") @ApiOperation({ summary: "Calculate cost basis for a specific ticker" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiParam({ name: "ticker", type: String, description: "Asset ticker symbol" }) + @ApiQuery({ name: "asOfDate", required: false, type: String, description: "As-of date (ISO 8601)" }) + @ApiResponse({ status: 200, description: "Cost basis for ticker" }) @UseGuards(PortfolioOwnerGuard) async getCostBasis( @Request() req: any, @@ -622,8 +833,10 @@ export class PortfolioController { ); } - @Get("portfolios/:portfolioId/transactions/cost-basis") + @Get(":portfolioId/transactions/cost-basis") @ApiOperation({ summary: "Calculate cost basis for all holdings" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "Cost basis for all holdings" }) @UseGuards(PortfolioOwnerGuard) async getAllCostBasis( @Request() req: any, @@ -635,8 +848,10 @@ export class PortfolioController { ); } - @Get("portfolios/:portfolioId/transactions/export/csv") + @Get(":portfolioId/transactions/export/csv") @ApiOperation({ summary: "Export transactions as CSV" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "CSV file download" }) @UseGuards(PortfolioOwnerGuard) async exportTransactionsCSV( @Request() req: any, @@ -658,8 +873,10 @@ export class PortfolioController { res.send(csv); } - @Get("portfolios/:portfolioId/transactions/export/json") + @Get(":portfolioId/transactions/export/json") @ApiOperation({ summary: "Export transactions as JSON" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiResponse({ status: 200, description: "JSON export" }) @UseGuards(PortfolioOwnerGuard) async exportTransactionsJSON( @Request() req: any, @@ -673,21 +890,30 @@ export class PortfolioController { ); } - @Get("portfolios/:portfolioId/transactions/stats") - @ApiOperation({ summary: "Get transaction statistics" }) + // :transactionId param route MUST come AFTER all specific sub-routes + @Get(":portfolioId/transactions/:transactionId") + @ApiOperation({ summary: "Get a single transaction" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiParam({ name: "transactionId", type: String, description: "Transaction UUID" }) + @ApiResponse({ status: 200, description: "Transaction details" }) @UseGuards(PortfolioOwnerGuard) - async getTransactionStats( + async getTransaction( @Request() req: any, @Param("portfolioId") portfolioId: string, + @Param("transactionId") transactionId: string, ) { - return this.transactionHistoryService.getTransactionStats( + return this.transactionHistoryService.getTransaction( + transactionId, portfolioId, req.user.id, ); } - @Post("portfolios/:portfolioId/transactions/:transactionId/archive") + @Post(":portfolioId/transactions/:transactionId/archive") @ApiOperation({ summary: "Archive a transaction" }) + @ApiParam({ name: "portfolioId", type: String, description: "Portfolio UUID" }) + @ApiParam({ name: "transactionId", type: String, description: "Transaction UUID" }) + @ApiResponse({ status: 200, description: "Transaction archived" }) @UseGuards(PortfolioOwnerGuard) async archiveTransaction( @Request() req: any, diff --git a/src/portfolio/portfolio.module.ts b/src/portfolio/portfolio.module.ts index 744eba2b..3d6785eb 100644 --- a/src/portfolio/portfolio.module.ts +++ b/src/portfolio/portfolio.module.ts @@ -23,8 +23,43 @@ import { TransactionHistoryService } from "./services/transaction-history.servic // Controllers import { PortfolioController } from "./portfolio.controller"; + +// Guards import { PortfolioOwnerGuard } from "../common/guard/portfolio-owner.guard"; +/** + * Portfolio Management Module + * + * Provides REST API endpoints for portfolio CRUD operations, asset management, + * optimization, rebalancing, performance analytics, backtesting, and ML predictions. + * + * ## Endpoints + * - `POST /portfolio` – Create portfolio + * - `GET /portfolio` – List user portfolios (paginated) + * - `GET /portfolio/:id` – Get portfolio details + * - `PUT /portfolio/:id` – Update portfolio + * - `DELETE /portfolio/:id` – Archive portfolio (soft delete) + * - `GET /portfolio/:id/summary` – Portfolio summary with key metrics + * - `GET /portfolio/stats` – Aggregate statistics across portfolios + * - `GET /portfolio/:id/export` – Full portfolio data export (JSON) + * - `POST /portfolio/:id/assets` – Add holding + * - `PUT /portfolio/:id/assets/:assetId` – Update holding + * - `DELETE /portfolio/:id/assets/:assetId` – Remove holding + * - `POST /portfolio/:id/optimize` – Run optimization + * - `GET /portfolio/:id/rebalance-check` – Check rebalancing needs + * - `POST /portfolio/:id/rebalance` – Trigger rebalancing + * - `GET /portfolio/:id/performance-summary` – Performance overview + * - `GET /portfolio/:id/metrics` – Performance metrics + * - `POST /portfolio/backtests` – Create backtest + * - `POST /portfolio/predictions/:ticker/train` – Train ML model + * + * ## Rate Limiting + * All endpoints are rate-limited to 20 requests/minute per user (trading tier). + * + * ## Authentication + * All endpoints require JWT authentication via `JwtAuthGuard`. + * Portfolio-specific endpoints also require `PortfolioOwnerGuard`. + */ @Module({ imports: [ TypeOrmModule.forFeature([ @@ -74,6 +109,7 @@ import { PortfolioOwnerGuard } from "../common/guard/portfolio-owner.guard"; MLPredictionService, TradingTransactionService, TransactionHistoryService, + TypeOrmModule.forFeature([Portfolio, PortfolioAsset]), ], }) export class PortfolioModule {} diff --git a/src/portfolio/services/portfolio.service.ts b/src/portfolio/services/portfolio.service.ts index 4a6a1bd8..4ac88ec2 100644 --- a/src/portfolio/services/portfolio.service.ts +++ b/src/portfolio/services/portfolio.service.ts @@ -723,4 +723,151 @@ export class PortfolioService { throw new DuplicatePortfolioNameException(name); } } + + /** + * Get a summary overview for a single portfolio. + * + * Returns key metrics (value, asset count, allocation, rebalance settings) + * without pulling in the full relations, making it suitable for dashboard + * widgets or quick status checks. + */ + async getPortfolioSummary(portfolioId: string): Promise<{ + id: string; + name: string; + status: PortfolioStatus; + type: PortfolioType; + totalValue: number; + assetCount: number; + currentAllocation: Record; + targetAllocation: Record; + autoRebalanceEnabled: boolean; + rebalanceFrequency: string | null; + rebalanceThreshold: number; + lastRebalanceDate: Date | null; + createdAt: Date; + updatedAt: Date; + }> { + const portfolio = await this.getPortfolio(portfolioId); + const assets = await this.portfolioAssetRepository.find({ + where: { portfolioId }, + select: ["id"], + }); + + return { + id: portfolio.id, + name: portfolio.name, + status: portfolio.status, + type: portfolio.type, + totalValue: portfolio.totalValue, + assetCount: assets.length, + currentAllocation: portfolio.currentAllocation || {}, + targetAllocation: portfolio.targetAllocation || {}, + autoRebalanceEnabled: portfolio.autoRebalanceEnabled, + rebalanceFrequency: portfolio.rebalanceFrequency, + rebalanceThreshold: portfolio.rebalanceThreshold, + lastRebalanceDate: portfolio.lastRebalanceDate, + createdAt: portfolio.createdAt, + updatedAt: portfolio.updatedAt, + }; + } + + /** + * Get aggregate statistics across all of a user's active portfolios. + * + * Provides a single object with total value, asset count, portfolio count, + * and per-type breakdowns that the frontend can display in a portfolio + * overview dashboard. + */ + async getPortfolioStats(userId: string): Promise<{ + totalPortfolios: number; + activePortfolios: number; + totalValue: number; + totalAssets: number; + byType: Record; + topPortfolios: Array<{ + id: string; + name: string; + totalValue: number; + type: PortfolioType; + }>; + }> { + const portfolios = await this.portfolioRepository.find({ + where: { + userId, + status: Not(PortfolioStatus.ARCHIVED), + }, + }); + + let totalValue = 0; + let totalAssets = 0; + const byType: Record = {}; + + for (const portfolio of portfolios) { + totalValue += portfolio.totalValue || 0; + + const assets = await this.portfolioAssetRepository.find({ + where: { portfolioId: portfolio.id }, + select: ["id"], + }); + totalAssets += assets.length; + + const type = portfolio.type || PortfolioType.BALANCED; + if (!byType[type]) { + byType[type] = { count: 0, totalValue: 0 }; + } + byType[type].count += 1; + byType[type].totalValue += portfolio.totalValue || 0; + } + + const topPortfolios = portfolios + .sort((a, b) => (b.totalValue || 0) - (a.totalValue || 0)) + .slice(0, 5) + .map((p) => ({ + id: p.id, + name: p.name, + totalValue: p.totalValue || 0, + type: p.type, + })); + + return { + totalPortfolios: portfolios.length, + activePortfolios: portfolios.filter( + (p) => p.status === PortfolioStatus.ACTIVE, + ).length, + totalValue, + totalAssets, + byType, + topPortfolios, + }; + } + + /** + * Export a portfolio as a JSON-serializable object. + * + * Includes the portfolio metadata, all assets, optimization history, + * rebalancing events, and performance metrics in a single payload. + */ + async exportPortfolio(portfolioId: string): Promise<{ + exportedAt: string; + portfolio: Portfolio; + assets: PortfolioAsset[]; + optimizationHistory: OptimizationHistory[]; + }> { + const portfolio = await this.getPortfolio(portfolioId); + const assets = await this.portfolioAssetRepository.find({ + where: { portfolioId }, + order: { ticker: "ASC" }, + }); + const optimizationHistory = await this.optimizationRepository.find({ + where: { portfolioId }, + order: { createdAt: "DESC" }, + }); + + return { + exportedAt: new Date().toISOString(), + portfolio, + assets, + optimizationHistory, + }; + } } diff --git a/test/portfolio/portfolio-error-scenarios.e2e-spec.ts b/test/portfolio/portfolio-error-scenarios.e2e-spec.ts new file mode 100644 index 00000000..d14b03d8 --- /dev/null +++ b/test/portfolio/portfolio-error-scenarios.e2e-spec.ts @@ -0,0 +1,285 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import * as request from "supertest"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { PortfolioModule } from "../../src/portfolio/portfolio.module"; +import { + Portfolio, + PortfolioStatus, + PortfolioType, +} from "../../src/portfolio/entities/portfolio.entity"; +import { PortfolioAsset } from "../../src/portfolio/entities/portfolio-asset.entity"; +import { RiskProfile } from "../../src/portfolio/entities/risk-profile.entity"; +import { OptimizationHistory } from "../../src/portfolio/entities/optimization-history.entity"; +import { RebalancingEvent } from "../../src/portfolio/entities/rebalancing-event.entity"; +import { PerformanceMetric } from "../../src/portfolio/entities/performance-metric.entity"; +import { BacktestResult } from "../../src/portfolio/entities/backtest-result.entity"; +import { Transaction } from "../../src/portfolio/entities/transaction.entity"; +import { User } from "../../src/user/entities/user.entity"; +import { GlobalExceptionFilter } from "../../src/common/filters/global-exception.filter"; + +// Mock JWT strategy +jest.mock("../../src/auth/jwt.guard", () => ({ + JwtAuthGuard: jest.fn().mockImplementation(() => ({ + canActivate: (context: any) => { + const request = context.switchToHttp().getRequest(); + request.user = { id: "test-user-id", email: "test@example.com" }; + return true; + }, + })), +})); + +// Mock PortfolioOwnerGuard +jest.mock("../../src/common/guard/portfolio-owner.guard", () => ({ + PortfolioOwnerGuard: jest.fn().mockImplementation(() => ({ + canActivate: () => true, + })), +})); + +describe("Portfolio Error Scenarios (e2e)", () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: "sqlite", + database: ":memory:", + entities: [ + User, + Portfolio, + PortfolioAsset, + RiskProfile, + OptimizationHistory, + RebalancingEvent, + PerformanceMetric, + BacktestResult, + Transaction, + ], + synchronize: true, + }), + PortfolioModule, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalFilters(new GlobalExceptionFilter()); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe("POST /portfolio – Validation Errors", () => { + it("should reject empty body (400)", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({}); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("statusCode", 400); + expect(response.body).toHaveProperty("message"); + }); + + it("should reject name with only spaces (400)", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ name: " " }); + + expect(response.status).toBe(400); + }); + + it("should reject name with special characters (still valid, 3+ chars)", async () => { + // Special chars in name should be allowed as long as length is valid + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ name: "Portfolio@#$%" }); + + // This should succeed since the DTO only validates length + expect(response.status).toBe(201); + }); + + it("should reject negative rebalance threshold (400)", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: "Negative Threshold", + rebalanceThreshold: -5, + }); + + expect(response.status).toBe(400); + }); + + it("should reject invalid rebalance frequency (400)", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: "Invalid Frequency", + rebalanceFrequency: "annually", + }); + + expect(response.status).toBe(400); + }); + + it("should reject non-boolean autoRebalanceEnabled (400)", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: "Invalid Auto Rebalance", + autoRebalanceEnabled: "yes", + }); + + expect(response.status).toBe(400); + }); + }); + + describe("GET /portfolio – Query Parameter Errors", () => { + it("should handle invalid page parameter gracefully (200 with defaults)", async () => { + const response = await request(app.getHttpServer()) + .get("/portfolio") + .query({ page: -1 }); + + // Should use default page of 1 + expect(response.status).toBe(200); + expect(response.body.page).toBe(1); + }); + + it("should handle invalid limit parameter gracefully (200 with defaults)", async () => { + const response = await request(app.getHttpServer()) + .get("/portfolio") + .query({ limit: 0 }); + + // Should use default limit of 20 + expect(response.status).toBe(200); + expect(response.body.limit).toBe(20); + }); + + it("should handle limit exceeding max (200 with cap)", async () => { + const response = await request(app.getHttpServer()) + .get("/portfolio") + .query({ limit: 200 }); + + // Should be capped at 100 by validation + expect(response.status).toBe(400); + }); + }); + + describe("GET /portfolio/:id – Not Found Errors", () => { + it("should return 404 for UUID format but non-existent ID", async () => { + const response = await request(app.getHttpServer()).get( + "/portfolio/12345678-1234-1234-1234-123456789abc", + ); + + expect(response.status).toBe(404); + expect(response.body).toHaveProperty("statusCode", 404); + expect(response.body).toHaveProperty("message"); + }); + + it("should return 404 for invalid UUID format", async () => { + const response = await request(app.getHttpServer()).get( + "/portfolio/invalid-id", + ); + + // Should be 404 since the ID won't match any portfolio + expect(response.status).toBe(404); + }); + }); + + describe("PUT /portfolio/:id – Update Errors", () => { + let testPortfolioId: string; + + beforeAll(async () => { + const createResponse = await request(app.getHttpServer()) + .post("/portfolio") + .send({ name: "Error Test Portfolio" }); + testPortfolioId = createResponse.body.id; + }); + + it("should return 404 for update on non-existent portfolio", async () => { + const response = await request(app.getHttpServer()) + .put("/portfolio/00000000-0000-0000-0000-000000000000") + .send({ name: "Updated Name" }); + + expect(response.status).toBe(404); + }); + + it("should reject update with invalid type (400)", async () => { + const response = await request(app.getHttpServer()) + .put(`/portfolio/${testPortfolioId}`) + .send({ type: "mega-aggressive" }); + + expect(response.status).toBe(400); + }); + + it("should reject update with invalid status (400)", async () => { + const response = await request(app.getHttpServer()) + .put(`/portfolio/${testPortfolioId}`) + .send({ status: "deleted" }); + + expect(response.status).toBe(400); + }); + }); + + describe("DELETE /portfolio/:id – Archive Errors", () => { + it("should return 404 for archive on non-existent portfolio", async () => { + const response = await request(app.getHttpServer()).delete( + "/portfolio/00000000-0000-0000-0000-000000000000", + ); + + expect(response.status).toBe(404); + }); + }); + + describe("Error Response Consistency", () => { + it("should include correlationId in error responses", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({}); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("correlationId"); + expect(typeof response.body.correlationId).toBe("string"); + }); + + it("should include timestamp in error responses", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({}); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("timestamp"); + // Should be a valid ISO date string + expect(new Date(response.body.timestamp).toISOString()).toBe( + response.body.timestamp, + ); + }); + + it("should include path in error responses", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({}); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("path"); + expect(response.body.path).toContain("/portfolio"); + }); + + it("should never expose stack traces in error responses", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({}); + + expect(response.status).toBe(400); + expect(response.body).not.toHaveProperty("stack"); + expect(response.body).not.toHaveProperty("trace"); + }); + }); +}); diff --git a/test/portfolio/portfolio-load.spec.ts b/test/portfolio/portfolio-load.spec.ts new file mode 100644 index 00000000..70244ebd --- /dev/null +++ b/test/portfolio/portfolio-load.spec.ts @@ -0,0 +1,290 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import * as request from "supertest"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { PortfolioModule } from "../../src/portfolio/portfolio.module"; +import { + Portfolio, + PortfolioStatus, + PortfolioType, +} from "../../src/portfolio/entities/portfolio.entity"; +import { PortfolioAsset } from "../../src/portfolio/entities/portfolio-asset.entity"; +import { RiskProfile } from "../../src/portfolio/entities/risk-profile.entity"; +import { OptimizationHistory } from "../../src/portfolio/entities/optimization-history.entity"; +import { RebalancingEvent } from "../../src/portfolio/entities/rebalancing-event.entity"; +import { PerformanceMetric } from "../../src/portfolio/entities/performance-metric.entity"; +import { BacktestResult } from "../../src/portfolio/entities/backtest-result.entity"; +import { Transaction } from "../../src/portfolio/entities/transaction.entity"; +import { User } from "../../src/user/entities/user.entity"; +import { GlobalExceptionFilter } from "../../src/common/filters/global-exception.filter"; + +// Mock JWT strategy +jest.mock("../../src/auth/jwt.guard", () => ({ + JwtAuthGuard: jest.fn().mockImplementation(() => ({ + canActivate: (context: any) => { + const request = context.switchToHttp().getRequest(); + request.user = { id: "test-user-id", email: "test@example.com" }; + return true; + }, + })), +})); + +// Mock PortfolioOwnerGuard +jest.mock("../../src/common/guard/portfolio-owner.guard", () => ({ + PortfolioOwnerGuard: jest.fn().mockImplementation(() => ({ + canActivate: () => true, + })), +})); + +describe("Portfolio Load Tests (e2e)", () => { + let app: INestApplication; + let portfolioIds: string[] = []; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: "sqlite", + database: ":memory:", + entities: [ + User, + Portfolio, + PortfolioAsset, + RiskProfile, + OptimizationHistory, + RebalancingEvent, + PerformanceMetric, + BacktestResult, + Transaction, + ], + synchronize: true, + }), + PortfolioModule, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalFilters(new GlobalExceptionFilter()); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + + // Pre-create portfolios for load testing + for (let i = 0; i < 5; i++) { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: `Load Test Portfolio ${i}`, + description: `Portfolio for load testing ${i}`, + type: i % 2 === 0 ? "balanced" : "aggressive", + initialAllocation: { BTC: 50, ETH: 50 }, + }); + portfolioIds.push(response.body.id); + } + }); + + afterAll(async () => { + await app.close(); + }); + + describe("GET /portfolio/:id – Sequential Read Load", () => { + it("should handle 100 sequential reads under 2 seconds", async () => { + const portfolioId = portfolioIds[0]; + const startTime = Date.now(); + const count = 100; + const statuses: number[] = []; + + for (let i = 0; i < count; i++) { + const res = await request(app.getHttpServer()).get( + `/portfolio/${portfolioId}`, + ); + statuses.push(res.status); + } + + const elapsed = Date.now() - startTime; + + expect(statuses.every((s) => s === 200)).toBe(true); + expect(elapsed).toBeLessThan(2000); + + const rps = (count / elapsed) * 1000; + expect(rps).toBeGreaterThanOrEqual(100); + }, 10000); + }); + + describe("GET /portfolio – Concurrent List Load", () => { + it("should handle 50 concurrent list requests under 3 seconds", async () => { + const startTime = Date.now(); + const count = 50; + const promises: Promise[] = []; + + for (let i = 0; i < count; i++) { + promises.push( + request(app.getHttpServer()) + .get("/portfolio") + .query({ page: 1, limit: 10 }), + ); + } + + const responses = await Promise.all(promises); + const elapsed = Date.now() - startTime; + + const successCount = responses.filter((r) => r.status === 200).length; + expect(successCount).toBe(count); + expect(elapsed).toBeLessThan(3000); + }, 10000); + }); + + describe("POST /portfolio – Concurrent Create Load", () => { + it("should handle 20 concurrent creates under 5 seconds", async () => { + const startTime = Date.now(); + const count = 20; + const promises: Promise[] = []; + + for (let i = 0; i < count; i++) { + promises.push( + request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: `Concurrent Create ${Date.now()}-${i}`, + description: `Load test ${i}`, + }), + ); + } + + const responses = await Promise.all(promises); + const elapsed = Date.now() - startTime; + + const successCount = responses.filter((r) => r.status === 201).length; + expect(successCount).toBe(count); + expect(elapsed).toBeLessThan(5000); + }, 10000); + }); + + describe("GET /portfolio/stats – Stats Load", () => { + it("should handle 50 concurrent stats requests under 3 seconds", async () => { + const startTime = Date.now(); + const count = 50; + const promises: Promise[] = []; + + for (let i = 0; i < count; i++) { + promises.push( + request(app.getHttpServer()).get("/portfolio/stats"), + ); + } + + const responses = await Promise.all(promises); + const elapsed = Date.now() - startTime; + + const successCount = responses.filter((r) => r.status === 200).length; + expect(successCount).toBe(count); + expect(elapsed).toBeLessThan(3000); + }, 10000); + }); + + describe("Mixed Workload – Read/Write Mix", () => { + it("should handle mixed read/write workload under 5 seconds", async () => { + const startTime = Date.now(); + const promises: Promise[] = []; + + // 30 reads + for (let i = 0; i < 30; i++) { + const idx = i % portfolioIds.length; + promises.push( + request(app.getHttpServer()).get(`/portfolio/${portfolioIds[idx]}`), + ); + } + + // 10 list queries + for (let i = 0; i < 10; i++) { + promises.push( + request(app.getHttpServer()) + .get("/portfolio") + .query({ page: 1, limit: 5 }), + ); + } + + // 5 creates + for (let i = 0; i < 5; i++) { + promises.push( + request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: `Mixed Workload ${Date.now()}-${i}`, + }), + ); + } + + // 5 stats + for (let i = 0; i < 5; i++) { + promises.push( + request(app.getHttpServer()).get("/portfolio/stats"), + ); + } + + const responses = await Promise.all(promises); + const elapsed = Date.now() - startTime; + + // All should succeed + const successCount = responses.filter( + (r) => r.status === 200 || r.status === 201, + ).length; + expect(successCount).toBe(50); + expect(elapsed).toBeLessThan(5000); + }, 10000); + }); + + describe("Pagination Edge Cases Under Load", () => { + it("should handle rapid pagination through all pages", async () => { + const startTime = Date.now(); + const promises: Promise[] = []; + + // Request pages 1-10 concurrently + for (let page = 1; page <= 10; page++) { + promises.push( + request(app.getHttpServer()) + .get("/portfolio") + .query({ page, limit: 1 }), + ); + } + + const responses = await Promise.all(promises); + const elapsed = Date.now() - startTime; + + const successCount = responses.filter((r) => r.status === 200).length; + expect(successCount).toBe(10); + expect(elapsed).toBeLessThan(3000); + + // Each response should have correct page number + responses.forEach((res, idx) => { + expect(res.body.page).toBe(idx + 1); + }); + }, 10000); + }); + + describe("Export Endpoint Load", () => { + it("should handle 10 concurrent export requests under 3 seconds", async () => { + const portfolioId = portfolioIds[0]; + const startTime = Date.now(); + const count = 10; + const promises: Promise[] = []; + + for (let i = 0; i < count; i++) { + promises.push( + request(app.getHttpServer()).get(`/portfolio/${portfolioId}/export`), + ); + } + + const responses = await Promise.all(promises); + const elapsed = Date.now() - startTime; + + const successCount = responses.filter((r) => r.status === 200).length; + expect(successCount).toBe(count); + expect(elapsed).toBeLessThan(3000); + }, 10000); + }); +}); diff --git a/test/portfolio/portfolio-rest-api.e2e-spec.ts b/test/portfolio/portfolio-rest-api.e2e-spec.ts new file mode 100644 index 00000000..701d4fbc --- /dev/null +++ b/test/portfolio/portfolio-rest-api.e2e-spec.ts @@ -0,0 +1,608 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import * as request from "supertest"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { PortfolioModule } from "../../src/portfolio/portfolio.module"; +import { + Portfolio, + PortfolioStatus, + PortfolioType, +} from "../../src/portfolio/entities/portfolio.entity"; +import { PortfolioAsset, Chain } from "../../src/portfolio/entities/portfolio-asset.entity"; +import { RiskProfile } from "../../src/portfolio/entities/risk-profile.entity"; +import { OptimizationHistory } from "../../src/portfolio/entities/optimization-history.entity"; +import { RebalancingEvent } from "../../src/portfolio/entities/rebalancing-event.entity"; +import { PerformanceMetric } from "../../src/portfolio/entities/performance-metric.entity"; +import { BacktestResult } from "../../src/portfolio/entities/backtest-result.entity"; +import { Transaction } from "../../src/portfolio/entities/transaction.entity"; +import { User } from "../../src/user/entities/user.entity"; +import { GlobalExceptionFilter } from "../../src/common/filters/global-exception.filter"; + +// Mock JWT strategy to bypass real authentication in tests +jest.mock("../../src/auth/jwt.guard", () => ({ + JwtAuthGuard: jest.fn().mockImplementation(() => ({ + canActivate: (context: any) => { + const request = context.switchToHttp().getRequest(); + request.user = { id: "test-user-id", email: "test@example.com" }; + return true; + }, + })), +})); + +// Mock PortfolioOwnerGuard to always allow access in tests +jest.mock("../../src/common/guard/portfolio-owner.guard", () => ({ + PortfolioOwnerGuard: jest.fn().mockImplementation(() => ({ + canActivate: () => true, + })), +})); + +describe("Portfolio REST API Endpoints (e2e)", () => { + let app: INestApplication; + let createdPortfolioId: string; + let secondPortfolioId: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: "sqlite", + database: ":memory:", + entities: [ + User, + Portfolio, + PortfolioAsset, + RiskProfile, + OptimizationHistory, + RebalancingEvent, + PerformanceMetric, + BacktestResult, + Transaction, + ], + synchronize: true, + }), + PortfolioModule, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalFilters(new GlobalExceptionFilter()); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + // ─── POST /portfolio – Create Portfolio ──────────────────────────── + + describe("POST /portfolio – Create Portfolio", () => { + it("should create a portfolio with valid data (201)", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: "Test Growth Fund", + description: "Long-term growth portfolio", + type: "aggressive", + initialAllocation: { BTC: 60, ETH: 40 }, + autoRebalanceEnabled: true, + rebalanceFrequency: "monthly", + rebalanceThreshold: 10, + }); + + expect(response.status).toBe(201); + expect(response.body).toHaveProperty("id"); + expect(response.body.name).toBe("Test Growth Fund"); + expect(response.body.description).toBe("Long-term growth portfolio"); + expect(response.body.status).toBe(PortfolioStatus.ACTIVE); + expect(response.body.type).toBe(PortfolioType.AGGRESSIVE); + expect(response.body.initialAllocation).toEqual({ BTC: 60, ETH: 40 }); + expect(response.body.currentAllocation).toEqual({ BTC: 60, ETH: 40 }); + expect(response.body.autoRebalanceEnabled).toBe(true); + expect(response.body.rebalanceFrequency).toBe("monthly"); + expect(response.body.rebalanceThreshold).toBe(10); + expect(response.body).toHaveProperty("createdAt"); + expect(response.body).toHaveProperty("updatedAt"); + + createdPortfolioId = response.body.id; + }); + + it("should create a second portfolio for stats testing", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: "Conservative Bond Fund", + description: "Fixed income focused", + type: "conservative", + initialAllocation: { BOND: 80, USDC: 20 }, + }); + + expect(response.status).toBe(201); + expect(response.body.type).toBe(PortfolioType.CONSERVATIVE); + secondPortfolioId = response.body.id; + }); + + it("should create a portfolio with minimal required fields", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: "Minimal Portfolio", + }); + + expect(response.status).toBe(201); + expect(response.body.name).toBe("Minimal Portfolio"); + expect(response.body.status).toBe(PortfolioStatus.ACTIVE); + expect(response.body.type).toBe(PortfolioType.BALANCED); + }); + + it("should reject creation with missing name (400)", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + description: "Missing name field", + }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("statusCode", 400); + }); + + it("should reject creation with name too short (400)", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: "AB", + }); + + expect(response.status).toBe(400); + }); + + it("should reject creation with name too long (400)", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: "A".repeat(101), + }); + + expect(response.status).toBe(400); + }); + + it("should reject creation with invalid type enum (400)", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: "Invalid Type Portfolio", + type: "invalid-type", + }); + + expect(response.status).toBe(400); + }); + + it("should reject duplicate portfolio name (409)", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: "Test Growth Fund", + }); + + expect(response.status).toBe(409); + }); + + it("should reject unknown fields (400)", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: "Unknown Fields Portfolio", + unknownField: "should be rejected", + }); + + expect(response.status).toBe(400); + }); + }); + + // ─── GET /portfolio – List User Portfolios ───────────────────────── + + describe("GET /portfolio – List User Portfolios", () => { + it("should list all portfolios with pagination (200)", async () => { + const response = await request(app.getHttpServer()) + .get("/portfolio") + .query({ page: 1, limit: 20 }); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("data"); + expect(response.body).toHaveProperty("total"); + expect(response.body).toHaveProperty("page", 1); + expect(response.body).toHaveProperty("limit", 20); + expect(response.body).toHaveProperty("totalPages"); + expect(Array.isArray(response.body.data)).toBe(true); + expect(response.body.data.length).toBeGreaterThanOrEqual(1); + }); + + it("should filter by status (200)", async () => { + const response = await request(app.getHttpServer()) + .get("/portfolio") + .query({ status: "active" }); + + expect(response.status).toBe(200); + expect(response.body.data.every((p: any) => p.status === "active")).toBe( + true, + ); + }); + + it("should filter by type (200)", async () => { + const response = await request(app.getHttpServer()) + .get("/portfolio") + .query({ type: "aggressive" }); + + expect(response.status).toBe(200); + response.body.data.forEach((p: any) => { + expect(p.type).toBe("aggressive"); + }); + }); + + it("should search by name (200)", async () => { + const response = await request(app.getHttpServer()) + .get("/portfolio") + .query({ search: "Growth" }); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBeGreaterThanOrEqual(1); + expect(response.body.data[0].name).toContain("Growth"); + }); + + it("should paginate correctly (200)", async () => { + const response = await request(app.getHttpServer()) + .get("/portfolio") + .query({ page: 1, limit: 1 }); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.limit).toBe(1); + }); + + it("should return empty data for non-matching search (200)", async () => { + const response = await request(app.getHttpServer()) + .get("/portfolio") + .query({ search: "nonexistent-portfolio-name-xyz" }); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(0); + expect(response.body.total).toBe(0); + }); + }); + + // ─── GET /portfolio/:id – Get Portfolio ──────────────────────────── + + describe("GET /portfolio/:id – Get Portfolio", () => { + it("should get a portfolio by ID (200)", async () => { + const response = await request(app.getHttpServer()).get( + `/portfolio/${createdPortfolioId}`, + ); + + expect(response.status).toBe(200); + expect(response.body.id).toBe(createdPortfolioId); + expect(response.body.name).toBe("Test Growth Fund"); + expect(response.body).toHaveProperty("createdAt"); + expect(response.body).toHaveProperty("updatedAt"); + }); + + it("should return 404 for non-existent portfolio", async () => { + const response = await request(app.getHttpServer()).get( + "/portfolio/00000000-0000-0000-0000-000000000000", + ); + + expect(response.status).toBe(404); + }); + }); + + // ─── GET /portfolio/:id/summary – Portfolio Summary ──────────────── + + describe("GET /portfolio/:id/summary – Portfolio Summary", () => { + it("should get portfolio summary (200)", async () => { + const response = await request(app.getHttpServer()).get( + `/portfolio/${createdPortfolioId}/summary`, + ); + + expect(response.status).toBe(200); + expect(response.body.id).toBe(createdPortfolioId); + expect(response.body.name).toBe("Test Growth Fund"); + expect(response.body).toHaveProperty("totalValue"); + expect(response.body).toHaveProperty("assetCount"); + expect(response.body).toHaveProperty("currentAllocation"); + expect(response.body).toHaveProperty("autoRebalanceEnabled"); + expect(response.body).toHaveProperty("rebalanceFrequency"); + expect(response.body).toHaveProperty("rebalanceThreshold"); + }); + + it("should return 404 for non-existent portfolio summary", async () => { + const response = await request(app.getHttpServer()).get( + "/portfolio/00000000-0000-0000-0000-000000000000/summary", + ); + + expect(response.status).toBe(404); + }); + }); + + // ─── GET /portfolio/stats – Portfolio Stats ──────────────────────── + + describe("GET /portfolio/stats – Portfolio Stats", () => { + it("should get aggregate portfolio stats (200)", async () => { + const response = await request(app.getHttpServer()).get( + "/portfolio/stats", + ); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("totalPortfolios"); + expect(response.body).toHaveProperty("activePortfolios"); + expect(response.body).toHaveProperty("totalValue"); + expect(response.body).toHaveProperty("totalAssets"); + expect(response.body).toHaveProperty("byType"); + expect(response.body).toHaveProperty("topPortfolios"); + expect(response.body.totalPortfolios).toBeGreaterThanOrEqual(1); + expect(typeof response.body.byType).toBe("object"); + expect(Array.isArray(response.body.topPortfolios)).toBe(true); + }); + }); + + // ─── GET /portfolio/:id/export – Export Portfolio ─────────────────── + + describe("GET /portfolio/:id/export – Export Portfolio", () => { + it("should export full portfolio data (200)", async () => { + const response = await request(app.getHttpServer()).get( + `/portfolio/${createdPortfolioId}/export`, + ); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("exportedAt"); + expect(response.body).toHaveProperty("portfolio"); + expect(response.body).toHaveProperty("assets"); + expect(response.body).toHaveProperty("optimizationHistory"); + expect(response.body.portfolio.id).toBe(createdPortfolioId); + expect(Array.isArray(response.body.assets)).toBe(true); + expect(Array.isArray(response.body.optimizationHistory)).toBe(true); + }); + + it("should return 404 for non-existent portfolio export", async () => { + const response = await request(app.getHttpServer()).get( + "/portfolio/00000000-0000-0000-0000-000000000000/export", + ); + + expect(response.status).toBe(404); + }); + }); + + // ─── PUT /portfolio/:id – Update Portfolio ───────────────────────── + + describe("PUT /portfolio/:id – Update Portfolio", () => { + it("should update portfolio name (200)", async () => { + const response = await request(app.getHttpServer()) + .put(`/portfolio/${createdPortfolioId}`) + .send({ + name: "Updated Growth Fund", + }); + + expect(response.status).toBe(200); + expect(response.body.name).toBe("Updated Growth Fund"); + expect(response.body.id).toBe(createdPortfolioId); + }); + + it("should update multiple fields (200)", async () => { + const response = await request(app.getHttpServer()) + .put(`/portfolio/${createdPortfolioId}`) + .send({ + description: "Updated description for long-term growth", + type: "balanced", + autoRebalanceEnabled: false, + rebalanceThreshold: 15, + }); + + expect(response.status).toBe(200); + expect(response.body.description).toBe( + "Updated description for long-term growth", + ); + expect(response.body.type).toBe(PortfolioType.BALANCED); + expect(response.body.autoRebalanceEnabled).toBe(false); + expect(response.body.rebalanceThreshold).toBe(15); + }); + + it("should return 404 when updating non-existent portfolio", async () => { + const response = await request(app.getHttpServer()) + .put("/portfolio/00000000-0000-0000-0000-000000000000") + .send({ + name: "Non-existent", + }); + + expect(response.status).toBe(404); + }); + + it("should reject invalid type enum on update (400)", async () => { + const response = await request(app.getHttpServer()) + .put(`/portfolio/${createdPortfolioId}`) + .send({ + type: "invalid-type", + }); + + expect(response.status).toBe(400); + }); + + it("should reject name shorter than 3 chars (400)", async () => { + const response = await request(app.getHttpServer()) + .put(`/portfolio/${createdPortfolioId}`) + .send({ + name: "AB", + }); + + expect(response.status).toBe(400); + }); + }); + + // ─── DELETE /portfolio/:id – Archive Portfolio ───────────────────── + + describe("DELETE /portfolio/:id – Archive Portfolio", () => { + let portfolioToDeleteId: string; + + beforeAll(async () => { + // Create a portfolio to archive + const createResponse = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: "Portfolio To Archive", + description: "Will be archived", + }); + portfolioToDeleteId = createResponse.body.id; + }); + + it("should archive a portfolio (200)", async () => { + const response = await request(app.getHttpServer()).delete( + `/portfolio/${portfolioToDeleteId}`, + ); + + expect(response.status).toBe(200); + expect(response.body.status).toBe(PortfolioStatus.ARCHIVED); + }); + + it("should not appear in active portfolio list after archiving", async () => { + const response = await request(app.getHttpServer()) + .get("/portfolio") + .query({ status: "active" }); + + expect(response.status).toBe(200); + const archivedIds = response.body.data.map((p: any) => p.id); + expect(archivedIds).not.toContain(portfolioToDeleteId); + }); + + it("should return 404 when archiving non-existent portfolio", async () => { + const response = await request(app.getHttpServer()).delete( + "/portfolio/00000000-0000-0000-0000-000000000000", + ); + + expect(response.status).toBe(404); + }); + }); + + // ─── Standard Error Response Format ──────────────────────────────── + + describe("Standard Error Response Format", () => { + it("should return structured error for 400", async () => { + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({}); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("statusCode", 400); + expect(response.body).toHaveProperty("message"); + expect(response.body).toHaveProperty("timestamp"); + expect(response.body).toHaveProperty("path"); + }); + + it("should return structured error for 404", async () => { + const response = await request(app.getHttpServer()).get( + "/portfolio/non-existent-id", + ); + + expect(response.status).toBe(404); + expect(response.body).toHaveProperty("statusCode", 404); + expect(response.body).toHaveProperty("message"); + expect(response.body).toHaveProperty("timestamp"); + expect(response.body).toHaveProperty("path"); + }); + + it("should return structured error for 409 (duplicate name)", async () => { + // Try to create with an existing name + const response = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: "Updated Growth Fund", // Already exists from earlier test + }); + + expect(response.status).toBe(409); + expect(response.body).toHaveProperty("statusCode", 409); + expect(response.body).toHaveProperty("message"); + }); + }); + + // ─── Load Test ───────────────────────────────────────────────────── + + describe("Load Test – 100 requests/second per portfolio", () => { + it("should handle 100 sequential GET requests within 2 seconds", async () => { + const portfolioId = createdPortfolioId; + const startTime = Date.now(); + const requestCount = 100; + const results: number[] = []; + + for (let i = 0; i < requestCount; i++) { + const response = await request(app.getHttpServer()).get( + `/portfolio/${portfolioId}`, + ); + results.push(response.status); + } + + const elapsed = Date.now() - startTime; + + // All requests should succeed + expect(results.every((status) => status === 200)).toBe(true); + + // Should handle 100 requests in under 2 seconds (well above 100 req/s) + expect(elapsed).toBeLessThan(2000); + + const requestsPerSecond = (requestCount / elapsed) * 1000; + expect(requestsPerSecond).toBeGreaterThanOrEqual(100); + }, 10000); + + it("should handle concurrent list requests without conflicts", async () => { + const startTime = Date.now(); + const concurrentCount = 20; + const promises: Promise[] = []; + + for (let i = 0; i < concurrentCount; i++) { + promises.push( + request(app.getHttpServer()) + .get("/portfolio") + .query({ page: 1, limit: 10 }), + ); + } + + const responses = await Promise.all(promises); + const elapsed = Date.now() - startTime; + + // All should succeed (200) + const successCount = responses.filter((r) => r.status === 200).length; + expect(successCount).toBe(concurrentCount); + + // Should complete within reasonable time + expect(elapsed).toBeLessThan(5000); + }, 10000); + + it("should handle concurrent create requests without conflicts", async () => { + const startTime = Date.now(); + const concurrentCount = 10; + const promises: Promise[] = []; + + for (let i = 0; i < concurrentCount; i++) { + promises.push( + request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: `Concurrent Portfolio ${i} ${Date.now()}`, + description: `Load test portfolio ${i}`, + }), + ); + } + + const responses = await Promise.all(promises); + const elapsed = Date.now() - startTime; + + // All should succeed (201) + const successCount = responses.filter((r) => r.status === 201).length; + expect(successCount).toBe(concurrentCount); + + // Should complete within reasonable time + expect(elapsed).toBeLessThan(5000); + }, 10000); + }); +}); diff --git a/test/portfolio/transactions.e2e-spec.ts b/test/portfolio/transactions.e2e-spec.ts index a5ea9221..aa9aede9 100644 --- a/test/portfolio/transactions.e2e-spec.ts +++ b/test/portfolio/transactions.e2e-spec.ts @@ -6,15 +6,39 @@ import { Portfolio, PortfolioStatus, PortfolioType, -} from "../entities/portfolio.entity"; -import { PortfolioAsset, AssetType } from "../entities/portfolio-asset.entity"; +} from "../../src/portfolio/entities/portfolio.entity"; +import { PortfolioAsset, AssetType } from "../../src/portfolio/entities/portfolio-asset.entity"; import { Transaction, TransactionType, TransactionStatus, -} from "../entities/transaction.entity"; -import { PortfolioModule } from "../portfolio.module"; -import { User } from "../../user/entities/user.entity"; +} from "../../src/portfolio/entities/transaction.entity"; +import { RiskProfile } from "../../src/portfolio/entities/risk-profile.entity"; +import { OptimizationHistory } from "../../src/portfolio/entities/optimization-history.entity"; +import { RebalancingEvent } from "../../src/portfolio/entities/rebalancing-event.entity"; +import { PerformanceMetric } from "../../src/portfolio/entities/performance-metric.entity"; +import { BacktestResult } from "../../src/portfolio/entities/backtest-result.entity"; +import { PortfolioModule } from "../../src/portfolio/portfolio.module"; +import { User } from "../../src/user/entities/user.entity"; +import { GlobalExceptionFilter } from "../../src/common/filters/global-exception.filter"; + +// Mock JWT strategy to bypass real authentication in tests +jest.mock("../../src/auth/jwt.guard", () => ({ + JwtAuthGuard: jest.fn().mockImplementation(() => ({ + canActivate: (context: any) => { + const request = context.switchToHttp().getRequest(); + request.user = { id: "test-user-id", email: "test@example.com" }; + return true; + }, + })), +})); + +// Mock PortfolioOwnerGuard to always allow access in tests +jest.mock("../../src/common/guard/portfolio-owner.guard", () => ({ + PortfolioOwnerGuard: jest.fn().mockImplementation(() => ({ + canActivate: () => true, + })), +})); // Integration test for transaction tracking and portfolio operations describe("Portfolio Transactions Integration (e2e)", () => { @@ -28,7 +52,17 @@ describe("Portfolio Transactions Integration (e2e)", () => { TypeOrmModule.forRoot({ type: "sqlite", database: ":memory:", - entities: [User, Portfolio, PortfolioAsset, Transaction], + entities: [ + User, + Portfolio, + PortfolioAsset, + Transaction, + RiskProfile, + OptimizationHistory, + RebalancingEvent, + PerformanceMetric, + BacktestResult, + ], synchronize: true, }), PortfolioModule, @@ -36,12 +70,27 @@ describe("Portfolio Transactions Integration (e2e)", () => { }).compile(); app = moduleFixture.createNestApplication(); - app.useGlobalPipes(new ValidationPipe()); + app.useGlobalFilters(new GlobalExceptionFilter()); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); await app.init(); - // Create test user - userId = "test-user-1"; - portfolioId = "test-portfolio-1"; + // Create test user and portfolio + userId = "test-user-id"; + + // Create a portfolio first + const createResponse = await request(app.getHttpServer()) + .post("/portfolio") + .send({ + name: "Test Portfolio for Transactions", + description: "Integration test portfolio", + }); + portfolioId = createResponse.body.id; }); afterAll(async () => { @@ -51,9 +100,8 @@ describe("Portfolio Transactions Integration (e2e)", () => { describe("Transaction Recording and History", () => { it("should record a BUY transaction", async () => { const response = await request(app.getHttpServer()) - .post(`/portfolio/portfolios/${portfolioId}/transactions`) - .set("Authorization", `Bearer ${userId}`) - .send({ + .post(`/portfolio/${portfolioId}/transactions`) + .send({ type: TransactionType.BUY, ticker: "AAPL", name: "Apple Inc", @@ -72,9 +120,8 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should record a DIVIDEND transaction", async () => { const response = await request(app.getHttpServer()) - .post(`/portfolio/portfolios/${portfolioId}/transactions`) - .set("Authorization", `Bearer ${userId}`) - .send({ + .post(`/portfolio/${portfolioId}/transactions`) + .send({ type: TransactionType.DIVIDEND, ticker: "AAPL", name: "Apple Inc", @@ -89,9 +136,8 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should record a SELL transaction", async () => { const response = await request(app.getHttpServer()) - .post(`/portfolio/portfolios/${portfolioId}/transactions`) - .set("Authorization", `Bearer ${userId}`) - .send({ + .post(`/portfolio/${portfolioId}/transactions`) + .send({ type: TransactionType.SELL, ticker: "AAPL", name: "Apple Inc", @@ -108,9 +154,8 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should record a STAKE transaction", async () => { const response = await request(app.getHttpServer()) - .post(`/portfolio/portfolios/${portfolioId}/transactions`) - .set("Authorization", `Bearer ${userId}`) - .send({ + .post(`/portfolio/${portfolioId}/transactions`) + .send({ type: TransactionType.STAKE, ticker: "ETH", name: "Ethereum", @@ -127,9 +172,8 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should record a TRANSFER transaction", async () => { const response = await request(app.getHttpServer()) - .post(`/portfolio/portfolios/${portfolioId}/transactions`) - .set("Authorization", `Bearer ${userId}`) - .send({ + .post(`/portfolio/${portfolioId}/transactions`) + .send({ type: TransactionType.TRANSFER, ticker: "BTC", name: "Bitcoin", @@ -148,7 +192,7 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should retrieve transaction history with pagination", async () => { const response = await request(app.getHttpServer()) .get( - `/portfolio/portfolios/${portfolioId}/transactions?page=1&limit=20`, + `/portfolio/${portfolioId}/transactions?page=1&limit=20`, ) .set("Authorization", `Bearer ${userId}`); @@ -163,7 +207,7 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should filter transactions by type", async () => { const response = await request(app.getHttpServer()) .get( - `/portfolio/portfolios/${portfolioId}/transactions?type=${TransactionType.BUY}`, + `/portfolio/${portfolioId}/transactions?type=${TransactionType.BUY}`, ) .set("Authorization", `Bearer ${userId}`); @@ -177,7 +221,7 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should filter transactions by ticker", async () => { const response = await request(app.getHttpServer()) - .get(`/portfolio/portfolios/${portfolioId}/transactions?ticker=AAPL`) + .get(`/portfolio/${portfolioId}/transactions?ticker=AAPL`) .set("Authorization", `Bearer ${userId}`); expect(response.status).toBe(200); @@ -194,7 +238,7 @@ describe("Portfolio Transactions Integration (e2e)", () => { const response = await request(app.getHttpServer()) .get( - `/portfolio/portfolios/${portfolioId}/transactions?startDate=${startDate}&endDate=${endDate}`, + `/portfolio/${portfolioId}/transactions?startDate=${startDate}&endDate=${endDate}`, ) .set("Authorization", `Bearer ${userId}`); @@ -205,14 +249,14 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should retrieve a single transaction", async () => { // First, get a transaction ID const listResponse = await request(app.getHttpServer()) - .get(`/portfolio/portfolios/${portfolioId}/transactions?limit=1`) + .get(`/portfolio/${portfolioId}/transactions?limit=1`) .set("Authorization", `Bearer ${userId}`); const transactionId = listResponse.body.transactions[0].id; const response = await request(app.getHttpServer()) .get( - `/portfolio/portfolios/${portfolioId}/transactions/${transactionId}`, + `/portfolio/${portfolioId}/transactions/${transactionId}`, ) .set("Authorization", `Bearer ${userId}`); @@ -225,7 +269,7 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should calculate cost basis for a specific ticker", async () => { const response = await request(app.getHttpServer()) .get( - `/portfolio/portfolios/${portfolioId}/transactions/cost-basis/AAPL`, + `/portfolio/${portfolioId}/transactions/cost-basis/AAPL`, ) .set("Authorization", `Bearer ${userId}`); @@ -239,7 +283,7 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should calculate cost basis for all holdings", async () => { const response = await request(app.getHttpServer()) - .get(`/portfolio/portfolios/${portfolioId}/transactions/cost-basis`) + .get(`/portfolio/${portfolioId}/transactions/cost-basis`) .set("Authorization", `Bearer ${userId}`); expect(response.status).toBe(200); @@ -254,7 +298,7 @@ describe("Portfolio Transactions Integration (e2e)", () => { describe("Transaction Export", () => { it("should export transactions as CSV", async () => { const response = await request(app.getHttpServer()) - .get(`/portfolio/portfolios/${portfolioId}/transactions/export/csv`) + .get(`/portfolio/${portfolioId}/transactions/export/csv`) .set("Authorization", `Bearer ${userId}`); expect(response.status).toBe(200); @@ -265,7 +309,7 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should export transactions as JSON", async () => { const response = await request(app.getHttpServer()) - .get(`/portfolio/portfolios/${portfolioId}/transactions/export/json`) + .get(`/portfolio/${portfolioId}/transactions/export/json`) .set("Authorization", `Bearer ${userId}`); expect(response.status).toBe(200); @@ -278,7 +322,7 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should export filtered transactions as CSV", async () => { const response = await request(app.getHttpServer()) .get( - `/portfolio/portfolios/${portfolioId}/transactions/export/csv?type=${TransactionType.BUY}`, + `/portfolio/${portfolioId}/transactions/export/csv?type=${TransactionType.BUY}`, ) .set("Authorization", `Bearer ${userId}`); @@ -290,7 +334,7 @@ describe("Portfolio Transactions Integration (e2e)", () => { describe("Transaction Statistics", () => { it("should return transaction statistics", async () => { const response = await request(app.getHttpServer()) - .get(`/portfolio/portfolios/${portfolioId}/transactions/stats`) + .get(`/portfolio/${portfolioId}/transactions/stats`) .set("Authorization", `Bearer ${userId}`); expect(response.status).toBe(200); @@ -307,14 +351,14 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should archive a transaction", async () => { // First, get a transaction ID const listResponse = await request(app.getHttpServer()) - .get(`/portfolio/portfolios/${portfolioId}/transactions?limit=1`) + .get(`/portfolio/${portfolioId}/transactions?limit=1`) .set("Authorization", `Bearer ${userId}`); const transactionId = listResponse.body.transactions[0].id; const response = await request(app.getHttpServer()) .post( - `/portfolio/portfolios/${portfolioId}/transactions/${transactionId}/archive`, + `/portfolio/${portfolioId}/transactions/${transactionId}/archive`, ) .set("Authorization", `Bearer ${userId}`); @@ -325,7 +369,7 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should not return archived transactions in default query", async () => { // Get total count before archival const beforeArchive = await request(app.getHttpServer()) - .get(`/portfolio/portfolios/${portfolioId}/transactions`) + .get(`/portfolio/${portfolioId}/transactions`) .set("Authorization", `Bearer ${userId}`); const beforeCount = beforeArchive.body.total; @@ -333,7 +377,7 @@ describe("Portfolio Transactions Integration (e2e)", () => { // Should include archived when flag is set const withArchived = await request(app.getHttpServer()) .get( - `/portfolio/portfolios/${portfolioId}/transactions?includeArchived=true`, + `/portfolio/${portfolioId}/transactions?includeArchived=true`, ) .set("Authorization", `Bearer ${userId}`); @@ -344,9 +388,8 @@ describe("Portfolio Transactions Integration (e2e)", () => { describe("Transaction Validation", () => { it("should reject transaction with zero quantity", async () => { const response = await request(app.getHttpServer()) - .post(`/portfolio/portfolios/${portfolioId}/transactions`) - .set("Authorization", `Bearer ${userId}`) - .send({ + .post(`/portfolio/${portfolioId}/transactions`) + .send({ type: TransactionType.BUY, ticker: "AAPL", name: "Apple Inc", @@ -359,9 +402,8 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should reject transaction with negative fees", async () => { const response = await request(app.getHttpServer()) - .post(`/portfolio/portfolios/${portfolioId}/transactions`) - .set("Authorization", `Bearer ${userId}`) - .send({ + .post(`/portfolio/${portfolioId}/transactions`) + .send({ type: TransactionType.BUY, ticker: "AAPL", name: "Apple Inc", @@ -375,9 +417,8 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should reject BUY transaction without price", async () => { const response = await request(app.getHttpServer()) - .post(`/portfolio/portfolios/${portfolioId}/transactions`) - .set("Authorization", `Bearer ${userId}`) - .send({ + .post(`/portfolio/${portfolioId}/transactions`) + .send({ type: TransactionType.BUY, ticker: "AAPL", name: "Apple Inc", @@ -389,9 +430,8 @@ describe("Portfolio Transactions Integration (e2e)", () => { it("should allow TRANSFER transaction without price", async () => { const response = await request(app.getHttpServer()) - .post(`/portfolio/portfolios/${portfolioId}/transactions`) - .set("Authorization", `Bearer ${userId}`) - .send({ + .post(`/portfolio/${portfolioId}/transactions`) + .send({ type: TransactionType.TRANSFER, ticker: "BTC", name: "Bitcoin", @@ -407,9 +447,8 @@ describe("Portfolio Transactions Integration (e2e)", () => { // First transaction await request(app.getHttpServer()) - .post(`/portfolio/portfolios/${portfolioId}/transactions`) - .set("Authorization", `Bearer ${userId}`) - .send({ + .post(`/portfolio/${portfolioId}/transactions`) + .send({ type: TransactionType.BUY, ticker: "AAPL", name: "Apple Inc", @@ -420,9 +459,8 @@ describe("Portfolio Transactions Integration (e2e)", () => { // Duplicate transaction const response = await request(app.getHttpServer()) - .post(`/portfolio/portfolios/${portfolioId}/transactions`) - .set("Authorization", `Bearer ${userId}`) - .send({ + .post(`/portfolio/${portfolioId}/transactions`) + .send({ type: TransactionType.BUY, ticker: "AAPL", name: "Apple Inc",