from typing import List, Sequence, Set from .invest_portfolio_weights_types import HoldingWeight, PortfolioHolding, PortfolioWeights from .money_amount import money def _round_half_away(n: int, d: int) -> int: """n / d rounded half away from zero; d > 0.""" a = -n if n < 0 else n q, r = divmod(a, d) if 2 * r >= d: q += 1 return -q if n < 0 else q def _is_int(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) def portfolio_weights(holdings: Sequence[PortfolioHolding]) -> PortfolioWeights: """Each holding's weight, target and drift. Everything is derived from the integer values and rounded once at the end, so a weight is never computed from another rounded figure.""" if len(holdings) == 0: raise ValueError("holdings must not be empty") currency = holdings[0].value.currency seen: Set[str] = set() total = 0 targets = 0 for h in holdings: if h.id in seen: raise ValueError('duplicate holding id "%s"' % h.id) seen.add(h.id) if h.value.currency != currency: raise ValueError("currency mismatch: %s and %s" % (currency, h.value.currency)) if not _is_int(h.value.minor) or h.value.minor < 0: raise ValueError( 'holding values must be whole minor units, 0 or more; received %s for "%s"' % (h.value.minor, h.id) ) t = h.target_basis_points if not _is_int(t) or t < 0 or t > 10000: raise ValueError( 'targetBasisPoints must be a whole number from 0 to 10000; received %s for "%s"' % (t, h.id) ) total += h.value.minor targets += t if targets != 10000: raise ValueError("targets must sum to 10000 basis points, received %d" % targets) if total == 0: raise ValueError("the portfolio total must be greater than zero") max_abs = 0 rows: List[HoldingWeight] = [] for h in holdings: value = h.value.minor target = h.target_basis_points drift = _round_half_away(value * 10000 - target * total, total) target_value = _round_half_away(total * target, 10000) max_abs = max(max_abs, abs(drift)) rows.append( HoldingWeight( id=h.id, value=money(value, currency), weight_basis_points=_round_half_away(value * 10000, total), target_basis_points=target, drift_basis_points=drift, target_value=money(target_value, currency), drift_value=money(value - target_value, currency), ) ) return PortfolioWeights(total=money(total, currency), holdings=rows, max_abs_drift_basis_points=max_abs)