feat: add shared account capital allocation policy
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
"""Pure account-capital allocation policy shared by TREX strategies.
|
||||
|
||||
The domain owns only deterministic allocation math. Broker adapters own cash
|
||||
and quote retrieval, while Portfolio Hub remains the portfolio-ledger owner.
|
||||
No network, persistence, environment, or strategy dependency is allowed here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, ROUND_CEILING
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapitalCandidate:
|
||||
"""One strategy candidate priced at a protected one-share cost."""
|
||||
|
||||
portfolio_id: str
|
||||
symbol: str
|
||||
protected_share_cost: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapitalAllocation:
|
||||
"""One candidate's deterministic allocation decision."""
|
||||
|
||||
portfolio_id: str
|
||||
symbol: str
|
||||
cap: int
|
||||
reason: str
|
||||
|
||||
@property
|
||||
def eligible(self) -> bool:
|
||||
return self.cap > 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapitalAllocationPlan:
|
||||
"""Account-level result that can be consumed by any TREX strategy."""
|
||||
|
||||
available_cash: int
|
||||
slot_count: int
|
||||
allocations: tuple[CapitalAllocation, ...]
|
||||
|
||||
@property
|
||||
def caps(self) -> dict[str, int]:
|
||||
return {
|
||||
allocation.portfolio_id: allocation.cap
|
||||
for allocation in self.allocations
|
||||
}
|
||||
|
||||
|
||||
def protected_share_cost(price: Decimal, slippage_pct: Decimal) -> int:
|
||||
"""Return the ceiling KRW cost of one share including execution margin."""
|
||||
if (
|
||||
not price.is_finite()
|
||||
or not slippage_pct.is_finite()
|
||||
or price <= 0
|
||||
or slippage_pct < 0
|
||||
):
|
||||
raise ValueError(
|
||||
"price must be finite and positive and slippage must be finite "
|
||||
"and non-negative"
|
||||
)
|
||||
multiplier = Decimal("1") + slippage_pct / Decimal("100")
|
||||
return int(
|
||||
(price * multiplier).to_integral_value(rounding=ROUND_CEILING)
|
||||
)
|
||||
|
||||
|
||||
def plan_liquid_capital(
|
||||
available_cash: int,
|
||||
*,
|
||||
slot_count: int,
|
||||
candidates: tuple[CapitalCandidate, ...],
|
||||
flex_share_count: int = 1,
|
||||
) -> CapitalAllocationPlan:
|
||||
"""Plan candidate caps whose worst active-slot combination fits cash.
|
||||
|
||||
The starting point is an equal cash budget per slot. A limited amount,
|
||||
measured in units of the cheapest protected share, may move between slots
|
||||
so that a slightly more expensive candidate remains eligible. A candidate
|
||||
that needs more than that margin is excluded.
|
||||
|
||||
After the first screen, the highest protected-share cost is removed until
|
||||
every possible active-slot combination can reserve at least one share
|
||||
without exceeding real account cash. Remaining candidates receive the
|
||||
largest common cap that preserves that invariant.
|
||||
"""
|
||||
_validate_inputs(
|
||||
available_cash,
|
||||
slot_count=slot_count,
|
||||
candidates=candidates,
|
||||
flex_share_count=flex_share_count,
|
||||
)
|
||||
if available_cash == 0:
|
||||
return CapitalAllocationPlan(
|
||||
available_cash=available_cash,
|
||||
slot_count=slot_count,
|
||||
allocations=tuple(
|
||||
CapitalAllocation(
|
||||
candidate.portfolio_id,
|
||||
candidate.symbol,
|
||||
0,
|
||||
"no_available_cash",
|
||||
)
|
||||
for candidate in candidates
|
||||
),
|
||||
)
|
||||
|
||||
equal_budget = available_cash // slot_count
|
||||
flex_unit = min(
|
||||
candidate.protected_share_cost for candidate in candidates
|
||||
)
|
||||
maximum_one_share_cost = (
|
||||
equal_budget + flex_unit * flex_share_count
|
||||
)
|
||||
eligible_indexes = [
|
||||
index
|
||||
for index, candidate in enumerate(candidates)
|
||||
if candidate.protected_share_cost <= maximum_one_share_cost
|
||||
]
|
||||
reasons = {
|
||||
index: "protected_share_above_flex_limit"
|
||||
for index, candidate in enumerate(candidates)
|
||||
if candidate.protected_share_cost > maximum_one_share_cost
|
||||
}
|
||||
|
||||
while eligible_indexes:
|
||||
active_count = min(slot_count, len(eligible_indexes))
|
||||
largest_costs = sorted(
|
||||
(
|
||||
candidates[index].protected_share_cost
|
||||
for index in eligible_indexes
|
||||
),
|
||||
reverse=True,
|
||||
)[:active_count]
|
||||
if sum(largest_costs) <= available_cash:
|
||||
break
|
||||
highest_index = max(
|
||||
eligible_indexes,
|
||||
key=lambda index: (
|
||||
candidates[index].protected_share_cost,
|
||||
index,
|
||||
),
|
||||
)
|
||||
eligible_indexes.remove(highest_index)
|
||||
reasons[highest_index] = "worst_case_cash_overcommit"
|
||||
|
||||
if not eligible_indexes:
|
||||
return CapitalAllocationPlan(
|
||||
available_cash=available_cash,
|
||||
slot_count=slot_count,
|
||||
allocations=tuple(
|
||||
CapitalAllocation(
|
||||
candidate.portfolio_id,
|
||||
candidate.symbol,
|
||||
0,
|
||||
reasons[index],
|
||||
)
|
||||
for index, candidate in enumerate(candidates)
|
||||
),
|
||||
)
|
||||
|
||||
active_count = min(slot_count, len(eligible_indexes))
|
||||
|
||||
def worst_active_total(common_budget: int) -> int:
|
||||
caps = sorted(
|
||||
(
|
||||
max(
|
||||
candidates[index].protected_share_cost,
|
||||
common_budget,
|
||||
)
|
||||
for index in eligible_indexes
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return sum(caps[:active_count])
|
||||
|
||||
low, high = 0, available_cash
|
||||
while low < high:
|
||||
middle = (low + high + 1) // 2
|
||||
if worst_active_total(middle) <= available_cash:
|
||||
low = middle
|
||||
else:
|
||||
high = middle - 1
|
||||
|
||||
eligible_set = set(eligible_indexes)
|
||||
allocations = tuple(
|
||||
CapitalAllocation(
|
||||
candidate.portfolio_id,
|
||||
candidate.symbol,
|
||||
(
|
||||
max(candidate.protected_share_cost, low)
|
||||
if index in eligible_set
|
||||
else 0
|
||||
),
|
||||
"eligible" if index in eligible_set else reasons[index],
|
||||
)
|
||||
for index, candidate in enumerate(candidates)
|
||||
)
|
||||
plan = CapitalAllocationPlan(
|
||||
available_cash=available_cash,
|
||||
slot_count=slot_count,
|
||||
allocations=allocations,
|
||||
)
|
||||
_assert_plan_invariants(plan)
|
||||
return plan
|
||||
|
||||
|
||||
def allocate_liquid_slots(
|
||||
available_cash: int,
|
||||
*,
|
||||
slot_count: int,
|
||||
candidates: tuple[CapitalCandidate, ...],
|
||||
flex_share_count: int = 1,
|
||||
) -> dict[str, int]:
|
||||
"""Compatibility surface returning only ``portfolio_id -> cap``."""
|
||||
return plan_liquid_capital(
|
||||
available_cash,
|
||||
slot_count=slot_count,
|
||||
candidates=candidates,
|
||||
flex_share_count=flex_share_count,
|
||||
).caps
|
||||
|
||||
|
||||
def _validate_inputs(
|
||||
available_cash: int,
|
||||
*,
|
||||
slot_count: int,
|
||||
candidates: tuple[CapitalCandidate, ...],
|
||||
flex_share_count: int,
|
||||
) -> None:
|
||||
if available_cash < 0 or slot_count <= 0 or not candidates:
|
||||
raise ValueError(
|
||||
"cash, positive slot count, and candidates are required"
|
||||
)
|
||||
if flex_share_count < 0:
|
||||
raise ValueError("flex share count must be non-negative")
|
||||
portfolio_ids = [
|
||||
candidate.portfolio_id for candidate in candidates
|
||||
]
|
||||
symbols = [candidate.symbol for candidate in candidates]
|
||||
if (
|
||||
len(portfolio_ids) != len(set(portfolio_ids))
|
||||
or len(symbols) != len(set(symbols))
|
||||
):
|
||||
raise ValueError(
|
||||
"candidate portfolio ids and symbols must be unique"
|
||||
)
|
||||
if any(
|
||||
candidate.protected_share_cost <= 0
|
||||
for candidate in candidates
|
||||
):
|
||||
raise ValueError(
|
||||
"candidate protected share costs must be positive"
|
||||
)
|
||||
|
||||
|
||||
def _assert_plan_invariants(plan: CapitalAllocationPlan) -> None:
|
||||
positive = sorted(
|
||||
(
|
||||
allocation.cap
|
||||
for allocation in plan.allocations
|
||||
if allocation.cap > 0
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
if sum(positive[: plan.slot_count]) > plan.available_cash:
|
||||
raise AssertionError("capital plan exceeds available cash")
|
||||
Reference in New Issue
Block a user