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,183 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import 'data/benchmarks_api.dart';
|
||||
import 'labels.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
final benchmarksApiProvider =
|
||||
Provider<BenchmarksApi>((ref) => BenchmarksApi(ref.watch(apiProvider).dio));
|
||||
|
||||
/// Benchmark comparison for the current scope. Part of Портфель, not a screen of its own:
|
||||
/// «на сколько я обогнал индекс» is a property of the portfolio, not a separate subject.
|
||||
final benchmarkRowsProvider = FutureProvider.autoDispose<List<BenchmarkRow>>((ref) async {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
return ref.watch(benchmarksApiProvider).compare(scope: scope);
|
||||
});
|
||||
|
||||
/// The comparison block on Портфель.
|
||||
///
|
||||
/// Two things are always marked: a **price** index (no dividends — it understates the
|
||||
/// holder's result by construction) and any non-zero `days_skipped` on either side (the two
|
||||
/// returns then do not cover the same days, so the difference is not strictly like-for-like).
|
||||
class BenchmarksCard extends ConsumerWidget {
|
||||
const BenchmarksCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final rows = ref.watch(benchmarkRowsProvider);
|
||||
|
||||
return AsyncValueView(
|
||||
value: rows,
|
||||
onRetry: () => ref.invalidate(benchmarkRowsProvider),
|
||||
data: (data) => data.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.compare_arrows,
|
||||
message: 'Бенчмарки не настроены — сравнивать не с чем.',
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final row in data)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _PeriodBlock(row: row),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PeriodBlock extends StatelessWidget {
|
||||
const _PeriodBlock({required this.row});
|
||||
|
||||
final BenchmarkRow row;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(periodLabel(row.period), style: theme.textTheme.titleSmall),
|
||||
const SizedBox(width: 8),
|
||||
if (row.dateFrom != null && row.dateTo != null)
|
||||
Text(
|
||||
'${ruDate(row.dateFrom!)} – ${ruDate(row.dateTo!)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'портфель ${formatPercent(row.portfolioTwr)}',
|
||||
style: theme.textTheme.titleSmall
|
||||
?.copyWith(color: signColor(context, row.portfolioTwr)),
|
||||
),
|
||||
if (row.portfolioDaysSkipped > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
_SkippedChip(days: row.portfolioDaysSkipped, side: 'портфеля'),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
for (final b in row.benchmarks) _BenchmarkRowView(result: b),
|
||||
if (row.hasSkippedDays)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
'Сетка дат не полностью совпадает: часть дней пропущена, '
|
||||
'сравнение не строго like-for-like.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
|
||||
),
|
||||
),
|
||||
const Divider(height: 20),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BenchmarkRowView extends StatelessWidget {
|
||||
const _BenchmarkRowView({required this.result});
|
||||
|
||||
final BenchmarkResult result;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(result.code, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(width: 6),
|
||||
if (result.isPriceIndex) const _PriceIndexChip(),
|
||||
if (result.daysSkipped > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
_SkippedChip(days: result.daysSkipped, side: 'бенчмарка'),
|
||||
],
|
||||
const Spacer(),
|
||||
Text(formatPercent(result.twr), style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(width: 16),
|
||||
SizedBox(
|
||||
width: 92,
|
||||
child: Text(
|
||||
formatPercent(result.excess),
|
||||
textAlign: TextAlign.right,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(color: signColor(context, result.excess)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PriceIndexChip extends StatelessWidget {
|
||||
const _PriceIndexChip();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Tooltip(
|
||||
message: 'Ценовой индекс: не учитывает дивиденды и систематически занижает '
|
||||
'результат держателя. Сравнение с ним — нижняя граница, а не эталон.',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text('ценовой индекс', style: Theme.of(context).textTheme.labelSmall),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SkippedChip extends StatelessWidget {
|
||||
const _SkippedChip({required this.days, required this.side});
|
||||
|
||||
final int days;
|
||||
final String side;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: 'В расчёте $side пропущено $days дн. — в эти дни не было цены',
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 14),
|
||||
const SizedBox(width: 2),
|
||||
Text('$days дн.', style: Theme.of(context).textTheme.labelSmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import 'benchmarks_card.dart';
|
||||
import 'labels.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
@@ -59,6 +60,11 @@ class HoldingsTab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _Card(
|
||||
title: 'Сравнение с бенчмарками',
|
||||
child: BenchmarksCard(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_Card(
|
||||
title: 'Позиции',
|
||||
child: AsyncValueView(
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import 'benchmarks_card.dart' show benchmarkRowsProvider;
|
||||
|
||||
/// The reporting unit every portfolio screen is scoped to: `all`, `account:<id>` or
|
||||
/// `portfolio:<id>`. Held in one place so switching it on Позиции also switches Аллокация
|
||||
@@ -67,4 +68,6 @@ void invalidatePortfolioProviders(WidgetRef ref) {
|
||||
ref.invalidate(allocationProvider);
|
||||
ref.invalidate(valueSeriesProvider);
|
||||
ref.invalidate(instrumentProvider);
|
||||
// the benchmark block lives on Позиции and is scoped the same way
|
||||
ref.invalidate(benchmarkRowsProvider);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user