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,245 @@
|
||||
/// Hand-written client for `/api/v1/portfolios/{id}/targets` and `/rebalance`.
|
||||
///
|
||||
/// **Temporary.** These routes are not in `openapi/openapi.json` yet, so
|
||||
/// `app/packages/api_client` has no generated methods or models for them. Everything here
|
||||
/// follows `docs/ai/phase4-contract.md` §2 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`: base URL, bearer header and the one-shot
|
||||
/// refresh on 401 are already wired there.
|
||||
library;
|
||||
|
||||
import 'package:decimal/decimal.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../core/utils/json.dart';
|
||||
|
||||
/// The dimensions targets can be set along, as wire strings (`AssetClass` is never exposed
|
||||
/// as an enum — `index` cannot be a Dart enum member).
|
||||
const targetDimensions = ['asset_class', 'sector', 'country', 'currency'];
|
||||
|
||||
class TargetWeight {
|
||||
const TargetWeight({
|
||||
required this.bucket,
|
||||
required this.targetWeight,
|
||||
this.band,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final String bucket;
|
||||
|
||||
/// A share, not a percent: `"0.60"` is 60 %.
|
||||
final String targetWeight;
|
||||
final String? band;
|
||||
final String? note;
|
||||
|
||||
TargetWeight copyWith({String? bucket, String? targetWeight, String? band, String? note}) =>
|
||||
TargetWeight(
|
||||
bucket: bucket ?? this.bucket,
|
||||
targetWeight: targetWeight ?? this.targetWeight,
|
||||
band: band ?? this.band,
|
||||
note: note ?? this.note,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'bucket': bucket,
|
||||
'target_weight': targetWeight,
|
||||
'band': ?band,
|
||||
'note': ?note,
|
||||
};
|
||||
|
||||
static TargetWeight fromJson(Map<String, dynamic> json) => TargetWeight(
|
||||
bucket: asString(json['bucket']) ?? '',
|
||||
targetWeight: asString(json['target_weight']) ?? '0',
|
||||
band: asString(json['band']),
|
||||
note: asString(json['note']),
|
||||
);
|
||||
}
|
||||
|
||||
class TargetSet {
|
||||
const TargetSet({required this.dimension, this.targets = const [], this.weightsSum});
|
||||
|
||||
final String dimension;
|
||||
final List<TargetWeight> targets;
|
||||
|
||||
/// What the server computed. The client computes its own sum too — the user must see the
|
||||
/// problem before pressing Save, not after the 422 comes back.
|
||||
final String? weightsSum;
|
||||
|
||||
/// Exact sum of the weights as typed. `Decimal`, never `double`: 0.1 + 0.2 in binary
|
||||
/// floating point would make a perfectly valid set look broken.
|
||||
Decimal get localSum => sumDecimals(targets.map((t) => t.targetWeight));
|
||||
|
||||
/// The contract's tolerance: the sum must be 1 within 0.0001.
|
||||
bool get sumIsValid => (localSum - Decimal.one).abs() <= Decimal.parse('0.0001');
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'dimension': dimension,
|
||||
'targets': [for (final t in targets) t.toJson()],
|
||||
};
|
||||
|
||||
static TargetSet fromJson(Map<String, dynamic> json) => TargetSet(
|
||||
dimension: asString(json['dimension']) ?? 'asset_class',
|
||||
targets: asObjects(json['targets']).map(TargetWeight.fromJson).toList(),
|
||||
weightsSum: asString(json['weights_sum']),
|
||||
);
|
||||
}
|
||||
|
||||
class RebalanceTrade {
|
||||
const RebalanceTrade({
|
||||
required this.action,
|
||||
required this.blockedByCash,
|
||||
this.instrumentId,
|
||||
this.ticker,
|
||||
this.name,
|
||||
this.suggestedQty,
|
||||
this.lot,
|
||||
this.price,
|
||||
this.priceCurrency,
|
||||
this.amountRub,
|
||||
});
|
||||
|
||||
final int? instrumentId;
|
||||
final String? ticker;
|
||||
final String? name;
|
||||
|
||||
/// `buy | sell`.
|
||||
final String action;
|
||||
|
||||
/// Whole lots, always inside the available cash. **Null means there is no price** — the
|
||||
/// screen shows an em dash and the reason, never 0.
|
||||
final String? suggestedQty;
|
||||
final int? lot;
|
||||
final String? price;
|
||||
final String? priceCurrency;
|
||||
final String? amountRub;
|
||||
|
||||
/// The quantity was cut down because the cash ran out. Without this flag a user cannot
|
||||
/// tell an underweight recommendation from a wrong one.
|
||||
final bool blockedByCash;
|
||||
|
||||
String get title => ticker ?? name ?? (instrumentId == null ? '—' : '#$instrumentId');
|
||||
|
||||
static RebalanceTrade fromJson(Map<String, dynamic> json) => RebalanceTrade(
|
||||
instrumentId: asInt(json['instrument_id']),
|
||||
ticker: asString(json['ticker']),
|
||||
name: asString(json['name']),
|
||||
action: asString(json['action']) ?? 'buy',
|
||||
suggestedQty: asString(json['suggested_qty']),
|
||||
lot: asInt(json['lot']),
|
||||
price: asString(json['price']),
|
||||
priceCurrency: asString(json['price_currency']),
|
||||
amountRub: asString(json['amount_rub']),
|
||||
blockedByCash: asBool(json['blocked_by_cash']),
|
||||
);
|
||||
}
|
||||
|
||||
class RebalanceBucket {
|
||||
const RebalanceBucket({
|
||||
required this.bucket,
|
||||
required this.withinBand,
|
||||
this.currentValueRub,
|
||||
this.currentWeight,
|
||||
this.targetWeight,
|
||||
this.drift,
|
||||
this.deltaValueRub,
|
||||
this.trades = const [],
|
||||
});
|
||||
|
||||
final String bucket;
|
||||
final String? currentValueRub;
|
||||
final String? currentWeight;
|
||||
final String? targetWeight;
|
||||
|
||||
/// `current - target`, in shares.
|
||||
final String? drift;
|
||||
|
||||
/// `|drift| <= band` — no action needed, and saying so is the point.
|
||||
final bool withinBand;
|
||||
final String? deltaValueRub;
|
||||
final List<RebalanceTrade> trades;
|
||||
|
||||
static RebalanceBucket fromJson(Map<String, dynamic> json) => RebalanceBucket(
|
||||
bucket: asString(json['bucket']) ?? '',
|
||||
currentValueRub: asString(json['current_value_rub']),
|
||||
currentWeight: asString(json['current_weight']),
|
||||
targetWeight: asString(json['target_weight']),
|
||||
drift: asString(json['drift']),
|
||||
withinBand: asBool(json['within_band']),
|
||||
deltaValueRub: asString(json['delta_value_rub']),
|
||||
trades: asObjects(json['trades']).map(RebalanceTrade.fromJson).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
class RebalancePlan {
|
||||
const RebalancePlan({
|
||||
required this.portfolioId,
|
||||
required this.dimension,
|
||||
this.asOf,
|
||||
this.totalValueRub,
|
||||
this.cashAvailableRub,
|
||||
this.buckets = const [],
|
||||
this.warnings = const [],
|
||||
});
|
||||
|
||||
final int portfolioId;
|
||||
final String dimension;
|
||||
final DateTime? asOf;
|
||||
final String? totalValueRub;
|
||||
final String? cashAvailableRub;
|
||||
final List<RebalanceBucket> buckets;
|
||||
final List<String> warnings;
|
||||
|
||||
bool get everythingWithinBand => buckets.isNotEmpty && buckets.every((b) => b.withinBand);
|
||||
|
||||
static RebalancePlan fromJson(Map<String, dynamic> json) => RebalancePlan(
|
||||
portfolioId: asInt(json['portfolio_id']) ?? 0,
|
||||
dimension: asString(json['dimension']) ?? 'asset_class',
|
||||
asOf: asDate(json['as_of']),
|
||||
totalValueRub: asString(json['total_value_rub']),
|
||||
cashAvailableRub: asString(json['cash_available_rub']),
|
||||
buckets: asObjects(json['buckets']).map(RebalanceBucket.fromJson).toList(),
|
||||
warnings: asStrings(json['warnings']),
|
||||
);
|
||||
}
|
||||
|
||||
class RebalanceApi {
|
||||
const RebalanceApi(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
static const _base = '/api/v1/portfolios';
|
||||
|
||||
/// The contract does not spell out a query parameter for `GET .../targets`, but `PUT`
|
||||
/// is per-dimension, so the set has to be addressable per-dimension as well. Sending
|
||||
/// `dimension` is harmless for a server that ignores it and necessary for one that does
|
||||
/// not — revisit once the route is in the spec.
|
||||
Future<TargetSet> getTargets(int portfolioId, {String dimension = 'asset_class'}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'$_base/$portfolioId/targets',
|
||||
queryParameters: {'dimension': dimension},
|
||||
);
|
||||
return TargetSet.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
/// Full replacement of one dimension — partial updates are not supported by the contract.
|
||||
Future<TargetSet> putTargets(int portfolioId, TargetSet set) async {
|
||||
final r = await _dio.put<Map<String, dynamic>>(
|
||||
'$_base/$portfolioId/targets',
|
||||
data: set.toJson(),
|
||||
);
|
||||
return TargetSet.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
Future<RebalancePlan> plan(
|
||||
int portfolioId, {
|
||||
String dimension = 'asset_class',
|
||||
String? cashAvailable,
|
||||
}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'$_base/$portfolioId/rebalance',
|
||||
queryParameters: {'dimension': dimension, 'cash_available': ?cashAvailable},
|
||||
);
|
||||
return RebalancePlan.fromJson(r.data ?? const {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import '../portfolio/labels.dart' show assetClassLabels;
|
||||
|
||||
/// Bucket labels keyed by the **wire** dimension string.
|
||||
///
|
||||
/// `features/portfolio/labels.dart` already has `bucketLabel`, but it takes the generated
|
||||
/// `AllocationDimension` enum, and the rebalance routes are not in the spec yet — this
|
||||
/// layer only has the plain string. Once `just gen-client` produces the enum, this helper
|
||||
/// should give way to the existing one.
|
||||
String bucketLabelForKey(String dimension, String bucket) {
|
||||
if (bucket == 'cash') return 'Денежные средства';
|
||||
if (bucket == 'unknown') return 'Не указано';
|
||||
if (bucket.isEmpty) return '—';
|
||||
return dimension == 'asset_class' ? (assetClassLabels[bucket] ?? bucket) : bucket;
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
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 '../../core/widgets/section_card.dart';
|
||||
import '../portfolio/labels.dart' show formatQty, signColor;
|
||||
import 'data/rebalance_api.dart';
|
||||
import 'labels.dart';
|
||||
import 'providers.dart';
|
||||
import 'weights.dart';
|
||||
|
||||
/// Рекомендации: what to buy and sell to get back to the target weights.
|
||||
///
|
||||
/// Three things are marked explicitly, because without them the numbers mislead:
|
||||
/// `within_band` (no action needed — the drift is inside the corridor the user set),
|
||||
/// `blocked_by_cash` (the quantity was cut because the cash ran out), and a null
|
||||
/// `suggested_qty` (there is no price, so no quantity can be computed — an em dash, not 0).
|
||||
class RebalancePlanTab extends ConsumerWidget {
|
||||
const RebalancePlanTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final plan = ref.watch(rebalancePlanProvider);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(rebalancePlanProvider),
|
||||
child: AsyncValueView(
|
||||
value: plan,
|
||||
onRetry: () => ref.invalidate(rebalancePlanProvider),
|
||||
data: (data) {
|
||||
if (data == null) {
|
||||
return const EmptyState(
|
||||
icon: Icons.balance,
|
||||
message: 'Портфель не выбран.',
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_Header(plan: data),
|
||||
if (data.warnings.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_Warnings(warnings: data.warnings),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (data.buckets.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 32),
|
||||
child: EmptyState(
|
||||
icon: Icons.balance,
|
||||
message: 'Нечего показать: целевые веса по этому измерению не заданы.',
|
||||
),
|
||||
)
|
||||
else if (data.everythingWithinBand)
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.check_circle_outline),
|
||||
title: Text('Все группы внутри коридора'),
|
||||
subtitle: Text('Действий не требуется — отклонения меньше заданного допуска.'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final b in data.buckets)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _BucketCard(dimension: data.dimension, bucket: b),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends ConsumerWidget {
|
||||
const _Header({required this.plan});
|
||||
|
||||
final RebalancePlan plan;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final whatIf = ref.watch(whatIfCashProvider);
|
||||
return SectionCard(
|
||||
title: 'Портфель',
|
||||
subtitle: plan.asOf == null ? null : 'на ${ruDate(plan.asOf!)}',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 24,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_Figure(
|
||||
label: 'Стоимость',
|
||||
child: plan.totalValueRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(plan.totalValueRub!, currency: 'RUB'),
|
||||
),
|
||||
_Figure(
|
||||
label: whatIf == null ? 'Доступно денег' : 'Доступно денег (what-if)',
|
||||
child: plan.cashAvailableRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(plan.cashAvailableRub!, currency: 'RUB'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_WhatIfCashField(current: whatIf),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Figure extends StatelessWidget {
|
||||
const _Figure({required this.label, required this.child});
|
||||
|
||||
final String label;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.bodySmall),
|
||||
DefaultTextStyle(style: theme.textTheme.titleMedium!, child: child),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WhatIfCashField extends ConsumerStatefulWidget {
|
||||
const _WhatIfCashField({required this.current});
|
||||
|
||||
final String? current;
|
||||
|
||||
@override
|
||||
ConsumerState<_WhatIfCashField> createState() => _WhatIfCashFieldState();
|
||||
}
|
||||
|
||||
class _WhatIfCashFieldState extends ConsumerState<_WhatIfCashField> {
|
||||
late final TextEditingController _controller =
|
||||
TextEditingController(text: widget.current ?? '');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _apply() {
|
||||
final text = _controller.text.trim().replaceAll(',', '.').replaceAll(' ', '');
|
||||
ref.read(whatIfCashProvider.notifier).state = text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Считать по другой сумме денег, ₽',
|
||||
isDense: true,
|
||||
helperText: 'пусто — реальный остаток на счетах',
|
||||
),
|
||||
onSubmitted: (_) => _apply(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(onPressed: _apply, child: const Text('Пересчитать')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Warnings extends StatelessWidget {
|
||||
const _Warnings({required this.warnings});
|
||||
|
||||
final List<String> warnings;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: Theme.of(context).colorScheme.tertiaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final w in warnings)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_outlined, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(w)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BucketCard extends StatelessWidget {
|
||||
const _BucketCard({required this.dimension, required this.bucket});
|
||||
|
||||
final String dimension;
|
||||
final RebalanceBucket bucket;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SectionCard(
|
||||
title: bucketLabelForKey(dimension, bucket.bucket),
|
||||
subtitle: 'сейчас ${formatShareAsPercent(bucket.currentWeight)} · '
|
||||
'цель ${formatShareAsPercent(bucket.targetWeight)} · '
|
||||
'отклонение ${formatShareAsPercent(bucket.drift, signed: true)}',
|
||||
trailing: bucket.withinBand
|
||||
? const _WithinBandChip()
|
||||
: (bucket.deltaValueRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(
|
||||
bucket.deltaValueRub!,
|
||||
currency: 'RUB',
|
||||
style: TextStyle(color: signColor(context, bucket.deltaValueRub)),
|
||||
)),
|
||||
child: bucket.withinBand
|
||||
? Text(
|
||||
'Внутри коридора — сделок не требуется.',
|
||||
style: theme.textTheme.bodySmall,
|
||||
)
|
||||
: bucket.trades.isEmpty
|
||||
? Text(
|
||||
'Сервер не предложил сделок по этой группе: '
|
||||
'вероятно, у подходящих бумаг нет цены — см. предупреждения выше.',
|
||||
style: theme.textTheme.bodySmall,
|
||||
)
|
||||
: Column(children: [for (final t in bucket.trades) TradeRow(trade: t)]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WithinBandChip extends StatelessWidget {
|
||||
const _WithinBandChip();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Tooltip(
|
||||
message: 'Отклонение не выходит за допуск (band) — действий не требуется',
|
||||
child: Chip(
|
||||
avatar: const Icon(Icons.check, size: 16),
|
||||
label: const Text('в коридоре'),
|
||||
backgroundColor: scheme.secondaryContainer,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One suggested trade. Public (unlike its neighbours) so the widget test can render the
|
||||
/// `suggested_qty == null` case without standing up a provider container.
|
||||
class TradeRow extends StatelessWidget {
|
||||
const TradeRow({required this.trade, super.key});
|
||||
|
||||
final RebalanceTrade trade;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isBuy = trade.action == 'buy';
|
||||
final noQty = trade.suggestedQty == null;
|
||||
|
||||
final details = [
|
||||
if (trade.lot != null) 'лот ${trade.lot}',
|
||||
if (trade.price != null)
|
||||
'цена ${MoneyText.format(trade.price!, trade.priceCurrency ?? 'RUB')}',
|
||||
].join(' · ');
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
onTap: trade.instrumentId == null
|
||||
? null
|
||||
: () => context.push('/portfolio/instrument/${trade.instrumentId}'),
|
||||
leading: Icon(
|
||||
isBuy ? Icons.add_circle_outline : Icons.remove_circle_outline,
|
||||
color: isBuy ? Theme.of(context).colorScheme.primary : theme.colorScheme.error,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Flexible(child: Text(trade.title, overflow: TextOverflow.ellipsis)),
|
||||
const SizedBox(width: 8),
|
||||
if (trade.blockedByCash) const _BlockedByCashChip(),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
// a missing price is a reason, not a zero quantity
|
||||
noQty
|
||||
? 'Количество не рассчитано: нет цены инструмента'
|
||||
: '${isBuy ? 'Купить' : 'Продать'} ${formatQty(trade.suggestedQty!)} шт.'
|
||||
'${details.isEmpty ? '' : ' · $details'}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
trailing: noQty || trade.amountRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(trade.amountRub!, currency: 'RUB', style: theme.textTheme.titleSmall),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockedByCashChip extends StatelessWidget {
|
||||
const _BlockedByCashChip();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Tooltip(
|
||||
message: 'Количество урезано: на счетах не хватает денег на полный объём',
|
||||
child: Chip(
|
||||
avatar: const Icon(Icons.account_balance_wallet_outlined, size: 16),
|
||||
label: const Text('не хватает денег'),
|
||||
backgroundColor: scheme.errorContainer,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../portfolio/providers.dart' show scopeProvider, scopesProvider;
|
||||
import 'data/rebalance_api.dart';
|
||||
|
||||
final rebalanceApiProvider =
|
||||
Provider<RebalanceApi>((ref) => RebalanceApi(ref.watch(apiProvider).dio));
|
||||
|
||||
/// A portfolio the user can rebalance, derived from the scope list the analytics API
|
||||
/// already publishes (`portfolio:<id>`): there is no separate portfolios endpoint, and
|
||||
/// inventing one would be a contract of its own.
|
||||
class PortfolioRef {
|
||||
const PortfolioRef(this.id, this.name);
|
||||
final int id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
final portfoliosProvider = FutureProvider.autoDispose<List<PortfolioRef>>((ref) async {
|
||||
final scopes = await ref.watch(scopesProvider.future);
|
||||
return [
|
||||
for (final s in scopes)
|
||||
if (s.scope.startsWith('portfolio:'))
|
||||
PortfolioRef(int.parse(s.scope.substring('portfolio:'.length)), s.name),
|
||||
];
|
||||
});
|
||||
|
||||
/// The portfolio the rebalance screen is working on. Null until the list loads; seeded from
|
||||
/// the app-wide scope when that scope already names a portfolio.
|
||||
final selectedPortfolioProvider = StateProvider<int?>((ref) {
|
||||
final scope = ref.watch(scopeProvider);
|
||||
if (!scope.startsWith('portfolio:')) return null;
|
||||
return int.tryParse(scope.substring('portfolio:'.length));
|
||||
});
|
||||
|
||||
final targetDimensionProvider = StateProvider<String>((ref) => 'asset_class');
|
||||
|
||||
/// What-if cash for the recommendations, as a decimal string. Null = use the real balance.
|
||||
final whatIfCashProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
final targetsProvider = FutureProvider.autoDispose<TargetSet>((ref) async {
|
||||
final id = ref.watch(selectedPortfolioProvider);
|
||||
final dimension = ref.watch(targetDimensionProvider);
|
||||
if (id == null) return TargetSet(dimension: dimension);
|
||||
return ref.watch(rebalanceApiProvider).getTargets(id, dimension: dimension);
|
||||
});
|
||||
|
||||
final rebalancePlanProvider = FutureProvider.autoDispose<RebalancePlan?>((ref) async {
|
||||
final id = ref.watch(selectedPortfolioProvider);
|
||||
final dimension = ref.watch(targetDimensionProvider);
|
||||
final cash = ref.watch(whatIfCashProvider);
|
||||
if (id == null) return null;
|
||||
return ref.watch(rebalanceApiProvider).plan(id, dimension: dimension, cashAvailable: cash);
|
||||
});
|
||||
|
||||
/// After a successful PUT the numbers are recomputed on the server, so both sides of the
|
||||
/// screen are refetched rather than patched in place.
|
||||
void invalidateRebalanceProviders(WidgetRef ref) {
|
||||
ref.invalidate(targetsProvider);
|
||||
ref.invalidate(rebalancePlanProvider);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../portfolio/labels.dart' show dimensionLabels;
|
||||
import 'data/rebalance_api.dart';
|
||||
import 'plan_tab.dart';
|
||||
import 'providers.dart';
|
||||
import 'targets_tab.dart';
|
||||
|
||||
/// Ребалансировка: the target weights on one tab, the resulting recommendations on the
|
||||
/// other. Both are per portfolio and per dimension, so the pickers live in the app bar and
|
||||
/// drive both tabs at once.
|
||||
class RebalancePage extends ConsumerWidget {
|
||||
const RebalancePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final portfolios = ref.watch(portfoliosProvider);
|
||||
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Ребалансировка'),
|
||||
actions: [
|
||||
const _PortfolioSelector(),
|
||||
const _DimensionSelector(),
|
||||
IconButton(
|
||||
tooltip: 'Обновить',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => invalidateRebalanceProviders(ref),
|
||||
),
|
||||
],
|
||||
bottom: const TabBar(
|
||||
tabs: [Tab(text: 'Целевые веса'), Tab(text: 'Рекомендации')],
|
||||
),
|
||||
),
|
||||
body: AsyncValueView(
|
||||
value: portfolios,
|
||||
onRetry: () => ref.invalidate(portfoliosProvider),
|
||||
data: (list) {
|
||||
if (list.isEmpty) {
|
||||
return const EmptyState(
|
||||
icon: Icons.pie_chart_outline,
|
||||
message: 'Портфелей пока нет.\n'
|
||||
'Ребалансировка считается по портфелю: заведите его в настройках счетов.',
|
||||
);
|
||||
}
|
||||
final selected = ref.watch(selectedPortfolioProvider);
|
||||
if (selected == null || !list.any((p) => p.id == selected)) {
|
||||
// pick the first portfolio once, after the list is known
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(selectedPortfolioProvider.notifier).state = list.first.id;
|
||||
});
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return const TabBarView(children: [TargetsTab(), RebalancePlanTab()]);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PortfolioSelector extends ConsumerWidget {
|
||||
const _PortfolioSelector();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final portfolios = ref.watch(portfoliosProvider).valueOrNull ?? const <PortfolioRef>[];
|
||||
final selected = ref.watch(selectedPortfolioProvider);
|
||||
if (portfolios.length < 2 || selected == null) return const SizedBox.shrink();
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: portfolios.any((p) => p.id == selected) ? selected : portfolios.first.id,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
items: [
|
||||
for (final p in portfolios)
|
||||
DropdownMenuItem(value: p.id, child: Text(p.name, overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) ref.read(selectedPortfolioProvider.notifier).state = v;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DimensionSelector extends ConsumerWidget {
|
||||
const _DimensionSelector();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final current = ref.watch(targetDimensionProvider);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: current,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
items: [
|
||||
for (final d in targetDimensions)
|
||||
DropdownMenuItem(value: d, child: Text(dimensionLabels[d] ?? d)),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) ref.read(targetDimensionProvider.notifier).state = v;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import 'package:decimal/decimal.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/utils/json.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/section_card.dart';
|
||||
import '../portfolio/labels.dart' show assetClassLabels;
|
||||
import 'data/rebalance_api.dart';
|
||||
import 'labels.dart';
|
||||
import 'providers.dart';
|
||||
import 'weights.dart';
|
||||
|
||||
/// Целевые веса: the editable target set for one dimension.
|
||||
///
|
||||
/// The sum of the weights is shown permanently and Save is blocked whenever it differs from
|
||||
/// 100 % by more than 0,01 pp. The server answers 422 in that case — but a user should see
|
||||
/// the problem while typing, not after a round trip.
|
||||
class TargetsTab extends ConsumerStatefulWidget {
|
||||
const TargetsTab({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TargetsTab> createState() => _TargetsTabState();
|
||||
}
|
||||
|
||||
class _TargetsTabState extends ConsumerState<TargetsTab> {
|
||||
/// Local draft. Seeded from the server set and reseeded whenever the portfolio or the
|
||||
/// dimension changes — editing one dimension must never leak into another.
|
||||
List<TargetWeight>? _draft;
|
||||
String? _seededFor;
|
||||
bool _saving = false;
|
||||
bool _dirty = false;
|
||||
|
||||
void _seed(TargetSet set, String key) {
|
||||
if (_seededFor == key) return;
|
||||
_seededFor = key;
|
||||
_draft = [...set.targets];
|
||||
_dirty = false;
|
||||
}
|
||||
|
||||
Decimal get _sum => sumDecimals((_draft ?? const []).map((t) => t.targetWeight));
|
||||
|
||||
bool get _sumIsValid => (_sum - Decimal.one).abs() <= Decimal.parse('0.0001');
|
||||
|
||||
Future<void> _save() async {
|
||||
final portfolioId = ref.read(selectedPortfolioProvider);
|
||||
final dimension = ref.read(targetDimensionProvider);
|
||||
final draft = _draft;
|
||||
if (portfolioId == null || draft == null) return;
|
||||
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref
|
||||
.read(rebalanceApiProvider)
|
||||
.putTargets(portfolioId, TargetSet(dimension: dimension, targets: draft));
|
||||
if (!mounted) return;
|
||||
_dirty = false;
|
||||
_seededFor = null; // refetch reseeds the draft from the saved set
|
||||
invalidateRebalanceProviders(ref);
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Целевые веса сохранены')));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final portfolioId = ref.watch(selectedPortfolioProvider);
|
||||
final dimension = ref.watch(targetDimensionProvider);
|
||||
final targets = ref.watch(targetsProvider);
|
||||
|
||||
return AsyncValueView(
|
||||
value: targets,
|
||||
onRetry: () => ref.invalidate(targetsProvider),
|
||||
data: (set) {
|
||||
_seed(set, '$portfolioId/$dimension');
|
||||
final draft = _draft ?? const <TargetWeight>[];
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 88),
|
||||
children: [
|
||||
_SumBanner(sum: _sum, valid: _sumIsValid, serverSum: set.weightsSum),
|
||||
const SizedBox(height: 12),
|
||||
SectionCard(
|
||||
title: 'Целевые веса',
|
||||
subtitle: 'Вес — доля портфеля; допуск (band) — ширина коридора, '
|
||||
'внутри которого сделки не предлагаются.',
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < draft.length; i++)
|
||||
_TargetRow(
|
||||
key: ValueKey('${_seededFor}_$i'),
|
||||
dimension: dimension,
|
||||
target: draft[i],
|
||||
onChanged: (t) => setState(() {
|
||||
_draft![i] = t;
|
||||
_dirty = true;
|
||||
}),
|
||||
onRemove: () => setState(() {
|
||||
_draft!.removeAt(i);
|
||||
_dirty = true;
|
||||
}),
|
||||
),
|
||||
if (draft.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text('Целевые веса ещё не заданы.'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(() {
|
||||
_draft = [
|
||||
...draft,
|
||||
const TargetWeight(bucket: '', targetWeight: '0', band: '0.05'),
|
||||
];
|
||||
_dirty = true;
|
||||
}),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Добавить группу'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: _saving || !_sumIsValid || draft.any((t) => t.bucket.isEmpty)
|
||||
? null
|
||||
: _save,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.save_outlined),
|
||||
label: const Text('Сохранить'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (_dirty)
|
||||
TextButton(
|
||||
onPressed: () => setState(() {
|
||||
_seededFor = null;
|
||||
_seed(set, '$portfolioId/$dimension');
|
||||
}),
|
||||
child: const Text('Отменить изменения'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (!_sumIsValid)
|
||||
Text(
|
||||
'Сохранение заблокировано: сумма весов должна быть ровно 100 %. '
|
||||
'Сервер не нормализует веса — сумма 90 % означает, что 10 % портфеля '
|
||||
'не отнесены ни к одной группе, а не что доли надо растянуть.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
)
|
||||
else if (draft.any((t) => t.bucket.isEmpty))
|
||||
Text(
|
||||
'У одной из групп пустое имя — сохранить нельзя.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SumBanner extends StatelessWidget {
|
||||
const _SumBanner({required this.sum, required this.valid, this.serverSum});
|
||||
|
||||
final Decimal sum;
|
||||
final bool valid;
|
||||
final String? serverSum;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Card(
|
||||
color: valid ? scheme.secondaryContainer : scheme.errorContainer,
|
||||
child: ListTile(
|
||||
leading: Icon(valid ? Icons.check_circle_outline : Icons.error_outline),
|
||||
title: Text('Сумма весов: ${formatShareAsPercent(sum.toString())}'),
|
||||
subtitle: Text(
|
||||
valid
|
||||
? 'Набор можно сохранить.'
|
||||
: 'Нужно ровно 100 %. Разница: '
|
||||
'${formatShareAsPercent((sum - Decimal.one).toString(), signed: true)}',
|
||||
),
|
||||
trailing: serverSum == null
|
||||
? null
|
||||
: Tooltip(
|
||||
message: 'Последняя сумма, сохранённая на сервере',
|
||||
child: Text(formatShareAsPercent(serverSum)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TargetRow extends StatefulWidget {
|
||||
const _TargetRow({
|
||||
required this.dimension,
|
||||
required this.target,
|
||||
required this.onChanged,
|
||||
required this.onRemove,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String dimension;
|
||||
final TargetWeight target;
|
||||
final ValueChanged<TargetWeight> onChanged;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
@override
|
||||
State<_TargetRow> createState() => _TargetRowState();
|
||||
}
|
||||
|
||||
class _TargetRowState extends State<_TargetRow> {
|
||||
late final TextEditingController _weight =
|
||||
TextEditingController(text: shareToPercentText(widget.target.targetWeight));
|
||||
late final TextEditingController _band =
|
||||
TextEditingController(text: shareToPercentText(widget.target.band));
|
||||
late final TextEditingController _bucket =
|
||||
TextEditingController(text: widget.target.bucket);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_weight.dispose();
|
||||
_band.dispose();
|
||||
_bucket.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// The known asset classes are offered as a list; every other dimension (sector, country,
|
||||
// currency) has an open set of buckets that only the data knows, so those stay free text.
|
||||
final knownBuckets = widget.dimension == 'asset_class'
|
||||
? [...assetClassLabels.keys, 'cash']
|
||||
: const <String>[];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: knownBuckets.isEmpty
|
||||
? TextField(
|
||||
controller: _bucket,
|
||||
decoration: const InputDecoration(labelText: 'Группа', isDense: true),
|
||||
onChanged: (v) => widget.onChanged(widget.target.copyWith(bucket: v)),
|
||||
)
|
||||
: DropdownButtonFormField<String>(
|
||||
initialValue:
|
||||
knownBuckets.contains(widget.target.bucket) ? widget.target.bucket : null,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Группа', isDense: true),
|
||||
items: [
|
||||
for (final b in knownBuckets)
|
||||
DropdownMenuItem(value: b, child: Text(bucketLabelForKey('asset_class', b))),
|
||||
],
|
||||
onChanged: (v) =>
|
||||
widget.onChanged(widget.target.copyWith(bucket: v ?? '')),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
controller: _weight,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Вес, %', isDense: true),
|
||||
onChanged: (v) {
|
||||
final share = percentTextToShare(v);
|
||||
widget.onChanged(widget.target.copyWith(targetWeight: share ?? '0'));
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
controller: _band,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Допуск, %', isDense: true),
|
||||
onChanged: (v) =>
|
||||
widget.onChanged(widget.target.copyWith(band: percentTextToShare(v))),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Удалить группу',
|
||||
onPressed: widget.onRemove,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:decimal/decimal.dart';
|
||||
|
||||
/// Weights travel as shares (`"0.60"` = 60 %) and are edited as percents. The conversion is
|
||||
/// exact on both sides: `Decimal`, never `double`, so 33,33 % round-trips unchanged and a
|
||||
/// set that sums to exactly 1 does not fail validation because of binary floating point.
|
||||
|
||||
final _hundred = Decimal.fromInt(100);
|
||||
|
||||
/// `"0.605"` → `"60,5"`; empty for anything unparseable.
|
||||
String shareToPercentText(String? share) {
|
||||
final d = share == null ? null : Decimal.tryParse(share);
|
||||
if (d == null) return '';
|
||||
final p = d * _hundred;
|
||||
final text = p == p.truncate() ? p.truncate().toString() : p.toString();
|
||||
return text.replaceAll('.', ',');
|
||||
}
|
||||
|
||||
/// `"60,5"` → `"0.605"`; null when the text is not a number.
|
||||
String? percentTextToShare(String text) {
|
||||
final normalized = text.trim().replaceAll(',', '.').replaceAll(' ', '');
|
||||
if (normalized.isEmpty) return null;
|
||||
final d = Decimal.tryParse(normalized);
|
||||
if (d == null) return null;
|
||||
return (d / _hundred).toDecimal(scaleOnInfinitePrecision: 10).toString();
|
||||
}
|
||||
|
||||
/// `"0.032"` → `"3,20 %"`, signed. Kept local to the rebalance screen because drift is a
|
||||
/// share, not a return, and the portfolio helper would sign it the same way by accident.
|
||||
String formatShareAsPercent(String? share, {bool signed = false, int digits = 2}) {
|
||||
final d = share == null ? null : Decimal.tryParse(share);
|
||||
if (d == null) return '—';
|
||||
final p = (d * _hundred).toDouble();
|
||||
final sign = signed && p > 0 ? '+' : '';
|
||||
return '$sign${p.toStringAsFixed(digits).replaceAll('.', ',')} %';
|
||||
}
|
||||
Reference in New Issue
Block a user