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

1"""Unified cost model for Portfolio analytics. 

2 

3This module provides :class:`CostModel`, a single abstraction that covers both 

4cost models available in :class:`~jquantstats.portfolio.Portfolio`: 

5 

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. 

9 

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. 

13 

14Use the class-method constructors to make intent explicit:: 

15 

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 

19 

20""" 

21 

22from __future__ import annotations 

23 

24import dataclasses 

25 

26 

27@dataclasses.dataclass(frozen=True) 

28class CostModel: 

29 """Unified representation of a portfolio transaction-cost model. 

30 

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. 

36 

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. 

42 

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). 

46 

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 """ 

55 

56 cost_per_unit: float = 0.0 

57 cost_bps: float = 0.0 

58 

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 ) 

70 

71 # ── Named constructors ──────────────────────────────────────────────────── 

72 

73 @classmethod 

74 def per_unit(cls, cost: float) -> CostModel: 

75 """Create a Model A (position-delta) cost model. 

76 

77 Args: 

78 cost: One-way cost per unit of position change. Must be 

79 non-negative. 

80 

81 Returns: 

82 A :class:`CostModel` with ``cost_per_unit=cost`` and 

83 ``cost_bps=0.0``. 

84 

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) 

90 

91 @classmethod 

92 def turnover_bps(cls, bps: float) -> CostModel: 

93 """Create a Model B (turnover-bps) cost model. 

94 

95 Args: 

96 bps: One-way cost in basis points of AUM turnover. Must be 

97 non-negative. 

98 

99 Returns: 

100 A :class:`CostModel` with ``cost_per_unit=0.0`` and 

101 ``cost_bps=bps``. 

102 

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) 

108 

109 @classmethod 

110 def zero(cls) -> CostModel: 

111 """Create a zero-cost model (no transaction costs). 

112 

113 Returns: 

114 A :class:`CostModel` with both parameters set to 0.0. 

115 

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)