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