f7d4fdbe2d
- Nuevo ChartValueLabels.swift: ChartValueBubble (cápsula) y ChartSelectionCard
(tarjeta multi-serie), con umbral compartido (<=12 puntos → labels SIEMPRE
visibles; las gráficas cortas aportan mucho más con los valores a la vista)
- PointMark añadido donde faltaba (Compare, Year vs Year, Drawdown, Volatility);
agrandado donde ya existía (Evolution, Prediction, Period vs Period, Rolling)
- Selección por tap/arrastre (chartXSelection) en las 8 gráficas: RuleMark + burbuja
prominente (una serie) o tarjeta con el valor de cada serie (multi-serie)
- Evolution reutiliza su scrubbing existente y ahora muestra burbuja en el punto
- Period vs Period: labels A arriba / B abajo para evitar solapes
- Drawdown/Volatility migrados de Chart(data:) a Chart{ForEach} para poder añadir
marcas de selección
Verificado en iPad Pro 13" (labels visibles en Evolution y Period vs Period).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
424 lines
16 KiB
Swift
424 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?
|
|
|
|
private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold }
|
|
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(data, id: \.date) { item in
|
|
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(showAllLabels ? 36 : 20)
|
|
.annotation(position: .bottom, spacing: 3) {
|
|
if showAllLabels {
|
|
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?
|
|
|
|
private var showAllLabels: Bool { data.count <= ChartLabels.alwaysShowThreshold }
|
|
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(data, id: \.date) { item in
|
|
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(showAllLabels ? 36 : 20)
|
|
.annotation(position: .top, spacing: 3) {
|
|
if showAllLabels {
|
|
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()
|
|
}
|