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 {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user