Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
117 lines
3.9 KiB
Dart
117 lines
3.9 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 || benchmarksSkipDays;
|
|
|
|
/// Only the index side: the portfolio's own gaps are already marked next to its number.
|
|
bool get benchmarksSkipDays => 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?);
|
|
}
|
|
}
|