Coverage for src / jquantstats / _cost_model.py: 100%
22 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 18:44 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 18:44 +0000
1"""Unified cost model for Portfolio analytics.
3This module provides :class:`CostModel`, a single abstraction that covers both
4cost models available in :class:`~jquantstats.portfolio.Portfolio`:
6**Model A — position-delta** (``cost_per_unit``)
7 One-way cost per unit of traded notional (e.g. £0.01 per share). Best for
8 equity portfolios where cost scales with shares traded.
10**Model B — turnover-bps** (``cost_bps``)
11 One-way cost in basis points of AUM turnover (e.g. 5 bps). Best for
12 macro / fund-of-funds portfolios where cost scales with notional traded.
14Use the class-method constructors to make intent explicit::
16 CostModel.per_unit(0.01) # Model A: £0.01 per share traded
17 CostModel.turnover_bps(5.0) # Model B: 5 bps per unit of AUM turnover
18 CostModel.zero() # No transaction costs
20"""
22from __future__ import annotations
24import dataclasses
27@dataclasses.dataclass(frozen=True)
28class CostModel:
29 """Unified representation of a portfolio transaction-cost model.
31 Eliminates the implicit "pick one" contract between the two independent
32 cost parameters (``cost_per_unit`` and ``cost_bps``) on
33 :class:`~jquantstats.portfolio.Portfolio`. A ``CostModel``
34 instance encapsulates one model at a time and can be passed to any
35 Portfolio factory method instead of specifying the raw float parameters.
37 Attributes:
38 cost_per_unit: One-way cost per unit of position change (Model A).
39 Defaults to 0.0.
40 cost_bps: One-way cost in basis points of AUM turnover (Model B).
41 Defaults to 0.0.
43 Raises:
44 ValueError: If ``cost_per_unit`` or ``cost_bps`` is negative, or if
45 both are non-zero (which would silently double-count costs).
47 Examples:
48 >>> CostModel.per_unit(0.01)
49 CostModel(cost_per_unit=0.01, cost_bps=0.0)
50 >>> CostModel.turnover_bps(5.0)
51 CostModel(cost_per_unit=0.0, cost_bps=5.0)
52 >>> CostModel.zero()
53 CostModel(cost_per_unit=0.0, cost_bps=0.0)
54 """
56 cost_per_unit: float = 0.0
57 cost_bps: float = 0.0
59 def __post_init__(self) -> None:
60 if self.cost_per_unit < 0:
61 raise ValueError(f"cost_per_unit must be non-negative, got {self.cost_per_unit}") # noqa: TRY003
62 if self.cost_bps < 0:
63 raise ValueError(f"cost_bps must be non-negative, got {self.cost_bps}") # noqa: TRY003
64 if self.cost_per_unit > 0 and self.cost_bps > 0:
65 raise ValueError( # noqa: TRY003
66 "Only one cost model may be active at a time: "
67 f"got cost_per_unit={self.cost_per_unit} and cost_bps={self.cost_bps}. "
68 "Use CostModel.per_unit() or CostModel.turnover_bps() to make intent explicit."
69 )
71 # ── Named constructors ────────────────────────────────────────────────────
73 @classmethod
74 def per_unit(cls, cost: float) -> CostModel:
75 """Create a Model A (position-delta) cost model.
77 Args:
78 cost: One-way cost per unit of position change. Must be
79 non-negative.
81 Returns:
82 A :class:`CostModel` with ``cost_per_unit=cost`` and
83 ``cost_bps=0.0``.
85 Examples:
86 >>> CostModel.per_unit(0.01)
87 CostModel(cost_per_unit=0.01, cost_bps=0.0)
88 """
89 return cls(cost_per_unit=cost, cost_bps=0.0)
91 @classmethod
92 def turnover_bps(cls, bps: float) -> CostModel:
93 """Create a Model B (turnover-bps) cost model.
95 Args:
96 bps: One-way cost in basis points of AUM turnover. Must be
97 non-negative.
99 Returns:
100 A :class:`CostModel` with ``cost_per_unit=0.0`` and
101 ``cost_bps=bps``.
103 Examples:
104 >>> CostModel.turnover_bps(5.0)
105 CostModel(cost_per_unit=0.0, cost_bps=5.0)
106 """
107 return cls(cost_per_unit=0.0, cost_bps=bps)
109 @classmethod
110 def zero(cls) -> CostModel:
111 """Create a zero-cost model (no transaction costs).
113 Returns:
114 A :class:`CostModel` with both parameters set to 0.0.
116 Examples:
117 >>> CostModel.zero()
118 CostModel(cost_per_unit=0.0, cost_bps=0.0)
119 """
120 return cls(cost_per_unit=0.0, cost_bps=0.0)