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:
Sergey Krylov 2026-06-24 18:53:46 +03:00
parent ba0a4cbfef
commit 95e9e7f71f
12 changed files with 423 additions and 50 deletions

View File

@ -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 {

View File

@ -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 {

View File

@ -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;
} }

View File

@ -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(),
}, },
}, },
{ {

View File

@ -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 };

View 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}'`);
}
});
});

View File

@ -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',

View File

@ -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 }) => {

View File

@ -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}

View File

@ -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 */

View File

@ -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>
) )
} }

View File

@ -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"