doribt 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- doribt/__init__.py +69 -0
- doribt/accounting/__init__.py +1 -0
- doribt/accounting/account.py +91 -0
- doribt/accounting/costs.py +32 -0
- doribt/accounting/orders.py +162 -0
- doribt/accounting/rights.py +239 -0
- doribt/accounting/taxes.py +170 -0
- doribt/kernels/__init__.py +1 -0
- doribt/kernels/compiled.py +93 -0
- doribt/kernels/execution.py +193 -0
- doribt/kernels/segments.py +79 -0
- doribt/market/__init__.py +1 -0
- doribt/market/actions.py +77 -0
- doribt/market/adjustments.py +88 -0
- doribt/market/bars.py +133 -0
- doribt/market/china.py +128 -0
- doribt/market/clock.py +56 -0
- doribt/market/data.py +269 -0
- doribt/market/instruments.py +24 -0
- doribt/market/rules.py +102 -0
- doribt/provenance.py +198 -0
- doribt/py.typed +0 -0
- doribt/reporting/__init__.py +1 -0
- doribt/reporting/analysis.py +159 -0
- doribt/reporting/benchmark.py +55 -0
- doribt/reporting/daily.py +94 -0
- doribt/reporting/export.py +224 -0
- doribt/reporting/plotting.py +97 -0
- doribt/reporting/publish.py +28 -0
- doribt/reporting/report.py +131 -0
- doribt/reporting/result.py +194 -0
- doribt/reporting/trades.py +186 -0
- doribt/research/__init__.py +1 -0
- doribt/research/config.py +128 -0
- doribt/research/outputs.py +176 -0
- doribt/research/parameters.py +122 -0
- doribt/runtime/__init__.py +1 -0
- doribt/runtime/bar_runtime.py +118 -0
- doribt/runtime/bar_targets.py +72 -0
- doribt/runtime/broker.py +297 -0
- doribt/runtime/context.py +213 -0
- doribt/runtime/engine.py +353 -0
- doribt/runtime/intents.py +75 -0
- doribt/runtime/matching.py +167 -0
- doribt/runtime/scheduled.py +131 -0
- doribt/runtime/slippage.py +53 -0
- doribt/runtime/targets.py +107 -0
- doribt/serialization.py +44 -0
- doribt/validation.py +50 -0
- doribt-0.2.0.dist-info/METADATA +107 -0
- doribt-0.2.0.dist-info/RECORD +53 -0
- doribt-0.2.0.dist-info/WHEEL +4 -0
- doribt-0.2.0.dist-info/licenses/LICENSE +202 -0
doribt/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""DoriBT: A-share backtesting with explicit data, rules and accounting."""
|
|
2
|
+
|
|
3
|
+
from doribt.accounting.costs import Costs
|
|
4
|
+
from doribt.accounting.orders import Fill, IntentRecord, Order, Reason
|
|
5
|
+
from doribt.accounting.rights import CorporateEvent, EntitlementRecord, UnsupportedCorporateAction
|
|
6
|
+
from doribt.accounting.taxes import TaxLotRecord, TaxPayment, TaxRecord
|
|
7
|
+
from doribt.market.actions import CorporateAction
|
|
8
|
+
from doribt.market.adjustments import PriceAdjustment
|
|
9
|
+
from doribt.market.china import china_rules
|
|
10
|
+
from doribt.market.clock import MinuteClock
|
|
11
|
+
from doribt.market.data import MarketData
|
|
12
|
+
from doribt.market.instruments import Instrument, TradingStatus
|
|
13
|
+
from doribt.market.rules import RuleBook, RulePeriod, TradingRule
|
|
14
|
+
from doribt.provenance import RunInfo
|
|
15
|
+
from doribt.reporting.benchmark import Benchmark
|
|
16
|
+
from doribt.reporting.report import ResearchReport
|
|
17
|
+
from doribt.reporting.result import BacktestResult
|
|
18
|
+
from doribt.research.config import RunConfig
|
|
19
|
+
from doribt.research.outputs import Metric, ResearchOutput, Series, Table
|
|
20
|
+
from doribt.research.parameters import Parameter, ParameterSet
|
|
21
|
+
from doribt.runtime.context import Context
|
|
22
|
+
from doribt.runtime.engine import Backtest
|
|
23
|
+
from doribt.runtime.slippage import BarExecution, FixedBps, FixedTicks, VolumeImpact
|
|
24
|
+
from doribt.runtime.targets import PositionTargets, WeightTargets
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"MinuteClock",
|
|
28
|
+
"RunConfig",
|
|
29
|
+
"Parameter",
|
|
30
|
+
"ParameterSet",
|
|
31
|
+
"Metric",
|
|
32
|
+
"Series",
|
|
33
|
+
"Table",
|
|
34
|
+
"ResearchOutput",
|
|
35
|
+
"ResearchReport",
|
|
36
|
+
"Backtest",
|
|
37
|
+
"BarExecution",
|
|
38
|
+
"FixedBps",
|
|
39
|
+
"FixedTicks",
|
|
40
|
+
"VolumeImpact",
|
|
41
|
+
"BacktestResult",
|
|
42
|
+
"Benchmark",
|
|
43
|
+
"RunInfo",
|
|
44
|
+
"Context",
|
|
45
|
+
"Costs",
|
|
46
|
+
"WeightTargets",
|
|
47
|
+
"PositionTargets",
|
|
48
|
+
"CorporateAction",
|
|
49
|
+
"PriceAdjustment",
|
|
50
|
+
"china_rules",
|
|
51
|
+
"CorporateEvent",
|
|
52
|
+
"EntitlementRecord",
|
|
53
|
+
"UnsupportedCorporateAction",
|
|
54
|
+
"TaxLotRecord",
|
|
55
|
+
"TaxPayment",
|
|
56
|
+
"TaxRecord",
|
|
57
|
+
"Instrument",
|
|
58
|
+
"MarketData",
|
|
59
|
+
"RuleBook",
|
|
60
|
+
"RulePeriod",
|
|
61
|
+
"TradingRule",
|
|
62
|
+
"TradingStatus",
|
|
63
|
+
"Order",
|
|
64
|
+
"Fill",
|
|
65
|
+
"IntentRecord",
|
|
66
|
+
"Reason",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DoriBT accounting implementation; public API is exported by doribt."""
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""FIFO acquisition lots and trading-session settlement state."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from doribt.accounting.orders import Order
|
|
9
|
+
from doribt.kernels.execution import IntArray
|
|
10
|
+
from doribt.validation import MAX_MONEY
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Lot:
|
|
15
|
+
symbol: str
|
|
16
|
+
quantity: int
|
|
17
|
+
acquired: date
|
|
18
|
+
available_session: int
|
|
19
|
+
price_units: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Position:
|
|
24
|
+
symbol: str
|
|
25
|
+
quantity: int
|
|
26
|
+
sellable: int
|
|
27
|
+
value: float
|
|
28
|
+
pending_quantity: int = 0
|
|
29
|
+
frozen_quantity: int = 0
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def available_quantity(self) -> int:
|
|
33
|
+
return max(0, self.sellable - self.frozen_quantity)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Account:
|
|
37
|
+
def __init__(self, initial_cash: int, symbols: tuple[str, ...]) -> None:
|
|
38
|
+
self.cash = initial_cash
|
|
39
|
+
self.symbols = symbols
|
|
40
|
+
self.lots: list[Lot] = []
|
|
41
|
+
|
|
42
|
+
def quantities(self, index: int | None = None) -> IntArray:
|
|
43
|
+
totals = dict.fromkeys(self.symbols, 0)
|
|
44
|
+
for lot in self.lots:
|
|
45
|
+
if index is None or lot.available_session <= index:
|
|
46
|
+
totals[lot.symbol] += lot.quantity
|
|
47
|
+
if any(quantity > 1_000_000_000 for quantity in totals.values()):
|
|
48
|
+
raise OverflowError("registered share quantity exceeds supported bounds")
|
|
49
|
+
return np.array(list(totals.values()), dtype=np.int64)
|
|
50
|
+
|
|
51
|
+
def apply(self, order: Order, index: int, settlement: int) -> None:
|
|
52
|
+
if order.filled > 0:
|
|
53
|
+
self.lots.append(
|
|
54
|
+
Lot(
|
|
55
|
+
order.symbol, order.filled, order.session, index + settlement, order.price_units
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
elif order.filled < 0:
|
|
59
|
+
self._sell(order.symbol, -order.filled, index)
|
|
60
|
+
|
|
61
|
+
def _sell(self, symbol: str, quantity: int, index: int) -> None:
|
|
62
|
+
for lot in self.lots:
|
|
63
|
+
if lot.symbol != symbol or lot.available_session > index:
|
|
64
|
+
continue
|
|
65
|
+
consumed = min(quantity, lot.quantity)
|
|
66
|
+
lot.quantity -= consumed
|
|
67
|
+
quantity -= consumed
|
|
68
|
+
self.lots = [lot for lot in self.lots if lot.quantity]
|
|
69
|
+
if quantity:
|
|
70
|
+
raise RuntimeError("execution sold more than settled lots")
|
|
71
|
+
|
|
72
|
+
def value(
|
|
73
|
+
self,
|
|
74
|
+
closes: IntArray,
|
|
75
|
+
pending_shares: IntArray | None = None,
|
|
76
|
+
receivable: int = 0,
|
|
77
|
+
tax_payable: int = 0,
|
|
78
|
+
) -> int:
|
|
79
|
+
equity = self.cash + receivable - tax_payable
|
|
80
|
+
quantities = self.quantities()
|
|
81
|
+
if pending_shares is not None:
|
|
82
|
+
quantities = quantities + pending_shares
|
|
83
|
+
if np.any(quantities > 1_000_000_000):
|
|
84
|
+
raise OverflowError("economic share quantity exceeds supported bounds")
|
|
85
|
+
for quantity, price in zip(quantities, closes, strict=True):
|
|
86
|
+
if quantity and price <= 0:
|
|
87
|
+
raise ValueError("held security has no active valuation; unsupported delisting")
|
|
88
|
+
equity += int(quantity) * int(price)
|
|
89
|
+
if not 0 <= equity <= MAX_MONEY:
|
|
90
|
+
raise OverflowError("account equity exceeds supported bounds")
|
|
91
|
+
return equity
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Account-specific execution assumptions, separate from historical market rules."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from doribt.kernels.execution import IntArray
|
|
8
|
+
from doribt.validation import Number, amount, integer, ratio
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, kw_only=True)
|
|
12
|
+
class Costs:
|
|
13
|
+
commission: Number = "0.0003"
|
|
14
|
+
minimum_commission: Number = "5.00"
|
|
15
|
+
slippage_ticks: int = 0
|
|
16
|
+
|
|
17
|
+
def __post_init__(self) -> None:
|
|
18
|
+
ratio(self.commission, "commission")
|
|
19
|
+
minimum = amount(self.minimum_commission, "minimum_commission")
|
|
20
|
+
if minimum % 100:
|
|
21
|
+
raise ValueError("minimum commission must use whole cents")
|
|
22
|
+
integer(self.slippage_ticks, 1, "slippage_ticks", 0, 1000)
|
|
23
|
+
|
|
24
|
+
def compile(self) -> IntArray:
|
|
25
|
+
return np.array(
|
|
26
|
+
[
|
|
27
|
+
ratio(self.commission),
|
|
28
|
+
amount(self.minimum_commission),
|
|
29
|
+
self.slippage_ticks,
|
|
30
|
+
],
|
|
31
|
+
dtype=np.int64,
|
|
32
|
+
)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Immutable public order history. All money fields are integer 1/10,000 yuan."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Reason(StrEnum):
|
|
9
|
+
NONE = "none"
|
|
10
|
+
SUSPENDED = "suspended"
|
|
11
|
+
INACTIVE = "inactive"
|
|
12
|
+
BUY_AT_UPPER_LIMIT = "buy_at_upper_limit"
|
|
13
|
+
SELL_AT_LOWER_LIMIT = "sell_at_lower_limit"
|
|
14
|
+
NO_VOLUME = "no_volume"
|
|
15
|
+
INVALID_QUANTITY = "invalid_quantity"
|
|
16
|
+
INSUFFICIENT_CASH = "insufficient_cash"
|
|
17
|
+
INSUFFICIENT_SELLABLE = "insufficient_sellable"
|
|
18
|
+
INSUFFICIENT_POSITION = "insufficient_position"
|
|
19
|
+
PRICE_OUT_OF_RANGE = "price_out_of_range"
|
|
20
|
+
PARTICIPATION_LIMIT = "participation_limit"
|
|
21
|
+
LIMIT_PRICE = "limit_price"
|
|
22
|
+
AUCTION = "auction"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
REASONS = tuple(Reason)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Order:
|
|
30
|
+
order_id: int
|
|
31
|
+
intent_id: int
|
|
32
|
+
session: date
|
|
33
|
+
symbol: str
|
|
34
|
+
quantity: int
|
|
35
|
+
filled: int
|
|
36
|
+
price_units: int
|
|
37
|
+
commission_units: int
|
|
38
|
+
stamp_duty_units: int
|
|
39
|
+
transfer_fee_units: int
|
|
40
|
+
reason: Reason
|
|
41
|
+
created_at: date | None = None
|
|
42
|
+
updated_at: date | None = None
|
|
43
|
+
state: str = ""
|
|
44
|
+
notional_units: int = 0
|
|
45
|
+
valid_for: str = "next_bar"
|
|
46
|
+
limit_units: int = 0
|
|
47
|
+
frozen_cash_units: int = 0
|
|
48
|
+
frozen_quantity: int = 0
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def commission(self) -> float:
|
|
52
|
+
return self.commission_units / 10_000
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def stamp_duty(self) -> float:
|
|
56
|
+
return self.stamp_duty_units / 10_000
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def transfer_fee(self) -> float:
|
|
60
|
+
return self.transfer_fee_units / 10_000
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def events(self) -> tuple[str, ...]:
|
|
64
|
+
if self.state:
|
|
65
|
+
middle = ("partially_filled",) if self.filled and self.remaining else ()
|
|
66
|
+
return ("created", "accepted", *middle, self.state)
|
|
67
|
+
if self.reason == Reason.INVALID_QUANTITY:
|
|
68
|
+
return ("created", "rejected")
|
|
69
|
+
if self.filled == self.quantity:
|
|
70
|
+
return ("created", "accepted", "filled")
|
|
71
|
+
if self.filled:
|
|
72
|
+
return ("created", "accepted", "partially_filled", "expired")
|
|
73
|
+
return ("created", "accepted", "expired")
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def fees(self) -> float:
|
|
77
|
+
return (self.commission_units + self.stamp_duty_units + self.transfer_fee_units) / 10_000
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def status(self) -> str:
|
|
81
|
+
return self.events[-1]
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def remaining(self) -> int:
|
|
85
|
+
return self.quantity - self.filled
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def price(self) -> float | None:
|
|
89
|
+
if self.state and self.filled:
|
|
90
|
+
return self.notional_units / abs(self.filled) / 10_000
|
|
91
|
+
return self.price_units / 10_000 if self.filled else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class Fill:
|
|
96
|
+
fill_id: int
|
|
97
|
+
order_id: int
|
|
98
|
+
intent_id: int
|
|
99
|
+
session: date
|
|
100
|
+
symbol: str
|
|
101
|
+
quantity: int
|
|
102
|
+
price_units: int
|
|
103
|
+
commission_units: int
|
|
104
|
+
stamp_duty_units: int
|
|
105
|
+
transfer_fee_units: int
|
|
106
|
+
timestamp: datetime | None = None
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def commission(self) -> float:
|
|
110
|
+
return self.commission_units / 10_000
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def stamp_duty(self) -> float:
|
|
114
|
+
return self.stamp_duty_units / 10_000
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def transfer_fee(self) -> float:
|
|
118
|
+
return self.transfer_fee_units / 10_000
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def price(self) -> float:
|
|
122
|
+
return self.price_units / 10_000
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def fees(self) -> float:
|
|
126
|
+
return (self.commission_units + self.stamp_duty_units + self.transfer_fee_units) / 10_000
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def from_order(cls, fill_id: int, order: Order) -> "Fill":
|
|
130
|
+
return cls(
|
|
131
|
+
fill_id,
|
|
132
|
+
order.order_id,
|
|
133
|
+
order.intent_id,
|
|
134
|
+
order.session,
|
|
135
|
+
order.symbol,
|
|
136
|
+
order.filled,
|
|
137
|
+
order.price_units,
|
|
138
|
+
order.commission_units,
|
|
139
|
+
order.stamp_duty_units,
|
|
140
|
+
order.transfer_fee_units,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class TargetAdjustment:
|
|
146
|
+
action_id: str
|
|
147
|
+
session: date
|
|
148
|
+
before: int
|
|
149
|
+
after: int
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class IntentRecord:
|
|
154
|
+
intent_id: int
|
|
155
|
+
created: date
|
|
156
|
+
symbol: str
|
|
157
|
+
kind: str
|
|
158
|
+
quantity: int
|
|
159
|
+
status: str
|
|
160
|
+
closed: date | None
|
|
161
|
+
reason: str
|
|
162
|
+
adjustments: tuple[TargetAdjustment, ...] = ()
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Record-date ownership, receivables, share credit and subsequent availability."""
|
|
2
|
+
|
|
3
|
+
from bisect import bisect_left
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import date
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from doribt.accounting.account import Account, Lot
|
|
10
|
+
from doribt.accounting.taxes import TaxBook
|
|
11
|
+
from doribt.kernels.execution import IntArray
|
|
12
|
+
from doribt.market.actions import CorporateAction
|
|
13
|
+
from doribt.market.data import MarketData
|
|
14
|
+
from doribt.runtime.intents import IntentBook
|
|
15
|
+
from doribt.validation import MAX_MONEY, day, integer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnsupportedCorporateAction(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class CorporateEvent:
|
|
24
|
+
session: date
|
|
25
|
+
action_id: str
|
|
26
|
+
symbol: str
|
|
27
|
+
kind: str
|
|
28
|
+
quantity: int = 0
|
|
29
|
+
amount_units: int = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Entitlement:
|
|
34
|
+
action: CorporateAction
|
|
35
|
+
quantity: int
|
|
36
|
+
cash_units: int
|
|
37
|
+
bonus_quantity: int
|
|
38
|
+
accrued: bool = False
|
|
39
|
+
cash_paid: bool = False
|
|
40
|
+
shares_credited: bool = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class EntitlementRecord:
|
|
45
|
+
action_id: str
|
|
46
|
+
symbol: str
|
|
47
|
+
record_date: date
|
|
48
|
+
ex_date: date
|
|
49
|
+
pay_date: date | None
|
|
50
|
+
share_credit_date: date | None
|
|
51
|
+
share_listing_date: date | None
|
|
52
|
+
eligible_quantity: int
|
|
53
|
+
cash_units: int
|
|
54
|
+
bonus_quantity: int
|
|
55
|
+
accrued: bool
|
|
56
|
+
cash_paid: bool
|
|
57
|
+
shares_credited: bool
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RightsBook:
|
|
61
|
+
def __init__(self, data: MarketData) -> None:
|
|
62
|
+
self.data = data
|
|
63
|
+
self.kinds = {item.symbol: item.kind for item in data.instruments}
|
|
64
|
+
self.tax = TaxBook(self.kinds)
|
|
65
|
+
self.entitlements: dict[str, Entitlement] = {}
|
|
66
|
+
self.events: list[CorporateEvent] = []
|
|
67
|
+
self._record: dict[date, list[CorporateAction]] = {}
|
|
68
|
+
self._ex: dict[date, list[CorporateAction]] = {}
|
|
69
|
+
for action in data.actions:
|
|
70
|
+
self._record.setdefault(day(action.record_date), []).append(action)
|
|
71
|
+
self._ex.setdefault(day(action.ex_date), []).append(action)
|
|
72
|
+
|
|
73
|
+
def start(self, index: int, account: Account, intents: IntentBook) -> None:
|
|
74
|
+
session = self.data.sessions[index]
|
|
75
|
+
for action in self._ex.get(session, []):
|
|
76
|
+
if action.kind != "distribution" and action.symbol in intents.pending:
|
|
77
|
+
raise UnsupportedCorporateAction(f"unsupported {action.kind}: {action.action_id}")
|
|
78
|
+
self._ex_date(action, session, account)
|
|
79
|
+
bonus = integer(action.bonus_per_share, 1_000_000, "bonus", 0, 100_000_000)
|
|
80
|
+
intents.adjust(action.symbol, action.action_id, session, bonus)
|
|
81
|
+
for entitlement in self.entitlements.values():
|
|
82
|
+
if not entitlement.accrued:
|
|
83
|
+
continue
|
|
84
|
+
self._pay(entitlement, session, account)
|
|
85
|
+
self._credit(entitlement, index, account)
|
|
86
|
+
account.cash = self.tax.settle(session, account.cash)
|
|
87
|
+
|
|
88
|
+
def close(self, index: int, account: Account) -> None:
|
|
89
|
+
session = self.data.sessions[index]
|
|
90
|
+
quantities = dict(zip(self.data.symbols, map(int, account.quantities()), strict=True))
|
|
91
|
+
self.tax.reconcile(session, quantities)
|
|
92
|
+
for action in self._record.get(session, []):
|
|
93
|
+
quantity = quantities[action.symbol]
|
|
94
|
+
if quantity:
|
|
95
|
+
self._capture(action, quantity, session)
|
|
96
|
+
|
|
97
|
+
def _capture(self, action: CorporateAction, quantity: int, session: date) -> None:
|
|
98
|
+
cash_micros = integer(action.cash_per_share, 1_000_000, "cash per share", 0, MAX_MONEY)
|
|
99
|
+
bonus = integer(action.bonus_per_share, 1_000_000, "bonus per share", 0, 100_000_000)
|
|
100
|
+
if quantity * bonus % 1_000_000:
|
|
101
|
+
raise UnsupportedCorporateAction(
|
|
102
|
+
f"fractional share allocation unavailable: {action.action_id}"
|
|
103
|
+
)
|
|
104
|
+
cash = (quantity * cash_micros + 5000) // 10_000 * 100
|
|
105
|
+
if cash > MAX_MONEY:
|
|
106
|
+
raise OverflowError("dividend entitlement exceeds supported bounds")
|
|
107
|
+
entitlement = Entitlement(
|
|
108
|
+
action,
|
|
109
|
+
quantity,
|
|
110
|
+
cash,
|
|
111
|
+
quantity * bonus // 1_000_000,
|
|
112
|
+
cash_paid=not bool(cash),
|
|
113
|
+
shares_credited=not bool(bonus),
|
|
114
|
+
)
|
|
115
|
+
self.entitlements[action.action_id] = entitlement
|
|
116
|
+
if action.kind == "distribution":
|
|
117
|
+
taxable_bonus = self._taxable_bonus(action, bonus)
|
|
118
|
+
self.tax.attach(action.symbol, action.action_id, cash_micros + taxable_bonus, session)
|
|
119
|
+
self.events.append(
|
|
120
|
+
CorporateEvent(session, action.action_id, action.symbol, "recorded", quantity)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def _taxable_bonus(self, action: CorporateAction, bonus: int) -> int:
|
|
124
|
+
value = action.taxable_bonus_amount_per_share
|
|
125
|
+
if bonus and value is None and self.kinds[action.symbol] == "stock":
|
|
126
|
+
raise ValueError(f"taxable bonus amount is required: {action.action_id}")
|
|
127
|
+
return integer(value or 0, 1_000_000, "taxable bonus", 0, MAX_MONEY)
|
|
128
|
+
|
|
129
|
+
def _ex_date(self, action: CorporateAction, session: date, account: Account) -> None:
|
|
130
|
+
entitlement = self.entitlements.get(action.action_id)
|
|
131
|
+
held = int(account.quantities()[self.data.symbols.index(action.symbol)])
|
|
132
|
+
pending = any(
|
|
133
|
+
e.action.symbol == action.symbol
|
|
134
|
+
and e.accrued
|
|
135
|
+
and (not e.shares_credited or not e.cash_paid)
|
|
136
|
+
for e in self.entitlements.values()
|
|
137
|
+
)
|
|
138
|
+
if action.kind != "distribution" and (entitlement is not None or held or pending):
|
|
139
|
+
raise UnsupportedCorporateAction(f"unsupported {action.kind}: {action.action_id}")
|
|
140
|
+
if entitlement is not None:
|
|
141
|
+
entitlement.accrued = True
|
|
142
|
+
self.events.append(
|
|
143
|
+
CorporateEvent(
|
|
144
|
+
session,
|
|
145
|
+
action.action_id,
|
|
146
|
+
action.symbol,
|
|
147
|
+
"accrued",
|
|
148
|
+
entitlement.bonus_quantity,
|
|
149
|
+
entitlement.cash_units,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _pay(self, entitlement: Entitlement, session: date, account: Account) -> None:
|
|
154
|
+
action = entitlement.action
|
|
155
|
+
if entitlement.cash_paid or action.pay_date is None or session < day(action.pay_date):
|
|
156
|
+
return
|
|
157
|
+
account.cash += entitlement.cash_units
|
|
158
|
+
if account.cash > MAX_MONEY:
|
|
159
|
+
raise OverflowError("dividend payment exceeds supported cash bounds")
|
|
160
|
+
entitlement.cash_paid = True
|
|
161
|
+
self.events.append(
|
|
162
|
+
CorporateEvent(
|
|
163
|
+
session,
|
|
164
|
+
action.action_id,
|
|
165
|
+
action.symbol,
|
|
166
|
+
"cash_paid",
|
|
167
|
+
amount_units=entitlement.cash_units,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _credit(self, entitlement: Entitlement, index: int, account: Account) -> None:
|
|
172
|
+
action, session = entitlement.action, self.data.sessions[index]
|
|
173
|
+
if entitlement.shares_credited or action.share_credit_date is None:
|
|
174
|
+
return
|
|
175
|
+
if session < day(action.share_credit_date):
|
|
176
|
+
return
|
|
177
|
+
if action.share_listing_date is None:
|
|
178
|
+
raise RuntimeError("validated bonus shares require a listing date")
|
|
179
|
+
available = bisect_left(self.data.sessions, day(action.share_listing_date))
|
|
180
|
+
account.lots.append(
|
|
181
|
+
Lot(
|
|
182
|
+
action.symbol,
|
|
183
|
+
entitlement.bonus_quantity,
|
|
184
|
+
day(action.share_credit_date),
|
|
185
|
+
available,
|
|
186
|
+
0,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
entitlement.shares_credited = True
|
|
190
|
+
self.events.append(
|
|
191
|
+
CorporateEvent(
|
|
192
|
+
session,
|
|
193
|
+
action.action_id,
|
|
194
|
+
action.symbol,
|
|
195
|
+
"shares_credited",
|
|
196
|
+
entitlement.bonus_quantity,
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def receivable(self) -> int:
|
|
202
|
+
total = sum(
|
|
203
|
+
e.cash_units for e in self.entitlements.values() if e.accrued and not e.cash_paid
|
|
204
|
+
)
|
|
205
|
+
if total > MAX_MONEY:
|
|
206
|
+
raise OverflowError("dividend receivables exceed supported bounds")
|
|
207
|
+
return total
|
|
208
|
+
|
|
209
|
+
def pending_shares(self) -> IntArray:
|
|
210
|
+
pending = dict.fromkeys(self.data.symbols, 0)
|
|
211
|
+
for entitlement in self.entitlements.values():
|
|
212
|
+
if entitlement.accrued and not entitlement.shares_credited:
|
|
213
|
+
pending[entitlement.action.symbol] += entitlement.bonus_quantity
|
|
214
|
+
if any(quantity > 1_000_000_000 for quantity in pending.values()):
|
|
215
|
+
raise OverflowError("pending share quantity exceeds supported bounds")
|
|
216
|
+
return np.array(list(pending.values()), dtype=np.int64)
|
|
217
|
+
|
|
218
|
+
def records(self) -> tuple[EntitlementRecord, ...]:
|
|
219
|
+
result = []
|
|
220
|
+
for e in self.entitlements.values():
|
|
221
|
+
action = e.action
|
|
222
|
+
result.append(
|
|
223
|
+
EntitlementRecord(
|
|
224
|
+
action.action_id,
|
|
225
|
+
action.symbol,
|
|
226
|
+
day(action.record_date),
|
|
227
|
+
day(action.ex_date),
|
|
228
|
+
None if action.pay_date is None else day(action.pay_date),
|
|
229
|
+
None if action.share_credit_date is None else day(action.share_credit_date),
|
|
230
|
+
None if action.share_listing_date is None else day(action.share_listing_date),
|
|
231
|
+
e.quantity,
|
|
232
|
+
e.cash_units,
|
|
233
|
+
e.bonus_quantity,
|
|
234
|
+
e.accrued,
|
|
235
|
+
e.cash_paid,
|
|
236
|
+
e.shares_credited,
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
return tuple(result)
|