/// 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 toJson() => { 'bucket': bucket, 'target_weight': targetWeight, 'band': ?band, 'note': ?note, }; static TargetWeight fromJson(Map 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 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 toJson() => { 'dimension': dimension, 'targets': [for (final t in targets) t.toJson()], }; static TargetSet fromJson(Map 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 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 trades; static RebalanceBucket fromJson(Map 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 buckets; final List warnings; bool get everythingWithinBand => buckets.isNotEmpty && buckets.every((b) => b.withinBand); static RebalancePlan fromJson(Map 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 getTargets(int portfolioId, {String dimension = 'asset_class'}) async { final r = await _dio.get>( '$_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 putTargets(int portfolioId, TargetSet set) async { final r = await _dio.put>( '$_base/$portfolioId/targets', data: set.toJson(), ); return TargetSet.fromJson(r.data ?? const {}); } Future plan( int portfolioId, { String dimension = 'asset_class', String? cashAvailable, }) async { final r = await _dio.get>( '$_base/$portfolioId/rebalance', queryParameters: {'dimension': dimension, 'cash_available': ?cashAvailable}, ); return RebalancePlan.fromJson(r.data ?? const {}); } }