feat: complete portfolio analytics Phases 2-3 and sync OpenAPI artifacts
- 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)
This commit is contained in:
parent
ba0a4cbfef
commit
95e9e7f71f
@ -11,6 +11,24 @@ export class PortfolioSummaryDto {
|
|||||||
@ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null;
|
@ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null;
|
||||||
@ApiProperty() positionCount!: number;
|
@ApiProperty() positionCount!: number;
|
||||||
@ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null;
|
@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 {
|
export class AnalyticsResponseDto {
|
||||||
|
|||||||
@ -9,6 +9,16 @@ export class PortfolioResponseDto {
|
|||||||
@ApiProperty({ default: 'RUB' }) currency!: string;
|
@ApiProperty({ default: 'RUB' }) currency!: string;
|
||||||
@ApiProperty() createdAt!: string;
|
@ApiProperty() createdAt!: string;
|
||||||
@ApiProperty() updatedAt!: 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 {
|
export class PortfolioDetailResponseDto extends PortfolioResponseDto {
|
||||||
|
|||||||
@ -1,8 +1,34 @@
|
|||||||
import { IsString, IsOptional, IsIn, MaxLength, MinLength } from 'class-validator';
|
import {
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
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;
|
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 {
|
export class UpdatePortfolioDto {
|
||||||
@ApiPropertyOptional({ example: 'Мой портфель' })
|
@ApiPropertyOptional({ example: 'Мой портфель' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -22,4 +48,11 @@ export class UpdatePortfolioDto {
|
|||||||
@IsIn(CURRENCIES)
|
@IsIn(CURRENCIES)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: { sharesPercent: 70, bondsPercent: 30 } })
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => PortfolioTargetsDto)
|
||||||
|
targets?: PortfolioTargetsDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,6 +70,7 @@ describe('PortfolioService', () => {
|
|||||||
getShareMarketDataBatch: vi.fn(),
|
getShareMarketDataBatch: vi.fn(),
|
||||||
getBondPositionDataBatch: vi.fn(),
|
getBondPositionDataBatch: vi.fn(),
|
||||||
getSecurityDescription: vi.fn(),
|
getSecurityDescription: vi.fn(),
|
||||||
|
getDividends: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -7,7 +7,11 @@ import {
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||||
import { CacheService } from '../cache/cache.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 { CreatePortfolioDto } from './dto/create-portfolio.dto';
|
||||||
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
||||||
import { AddPositionDto } from './dto/add-position.dto';
|
import { AddPositionDto } from './dto/add-position.dto';
|
||||||
@ -59,7 +63,7 @@ export class PortfolioService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(userId: number, dto: CreatePortfolioDto) {
|
async create(userId: number, dto: CreatePortfolioDto) {
|
||||||
return this.prisma.portfolio.create({
|
const portfolio = await this.prisma.portfolio.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
@ -67,6 +71,8 @@ export class PortfolioService {
|
|||||||
currency: dto.currency ?? 'RUB',
|
currency: dto.currency ?? 'RUB',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { ...portfolio, targets: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(userId: number) {
|
async findAll(userId: number) {
|
||||||
@ -89,6 +95,7 @@ export class PortfolioService {
|
|||||||
positionCount: 0,
|
positionCount: 0,
|
||||||
shareCount: 0,
|
shareCount: 0,
|
||||||
bondCount: 0,
|
bondCount: 0,
|
||||||
|
targets: p.targets ? JSON.parse(p.targets) : null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,6 +124,7 @@ export class PortfolioService {
|
|||||||
positionCount: positions.length,
|
positionCount: positions.length,
|
||||||
shareCount: positions.filter((pos) => pos.type === 'share').length,
|
shareCount: positions.filter((pos) => pos.type === 'share').length,
|
||||||
bondCount: positions.filter((pos) => pos.type === 'bond').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,
|
positions: positionsWithWeights,
|
||||||
totalValue: Math.round(totalValue * 100) / 100,
|
totalValue: Math.round(totalValue * 100) / 100,
|
||||||
analytics: analytics.summary,
|
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) throw new NotFoundException(`Portfolio ${id} not found`);
|
||||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||||||
|
|
||||||
return this.prisma.portfolio.update({
|
const updated = await this.prisma.portfolio.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...(dto.name !== undefined && { name: dto.name }),
|
...(dto.name !== undefined && { name: dto.name }),
|
||||||
...(dto.description !== undefined && { description: dto.description }),
|
...(dto.description !== undefined && { description: dto.description }),
|
||||||
...(dto.currency !== undefined && { currency: dto.currency }),
|
...(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) {
|
async remove(userId: number, id: number) {
|
||||||
@ -272,9 +287,10 @@ export class PortfolioService {
|
|||||||
const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort();
|
const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort();
|
||||||
const bondSecids = [...new Set(bondPositions.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.fetchShareBatch(shareSecids, portfolioId),
|
||||||
this.fetchBondBatch(bondSecids, portfolioId),
|
this.fetchBondBatch(bondSecids, portfolioId),
|
||||||
|
this.fetchDividendsBatch(shareSecids, portfolioId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const enriched: EnrichedPosition[] = [];
|
const enriched: EnrichedPosition[] = [];
|
||||||
@ -305,7 +321,9 @@ export class PortfolioService {
|
|||||||
if (pos.type === 'bond') {
|
if (pos.type === 'bond') {
|
||||||
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
|
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
|
||||||
} else {
|
} 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]));
|
return new Map(data.map((d) => [d.secid, d]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async fetchDividendsBatch(
|
||||||
|
secids: string[],
|
||||||
|
portfolioId?: number,
|
||||||
|
): Promise<Map<string, MoexDividend[]>> {
|
||||||
|
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(
|
private buildSharePosition(
|
||||||
pos: {
|
pos: {
|
||||||
id: number;
|
id: number;
|
||||||
@ -352,9 +390,16 @@ export class PortfolioService {
|
|||||||
},
|
},
|
||||||
base: EnrichedPosition,
|
base: EnrichedPosition,
|
||||||
data: MoexShareMarketData | undefined,
|
data: MoexShareMarketData | undefined,
|
||||||
|
dividends?: MoexDividend[],
|
||||||
): EnrichedPosition {
|
): EnrichedPosition {
|
||||||
const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null;
|
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) {
|
if (!data) {
|
||||||
return {
|
return {
|
||||||
@ -494,6 +539,32 @@ export class PortfolioService {
|
|||||||
)
|
)
|
||||||
: null;
|
: 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 = {
|
const summary = {
|
||||||
totalInvested,
|
totalInvested,
|
||||||
totalValue,
|
totalValue,
|
||||||
@ -504,6 +575,12 @@ export class PortfolioService {
|
|||||||
totalReturnPercent,
|
totalReturnPercent,
|
||||||
positionCount,
|
positionCount,
|
||||||
weightedYield,
|
weightedYield,
|
||||||
|
targetSharesPercent,
|
||||||
|
targetBondsPercent,
|
||||||
|
actualSharesPercent,
|
||||||
|
actualBondsPercent,
|
||||||
|
sharesDeviation,
|
||||||
|
bondsDeviation,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { positions: enrichedPositions, summary };
|
return { positions: enrichedPositions, summary };
|
||||||
|
|||||||
27
apps/backend/src/openapi-artifacts.spec.ts
Normal file
27
apps/backend/src/openapi-artifacts.spec.ts
Normal file
@ -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}'`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -27,7 +27,12 @@ export function createPortfolio(data: {
|
|||||||
|
|
||||||
export function updatePortfolio(
|
export function updatePortfolio(
|
||||||
id: number,
|
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 } }> {
|
): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||||
return request<Portfolio>(`/api/v1/portfolios/${id}`, undefined, {
|
return request<Portfolio>(`/api/v1/portfolios/${id}`, undefined, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export function usePortfolioMutations() {
|
|||||||
name?: string
|
name?: string
|
||||||
description?: string
|
description?: string
|
||||||
currency?: string
|
currency?: string
|
||||||
|
targets?: { sharesPercent: number; bondsPercent: number }
|
||||||
}
|
}
|
||||||
}) => updatePortfolio(id, data),
|
}) => updatePortfolio(id, data),
|
||||||
onSuccess: (_, { id }) => {
|
onSuccess: (_, { id }) => {
|
||||||
|
|||||||
@ -103,6 +103,7 @@ export function PortfolioDetailPage() {
|
|||||||
</h3>
|
</h3>
|
||||||
<PortfolioForm
|
<PortfolioForm
|
||||||
initial={portfolio}
|
initial={portfolio}
|
||||||
|
targets={portfolio.targets ?? null}
|
||||||
onSave={(d) => update.mutate({ id: portfolioId, data: d })}
|
onSave={(d) => update.mutate({ id: portfolioId, data: d })}
|
||||||
onCancel={() => setEditing(false)}
|
onCancel={() => setEditing(false)}
|
||||||
isLoading={update.isPending}
|
isLoading={update.isPending}
|
||||||
|
|||||||
@ -861,6 +861,10 @@ export interface components {
|
|||||||
currency: string
|
currency: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
targets?: {
|
||||||
|
sharesPercent?: number
|
||||||
|
bondsPercent?: number
|
||||||
|
} | null
|
||||||
/** @description Total market value of all positions */
|
/** @description Total market value of all positions */
|
||||||
totalValue: number
|
totalValue: number
|
||||||
/** @description Total number of positions */
|
/** @description Total number of positions */
|
||||||
@ -893,6 +897,10 @@ export interface components {
|
|||||||
currency: string
|
currency: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
targets?: {
|
||||||
|
sharesPercent?: number
|
||||||
|
bondsPercent?: number
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
PortfolioEnvelopeDto: {
|
PortfolioEnvelopeDto: {
|
||||||
data: components['schemas']['PortfolioResponseDto']
|
data: components['schemas']['PortfolioResponseDto']
|
||||||
@ -948,6 +956,12 @@ export interface components {
|
|||||||
totalReturnPercent: number | null
|
totalReturnPercent: number | null
|
||||||
positionCount: number
|
positionCount: number
|
||||||
weightedYield: number | null
|
weightedYield: number | null
|
||||||
|
targetSharesPercent: number | null
|
||||||
|
targetBondsPercent: number | null
|
||||||
|
actualSharesPercent: number
|
||||||
|
actualBondsPercent: number
|
||||||
|
sharesDeviation: number | null
|
||||||
|
bondsDeviation: number | null
|
||||||
}
|
}
|
||||||
PortfolioDetailResponseDto: {
|
PortfolioDetailResponseDto: {
|
||||||
id: number
|
id: number
|
||||||
@ -957,6 +971,10 @@ export interface components {
|
|||||||
currency: string
|
currency: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
targets?: {
|
||||||
|
sharesPercent?: number
|
||||||
|
bondsPercent?: number
|
||||||
|
} | null
|
||||||
positions: components['schemas']['PositionWithPriceDto'][]
|
positions: components['schemas']['PositionWithPriceDto'][]
|
||||||
totalValue: number
|
totalValue: number
|
||||||
analytics: components['schemas']['PortfolioSummaryDto']
|
analytics: components['schemas']['PortfolioSummaryDto']
|
||||||
@ -965,6 +983,12 @@ export interface components {
|
|||||||
data: components['schemas']['PortfolioDetailResponseDto']
|
data: components['schemas']['PortfolioDetailResponseDto']
|
||||||
meta: components['schemas']['PortfolioResponseMetaDto']
|
meta: components['schemas']['PortfolioResponseMetaDto']
|
||||||
}
|
}
|
||||||
|
PortfolioTargetsDto: {
|
||||||
|
/** @example 70 */
|
||||||
|
sharesPercent: number
|
||||||
|
/** @example 30 */
|
||||||
|
bondsPercent: number
|
||||||
|
}
|
||||||
UpdatePortfolioDto: {
|
UpdatePortfolioDto: {
|
||||||
/** @example Мой портфель */
|
/** @example Мой портфель */
|
||||||
name?: string
|
name?: string
|
||||||
@ -975,6 +999,13 @@ export interface components {
|
|||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN'
|
currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN'
|
||||||
|
/**
|
||||||
|
* @example {
|
||||||
|
* "sharesPercent": 70,
|
||||||
|
* "bondsPercent": 30
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
targets?: components['schemas']['PortfolioTargetsDto']
|
||||||
}
|
}
|
||||||
AddPositionDto: {
|
AddPositionDto: {
|
||||||
/** @example SBER */
|
/** @example SBER */
|
||||||
|
|||||||
@ -15,7 +15,20 @@ export function AnalyticsSummary({ summary }: { summary: PortfolioSummary }) {
|
|||||||
? 'var(--color-negative)'
|
? 'var(--color-negative)'
|
||||||
: 'inherit'
|
: '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 (
|
return (
|
||||||
|
<div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@ -63,6 +76,86 @@ export function AnalyticsSummary({ summary }: { summary: PortfolioSummary }) {
|
|||||||
{formatPct(summary.weightedYield)}
|
{formatPct(summary.weightedYield)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
|
||||||
|
Дивиденды
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700 }}>
|
||||||
|
{summary.totalDividends > 0 ? '+' : ''}
|
||||||
|
{formatRub(summary.totalDividends)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
|
||||||
|
Общая доходность
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: returnColor }}>
|
||||||
|
{formatPct(summary.totalReturnPercent)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary.targetSharesPercent != null && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||||
|
gap: 16,
|
||||||
|
padding: 16,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
marginTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Целевое распределение
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 2 }}>
|
||||||
|
Акции: цель {summary.targetSharesPercent}% / факт{' '}
|
||||||
|
{summary.actualSharesPercent.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: deviationColor(summary.sharesDeviation),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{summary.sharesDeviation != null
|
||||||
|
? `Отклонение: ${summary.sharesDeviation > 0 ? '+' : ''}${summary.sharesDeviation.toFixed(1)}%`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 2 }}>
|
||||||
|
Облигации: цель {summary.targetBondsPercent}% / факт{' '}
|
||||||
|
{summary.actualBondsPercent.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: deviationColor(summary.bondsDeviation),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{summary.bondsDeviation != null
|
||||||
|
? `Отклонение: ${summary.bondsDeviation > 0 ? '+' : ''}${summary.bondsDeviation.toFixed(1)}%`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,22 +3,53 @@ import type { Portfolio } from '@/shared/api'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initial?: Portfolio
|
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
|
onCancel: () => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN']
|
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 [name, setName] = useState(initial?.name || '')
|
||||||
const [description, setDescription] = useState(initial?.description || '')
|
const [description, setDescription] = useState(initial?.description || '')
|
||||||
const [currency, setCurrency] = useState(initial?.currency || 'RUB')
|
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) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!name.trim()) return
|
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 (
|
return (
|
||||||
@ -82,6 +113,51 @@ export function PortfolioForm({ initial, onSave, onCancel, isLoading }: Props) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||||
|
Целевое распределение
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 2 }}>
|
||||||
|
Акции %
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={sharesPercent}
|
||||||
|
onChange={(e) => handleSharesChange(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 2 }}>
|
||||||
|
Облигации %
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={bondsPercent}
|
||||||
|
onChange={(e) => handleBondsChange(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user