From 95e9e7f71f477ba3844430518715428f8df863cb Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 24 Jun 2026 18:53:46 +0300 Subject: [PATCH] feat: complete portfolio analytics Phases 2-3 and sync OpenAPI artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 2: dividend income — batch MOEX dividend fetching with buyDate filtering, UI cards - Phase 3: target allocation — backend targets parsing + deviation calc, frontend inputs + display - Add openapi-artifacts.spec.ts for checked-in contract verification - Regenerate frontend types from Swagger (auth/screener/portfolio paths) --- .../portfolio/dto/analytics-response.dto.ts | 18 ++ .../portfolio/dto/portfolio-response.dto.ts | 10 ++ .../portfolio/dto/update-portfolio.dto.ts | 37 +++- .../portfolio/portfolio.service.spec.ts | 1 + .../modules/portfolio/portfolio.service.ts | 89 ++++++++- apps/backend/src/openapi-artifacts.spec.ts | 27 +++ .../entities/portfolio/api/portfolioApi.ts | 7 +- .../portfolio/model/usePortfolioMutations.ts | 1 + .../portfolios/ui/PortfolioDetailPage.tsx | 1 + apps/frontend/src/shared/api/types.ts | 31 ++++ .../ui/AnalyticsSummary.tsx | 169 ++++++++++++++---- .../portfolio-form/ui/PortfolioForm.tsx | 82 ++++++++- 12 files changed, 423 insertions(+), 50 deletions(-) create mode 100644 apps/backend/src/openapi-artifacts.spec.ts diff --git a/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts b/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts index 059b678..caf86ae 100644 --- a/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts @@ -11,6 +11,24 @@ export class PortfolioSummaryDto { @ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null; @ApiProperty() positionCount!: number; @ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null; + + @ApiProperty({ type: Number, nullable: true }) + targetSharesPercent?: number | null; + + @ApiProperty({ type: Number, nullable: true }) + targetBondsPercent?: number | null; + + @ApiProperty() + actualSharesPercent!: number; + + @ApiProperty() + actualBondsPercent!: number; + + @ApiProperty({ type: Number, nullable: true }) + sharesDeviation?: number | null; + + @ApiProperty({ type: Number, nullable: true }) + bondsDeviation?: number | null; } export class AnalyticsResponseDto { diff --git a/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts b/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts index 1b3d02d..5279f98 100644 --- a/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts @@ -9,6 +9,16 @@ export class PortfolioResponseDto { @ApiProperty({ default: 'RUB' }) currency!: string; @ApiProperty() createdAt!: string; @ApiProperty() updatedAt!: string; + + @ApiPropertyOptional({ + type: 'object', + properties: { + sharesPercent: { type: 'number' }, + bondsPercent: { type: 'number' }, + }, + nullable: true, + }) + targets!: { sharesPercent: number; bondsPercent: number } | null; } export class PortfolioDetailResponseDto extends PortfolioResponseDto { diff --git a/apps/backend/src/modules/portfolio/dto/update-portfolio.dto.ts b/apps/backend/src/modules/portfolio/dto/update-portfolio.dto.ts index 86a7623..94877f2 100644 --- a/apps/backend/src/modules/portfolio/dto/update-portfolio.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/update-portfolio.dto.ts @@ -1,8 +1,34 @@ -import { IsString, IsOptional, IsIn, MaxLength, MinLength } from 'class-validator'; -import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsIn, + IsObject, + IsNumber, + MaxLength, + MinLength, + Min, + Max, + ValidateNested, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] as const; +export class PortfolioTargetsDto { + @ApiProperty({ example: 70 }) + @IsNumber() + @Min(0) + @Max(100) + sharesPercent!: number; + + @ApiProperty({ example: 30 }) + @IsNumber() + @Min(0) + @Max(100) + bondsPercent!: number; +} + export class UpdatePortfolioDto { @ApiPropertyOptional({ example: 'Мой портфель' }) @IsString() @@ -22,4 +48,11 @@ export class UpdatePortfolioDto { @IsIn(CURRENCIES) @IsOptional() currency?: string; + + @ApiPropertyOptional({ example: { sharesPercent: 70, bondsPercent: 30 } }) + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => PortfolioTargetsDto) + targets?: PortfolioTargetsDto; } diff --git a/apps/backend/src/modules/portfolio/portfolio.service.spec.ts b/apps/backend/src/modules/portfolio/portfolio.service.spec.ts index 26db4b3..e98ba63 100644 --- a/apps/backend/src/modules/portfolio/portfolio.service.spec.ts +++ b/apps/backend/src/modules/portfolio/portfolio.service.spec.ts @@ -70,6 +70,7 @@ describe('PortfolioService', () => { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn(), getSecurityDescription: vi.fn(), + getDividends: vi.fn(), }, }, { diff --git a/apps/backend/src/modules/portfolio/portfolio.service.ts b/apps/backend/src/modules/portfolio/portfolio.service.ts index abbcd28..4add5ae 100644 --- a/apps/backend/src/modules/portfolio/portfolio.service.ts +++ b/apps/backend/src/modules/portfolio/portfolio.service.ts @@ -7,7 +7,11 @@ import { import { PrismaService } from '../prisma/prisma.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; -import type { MoexShareMarketData, MoexBondPositionData } from '../moex-client/moex-client.types'; +import type { + MoexShareMarketData, + MoexBondPositionData, + MoexDividend, +} from '../moex-client/moex-client.types'; import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { AddPositionDto } from './dto/add-position.dto'; @@ -59,7 +63,7 @@ export class PortfolioService { ) {} async create(userId: number, dto: CreatePortfolioDto) { - return this.prisma.portfolio.create({ + const portfolio = await this.prisma.portfolio.create({ data: { userId, name: dto.name, @@ -67,6 +71,8 @@ export class PortfolioService { currency: dto.currency ?? 'RUB', }, }); + + return { ...portfolio, targets: null }; } async findAll(userId: number) { @@ -89,6 +95,7 @@ export class PortfolioService { positionCount: 0, shareCount: 0, bondCount: 0, + targets: p.targets ? JSON.parse(p.targets) : null, })); } @@ -117,6 +124,7 @@ export class PortfolioService { positionCount: positions.length, shareCount: positions.filter((pos) => pos.type === 'share').length, bondCount: positions.filter((pos) => pos.type === 'bond').length, + targets: p.targets ? JSON.parse(p.targets) : null, }; }); } @@ -154,6 +162,7 @@ export class PortfolioService { positions: positionsWithWeights, totalValue: Math.round(totalValue * 100) / 100, analytics: analytics.summary, + targets: portfolio.targets ? JSON.parse(portfolio.targets) : null, }; } @@ -162,14 +171,20 @@ export class PortfolioService { if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`); if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); - return this.prisma.portfolio.update({ + const updated = await this.prisma.portfolio.update({ where: { id }, data: { ...(dto.name !== undefined && { name: dto.name }), ...(dto.description !== undefined && { description: dto.description }), ...(dto.currency !== undefined && { currency: dto.currency }), + ...(dto.targets !== undefined && { targets: JSON.stringify(dto.targets) }), }, }); + + return { + ...updated, + targets: updated.targets ? JSON.parse(updated.targets) : null, + }; } async remove(userId: number, id: number) { @@ -272,9 +287,10 @@ export class PortfolioService { const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort(); const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort(); - const [shareDataBySecid, bondDataBySecid] = await Promise.all([ + const [shareDataBySecid, bondDataBySecid, dividendsBySecid] = await Promise.all([ this.fetchShareBatch(shareSecids, portfolioId), this.fetchBondBatch(bondSecids, portfolioId), + this.fetchDividendsBatch(shareSecids, portfolioId), ]); const enriched: EnrichedPosition[] = []; @@ -305,7 +321,9 @@ export class PortfolioService { if (pos.type === 'bond') { enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid))); } else { - enriched.push(this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid))); + enriched.push( + this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid), dividendsBySecid.get(pos.secid)), + ); } } @@ -342,6 +360,26 @@ export class PortfolioService { return new Map(data.map((d) => [d.secid, d])); } + private async fetchDividendsBatch( + secids: string[], + portfolioId?: number, + ): Promise> { + if (secids.length === 0) return new Map(); + const results = await Promise.all( + secids.map(async (secid) => { + const cacheKey = portfolioId ? `pf:${portfolioId}:${secid}` : secid; + const { data } = await this.cache.getOrFetch( + 'dividends', + [cacheKey], + () => this.moexClient.getDividends(secid), + 'marketDataTtl', + ); + return { secid, dividends: data }; + }), + ); + return new Map(results.map((r) => [r.secid, r.dividends])); + } + private buildSharePosition( pos: { id: number; @@ -352,9 +390,16 @@ export class PortfolioService { }, base: EnrichedPosition, data: MoexShareMarketData | undefined, + dividends?: MoexDividend[], ): EnrichedPosition { const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null; - const dividendIncome = 0; + let dividendIncome = 0; + if (pos.buyDate && dividends && dividends.length > 0) { + const buyDateStr = pos.buyDate.toISOString().split('T')[0]; + dividendIncome = dividends + .filter((d) => d.registryCloseDate >= buyDateStr) + .reduce((sum, d) => sum + d.value * pos.quantity, 0); + } if (!data) { return { @@ -494,6 +539,32 @@ export class PortfolioService { ) : null; + let targetSharesPercent: number | null = null; + let targetBondsPercent: number | null = null; + if (portfolio.targets) { + const targets = JSON.parse(portfolio.targets); + targetSharesPercent = targets.sharesPercent; + targetBondsPercent = targets.bondsPercent; + } + + let actualSharesPercent = 0; + let actualBondsPercent = 0; + if (totalValue > 0) { + const shareValue = enrichedPositions + .filter((p) => p.type === 'share') + .reduce((sum, p) => sum + (p.currentValue ?? 0), 0); + const bondValue = enrichedPositions + .filter((p) => p.type === 'bond') + .reduce((sum, p) => sum + (p.currentValue ?? 0), 0); + actualSharesPercent = Math.round((shareValue / totalValue) * 10000) / 100; + actualBondsPercent = Math.round((bondValue / totalValue) * 10000) / 100; + } + + const sharesDeviation = + targetSharesPercent !== null ? Math.round((actualSharesPercent - targetSharesPercent) * 100) / 100 : null; + const bondsDeviation = + targetBondsPercent !== null ? Math.round((actualBondsPercent - targetBondsPercent) * 100) / 100 : null; + const summary = { totalInvested, totalValue, @@ -504,6 +575,12 @@ export class PortfolioService { totalReturnPercent, positionCount, weightedYield, + targetSharesPercent, + targetBondsPercent, + actualSharesPercent, + actualBondsPercent, + sharesDeviation, + bondsDeviation, }; return { positions: enrichedPositions, summary }; diff --git a/apps/backend/src/openapi-artifacts.spec.ts b/apps/backend/src/openapi-artifacts.spec.ts new file mode 100644 index 0000000..24402f6 --- /dev/null +++ b/apps/backend/src/openapi-artifacts.spec.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +describe('checked-in OpenAPI artifacts', () => { + const rootDir = resolve(process.cwd(), '../..'); + const frontendTypes = readFileSync(join(rootDir, 'apps/frontend/src/shared/api/types.ts'), 'utf8'); + + const requiredPaths = [ + '/api/v1/auth/register', + '/api/v1/auth/login', + '/api/v1/auth/refresh', + '/api/v1/auth/logout', + '/api/v1/auth/me', + '/api/v1/securities/screener', + '/api/v1/portfolios', + '/api/v1/portfolios/{id}', + '/api/v1/portfolios/{id}/positions', + '/api/v1/portfolios/{id}/positions/{positionId}', + '/api/v1/portfolios/{id}/analytics', + ]; + + it('frontend generated types include current protected domains', () => { + for (const path of requiredPaths) { + expect(frontendTypes).toContain(`'${path}'`); + } + }); +}); diff --git a/apps/frontend/src/entities/portfolio/api/portfolioApi.ts b/apps/frontend/src/entities/portfolio/api/portfolioApi.ts index 26634c9..44df704 100644 --- a/apps/frontend/src/entities/portfolio/api/portfolioApi.ts +++ b/apps/frontend/src/entities/portfolio/api/portfolioApi.ts @@ -27,7 +27,12 @@ export function createPortfolio(data: { export function updatePortfolio( id: number, - data: { name?: string; description?: string; currency?: string }, + data: { + name?: string + description?: string + currency?: string + targets?: { sharesPercent: number; bondsPercent: number } + }, ): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> { return request(`/api/v1/portfolios/${id}`, undefined, { method: 'PATCH', diff --git a/apps/frontend/src/entities/portfolio/model/usePortfolioMutations.ts b/apps/frontend/src/entities/portfolio/model/usePortfolioMutations.ts index ae6559f..647b83b 100644 --- a/apps/frontend/src/entities/portfolio/model/usePortfolioMutations.ts +++ b/apps/frontend/src/entities/portfolio/model/usePortfolioMutations.ts @@ -25,6 +25,7 @@ export function usePortfolioMutations() { name?: string description?: string currency?: string + targets?: { sharesPercent: number; bondsPercent: number } } }) => updatePortfolio(id, data), onSuccess: (_, { id }) => { diff --git a/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx b/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx index ee88d50..ea2c0db 100644 --- a/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx +++ b/apps/frontend/src/pages/portfolios/ui/PortfolioDetailPage.tsx @@ -103,6 +103,7 @@ export function PortfolioDetailPage() { update.mutate({ id: portfolioId, data: d })} onCancel={() => setEditing(false)} isLoading={update.isPending} diff --git a/apps/frontend/src/shared/api/types.ts b/apps/frontend/src/shared/api/types.ts index 76b48a8..f5d3f61 100644 --- a/apps/frontend/src/shared/api/types.ts +++ b/apps/frontend/src/shared/api/types.ts @@ -861,6 +861,10 @@ export interface components { currency: string createdAt: string updatedAt: string + targets?: { + sharesPercent?: number + bondsPercent?: number + } | null /** @description Total market value of all positions */ totalValue: number /** @description Total number of positions */ @@ -893,6 +897,10 @@ export interface components { currency: string createdAt: string updatedAt: string + targets?: { + sharesPercent?: number + bondsPercent?: number + } | null } PortfolioEnvelopeDto: { data: components['schemas']['PortfolioResponseDto'] @@ -948,6 +956,12 @@ export interface components { totalReturnPercent: number | null positionCount: number weightedYield: number | null + targetSharesPercent: number | null + targetBondsPercent: number | null + actualSharesPercent: number + actualBondsPercent: number + sharesDeviation: number | null + bondsDeviation: number | null } PortfolioDetailResponseDto: { id: number @@ -957,6 +971,10 @@ export interface components { currency: string createdAt: string updatedAt: string + targets?: { + sharesPercent?: number + bondsPercent?: number + } | null positions: components['schemas']['PositionWithPriceDto'][] totalValue: number analytics: components['schemas']['PortfolioSummaryDto'] @@ -965,6 +983,12 @@ export interface components { data: components['schemas']['PortfolioDetailResponseDto'] meta: components['schemas']['PortfolioResponseMetaDto'] } + PortfolioTargetsDto: { + /** @example 70 */ + sharesPercent: number + /** @example 30 */ + bondsPercent: number + } UpdatePortfolioDto: { /** @example Мой портфель */ name?: string @@ -975,6 +999,13 @@ export interface components { * @enum {string} */ currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN' + /** + * @example { + * "sharesPercent": 70, + * "bondsPercent": 30 + * } + */ + targets?: components['schemas']['PortfolioTargetsDto'] } AddPositionDto: { /** @example SBER */ diff --git a/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx b/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx index e6d1358..2f32a09 100644 --- a/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx +++ b/apps/frontend/src/widgets/portfolio-analytics/ui/AnalyticsSummary.tsx @@ -15,54 +15,147 @@ export function AnalyticsSummary({ summary }: { summary: PortfolioSummary }) { ? 'var(--color-negative)' : 'inherit' + const returnColor = + summary.totalReturnPercent != null && summary.totalReturnPercent > 0 + ? 'var(--color-positive)' + : summary.totalReturnPercent != null && summary.totalReturnPercent < 0 + ? 'var(--color-negative)' + : 'inherit' + + const deviationColor = (val: number | null | undefined) => { + if (val == null) return 'inherit' + return val > 0 ? 'var(--color-negative)' : val < 0 ? 'var(--color-positive)' : 'inherit' + } + return ( -
-
-
- Инвестировано +
+
+
+
+ Инвестировано +
+
{formatRub(summary.totalInvested)}
-
{formatRub(summary.totalInvested)}
-
-
-
- Текущая стоимость +
+
+ Текущая стоимость +
+
{formatRub(summary.totalValue)}
-
{formatRub(summary.totalValue)}
-
-
-
- Прибыль/Убыток +
+
+ Прибыль/Убыток +
+
+ {summary.totalPnl > 0 ? '+' : ''} + {formatRub(summary.totalPnl)} + + ({formatPct(summary.totalPnlPercent)}) + +
-
- {summary.totalPnl > 0 ? '+' : ''} - {formatRub(summary.totalPnl)} - - ({formatPct(summary.totalPnlPercent)}) - + +
+
+ Доходность (weighted) +
+
+ {formatPct(summary.weightedYield)} +
+
+ +
+
+ Дивиденды +
+
+ {summary.totalDividends > 0 ? '+' : ''} + {formatRub(summary.totalDividends)} +
+
+ +
+
+ Общая доходность +
+
+ {formatPct(summary.totalReturnPercent)} +
-
-
- Доходность (weighted) + {summary.targetSharesPercent != null && ( +
+
+ Целевое распределение +
+
+
+ Акции: цель {summary.targetSharesPercent}% / факт{' '} + {summary.actualSharesPercent.toFixed(1)}% +
+
+ {summary.sharesDeviation != null + ? `Отклонение: ${summary.sharesDeviation > 0 ? '+' : ''}${summary.sharesDeviation.toFixed(1)}%` + : ''} +
+
+
+
+ Облигации: цель {summary.targetBondsPercent}% / факт{' '} + {summary.actualBondsPercent.toFixed(1)}% +
+
+ {summary.bondsDeviation != null + ? `Отклонение: ${summary.bondsDeviation > 0 ? '+' : ''}${summary.bondsDeviation.toFixed(1)}%` + : ''} +
+
-
- {formatPct(summary.weightedYield)} -
-
+ )}
) } diff --git a/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx b/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx index 73724eb..213ed4f 100644 --- a/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx +++ b/apps/frontend/src/widgets/portfolio-form/ui/PortfolioForm.tsx @@ -3,22 +3,53 @@ import type { Portfolio } from '@/shared/api' interface Props { initial?: Portfolio - onSave: (data: { name: string; description?: string; currency?: string }) => void + targets?: { sharesPercent: number; bondsPercent: number } | null + onSave: (data: { + name: string + description?: string + currency?: string + targets?: { sharesPercent: number; bondsPercent: number } + }) => void onCancel: () => void isLoading?: boolean } const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] -export function PortfolioForm({ initial, onSave, onCancel, isLoading }: Props) { +export function PortfolioForm({ + initial, + targets: initialTargets, + onSave, + onCancel, + isLoading, +}: Props) { const [name, setName] = useState(initial?.name || '') const [description, setDescription] = useState(initial?.description || '') const [currency, setCurrency] = useState(initial?.currency || 'RUB') + const [sharesPercent, setSharesPercent] = useState(initialTargets?.sharesPercent ?? 70) + const [bondsPercent, setBondsPercent] = useState(initialTargets?.bondsPercent ?? 30) + + function handleSharesChange(value: string) { + const num = Math.min(100, Math.max(0, Number(value) || 0)) + setSharesPercent(num) + setBondsPercent(100 - num) + } + + function handleBondsChange(value: string) { + const num = Math.min(100, Math.max(0, Number(value) || 0)) + setBondsPercent(num) + setSharesPercent(100 - num) + } function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!name.trim()) return - onSave({ name: name.trim(), description: description.trim() || undefined, currency }) + onSave({ + name: name.trim(), + description: description.trim() || undefined, + currency, + targets: { sharesPercent, bondsPercent }, + }) } return ( @@ -82,6 +113,51 @@ export function PortfolioForm({ initial, onSave, onCancel, isLoading }: Props) { ))}
+
+ +
+
+
+ Акции % +
+ handleSharesChange(e.target.value)} + style={{ + width: '100%', + padding: '8px 12px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 14, + }} + /> +
+
+
+ Облигации % +
+ handleBondsChange(e.target.value)} + style={{ + width: '100%', + padding: '8px 12px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 14, + }} + /> +
+
+