270 lines
7.9 KiB
Python
270 lines
7.9 KiB
Python
"""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")
|