Portfolio Health Score — здоровье портфеля
Назначение
Composite score 0-100, показывающий «здоровье» портфеля на основе диверсификации, концентрации, волатильности, просадки, классов активов и доходности. Дополнен AI-инсайтами через OpenAI.
Формула
healthScore = 0.30 * diversification
+ 0.20 * concentration
+ 0.20 * volatility
+ 0.15 * drawdown
+ 0.10 * assetClass
+ 0.05 * performanceГде считаются метрики
Бэкенд (PortfolioStats::buildHealthMetrics)
- diversificationScore: (1 - HHI) * 100, где HHI = sum(weight_i^2)
- concentrationScore: 100 - max_weight * 100
- assetClassCount: количество уникальных типов активов
- largestPosition: символ + вес крупнейшей позиции
- topPositions: топ-5 по весу (symbol, weight, type)
Важно: health считается только когда $includeHealth = true — то есть когда запрос пришёл без фильтров (categories, assets, assetTypes пустые). При наличии фильтров $data['health'] не добавляется. Флаг определяется в PortfolioController::getPortfolioStats.
При использовании кэша: если в hydrated-кэше отсутствует health (старый кэш до фичи), а запрос без фильтров — досчитывается из state.
Фронтенд (composables/portfolioHealth.js)
- portfolioHealthStats (store): отдельное поле
portfolioStore.portfolioHealthStats, которое обновляется только когда бэкенд вернулdata.health(т.е. запрос без фильтров). При фильтрации портфеля старые значения сохраняются. Composable читает из этого поля, а не изportfolioStats.health. - portfolioNAV: полная стоимость портфеля = totalSpent + totalIncome + (totalFixed - totalCommissions - totalDealsAccruedInterest + totalTransactionsAccruedInterest) + (totalAccruals - totalExpenses - totalTaxes). Соответствует fullProfit из composables/portfolio.js (chart context).
- Log returns: дневные доходности считаются как
r_i = ln((NAV_i - CF_i) / NAV_{i-1}). Log returns аддитивны и устойчивее при больших движениях цен. - Защита от грязных данных: порог NAV > 1e-6 (вместо > 0), кэп выбросов ±50% (LOG_RETURN_CAP = 0.5)
- Кэш NAV: массив NAV предрассчитывается один раз через
chartData.map(portfolioNAV), затем переиспользуется в цикле - filterLastYear: хелпер, обрезающий chartData до последних 365 дней (по полю
time— unix timestamp). Используется для volatility и drawdown в контексте health score. - volatility: annualized std dev дневных log-доходностей за последние 12 месяцев (пополнения/выводы исключены, NAV включает все компоненты прибыли). Рассчитывается по
chartData1Y. - drawdown1Y: max peak-to-trough за последние 12 месяцев. Используется для расчёта health score. Включает даты пика и дна.
- drawdownAllTime: max peak-to-trough за всё время. Показывается пользователю информационно (не влияет на оценку здоровья). Отображается только если отличается от 1Y.
- Конвертация drawdown:
1 - exp(cumLog - peakLog)
Принцип: ошибки прошлого не давят на пользователя — health score отражает текущее состояние портфеля (последний год), а не историю.
- TWR (Time-Weighted Return):
exp(Σ r_i) - 1, где r_i — дневная log-доходность с поправкой на cash flows - annualizedTWR: CAGR от TWR = (1 + TWR)^(365/days) - 1. Всегда аннуализируется (включая периоды < 1 года) для сопоставимости метрики
- assetClassScore: маппинг count -> score (1→30, 2→60, 3→80, 4+→100)
- performanceScore: маппинг XIRR -> score
TWR показывается в:
PortfolioStatsSwipeCard.vue— строка «Доходность портфеля (TWR)» с годовым и абсолютным значениемPortfolioInfoBoxes.vue— в InfoBox «Доходность» рядом с XIRR
AI-чат по здоровью портфеля
- Кнопка «Задать вопрос AI» в
PortfolioHealthOverview.vueиPortfolioHealthSwipeCard.vue - Открывает
AiChatWidgetчерезaiChatStore.openWidget('portfolio_health', portfolioId, insightsPayload) - Payload формируется в
insightsPayloadиз composable и передаётся как контекст для AI-чата
UX маппинг
| Score | Label | Цвет |
|---|---|---|
| 90-100 | Отлично | #22c55e |
| 75-89 | Сильный | #84cc16 |
| 60-74 | Здоровый | #eab308 |
| 40-59 | Рискованный | #f97316 |
| 0-39 | Очень рискованный | #ef4444 |
UI-деталь по топ-позициям
- В
PortfolioHealthOverview.vueдля списка «Топ позиции по весу» используется форматтерformatPositionWeight(weight). - Формат: до 99.9% показываем 1 знак после запятой, для 100% и выше — без десятых.
- Для текстового процента задан
whitespace-nowrap+ фиксированная минимальная ширина, чтобы значение (100%) не переносилось на новую строку.
UI-деталь по tooltip-описаниям метрик
- Для числовых индикаторов
Волатильность,TWR (годовая),Просадка (1Y),Просадка (всё время)вPortfolioHealthSwipeCard.vueиPortfolioHealthOverview.vueнужно держать одинаковые тексты подсказок. - Логика динамических tooltip для просадок (с датами пика/дна при наличии) тоже должна совпадать, чтобы mobile/desktop не расходились по смыслу.
- Источник истины для этих подсказок:
composables/portfolioHealth.js(PORTFOLIO_HEALTH_TOOLTIPS,getDrawdown1YTooltip,getDrawdownAllTimeTooltip).
Связанные файлы
cryptodiary/app/Components/Portfolio/PortfolioStats.php— методbuildHealthMetricscryptodiary/app/Http/Controllers/PortfolioController.php— нет endpoint health-insights (удалён)hadl-front/src/composables/portfolioHealth.js— composable с расчётамиhadl-front/src/components/PortfolioHealthSwipeCard.vue— мобильная карточкаhadl-front/src/components/PortfolioHealthOverview.vue— десктопная вкладкаhadl-front/src/components/PortfolioSwiper.vue— интеграция слайдаhadl-front/src/components/Portfolio.vue— интеграция вкладкиhadl-front/src/stores/portfolio.js— overviewMode 'health'