feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков
Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income, /rebalance, /tax, аналитика-хаб с benchmarks_card) — по docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для загрузки отчёта.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/// 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/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;
|
||||
|
||||
Future<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},
|
||||
);
|
||||
return asObjects((r.data ?? const {})['rows']).map(BenchmarkRow.fromJson).toList();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user