Sergey Krylov 96f003852d
Some checks failed
CI / lint (pull_request) Failing after 1m48s
CI / test (pull_request) Successful in 1m47s
CI / build (pull_request) Successful in 1m53s
CI / lint (push) Failing after 1m59s
CI / test (push) Successful in 1m56s
CI / build (push) Successful in 1m50s
feat: implement portfolio analytics, PnL calculation, and security screener
- Add buyPrice and buyDate to positions for PnL tracking
- Implement backend analytics service for real-time portfolio performance
- Add server-side security screener with filtering, sorting, and pagination
- Update frontend UI with analytics summaries and sortable screener table
- Optimize MOEX API calls with batch fetching and portfolio-specific caching
- Add unit tests for analytics and screener services
2026-06-14 15:59:25 +03:00

223 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react';
import { Link } from 'react-router-dom';
import type { PositionWithPrice } from '../../api/responses';
interface Props {
position: PositionWithPrice;
onUpdate: (data: { quantity?: number; buyPrice?: number }) => void;
onDelete: () => void;
}
export function BondPositionRow({ position, onUpdate, onDelete }: Props) {
const [editingQty, setEditingQty] = useState(false);
const [editingPrice, setEditingPrice] = useState(false);
const [qty, setQty] = useState(String(position.quantity));
const [price, setPrice] = useState(String(position.buyPrice ?? ''));
function handleSaveQty() {
const num = parseInt(qty, 10);
if (!isNaN(num) && num >= 0 && num !== position.quantity) {
onUpdate({ quantity: num });
}
setEditingQty(false);
}
function handleSavePrice() {
const num = parseFloat(price);
if (!isNaN(num) && num >= 0 && num !== position.buyPrice) {
onUpdate({ buyPrice: num });
} else if (price === '' && position.buyPrice !== null) {
onUpdate({ buyPrice: undefined });
}
setEditingPrice(false);
}
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('ru-RU');
}
function formatPct(value: number | null | undefined): string {
return value != null ? `${value.toFixed(2)}%` : '—';
}
function formatRubles(value: number | null | undefined): string {
return value != null
? value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '—';
}
const totalAccrued = position.accruedInt != null ? position.accruedInt * position.quantity : null;
return (
<tr style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={{ padding: '8px 12px', fontWeight: 600, fontFamily: 'monospace' }}>
<Link to={`/bonds/${position.secid}`} style={{ color: 'inherit', textDecoration: 'none' }}>
{position.secid}
</Link>
</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>
{position.shortName ?? '—'}
</td>
<td style={{ padding: '8px 12px', fontSize: 13, color: 'var(--color-text-secondary)' }}>
{position.bondType ? <span>{position.bondType}</span> : '—'}
</td>
<td style={{ padding: '8px 12px' }}>
{editingQty ? (
<input
type="number"
min={0}
value={qty}
onChange={(e) => setQty(e.target.value)}
onBlur={handleSaveQty}
onKeyDown={(e) => e.key === 'Enter' && handleSaveQty()}
autoFocus
style={{
width: 80,
padding: '4px 8px',
border: '1px solid var(--color-primary)',
borderRadius: 'var(--border-radius)',
fontSize: 14,
}}
/>
) : (
<span
onClick={() => {
setQty(String(position.quantity));
setEditingQty(true);
}}
style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }}
>
{position.quantity.toLocaleString('ru-RU')}
</span>
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{editingPrice ? (
<input
type="number"
step="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
onBlur={handleSavePrice}
onKeyDown={(e) => e.key === 'Enter' && handleSavePrice()}
autoFocus
style={{
width: 90,
padding: '4px 8px',
border: '1px solid var(--color-primary)',
borderRadius: 'var(--border-radius)',
fontSize: 14,
textAlign: 'right',
}}
/>
) : (
<span
onClick={() => {
setPrice(String(position.buyPrice ?? ''));
setEditingPrice(true);
}}
style={{
cursor: 'pointer',
padding: '4px 0',
display: 'inline-block',
color: position.buyPrice === null ? 'var(--color-text-secondary)' : 'inherit',
}}
>
{formatPct(position.buyPrice)}
</span>
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatPct(position.currentPrice)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>{formatPct(position.bid)}</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>{formatPct(position.offer)}</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatPct(position.yieldToMaturity)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.duration != null ? `${position.duration.toFixed(2)}г` : '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatRubles(position.couponValue)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatPct(position.couponPercent)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.couponPeriod ? `${position.couponPeriod}д` : '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{totalAccrued != null
? totalAccrued.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.totalCost !== null
? position.totalCost.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.pnl !== null ? (
<span
style={{ color: position.pnl >= 0 ? 'var(--color-positive)' : 'var(--color-negative)' }}
>
{position.pnl >= 0 ? '+' : ''}
{position.pnl.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
) : (
'—'
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.pnlPercent !== null ? (
<span
style={{
color: position.pnlPercent >= 0 ? 'var(--color-positive)' : 'var(--color-negative)',
}}
>
{position.pnlPercent >= 0 ? '+' : ''}
{position.pnlPercent.toFixed(2)}%
</span>
) : (
'—'
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatDate(position.nextCouponDate)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>{formatDate(position.matDate)}</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>{formatDate(position.offerDate)}</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.weightPercent.toFixed(1)}%
</td>
<td style={{ padding: '8px 12px' }}>
<button
onClick={onDelete}
style={{
background: 'none',
border: 'none',
color: '#e53935',
cursor: 'pointer',
fontSize: 13,
padding: 4,
}}
title="Удалить позицию"
>
</button>
</td>
</tr>
);
}