quantrail 0.1.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.
- quantrail/__init__.py +17 -0
- quantrail/accounting/__init__.py +1 -0
- quantrail/accounting/ledger.py +159 -0
- quantrail/backtest.py +116 -0
- quantrail/core.py +218 -0
- quantrail/datasets.py +199 -0
- quantrail/engine.py +202 -0
- quantrail/markets/__init__.py +1 -0
- quantrail/markets/generic.py +30 -0
- quantrail/markets/tw_equity.py +105 -0
- quantrail/research/__init__.py +1 -0
- quantrail/research/trials.py +148 -0
- quantrail/stats/__init__.py +1 -0
- quantrail/stats/inference.py +143 -0
- quantrail/stats/metrics.py +129 -0
- quantrail-0.1.0.dist-info/METADATA +149 -0
- quantrail-0.1.0.dist-info/RECORD +20 -0
- quantrail-0.1.0.dist-info/WHEEL +4 -0
- quantrail-0.1.0.dist-info/licenses/LICENSE +202 -0
- quantrail-0.1.0.dist-info/licenses/NOTICE +6 -0
quantrail/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""QuantRail: quantitative research you can trust.
|
|
2
|
+
|
|
3
|
+
Importing QuantRail never downloads data, writes files or contacts a broker.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .backtest import BacktestResult, backtest
|
|
7
|
+
from .core import UNKNOWN, Declaration, Instrument, Money, PriceBasis, Provenance, TrustReport
|
|
8
|
+
from .datasets import export_dataset, ingest, list_versions, load_dataset
|
|
9
|
+
from .research.trials import TrialRegistry
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"UNKNOWN", "BacktestResult", "Declaration", "Instrument", "Money", "PriceBasis", "Provenance",
|
|
15
|
+
"TrialRegistry", "TrustReport", "backtest", "export_dataset", "ingest", "list_versions",
|
|
16
|
+
"load_dataset",
|
|
17
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Accounting: ledger and settlement."""
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Multi-currency ledger with Decimal quantities and settlement-dated cash.
|
|
2
|
+
|
|
3
|
+
Positions change when a fill is booked; cash changes only when an obligation settles.
|
|
4
|
+
Receivables (unsettled sales, declared dividends) count towards net asset value but
|
|
5
|
+
are never spendable. Buying power is the lowest projected cash balance over future
|
|
6
|
+
settlement dates, so an order can never be funded by money that arrives later.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import date
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
|
|
16
|
+
from ..core import Instrument, to_decimal
|
|
17
|
+
|
|
18
|
+
ZERO = Decimal("0")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InsufficientCash(RuntimeError):
|
|
22
|
+
"""An order or settlement would need cash the account does not have."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Obligation:
|
|
27
|
+
settles_on: date
|
|
28
|
+
currency: str
|
|
29
|
+
amount: Decimal # positive: receivable, negative: payable
|
|
30
|
+
reference: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Fill:
|
|
35
|
+
instrument: Instrument
|
|
36
|
+
side: str # "BUY" or "SELL"
|
|
37
|
+
quantity: Decimal
|
|
38
|
+
price: Decimal
|
|
39
|
+
trade_date: date
|
|
40
|
+
settles_on: date
|
|
41
|
+
commission: Decimal = ZERO
|
|
42
|
+
tax: Decimal = ZERO
|
|
43
|
+
reference: str = ""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def value(self) -> Decimal:
|
|
47
|
+
return self.quantity * self.price
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def cash_delta(self) -> Decimal:
|
|
51
|
+
fees = self.commission + self.tax
|
|
52
|
+
return -(self.value + fees) if self.side == "BUY" else self.value - fees
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class CashSnapshot:
|
|
57
|
+
currency: str
|
|
58
|
+
settled: Decimal
|
|
59
|
+
receivable: Decimal
|
|
60
|
+
payable: Decimal
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def net(self) -> Decimal:
|
|
64
|
+
return self.settled + self.receivable - self.payable
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Ledger:
|
|
68
|
+
def __init__(self, cash: dict[str, object]):
|
|
69
|
+
self._cash = {ccy: to_decimal(v, f"cash[{ccy}]") for ccy, v in cash.items()}
|
|
70
|
+
if any(v < 0 for v in self._cash.values()):
|
|
71
|
+
raise ValueError("Initial cash must be non-negative.")
|
|
72
|
+
self._positions: dict[str, Decimal] = defaultdict(lambda: ZERO)
|
|
73
|
+
self._pending: dict[str, Obligation] = {}
|
|
74
|
+
self.journal: list[dict] = []
|
|
75
|
+
|
|
76
|
+
# Queries -----------------------------------------------------------------
|
|
77
|
+
def position(self, instrument_id: str) -> Decimal:
|
|
78
|
+
return self._positions.get(instrument_id, ZERO)
|
|
79
|
+
|
|
80
|
+
def snapshot(self, currency: str) -> CashSnapshot:
|
|
81
|
+
pending = [o for o in self._pending.values() if o.currency == currency]
|
|
82
|
+
return CashSnapshot(currency, self._cash.get(currency, ZERO),
|
|
83
|
+
sum((o.amount for o in pending if o.amount > 0), ZERO),
|
|
84
|
+
-sum((o.amount for o in pending if o.amount < 0), ZERO))
|
|
85
|
+
|
|
86
|
+
def buying_power(self, currency: str) -> Decimal:
|
|
87
|
+
"""Lowest projected settled cash over the pending settlement dates."""
|
|
88
|
+
projected = lowest = self._cash.get(currency, ZERO)
|
|
89
|
+
by_date: dict[date, Decimal] = defaultdict(lambda: ZERO)
|
|
90
|
+
for o in self._pending.values():
|
|
91
|
+
if o.currency == currency:
|
|
92
|
+
by_date[o.settles_on] += o.amount
|
|
93
|
+
for day in sorted(by_date):
|
|
94
|
+
projected += by_date[day]
|
|
95
|
+
lowest = min(lowest, projected)
|
|
96
|
+
return lowest
|
|
97
|
+
|
|
98
|
+
# Events ------------------------------------------------------------------
|
|
99
|
+
def _add_obligation(self, obligation: Obligation) -> None:
|
|
100
|
+
if obligation.reference in self._pending:
|
|
101
|
+
raise ValueError(f"Duplicate obligation reference {obligation.reference}")
|
|
102
|
+
self._pending[obligation.reference] = obligation
|
|
103
|
+
|
|
104
|
+
def book_fill(self, fill: Fill) -> None:
|
|
105
|
+
q = fill.instrument.validate_quantity(fill.quantity)
|
|
106
|
+
if q == 0:
|
|
107
|
+
raise ValueError("A fill needs a positive quantity.")
|
|
108
|
+
if fill.side not in {"BUY", "SELL"}:
|
|
109
|
+
raise ValueError(f"Unknown side {fill.side!r}")
|
|
110
|
+
currency = fill.instrument.quote_currency
|
|
111
|
+
if fill.side == "SELL" and q > self.position(fill.instrument.id):
|
|
112
|
+
raise ValueError("Sell exceeds position; short selling is not modelled.")
|
|
113
|
+
if fill.side == "BUY" and -fill.cash_delta > self.buying_power(currency):
|
|
114
|
+
raise InsufficientCash(f"Buy of {-fill.cash_delta} {currency} exceeds buying power.")
|
|
115
|
+
self._positions[fill.instrument.id] += q if fill.side == "BUY" else -q
|
|
116
|
+
self._add_obligation(Obligation(fill.settles_on, currency, fill.cash_delta, f"fill:{fill.reference}"))
|
|
117
|
+
self.journal.append({"type": "FILL", "date": fill.trade_date, "instrument": fill.instrument.id,
|
|
118
|
+
"side": fill.side, "quantity": q, "price": fill.price,
|
|
119
|
+
"commission": fill.commission, "tax": fill.tax, "settles_on": fill.settles_on})
|
|
120
|
+
|
|
121
|
+
def entitle_cash_dividend(self, instrument: Instrument, per_unit, ex_date: date, payment_date: date,
|
|
122
|
+
reference: str) -> Decimal:
|
|
123
|
+
"""Entitlement on units held at the close before ex_date; cash arrives on payment_date."""
|
|
124
|
+
amount = self.position(instrument.id) * to_decimal(per_unit, "per_unit")
|
|
125
|
+
if amount > 0:
|
|
126
|
+
self._add_obligation(Obligation(payment_date, instrument.quote_currency, amount,
|
|
127
|
+
f"div:{reference}"))
|
|
128
|
+
self.journal.append({"type": "DIVIDEND_ENTITLEMENT", "date": ex_date, "instrument": instrument.id,
|
|
129
|
+
"units": self.position(instrument.id), "per_unit": per_unit, "amount": amount,
|
|
130
|
+
"payment_date": payment_date})
|
|
131
|
+
return amount
|
|
132
|
+
|
|
133
|
+
def split(self, instrument: Instrument, new_per_old, effective: date) -> Decimal:
|
|
134
|
+
ratio = to_decimal(new_per_old, "split ratio")
|
|
135
|
+
if ratio <= 0:
|
|
136
|
+
raise ValueError("Split ratio must be positive.")
|
|
137
|
+
old = self.position(instrument.id)
|
|
138
|
+
new = old * ratio
|
|
139
|
+
if new != instrument.floor_quantity(new) and new != 0:
|
|
140
|
+
raise ValueError("Fractional split entitlement needs an explicit cash-in-lieu event.")
|
|
141
|
+
self._positions[instrument.id] = new
|
|
142
|
+
self.journal.append({"type": "SPLIT", "date": effective, "instrument": instrument.id,
|
|
143
|
+
"before": old, "after": new})
|
|
144
|
+
return new
|
|
145
|
+
|
|
146
|
+
def settle_through(self, day: date) -> list[Obligation]:
|
|
147
|
+
due = sorted((o for o in self._pending.values() if o.settles_on <= day),
|
|
148
|
+
key=lambda o: (o.settles_on, o.reference))
|
|
149
|
+
projected = dict(self._cash)
|
|
150
|
+
for o in due:
|
|
151
|
+
projected[o.currency] = projected.get(o.currency, ZERO) + o.amount
|
|
152
|
+
if projected[o.currency] < 0:
|
|
153
|
+
raise InsufficientCash(f"Cannot settle {o.reference} on {o.settles_on}.")
|
|
154
|
+
self._cash = projected
|
|
155
|
+
for o in due:
|
|
156
|
+
del self._pending[o.reference]
|
|
157
|
+
self.journal.append({"type": "SETTLEMENT", "date": o.settles_on, "currency": o.currency,
|
|
158
|
+
"amount": o.amount, "reference": o.reference})
|
|
159
|
+
return due
|
quantrail/backtest.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""One-call backtest from an ingested dataset, with the trust report attached.
|
|
2
|
+
|
|
3
|
+
This adapter turns a dataset produced by `ingest()` into the engine's tables and adds
|
|
4
|
+
the caveats that come from the data itself:
|
|
5
|
+
|
|
6
|
+
- no open prices: fills use the next session's close (EXECUTION_NEXT_CLOSE_PROXY);
|
|
7
|
+
- sessions inferred from the price dates (CALENDAR_FROM_PRICES): exchange holidays and
|
|
8
|
+
suspensions without a row cannot be told apart;
|
|
9
|
+
- total-return prices together with dividend events would count dividends twice and
|
|
10
|
+
are refused.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from .core import Instrument, PriceBasis, Severity, TrustFlag, TrustReport
|
|
20
|
+
from .datasets import Dataset
|
|
21
|
+
from .engine import Result, run_target_weights
|
|
22
|
+
from .stats import metrics
|
|
23
|
+
|
|
24
|
+
ACTION_COLUMNS = {"action_id", "action_type", "effective_date", "ex_date", "payment_date",
|
|
25
|
+
"split_new_per_old", "cash_per_entitled_unit"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class BacktestResult:
|
|
30
|
+
result: Result
|
|
31
|
+
trust: TrustReport
|
|
32
|
+
summary: dict
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def daily(self) -> pd.DataFrame:
|
|
36
|
+
return self.result.daily
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def orders(self) -> pd.DataFrame:
|
|
40
|
+
return self.result.orders
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def events(self) -> pd.DataFrame:
|
|
44
|
+
return self.result.events
|
|
45
|
+
|
|
46
|
+
def report(self) -> str:
|
|
47
|
+
lines = [self.trust.render(), "", "Summary:"]
|
|
48
|
+
for key in ("start", "end", "final_nav", "cagr", "annual_volatility", "sharpe", "max_drawdown"):
|
|
49
|
+
value = self.summary[key]
|
|
50
|
+
lines.append(f" {key}: {value:.4f}" if isinstance(value, float) else f" {key}: {value}")
|
|
51
|
+
return "\n".join(lines)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _sessions(n: int):
|
|
55
|
+
"""Settlement helper: T+n on the dataset's own sessions."""
|
|
56
|
+
def make(sessions):
|
|
57
|
+
ordered = sorted(sessions)
|
|
58
|
+
|
|
59
|
+
def settle(day):
|
|
60
|
+
if n == 0:
|
|
61
|
+
return day
|
|
62
|
+
future = [s for s in ordered if s > day]
|
|
63
|
+
if len(future) < n:
|
|
64
|
+
# Beyond the data: settle n calendar days later; affects only the last sessions.
|
|
65
|
+
return day + pd.Timedelta(n, unit="D").to_pytimedelta()
|
|
66
|
+
return future[n - 1]
|
|
67
|
+
return settle
|
|
68
|
+
return make
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def backtest(dataset: Dataset, *, instrument: Instrument, costs, capital, initial_target=1.0,
|
|
72
|
+
decisions=None, no_trade_band=0.0, slippage_bps=0.0, settlement_lag=0,
|
|
73
|
+
start=None, end=None) -> BacktestResult:
|
|
74
|
+
"""Run a target-weight backtest of one instrument on an ingested dataset."""
|
|
75
|
+
prices = dataset.tables["prices"]
|
|
76
|
+
if prices["instrument"].nunique() != 1:
|
|
77
|
+
raise ValueError("backtest() handles one instrument per dataset for now.")
|
|
78
|
+
flags = []
|
|
79
|
+
table = pd.DataFrame({"session_date": pd.to_datetime(prices["date"]),
|
|
80
|
+
"close": prices["close"].astype(float),
|
|
81
|
+
"quote_valid": prices["quote_valid"].astype(bool)})
|
|
82
|
+
if "open" in prices:
|
|
83
|
+
table["open"] = prices["open"].astype(float)
|
|
84
|
+
else:
|
|
85
|
+
table["open"] = table["close"]
|
|
86
|
+
flags.append(TrustFlag("EXECUTION_NEXT_CLOSE_PROXY", Severity.WARNING,
|
|
87
|
+
"No open prices: orders fill at the next session's close."))
|
|
88
|
+
calendar = pd.DataFrame({"session_date": table["session_date"], "asset_tradable": True, "reason": ""})
|
|
89
|
+
flags.append(TrustFlag("CALENDAR_FROM_PRICES", Severity.INFO,
|
|
90
|
+
"Sessions are inferred from price dates; missing sessions cannot be detected."))
|
|
91
|
+
|
|
92
|
+
actions = dataset.tables.get("corporate_actions")
|
|
93
|
+
if actions is None:
|
|
94
|
+
actions = pd.DataFrame(columns=sorted(ACTION_COLUMNS))
|
|
95
|
+
else:
|
|
96
|
+
missing = ACTION_COLUMNS - set(actions.columns)
|
|
97
|
+
if missing:
|
|
98
|
+
raise ValueError(f"corporate_actions is missing columns: {sorted(missing)}")
|
|
99
|
+
actions = actions.copy()
|
|
100
|
+
for column in ("effective_date", "ex_date", "payment_date"):
|
|
101
|
+
actions[column] = pd.to_datetime(actions[column])
|
|
102
|
+
has_dividends = (actions["action_type"] == "CASH_DISTRIBUTION").any()
|
|
103
|
+
if dataset.declaration.price_basis is PriceBasis.TOTAL_RETURN and has_dividends:
|
|
104
|
+
raise ValueError("Total-return prices already include dividends; supplying dividend events "
|
|
105
|
+
"too would count them twice.")
|
|
106
|
+
|
|
107
|
+
first, last = table["session_date"].min(), table["session_date"].max()
|
|
108
|
+
start, end = pd.Timestamp(start) if start else first, pd.Timestamp(end) if end else last
|
|
109
|
+
sessions = [d.date() for d in table["session_date"]]
|
|
110
|
+
result = run_target_weights(
|
|
111
|
+
instrument=instrument, costs=costs, settle=_sessions(settlement_lag)(sessions),
|
|
112
|
+
prices=table, calendar=calendar, actions=actions, start=start, end=end, capital=capital,
|
|
113
|
+
initial_target=initial_target, decisions=decisions, no_trade_band=no_trade_band,
|
|
114
|
+
slippage_bps=slippage_bps, label=dataset.dataset_id)
|
|
115
|
+
trust = dataset.trust.merge(TrustReport(tuple(flags)))
|
|
116
|
+
return BacktestResult(result, trust, metrics.summary(result.daily["nav"], float(capital)))
|
quantrail/core.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Core types shared by every layer: provenance, declarations, trust flags, instruments, money.
|
|
2
|
+
|
|
3
|
+
Nothing here knows about a particular market. See docs/adr/0002 and docs/adr/0003.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from decimal import Decimal, InvalidOperation
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
|
|
12
|
+
UNKNOWN = "unknown"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Severity(StrEnum):
|
|
16
|
+
INFO = "info"
|
|
17
|
+
WARNING = "warning"
|
|
18
|
+
BLOCKING = "blocking"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class TrustFlag:
|
|
23
|
+
"""A machine-readable caveat that travels with a dataset into every result."""
|
|
24
|
+
|
|
25
|
+
code: str
|
|
26
|
+
severity: Severity
|
|
27
|
+
message: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class TrustReport:
|
|
32
|
+
flags: tuple[TrustFlag, ...] = ()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def clean(self) -> bool:
|
|
36
|
+
return not self.flags
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def blocking(self) -> tuple[TrustFlag, ...]:
|
|
40
|
+
return tuple(f for f in self.flags if f.severity is Severity.BLOCKING)
|
|
41
|
+
|
|
42
|
+
def merge(self, *others: TrustReport) -> TrustReport:
|
|
43
|
+
seen = {f.code: f for f in self.flags}
|
|
44
|
+
for other in others:
|
|
45
|
+
for flag in other.flags:
|
|
46
|
+
seen.setdefault(flag.code, flag)
|
|
47
|
+
return TrustReport(tuple(seen.values()))
|
|
48
|
+
|
|
49
|
+
def render(self) -> str:
|
|
50
|
+
if self.clean:
|
|
51
|
+
return "Trust report: no caveats recorded."
|
|
52
|
+
marks = {Severity.INFO: "i", Severity.WARNING: "!", Severity.BLOCKING: "x"}
|
|
53
|
+
lines = ["Trust report:"]
|
|
54
|
+
lines += [f" [{marks[f.severity]}] {f.code}: {f.message}" for f in self.flags]
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Provenance:
|
|
60
|
+
"""Where data came from and under what terms. Every field may be unknown (ADR 0002)."""
|
|
61
|
+
|
|
62
|
+
source: str = UNKNOWN
|
|
63
|
+
license: str = UNKNOWN
|
|
64
|
+
terms_url: str | None = None
|
|
65
|
+
retrieved_at: str | None = None
|
|
66
|
+
notes: str = ""
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def license_known(self) -> bool:
|
|
70
|
+
return self.license.strip().lower() != UNKNOWN and bool(self.license.strip())
|
|
71
|
+
|
|
72
|
+
def trust(self) -> TrustReport:
|
|
73
|
+
flags = []
|
|
74
|
+
if self.source.strip().lower() in {"", UNKNOWN}:
|
|
75
|
+
flags.append(TrustFlag("SOURCE_UNKNOWN", Severity.WARNING,
|
|
76
|
+
"Data source is not recorded."))
|
|
77
|
+
if not self.license_known:
|
|
78
|
+
flags.append(TrustFlag("LICENCE_UNKNOWN", Severity.WARNING,
|
|
79
|
+
"Licence is unknown: private research only; "
|
|
80
|
+
"exporting or sharing requires an explicit acknowledgement."))
|
|
81
|
+
return TrustReport(tuple(flags))
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict:
|
|
84
|
+
return asdict(self)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_dict(cls, data: dict) -> Provenance:
|
|
88
|
+
return cls(**data)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PriceBasis(StrEnum):
|
|
92
|
+
RAW = "raw"
|
|
93
|
+
SPLIT_ADJUSTED = "split_adjusted"
|
|
94
|
+
TOTAL_RETURN = "total_return"
|
|
95
|
+
UNKNOWN = UNKNOWN
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class Declaration:
|
|
100
|
+
"""What a dataset claims about itself.
|
|
101
|
+
|
|
102
|
+
availability: when each row becomes known, e.g. "close" (after the session close),
|
|
103
|
+
"column:published_at" (per-row timestamp column) or "unknown".
|
|
104
|
+
corporate_actions: True if dividend/split events are supplied, False if known to be
|
|
105
|
+
absent, None if not stated.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
price_basis: PriceBasis = PriceBasis.UNKNOWN
|
|
109
|
+
availability: str = UNKNOWN
|
|
110
|
+
corporate_actions: bool | None = None
|
|
111
|
+
|
|
112
|
+
def trust(self) -> TrustReport:
|
|
113
|
+
flags = []
|
|
114
|
+
if self.price_basis is PriceBasis.UNKNOWN:
|
|
115
|
+
flags.append(TrustFlag(
|
|
116
|
+
"PRICE_BASIS_UNKNOWN", Severity.WARNING,
|
|
117
|
+
"Prices may be raw or adjusted; accounting uses a conservative approximation."))
|
|
118
|
+
if self.price_basis is not PriceBasis.RAW or self.corporate_actions is not True:
|
|
119
|
+
flags.append(TrustFlag(
|
|
120
|
+
"ACCOUNTING_APPROXIMATE", Severity.WARNING,
|
|
121
|
+
"Ledger-accurate accounting needs raw prices plus dividend and split events."))
|
|
122
|
+
if self.availability.strip().lower() in {"", UNKNOWN}:
|
|
123
|
+
flags.append(TrustFlag(
|
|
124
|
+
"AVAILABILITY_UNVERIFIED", Severity.WARNING,
|
|
125
|
+
"Availability time is not declared; results may use data before it was known."))
|
|
126
|
+
return TrustReport(tuple(flags))
|
|
127
|
+
|
|
128
|
+
def to_dict(self) -> dict:
|
|
129
|
+
return {"price_basis": self.price_basis.value, "availability": self.availability,
|
|
130
|
+
"corporate_actions": self.corporate_actions}
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_dict(cls, data: dict) -> Declaration:
|
|
134
|
+
return cls(PriceBasis(data["price_basis"]), data["availability"], data["corporate_actions"])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def to_decimal(value, name: str = "value") -> Decimal:
|
|
138
|
+
"""Exact conversion; floats go through str() so 0.1 stays 0.1."""
|
|
139
|
+
try:
|
|
140
|
+
result = value if isinstance(value, Decimal) else Decimal(str(value))
|
|
141
|
+
except (InvalidOperation, ValueError, TypeError) as error:
|
|
142
|
+
raise ValueError(f"{name} is not a number: {value!r}") from error
|
|
143
|
+
if not result.is_finite():
|
|
144
|
+
raise ValueError(f"{name} must be finite: {value!r}")
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class Money:
|
|
150
|
+
amount: Decimal
|
|
151
|
+
currency: str
|
|
152
|
+
|
|
153
|
+
def __post_init__(self):
|
|
154
|
+
object.__setattr__(self, "amount", to_decimal(self.amount, "amount"))
|
|
155
|
+
if not self.currency or self.currency != self.currency.upper():
|
|
156
|
+
raise ValueError(f"currency must be an upper-case code: {self.currency!r}")
|
|
157
|
+
|
|
158
|
+
def _same(self, other: Money) -> None:
|
|
159
|
+
if not isinstance(other, Money) or other.currency != self.currency:
|
|
160
|
+
raise ValueError("Money arithmetic needs the same currency; convert explicitly first.")
|
|
161
|
+
|
|
162
|
+
def __add__(self, other: Money) -> Money:
|
|
163
|
+
self._same(other)
|
|
164
|
+
return Money(self.amount + other.amount, self.currency)
|
|
165
|
+
|
|
166
|
+
def __sub__(self, other: Money) -> Money:
|
|
167
|
+
self._same(other)
|
|
168
|
+
return Money(self.amount - other.amount, self.currency)
|
|
169
|
+
|
|
170
|
+
def __neg__(self) -> Money:
|
|
171
|
+
return Money(-self.amount, self.currency)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class Instrument:
|
|
176
|
+
"""A tradable thing. Quantity rules belong to the instrument, not to the core.
|
|
177
|
+
|
|
178
|
+
quantity_step: smallest increment (1 share, 1000 shares, 0.00001 BTC).
|
|
179
|
+
min_quantity: smallest order size; defaults to one step.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
id: str
|
|
183
|
+
venue: str
|
|
184
|
+
asset_class: str
|
|
185
|
+
quote_currency: str
|
|
186
|
+
quantity_step: Decimal = Decimal("1")
|
|
187
|
+
min_quantity: Decimal | None = None
|
|
188
|
+
price_tick: Decimal | None = None
|
|
189
|
+
attributes: dict = field(default_factory=dict, compare=False)
|
|
190
|
+
|
|
191
|
+
def __post_init__(self):
|
|
192
|
+
step = to_decimal(self.quantity_step, "quantity_step")
|
|
193
|
+
if step <= 0:
|
|
194
|
+
raise ValueError("quantity_step must be positive")
|
|
195
|
+
object.__setattr__(self, "quantity_step", step)
|
|
196
|
+
minimum = step if self.min_quantity is None else to_decimal(self.min_quantity, "min_quantity")
|
|
197
|
+
if minimum < step or (minimum / step) % 1 != 0:
|
|
198
|
+
raise ValueError("min_quantity must be a positive multiple of quantity_step")
|
|
199
|
+
object.__setattr__(self, "min_quantity", minimum)
|
|
200
|
+
|
|
201
|
+
def validate_quantity(self, quantity) -> Decimal:
|
|
202
|
+
"""Return the quantity as Decimal if it is on the step grid, else raise."""
|
|
203
|
+
q = to_decimal(quantity, "quantity")
|
|
204
|
+
if q < 0:
|
|
205
|
+
raise ValueError("quantity must be non-negative")
|
|
206
|
+
if (q / self.quantity_step) % 1 != 0:
|
|
207
|
+
raise ValueError(f"{q} is not a multiple of {self.quantity_step} for {self.id}")
|
|
208
|
+
if 0 < q < self.min_quantity:
|
|
209
|
+
raise ValueError(f"{q} is below the minimum {self.min_quantity} for {self.id}")
|
|
210
|
+
return q
|
|
211
|
+
|
|
212
|
+
def floor_quantity(self, quantity) -> Decimal:
|
|
213
|
+
"""Largest valid quantity not above `quantity` (0 if below the minimum)."""
|
|
214
|
+
q = to_decimal(quantity, "quantity")
|
|
215
|
+
if q <= 0:
|
|
216
|
+
return Decimal("0")
|
|
217
|
+
floored = (q // self.quantity_step) * self.quantity_step
|
|
218
|
+
return floored if floored >= self.min_quantity else Decimal("0")
|