From e629ac5203a12a2b2c7fd29a09ca294d931a7b2c Mon Sep 17 00:00:00 2001 From: michaelvic123 Date: Fri, 19 Jun 2026 15:41:44 +0100 Subject: [PATCH] feat: add, update, remove holdings with validation --- .../portfolio/dto/portfolio-asset.dto.ts | 43 ++++- .../entities/portfolio-asset.entity.ts | 17 +- .../portfolio/portfolio.controller.ts | 40 +++- .../portfolio/services/portfolio.service.ts | 171 ++++++++++++++---- test/portfolio/portfolio.service.spec.ts | 99 +++++++++- 5 files changed, 326 insertions(+), 44 deletions(-) diff --git a/src/investment/portfolio/dto/portfolio-asset.dto.ts b/src/investment/portfolio/dto/portfolio-asset.dto.ts index e7383de..010ad5f 100644 --- a/src/investment/portfolio/dto/portfolio-asset.dto.ts +++ b/src/investment/portfolio/dto/portfolio-asset.dto.ts @@ -1,12 +1,21 @@ -import { IsString, IsOptional, IsNumber, IsDateString } from "class-validator"; +import { IsString, IsOptional, IsNumber, IsEnum, Length } from "class-validator"; +import { Chain, AssetType } from "../entities/portfolio-asset.entity"; export class PortfolioAssetDto { @IsString() + @Length(3, 10) ticker: string; @IsString() name: string; + @IsEnum(Chain) + chain: Chain; + + @IsOptional() + @IsEnum(AssetType) + type?: AssetType; + @IsOptional() @IsNumber() quantity?: number; @@ -39,7 +48,33 @@ export class AddAssetToPortfolioDto { costBasis?: number; } -export class UpdatePortfolioAssetDto { +export class AddHoldingDto { + @IsString() + @Length(3, 10) + ticker: string; + + @IsString() + name: string; + + @IsEnum(Chain) + chain: Chain; + + @IsOptional() + @IsEnum(AssetType) + type?: AssetType; + + @IsNumber() + quantity: number; + + @IsOptional() + @IsNumber() + currentPrice?: number; + + @IsNumber() + costBasis: number; +} + +export class UpdateHoldingDto { @IsOptional() @IsNumber() quantity?: number; @@ -57,7 +92,8 @@ export class PortfolioAssetResponseDto { id: string; ticker: string; name: string; - type: string; + chain: Chain; + type: AssetType; quantity: number; currentPrice?: number; value: number; @@ -66,6 +102,7 @@ export class PortfolioAssetResponseDto { expectedReturn?: number; volatility?: number; beta?: number; + costBasis?: number; unrealizedGain?: number; updatedAt: Date; } diff --git a/src/investment/portfolio/entities/portfolio-asset.entity.ts b/src/investment/portfolio/entities/portfolio-asset.entity.ts index 66b99f6..00cd56a 100644 --- a/src/investment/portfolio/entities/portfolio-asset.entity.ts +++ b/src/investment/portfolio/entities/portfolio-asset.entity.ts @@ -21,8 +21,16 @@ export enum AssetType { OTHER = "other", } +export enum Chain { + ETHEREUM = "ethereum", + POLYGON = "polygon", + BINANCE = "binance", + SOLANA = "solana", + OTHER = "other", +} + @Entity("portfolio_assets") -@Index(["portfolioId", "ticker"]) +@Index(["portfolioId", "ticker", "chain"], { unique: true }) export class PortfolioAsset { @PrimaryGeneratedColumn("uuid") id: string; @@ -33,6 +41,13 @@ export class PortfolioAsset { @Column() name: string; + @Column({ + type: "enum", + enum: Chain, + default: Chain.OTHER, + }) + chain: Chain; + @Column({ type: "enum", enum: AssetType, diff --git a/src/investment/portfolio/portfolio.controller.ts b/src/investment/portfolio/portfolio.controller.ts index b45315a..18c79a5 100644 --- a/src/investment/portfolio/portfolio.controller.ts +++ b/src/investment/portfolio/portfolio.controller.ts @@ -23,7 +23,7 @@ import { BacktestingService } from "./services/backtesting.service"; import { MLPredictionService } from "./services/ml-prediction.service"; import { PortfolioOwnerGuard } from "./guards/portfolio-owner.guard"; import { CreatePortfolioDto, UpdatePortfolioDto } from "./dto/portfolio.dto"; -import { AddAssetToPortfolioDto } from "./dto/portfolio-asset.dto"; +import { AddAssetToPortfolioDto, AddHoldingDto, UpdateHoldingDto } from "./dto/portfolio-asset.dto"; import { ApproveOptimizationDto, CreateOptimizationDto } from "./dto/optimization.dto"; import { ExecuteRebalancingDto, TriggerRebalancingDto } from "./dto/rebalancing.dto"; import { GetPerformanceMetricsDto } from "./dto/performance.dto"; @@ -82,10 +82,44 @@ export class PortfolioController { return this.portfolioService.deletePortfolio(portfolioId); } - // Asset Management Endpoints + // Holding Management Endpoints + + @Post("portfolios/:portfolioId/holdings") + @ApiOperation({ summary: "Add holding to portfolio" }) + @UseGuards(PortfolioOwnerGuard) + async addHolding( + @Param("portfolioId") portfolioId: string, + @Body() dto: AddHoldingDto, + ) { + return this.portfolioService.addHolding(portfolioId, dto); + } + + @Put("portfolios/:portfolioId/holdings/:holdingId") + @ApiOperation({ summary: "Update holding in portfolio" }) + @UseGuards(PortfolioOwnerGuard) + async updateHolding( + @Param("portfolioId") portfolioId: string, + @Param("holdingId") holdingId: string, + @Body() dto: UpdateHoldingDto, + ) { + return this.portfolioService.updateHolding(portfolioId, holdingId, dto); + } + + @Delete("portfolios/:portfolioId/holdings/:holdingId") + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Remove holding from portfolio" }) + @UseGuards(PortfolioOwnerGuard) + async removeHolding( + @Param("portfolioId") portfolioId: string, + @Param("holdingId") holdingId: string, + ) { + return this.portfolioService.removeHolding(portfolioId, holdingId); + } + + // Asset Management Endpoints (Backward Compatibility) @Post("portfolios/:portfolioId/assets") - @ApiOperation({ summary: "Add asset to portfolio" }) + @ApiOperation({ summary: "Add asset to portfolio (legacy)" }) @UseGuards(PortfolioOwnerGuard) async addAsset( @Param("portfolioId") portfolioId: string, diff --git a/src/investment/portfolio/services/portfolio.service.ts b/src/investment/portfolio/services/portfolio.service.ts index 09e2cf4..d329161 100644 --- a/src/investment/portfolio/services/portfolio.service.ts +++ b/src/investment/portfolio/services/portfolio.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger, BadRequestException } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { Portfolio } from "../entities/portfolio.entity"; -import { PortfolioAsset } from "../entities/portfolio-asset.entity"; +import { PortfolioAsset, Chain, AssetType } from "../entities/portfolio-asset.entity"; import { OptimizationHistory, OptimizationMethod, @@ -11,6 +11,7 @@ import { import { RiskProfile } from "../entities/risk-profile.entity"; import { CreatePortfolioDto, UpdatePortfolioDto } from "../dto/portfolio.dto"; import { CreateOptimizationDto } from "../dto/optimization.dto"; +import { AddHoldingDto, UpdateHoldingDto } from "../dto/portfolio-asset.dto"; import { PortfolioStatus } from "../entities/portfolio.entity"; import { ModernPortfolioTheory } from "../algorithms/modern-portfolio-theory"; import { BlackLittermanModel } from "../algorithms/black-litterman"; @@ -91,47 +92,133 @@ export class PortfolioService { } /** - * Add asset to portfolio + * Add holding to portfolio */ - async addAsset( + async addHolding( portfolioId: string, - ticker: string, - name: string, - quantity: number, - currentPrice: number = 0, - costBasis: number = 0, + dto: AddHoldingDto, ): Promise { - const portfolio = await this.getPortfolio(portfolioId); + await this.getPortfolio(portfolioId); - // Check if asset already exists - let asset = await this.portfolioAssetRepository.findOne({ - where: { portfolioId, ticker }, + // Check if holding already exists + const existing = await this.portfolioAssetRepository.findOne({ + where: { portfolioId, ticker: dto.ticker, chain: dto.chain }, }); - if (!asset) { - asset = this.portfolioAssetRepository.create({ - portfolioId, - ticker, - name, - quantity: 0, - value: 0, - allocationPercentage: 0, - costBasis, - costBasisPerShare: currentPrice, - }); + if (existing) { + throw new BadRequestException("Holding with same ticker and chain already exists"); } - // Update asset - asset.quantity = quantity; - asset.currentPrice = currentPrice; - asset.value = quantity * currentPrice; + const holding = this.portfolioAssetRepository.create({ + portfolioId, + ticker: dto.ticker, + name: dto.name, + chain: dto.chain, + type: dto.type || AssetType.CRYPTOCURRENCY, + quantity: dto.quantity, + currentPrice: dto.currentPrice || 0, + value: dto.quantity * (dto.currentPrice || 0), + costBasis: dto.costBasis, + costBasisPerShare: dto.quantity > 0 ? dto.costBasis / dto.quantity : 0, + }); + + // Calculate unrealized gain + if (holding.currentPrice && holding.costBasisPerShare) { + holding.unrealizedGain = (holding.currentPrice - holding.costBasisPerShare) * holding.quantity; + } + + const saved = await this.portfolioAssetRepository.save(holding); + + // Update portfolio metrics + await this.updatePortfolioMetrics(portfolioId); + + return saved; + } + + /** + * Update holding + */ + async updateHolding( + portfolioId: string, + holdingId: string, + dto: UpdateHoldingDto, + ): Promise { + const holding = await this.portfolioAssetRepository.findOne({ + where: { id: holdingId, portfolioId }, + }); + + if (!holding) { + throw new BadRequestException("Holding not found"); + } + + // Update fields + if (dto.quantity !== undefined) { + holding.quantity = dto.quantity; + } + if (dto.currentPrice !== undefined) { + holding.currentPrice = dto.currentPrice; + } + if (dto.costBasis !== undefined) { + holding.costBasis = dto.costBasis; + holding.costBasisPerShare = holding.quantity > 0 ? dto.costBasis / holding.quantity : 0; + } + + // Recalculate value + holding.value = holding.quantity * (holding.currentPrice || 0); + + // Recalculate unrealized gain + if (holding.currentPrice && holding.costBasisPerShare) { + holding.unrealizedGain = (holding.currentPrice - holding.costBasisPerShare) * holding.quantity; + } - asset = await this.portfolioAssetRepository.save(asset); + const updated = await this.portfolioAssetRepository.save(holding); - // Update portfolio allocation - await this.updatePortfolioAllocation(portfolioId); + // Update portfolio metrics + await this.updatePortfolioMetrics(portfolioId); - return asset; + return updated; + } + + /** + * Remove holding from portfolio + */ + async removeHolding( + portfolioId: string, + holdingId: string, + ): Promise { + const holding = await this.portfolioAssetRepository.findOne({ + where: { id: holdingId, portfolioId }, + }); + + if (!holding) { + throw new BadRequestException("Holding not found"); + } + + await this.portfolioAssetRepository.remove(holding); + + // Update portfolio metrics + await this.updatePortfolioMetrics(portfolioId); + } + + /** + * Add asset to portfolio (keeping for backward compatibility) + */ + async addAsset( + portfolioId: string, + ticker: string, + name: string, + quantity: number, + currentPrice: number = 0, + costBasis: number = 0, + ): Promise { + return this.addHolding(portfolioId, { + ticker, + name, + chain: Chain.OTHER, + quantity, + currentPrice, + costBasis, + }); } /** @@ -153,18 +240,23 @@ export class PortfolioService { asset.value = asset.quantity * currentPrice; asset.lastPriceUpdate = new Date(); + // Recalculate unrealized gain + if (asset.costBasisPerShare) { + asset.unrealizedGain = (asset.currentPrice - asset.costBasisPerShare) * asset.quantity; + } + const updated = await this.portfolioAssetRepository.save(asset); - // Recalculate allocation - await this.updatePortfolioAllocation(asset.portfolioId); + // Update portfolio metrics + await this.updatePortfolioMetrics(asset.portfolioId); return updated; } /** - * Update portfolio allocation percentages + * Update portfolio metrics */ - async updatePortfolioAllocation(portfolioId: string): Promise { + async updatePortfolioMetrics(portfolioId: string): Promise { const portfolio = await this.getPortfolio(portfolioId); const assets = await this.portfolioAssetRepository.find({ where: { portfolioId }, @@ -182,7 +274,7 @@ export class PortfolioService { for (const asset of assets) { const percentage = totalValue > 0 ? (asset.value / totalValue) * 100 : 0; asset.allocationPercentage = percentage; - allocation[asset.ticker] = percentage; + allocation[`${asset.ticker}-${asset.chain}`] = percentage; } portfolio.currentAllocation = allocation; @@ -191,6 +283,13 @@ export class PortfolioService { await this.portfolioAssetRepository.save(assets); } + /** + * Update portfolio allocation percentages (keeping for backward compatibility) + */ + async updatePortfolioAllocation(portfolioId: string): Promise { + await this.updatePortfolioMetrics(portfolioId); + } + /** * Run portfolio optimization */ diff --git a/test/portfolio/portfolio.service.spec.ts b/test/portfolio/portfolio.service.spec.ts index 8b1d453..b8df338 100644 --- a/test/portfolio/portfolio.service.spec.ts +++ b/test/portfolio/portfolio.service.spec.ts @@ -2,11 +2,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { PortfolioService } from '../../src/investment/portfolio/services/portfolio.service'; import { Portfolio } from '../../src/investment/portfolio/entities/portfolio.entity'; -import { PortfolioAsset } from '../../src/investment/portfolio/entities/portfolio-asset.entity'; +import { PortfolioAsset, Chain, AssetType } from '../../src/investment/portfolio/entities/portfolio-asset.entity'; import { OptimizationHistory } from '../../src/investment/portfolio/entities/optimization-history.entity'; import { RiskProfile } from '../../src/investment/portfolio/entities/risk-profile.entity'; import { CreatePortfolioDto } from '../../src/investment/portfolio/dto/portfolio.dto'; import { OptimizationMethod } from '../../src/investment/portfolio/entities/optimization-history.entity'; +import { AddHoldingDto, UpdateHoldingDto } from '../../src/investment/portfolio/dto/portfolio-asset.dto'; describe('PortfolioService', () => { let service: PortfolioService; @@ -33,6 +34,7 @@ describe('PortfolioService', () => { id: 'asset-1', ticker: 'AAPL', name: 'Apple', + chain: Chain.OTHER, quantity: 100, currentPrice: 150, value: 15000, @@ -235,4 +237,99 @@ describe('PortfolioService', () => { expect(result.status).toBe('completed'); }); }); + + describe('addHolding', () => { + it('should add a new holding to portfolio', async () => { + const dto: AddHoldingDto = { + ticker: 'ETH', + name: 'Ethereum', + chain: Chain.ETHEREUM, + quantity: 10, + currentPrice: 2000, + costBasis: 15000, + }; + + assetRepository.findOne.mockResolvedValue(null); + assetRepository.create.mockReturnValue({ ...mockAsset, ...dto }); + assetRepository.save.mockResolvedValue({ ...mockAsset, ...dto }); + + const result = await service.addHolding('test-portfolio-1', dto); + + expect(assetRepository.findOne).toHaveBeenCalledWith({ + where: { portfolioId: 'test-portfolio-1', ticker: dto.ticker, chain: dto.chain }, + }); + expect(assetRepository.create).toHaveBeenCalled(); + expect(assetRepository.save).toHaveBeenCalled(); + expect(result.ticker).toBe(dto.ticker); + expect(result.chain).toBe(dto.chain); + }); + + it('should throw error if holding already exists', async () => { + const dto: AddHoldingDto = { + ticker: 'ETH', + name: 'Ethereum', + chain: Chain.ETHEREUM, + quantity: 10, + currentPrice: 2000, + costBasis: 15000, + }; + + assetRepository.findOne.mockResolvedValue(mockAsset); + + await expect( + service.addHolding('test-portfolio-1', dto), + ).rejects.toThrow('Holding with same ticker and chain already exists'); + }); + }); + + describe('updateHolding', () => { + it('should update holding in portfolio', async () => { + const dto: UpdateHoldingDto = { + quantity: 20, + currentPrice: 2500, + }; + + const updatedAsset = { ...mockAsset, ...dto, value: 50000 }; + assetRepository.findOne.mockResolvedValue(mockAsset); + assetRepository.save.mockResolvedValue(updatedAsset); + + const result = await service.updateHolding('test-portfolio-1', 'asset-1', dto); + + expect(assetRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'asset-1', portfolioId: 'test-portfolio-1' }, + }); + expect(assetRepository.save).toHaveBeenCalled(); + }); + + it('should throw error if holding not found', async () => { + const dto: UpdateHoldingDto = { quantity: 20 }; + assetRepository.findOne.mockResolvedValue(null); + + await expect( + service.updateHolding('test-portfolio-1', 'non-existent', dto), + ).rejects.toThrow('Holding not found'); + }); + }); + + describe('removeHolding', () => { + it('should remove holding from portfolio', async () => { + assetRepository.findOne.mockResolvedValue(mockAsset); + assetRepository.remove.mockResolvedValue(null); + + await service.removeHolding('test-portfolio-1', 'asset-1'); + + expect(assetRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'asset-1', portfolioId: 'test-portfolio-1' }, + }); + expect(assetRepository.remove).toHaveBeenCalledWith(mockAsset); + }); + + it('should throw error if holding not found', async () => { + assetRepository.findOne.mockResolvedValue(null); + + await expect( + service.removeHolding('test-portfolio-1', 'non-existent'), + ).rejects.toThrow('Holding not found'); + }); + }); });