86c95a9e73
De 7 comentarios de TestFlight: - Labels de línea ilegibles (puntos 5+7): ChartLabels.showsLabel thin-out (máx 5 iPhone / 8 iPad, siempre primero y último). Charts multi-serie (Compare, Period, Year vs Year) etiquetan solo el ENDPOINT de cada serie. Prediction etiqueta solo el forecast final. Aplicado a Evolution, Rolling, Drawdown, Volatility, Compare, Period, YoY, Prediction. - 'Needs update' en meses pasados (punto 2): MonthlyCheckInView.snapshotForViewedMonth — el estado es por el MES QUE SE VE (effective month de referenceDate), no contra la última completion global. Diff usa el snapshot de ese mes. - Selector de periodo Performance (punto 6): slider 1..60 → picker segmentado consistente con el resto, opciones capadas a los meses de datos disponibles (availableHistoryMonths). Ya no deja pedir ventanas sin datos. - Home iPad/Mac (feedback previo): masonry con nº de columnas según ancho (2 iPad / 3 Mac ancho >=1250), reparto por peso, propio ScrollView. Pendiente de este lote: Goals no sincronizan (verificado modelo Goal CloudKit-OK → es el subset del partial-failure, no filtro ni esquema; necesita códigos internos del error), paste/OCR por campo (punto 3) y swipe entre charts (punto 4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
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()
|
|
}
|