f1aeafccf6
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
428 lines
16 KiB
Swift
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()
|
|
}
|