/// Hand-written client for `GET /api/v1/analytics/benchmarks`. /// /// **Temporary.** The benchmark routes are not in `openapi/openapi.json` yet, so /// `app/packages/api_client` has no generated models or methods for them. Everything here /// follows `docs/ai/phase4-contract.md` ยง3 literally and is meant to be **replaced by the /// generated client** once the routes land in the spec and `just gen-client` runs. /// /// Raw Dio comes from `ref.read(apiProvider).dio`. library; import 'package:dio/dio.dart'; import '../../../core/cache/cached.dart'; import '../../../core/utils/json.dart'; /// One benchmark inside one period row. class BenchmarkResult { const BenchmarkResult({ required this.benchmarkId, required this.code, required this.kind, this.twr, this.twrAnnualized, this.daysSkipped = 0, this.excess, }); final int benchmarkId; final String code; /// `total_return | price`. A **price** index does not include dividends and therefore /// systematically understates what a holder earned โ€” comparing against it without saying /// so is misleading, so the UI marks it. final String kind; final String? twr; final String? twrAnnualized; final int daysSkipped; /// `portfolio_twr - twr`, as the server computed it. final String? excess; bool get isPriceIndex => kind == 'price'; static BenchmarkResult fromJson(Map json) => BenchmarkResult( benchmarkId: asInt(json['benchmark_id']) ?? 0, code: asString(json['code']) ?? 'โ€”', kind: asString(json['kind']) ?? 'total_return', twr: asString(json['twr']), twrAnnualized: asString(json['twr_annualized']), daysSkipped: asInt(json['days_skipped']) ?? 0, excess: asString(json['excess']), ); } class BenchmarkRow { const BenchmarkRow({ required this.period, this.dateFrom, this.dateTo, this.portfolioTwr, this.portfolioTwrAnnualized, this.portfolioDaysSkipped = 0, this.benchmarks = const [], }); final String period; final DateTime? dateFrom; final DateTime? dateTo; final String? portfolioTwr; final String? portfolioTwrAnnualized; /// Days the portfolio series had to skip. Non-zero on either side means the two returns /// were not computed over the same set of days โ€” not a like-for-like comparison. final int portfolioDaysSkipped; final List benchmarks; bool get hasSkippedDays => portfolioDaysSkipped > 0 || benchmarks.any((b) => b.daysSkipped > 0); static BenchmarkRow fromJson(Map json) => BenchmarkRow( period: asString(json['period']) ?? 'all', dateFrom: asDate(json['date_from']), dateTo: asDate(json['date_to']), portfolioTwr: asString(json['portfolio_twr']), portfolioTwrAnnualized: asString(json['portfolio_twr_annualized']), portfolioDaysSkipped: asInt(json['portfolio_days_skipped']) ?? 0, benchmarks: asObjects(json['benchmarks']).map(BenchmarkResult.fromJson).toList(), ); } class BenchmarksApi { const BenchmarksApi(this._dio); final Dio _dio; /// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached` directly /// (there is no generated `Response` for `BenchmarksCard` to unwrap `r.cached` from). Future>> compare({ String scope = 'all', List periods = const ['1m', 'ytd', '1y', 'all'], }) async { final r = await _dio.get>( '/api/v1/analytics/benchmarks', // `period` is repeatable; Dio serialises a list as repeated query parameters. queryParameters: {'scope': scope, 'period': periods}, ); final rows = asObjects((r.data ?? const {})['rows']).map(BenchmarkRow.fromJson).toList(); return Cached(rows, fetchedAt: r.extra['fetchedAt'] as DateTime?); } }