Перейти к содержанию

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 маппинг

ScoreLabelЦвет
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 — метод buildHealthMetrics
  • cryptodiary/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'