Files
InvestmentTrackerApp/PortfolioJournal/Views/Charts/DrawdownChart.swift
T
alexandrev-tibco f1aeafccf6 Brush global de rango: filtro de datos para todos los charts + fix swipe (build 79)
Feedback TestFlight build 78: arrastrar las asas del minimapa disparaba el
swipe entre charts (el brush vivía dentro de chartContent, bajo el
simultaneousGesture del swipe). El brush sale del chart y pasa a la barra de
periodo, fuera del área con gesto.

Y de paso, el cambio de diseño pedido: la ventana elegida ya no es zoom visual
por-chart sino ChartsViewModel.customRange — filtra los DATOS de todas las
gráficas (evolution, contributions, rolling, drawdown, volatility, cashflow,
comparison, YoY, prediction). Sparkline del brush desde fullHistorySeries
(agregación mensual extraída de calculateEvolutionData como monthlySeries).
Presets limpian el rango custom; selección completa = sin filtro. El pinch
por-chart se mantiene como zoom visual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L38583J7AYWCVPevkivscj
2026-08-06 20:33:58 +02:00

428 lines
16 KiB
Swift

import SwiftUI
import Charts
struct DrawdownChart: View {
let data: [(date: Date, drawdown: Double)]
@State private var zoom = ChartZoomModel()
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var labelsSizeClass
private var labelsCompact: Bool { labelsSizeClass != .regular }
private var selectedPoint: (date: Date, drawdown: Double)? {
guard let selectedDate else { return nil }
return data.min(by: {
abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate))
})
}
var maxDrawdown: Double {
abs(data.map { $0.drawdown }.min() ?? 0)
}
var currentDrawdown: Double {
abs(data.last?.drawdown ?? 0)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Drawdown Analysis")
.font(.headline)
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("Max Drawdown")
.font(.caption)
.foregroundColor(.secondary)
Text(String(format: "%.1f%%", maxDrawdown))
.font(.subheadline.weight(.semibold))
.foregroundColor(.negativeRed)
}
}
Text("Shows percentage decline from peak values")
.font(.caption)
.foregroundColor(.secondary)
if data.count >= 2 {
Chart {
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
let item = pair.element
AreaMark(
x: .value("Date", item.date),
y: .value("Drawdown", item.drawdown)
)
.foregroundStyle(
LinearGradient(
colors: [Color.negativeRed.opacity(0.5), Color.negativeRed.opacity(0.1)],
startPoint: .top,
endPoint: .bottom
)
)
.interpolationMethod(.catmullRom)
LineMark(
x: .value("Date", item.date),
y: .value("Drawdown", item.drawdown)
)
.foregroundStyle(Color.negativeRed)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Date", item.date),
y: .value("Drawdown", item.drawdown)
)
.foregroundStyle(Color.negativeRed)
.symbolSize(20)
.annotation(position: .bottom, spacing: 3) {
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
ChartValueBubble(text: ChartLabels.percent(item.drawdown), color: .negativeRed)
}
}
}
if let sel = selectedPoint {
RuleMark(x: .value("Selected", sel.date))
.foregroundStyle(Color.secondary.opacity(0.35))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
PointMark(
x: .value("Selected", sel.date),
y: .value("Drawdown", sel.drawdown)
)
.foregroundStyle(Color.negativeRed)
.symbolSize(70)
.annotation(position: .bottom, spacing: 4) {
ChartValueBubble(text: ChartLabels.percent(sel.drawdown), color: .negativeRed, prominent: true)
}
}
}
.chartXSelection(value: $selectedDate)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.chartYScale(domain: (data.map { $0.drawdown }.min() ?? -50)...0)
.frame(height: 250)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
// Statistics
HStack(spacing: 20) {
DrawdownStatView(
title: "Current",
value: String(format: "%.1f%%", currentDrawdown),
isHighlighted: currentDrawdown > maxDrawdown * 0.8
)
DrawdownStatView(
title: "Maximum",
value: String(format: "%.1f%%", maxDrawdown),
isHighlighted: true
)
DrawdownStatView(
title: "Average",
value: String(format: "%.1f%%", averageDrawdown),
isHighlighted: false
)
}
.padding(.top, 8)
ChartDataTable(
rows: drawdownTableRows,
valueHeader: "Drawdown",
deltaPrevHeader: "Δ prev",
deltaFirstHeader: "Δ first"
)
.padding(.top, 4)
} else {
Text("Not enough data for drawdown analysis")
.foregroundColor(.secondary)
.frame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var averageDrawdown: Double {
guard !data.isEmpty else { return 0 }
let sum = data.reduce(0.0) { $0 + abs($1.drawdown) }
return sum / Double(data.count)
}
private var drawdownTableRows: [ChartDataTableRow] {
data.enumerated().map { idx, point in
let val = abs(point.drawdown)
let prevVal = idx > 0 ? abs(data[idx - 1].drawdown) : val
let firstVal = abs(data.first?.drawdown ?? val)
let deltaPrev = -(val - prevVal) // negative delta = improvement (less drawdown)
let deltaFirst = -(val - firstVal)
return ChartDataTableRow(
label: chartTableDateLabel(point.date),
value: String(format: "%.1f%%", val),
deltaPrev: idx > 0 ? String(format: "%+.1f%%", deltaPrev) : nil,
deltaFirst: idx > 0 ? String(format: "%+.1f%%", deltaFirst) : nil,
isPrevPositive: deltaPrev >= 0,
isFirstPositive: deltaFirst >= 0
)
}
}
}
struct DrawdownStatView: View {
let title: String
let value: String
let isHighlighted: Bool
var body: some View {
VStack(spacing: 4) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundColor(isHighlighted ? .negativeRed : .primary)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Volatility Chart View
struct VolatilityChartView: View {
let data: [(date: Date, volatility: Double)]
@State private var zoom = ChartZoomModel()
@State private var selectedDate: Date?
@Environment(\.horizontalSizeClass) private var labelsSizeClass
private var labelsCompact: Bool { labelsSizeClass != .regular }
private var selectedPoint: (date: Date, volatility: Double)? {
guard let selectedDate else { return nil }
return data.min(by: {
abs($0.date.timeIntervalSince(selectedDate)) < abs($1.date.timeIntervalSince(selectedDate))
})
}
var currentVolatility: Double {
data.last?.volatility ?? 0
}
var averageVolatility: Double {
guard !data.isEmpty else { return 0 }
return data.reduce(0.0) { $0 + $1.volatility } / Double(data.count)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Volatility")
.font(.headline)
Spacer()
}
Text("Measures price variability over time")
.font(.caption)
.foregroundColor(.secondary)
if data.count >= 2 {
Chart {
ForEach(Array(data.enumerated()), id: \.element.date) { pair in
let item = pair.element
LineMark(
x: .value("Date", item.date),
y: .value("Volatility", item.volatility)
)
.foregroundStyle(Color.appPrimary)
.interpolationMethod(.catmullRom)
AreaMark(
x: .value("Date", item.date),
y: .value("Volatility", item.volatility)
)
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary.opacity(0.3), Color.appPrimary.opacity(0.0)],
startPoint: .top,
endPoint: .bottom
)
)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Date", item.date),
y: .value("Volatility", item.volatility)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(20)
.annotation(position: .top, spacing: 3) {
if ChartLabels.showsLabel(index: pair.offset, count: data.count, compact: labelsCompact) {
ChartValueBubble(text: String(format: "%.1f%%", item.volatility))
}
}
}
// Average line
RuleMark(y: .value("Average", averageVolatility))
.foregroundStyle(Color.gray.opacity(0.5))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
.annotation(position: .top, alignment: .leading) {
Text("Avg: \(String(format: "%.1f%%", averageVolatility))")
.font(.caption2)
.foregroundColor(.secondary)
}
if let sel = selectedPoint {
RuleMark(x: .value("Selected", sel.date))
.foregroundStyle(Color.secondary.opacity(0.35))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
PointMark(
x: .value("Selected", sel.date),
y: .value("Volatility", sel.volatility)
)
.foregroundStyle(Color.appPrimary)
.symbolSize(70)
.annotation(position: .top, spacing: 4) {
ChartValueBubble(text: String(format: "%.1f%%", sel.volatility), prominent: true)
}
}
}
.chartXSelection(value: $selectedDate)
.chartXAxis {
AxisMarks(values: .stride(by: .month, count: 2)) { value in
AxisValueLabel(format: .dateTime.month(.abbreviated))
}
}
.chartYAxis {
AxisMarks { value in
AxisGridLine()
AxisValueLabel {
if let doubleValue = value.as(Double.self) {
Text(String(format: "%.0f%%", doubleValue))
.font(.caption)
}
}
}
}
.frame(height: 250)
.zoomableTimeSeries(dates: data.map(\.date), zoom: $zoom)
ChartStatsRow(stats: [
ChartStat(label: "Current", value: String(format: "%.1f%%", currentVolatility), color: volatilityColor(currentVolatility)),
ChartStat(label: "Max", value: String(format: "%.1f%%", data.map { $0.volatility }.max() ?? 0), color: .negativeRed),
ChartStat(label: "Min", value: String(format: "%.1f%%", data.map { $0.volatility }.min() ?? 0), color: .positiveGreen),
ChartStat(label: "Average", value: String(format: "%.1f%%", averageVolatility), color: .secondary),
])
.padding(.top, 8)
ChartDataTable(
rows: volatilityTableRows,
valueHeader: "Volatility",
deltaPrevHeader: "Δ prev",
deltaFirstHeader: nil
)
} else {
Text("Not enough data for volatility analysis")
.foregroundColor(.secondary)
.frame(height: 250)
.frame(maxWidth: .infinity)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private func volatilityColor(_ volatility: Double) -> Color {
switch volatility {
case 0..<10:
return .positiveGreen
case 10..<20:
return .appWarning
default:
return .negativeRed
}
}
private var volatilityTableRows: [ChartDataTableRow] {
data.enumerated().map { idx, point in
let prevVal = idx > 0 ? data[idx - 1].volatility : point.volatility
let delta = point.volatility - prevVal
return ChartDataTableRow(
label: chartTableDateLabel(point.date),
value: String(format: "%.1f%%", point.volatility),
deltaPrev: idx > 0 ? String(format: "%+.1f%%", delta) : nil,
deltaFirst: nil,
isPrevPositive: delta <= 0,
isFirstPositive: false
)
}
}
}
struct VolatilityLevelView: View {
let level: String
let range: String
let color: Color
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 0) {
Text(level)
.font(.caption2)
Text(range)
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
}
#Preview {
let drawdownData: [(date: Date, drawdown: Double)] = [
(Date().adding(months: -6), -5),
(Date().adding(months: -5), -8),
(Date().adding(months: -4), -3),
(Date().adding(months: -3), -15),
(Date().adding(months: -2), -10),
(Date().adding(months: -1), -7),
(Date(), -4)
]
let volatilityData: [(date: Date, volatility: Double)] = [
(Date().adding(months: -6), 12),
(Date().adding(months: -5), 15),
(Date().adding(months: -4), 10),
(Date().adding(months: -3), 22),
(Date().adding(months: -2), 18),
(Date().adding(months: -1), 14),
(Date(), 11)
]
return VStack(spacing: 20) {
DrawdownChart(data: drawdownData)
VolatilityChartView(data: volatilityData)
}
.padding()
}