Files
fin-tracker/app/lib/features/rebalance/data/rebalance_api.dart
T
Dmitry 7b419f4188 feat(app): offline-кэш на остальных экранах — фаза 5
accounts, cashflow, categories, goals, income, portfolio (+instrument),
rebalance, tax, rules переведены на Cached<T> по контракту
docs/ai/offline-cache.md. Общие/второстепенные селекторы (scopesProvider,
categoriesListProvider и т.п.) оставлены как есть — не основной контент
экрана. sync_page.dart и settings_page.dart не тронуты (первый — заменён
health-page отдельно, второй — чистые действия без списка для баннера).
2026-09-19 14:00:13 +03:00

252 lines
8.4 KiB
Dart

/// 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/cache/cached.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.
/// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly
/// (there is no generated `Response<T>` for the screen to unwrap `r.cached` from).
Future<Cached<TargetSet>> getTargets(int portfolioId, {String dimension = 'asset_class'}) async {
final r = await _dio.get<Map<String, dynamic>>(
'$_base/$portfolioId/targets',
queryParameters: {'dimension': dimension},
);
return Cached(TargetSet.fromJson(r.data ?? const {}), fetchedAt: r.extra['fetchedAt'] as DateTime?);
}
/// 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<Cached<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 Cached(
RebalancePlan.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}
}