morphiq-sdk 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.
@@ -0,0 +1,91 @@
1
+ """Provider-neutral price data. No data fetching or optional dependencies live here."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import UTC, date, datetime
5
+ from math import isfinite
6
+ from typing import Literal, Protocol
7
+
8
+ PriceInterval = Literal["1d", "1m", "5m", "15m", "30m", "60m"]
9
+ PriceAdjustment = Literal["split", "split_and_dividend", "unadjusted"]
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class PriceBar:
14
+ """A close with a session date or timezone-aware bar-start label.
15
+
16
+ The label is not an exact quote/close timestamp. A final intraday bar may
17
+ still be forming; it must not automatically become a regime request's as_of.
18
+ """
19
+
20
+ time: date | datetime
21
+ close: float
22
+
23
+ def __post_init__(self) -> None:
24
+ if not isfinite(self.close) or self.close <= 0:
25
+ raise ValueError("Close must be finite and positive")
26
+ if not isinstance(self.time, date):
27
+ raise ValueError("A price label must be a date or datetime")
28
+ if isinstance(self.time, datetime) and self.time.utcoffset() is None:
29
+ raise ValueError("Intraday timestamps must include a timezone")
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class PriceHistory:
34
+ """Nonempty, oldest-first prices with explicit provider and adjustment identity.
35
+
36
+ Gaps between bars are preserved. No interpolation, resampling or currency
37
+ conversion is implied. Prices retain the source's quoted units.
38
+ """
39
+
40
+ symbol: str
41
+ interval: PriceInterval
42
+ adjustment: PriceAdjustment
43
+ source: str
44
+ bars: tuple[PriceBar, ...]
45
+
46
+ def __post_init__(self) -> None:
47
+ if not isinstance(self.bars, tuple) or not self.bars:
48
+ raise ValueError("History requires a nonempty tuple of bars")
49
+ if self.interval not in ("1d", "1m", "5m", "15m", "30m", "60m"):
50
+ raise ValueError("Unsupported price interval")
51
+ if self.adjustment not in ("split", "split_and_dividend", "unadjusted"):
52
+ raise ValueError("Unknown price adjustment")
53
+ if not self.symbol.strip() or not self.source.strip():
54
+ raise ValueError("Symbol and source are required")
55
+ for bar in self.bars:
56
+ intraday_label = isinstance(bar.time, datetime)
57
+ if intraday_label != (self.interval != "1d"):
58
+ raise ValueError("Daily bars need session dates; intraday bars need datetimes")
59
+ labels = [
60
+ bar.time.astimezone(UTC) if isinstance(bar.time, datetime) else bar.time
61
+ for bar in self.bars
62
+ ]
63
+ if any(left >= right for left, right in zip(labels, labels[1:], strict=False)):
64
+ raise ValueError("Price labels must be unique and ordered oldest to newest")
65
+
66
+ @property
67
+ def prices(self) -> tuple[float, ...]:
68
+ return tuple(bar.close for bar in self.bars)
69
+
70
+ @property
71
+ def latest(self) -> PriceBar:
72
+ return self.bars[-1]
73
+
74
+
75
+ class PriceProvider(Protocol):
76
+ """Optional source of regular-session price bars, separate from the API client.
77
+
78
+ Date bounds use the instrument's exchange calendar: start inclusive, end
79
+ exclusive. Providers declare adjustments in the result and raise on invalid
80
+ or unavailable data. Available history can be shorter than the requested
81
+ date range; callers enforce their own required observation count.
82
+ """
83
+
84
+ def history(
85
+ self,
86
+ symbol: str,
87
+ *,
88
+ start: date,
89
+ end: date,
90
+ interval: PriceInterval = "1d",
91
+ ) -> PriceHistory: ...
morphiq_sdk/models.py ADDED
@@ -0,0 +1,442 @@
1
+ """API wire models for the reviewed HTTP contract; no pricing logic lives here."""
2
+
3
+ from typing import Annotated, Any, Generic, Literal, TypeVar
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, FiniteFloat, PrivateAttr
6
+
7
+ ExerciseStyle = Literal["european", "american"]
8
+ OptionType = Literal["call", "put"]
9
+ PricingModel = Literal[
10
+ "auto", "black_scholes_merton", "bjerksund_stensland_2002", "bjerksund_stensland_1993"
11
+ ]
12
+
13
+
14
+ class _Request(BaseModel):
15
+ model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False, frozen=True)
16
+
17
+
18
+ class DividendEvent(_Request):
19
+ """Cash per share at an ex-dividend time expressed in years from valuation."""
20
+
21
+ ex_time_years: FiniteFloat
22
+ cash_amount: FiniteFloat
23
+
24
+
25
+ class _OptionRequest(_Request):
26
+ """Common option inputs. Rates and yield are decimals; time is in years.
27
+
28
+ Numeric/model-domain validation belongs to the service. The SDK rejects
29
+ malformed wire types and non-finite values without clamping or repricing.
30
+ """
31
+
32
+ exercise_style: ExerciseStyle
33
+ option_type: OptionType
34
+ spot: FiniteFloat
35
+ strike: FiniteFloat
36
+ time_to_expiry_years: FiniteFloat
37
+ risk_free_rate: FiniteFloat
38
+ dividend_yield: FiniteFloat
39
+ pricing_model: PricingModel = "auto"
40
+ model_version: str | None = None
41
+ dividend_schedule: list[DividendEvent] = Field(default_factory=list, max_length=16)
42
+
43
+ def to_wire(self) -> dict[str, Any]:
44
+ """Validate current inputs, preserving omission and explicit null/empty values."""
45
+ values = self.model_dump(exclude={"dividend_schedule"}, exclude_unset=True)
46
+ # Freezing the model does not freeze lists or track their in-place edits.
47
+ # Include nonempty schedules and revalidate edits before JSON serialization.
48
+ if self.dividend_schedule or "dividend_schedule" in self.model_fields_set:
49
+ values["dividend_schedule"] = self.dividend_schedule
50
+ validated = type(self).model_validate(values)
51
+ result = validated.model_dump(mode="json", exclude_unset=True)
52
+ # This required wire field has a convenience default in Python.
53
+ result["pricing_model"] = self.pricing_model
54
+ return result
55
+
56
+
57
+ class PriceRequest(_OptionRequest):
58
+ """Price an option at a supplied annual volatility, expressed as a decimal."""
59
+
60
+ volatility: FiniteFloat
61
+
62
+
63
+ class ImpliedVolatilityRequest(_OptionRequest):
64
+ """Solve volatility from a per-share market price in the spot/strike currency.
65
+
66
+ Volatility is an output, not a request field. The service validates premiums
67
+ and numerical domains without SDK-side clamping or local solving.
68
+ """
69
+
70
+ market_price: FiniteFloat
71
+
72
+
73
+ class _Response(BaseModel):
74
+ # Retain additive fields. A new resolved model name must not break decoding.
75
+ model_config = ConfigDict(extra="allow", strict=True, allow_inf_nan=False)
76
+
77
+
78
+ class GreeksRequest(PriceRequest):
79
+ """Price and all ten Greeks at a supplied volatility, using the pricing inputs.
80
+
81
+ Cash-dividend schedules are forwarded unchanged. The service owns model
82
+ selection, schedule support and numerical-domain validation.
83
+ """
84
+
85
+
86
+ class AnalyticsRequest(PriceRequest):
87
+ """Price and Greeks, optionally evaluated at IV solved from a market premium.
88
+
89
+ The wire contract requires volatility even with a premium; the service uses
90
+ it only when market_price is absent or null. Preserve both fields unchanged.
91
+ """
92
+
93
+ market_price: FiniteFloat | None = None
94
+
95
+
96
+ class Greeks(_Response):
97
+ """Unscaled API values: time derivatives per year; volatility/rates per unit.
98
+
99
+ No calendar-day, percentage-point or contract-multiplier conversion occurs.
100
+ """
101
+
102
+ delta: FiniteFloat
103
+ gamma: FiniteFloat
104
+ theta: FiniteFloat
105
+ vega: FiniteFloat
106
+ rho: FiniteFloat
107
+ vanna: FiniteFloat
108
+ volga: FiniteFloat
109
+ charm: FiniteFloat
110
+ veta: FiniteFloat
111
+ color: FiniteFloat
112
+
113
+
114
+ class GreeksResult(_Response):
115
+ price: FiniteFloat
116
+ greeks: Greeks
117
+ pricing_model: str
118
+ model_version: str
119
+
120
+
121
+ class ImpliedVolatilityResult(_Response):
122
+ """Unmodified solver diagnostics. Volatility is a decimal fraction.
123
+
124
+ Known residual bases are ``forward_normalised_price`` and ``option_price``;
125
+ retain future basis names without interpreting or converting their values.
126
+ ``used_fallback`` describes the algorithm, not solution quality.
127
+ """
128
+
129
+ implied_volatility: FiniteFloat
130
+ iterations: int = Field(ge=0)
131
+ used_fallback: bool
132
+ residual: FiniteFloat
133
+ residual_basis: str
134
+ pricing_model: str
135
+ model_version: str
136
+
137
+
138
+ class AnalyticsResult(GreeksResult):
139
+ """Combined result; the nested IV solve has no separate commercial charge."""
140
+
141
+ implied_volatility: ImpliedVolatilityResult | None = None
142
+
143
+
144
+ class EngineIdentity(_Response):
145
+ name: str
146
+ version: str
147
+ revision: str
148
+
149
+
150
+ class PolicyMetadata(_Response):
151
+ snapshot_id: str
152
+ revision: str
153
+ plan: str
154
+ records: list[str]
155
+
156
+
157
+ class _CommercialMetadata(_Response):
158
+ fcu_quoted: int | None = Field(default=None, ge=0)
159
+ fcu_charged: int | None = Field(default=None, ge=0)
160
+ fcu_schedule_id: str | None = None
161
+ fcu_schedule_version: str | None = None
162
+ policy: PolicyMetadata | None = None
163
+ remaining_fcu_instance_estimate: int | None = Field(default=None, ge=0)
164
+ budget_period: str | None = None
165
+ budget_reset_at: str | None = None
166
+
167
+
168
+ class ResponseMetadata(_CommercialMetadata):
169
+ """Server provenance and optional commercial CU evidence.
170
+
171
+ ``units_charged`` is an internal compute weight, not a commercial CU charge.
172
+ Missing ``fcu_charged`` means unreported, not zero.
173
+ """
174
+
175
+ request_id: str
176
+ api_version: str
177
+ engine_version: str
178
+ engines: list[EngineIdentity]
179
+ units_charged: int = Field(ge=0)
180
+ compute_schedule_version: str
181
+
182
+
183
+ class ServiceErrorMetadata(_CommercialMetadata):
184
+ request_id: str
185
+ api_version: str
186
+ service_version: str
187
+
188
+
189
+ class PriceResult(_Response):
190
+ price: FiniteFloat
191
+ exercise_style: ExerciseStyle
192
+ option_type: OptionType
193
+ pricing_model: str
194
+ model_version: str
195
+
196
+
197
+ class _EvidenceResponse(_Response):
198
+ _raw_body: bytes = PrivateAttr(default=b"")
199
+ _headers: dict[str, str] = PrivateAttr(default_factory=dict)
200
+
201
+ @property
202
+ def raw_body(self) -> bytes:
203
+ """JSON response bytes, with any reflected API key redacted."""
204
+ return self._raw_body
205
+
206
+ @property
207
+ def headers(self) -> dict[str, str]:
208
+ """HTTP response headers without retaining a credential-bearing request."""
209
+ return self._headers.copy()
210
+
211
+
212
+ class PriceResponse(_EvidenceResponse):
213
+ result: PriceResult
214
+ metadata: ResponseMetadata
215
+
216
+
217
+ class GreeksResponse(_EvidenceResponse):
218
+ result: GreeksResult
219
+ metadata: ResponseMetadata
220
+
221
+
222
+ class ImpliedVolatilityResponse(_EvidenceResponse):
223
+ result: ImpliedVolatilityResult
224
+ metadata: ResponseMetadata
225
+
226
+
227
+ class AnalyticsResponse(_EvidenceResponse):
228
+ result: AnalyticsResult
229
+ metadata: ResponseMetadata
230
+
231
+
232
+ class RegimeRequest(_Request):
233
+ """Comparable positive prices, oldest first; no sampling interval is assumed.
234
+
235
+ V1 requires 512 prices and consumes the latest 507. Supply split-adjusted
236
+ closes without cash-dividend total-return adjustment. The service validates
237
+ timestamp/domain semantics and request byte limits; no cropping occurs here.
238
+ """
239
+
240
+ prices: list[Annotated[FiniteFloat, Field(gt=0)]] = Field(min_length=512)
241
+ as_of: str | None = Field(default=None, max_length=64)
242
+
243
+ def to_wire(self) -> dict[str, Any]:
244
+ # Revalidate mutable price lists without dropping explicit null timestamps.
245
+ values = self.model_dump(exclude={"prices"}, exclude_unset=True)
246
+ values["prices"] = self.prices
247
+ validated = type(self).model_validate(values)
248
+ return validated.model_dump(mode="json", exclude_unset=True)
249
+
250
+
251
+ RegimeDecisionLabel = Literal["not_eligible", "supported", "unsupported", "unresolved"]
252
+ RegimeDirectionLabel = Literal["UP", "DOWN", "NEUTRAL"]
253
+ RegimeStateLabel = Literal["LOWER_VOL", "HIGHER_VOL"]
254
+ _Label = TypeVar("_Label")
255
+ _Prediction = TypeVar("_Prediction")
256
+
257
+
258
+ class RegimeScore(_Response, Generic[_Label]):
259
+ label: _Label
260
+ value: FiniteFloat = Field(ge=0, le=1)
261
+
262
+
263
+ class RegimePrediction(_Response, Generic[_Label]):
264
+ label: _Label
265
+ scores: list[RegimeScore[_Label]]
266
+ selection: Literal["argmax"]
267
+
268
+
269
+ class RegimeDecisionPrediction(RegimePrediction[RegimeDecisionLabel]):
270
+ scores: list[RegimeScore[RegimeDecisionLabel]] = Field(min_length=4, max_length=4)
271
+
272
+
273
+ class RegimeDirectionPrediction(RegimePrediction[RegimeDirectionLabel]):
274
+ scores: list[RegimeScore[RegimeDirectionLabel]] = Field(min_length=3, max_length=3)
275
+
276
+
277
+ class RegimeStatePrediction(RegimePrediction[RegimeStateLabel]):
278
+ scores: list[RegimeScore[RegimeStateLabel]] = Field(min_length=2, max_length=2)
279
+
280
+
281
+ class RegimeBasis(_Response):
282
+ target: Literal[
283
+ "reference_procedure_outcome",
284
+ "selected_state_tendency",
285
+ "filtered_scale_order_membership",
286
+ ]
287
+
288
+
289
+ class RegimeAvailable(_Response, Generic[_Prediction]):
290
+ status: Literal["available"]
291
+ basis: RegimeBasis
292
+ prediction: _Prediction
293
+
294
+
295
+ class RegimeNotApplicable(_Response):
296
+ status: Literal["not_applicable"]
297
+ basis: RegimeBasis
298
+ prediction: None
299
+ reason_code: Literal["predicted_not_eligible"]
300
+
301
+
302
+ class RegimeUnavailable(_Response):
303
+ status: Literal["unavailable"]
304
+ basis: RegimeBasis
305
+ prediction: None
306
+ reason_code: Literal["prediction_unavailable"]
307
+
308
+
309
+ class RegimeScoreContract(_Response):
310
+ kind: Literal["uncalibrated_softmax"]
311
+ version: str
312
+ sum_tolerance: FiniteFloat
313
+
314
+
315
+ class RegimeResult(_Response):
316
+ score_contract: RegimeScoreContract
317
+ decision: Annotated[
318
+ RegimeAvailable[RegimeDecisionPrediction] | RegimeNotApplicable | RegimeUnavailable,
319
+ Field(discriminator="status"),
320
+ ]
321
+ direction: Annotated[
322
+ RegimeAvailable[RegimeDirectionPrediction] | RegimeNotApplicable | RegimeUnavailable,
323
+ Field(discriminator="status"),
324
+ ]
325
+ state: Annotated[
326
+ RegimeAvailable[RegimeStatePrediction] | RegimeNotApplicable | RegimeUnavailable,
327
+ Field(discriminator="status"),
328
+ ]
329
+
330
+
331
+ class RegimeProvenance(_Response):
332
+ model_id: str
333
+ model_status: str
334
+ model_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
335
+ preprocessing: str
336
+ teacher_executed: bool
337
+
338
+
339
+ class RegimeConsumedPriceRange(_Response):
340
+ start_index: int = Field(ge=0)
341
+ end_index_exclusive: int = Field(ge=0)
342
+
343
+
344
+ class RegimeMetadata(_CommercialMetadata):
345
+ response_contract_version: str
346
+ request_id: str
347
+ api_version: str
348
+ service_version: str
349
+ engines: list[EngineIdentity]
350
+ provenance: RegimeProvenance
351
+ received_price_count: int = Field(ge=0)
352
+ window_price_count: int = Field(ge=0)
353
+ price_count: int = Field(ge=0)
354
+ return_count: int = Field(ge=0)
355
+ consumed_price_range: RegimeConsumedPriceRange
356
+ observation_index: int = Field(ge=0)
357
+ input_price_index: int = Field(ge=0)
358
+ as_of: str | None
359
+ return_type: Literal["log"]
360
+ return_unit: Literal["fraction"]
361
+ inference_time_us: int = Field(ge=0)
362
+ all_prices_consumed: bool
363
+
364
+
365
+ class RegimeResponse(_EvidenceResponse):
366
+ result: RegimeResult
367
+ metadata: RegimeMetadata
368
+
369
+
370
+ class ErrorDetails(_Response):
371
+ code: str
372
+ message: str
373
+ field: str | None = None
374
+
375
+
376
+ class ErrorEnvelope(_Response):
377
+ error: ErrorDetails
378
+ metadata: ResponseMetadata | ServiceErrorMetadata
379
+
380
+
381
+ _OptionRequestType = TypeVar("_OptionRequestType", bound=_OptionRequest)
382
+ _OptionResultType = TypeVar("_OptionResultType", bound=_Response)
383
+
384
+
385
+ class _BatchRequest(_Request, Generic[_OptionRequestType]):
386
+ # This is the reviewed wire limit, not an account/plan limit.
387
+ requests: list[_OptionRequestType] = Field(min_length=1, max_length=1024)
388
+
389
+ def to_wire(self) -> dict[str, Any]:
390
+ validated = type(self).model_validate({"requests": self.requests})
391
+ return {"requests": [request.to_wire() for request in validated.requests]}
392
+
393
+
394
+ class BatchPriceRequest(_BatchRequest[PriceRequest]):
395
+ """One explicit batch of pricing requests in caller order."""
396
+
397
+
398
+ class BatchGreeksRequest(_BatchRequest[GreeksRequest]):
399
+ """One explicit batch of price-and-Greeks requests."""
400
+
401
+
402
+ class BatchImpliedVolatilityRequest(_BatchRequest[ImpliedVolatilityRequest]):
403
+ """One explicit batch of IV solves."""
404
+
405
+
406
+ class BatchAnalyticsRequest(_BatchRequest[AnalyticsRequest]):
407
+ """One explicit batch of combined analytics requests."""
408
+
409
+
410
+ class BatchSuccess(_Response, Generic[_OptionResultType]):
411
+ index: int = Field(ge=0)
412
+ status: Literal["success"]
413
+ result: _OptionResultType
414
+
415
+
416
+ class BatchFailure(_Response):
417
+ index: int = Field(ge=0)
418
+ status: Literal["error"]
419
+ error: ErrorDetails
420
+
421
+
422
+ class _BatchResponse(_EvidenceResponse, Generic[_OptionResultType]):
423
+ metadata: ResponseMetadata
424
+ results: list[
425
+ Annotated[BatchSuccess[_OptionResultType] | BatchFailure, Field(discriminator="status")]
426
+ ]
427
+
428
+
429
+ class BatchPriceResponse(_BatchResponse[PriceResult]):
430
+ """Ordered outcomes and one metadata/charge block for the entire batch."""
431
+
432
+
433
+ class BatchGreeksResponse(_BatchResponse[GreeksResult]):
434
+ """Ordered Greeks outcomes and batch-level charge evidence."""
435
+
436
+
437
+ class BatchImpliedVolatilityResponse(_BatchResponse[ImpliedVolatilityResult]):
438
+ """Ordered IV outcomes and batch-level charge evidence."""
439
+
440
+
441
+ class BatchAnalyticsResponse(_BatchResponse[AnalyticsResult]):
442
+ """Ordered combined analytics outcomes and batch-level charge evidence."""