diff --git a/__tests__/api/isa-rebalancing-api.test.ts b/__tests__/api/isa-rebalancing-api.test.ts new file mode 100644 index 0000000..75872c6 --- /dev/null +++ b/__tests__/api/isa-rebalancing-api.test.ts @@ -0,0 +1,182 @@ +/** + * @jest-environment node + */ +import { getServerSession } from 'next-auth'; +import { NextRequest } from 'next/server'; +import { GET } from '@/app/api/isa/rebalancing/route'; + +// 모킹 설정 +jest.mock('next-auth'); +jest.mock('@/services/isa/rebalancing-service', () => ({ + RebalancingService: jest.fn(), + InvestmentProfileNotFoundError: class InvestmentProfileNotFoundError extends Error { + constructor() { + super('투자 성향 정보가 없습니다.'); + this.name = 'InvestmentProfileNotFoundError'; + } + }, + ISAAccountNotFoundError: class ISAAccountNotFoundError extends Error { + constructor() { + super('ISA 계좌 정보가 없습니다.'); + this.name = 'ISAAccountNotFoundError'; + } + }, +})); + +const mockRebalancingService = { + getRebalancingRecommendation: jest.fn(), +}; + +const { + RebalancingService, + InvestmentProfileNotFoundError, + ISAAccountNotFoundError, +} = require('@/services/isa/rebalancing-service'); + +(RebalancingService as jest.Mock).mockImplementation( + () => mockRebalancingService +); + +describe('GET /api/isa/rebalancing', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('인증 테스트', () => { + it('인증되지 않은 사용자는 401 에러를 반환한다', async () => { + // Given + (getServerSession as jest.Mock).mockResolvedValue(null); + + // When + const req = new NextRequest('http://localhost:3000/api/isa/rebalancing'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(401); + expect(json.message).toBe('인증되지 않은 사용자입니다.'); + }); + + it('세션에 사용자 ID가 없으면 401 에러를 반환한다', async () => { + // Given + (getServerSession as jest.Mock).mockResolvedValue({ + user: {}, + }); + + // When + const req = new NextRequest('http://localhost:3000/api/isa/rebalancing'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(401); + expect(json.message).toBe('인증되지 않은 사용자입니다.'); + }); + }); + + describe('서비스 로직 테스트', () => { + beforeEach(() => { + (getServerSession as jest.Mock).mockResolvedValue({ + user: { id: '123' }, + }); + }); + + it('투자 성향 정보가 없으면 404 에러를 반환한다', async () => { + // Given + mockRebalancingService.getRebalancingRecommendation.mockRejectedValue( + new InvestmentProfileNotFoundError() + ); + + // When + const req = new NextRequest('http://localhost:3000/api/isa/rebalancing'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(404); + expect(json.message).toBe('투자 성향 정보가 없습니다.'); + expect( + mockRebalancingService.getRebalancingRecommendation + ).toHaveBeenCalledWith(BigInt('123')); + }); + + it('ISA 계좌 정보가 없으면 404 에러를 반환한다', async () => { + // Given + mockRebalancingService.getRebalancingRecommendation.mockRejectedValue( + new ISAAccountNotFoundError() + ); + + // When + const req = new NextRequest('http://localhost:3000/api/isa/rebalancing'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(404); + expect(json.message).toBe('ISA 계좌 정보가 없습니다.'); + }); + + it('성공적으로 리밸런싱 추천 결과를 반환한다', async () => { + // Given + const mockResult = { + recommendedPortfolio: [ + { category: '국내 주식', percentage: 25 }, + { category: '해외 주식', percentage: 25 }, + { category: '채권', percentage: 40 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], + score: 85.5, + rebalancingOpinions: [ + { + category: '국내 주식', + userPercentage: 30, + recommendedPercentage: 25, + opinion: '비중 축소 필요', + detail: '국내 주식 비중이 권장수준보다 5.0%p 높습니다.', + }, + { + category: '해외 주식', + userPercentage: 20, + recommendedPercentage: 25, + opinion: '비중 확대 필요', + detail: + '해외 주식 비중이 권장수준보다 5.0%p 낮습니다. 해당 자산군에 대한 투자를 늘리는 것을 추천합니다.', + }, + ], + }; + + mockRebalancingService.getRebalancingRecommendation.mockResolvedValue( + mockResult + ); + + // When + const req = new NextRequest('http://localhost:3000/api/isa/rebalancing'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(200); + expect(json).toEqual(mockResult); + expect( + mockRebalancingService.getRebalancingRecommendation + ).toHaveBeenCalledWith(BigInt('123')); + }); + + it('예상치 못한 에러가 발생하면 500 에러를 반환한다', async () => { + // Given + mockRebalancingService.getRebalancingRecommendation.mockRejectedValue( + new Error('예상치 못한 에러') + ); + + // When + const req = new NextRequest('http://localhost:3000/api/isa/rebalancing'); + const res = await GET(req); + const json = await res.json(); + + // Then + expect(res.status).toBe(500); + expect(json.message).toBe('서버 오류가 발생했습니다.'); + }); + }); +}); diff --git a/__tests__/helpers/rebalancing-helpers.ts b/__tests__/helpers/rebalancing-helpers.ts new file mode 100644 index 0000000..4bd91b3 --- /dev/null +++ b/__tests__/helpers/rebalancing-helpers.ts @@ -0,0 +1,217 @@ +import { + InvestType, + UserHoldingDetails, + UserPortfolio, +} from '@/services/isa/rebalancing-service'; +import { InvestType as PrismaInvestType } from '@prisma/client'; + +export const TEST_USER_ID = BigInt('123'); + +export const ERROR_MESSAGES = { + INVESTMENT_PROFILE_NOT_FOUND: '투자 성향 정보가 없습니다.', + ISA_ACCOUNT_NOT_FOUND: 'ISA 계좌 정보가 없습니다.', +}; + +export function createMockInvestmentProfile( + investType: PrismaInvestType = PrismaInvestType.MODERATE +) { + return { + userId: TEST_USER_ID, + investType, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +export function createMockISAAccount() { + return { + id: BigInt(1), + userId: TEST_USER_ID, + accountNumber: '1234567890', + generalHoldingSnapshots: [], + generalHoldings: [], + etfHoldingSnapshots: [], + etfHoldings: [], + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +export function createMockGeneralHolding( + instrumentType: 'BOND' | 'FUND' | 'ELS', + totalCost: number, + productName?: string +) { + return { + totalCost, + product: { + instrumentType, + productName: productName || `${instrumentType} 상품`, + }, + }; +} + +export function createMockEtfHoldingSnapshot( + etfId: bigint, + evaluatedAmount: number, + idxMarketType: '국내' | '해외' | '국내&해외', + issueNameKo?: string +) { + return { + evaluatedAmount, + etf: { + id: etfId, + issueNameKo: issueNameKo || `${idxMarketType} ETF`, + idxMarketType, + }, + }; +} + +export function createMockEtfHolding( + etfId: bigint, + quantity: number, + avgCost: number, + idxMarketType: '국내' | '해외' | '국내&해외', + issueNameKo?: string, + currentPrice?: number +) { + return { + etfId, + quantity: { + toNumber: () => quantity, + mul: (price: any) => ({ toNumber: () => quantity * price.toNumber() }), + }, + avgCost: { + toNumber: () => avgCost, + mul: (qty: any) => ({ toNumber: () => avgCost * qty.toNumber() }), + }, + etf: { + id: etfId, + issueNameKo: issueNameKo || `${idxMarketType} ETF`, + idxMarketType, + tradings: currentPrice + ? [ + { + tddClosePrice: { toNumber: () => currentPrice }, + }, + ] + : [], + }, + }; +} + +export function createMockUserHolding( + etfId?: bigint, + name = '테스트 자산', + totalCost = 1000000, + currentValue = 1100000, + categoryPath = '국내 주식', + assetType: 'ETF' | 'BOND' | 'FUND' | 'ELS' | 'CASH' = 'ETF' +): UserHoldingDetails { + const profitOrLoss = currentValue - totalCost; + const returnRate = totalCost > 0 ? (profitOrLoss / totalCost) * 100 : 0; + + return { + etfId, + name, + totalCost, + currentValue, + profitOrLoss, + returnRate, + categoryPath, + assetType, + }; +} + +export function createMockUserPortfolio( + category: string, + percentage: number, + totalValue: number, + profitOrLoss = 0, + returnRate = 0 +): UserPortfolio { + return { + category, + percentage, + totalValue, + profitOrLoss, + returnRate, + }; +} + +export function createMockRebalancingResponse( + investType: InvestType = InvestType.MODERATE, + score = 85.5 +) { + return { + recommendedPortfolio: [ + { category: '국내 주식', percentage: 25 }, + { category: '해외 주식', percentage: 25 }, + { category: '채권', percentage: 40 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], + score, + rebalancingOpinions: [ + { + category: '국내 주식', + userPercentage: 30, + recommendedPercentage: 25, + opinion: '비중 축소 필요', + detail: '국내 주식 비중이 권장수준보다 5.0%p 높습니다.', + }, + { + category: '해외 주식', + userPercentage: 20, + recommendedPercentage: 25, + opinion: '비중 확대 필요', + detail: + '해외 주식 비중이 권장수준보다 5.0%p 낮습니다. 해당 자산군에 대한 투자를 늘리는 것을 추천합니다.', + }, + ], + }; +} + +export function getTestRecommendedPortfolioByInvestType( + investType: InvestType +) { + const portfolios = { + [InvestType.CONSERVATIVE]: [ + { category: '국내 주식', percentage: 10 }, + { category: '해외 주식', percentage: 10 }, + { category: '채권', percentage: 60 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 15 }, + ], + [InvestType.MODERATE]: [ + { category: '국내 주식', percentage: 25 }, + { category: '해외 주식', percentage: 25 }, + { category: '채권', percentage: 40 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], + [InvestType.NEUTRAL]: [ + { category: '국내 주식', percentage: 30 }, + { category: '해외 주식', percentage: 30 }, + { category: '채권', percentage: 30 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], + [InvestType.ACTIVE]: [ + { category: '국내 주식', percentage: 35 }, + { category: '해외 주식', percentage: 35 }, + { category: '채권', percentage: 20 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], + [InvestType.AGGRESSIVE]: [ + { category: '국내 주식', percentage: 40 }, + { category: '해외 주식', percentage: 40 }, + { category: '채권', percentage: 10 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], + }; + + return portfolios[investType]; +} diff --git a/__tests__/services/isa-rebalancing-service.test.ts b/__tests__/services/isa-rebalancing-service.test.ts new file mode 100644 index 0000000..37226a5 --- /dev/null +++ b/__tests__/services/isa-rebalancing-service.test.ts @@ -0,0 +1,367 @@ +/** + * @jest-environment node + */ +import { + InvestmentProfileNotFoundError, + InvestType, + ISAAccountNotFoundError, + RebalancingService, + UserHoldingDetails, + UserPortfolio, +} from '@/services/isa/rebalancing-service'; +import { InvestType as PrismaInvestType } from '@prisma/client'; +import { + createMockEtfHoldingSnapshot, + createMockGeneralHolding, + createMockInvestmentProfile, + createMockISAAccount, + ERROR_MESSAGES, + TEST_USER_ID, +} from '../helpers/rebalancing-helpers'; + +// Mock Prisma Client +const mockPrismaClient = { + investmentProfile: { + findUnique: jest.fn(), + }, + iSAAccount: { + findUnique: jest.fn(), + }, +} as any; + +describe('RebalancingService', () => { + let rebalancingService: RebalancingService; + + beforeEach(() => { + jest.clearAllMocks(); + rebalancingService = new RebalancingService(mockPrismaClient); + }); + + describe('getRebalancingRecommendation', () => { + it('투자 성향 정보가 없으면 InvestmentProfileNotFoundError를 던진다', async () => { + // Given + mockPrismaClient.investmentProfile.findUnique.mockResolvedValue(null); + + // When & Then + await expect( + rebalancingService.getRebalancingRecommendation(TEST_USER_ID) + ).rejects.toThrow(InvestmentProfileNotFoundError); + await expect( + rebalancingService.getRebalancingRecommendation(TEST_USER_ID) + ).rejects.toThrow(ERROR_MESSAGES.INVESTMENT_PROFILE_NOT_FOUND); + }); + + it('ISA 계좌 정보가 없으면 ISAAccountNotFoundError를 던진다', async () => { + // Given + mockPrismaClient.investmentProfile.findUnique.mockResolvedValue( + createMockInvestmentProfile(PrismaInvestType.MODERATE) + ); + mockPrismaClient.iSAAccount.findUnique.mockResolvedValue(null); + + // When & Then + await expect( + rebalancingService.getRebalancingRecommendation(TEST_USER_ID) + ).rejects.toThrow(ISAAccountNotFoundError); + await expect( + rebalancingService.getRebalancingRecommendation(TEST_USER_ID) + ).rejects.toThrow(ERROR_MESSAGES.ISA_ACCOUNT_NOT_FOUND); + }); + + it('자산이 없으면 빈 포트폴리오와 0점을 반환한다', async () => { + // Given + mockPrismaClient.investmentProfile.findUnique.mockResolvedValue( + createMockInvestmentProfile(PrismaInvestType.MODERATE) + ); + mockPrismaClient.iSAAccount.findUnique.mockResolvedValue( + createMockISAAccount() + ); + + // When + const result = + await rebalancingService.getRebalancingRecommendation(TEST_USER_ID); + + // Then + expect(result.score).toBe(0); + expect(result.rebalancingOpinions).toHaveLength(0); + expect(result.recommendedPortfolio).toEqual([ + { category: '국내 주식', percentage: 25 }, + { category: '해외 주식', percentage: 25 }, + { category: '채권', percentage: 40 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ]); + }); + + it('ETF 스냅샷 데이터가 있으면 스냅샷 데이터를 사용한다', async () => { + // Given + mockPrismaClient.investmentProfile.findUnique.mockResolvedValue( + createMockInvestmentProfile(PrismaInvestType.MODERATE) + ); + mockPrismaClient.iSAAccount.findUnique.mockResolvedValue({ + ...createMockISAAccount(), + etfHoldingSnapshots: [ + createMockEtfHoldingSnapshot(BigInt(1), 1000000, '국내', '국내 ETF'), + createMockEtfHoldingSnapshot(BigInt(2), 2000000, '해외', '해외 ETF'), + ], + }); + + // When + const result = + await rebalancingService.getRebalancingRecommendation(TEST_USER_ID); + + // Then + expect(result.score).toBeGreaterThan(0); + expect(result.rebalancingOpinions).toHaveLength(5); + + // 국내 주식과 해외 주식 비중이 각각 33.3% 정도여야 함 (300만원 중 100만원, 200만원) + const domesticStock = result.rebalancingOpinions.find( + (op) => op.category === '국내 주식' + ); + const foreignStock = result.rebalancingOpinions.find( + (op) => op.category === '해외 주식' + ); + + expect(domesticStock?.userPercentage).toBeCloseTo(33.3, 1); + expect(foreignStock?.userPercentage).toBeCloseTo(66.7, 1); + }); + + it('일반 자산(채권, 펀드, ELS)을 올바르게 처리한다', async () => { + // Given + mockPrismaClient.investmentProfile.findUnique.mockResolvedValue( + createMockInvestmentProfile(PrismaInvestType.CONSERVATIVE) + ); + mockPrismaClient.iSAAccount.findUnique.mockResolvedValue({ + ...createMockISAAccount(), + generalHoldings: [ + createMockGeneralHolding('BOND', 5000000, '국채'), + createMockGeneralHolding('FUND', 2000000, '펀드'), + createMockGeneralHolding('ELS', 1000000, 'ELS 상품'), + ], + }); + + // When + const result = + await rebalancingService.getRebalancingRecommendation(TEST_USER_ID); + + // Then + expect(result.score).toBeGreaterThan(0); + + // 보수적 포트폴리오: 채권 60%, 펀드 15%, ELS 5% + const bond = result.rebalancingOpinions.find( + (op) => op.category === '채권' + ); + const fund = result.rebalancingOpinions.find( + (op) => op.category === '펀드' + ); + const els = result.rebalancingOpinions.find( + (op) => op.category === 'ELS' + ); + + expect(bond?.userPercentage).toBeCloseTo(62.5, 1); // 500만원 / 800만원 + expect(fund?.userPercentage).toBeCloseTo(25, 1); // 200만원 / 800만원 + expect(els?.userPercentage).toBeCloseTo(12.5, 1); // 100만원 / 800만원 + }); + + it('투자 성향에 따라 다른 권장 포트폴리오를 반환한다', async () => { + // Given + mockPrismaClient.investmentProfile.findUnique.mockResolvedValue( + createMockInvestmentProfile(PrismaInvestType.AGGRESSIVE) + ); + mockPrismaClient.iSAAccount.findUnique.mockResolvedValue( + createMockISAAccount() + ); + + // When + const result = + await rebalancingService.getRebalancingRecommendation(TEST_USER_ID); + + // Then + expect(result.recommendedPortfolio).toEqual([ + { category: '국내 주식', percentage: 40 }, + { category: '해외 주식', percentage: 40 }, + { category: '채권', percentage: 10 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ]); + }); + }); + + describe('calculateScore', () => { + it('완벽한 매칭일 때 100점을 반환한다', () => { + // Given + const userPortfolio: UserPortfolio[] = [ + { + category: '국내 주식', + percentage: 25, + totalValue: 2500000, + profitOrLoss: 0, + returnRate: 0, + }, + { + category: '해외 주식', + percentage: 25, + totalValue: 2500000, + profitOrLoss: 0, + returnRate: 0, + }, + { + category: '채권', + percentage: 40, + totalValue: 4000000, + profitOrLoss: 0, + returnRate: 0, + }, + { + category: 'ELS', + percentage: 5, + totalValue: 500000, + profitOrLoss: 0, + returnRate: 0, + }, + { + category: '펀드', + percentage: 5, + totalValue: 500000, + profitOrLoss: 0, + returnRate: 0, + }, + ]; + const recommendedPortfolio = [ + { category: '국내 주식', percentage: 25 }, + { category: '해외 주식', percentage: 25 }, + { category: '채권', percentage: 40 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ]; + + // When + const score = (rebalancingService as any).calculateScore( + userPortfolio, + recommendedPortfolio + ); + + // Then + expect(score).toBe(100); + }); + + it('차이가 클수록 낮은 점수를 반환한다', () => { + // Given + const userPortfolio: UserPortfolio[] = [ + { + category: '국내 주식', + percentage: 50, + totalValue: 5000000, + profitOrLoss: 0, + returnRate: 0, + }, + { + category: '해외 주식', + percentage: 50, + totalValue: 5000000, + profitOrLoss: 0, + returnRate: 0, + }, + ]; + const recommendedPortfolio = [ + { category: '국내 주식', percentage: 25 }, + { category: '해외 주식', percentage: 25 }, + ]; + + // When + const score = (rebalancingService as any).calculateScore( + userPortfolio, + recommendedPortfolio + ); + + // Then + expect(score).toBeLessThan(100); + expect(score).toBeGreaterThan(0); + }); + }); + + describe('generateRebalancingOpinions', () => { + it('적정 비중일 때 적절한 의견을 생성한다', () => { + // Given + const userPortfolio: UserPortfolio[] = [ + { + category: '국내 주식', + percentage: 25, + totalValue: 2500000, + profitOrLoss: 0, + returnRate: 0, + }, + ]; + const recommendedPortfolio = [{ category: '국내 주식', percentage: 25 }]; + const userHoldings: UserHoldingDetails[] = []; + + // When + const opinions = (rebalancingService as any).generateRebalancingOpinions( + userPortfolio, + recommendedPortfolio, + userHoldings + ); + + // Then + expect(opinions).toHaveLength(1); + expect(opinions[0].opinion).toBe('적정 비중'); + expect(opinions[0].userPercentage).toBe(25); + expect(opinions[0].recommendedPercentage).toBe(25); + }); + + it('비중이 높을 때 축소 의견을 생성한다', () => { + // Given + const userPortfolio: UserPortfolio[] = [ + { + category: '국내 주식', + percentage: 35, + totalValue: 3500000, + profitOrLoss: 0, + returnRate: 0, + }, + ]; + const recommendedPortfolio = [{ category: '국내 주식', percentage: 25 }]; + const userHoldings: UserHoldingDetails[] = []; + + // When + const opinions = (rebalancingService as any).generateRebalancingOpinions( + userPortfolio, + recommendedPortfolio, + userHoldings + ); + + // Then + expect(opinions).toHaveLength(1); + expect(opinions[0].opinion).toBe('비중 축소 필요'); + expect(opinions[0].userPercentage).toBe(35); + expect(opinions[0].recommendedPercentage).toBe(25); + }); + + it('비중이 낮을 때 확대 의견을 생성한다', () => { + // Given + const userPortfolio: UserPortfolio[] = [ + { + category: '국내 주식', + percentage: 15, + totalValue: 1500000, + profitOrLoss: 0, + returnRate: 0, + }, + ]; + const recommendedPortfolio = [{ category: '국내 주식', percentage: 25 }]; + const userHoldings: UserHoldingDetails[] = []; + + // When + const opinions = (rebalancingService as any).generateRebalancingOpinions( + userPortfolio, + recommendedPortfolio, + userHoldings + ); + + // Then + expect(opinions).toHaveLength(1); + expect(opinions[0].opinion).toBe('비중 확대 필요'); + expect(opinions[0].userPercentage).toBe(15); + expect(opinions[0].recommendedPercentage).toBe(25); + }); + }); +}); diff --git a/app/api/isa/rebalancing/route.ts b/app/api/isa/rebalancing/route.ts index 4fae221..b152154 100644 --- a/app/api/isa/rebalancing/route.ts +++ b/app/api/isa/rebalancing/route.ts @@ -1,107 +1,13 @@ import { getServerSession } from 'next-auth'; import { NextRequest, NextResponse } from 'next/server'; -import { Prisma, SnapshotType } from '@prisma/client'; +import { + InvestmentProfileNotFoundError, + ISAAccountNotFoundError, + RebalancingService, +} from '@/services/isa/rebalancing-service'; import { authOptions } from '@/lib/auth-options'; import { prisma } from '@/lib/prisma'; -// 안전한 숫자 변환 유틸리티 함수 -function safeNumber(value: any): number { - if (value === null || value === undefined) return 0; - const num = Number(value); - return isNaN(num) ? 0 : num; -} - -// BigInt를 문자열로 변환하는 함수 -function convertBigIntToString(obj: any): any { - if (obj === null || obj === undefined) return obj; - - if (typeof obj === 'bigint') { - return obj.toString(); - } - - if (Array.isArray(obj)) { - return obj.map(convertBigIntToString); - } - - if (typeof obj === 'object') { - const converted: any = {}; - for (const [key, value] of Object.entries(obj)) { - converted[key] = convertBigIntToString(value); - } - return converted; - } - - return obj; -} - -enum InvestType { - CONSERVATIVE = 'CONSERVATIVE', - MODERATE = 'MODERATE', - NEUTRAL = 'NEUTRAL', - ACTIVE = 'ACTIVE', - AGGRESSIVE = 'AGGRESSIVE', -} - -type UserHoldingDetails = { - etfId?: bigint; - name: string; - totalCost: number; - currentValue: number; - profitOrLoss: number; - returnRate: number; - categoryPath: string; - assetType: 'ETF' | 'BOND' | 'FUND' | 'ELS' | 'CASH'; -}; - -const RECOMMENDED_PORTFOLIOS: Record< - InvestType, - { category: string; percentage: number }[] -> = { - [InvestType.CONSERVATIVE]: [ - { category: '국내 주식', percentage: 10 }, - { category: '해외 주식', percentage: 10 }, - { category: '채권', percentage: 60 }, - { category: 'ELS', percentage: 5 }, - { category: '펀드', percentage: 15 }, - ], - [InvestType.MODERATE]: [ - { category: '국내 주식', percentage: 25 }, - { category: '해외 주식', percentage: 25 }, - { category: '채권', percentage: 40 }, - { category: 'ELS', percentage: 5 }, - { category: '펀드', percentage: 5 }, - ], - [InvestType.NEUTRAL]: [ - { category: '국내 주식', percentage: 30 }, - { category: '해외 주식', percentage: 30 }, - { category: '채권', percentage: 30 }, - { category: 'ELS', percentage: 5 }, - { category: '펀드', percentage: 5 }, - ], - [InvestType.ACTIVE]: [ - { category: '국내 주식', percentage: 35 }, - { category: '해외 주식', percentage: 35 }, - { category: '채권', percentage: 20 }, - { category: 'ELS', percentage: 5 }, - { category: '펀드', percentage: 5 }, - ], - [InvestType.AGGRESSIVE]: [ - { category: '국내 주식', percentage: 40 }, - { category: '해외 주식', percentage: 40 }, - { category: '채권', percentage: 10 }, - { category: 'ELS', percentage: 5 }, - { category: '펀드', percentage: 5 }, - ], -}; - -type UserPortfolio = { - category: string; - percentage: number; - totalValue: number; - profitOrLoss: number; - returnRate: number; -}; - export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); if (!session?.user?.id) { @@ -112,316 +18,23 @@ export async function GET(req: NextRequest) { } const userId = BigInt(session.user.id); + const rebalancingService = new RebalancingService(prisma); try { - const investmentProfile = await prisma.investmentProfile.findUnique({ - where: { userId }, - }); - - if (!investmentProfile) { - return NextResponse.json( - { message: '투자 성향 정보가 없습니다.' }, - { status: 404 } - ); - } - - // 현재 월의 스냅샷 데이터 조회 - const now = new Date(); - const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; - const [year, month] = yearMonth.split('-').map(Number); - const snapshotDate = new Date(Date.UTC(year, month, 0)); - - const isaAccount = await prisma.iSAAccount.findUnique({ - where: { userId }, - select: { - generalHoldingSnapshots: { - where: { - snapshotDate: { - gte: snapshotDate, - lt: new Date(snapshotDate.getTime() + 24 * 60 * 60 * 1000), - }, - }, - select: { - evaluatedAmount: true, - snapshotType: true, - }, - }, - generalHoldings: { - select: { - totalCost: true, - product: { - select: { - instrumentType: true, - productName: true, - }, - }, - }, - }, - etfHoldingSnapshots: { - where: { - snapshotDate: { - gte: snapshotDate, - lt: new Date(snapshotDate.getTime() + 24 * 60 * 60 * 1000), - }, - }, - select: { - evaluatedAmount: true, - etf: { - select: { - id: true, - issueNameKo: true, - idxMarketType: true, - }, - }, - }, - }, - etfHoldings: { - include: { - etf: { - include: { - tradings: { - orderBy: { baseDate: 'desc' }, - take: 1, - }, - }, - }, - }, - }, - }, - }); - - if (!isaAccount) { - return NextResponse.json( - { message: 'ISA 계좌 정보가 없습니다.' }, - { status: 404 } - ); - } - - const portfolioByCategory: Record = - {}; - let totalValue = 0; - const userHoldings: UserHoldingDetails[] = []; - - // 일반 자산 집계 (스냅샷 데이터 우선, 없으면 현재 보유 데이터 사용) - let bond = 0, - fund = 0, - els = 0; - - for (const holding of isaAccount.generalHoldings) { - const currentValue = safeNumber(holding.totalCost); - - if (holding.product?.instrumentType === 'BOND') { - bond += currentValue; - userHoldings.push({ - name: holding.product.productName || '채권', - totalCost: currentValue, - currentValue: currentValue, - profitOrLoss: 0, - returnRate: 0, - categoryPath: '채권', - assetType: 'BOND', - }); - } else if (holding.product?.instrumentType === 'FUND') { - fund += currentValue; - userHoldings.push({ - name: holding.product.productName || '펀드', - totalCost: currentValue, - currentValue: currentValue, - profitOrLoss: 0, - returnRate: 0, - categoryPath: '펀드', - assetType: 'FUND', - }); - } else { - els += currentValue; - userHoldings.push({ - name: holding.product.productName || 'ELS', - totalCost: currentValue, - currentValue: currentValue, - profitOrLoss: 0, - returnRate: 0, - categoryPath: 'ELS', - assetType: 'ELS', - }); - } - } - - // ETF 자산 집계 (스냅샷 데이터 우선, 없으면 현재 보유 데이터 사용) - let etfDomestic = 0, - etfForeign = 0, - etfBoth = 0; - - if (isaAccount.etfHoldingSnapshots.length > 0) { - // 스냅샷 데이터 사용 - for (const snapshot of isaAccount.etfHoldingSnapshots) { - const currentValue = safeNumber(snapshot.evaluatedAmount); - const type = snapshot.etf.idxMarketType; - - if (type === '국내') { - etfDomestic += currentValue; - userHoldings.push({ - etfId: snapshot.etf.id, - name: snapshot.etf.issueNameKo || 'ETF', - totalCost: currentValue, - currentValue: currentValue, - profitOrLoss: 0, - returnRate: 0, - categoryPath: '국내 주식', - assetType: 'ETF', - }); - } else if (type === '해외') { - etfForeign += currentValue; - userHoldings.push({ - etfId: snapshot.etf.id, - name: snapshot.etf.issueNameKo || 'ETF', - totalCost: currentValue, - currentValue: currentValue, - profitOrLoss: 0, - returnRate: 0, - categoryPath: '해외 주식', - assetType: 'ETF', - }); - } else if (type === '국내&해외') { - etfBoth += currentValue; - userHoldings.push({ - etfId: snapshot.etf.id, - name: snapshot.etf.issueNameKo || 'ETF', - totalCost: currentValue, - currentValue: currentValue, - profitOrLoss: 0, - returnRate: 0, - categoryPath: '국내&해외 주식', - assetType: 'ETF', - }); - } - } - } else { - // 스냅샷 데이터가 없으면 현재 보유 데이터 사용 (기존 로직) - for (const holding of isaAccount.etfHoldings) { - try { - const latestTrading = holding.etf.tradings?.[0]; - const currentPrice = - latestTrading?.tddClosePrice ?? new Prisma.Decimal(0); - const currentValue = holding.quantity.mul(currentPrice).toNumber(); - const totalHoldingCost = holding.quantity - .mul(holding.avgCost) - .toNumber(); - const profitOrLoss = currentValue - totalHoldingCost; - - const type = holding.etf.idxMarketType; - let categoryPath = ''; - if (type === '국내') { - etfDomestic += currentValue; - categoryPath = '국내 주식'; - } else if (type === '해외') { - etfForeign += currentValue; - categoryPath = '해외 주식'; - } else if (type === '국내&해외') { - etfBoth += currentValue; - categoryPath = '국내&해외 주식'; - } - - userHoldings.push({ - etfId: holding.etfId, - name: holding.etf.issueNameKo || 'ETF', - totalCost: totalHoldingCost, - currentValue: currentValue, - profitOrLoss, - returnRate: - totalHoldingCost > 0 - ? (profitOrLoss / totalHoldingCost) * 100 - : 0, - categoryPath, - assetType: 'ETF', - }); - } catch (error) { - console.error('ETF 보유 정보 처리 중 오류:', error, holding); - continue; - } - } - } + const response = + await rebalancingService.getRebalancingRecommendation(userId); + return NextResponse.json(response); + } catch (error) { + console.error('리밸런싱 추천 조회 오류:', error); - // 포트폴리오 카테고리별 집계 - if (bond > 0) { - portfolioByCategory['채권'] = { value: bond, cost: bond }; - totalValue += bond; - } - if (fund > 0) { - portfolioByCategory['펀드'] = { value: fund, cost: fund }; - totalValue += fund; - } - if (els > 0) { - portfolioByCategory['ELS'] = { value: els, cost: els }; - totalValue += els; - } - if (etfDomestic > 0) { - portfolioByCategory['국내 주식'] = { - value: etfDomestic, - cost: etfDomestic, - }; - totalValue += etfDomestic; - } - if (etfForeign > 0) { - portfolioByCategory['해외 주식'] = { - value: etfForeign, - cost: etfForeign, - }; - totalValue += etfForeign; - } - if (etfBoth > 0) { - portfolioByCategory['국내&해외 주식'] = { value: etfBoth, cost: etfBoth }; - totalValue += etfBoth; + if (error instanceof InvestmentProfileNotFoundError) { + return NextResponse.json({ message: error.message }, { status: 404 }); } - if (totalValue === 0) { - const emptyResponse = { - recommendedPortfolio: - RECOMMENDED_PORTFOLIOS[investmentProfile.investType as InvestType], - score: 0, - rebalancingOpinions: [], - }; - - return NextResponse.json(convertBigIntToString(emptyResponse), { - status: 200, - }); + if (error instanceof ISAAccountNotFoundError) { + return NextResponse.json({ message: error.message }, { status: 404 }); } - const userPortfolio: UserPortfolio[] = Object.entries( - portfolioByCategory - ).map(([category, data]) => { - const categoryProfitOrLoss = data.value - data.cost; - return { - category, - percentage: - totalValue > 0 - ? Number(((data.value / totalValue) * 100).toFixed(1)) - : 0, - totalValue: data.value, - profitOrLoss: categoryProfitOrLoss, - returnRate: - data.cost > 0 ? (categoryProfitOrLoss / data.cost) * 100 : 0, - }; - }); - - const recommendedPortfolio = - RECOMMENDED_PORTFOLIOS[investmentProfile.investType as InvestType]; - const score = calculateScore(userPortfolio, recommendedPortfolio); - const rebalancingOpinions = generateRebalancingOpinions( - userPortfolio, - recommendedPortfolio, - userHoldings - ); - - const response = { - recommendedPortfolio, - score, - rebalancingOpinions, - }; - - return NextResponse.json(convertBigIntToString(response)); - } catch (error) { - console.error('리밸런싱 추천 조회 오류:', error); - // 더 구체적인 에러 정보 로깅 if (error instanceof Error) { console.error('에러 메시지:', error.message); @@ -434,83 +47,3 @@ export async function GET(req: NextRequest) { ); } } - -function mapCategory(categoryPath: string): string | null { - if (categoryPath === '국내 주식') return '국내 주식'; - if (categoryPath === '해외 주식') return '해외 주식'; - if (categoryPath === '국내&해외 주식') return '국내 주식'; - if (categoryPath === '채권') return '채권'; - if (categoryPath === 'ELS') return 'ELS'; - if (categoryPath === '펀드') return '펀드'; - if (categoryPath === '현금') return '현금'; - return null; -} - -function calculateScore( - userPortfolio: UserPortfolio[], - recommendedPortfolio: { category: string; percentage: number }[] -): number { - let score = 0; - const userMap = new Map(userPortfolio.map((p) => [p.category, p.percentage])); - - for (const recommended of recommendedPortfolio) { - const userPercentage = userMap.get(recommended.category) || 0; - score += Math.abs(userPercentage - recommended.percentage); - } - - return Math.max(0, 100 - score / 2); -} - -function generateRebalancingOpinions( - userPortfolio: UserPortfolio[], - recommendedPortfolio: { category: string; percentage: number }[], - userHoldings: UserHoldingDetails[] -) { - const opinions: { - category: string; - userPercentage: number; - recommendedPercentage: number; - opinion: string; - detail: string; - }[] = []; - const userMap = new Map(userPortfolio.map((p) => [p.category, p.percentage])); - - for (const recommended of recommendedPortfolio) { - const userPercentage = userMap.get(recommended.category) || 0; - const diff = userPercentage - recommended.percentage; - const threshold = 5; - - let opinion = '적정 비중'; - let detail = `${recommended.category}의 현재 비중(${userPercentage.toFixed(1)}%)은 권장 비중(${recommended.percentage}%)에 부합합니다.`; - - if (diff > threshold) { - opinion = '비중 축소 필요'; - const holdingsInCategory = userHoldings - .filter((h) => mapCategory(h.categoryPath) === recommended.category) - .sort((a, b) => a.returnRate - b.returnRate); - - const lowProfitHoldings = holdingsInCategory - .filter((h) => h.returnRate < 0) - .slice(0, 2); - - if (lowProfitHoldings.length > 0) { - detail = `${recommended.category} 비중이 권장수준보다 ${diff.toFixed(1)}%p 높습니다. 특히 수익률이 낮은 ${lowProfitHoldings.map((h) => `'${h.name}'`).join(', ')}의 비중 조절을 우선적으로 고려해보세요.`; - } else { - detail = `${recommended.category} 비중이 권장수준보다 ${diff.toFixed(1)}%p 높습니다.`; - } - } else if (diff < -threshold) { - opinion = '비중 확대 필요'; - detail = `${recommended.category} 비중이 권장수준보다 ${Math.abs(diff).toFixed(1)}%p 낮습니다. 해당 자산군에 대한 투자를 늘리는 것을 추천합니다.`; - } - - opinions.push({ - category: recommended.category, - userPercentage: parseFloat(userPercentage.toFixed(2)), - recommendedPercentage: recommended.percentage, - opinion, - detail, - }); - } - - return opinions; -} diff --git a/lib/solapi.ts b/lib/solapi.ts index 78e9f40..20e6cdc 100644 --- a/lib/solapi.ts +++ b/lib/solapi.ts @@ -113,7 +113,7 @@ export async function sendSMS( { to: phone, from: sender, - text: `ISAID 회원가입 인증번호입니다. #${code}`, // WebOTP 형식: @도메인 #인증번호 + text: `ISAID 회원가입 인증번호입니다.\n` + `${code}`, }, ], }; diff --git a/services/isa/rebalancing-service.ts b/services/isa/rebalancing-service.ts new file mode 100644 index 0000000..c0de9b3 --- /dev/null +++ b/services/isa/rebalancing-service.ts @@ -0,0 +1,492 @@ +import { Prisma } from '@prisma/client'; +import { prisma } from '@/lib/prisma'; + +// 안전한 숫자 변환 유틸리티 함수 +function safeNumber(value: any): number { + if (value === null || value === undefined) return 0; + const num = Number(value); + return isNaN(num) ? 0 : num; +} + +export enum InvestType { + CONSERVATIVE = 'CONSERVATIVE', + MODERATE = 'MODERATE', + NEUTRAL = 'NEUTRAL', + ACTIVE = 'ACTIVE', + AGGRESSIVE = 'AGGRESSIVE', +} + +export type UserHoldingDetails = { + etfId?: bigint; + name: string; + totalCost: number; + currentValue: number; + profitOrLoss: number; + returnRate: number; + categoryPath: string; + assetType: 'ETF' | 'BOND' | 'FUND' | 'ELS' | 'CASH'; +}; + +export const RECOMMENDED_PORTFOLIOS: Record< + InvestType, + { category: string; percentage: number }[] +> = { + [InvestType.CONSERVATIVE]: [ + { category: '국내 주식', percentage: 10 }, + { category: '해외 주식', percentage: 10 }, + { category: '채권', percentage: 60 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 15 }, + ], + [InvestType.MODERATE]: [ + { category: '국내 주식', percentage: 25 }, + { category: '해외 주식', percentage: 25 }, + { category: '채권', percentage: 40 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], + [InvestType.NEUTRAL]: [ + { category: '국내 주식', percentage: 30 }, + { category: '해외 주식', percentage: 30 }, + { category: '채권', percentage: 30 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], + [InvestType.ACTIVE]: [ + { category: '국내 주식', percentage: 35 }, + { category: '해외 주식', percentage: 35 }, + { category: '채권', percentage: 20 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], + [InvestType.AGGRESSIVE]: [ + { category: '국내 주식', percentage: 40 }, + { category: '해외 주식', percentage: 40 }, + { category: '채권', percentage: 10 }, + { category: 'ELS', percentage: 5 }, + { category: '펀드', percentage: 5 }, + ], +}; + +export type UserPortfolio = { + category: string; + percentage: number; + totalValue: number; + profitOrLoss: number; + returnRate: number; +}; + +export type RebalancingResponse = { + recommendedPortfolio: { category: string; percentage: number }[]; + score: number; + rebalancingOpinions: { + category: string; + userPercentage: number; + recommendedPercentage: number; + opinion: string; + detail: string; + }[]; +}; + +export class InvestmentProfileNotFoundError extends Error { + constructor() { + super('투자 성향 정보가 없습니다.'); + this.name = 'InvestmentProfileNotFoundError'; + } +} + +export class ISAAccountNotFoundError extends Error { + constructor() { + super('ISA 계좌 정보가 없습니다.'); + this.name = 'ISAAccountNotFoundError'; + } +} + +export class RebalancingService { + constructor(private prismaClient: typeof prisma) {} + + async getRebalancingRecommendation( + userId: bigint + ): Promise { + const investmentProfile = + await this.prismaClient.investmentProfile.findUnique({ + where: { userId }, + }); + + if (!investmentProfile) { + throw new InvestmentProfileNotFoundError(); + } + + // 현재 월의 스냅샷 데이터 조회 + const now = new Date(); + const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + const [year, month] = yearMonth.split('-').map(Number); + const snapshotDate = new Date(Date.UTC(year, month, 0)); + + const isaAccount = await this.prismaClient.iSAAccount.findUnique({ + where: { userId }, + select: { + generalHoldingSnapshots: { + where: { + snapshotDate: { + gte: snapshotDate, + lt: new Date(snapshotDate.getTime() + 24 * 60 * 60 * 1000), + }, + }, + select: { + evaluatedAmount: true, + snapshotType: true, + }, + }, + generalHoldings: { + select: { + totalCost: true, + product: { + select: { + instrumentType: true, + productName: true, + }, + }, + }, + }, + etfHoldingSnapshots: { + where: { + snapshotDate: { + gte: snapshotDate, + lt: new Date(snapshotDate.getTime() + 24 * 60 * 60 * 1000), + }, + }, + select: { + evaluatedAmount: true, + etf: { + select: { + id: true, + issueNameKo: true, + idxMarketType: true, + }, + }, + }, + }, + etfHoldings: { + include: { + etf: { + include: { + tradings: { + orderBy: { baseDate: 'desc' }, + take: 1, + }, + }, + }, + }, + }, + }, + }); + + if (!isaAccount) { + throw new ISAAccountNotFoundError(); + } + + const portfolioByCategory: Record = + {}; + let totalValue = 0; + const userHoldings: UserHoldingDetails[] = []; + + // 일반 자산 집계 (스냅샷 데이터 우선, 없으면 현재 보유 데이터 사용) + let bond = 0, + fund = 0, + els = 0; + + for (const holding of isaAccount.generalHoldings) { + const currentValue = safeNumber(holding.totalCost); + + if (holding.product?.instrumentType === 'BOND') { + bond += currentValue; + userHoldings.push({ + name: holding.product.productName || '채권', + totalCost: currentValue, + currentValue: currentValue, + profitOrLoss: 0, + returnRate: 0, + categoryPath: '채권', + assetType: 'BOND', + }); + } else if (holding.product?.instrumentType === 'FUND') { + fund += currentValue; + userHoldings.push({ + name: holding.product.productName || '펀드', + totalCost: currentValue, + currentValue: currentValue, + profitOrLoss: 0, + returnRate: 0, + categoryPath: '펀드', + assetType: 'FUND', + }); + } else { + els += currentValue; + userHoldings.push({ + name: holding.product.productName || 'ELS', + totalCost: currentValue, + currentValue: currentValue, + profitOrLoss: 0, + returnRate: 0, + categoryPath: 'ELS', + assetType: 'ELS', + }); + } + } + + // ETF 자산 집계 (스냅샷 데이터 우선, 없으면 현재 보유 데이터 사용) + let etfDomestic = 0, + etfForeign = 0, + etfBoth = 0; + + if (isaAccount.etfHoldingSnapshots.length > 0) { + // 스냅샷 데이터 사용 + for (const snapshot of isaAccount.etfHoldingSnapshots) { + const currentValue = safeNumber(snapshot.evaluatedAmount); + const type = snapshot.etf.idxMarketType; + + if (type === '국내') { + etfDomestic += currentValue; + userHoldings.push({ + etfId: snapshot.etf.id, + name: snapshot.etf.issueNameKo || 'ETF', + totalCost: currentValue, + currentValue: currentValue, + profitOrLoss: 0, + returnRate: 0, + categoryPath: '국내 주식', + assetType: 'ETF', + }); + } else if (type === '해외') { + etfForeign += currentValue; + userHoldings.push({ + etfId: snapshot.etf.id, + name: snapshot.etf.issueNameKo || 'ETF', + totalCost: currentValue, + currentValue: currentValue, + profitOrLoss: 0, + returnRate: 0, + categoryPath: '해외 주식', + assetType: 'ETF', + }); + } else if (type === '국내&해외') { + etfBoth += currentValue; + userHoldings.push({ + etfId: snapshot.etf.id, + name: snapshot.etf.issueNameKo || 'ETF', + totalCost: currentValue, + currentValue: currentValue, + profitOrLoss: 0, + returnRate: 0, + categoryPath: '국내&해외 주식', + assetType: 'ETF', + }); + } + } + } else { + // 스냅샷 데이터가 없으면 현재 보유 데이터 사용 (기존 로직) + for (const holding of isaAccount.etfHoldings) { + try { + const latestTrading = holding.etf.tradings?.[0]; + const currentPrice = + latestTrading?.tddClosePrice ?? new Prisma.Decimal(0); + const currentValue = holding.quantity.mul(currentPrice).toNumber(); + const totalHoldingCost = holding.quantity + .mul(holding.avgCost) + .toNumber(); + const profitOrLoss = currentValue - totalHoldingCost; + + const type = holding.etf.idxMarketType; + let categoryPath = ''; + if (type === '국내') { + etfDomestic += currentValue; + categoryPath = '국내 주식'; + } else if (type === '해외') { + etfForeign += currentValue; + categoryPath = '해외 주식'; + } else if (type === '국내&해외') { + etfBoth += currentValue; + categoryPath = '국내&해외 주식'; + } + + userHoldings.push({ + etfId: holding.etfId, + name: holding.etf.issueNameKo || 'ETF', + totalCost: totalHoldingCost, + currentValue: currentValue, + profitOrLoss, + returnRate: + totalHoldingCost > 0 + ? (profitOrLoss / totalHoldingCost) * 100 + : 0, + categoryPath, + assetType: 'ETF', + }); + } catch (error) { + console.error('ETF 보유 정보 처리 중 오류:', error, holding); + continue; + } + } + } + + // 포트폴리오 카테고리별 집계 + if (bond > 0) { + portfolioByCategory['채권'] = { value: bond, cost: bond }; + totalValue += bond; + } + if (fund > 0) { + portfolioByCategory['펀드'] = { value: fund, cost: fund }; + totalValue += fund; + } + if (els > 0) { + portfolioByCategory['ELS'] = { value: els, cost: els }; + totalValue += els; + } + if (etfDomestic > 0) { + portfolioByCategory['국내 주식'] = { + value: etfDomestic, + cost: etfDomestic, + }; + totalValue += etfDomestic; + } + if (etfForeign > 0) { + portfolioByCategory['해외 주식'] = { + value: etfForeign, + cost: etfForeign, + }; + totalValue += etfForeign; + } + if (etfBoth > 0) { + portfolioByCategory['국내&해외 주식'] = { value: etfBoth, cost: etfBoth }; + totalValue += etfBoth; + } + + if (totalValue === 0) { + return { + recommendedPortfolio: + RECOMMENDED_PORTFOLIOS[investmentProfile.investType as InvestType], + score: 0, + rebalancingOpinions: [], + }; + } + + const userPortfolio: UserPortfolio[] = Object.entries( + portfolioByCategory + ).map(([category, data]) => { + const categoryProfitOrLoss = data.value - data.cost; + return { + category, + percentage: + totalValue > 0 + ? Number(((data.value / totalValue) * 100).toFixed(1)) + : 0, + totalValue: data.value, + profitOrLoss: categoryProfitOrLoss, + returnRate: + data.cost > 0 ? (categoryProfitOrLoss / data.cost) * 100 : 0, + }; + }); + + const recommendedPortfolio = + RECOMMENDED_PORTFOLIOS[investmentProfile.investType as InvestType]; + const score = this.calculateScore(userPortfolio, recommendedPortfolio); + const rebalancingOpinions = this.generateRebalancingOpinions( + userPortfolio, + recommendedPortfolio, + userHoldings + ); + + return { + recommendedPortfolio, + score, + rebalancingOpinions, + }; + } + + private mapCategory(categoryPath: string): string | null { + if (categoryPath === '국내 주식') return '국내 주식'; + if (categoryPath === '해외 주식') return '해외 주식'; + if (categoryPath === '국내&해외 주식') return '국내 주식'; + if (categoryPath === '채권') return '채권'; + if (categoryPath === 'ELS') return 'ELS'; + if (categoryPath === '펀드') return '펀드'; + if (categoryPath === '현금') return '현금'; + return null; + } + + private calculateScore( + userPortfolio: UserPortfolio[], + recommendedPortfolio: { category: string; percentage: number }[] + ): number { + let score = 0; + const userMap = new Map( + userPortfolio.map((p) => [p.category, p.percentage]) + ); + + for (const recommended of recommendedPortfolio) { + const userPercentage = userMap.get(recommended.category) || 0; + score += Math.abs(userPercentage - recommended.percentage); + } + + return Math.max(0, 100 - score / 2); + } + + private generateRebalancingOpinions( + userPortfolio: UserPortfolio[], + recommendedPortfolio: { category: string; percentage: number }[], + userHoldings: UserHoldingDetails[] + ) { + const opinions: { + category: string; + userPercentage: number; + recommendedPercentage: number; + opinion: string; + detail: string; + }[] = []; + const userMap = new Map( + userPortfolio.map((p) => [p.category, p.percentage]) + ); + + for (const recommended of recommendedPortfolio) { + const userPercentage = userMap.get(recommended.category) || 0; + const diff = userPercentage - recommended.percentage; + const threshold = 5; + + let opinion = '적정 비중'; + let detail = `${recommended.category}의 현재 비중(${userPercentage.toFixed(1)}%)은 권장 비중(${recommended.percentage}%)에 부합합니다.`; + + if (diff > threshold) { + opinion = '비중 축소 필요'; + const holdingsInCategory = userHoldings + .filter( + (h) => this.mapCategory(h.categoryPath) === recommended.category + ) + .sort((a, b) => a.returnRate - b.returnRate); + + const lowProfitHoldings = holdingsInCategory + .filter((h) => h.returnRate < 0) + .slice(0, 2); + + if (lowProfitHoldings.length > 0) { + detail = `${recommended.category} 비중이 권장수준보다 ${diff.toFixed(1)}%p 높습니다. 특히 수익률이 낮은 ${lowProfitHoldings.map((h) => `'${h.name}'`).join(', ')}의 비중 조절을 우선적으로 고려해보세요.`; + } else { + detail = `${recommended.category} 비중이 권장수준보다 ${diff.toFixed(1)}%p 높습니다.`; + } + } else if (diff < -threshold) { + opinion = '비중 확대 필요'; + detail = `${recommended.category} 비중이 권장수준보다 ${Math.abs(diff).toFixed(1)}%p 낮습니다. 해당 자산군에 대한 투자를 늘리는 것을 추천합니다.`; + } + + opinions.push({ + category: recommended.category, + userPercentage: parseFloat(userPercentage.toFixed(2)), + recommendedPercentage: recommended.percentage, + opinion, + detail, + }); + } + + return opinions; + } +}