Files
fin-tracker/app/lib/features/portfolio/data/benchmarks_api.dart
T
Dmitry 7b419f4188 feat(app): offline-кэш на остальных экранах — фаза 5
accounts, cashflow, categories, goals, income, portfolio (+instrument),
rebalance, tax, rules переведены на Cached<T> по контракту
docs/ai/offline-cache.md. Общие/второстепенные селекторы (scopesProvider,
categoriesListProvider и т.п.) оставлены как есть — не основной контент
экрана. sync_page.dart и settings_page.dart не тронуты (первый — заменён
health-page отдельно, второй — чистые действия без списка для баннера).
2026-09-19 14:00:13 +03:00

111 lines
3.8 KiB
Dart

/// 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<String, dynamic> 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<BenchmarkResult> benchmarks;
bool get hasSkippedDays =>
portfolioDaysSkipped > 0 || benchmarks.any((b) => b.daysSkipped > 0);
static BenchmarkRow fromJson(Map<String, dynamic> 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<T>` directly
/// (there is no generated `Response<T>` for `BenchmarksCard` to unwrap `r.cached` from).
Future<Cached<List<BenchmarkRow>>> compare({
String scope = 'all',
List<String> periods = const ['1m', 'ytd', '1y', 'all'],
}) async {
final r = await _dio.get<Map<String, dynamic>>(
'/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?);
}
}