diff --git a/README.md b/README.md index 47a083d..918da90 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Option pricing, Greeks, volatility and market regimes in Python -Price European and American options, calculate ten Greeks, solve implied +Price European and American options, calculate standard-five or all-ten Greeks, solve implied volatility, and identify market regimes from price histories. `morphiq-sdk` is a typed Python client for the MorphIQ API, with synchronous and @@ -11,6 +11,12 @@ valuation, sensitivity analysis and market-regime identification. See the [documentation index](https://github.com/MorphIQ-Labs/morphiq-sdk-python/blob/main/docs/README.md) for all feature guides and the [Python API reference](https://github.com/MorphIQ-Labs/morphiq-sdk-python/blob/main/docs/API.md) for methods, models and errors. +This client targets API **v0.5.0**, including public model discovery, +standard-five Greeks, cost utilities and spend caps. SDK 0.1.x lacks these +additions; use the release containing this contract update or install this checkout. +See [model capabilities and migration](https://github.com/MorphIQ-Labs/morphiq-sdk-python/blob/main/docs/MODELS.md): BSM/BS1993 support batches +up to 100; QdFp and cash-dividend finite difference support single price and IV. + ## Quickstart ### 1. Get API access @@ -70,26 +76,26 @@ and prints the JSON response. Example response from the recorded synthetic fixtu ```json { - "result": { - "price": 10.450583572185538, - "exercise_style": "european", - "option_type": "call", - "pricing_model": "black_scholes_merton", - "model_version": "black_scholes_merton/1" - }, "metadata": { - "request_id": "fixture-european", + "request_id": "fr-18e4e90129262501-0000000000000003", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 1, "compute_schedule_version": "2026-09-11" + }, + "result": { + "price": 10.45058357218557, + "exercise_style": "european", + "option_type": "call", + "pricing_model": "black_scholes_merton", + "model_version": "black_scholes_merton/1" } } ``` @@ -207,3 +213,5 @@ docs/ Usage guides and contract documentation The SDK, examples and notebooks are licensed under the [MIT License](https://github.com/MorphIQ-Labs/morphiq-sdk-python/blob/main/LICENSE). + +See [cost schedules and request quotes](docs/COST.md) for zero-CU discovery, conditional quotes, and the optional `max_cu` execution ceiling. diff --git a/contracts/openapi.json b/contracts/openapi.json index d30e6e2..6d19cee 100644 --- a/contracts/openapi.json +++ b/contracts/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "MorphIQ API", "description": "Native HTTP API for MorphIQ Labs pricing, risk and regime analysis", - "version": "3.0.0" + "version": "0.1.0" }, "paths": { "/health": { @@ -170,6 +170,19 @@ "pricing" ], "operationId": "fusedAnalytics", + "parameters": [ + { + "name": "x-morphiq-max-cu", + "in": "header", + "description": "Optional maximum commercial CU charge for this whole request. An actual quote above this ceiling is refused before commitment with 409 cost_limit_exceeded and zero charged CUs. Applies equally to JSON, Protobuf and gRPC metadata. Zero is allowed; duplicate or malformed values are rejected.", + "required": false, + "schema": { + "type": "integer", + "maximum": 18446744073709551615, + "minimum": 0 + } + } + ], "requestBody": { "description": "FusedAnalyticsRequest encoded as JSON or morphiq.ferro_risk.v1.FusedAnalyticsRequest", "content": { @@ -211,7 +224,7 @@ } }, "400": { - "description": "Malformed request", + "description": "Malformed request, or disabled model operation (unsupported_operation): QdFp Greeks/analytics or cash-dividend finite-difference Greeks/analytics, including Auto", "headers": { "x-request-id": { "schema": { @@ -319,7 +332,7 @@ } }, "409": { - "description": "The reservation expired before execution began", + "description": "Reservation expired, or the quote exceeds x-morphiq-max-cu; check error.code. Zero commercial charge.", "headers": { "x-request-id": { "schema": { @@ -482,7 +495,21 @@ "tags": [ "pricing" ], + "description": "Synchronous BSM/BS1993 batches only; at most 100 contracts or the lower account limit. Any QdFp or cash-dividend item rejects the whole batch before reservation or execution. Results preserve input order; the charge sums per-contract CUs.", "operationId": "fusedAnalyticsBatch", + "parameters": [ + { + "name": "x-morphiq-max-cu", + "in": "header", + "description": "Optional maximum commercial CU charge for this whole request. An actual quote above this ceiling is refused before commitment with 409 cost_limit_exceeded and zero charged CUs. Applies equally to JSON, Protobuf and gRPC metadata. Zero is allowed; duplicate or malformed values are rejected.", + "required": false, + "schema": { + "type": "integer", + "maximum": 18446744073709551615, + "minimum": 0 + } + } + ], "requestBody": { "description": "BatchFusedAnalyticsRequest encoded as JSON or morphiq.ferro_risk.v1.BatchFusedAnalyticsRequest", "content": { @@ -632,7 +659,7 @@ } }, "409": { - "description": "The reservation expired before execution began", + "description": "Reservation expired, or the quote exceeds x-morphiq-max-cu; check error.code. Zero commercial charge.", "headers": { "x-request-id": { "schema": { @@ -790,23 +817,17 @@ } } }, - "/v1/greeks": { + "/v1/cost/quote": { "post": { "tags": [ - "pricing" + "cost" ], - "operationId": "greeksAllTen", + "operationId": "quoteCost", "requestBody": { - "description": "GreeksAllTenRequest encoded as JSON or morphiq.ferro_risk.v1.GreeksAllTenRequest", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GreeksAllTenRequest" - } - }, - "application/protobuf": { - "schema": { - "$ref": "#/components/schemas/ProtobufBody" + "$ref": "#/components/schemas/CostQuoteRequest" } } }, @@ -814,7 +835,7 @@ }, "responses": { "200": { - "description": "Numerical result", + "description": "Exact conditional CU quote; no execution, reservation or charge", "headers": { "x-request-id": { "schema": { @@ -826,18 +847,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GreeksAllTenResponse" - } - }, - "application/protobuf": { - "schema": { - "$ref": "#/components/schemas/ProtobufBody" + "$ref": "#/components/schemas/CostQuoteResponse" } } } }, "400": { - "description": "Malformed request", + "description": "Malformed JSON", "headers": { "x-request-id": { "schema": { @@ -849,13 +865,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" + "$ref": "#/components/schemas/ServiceErrorEnvelope" } } } }, "401": { - "description": "Credential missing or not verifiable", + "description": "Invalid credential", "headers": { "x-request-id": { "schema": { @@ -867,13 +883,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" + "$ref": "#/components/schemas/ServiceErrorEnvelope" } } } }, - "402": { - "description": "The account's funding is spent until a top-up or the next budget period", + "403": { + "description": "Account or target operation forbidden", "headers": { "x-request-id": { "schema": { @@ -885,13 +901,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" + "$ref": "#/components/schemas/ServiceErrorEnvelope" } } } }, - "403": { - "description": "The account is not permitted, or its plan does not include this operation", + "406": { + "description": "JSON response not acceptable", "headers": { "x-request-id": { "schema": { @@ -903,13 +919,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" + "$ref": "#/components/schemas/ServiceErrorEnvelope" } } } }, - "406": { - "description": "Response media type is not acceptable", + "408": { + "description": "Body deadline exceeded", "headers": { "x-request-id": { "schema": { @@ -921,13 +937,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" + "$ref": "#/components/schemas/ServiceErrorEnvelope" } } } }, - "408": { - "description": "Request body deadline exceeded", + "413": { + "description": "Request too large", "headers": { "x-request-id": { "schema": { @@ -939,13 +955,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" + "$ref": "#/components/schemas/ServiceErrorEnvelope" } } } }, - "409": { - "description": "The reservation expired before execution began", + "415": { + "description": "JSON required", "headers": { "x-request-id": { "schema": { @@ -957,13 +973,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" + "$ref": "#/components/schemas/ServiceErrorEnvelope" } } } }, - "413": { - "description": "Request too large", + "422": { + "description": "Invalid or unquotable request", "headers": { "x-request-id": { "schema": { @@ -975,14 +991,20 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" + "$ref": "#/components/schemas/ServiceErrorEnvelope" } } } }, - "415": { - "description": "Unsupported media type", + "429": { + "description": "Utility rate or account concurrency exhausted", "headers": { + "Retry-After": { + "schema": { + "type": "string" + }, + "description": "Minimum retry delay in seconds" + }, "x-request-id": { "schema": { "type": "string" @@ -993,13 +1015,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" + "$ref": "#/components/schemas/ServiceErrorEnvelope" } } } }, - "422": { - "description": "Invalid pricing request, or work that cannot be priced under the account's limits", + "500": { + "description": "Utility worker or response encoding failure", "headers": { "x-request-id": { "schema": { @@ -1011,13 +1033,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" + "$ref": "#/components/schemas/ServiceErrorEnvelope" } } } }, - "429": { - "description": "Request rate or concurrent request allowance exhausted", + "503": { + "description": "Target, policy or utility workers unavailable", "headers": { "Retry-After": { "schema": { @@ -1035,13 +1057,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" + "$ref": "#/components/schemas/ServiceErrorEnvelope" } } } }, - "500": { - "description": "Pricing failure", + "504": { + "description": "Utility compute deadline exceeded", "headers": { "x-request-id": { "schema": { @@ -1053,13 +1075,138 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/v1/cost/schedule": { + "get": { + "tags": [ + "cost" + ], + "operationId": "getCostSchedule", + "parameters": [ + { + "name": "operation", + "in": "query", + "description": "Filter by operation: price, implied_volatility, greeks_standard, greeks, analytics, regime", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "model", + "in": "query", + "description": "Filter by versioned model identifier", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Account-effective active cost schedule; zero CU charge", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CostScheduleResponse" } } } }, - "503": { - "description": "Capacity exhausted, funding, policy or admission temporarily unavailable, or service draining", + "401": { + "description": "Invalid credential", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "403": { + "description": "Account forbidden", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "406": { + "description": "JSON response not acceptable", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "422": { + "description": "Invalid filter", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "429": { + "description": "Utility rate or account concurrency exhausted", "headers": { "Retry-After": { "schema": { @@ -1077,13 +1224,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" + "$ref": "#/components/schemas/ServiceErrorEnvelope" } } } }, - "504": { - "description": "Compute deadline exceeded", + "500": { + "description": "Response encoding failure", "headers": { "x-request-id": { "schema": { @@ -1095,26 +1242,68 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ErrorEnvelope" + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "503": { + "description": "Policy or service unavailable", + "headers": { + "Retry-After": { + "schema": { + "type": "string" + }, + "description": "Minimum retry delay in seconds" + }, + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" } } } } - } + }, + "security": [ + { + "api_key": [] + } + ] } }, - "/v1/greeks:batch": { + "/v1/greeks": { "post": { "tags": [ "pricing" ], - "operationId": "greeksAllTenBatch", + "operationId": "greeksAllTen", + "parameters": [ + { + "name": "x-morphiq-max-cu", + "in": "header", + "description": "Optional maximum commercial CU charge for this whole request. An actual quote above this ceiling is refused before commitment with 409 cost_limit_exceeded and zero charged CUs. Applies equally to JSON, Protobuf and gRPC metadata. Zero is allowed; duplicate or malformed values are rejected.", + "required": false, + "schema": { + "type": "integer", + "maximum": 18446744073709551615, + "minimum": 0 + } + } + ], "requestBody": { - "description": "BatchGreeksAllTenRequest encoded as JSON or morphiq.ferro_risk.v1.BatchGreeksAllTenRequest", + "description": "GreeksAllTenRequest encoded as JSON or morphiq.ferro_risk.v1.GreeksAllTenRequest", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchGreeksAllTenRequest" + "$ref": "#/components/schemas/GreeksAllTenRequest" } }, "application/protobuf": { @@ -1127,7 +1316,7 @@ }, "responses": { "200": { - "description": "Ordered batch results", + "description": "Numerical result", "headers": { "x-request-id": { "schema": { @@ -1139,7 +1328,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchGreeksAllTenResponse" + "$ref": "#/components/schemas/GreeksAllTenResponse" } }, "application/protobuf": { @@ -1150,7 +1339,7 @@ } }, "400": { - "description": "Malformed request", + "description": "Malformed request, or disabled model operation (unsupported_operation): QdFp Greeks/analytics or cash-dividend finite-difference Greeks/analytics, including Auto", "headers": { "x-request-id": { "schema": { @@ -1258,7 +1447,7 @@ } }, "409": { - "description": "The reservation expired before execution began", + "description": "Reservation expired, or the quote exceeds x-morphiq-max-cu; check error.code. Zero commercial charge.", "headers": { "x-request-id": { "schema": { @@ -1312,7 +1501,7 @@ } }, "422": { - "description": "Invalid batch request, or work that cannot be priced under the account's limits", + "description": "Invalid pricing request, or work that cannot be priced under the account's limits", "headers": { "x-request-id": { "schema": { @@ -1372,7 +1561,7 @@ } }, "503": { - "description": "Ingress or weighted compute capacity exhausted, or funding, policy or admission temporarily unavailable", + "description": "Capacity exhausted, funding, policy or admission temporarily unavailable, or service draining", "headers": { "Retry-After": { "schema": { @@ -1416,18 +1605,31 @@ } } }, - "/v1/implied-volatility": { + "/v1/greeks/standard": { "post": { "tags": [ "pricing" ], - "operationId": "impliedVolatility", + "operationId": "greeksStandard", + "parameters": [ + { + "name": "x-morphiq-max-cu", + "in": "header", + "description": "Optional maximum commercial CU charge for this whole request. An actual quote above this ceiling is refused before commitment with 409 cost_limit_exceeded and zero charged CUs. Applies equally to JSON, Protobuf and gRPC metadata. Zero is allowed; duplicate or malformed values are rejected.", + "required": false, + "schema": { + "type": "integer", + "maximum": 18446744073709551615, + "minimum": 0 + } + } + ], "requestBody": { - "description": "ImpliedVolatilityRequest encoded as JSON or morphiq.ferro_risk.v1.ImpliedVolatilityRequest", + "description": "GreeksStandardRequest encoded as JSON or morphiq.ferro_risk.v1.GreeksStandardRequest", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImpliedVolatilityRequest" + "$ref": "#/components/schemas/GreeksStandardRequest" } }, "application/protobuf": { @@ -1452,7 +1654,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImpliedVolatilityResponse" + "$ref": "#/components/schemas/GreeksStandardResponse" } }, "application/protobuf": { @@ -1463,7 +1665,7 @@ } }, "400": { - "description": "Malformed request", + "description": "Malformed request, or disabled model operation (unsupported_operation): QdFp or cash-dividend finite-difference Greeks/analytics, including Auto", "headers": { "x-request-id": { "schema": { @@ -1571,7 +1773,7 @@ } }, "409": { - "description": "The reservation expired before execution began", + "description": "Reservation expired, or the quote exceeds x-morphiq-max-cu; check error.code. Zero commercial charge.", "headers": { "x-request-id": { "schema": { @@ -1729,18 +1931,32 @@ } } }, - "/v1/implied-volatility:batch": { + "/v1/greeks/standard:batch": { "post": { "tags": [ "pricing" ], - "operationId": "impliedVolatilityBatch", + "description": "Synchronous BSM/BS1993 batches only; at most 100 contracts or the lower account limit. Any QdFp or cash-dividend item rejects the whole batch before reservation or execution. Results preserve input order; the charge sums per-contract CUs.", + "operationId": "greeksStandardBatch", + "parameters": [ + { + "name": "x-morphiq-max-cu", + "in": "header", + "description": "Optional maximum commercial CU charge for this whole request. An actual quote above this ceiling is refused before commitment with 409 cost_limit_exceeded and zero charged CUs. Applies equally to JSON, Protobuf and gRPC metadata. Zero is allowed; duplicate or malformed values are rejected.", + "required": false, + "schema": { + "type": "integer", + "maximum": 18446744073709551615, + "minimum": 0 + } + } + ], "requestBody": { - "description": "BatchImpliedVolatilityRequest encoded as JSON or morphiq.ferro_risk.v1.BatchImpliedVolatilityRequest", + "description": "BatchGreeksStandardRequest encoded as JSON or morphiq.ferro_risk.v1.BatchGreeksStandardRequest", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchImpliedVolatilityRequest" + "$ref": "#/components/schemas/BatchGreeksStandardRequest" } }, "application/protobuf": { @@ -1765,7 +1981,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchImpliedVolatilityResponse" + "$ref": "#/components/schemas/BatchGreeksStandardResponse" } }, "application/protobuf": { @@ -1884,7 +2100,7 @@ } }, "409": { - "description": "The reservation expired before execution began", + "description": "Reservation expired, or the quote exceeds x-morphiq-max-cu; check error.code. Zero commercial charge.", "headers": { "x-request-id": { "schema": { @@ -2042,18 +2258,32 @@ } } }, - "/v1/price": { + "/v1/greeks:batch": { "post": { "tags": [ "pricing" ], - "operationId": "priceOption", + "description": "Synchronous BSM/BS1993 batches only; at most 100 contracts or the lower account limit. Any QdFp or cash-dividend item rejects the whole batch before reservation or execution. Results preserve input order; the charge sums per-contract CUs.", + "operationId": "greeksAllTenBatch", + "parameters": [ + { + "name": "x-morphiq-max-cu", + "in": "header", + "description": "Optional maximum commercial CU charge for this whole request. An actual quote above this ceiling is refused before commitment with 409 cost_limit_exceeded and zero charged CUs. Applies equally to JSON, Protobuf and gRPC metadata. Zero is allowed; duplicate or malformed values are rejected.", + "required": false, + "schema": { + "type": "integer", + "maximum": 18446744073709551615, + "minimum": 0 + } + } + ], "requestBody": { - "description": "PriceRequest encoded as JSON or morphiq.ferro_risk.v1.PriceRequest", + "description": "BatchGreeksAllTenRequest encoded as JSON or morphiq.ferro_risk.v1.BatchGreeksAllTenRequest", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PriceRequest" + "$ref": "#/components/schemas/BatchGreeksAllTenRequest" } }, "application/protobuf": { @@ -2066,7 +2296,7 @@ }, "responses": { "200": { - "description": "Price calculated", + "description": "Ordered batch results", "headers": { "x-request-id": { "schema": { @@ -2078,7 +2308,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PriceResponse" + "$ref": "#/components/schemas/BatchGreeksAllTenResponse" } }, "application/protobuf": { @@ -2197,7 +2427,7 @@ } }, "409": { - "description": "The reservation expired before execution began", + "description": "Reservation expired, or the quote exceeds x-morphiq-max-cu; check error.code. Zero commercial charge.", "headers": { "x-request-id": { "schema": { @@ -2251,7 +2481,7 @@ } }, "422": { - "description": "Invalid pricing request, or work that cannot be priced under the account's limits", + "description": "Invalid batch request, or work that cannot be priced under the account's limits", "headers": { "x-request-id": { "schema": { @@ -2311,7 +2541,7 @@ } }, "503": { - "description": "Capacity exhausted, funding, policy or admission temporarily unavailable, or service draining", + "description": "Ingress or weighted compute capacity exhausted, or funding, policy or admission temporarily unavailable", "headers": { "Retry-After": { "schema": { @@ -2355,18 +2585,31 @@ } } }, - "/v1/price:batch": { + "/v1/implied-volatility": { "post": { "tags": [ "pricing" ], - "operationId": "priceOptionBatch", + "operationId": "impliedVolatility", + "parameters": [ + { + "name": "x-morphiq-max-cu", + "in": "header", + "description": "Optional maximum commercial CU charge for this whole request. An actual quote above this ceiling is refused before commitment with 409 cost_limit_exceeded and zero charged CUs. Applies equally to JSON, Protobuf and gRPC metadata. Zero is allowed; duplicate or malformed values are rejected.", + "required": false, + "schema": { + "type": "integer", + "maximum": 18446744073709551615, + "minimum": 0 + } + } + ], "requestBody": { - "description": "BatchPriceRequest encoded as JSON or morphiq.ferro_risk.v1.BatchPriceRequest", + "description": "ImpliedVolatilityRequest encoded as JSON or morphiq.ferro_risk.v1.ImpliedVolatilityRequest", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchPriceRequest" + "$ref": "#/components/schemas/ImpliedVolatilityRequest" } }, "application/protobuf": { @@ -2379,7 +2622,7 @@ }, "responses": { "200": { - "description": "Ordered batch results", + "description": "Numerical result", "headers": { "x-request-id": { "schema": { @@ -2391,7 +2634,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchPriceResponse" + "$ref": "#/components/schemas/ImpliedVolatilityResponse" } }, "application/protobuf": { @@ -2510,7 +2753,7 @@ } }, "409": { - "description": "The reservation expired before execution began", + "description": "Reservation expired, or the quote exceeds x-morphiq-max-cu; check error.code. Zero commercial charge.", "headers": { "x-request-id": { "schema": { @@ -2564,7 +2807,7 @@ } }, "422": { - "description": "Invalid batch request, or work that cannot be priced under the account's limits", + "description": "Invalid pricing request, or work that cannot be priced under the account's limits", "headers": { "x-request-id": { "schema": { @@ -2624,7 +2867,7 @@ } }, "503": { - "description": "Ingress or weighted compute capacity exhausted, or funding, policy or admission temporarily unavailable", + "description": "Capacity exhausted, funding, policy or admission temporarily unavailable, or service draining", "headers": { "Retry-After": { "schema": { @@ -2668,17 +2911,37 @@ } } }, - "/v1/regime": { + "/v1/implied-volatility:batch": { "post": { "tags": [ - "regime" + "pricing" + ], + "description": "Synchronous BSM/BS1993 batches only; at most 100 contracts or the lower account limit. Any QdFp or cash-dividend item rejects the whole batch before reservation or execution. Results preserve input order; the charge sums per-contract CUs.", + "operationId": "impliedVolatilityBatch", + "parameters": [ + { + "name": "x-morphiq-max-cu", + "in": "header", + "description": "Optional maximum commercial CU charge for this whole request. An actual quote above this ceiling is refused before commitment with 409 cost_limit_exceeded and zero charged CUs. Applies equally to JSON, Protobuf and gRPC metadata. Zero is allowed; duplicate or malformed values are rejected.", + "required": false, + "schema": { + "type": "integer", + "maximum": 18446744073709551615, + "minimum": 0 + } + } ], - "operationId": "identifyRegime", "requestBody": { + "description": "BatchImpliedVolatilityRequest encoded as JSON or morphiq.ferro_risk.v1.BatchImpliedVolatilityRequest", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RegimeRequest" + "$ref": "#/components/schemas/BatchImpliedVolatilityRequest" + } + }, + "application/protobuf": { + "schema": { + "$ref": "#/components/schemas/ProtobufBody" } } }, @@ -2686,7 +2949,7 @@ }, "responses": { "200": { - "description": "V1 regime estimates; scores are uncalibrated", + "description": "Ordered batch results", "headers": { "x-request-id": { "schema": { @@ -2698,13 +2961,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RegimeResponse" + "$ref": "#/components/schemas/BatchImpliedVolatilityResponse" + } + }, + "application/protobuf": { + "schema": { + "$ref": "#/components/schemas/ProtobufBody" } } } }, "400": { - "description": "Invalid JSON or price array", + "description": "Malformed request", "headers": { "x-request-id": { "schema": { @@ -2716,13 +2984,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ServiceErrorEnvelope" + "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { - "description": "Missing or invalid credential", + "description": "Credential missing or not verifiable", "headers": { "x-request-id": { "schema": { @@ -2734,13 +3002,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ServiceErrorEnvelope" + "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "402": { - "description": "Insufficient FCU", + "description": "The account's funding is spent until a top-up or the next budget period", "headers": { "x-request-id": { "schema": { @@ -2752,13 +3020,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ServiceErrorEnvelope" + "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { - "description": "Forbidden or not entitled", + "description": "The account is not permitted, or its plan does not include this operation", "headers": { "x-request-id": { "schema": { @@ -2770,13 +3038,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ServiceErrorEnvelope" + "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "406": { - "description": "Only JSON responses are available", + "description": "Response media type is not acceptable", "headers": { "x-request-id": { "schema": { @@ -2788,13 +3056,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ServiceErrorEnvelope" + "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "408": { - "description": "Request body deadline", + "description": "Request body deadline exceeded", "headers": { "x-request-id": { "schema": { @@ -2806,13 +3074,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ServiceErrorEnvelope" + "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { - "description": "Reservation expired before execution", + "description": "Reservation expired, or the quote exceeds x-morphiq-max-cu; check error.code. Zero commercial charge.", "headers": { "x-request-id": { "schema": { @@ -2824,13 +3092,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ServiceErrorEnvelope" + "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "413": { - "description": "Request exceeds byte allowance", + "description": "Request too large", "headers": { "x-request-id": { "schema": { @@ -2842,13 +3110,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ServiceErrorEnvelope" + "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "415": { - "description": "Only JSON requests are available", + "description": "Unsupported media type", "headers": { "x-request-id": { "schema": { @@ -2860,13 +3128,31 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ServiceErrorEnvelope" + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "422": { + "description": "Invalid batch request, or work that cannot be priced under the account's limits", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "429": { - "description": "Rate or account concurrency limit", + "description": "Request rate or concurrent request allowance exhausted", "headers": { "Retry-After": { "schema": { @@ -2884,13 +3170,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ServiceErrorEnvelope" + "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "500": { - "description": "Inference or encoding failure", + "description": "Pricing failure", "headers": { "x-request-id": { "schema": { @@ -2902,13 +3188,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ServiceErrorEnvelope" + "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "503": { - "description": "Capacity, policy, funding or model unavailable", + "description": "Ingress or weighted compute capacity exhausted, or funding, policy or admission temporarily unavailable", "headers": { "Retry-After": { "schema": { @@ -2926,13 +3212,13 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ServiceErrorEnvelope" + "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "504": { - "description": "Compute deadline", + "description": "Compute deadline exceeded", "headers": { "x-request-id": { "schema": { @@ -2944,85 +3230,1284 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ServiceErrorEnvelope" + "$ref": "#/components/schemas/ErrorEnvelope" } } } } - }, - "security": [ - { - "api_key": [] - } - ] + } } - } - }, - "components": { - "schemas": { - "BatchFusedAnalyticsItemResult": { - "allOf": [ - { - "$ref": "#/components/schemas/BatchFusedAnalyticsOutcome" - }, + }, + "/v1/price": { + "post": { + "tags": [ + "pricing" + ], + "operationId": "priceOption", + "parameters": [ { - "type": "object", - "required": [ - "index" - ], - "properties": { - "index": { - "type": "integer", - "format": "int32", - "minimum": 0 - } + "name": "x-morphiq-max-cu", + "in": "header", + "description": "Optional maximum commercial CU charge for this whole request. An actual quote above this ceiling is refused before commitment with 409 cost_limit_exceeded and zero charged CUs. Applies equally to JSON, Protobuf and gRPC metadata. Zero is allowed; duplicate or malformed values are rejected.", + "required": false, + "schema": { + "type": "integer", + "maximum": 18446744073709551615, + "minimum": 0 } } ], - "description": "Ordered result with an explicit per-item failure alternative." - }, - "BatchFusedAnalyticsOutcome": { - "oneOf": [ - { - "type": "object", - "required": [ - "result", - "status" - ], - "properties": { - "result": { - "$ref": "#/components/schemas/FusedAnalyticsResult" - }, - "status": { - "type": "string", - "enum": [ - "success" - ] + "requestBody": { + "description": "PriceRequest encoded as JSON or morphiq.ferro_risk.v1.PriceRequest", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PriceRequest" + } + }, + "application/protobuf": { + "schema": { + "$ref": "#/components/schemas/ProtobufBody" } } }, - { - "type": "object", - "required": [ - "error", - "status" - ], - "properties": { - "error": { - "$ref": "#/components/schemas/ErrorBody" + "required": true + }, + "responses": { + "200": { + "description": "Price calculated", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PriceResponse" + } }, - "status": { - "type": "string", - "enum": [ - "error" - ] + "application/protobuf": { + "schema": { + "$ref": "#/components/schemas/ProtobufBody" + } } } - } - ] - }, - "BatchFusedAnalyticsRequest": { - "type": "object", + }, + "400": { + "description": "Malformed request", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "401": { + "description": "Credential missing or not verifiable", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "402": { + "description": "The account's funding is spent until a top-up or the next budget period", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "403": { + "description": "The account is not permitted, or its plan does not include this operation", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "406": { + "description": "Response media type is not acceptable", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "408": { + "description": "Request body deadline exceeded", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "409": { + "description": "Reservation expired, or the quote exceeds x-morphiq-max-cu; check error.code. Zero commercial charge.", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "413": { + "description": "Request too large", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "415": { + "description": "Unsupported media type", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "422": { + "description": "Invalid pricing request, or work that cannot be priced under the account's limits", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "429": { + "description": "Request rate or concurrent request allowance exhausted", + "headers": { + "Retry-After": { + "schema": { + "type": "string" + }, + "description": "Minimum retry delay in seconds" + }, + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "500": { + "description": "Pricing failure", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "503": { + "description": "Capacity exhausted, funding, policy or admission temporarily unavailable, or service draining", + "headers": { + "Retry-After": { + "schema": { + "type": "string" + }, + "description": "Minimum retry delay in seconds" + }, + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "504": { + "description": "Compute deadline exceeded", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/v1/price:batch": { + "post": { + "tags": [ + "pricing" + ], + "description": "Synchronous BSM/BS1993 batches only; at most 100 contracts or the lower account limit. Any QdFp or cash-dividend item rejects the whole batch before reservation or execution. Results preserve input order; the charge sums per-contract CUs.", + "operationId": "priceOptionBatch", + "parameters": [ + { + "name": "x-morphiq-max-cu", + "in": "header", + "description": "Optional maximum commercial CU charge for this whole request. An actual quote above this ceiling is refused before commitment with 409 cost_limit_exceeded and zero charged CUs. Applies equally to JSON, Protobuf and gRPC metadata. Zero is allowed; duplicate or malformed values are rejected.", + "required": false, + "schema": { + "type": "integer", + "maximum": 18446744073709551615, + "minimum": 0 + } + } + ], + "requestBody": { + "description": "BatchPriceRequest encoded as JSON or morphiq.ferro_risk.v1.BatchPriceRequest", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchPriceRequest" + } + }, + "application/protobuf": { + "schema": { + "$ref": "#/components/schemas/ProtobufBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Ordered batch results", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchPriceResponse" + } + }, + "application/protobuf": { + "schema": { + "$ref": "#/components/schemas/ProtobufBody" + } + } + } + }, + "400": { + "description": "Malformed request", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "401": { + "description": "Credential missing or not verifiable", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "402": { + "description": "The account's funding is spent until a top-up or the next budget period", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "403": { + "description": "The account is not permitted, or its plan does not include this operation", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "406": { + "description": "Response media type is not acceptable", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "408": { + "description": "Request body deadline exceeded", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "409": { + "description": "Reservation expired, or the quote exceeds x-morphiq-max-cu; check error.code. Zero commercial charge.", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "413": { + "description": "Request too large", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "415": { + "description": "Unsupported media type", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "422": { + "description": "Invalid batch request, or work that cannot be priced under the account's limits", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "429": { + "description": "Request rate or concurrent request allowance exhausted", + "headers": { + "Retry-After": { + "schema": { + "type": "string" + }, + "description": "Minimum retry delay in seconds" + }, + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "500": { + "description": "Pricing failure", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "503": { + "description": "Ingress or weighted compute capacity exhausted, or funding, policy or admission temporarily unavailable", + "headers": { + "Retry-After": { + "schema": { + "type": "string" + }, + "description": "Minimum retry delay in seconds" + }, + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + }, + "504": { + "description": "Compute deadline exceeded", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + } + }, + "/v1/pricing/models": { + "get": { + "tags": [ + "pricing" + ], + "summary": "List pricing models and their numerical capabilities.", + "description": "Public JSON discovery: no API key, account filtering, pricing work or CU\ncharge. Every caller receives the same compiled catalog. Quantity support\nis a capability, not a promise of convergence for every input. Auto selects\nEuropean BSM, American QdFp, or American finite difference with cash events.", + "operationId": "listPricingModels", + "responses": { + "200": { + "description": "All pricing models with methods, capabilities and usage guidance", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PricingModelCatalog" + } + } + } + }, + "406": { + "description": "JSON is not acceptable", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + } + }, + "security": [ + {} + ] + } + }, + "/v1/regime": { + "post": { + "tags": [ + "regime" + ], + "operationId": "identifyRegime", + "parameters": [ + { + "name": "x-morphiq-max-cu", + "in": "header", + "description": "Optional maximum commercial CU charge for this whole request. An actual quote above this ceiling is refused before commitment with 409 cost_limit_exceeded and zero charged CUs. Applies equally to JSON, Protobuf and gRPC metadata. Zero is allowed; duplicate or malformed values are rejected.", + "required": false, + "schema": { + "type": "integer", + "maximum": 18446744073709551615, + "minimum": 0 + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegimeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "V1 regime estimates; scores are uncalibrated", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegimeResponse" + } + } + } + }, + "400": { + "description": "Invalid JSON or price array", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "401": { + "description": "Missing or invalid credential", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "402": { + "description": "Insufficient FCU", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "403": { + "description": "Forbidden or not entitled", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "406": { + "description": "Only JSON responses are available", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "408": { + "description": "Request body deadline", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "409": { + "description": "Reservation expired, or the quote exceeds x-morphiq-max-cu; check error.code. Zero commercial charge.", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "413": { + "description": "Request exceeds byte allowance", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "415": { + "description": "Only JSON requests are available", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "429": { + "description": "Rate or account concurrency limit", + "headers": { + "Retry-After": { + "schema": { + "type": "string" + }, + "description": "Minimum retry delay in seconds" + }, + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "500": { + "description": "Inference or encoding failure", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "503": { + "description": "Capacity, policy, funding or model unavailable", + "headers": { + "Retry-After": { + "schema": { + "type": "string" + }, + "description": "Minimum retry delay in seconds" + }, + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + }, + "504": { + "description": "Compute deadline", + "headers": { + "x-request-id": { + "schema": { + "type": "string" + }, + "description": "Unique service-generated request identifier" + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ServiceErrorEnvelope" + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + } + }, + "components": { + "schemas": { + "BatchFusedAnalyticsItemResult": { + "allOf": [ + { + "$ref": "#/components/schemas/BatchFusedAnalyticsOutcome" + }, + { + "type": "object", + "required": [ + "index" + ], + "properties": { + "index": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + } + ], + "description": "Ordered result with an explicit per-item failure alternative." + }, + "BatchFusedAnalyticsOutcome": { + "oneOf": [ + { + "type": "object", + "required": [ + "result", + "status" + ], + "properties": { + "result": { + "$ref": "#/components/schemas/FusedAnalyticsResult" + }, + "status": { + "type": "string", + "enum": [ + "success" + ] + } + } + }, + { + "type": "object", + "required": [ + "error", + "status" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/ErrorBody" + }, + "status": { + "type": "string", + "enum": [ + "error" + ] + } + } + } + ] + }, + "BatchFusedAnalyticsRequest": { + "type": "object", + "description": "Ordered request envelope; runtime applies the pinned plan limit.", + "required": [ + "requests" + ], + "properties": { + "requests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FusedAnalyticsRequest" + }, + "description": "One to 100 contracts; a lower account limit still applies.", + "maxItems": 100, + "minItems": 1 + } + }, + "additionalProperties": false + }, + "BatchFusedAnalyticsResponse": { + "type": "object", + "description": "One metadata block for the entire admitted batch.", + "required": [ + "metadata", + "results" + ], + "properties": { + "metadata": { + "$ref": "#/components/schemas/ResponseMetadata" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BatchFusedAnalyticsItemResult" + } + } + } + }, + "BatchGreeksAllTenItemResult": { + "allOf": [ + { + "$ref": "#/components/schemas/BatchGreeksAllTenOutcome" + }, + { + "type": "object", + "required": [ + "index" + ], + "properties": { + "index": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + } + ], + "description": "Ordered result with an explicit per-item failure alternative." + }, + "BatchGreeksAllTenOutcome": { + "oneOf": [ + { + "type": "object", + "required": [ + "result", + "status" + ], + "properties": { + "result": { + "$ref": "#/components/schemas/GreeksAllTenResult" + }, + "status": { + "type": "string", + "enum": [ + "success" + ] + } + } + }, + { + "type": "object", + "required": [ + "error", + "status" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/ErrorBody" + }, + "status": { + "type": "string", + "enum": [ + "error" + ] + } + } + } + ] + }, + "BatchGreeksAllTenRequest": { + "type": "object", + "description": "Ordered request envelope; runtime applies the pinned plan limit.", + "required": [ + "requests" + ], + "properties": { + "requests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GreeksAllTenRequest" + }, + "description": "One to 100 contracts; a lower account limit still applies.", + "maxItems": 100, + "minItems": 1 + } + }, + "additionalProperties": false + }, + "BatchGreeksAllTenResponse": { + "type": "object", + "description": "One metadata block for the entire admitted batch.", + "required": [ + "metadata", + "results" + ], + "properties": { + "metadata": { + "$ref": "#/components/schemas/ResponseMetadata" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BatchGreeksAllTenItemResult" + } + } + } + }, + "BatchGreeksStandardItemResult": { + "allOf": [ + { + "$ref": "#/components/schemas/BatchGreeksStandardOutcome" + }, + { + "type": "object", + "required": [ + "index" + ], + "properties": { + "index": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + } + ], + "description": "Ordered result with an explicit per-item failure alternative." + }, + "BatchGreeksStandardOutcome": { + "oneOf": [ + { + "type": "object", + "required": [ + "result", + "status" + ], + "properties": { + "result": { + "$ref": "#/components/schemas/GreeksStandardResult" + }, + "status": { + "type": "string", + "enum": [ + "success" + ] + } + } + }, + { + "type": "object", + "required": [ + "error", + "status" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/ErrorBody" + }, + "status": { + "type": "string", + "enum": [ + "error" + ] + } + } + } + ] + }, + "BatchGreeksStandardRequest": { + "type": "object", "description": "Ordered request envelope; runtime applies the pinned plan limit.", "required": [ "requests" @@ -3031,15 +4516,16 @@ "requests": { "type": "array", "items": { - "$ref": "#/components/schemas/FusedAnalyticsRequest" + "$ref": "#/components/schemas/GreeksStandardRequest" }, - "maxItems": 1024, + "description": "One to 100 contracts; a lower account limit still applies.", + "maxItems": 100, "minItems": 1 } }, "additionalProperties": false }, - "BatchFusedAnalyticsResponse": { + "BatchGreeksStandardResponse": { "type": "object", "description": "One metadata block for the entire admitted batch.", "required": [ @@ -3053,15 +4539,15 @@ "results": { "type": "array", "items": { - "$ref": "#/components/schemas/BatchFusedAnalyticsItemResult" + "$ref": "#/components/schemas/BatchGreeksStandardItemResult" } } } }, - "BatchGreeksAllTenItemResult": { + "BatchImpliedVolatilityItemResult": { "allOf": [ { - "$ref": "#/components/schemas/BatchGreeksAllTenOutcome" + "$ref": "#/components/schemas/BatchImpliedVolatilityOutcome" }, { "type": "object", @@ -3079,7 +4565,7 @@ ], "description": "Ordered result with an explicit per-item failure alternative." }, - "BatchGreeksAllTenOutcome": { + "BatchImpliedVolatilityOutcome": { "oneOf": [ { "type": "object", @@ -3089,7 +4575,7 @@ ], "properties": { "result": { - "$ref": "#/components/schemas/GreeksAllTenResult" + "$ref": "#/components/schemas/ImpliedVolatilityResult" }, "status": { "type": "string", @@ -3119,7 +4605,7 @@ } ] }, - "BatchGreeksAllTenRequest": { + "BatchImpliedVolatilityRequest": { "type": "object", "description": "Ordered request envelope; runtime applies the pinned plan limit.", "required": [ @@ -3129,15 +4615,16 @@ "requests": { "type": "array", "items": { - "$ref": "#/components/schemas/GreeksAllTenRequest" + "$ref": "#/components/schemas/ImpliedVolatilityRequest" }, - "maxItems": 1024, + "description": "One to 100 contracts; a lower account limit still applies.", + "maxItems": 100, "minItems": 1 } }, "additionalProperties": false }, - "BatchGreeksAllTenResponse": { + "BatchImpliedVolatilityResponse": { "type": "object", "description": "One metadata block for the entire admitted batch.", "required": [ @@ -3151,15 +4638,15 @@ "results": { "type": "array", "items": { - "$ref": "#/components/schemas/BatchGreeksAllTenItemResult" + "$ref": "#/components/schemas/BatchImpliedVolatilityItemResult" } } } }, - "BatchImpliedVolatilityItemResult": { + "BatchItemResult": { "allOf": [ { - "$ref": "#/components/schemas/BatchImpliedVolatilityOutcome" + "$ref": "#/components/schemas/BatchOutcome" }, { "type": "object", @@ -3175,9 +4662,9 @@ } } ], - "description": "Ordered result with an explicit per-item failure alternative." + "description": "Ordered result for one batch element." }, - "BatchImpliedVolatilityOutcome": { + "BatchOutcome": { "oneOf": [ { "type": "object", @@ -3187,7 +4674,7 @@ ], "properties": { "result": { - "$ref": "#/components/schemas/ImpliedVolatilityResult" + "$ref": "#/components/schemas/PriceResult" }, "status": { "type": "string", @@ -3215,11 +4702,12 @@ } } } - ] + ], + "description": "Per-item batch outcome. Structural request failures remain request-level errors." }, - "BatchImpliedVolatilityRequest": { + "BatchPriceRequest": { "type": "object", - "description": "Ordered request envelope; runtime applies the pinned plan limit.", + "description": "An ordered batch of option-pricing requests.", "required": [ "requests" ], @@ -3227,17 +4715,18 @@ "requests": { "type": "array", "items": { - "$ref": "#/components/schemas/ImpliedVolatilityRequest" + "$ref": "#/components/schemas/PriceRequest" }, - "maxItems": 1024, + "description": "One to 100 contracts; a lower account limit still applies.", + "maxItems": 100, "minItems": 1 } }, "additionalProperties": false }, - "BatchImpliedVolatilityResponse": { + "BatchPriceResponse": { "type": "object", - "description": "One metadata block for the entire admitted batch.", + "description": "Successful response for a batch, including any per-item pricing failures.", "required": [ "metadata", "results" @@ -3249,107 +4738,422 @@ "results": { "type": "array", "items": { - "$ref": "#/components/schemas/BatchImpliedVolatilityItemResult" + "$ref": "#/components/schemas/BatchItemResult" } } } }, - "BatchItemResult": { - "allOf": [ + "CostBreakdown": { + "type": "object", + "description": "Tollgate's exact quote, conditional on this policy and workload at execution.", + "required": [ + "total_fcu", + "fixed_fcu", + "variable_fcu", + "minimum_charge_fcu", + "lines" + ], + "properties": { + "fixed_fcu": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CostLine" + } + }, + "minimum_charge_fcu": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "total_fcu": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "variable_fcu": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "CostLine": { + "type": "object", + "description": "A rate-class aggregate. Model identifies the schedule row, not necessarily\nthe selected numerical algorithm; `resolved_models` reports the latter.", + "required": [ + "operation", + "model", + "billing_unit", + "quantity", + "fcu_per_unit", + "variable_fcu" + ], + "properties": { + "billing_unit": { + "type": "string" + }, + "fcu_per_unit": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "model": { + "type": "string" + }, + "operation": { + "type": "string" + }, + "quantity": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "variable_fcu": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "CostQuoteRequest": { + "oneOf": [ { - "$ref": "#/components/schemas/BatchOutcome" + "type": "object", + "required": [ + "request", + "operation" + ], + "properties": { + "operation": { + "type": "string", + "enum": [ + "price" + ] + }, + "request": { + "$ref": "#/components/schemas/PriceRequest" + } + } }, { "type": "object", "required": [ - "index" + "request", + "operation" ], "properties": { - "index": { - "type": "integer", - "format": "int32", - "minimum": 0 + "operation": { + "type": "string", + "enum": [ + "price_batch" + ] + }, + "request": { + "$ref": "#/components/schemas/BatchPriceRequest" } } - } - ], - "description": "Ordered result for one batch element." - }, - "BatchOutcome": { - "oneOf": [ + }, { "type": "object", "required": [ - "result", - "status" + "request", + "operation" ], "properties": { - "result": { - "$ref": "#/components/schemas/PriceResult" + "operation": { + "type": "string", + "enum": [ + "implied_volatility" + ] }, - "status": { + "request": { + "$ref": "#/components/schemas/ImpliedVolatilityRequest" + } + } + }, + { + "type": "object", + "required": [ + "request", + "operation" + ], + "properties": { + "operation": { "type": "string", "enum": [ - "success" + "implied_volatility_batch" ] + }, + "request": { + "$ref": "#/components/schemas/BatchImpliedVolatilityRequest" } } }, { "type": "object", "required": [ - "error", - "status" + "request", + "operation" ], "properties": { - "error": { - "$ref": "#/components/schemas/ErrorBody" + "operation": { + "type": "string", + "enum": [ + "greeks_standard" + ] }, - "status": { + "request": { + "$ref": "#/components/schemas/GreeksStandardRequest" + } + } + }, + { + "type": "object", + "required": [ + "request", + "operation" + ], + "properties": { + "operation": { "type": "string", "enum": [ - "error" + "greeks_standard_batch" + ] + }, + "request": { + "$ref": "#/components/schemas/BatchGreeksStandardRequest" + } + } + }, + { + "type": "object", + "required": [ + "request", + "operation" + ], + "properties": { + "operation": { + "type": "string", + "enum": [ + "greeks" + ] + }, + "request": { + "$ref": "#/components/schemas/GreeksAllTenRequest" + } + } + }, + { + "type": "object", + "required": [ + "request", + "operation" + ], + "properties": { + "operation": { + "type": "string", + "enum": [ + "greeks_batch" + ] + }, + "request": { + "$ref": "#/components/schemas/BatchGreeksAllTenRequest" + } + } + }, + { + "type": "object", + "required": [ + "request", + "operation" + ], + "properties": { + "operation": { + "type": "string", + "enum": [ + "analytics" + ] + }, + "request": { + "$ref": "#/components/schemas/FusedAnalyticsRequest" + } + } + }, + { + "type": "object", + "required": [ + "request", + "operation" + ], + "properties": { + "operation": { + "type": "string", + "enum": [ + "analytics_batch" + ] + }, + "request": { + "$ref": "#/components/schemas/BatchFusedAnalyticsRequest" + } + } + }, + { + "type": "object", + "required": [ + "request", + "operation" + ], + "properties": { + "operation": { + "type": "string", + "enum": [ + "regime" ] + }, + "request": { + "$ref": "#/components/schemas/RegimeRequest" } } } - ], - "description": "Per-item batch outcome. Structural request failures remain request-level errors." + ], + "description": "OpenAPI's discriminated request union; runtime retains each body verbatim." + }, + "CostQuoteResponse": { + "type": "object", + "description": "Quote identity and breakdown, with no reservation or price lock.", + "required": [ + "quote", + "resolved_models", + "metadata" + ], + "properties": { + "metadata": { + "$ref": "#/components/schemas/ServiceErrorMetadata" + }, + "quote": { + "$ref": "#/components/schemas/CostBreakdown" + }, + "resolved_models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResolvedCostModel" + } + } + } + }, + "CostRate": { + "type": "object", + "description": "A published rate under the caller's pinned schedule. A rate is not an entitlement.", + "required": [ + "operation", + "model", + "billing_unit", + "fcu_per_unit", + "max_discrete_dividends" + ], + "properties": { + "billing_unit": { + "type": "string" + }, + "fcu_per_unit": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "max_discrete_dividends": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "model": { + "type": "string" + }, + "operation": { + "type": "string" + } + } }, - "BatchPriceRequest": { + "CostSchedule": { "type": "object", - "description": "An ordered batch of option-pricing requests.", + "description": "Customer-facing projection of one immutable, account-effective cost schedule.", "required": [ - "requests" + "schedule_id", + "schedule_version", + "record_version", + "policy_revision", + "effective_from", + "fixed_request_fcu", + "minimum_charge_fcu", + "rounding", + "failure_charging", + "rates" ], "properties": { - "requests": { + "effective_from": { + "type": "string" + }, + "effective_until": { + "type": [ + "string", + "null" + ] + }, + "failure_charging": { + "type": "string" + }, + "fixed_request_fcu": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "minimum_charge_fcu": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "policy_revision": { + "type": "string" + }, + "rates": { "type": "array", "items": { - "$ref": "#/components/schemas/PriceRequest" - }, - "maxItems": 1024, - "minItems": 1 + "$ref": "#/components/schemas/CostRate" + } + }, + "record_version": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "rounding": { + "type": "string" + }, + "schedule_id": { + "type": "string" + }, + "schedule_version": { + "type": "string" } - }, - "additionalProperties": false + } }, - "BatchPriceResponse": { + "CostScheduleResponse": { "type": "object", - "description": "Successful response for a batch, including any per-item pricing failures.", + "description": "Full or filtered schedule; metadata describes this non-billable utility call.", "required": [ - "metadata", - "results" + "schedule", + "metadata" ], "properties": { "metadata": { - "$ref": "#/components/schemas/ResponseMetadata" + "$ref": "#/components/schemas/ServiceErrorMetadata" }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BatchItemResult" - } + "schedule": { + "$ref": "#/components/schemas/CostSchedule" } } }, @@ -3405,44 +5209,236 @@ "code": { "type": "string" }, - "field": { - "type": [ - "string", - "null" - ] + "field": { + "type": [ + "string", + "null" + ] + }, + "message": { + "type": "string" + } + } + }, + "ErrorEnvelope": { + "type": "object", + "description": "Top-level HTTP error envelope.", + "required": [ + "metadata", + "error" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/ErrorBody" + }, + "metadata": { + "$ref": "#/components/schemas/ResponseMetadata" + } + } + }, + "ExerciseStyle": { + "type": "string", + "description": "Contract exercise convention, independent of the pricing algorithm.", + "enum": [ + "european", + "american" + ] + }, + "FusedAnalyticsRequest": { + "type": "object", + "description": "A request for price and Greeks, optionally at a volatility solved from an\nobserved premium first.\n\nWith `market_price`, volatility is solved and the Greeks are evaluated at\nthe solved value — one admission, one solve, and the solved point passed\ninward as verified evidence rather than re-derived. Without it, the\nsupplied `volatility` is used and the result is the all-ten-Greeks\noperation under another name.", + "required": [ + "exercise_style", + "pricing_model", + "option_type", + "spot", + "strike", + "time_to_expiry_years", + "risk_free_rate", + "dividend_yield", + "volatility" + ], + "properties": { + "dividend_schedule": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DividendEvent" + }, + "description": "Cash dividends are supported with supplied or solved volatility.", + "maxItems": 16 + }, + "dividend_yield": { + "type": "number", + "format": "double" + }, + "exercise_style": { + "$ref": "#/components/schemas/ExerciseStyle" + }, + "market_price": { + "type": [ + "number", + "null" + ], + "format": "double", + "description": "The observed premium to invert before evaluating the Greeks." + }, + "model_version": { + "type": [ + "string", + "null" + ] + }, + "option_type": { + "$ref": "#/components/schemas/OptionType" + }, + "pricing_model": { + "$ref": "#/components/schemas/PricingModel" + }, + "risk_free_rate": { + "type": "number", + "format": "double" + }, + "spot": { + "type": "number", + "format": "double" + }, + "strike": { + "type": "number", + "format": "double" + }, + "time_to_expiry_years": { + "type": "number", + "format": "double" + }, + "volatility": { + "type": "number", + "format": "double", + "description": "Used only when `market_price` is absent." + } + }, + "additionalProperties": false + }, + "FusedAnalyticsResponse": { + "type": "object", + "description": "Successful response for one fused-analytics operation.", + "required": [ + "metadata", + "result" + ], + "properties": { + "metadata": { + "$ref": "#/components/schemas/ResponseMetadata" + }, + "result": { + "$ref": "#/components/schemas/FusedAnalyticsResult" + } + } + }, + "FusedAnalyticsResult": { + "type": "object", + "description": "Price, Greeks and — when a premium was supplied — the solve that produced\nthe volatility they were evaluated at.", + "required": [ + "price", + "greeks", + "pricing_model", + "model_version" + ], + "properties": { + "greeks": { + "$ref": "#/components/schemas/GreeksAllTen" + }, + "implied_volatility": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ImpliedVolatilityResult", + "description": "Present only when the request supplied `market_price`." + } + ] + }, + "model_version": { + "type": "string" + }, + "price": { + "type": "number", + "format": "double" + }, + "pricing_model": { + "$ref": "#/components/schemas/ResolvedPricingModel" + } + } + }, + "GreeksAllTen": { + "type": "object", + "description": "Available Greeks on the contract's stated bases; absent values are explained by `unavailable`.\n\nTime derivatives are **per year**. The engine reports them per calendar\nday, which embeds a 365-day calendar; the contract carries no calendar,\nso the service scales by the engine's own day count at this boundary and\nthe caller applies whichever convention it holds. Vega is raw `dV/dσ` per\nunit volatility, not per point. Nothing else is rescaled.", + "properties": { + "charm": { + "type": "number", + "format": "double", + "description": "`d²V/(dS dt)`, **per year**." + }, + "color": { + "type": "number", + "format": "double", + "description": "`d³V/(dS² dt)`, **per year**." + }, + "delta": { + "type": "number", + "format": "double", + "description": "`dV/dS`." + }, + "gamma": { + "type": "number", + "format": "double", + "description": "`d²V/dS²`." + }, + "rho": { + "type": "number", + "format": "double", + "description": "`dV/dr`, per unit rate." + }, + "theta": { + "type": "number", + "format": "double", + "description": "`dV/dt`, **per year**." + }, + "unavailable": { + "type": "object", + "description": "Each omitted Greek has a reason. A resolved zero is present as 0.0.\nCodes distinguish unsupported quantities, model domain/validity,\naccuracy refusals and unresolved numerical derivatives.", + "additionalProperties": { + "$ref": "#/components/schemas/ErrorBody" + }, + "propertyNames": { + "type": "string" + } }, - "message": { - "type": "string" - } - } - }, - "ErrorEnvelope": { - "type": "object", - "description": "Top-level HTTP error envelope.", - "required": [ - "metadata", - "error" - ], - "properties": { - "error": { - "$ref": "#/components/schemas/ErrorBody" + "vanna": { + "type": "number", + "format": "double", + "description": "`d²V/(dS dσ)`, per unit volatility." }, - "metadata": { - "$ref": "#/components/schemas/ResponseMetadata" + "vega": { + "type": "number", + "format": "double", + "description": "`dV/dσ`, per unit volatility." + }, + "veta": { + "type": "number", + "format": "double", + "description": "`d²V/(dσ dt)`, **per year** per unit volatility." + }, + "volga": { + "type": "number", + "format": "double", + "description": "`d²V/dσ²`, per unit volatility squared." } } }, - "ExerciseStyle": { - "type": "string", - "description": "Contract exercise convention, independent of the pricing algorithm.", - "enum": [ - "european", - "american" - ] - }, - "FusedAnalyticsRequest": { + "GreeksAllTenRequest": { "type": "object", - "description": "A request for price and Greeks, optionally at a volatility solved from an\nobserved premium first.\n\nWith `market_price`, volatility is solved and the Greeks are evaluated at\nthe solved value — one admission, one solve, and the solved point passed\ninward as verified evidence rather than re-derived. Without it, the\nsupplied `volatility` is used and the result is the all-ten-Greeks\noperation under another name.", + "description": "A request for the price and all ten Greeks at a supplied volatility.\n\nThe same shape as `PriceRequest`: the Greeks are derivatives of the\nprice, so they are a property of the same contract and the same\ncoordinates.", "required": [ "exercise_style", "pricing_model", @@ -3460,7 +5456,7 @@ "items": { "$ref": "#/components/schemas/DividendEvent" }, - "description": "Served only together with `market_price` (#330).", + "description": "Cash dividends select the finite-difference American model with Auto.", "maxItems": 16 }, "dividend_yield": { @@ -3470,14 +5466,6 @@ "exercise_style": { "$ref": "#/components/schemas/ExerciseStyle" }, - "market_price": { - "type": [ - "number", - "null" - ], - "format": "double", - "description": "The observed premium to invert before evaluating the Greeks." - }, "model_version": { "type": [ "string", @@ -3508,15 +5496,14 @@ }, "volatility": { "type": "number", - "format": "double", - "description": "Used only when `market_price` is absent." + "format": "double" } }, "additionalProperties": false }, - "FusedAnalyticsResponse": { + "GreeksAllTenResponse": { "type": "object", - "description": "Successful response for one fused-analytics operation.", + "description": "Successful response for one all-ten-Greeks operation.", "required": [ "metadata", "result" @@ -3526,13 +5513,13 @@ "$ref": "#/components/schemas/ResponseMetadata" }, "result": { - "$ref": "#/components/schemas/FusedAnalyticsResult" + "$ref": "#/components/schemas/GreeksAllTenResult" } } }, - "FusedAnalyticsResult": { + "GreeksAllTenResult": { "type": "object", - "description": "Price, Greeks and — when a premium was supplied — the solve that produced\nthe volatility they were evaluated at.", + "description": "The price and Greeks of one contract at the volatility supplied.", "required": [ "price", "greeks", @@ -3543,17 +5530,6 @@ "greeks": { "$ref": "#/components/schemas/GreeksAllTen" }, - "implied_volatility": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ImpliedVolatilityResult", - "description": "Present only when the request supplied `market_price`." - } - ] - }, "model_version": { "type": "string" }, @@ -3566,32 +5542,10 @@ } } }, - "GreeksAllTen": { + "GreeksStandard": { "type": "object", - "description": "The ten Greeks, on the contract's stated bases.\n\nTime derivatives are **per year**. The engine reports them per calendar\nday, which embeds a 365-day calendar; the contract carries no calendar,\nso the service scales by the engine's own day count at this boundary and\nthe caller applies whichever convention it holds. Vega is raw `dV/dσ` per\nunit volatility, not per point. Nothing else is rescaled.", - "required": [ - "delta", - "gamma", - "theta", - "vega", - "rho", - "vanna", - "volga", - "charm", - "veta", - "color" - ], + "description": "Available Greeks on the contract's stated bases; absent values are explained by `unavailable`.\n\nTime derivatives are **per year**. The engine reports them per calendar\nday, which embeds a 365-day calendar; the contract carries no calendar,\nso the service scales by the engine's own day count at this boundary and\nthe caller applies whichever convention it holds. Vega is raw `dV/dσ` per\nunit volatility, not per point. Nothing else is rescaled.", "properties": { - "charm": { - "type": "number", - "format": "double", - "description": "`d²V/(dS dt)`, **per year**." - }, - "color": { - "type": "number", - "format": "double", - "description": "`d³V/(dS² dt)`, **per year**." - }, "delta": { "type": "number", "format": "double", @@ -3612,31 +5566,26 @@ "format": "double", "description": "`dV/dt`, **per year**." }, - "vanna": { - "type": "number", - "format": "double", - "description": "`d²V/(dS dσ)`, per unit volatility." + "unavailable": { + "type": "object", + "description": "Each omitted Greek has a reason. A resolved zero is present as 0.0.\nCodes distinguish unsupported quantities, model domain/validity,\naccuracy refusals and unresolved numerical derivatives.", + "additionalProperties": { + "$ref": "#/components/schemas/ErrorBody" + }, + "propertyNames": { + "type": "string" + } }, "vega": { "type": "number", "format": "double", "description": "`dV/dσ`, per unit volatility." - }, - "veta": { - "type": "number", - "format": "double", - "description": "`d²V/(dσ dt)`, **per year** per unit volatility." - }, - "volga": { - "type": "number", - "format": "double", - "description": "`d²V/dσ²`, per unit volatility squared." } } }, - "GreeksAllTenRequest": { + "GreeksStandardRequest": { "type": "object", - "description": "A request for the price and all ten Greeks at a supplied volatility.\n\nThe same shape as `PriceRequest`: the Greeks are derivatives of the\nprice, so they are a property of the same contract and the same\ncoordinates.", + "description": "A request for the price and the standard five Greeks at a supplied volatility.\n\nThe same shape as `PriceRequest`: the Greeks are derivatives of the\nprice, so they are a property of the same contract and the same\ncoordinates.", "required": [ "exercise_style", "pricing_model", @@ -3654,7 +5603,7 @@ "items": { "$ref": "#/components/schemas/DividendEvent" }, - "description": "Accepted by the wire contract, refused by this operation until the\nengine exposes schedule-aware Greeks without a premium (#330). A\nschedule is served on `FusedAnalytics` with `market_price`.", + "description": "Cash dividends select the finite-difference American model with Auto.", "maxItems": 16 }, "dividend_yield": { @@ -3699,9 +5648,9 @@ }, "additionalProperties": false }, - "GreeksAllTenResponse": { + "GreeksStandardResponse": { "type": "object", - "description": "Successful response for one all-ten-Greeks operation.", + "description": "Successful response for one standard-five-Greeks operation.", "required": [ "metadata", "result" @@ -3711,11 +5660,11 @@ "$ref": "#/components/schemas/ResponseMetadata" }, "result": { - "$ref": "#/components/schemas/GreeksAllTenResult" + "$ref": "#/components/schemas/GreeksStandardResult" } } }, - "GreeksAllTenResult": { + "GreeksStandardResult": { "type": "object", "description": "The price and Greeks of one contract at the volatility supplied.", "required": [ @@ -3726,7 +5675,7 @@ ], "properties": { "greeks": { - "$ref": "#/components/schemas/GreeksAllTen" + "$ref": "#/components/schemas/GreeksStandard" }, "model_version": { "type": "string" @@ -3863,6 +5812,38 @@ } } }, + "ModelConstraint": { + "type": "object", + "required": [ + "quantity", + "description" + ], + "properties": { + "description": { + "type": "string" + }, + "maximum": { + "type": [ + "number", + "null" + ], + "format": "double", + "description": "Inclusive upper bound when present." + }, + "minimum": { + "type": [ + "number", + "null" + ], + "format": "double", + "description": "Inclusive lower bound when present." + }, + "quantity": { + "type": "string", + "description": "Input field or derived quantity, defined by description." + } + } + }, "OptionType": { "type": "string", "description": "Option payoff direction.", @@ -3921,7 +5902,7 @@ "items": { "$ref": "#/components/schemas/DividendEvent" }, - "description": "Discrete cash dividends within the option's life. American\nBjerksund-Stensland 2002 only; omit for continuous `dividend_yield` alone.", + "description": "Discrete cash dividends within the option's life. American\nCash-dividend American model only; Auto selects it for a non-empty schedule.", "maxItems": 16 }, "dividend_yield": { @@ -4015,14 +5996,147 @@ }, "PricingModel": { "type": "string", - "description": "Pricing algorithm selection. `Auto` resolves from the exercise style.", + "description": "Canonical selectors are listed by GET /v1/pricing/models. qd_fixed_point and cash_dividend_american remain input aliases. BS2002 is retired and returns a migration error.", "enum": [ "auto", "black_scholes_merton", "bjerksund_stensland_2002", - "bjerksund_stensland_1993" + "bjerksund_stensland_1993", + "black_scholes_merton_qdfp", + "black_scholes_merton_fd", + "qd_fixed_point", + "cash_dividend_american" ] }, + "PricingModelCatalog": { + "type": "object", + "required": [ + "models", + "engines", + "greek_units" + ], + "properties": { + "engines": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EngineIdentity" + } + }, + "greek_units": { + "type": "object", + "description": "API units, derived from canonical Greek field descriptions.", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, + "models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PricingModelDescriptor" + }, + "description": "All executable pricing models in this build, identical for every caller.\nAuto is a selection rule and retired BS2002 is not an executable model." + } + } + }, + "PricingModelDescriptor": { + "type": "object", + "required": [ + "id", + "name", + "version", + "model_version", + "aliases", + "underlying_model", + "method", + "exercise_styles", + "dividend_support", + "operations", + "batch_operations", + "greeks", + "guidance", + "constraints", + "notes" + ], + "properties": { + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Earlier JSON selectors still accepted as aliases of this same algorithm." + }, + "batch_operations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Operations also supported in synchronous batches of at most 100 contracts." + }, + "constraints": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelConstraint" + }, + "description": "Selected structured limits of the pinned engine for live contracts.\nThese describe capabilities; the engine remains the validation authority." + }, + "dividend_support": { + "type": "array", + "items": { + "type": "string" + } + }, + "exercise_styles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExerciseStyle" + } + }, + "greeks": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Implemented quantities, not a guarantee they resolve for every input.\nPer-request refusals appear in greeks.unavailable." + }, + "guidance": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/ResolvedPricingModel", + "description": "Canonical JSON pricing_model selector, accepted by pricing operations." + }, + "method": { + "type": "string" + }, + "model_version": { + "type": "string", + "description": "Exact compatible model_version pin for requests and response provenance." + }, + "name": { + "type": "string" + }, + "notes": { + "type": "string" + }, + "operations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Operation identifiers supported as single requests." + }, + "underlying_model": { + "type": "string" + }, + "version": { + "type": "string", + "description": "Algorithm revision, independent of API and engine package versions." + } + } + }, "ProtobufBody": { "type": "string", "format": "binary" @@ -4792,19 +6906,38 @@ }, "ResidualBasis": { "type": "string", - "description": "The basis a solved residual is expressed in.\n\nThe residual is not one quantity. European and Black paths report a\nforward-normalised price residual comparable against the engine's\nconvergence threshold; Bachelier and American Bjerksund-Stensland report a\nraw option-price residual. Publishing the number without its basis would\ninvite exactly the cross-model comparison it does not support, so the basis\ntravels with it and a client that ignores this field cannot silently get it\nwrong.", + "description": "The basis a solved residual is expressed in.\n\nThe residual is not one quantity. European and Black paths report a\nforward-normalised price residual comparable against the engine's\nconvergence threshold; American models report a\nraw option-price residual. Publishing the number without its basis would\ninvite exactly the cross-model comparison it does not support, so the basis\ntravels with it and a client that ignores this field cannot silently get it\nwrong.", "enum": [ "forward_normalised_price", "option_price" ] }, + "ResolvedCostModel": { + "type": "object", + "description": "Selected numerical model identity, without executing the numerical kernel.", + "required": [ + "model", + "quantity" + ], + "properties": { + "model": { + "type": "string" + }, + "quantity": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, "ResolvedPricingModel": { "type": "string", "description": "Pricing algorithm selected after resolving an input request.\n\nUnlike [`PricingModel`], this output-only type cannot represent `auto`.", "x-extensible-enum": [ "black_scholes_merton", - "bjerksund_stensland_2002", - "bjerksund_stensland_1993" + "bjerksund_stensland_1993", + "black_scholes_merton_qdfp", + "black_scholes_merton_fd" ] }, "ResponseMetadata": { @@ -5031,6 +7164,10 @@ } }, "tags": [ + { + "name": "cost", + "description": "Authenticated cost schedules and non-billable request quotes" + }, { "name": "pricing", "description": "MorphIQ Risk pricing operations" @@ -5069,5 +7206,61 @@ }, "status": 405 } - ] + ], + "x-morphiq-mcp": { + "accept": [ + "application/json", + "text/event-stream" + ], + "authentication": "Bearer API key and signed MCP representation permission", + "contract": "services/api/mcp.contract.json", + "documentation": "/docs/mcp", + "enableEnvironment": "MORPHIQ_API_MCP_ENABLED", + "enabledByDefault": false, + "headers": [ + "x-request-id" + ], + "implementedTools": [ + "analytics", + "analytics_batch", + "cost_quote", + "cost_schedule", + "greeks", + "greeks_batch", + "greeks_standard", + "greeks_standard_batch", + "implied_volatility", + "implied_volatility_batch", + "price", + "price_batch", + "pricing_models", + "regime" + ], + "limits": { + "costQuote": { + "jsonNodes": 131072, + "requestBytes": 40960, + "stringBytes": 1024 + }, + "jsonDepth": 16, + "jsonNodes": 1024, + "regime": { + "jsonNodes": 32768, + "requestBytes": 32768, + "stringBytes": 1024 + }, + "requestBytes": 8192, + "responseBytes": 131072, + "toolValueBytes": 16384 + }, + "method": "POST", + "path": "/mcp", + "protocolVersion": "2026-07-28", + "requestContentType": "application/json", + "responseContentType": "application/json", + "unsupportedMethods": { + "allow": "POST", + "status": 405 + } + } } diff --git a/contracts/provenance.json b/contracts/provenance.json index 443eb23..dab3e19 100644 --- a/contracts/provenance.json +++ b/contracts/provenance.json @@ -1,10 +1,10 @@ { "repository": "https://gitlab.com/prophetizo/morphiq-labs/sites/morphiq-api", - "source_commit": "b2cb8d72b049d61c062f787724c1c02c15dd12be", + "source_commit": "2284d802b5ac0b37b43ee269bbf4bfeec3777776", "source_path": "services/api/openapi.json", - "contract_version": "3.0.0", - "sha256": "09527006497b4f23bbf174f4a5b8bd9d02f75d02554b3d489eaba953ec5a18f1", - "import_method": "Reviewed main snapshot copied byte-for-byte from the immutable Git commit; automated artifact publication remains SDK #2.", + "contract_version": "0.1.0", + "sha256": "e206327e13aee32c02dbfa22e1d8e8a80ea2d549083971a1cc87aa2b67f401e7", + "import_method": "Reviewed API v0.5.0 source snapshot copied byte-for-byte from the deployed immutable main commit; the release tag has the identical source tree. Public API and developer-site schema digests verified at adoption.", "covered_operations": [ "POST /v1/price", "POST /v1/regime", @@ -14,6 +14,11 @@ "POST /v1/price:batch", "POST /v1/greeks:batch", "POST /v1/implied-volatility:batch", - "POST /v1/analytics:batch" + "POST /v1/analytics:batch", + "GET /v1/cost/schedule", + "POST /v1/cost/quote", + "GET /v1/pricing/models", + "POST /v1/greeks/standard", + "POST /v1/greeks/standard:batch" ] } diff --git a/docs/API.md b/docs/API.md index 4bafa1e..8cd48e1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -12,7 +12,7 @@ these keyword-only arguments: | Argument | Default and behavior | | --- | --- | -| `api_key: str \| None` | Explicit key, otherwise `MORPHIQ_API_KEY`. Required unless open development access is explicitly enabled. | +| `api_key: str \| None` | Explicit key, otherwise `MORPHIQ_API_KEY`. Required for protected calls unless open development access is explicitly enabled. Public `pricing_models()` needs no key. | | `base_url: str \| None` | Explicit URL, otherwise `MORPHIQ_API_BASE_URL`, otherwise `https://api.morphiqlabs.com`. Use the API root, not an operation path. | | `timeout: float \| httpx.Timeout` | `30.0` seconds per connection/read/write/pool wait, not a total request deadline. | | `allow_unauthenticated: bool` | `False`. Set `True` only for a controlled open development service; it does not bypass authentication on a protected service. | @@ -38,11 +38,13 @@ All request/response classes below are exported from `morphiq_sdk`. | Method | Request type | Response type | Route | | --- | --- | --- | --- | | `price(request)` | `PriceRequest` | `PriceResponse` | `/v1/price` | +| `greeks_standard(request)` | `GreeksStandardRequest` | `GreeksStandardResponse` | `/v1/greeks/standard` | | `greeks(request)` | `GreeksRequest` | `GreeksResponse` | `/v1/greeks` | | `implied_volatility(request)` | `ImpliedVolatilityRequest` | `ImpliedVolatilityResponse` | `/v1/implied-volatility` | | `analytics(request)` | `AnalyticsRequest` | `AnalyticsResponse` | `/v1/analytics` | | `regime(request)` | `RegimeRequest` | `RegimeResponse` | `/v1/regime` | | `price_batch(request)` | `BatchPriceRequest` | `BatchPriceResponse` | `/v1/price:batch` | +| `greeks_standard_batch(request)` | `BatchGreeksStandardRequest` | `BatchGreeksStandardResponse` | `/v1/greeks/standard:batch` | | `greeks_batch(request)` | `BatchGreeksRequest` | `BatchGreeksResponse` | `/v1/greeks:batch` | | `implied_volatility_batch(request)` | `BatchImpliedVolatilityRequest` | `BatchImpliedVolatilityResponse` | `/v1/implied-volatility:batch` | | `analytics_batch(request)` | `BatchAnalyticsRequest` | `BatchAnalyticsResponse` | `/v1/analytics:batch` | @@ -54,6 +56,7 @@ batch envelope; they do not split requests or start parallel calls. ## Workflow factories Both clients expose `price_workflow(requests, *, chunk_size)`, +`greeks_standard_workflow(requests, *, chunk_size)`, `greeks_workflow(requests, *, chunk_size)`, `implied_volatility_workflow(requests, *, chunk_size)` and `analytics_workflow(requests, *, chunk_size)`. Each takes a sequence of the @@ -72,7 +75,7 @@ stopping behavior](WORKFLOWS.md). ## Option request fields -`PriceRequest`, `GreeksRequest`, `ImpliedVolatilityRequest` and `AnalyticsRequest` +`PriceRequest`, `GreeksRequest`, `GreeksStandardRequest`, `ImpliedVolatilityRequest` and `AnalyticsRequest` share these fields: | Field | Type/default | Meaning | @@ -82,7 +85,7 @@ share these fields: | `spot`, `strike` | Required finite numbers | Underlying and strike prices in the same currency. | | `time_to_expiry_years` | Required finite number | Time from valuation in years. No date/day-count conversion is performed. | | `risk_free_rate`, `dividend_yield` | Required finite numbers | Annual rate and continuous yield as decimals; 0.05 means 5%. | -| `pricing_model` | `"auto"` | Also accepts `"black_scholes_merton"`, `"bjerksund_stensland_2002"`, `"bjerksund_stensland_1993"`. The service validates compatibility and chooses the resolved model. | +| `pricing_model` | `"auto"` | Accepts `"black_scholes_merton"`, `"bjerksund_stensland_1993"`, `"black_scholes_merton_qdfp"`, `"black_scholes_merton_fd"`, and aliases `"qd_fixed_point"`/`"cash_dividend_american"`. BS2002 is retained as an input for the server's actionable retirement error. The service validates compatibility and chooses the resolved model. | | `model_version` | Optional string or explicit null | Forwarded to the service; omitted unless supplied. | | `dividend_schedule` | Optional list of `DividendEvent` | Cash per share at each ex-dividend time. Wire maximum: 16 events. Support and domain constraints remain server-owned. | @@ -94,7 +97,7 @@ Operation-specific fields: | Request | Additional fields | | --- | --- | -| `PriceRequest`, `GreeksRequest` | Required finite `volatility`, as a decimal fraction; 0.20 means 20%. | +| `PriceRequest`, `GreeksRequest`, `GreeksStandardRequest` | Required finite `volatility`, as a decimal fraction; 0.20 means 20%. | | `ImpliedVolatilityRequest` | Required finite `market_price`, the option premium per share. `volatility` is not accepted. | | `AnalyticsRequest` | Required finite `volatility`; optional finite `market_price` or null. With a premium, the API solves IV and ignores supplied volatility. Without a premium, it uses supplied volatility. | @@ -109,10 +112,10 @@ updates do not themselves validate changed values. ## Batch and regime requests Every batch envelope requires `requests`, a list of the corresponding single -request type. The reviewed wire schema accepts 1–1024 items; service/account +request type. The reviewed wire schema accepts 1–100 items; service/account limits can be lower. Empty/oversized lists and invalid list edits fail locally. The SDK preserves item order and single-request omission/null semantics. -See [all four batch construction examples](BATCH.md#constructing-each-batch-type). +See [batch construction examples](BATCH.md#constructing-each-batch-type). `RegimeRequest` takes `prices`, an ordered list of at least 512 finite positive prices, and optional `as_of: str | None` (maximum 64 characters). The service @@ -194,3 +197,11 @@ with separate `results` and `requests` DataFrames, execution `state`, `reported_fcu_charged`, `unknown_charge_count` and `total_fcu_charged`. The optional `pandas` extra is required only at conversion time. See the [table schema, units, missing-value and cost semantics](PANDAS.md). + +See [cost schedules and request quotes](COST.md) for zero-CU discovery, conditional quotes, and the optional `max_cu` execution ceiling. + +Standard-five batching uses `BatchGreeksStandardRequest(requests=[...])` and +`client.greeks_standard_batch(...)` (or `await` with `AsyncMorphIQ`). The batch +contains `GreeksStandardRequest` items; ordering, error and charge handling match +the other batch families. Public `client.pricing_models()` returns a typed +`PricingModelCatalog` without authentication; see [model discovery](MODELS.md). diff --git a/docs/API_V050_ADOPTION.md b/docs/API_V050_ADOPTION.md new file mode 100644 index 0000000..80a1890 --- /dev/null +++ b/docs/API_V050_ADOPTION.md @@ -0,0 +1,62 @@ +# API v0.5.0 SDK adoption + +The SDK consumes the deployed API contract at source +`2284d802b5ac0b37b43ee269bbf4bfeec3777776`, SHA-256 +`e206327e13aee32c02dbfa22e1d8e8a80ea2d549083971a1cc87aa2b67f401e7`. +The beta wire version remains `0.1.0`; API and SDK releases have separate versions. +See [model capabilities](MODELS.md) and [contract provenance](CONTRACT.md). + +## Compatibility changes + +- Sync and async clients expose all 14 numerical/discovery/cost operations, + including public model discovery and standard-five single/batch Greeks. +- All 11 execution methods accept `max_cu`; both standard-five request types + participate in conditional cost quotation. Rates remain server-owned. +- Batch envelopes and workflow chunks enforce the wire ceiling of 100. BSM and + BS1993 support all five batch operations; the server rejects other models. +- Greek fields may be unavailable. Their Python value is `None`, accompanied by + typed reason codes/messages; real zero remains zero. Use + `model_dump(exclude_unset=True)` to preserve wire presence. +- Construction without a key permits public discovery. Protected calls still + reject a missing key before transport unless open-development access is explicit. +- Auto American selects QdFp. American Greeks/analytics/batch examples select + BS1993 explicitly. The server retains authority over capability admission. +- Disabled operations return 400; the retired BS2002 selector returns 422 + `invalid_field`. The SDK preserves those errors without retries or substitutions. + +## Verification + +On 2026-11-04, Ruff lint/format and mypy passed. The isolated sdist-to-wheel check +verified core dependency isolation and all five pandas batch conversions. +Eighteen native integration tests passed against an optimized, locked build of +the exact API source above, including sync/async parity, 100-item standard-five +batches, explicit chunk workflows, QdFp/finite-difference price and IV, capability +refusals and BS2002 retirement. The debug build exceeded the deadline on +finite-difference IV; optimized validation completed within the service deadline. +This is client contract validation, not a new engine performance benchmark. + +Native pricing, Greek, IV, analytics and batch fixtures use synthetic inputs from +that service. An invalid IV premium is now rejected before batch execution, so +mixed-outcome transport tests explicitly inject a synthetic item failure rather +than mislabeling it as a native result. Regime prediction fixtures remain clearly +identified synthetic wire fixtures. No production keys or private data are used. + +Reproduce local checks with the repository's pinned development requirements: + +```sh +python -m ruff check . +python -m ruff format --check . +python -m mypy +python scripts/generate_notebook.py --check +python -m pytest -q +python scripts/check_package.py +docker build -t morphiq-sdk-notebook:test . +python scripts/check_notebook_container.py --image morphiq-sdk-notebook:test +``` + +For native checks, run the pinned API with open admission on an isolated loopback +address, using `cargo build --release --locked -p morphiq-api-service --bin +morphiq-api-service`, then set `MORPHIQ_TEST_API_URL` for `tests/test_integration.py`. +The test verifies the served schema digest. Before publishing, use +`scripts/check_release_candidate.py` on the built release-PR wheel in a clean +interpreter; [RELEASE.md](../RELEASE.md) owns publication and versioning. diff --git a/docs/BATCH.md b/docs/BATCH.md index c6cb28f..8613fe0 100644 --- a/docs/BATCH.md +++ b/docs/BATCH.md @@ -1,10 +1,14 @@ # Batch operations +See [API v0.5.0 model capabilities](MODELS.md) for supported operations, +100-item batch limits and unavailable-Greek semantics. + Submit one explicit ordered batch with either `MorphIQ` or `AsyncMorphIQ`: | Method | Request | Response | | --- | --- | --- | | `price_batch()` | `BatchPriceRequest` | `BatchPriceResponse` | +| `greeks_standard_batch()` | `BatchGreeksStandardRequest` | `BatchGreeksStandardResponse` | | `greeks_batch()` | `BatchGreeksRequest` | `BatchGreeksResponse` | | `implied_volatility_batch()` | `BatchImpliedVolatilityRequest` | `BatchImpliedVolatilityResponse` | | `analytics_batch()` | `BatchAnalyticsRequest` | `BatchAnalyticsResponse` | @@ -14,7 +18,7 @@ request models. One call sends one HTTP request. These methods do not split batc create concurrent work, retry rejected requests, or replay failed items. For caller-sized chunks, optional async concurrency and per-request costs, use an explicit [batch workflow](WORKFLOWS.md). -The reviewed wire schema allows 1–1024 items; the service applies the account's +The reviewed wire schema allows 1–100 items; the service applies the account's actual limit, which may be lower. The SDK does not hardcode plan limits. Set `MORPHIQ_API_KEY`, then run a self-contained example: @@ -30,7 +34,7 @@ For async calls, use `async with AsyncMorphIQ()` and await the same method name. ## Constructing each batch type -This builds all four envelope types without sending requests, then submits **one +This builds all five envelope types without sending requests, then submits **one analytics batch**. The single item is a synthetic European call. Add explicitly constructed items to each list to send more contracts in that batch. @@ -39,9 +43,11 @@ from morphiq_sdk import ( AnalyticsRequest, BatchAnalyticsRequest, BatchGreeksRequest, + BatchGreeksStandardRequest, BatchImpliedVolatilityRequest, BatchPriceRequest, GreeksRequest, + GreeksStandardRequest, ImpliedVolatilityRequest, MorphIQ, PriceRequest, @@ -59,6 +65,9 @@ option = { } price_batch = BatchPriceRequest(requests=[PriceRequest(**option, volatility=0.20)]) greeks_batch = BatchGreeksRequest(requests=[GreeksRequest(**option, volatility=0.20)]) +standard_batch = BatchGreeksStandardRequest( + requests=[GreeksStandardRequest(**option, volatility=0.2)] +) iv_batch = BatchImpliedVolatilityRequest( requests=[ImpliedVolatilityRequest(**option, market_price=10.450583572185538)] ) @@ -80,6 +89,7 @@ print("Request ID:", response.metadata.request_id) ``` The other calls are `client.price_batch(price_batch)`, +`client.greeks_standard_batch(standard_batch)`, `client.greeks_batch(greeks_batch)` and `client.implied_volatility_batch(iv_batch)`. Each sends a separate request if executed. Each operation keeps the corresponding [single-request fields and units](API.md#option-request-fields). @@ -151,3 +161,9 @@ remain unknown; use the request ID and account records to reconcile them. Use the optional [pandas adapter](PANDAS.md) to convert batch results into ordered analysis rows and a separate per-request charge table. Core SDK calls do not require pandas. + +Standard-five batching uses `BatchGreeksStandardRequest(requests=[...])` and +`client.greeks_standard_batch(...)` (or `await` with `AsyncMorphIQ`). The batch +contains `GreeksStandardRequest` items; ordering, error and charge handling match +the other batch families. Public `client.pricing_models()` returns a typed +`PricingModelCatalog` without authentication; see [model discovery](MODELS.md). diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 707c2c9..6f82bc3 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -9,12 +9,18 @@ coordinates the producer/import workflow with [API #106](https://gitlab.com/prophetizo/morphiq-labs/sites/morphiq-api/-/work_items/106). contracts/openapi.json now contains the reviewed snapshot byte-for-byte. contracts/provenance.json records its digest, API source commit and contract -version. It was copied from immutable reviewed main source; automated published +version. This snapshot is from deployed API **v0.5.0**, commit +`2284d802b5ac0b37b43ee269bbf4bfeec3777776`, SHA-256 +`e206327e13aee32c02dbfa22e1d8e8a80ea2d549083971a1cc87aa2b67f401e7`. +The beta wire contract version remains `0.1.0`; API and SDK release versions are separate. +It was copied from immutable reviewed main source; automated published artifact delivery remains #2. SDK tests and builds use the offline snapshot without private repository or numerical engine access. The SDK covers POST /v1/price, POST /v1/greeks, POST /v1/implied-volatility, -POST /v1/analytics, their four `:batch` routes, and POST /v1/regime. Hand-written model field sets and serialized +POST /v1/analytics, POST /v1/greeks/standard, their five `:batch` routes, +POST /v1/regime, GET /v1/pricing/models, GET /v1/cost/schedule and +POST /v1/cost/quote (14 operations). Hand-written model field sets and serialized requests/responses are checked against the pinned schema. Digest/version checks detect accidental snapshot changes. Intentional upgrades need reviewed provenance and tests; this is not a complete future-operation compatibility generator. @@ -41,6 +47,6 @@ source identity are unchanged. Optional controlled-service integration checks SDK results against native JSON; it does not expect synthetic fixture scores. Greeks, implied-volatility and analytics fixtures contain synthetic inputs evaluated by the -native service built from the pinned API commit. Tests verify all ten unscaled -Greeks, IV solver diagnostics and residual bases, model identity, metadata, error +native service built from the pinned API commit. Tests verify unscaled +Greeks and explicit unavailable-quantity reasons, IV solver diagnostics and residual bases, model identity, metadata, error evidence and sync/async parity against native JSON. diff --git a/docs/COST.md b/docs/COST.md new file mode 100644 index 0000000..ca2687a --- /dev/null +++ b/docs/COST.md @@ -0,0 +1,299 @@ +# Cost schedules, quotes and execution ceilings + +These methods require an API deployment and SDK release that support cost utilities. +Use your normal API key. Utility calls consume zero commercial CUs, perform no +numerical calculation and reserve no funds or execution capacity. + +```python +from morphiq_sdk import MorphIQ, PriceRequest + +# Synthetic inputs, not market observations. +request = PriceRequest( + exercise_style="european", + option_type="call", + spot=100.0, + strike=100.0, + time_to_expiry_years=1.0, + risk_free_rate=0.05, + dividend_yield=0.0, + volatility=0.2, +) +with MorphIQ() as client: + schedule = client.cost_schedule(operation="price", model="black_scholes_merton/1") + quote = client.quote(request) + print(quote.quote.total_fcu, quote.resolved_models) + # Explicit execution: one additional, potentially billable request. + result = client.price(request, max_cu=quote.quote.total_fcu) + print(result.metadata.fcu_charged) +``` + +`AsyncMorphIQ` exposes the same methods with `await`. All eleven typed +single and batch request models can be passed to `quote`; the SDK infers the +operation and serializes the same body it uses for execution. It performs no +local pricing or CU calculation. The response keeps the original JSON in `raw_body`; +`model_dump_json(indent=2, exclude_none=True)` prints its typed fields. Every single/batch execution method accepts +optional `max_cu`. For a chunked workflow, quote and submit each chunk explicitly +when you need per-request ceilings; a ceiling does not cap an entire session. + +`cost_schedule()` returns the caller's active schedule, including account-specific +overrides, identity/version, policy revision, effective dates, rates, fixed fee, +minimum and charging rule. Filters combine with AND; known filters with no matching +rows return an empty list. A rate does not establish entitlement or availability. `record_version` identifies +the immutable policy record, while `schedule_version` identifies the rate schedule. +A null `effective_until` means no fixed end date on that record, not a price lock. +Operation filters are `price`, `implied_volatility`, `greeks`, `analytics`, `regime`; +batch calls use those same schedule rows. Model filters use versioned identifiers. + +`quote.quote.total_fcu` is exact conditional on the same policy and resolved work +at execution. Fixed fees and minimums apply once to a batch, not once per item. +The minimum is a floor on the request total, not an extra fee. Each line groups +contracts sharing a rate class; mixed-model batches can have several lines. +`quote.quote.lines` identifies rate classes; `resolved_models` identifies actual +selected algorithms. These can differ when algorithms share a billing class. +`quote.metadata.fcu_charged` describes the utility call and is zero. + +A quote does not lock a price or guarantee funding, capacity, numerical convergence +or success. Its policy revision identifies the quote's snapshot. `max_cu` sends +`X-MorphIQ-Max-CU`; it accepts an integer from zero through 2**64 - 1. An actual +quote above the ceiling produces `APIError` with status 409 and code +`cost_limit_exceeded`, before commitment and at zero charge. Equality is allowed. +Once execution commits, a subsequent timeout or failure retains the committed +charge. The SDK never automatically retries. + +Cost utility calls use separate account rate buckets from execution, with the +pinned plan's sustained and burst request allowances. Target request/body limits, +account concurrency and service bounds still apply. Invalid or unavailable work +is an error, never a free quote. Utility responses are private and not cacheable. + +Run the executable example with explicit actions: + +```sh +# Two zero-CU utility calls: schedule and single-request quote. +python examples/cost_quote.py +# Two zero-CU utility calls: schedule and two-contract batch quote. +python examples/cost_quote.py --batch +# Adds one potentially billable batch execution, capped at its quoted CU. +python examples/cost_quote.py --batch --execute +# Adds one execution with your chosen cap; may produce a ceiling refusal. +python examples/cost_quote.py --batch --execute --max-cu 1 +``` + +No command retries or raises its ceiling automatically. The +[cost workspace](../notebooks/cost/quotation.ipynb) provides separate schedule, +quote and capped execution buttons, with synthetic inputs and no setup-time calls. + + +## Historical wire-response examples + +These are wire responses from synthetic requests against the local API example +schedule, captured for [API !188](https://gitlab.com/prophetizo/morphiq-labs/sites/morphiq-api/-/merge_requests/188) +at source `ae243c62bf2c3c06182f845bab4f8131c60e3d48`. Request IDs are normalized; +optional policy and budget metadata is omitted for readability. Your account's +rates, policy identity and service version can differ. They illustrate the wire shape, not the current production rate card. +API v0.5.0 exposes these utilities; fetch your current schedule instead of +copying the sample rates or schedule identity. SDK availability follows its own release. + +A filtered schedule, accessed as `response.schedule`: + +```json +{ + "schedule": { + "effective_from": "2026-10-19T00:00:00Z", + "effective_until": null, + "failure_charging": "commit_at_execution_start", + "fixed_request_fcu": 0, + "minimum_charge_fcu": 0, + "policy_revision": "5f33de9e901762390e72bb9820f01e64b704e43155973871672973878405f0f5", + "rates": [ + { + "billing_unit": "contract", + "fcu_per_unit": 1, + "max_discrete_dividends": 0, + "model": "black_scholes_merton/1", + "operation": "price" + } + ], + "record_version": 2, + "rounding": "none", + "schedule_id": "fcu-student-v1", + "schedule_version": "2026-10-19-r2" + }, + "metadata": { + "api_version": "v1", + "fcu_charged": 0, + "fcu_schedule_id": "fcu-student-v1", + "fcu_schedule_version": "2026-10-19-r2", + "request_id": "example-schedule", + "service_version": "0.4.1" + } +} +``` + +A single-contract quote, accessed as `response.quote` and `response.resolved_models`: + +```json +{ + "quote": { + "fixed_fcu": 0, + "lines": [ + { + "billing_unit": "contract", + "fcu_per_unit": 1, + "model": "black_scholes_merton/1", + "operation": "price", + "quantity": 1, + "variable_fcu": 1 + } + ], + "minimum_charge_fcu": 0, + "total_fcu": 1, + "variable_fcu": 1 + }, + "resolved_models": [ + { + "model": "black_scholes_merton/1", + "quantity": 1 + } + ], + "metadata": { + "api_version": "v1", + "fcu_charged": 0, + "fcu_schedule_id": "fcu-student-v1", + "fcu_schedule_version": "2026-10-19-r2", + "request_id": "example-quote", + "service_version": "0.4.1" + } +} +``` + +Here the prospective execution charge is **one CU**, and the quote call's +actual charge is **zero CU**. Utility quotes put the prospective amount in +`response.quote.total_fcu`. Execution responses use `metadata.fcu_quoted` for +their own quote and `metadata.fcu_charged` for the committed charge. + +## Quote and execute a batch + +This complete example submits one zero-CU quote and then one potentially billable +execution. Both use the same two-contract request. The ceiling applies to the +whole batch. Choose whether to run the execution step. + +```python +from morphiq_sdk import APIError, BatchPriceRequest, MorphIQ, PriceRequest + +# Two identical synthetic contracts, not market data. +option = PriceRequest( + exercise_style="european", + pricing_model="auto", + option_type="call", + spot=100.0, + strike=100.0, + time_to_expiry_years=1.0, + risk_free_rate=0.05, + dividend_yield=0.0, + volatility=0.2, +) +batch = BatchPriceRequest(requests=[option, option]) +with MorphIQ() as client: + quote = client.quote(batch) + print(quote.model_dump_json(indent=2, exclude_none=True)) + try: + result = client.price_batch(batch, max_cu=quote.quote.total_fcu) + except APIError as error: + print(error.status_code, error.code, error.request_id) + if error.metadata is not None: + print("Execution quote CU:", error.metadata.fcu_quoted) + print("CU charged:", error.metadata.fcu_charged) + # Missing charge metadata means unknown, never zero. No automatic retry. + raise + print("Batch CU charged:", result.metadata.fcu_charged) + for item in result.results: + if item.status == "success": + print(item.index, item.result.price) + else: + print(item.index, item.error.code) +``` + +The quote for those two contracts under the example schedule is: + +```json +{ + "quote": { + "fixed_fcu": 0, + "lines": [ + { + "billing_unit": "contract", + "fcu_per_unit": 1, + "model": "black_scholes_merton/1", + "operation": "price", + "quantity": 2, + "variable_fcu": 2 + } + ], + "minimum_charge_fcu": 0, + "total_fcu": 2, + "variable_fcu": 2 + }, + "resolved_models": [ + { + "model": "black_scholes_merton/1", + "quantity": 2 + } + ], + "metadata": { + "api_version": "v1", + "fcu_charged": 0, + "fcu_schedule_id": "fcu-student-v1", + "fcu_schedule_version": "2026-10-19-r2", + "request_id": "example-batch_quote", + "service_version": "0.4.1" + } +} +``` + +The single line has `quantity: 2` because both contracts share a billing class. +The API resolves models and computes the total; the SDK forwards and preserves +that evidence. `AsyncMorphIQ` takes the same typed batch with +`await client.quote(batch)` and `await client.price_batch(batch, max_cu=...)`. +For a chunked workload, repeat the quote/execute choice for each chunk: there is +no implicit session-wide ceiling. + +## Inspect a ceiling refusal + +Submitting that batch with `max_cu=1` under the example schedule produces +HTTP 409 and raises `APIError`. The original JSON is available in `error.body`; +its typed evidence is in `error.metadata`: + +```json +{ + "error": { + "code": "cost_limit_exceeded", + "message": "the quoted CU charge exceeds x-morphiq-max-cu" + }, + "metadata": { + "api_version": "v1", + "compute_schedule_version": "2026-09-11", + "engine_version": "0.39.1", + "engines": [ + { + "name": "morphiq-risk", + "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164", + "version": "0.39.1" + } + ], + "fcu_charged": 0, + "fcu_quoted": 2, + "fcu_schedule_id": "fcu-student-v1", + "fcu_schedule_version": "2026-10-19-r2", + "request_id": "example-ceiling_error", + "units_charged": 0 + } +} +``` + +`fcu_quoted: 2` records the execution quote; `fcu_charged: 0` records the +zero committed charge. `units_charged` is internal execution accounting, not +commercial CU. Engine identity does not establish that calculation ran. +Inspect the error code, the current quote and your budget before choosing a new +ceiling. Other admission errors, including funding refusals, can occur before +the ceiling check. Malformed or duplicate HTTP ceilings return 422; the SDK +rejects invalid `max_cu` values locally before sending a request. diff --git a/docs/GREEKS.md b/docs/GREEKS.md index e844ba2..a365746 100644 --- a/docs/GREEKS.md +++ b/docs/GREEKS.md @@ -1,5 +1,8 @@ # Option price and Greeks +See [API v0.5.0 model capabilities](MODELS.md) for supported operations, +100-item batch limits and unavailable-Greek semantics. + `MorphIQ.greeks()` and `AsyncMorphIQ.greeks()` call `POST /v1/greeks`. One request returns the option price and all ten Greeks at the supplied volatility. The SDK does not calculate derivatives, choose the numerical model, or rescale values. @@ -87,3 +90,15 @@ example retries automatically. See the [client/model reference](API.md) for shared configuration and errors, and the [documentation index](README.md) for related features. + +## Standard five + +`GreeksStandardRequest` and `client.greeks_standard(request)` return price, +delta, gamma, theta, vega and rho from `/v1/greeks/standard`. The request fields +match `GreeksRequest`; `max_cu` is supported. Use `BatchGreeksStandardRequest` +with `greeks_standard_batch()` for a single synchronous batch, or explicitly +chunk with `greeks_standard_workflow(..., chunk_size=100)`. Both clients expose +these methods; async client calls use `await` and do not create server jobs. + +Run `python examples/greeks_standard.py` or open +`notebooks/pricing/standard-five.ipynb` for an explicit single-request analysis. diff --git a/docs/IMPLIED_VOLATILITY.md b/docs/IMPLIED_VOLATILITY.md index 40e9a3b..1daf4a9 100644 --- a/docs/IMPLIED_VOLATILITY.md +++ b/docs/IMPLIED_VOLATILITY.md @@ -51,13 +51,13 @@ semantics; the SDK does not adjust inputs or prices. | --- | --- | | `implied_volatility` | Decimal volatility: 0.2 is 20%. | | `iterations` | Solver iteration count as reported by the service. | -| `used_fallback` | Algorithm provenance, not a solution-quality flag. American bisection reports true for live solves. | +| `used_fallback` | Algorithm provenance, not a solution-quality flag. The value depends on the model and solver path; do not infer it from exercise style. | | `residual` | Solver error in the units named by `residual_basis`. | | `residual_basis` | `forward_normalised_price` or `option_price`; values on different bases are not directly comparable. | | `pricing_model`, `model_version` | Resolved model and version used for the solve. | -The pinned native European fixture solves to `0.20000000000000007`, with 2 -iterations, `used_fallback=false` and residual `1.3877787807814457e-17` on the +The pinned native European fixture solves to `0.1999999999999992`, with 2 +iterations, `used_fallback=false` and residual `1.3200960109770371e-17` on the `forward_normalised_price` basis. Its American counterpart uses the `option_price` basis. Full native results and provenance are retained in [the fixture](../tests/fixtures/implied_volatility.json). diff --git a/docs/MODELS.md b/docs/MODELS.md new file mode 100644 index 0000000..15f831a --- /dev/null +++ b/docs/MODELS.md @@ -0,0 +1,43 @@ +# Available models and API v0.5.0 compatibility + +```python +from morphiq_sdk import MorphIQ + +with MorphIQ() as client: + catalog = client.pricing_models() +for model in catalog.models: + print(model.id, model.operations, model.batch_operations, model.constraints) +``` + +This public GET sends no API key, even when a key is configured. `AsyncMorphIQ` +provides the same method with `await`. The catalog includes aliases, engine/model +versions, Greek units, supported dividends, constraints and guidance. It describes +compiled service capabilities; account entitlements and rates come from the +authenticated `cost_schedule()` and `quote(request)` methods. + +| Canonical selector | Single operations | Batch operations | +| --- | --- | --- | +| `black_scholes_merton` | Price, IV, standard five, all ten, analytics | All five | +| `bjerksund_stensland_1993` | Price, IV, standard five, all ten, analytics | All five | +| `black_scholes_merton_qdfp` | Price, IV | None | +| `black_scholes_merton_fd` | Price, IV | None | + +The finite-difference model supports cash-dividend schedules. Legacy aliases +`qd_fixed_point` and `cash_dividend_american` are forwarded unchanged. BS2002 is +retired: the SDK forwards that selector so callers receive the server's migration +error. It does not substitute a model or calculate numerical fallbacks. + +`auto` selects BSM for European options and QdFp for American options. Select +BS1993 explicitly for American Greeks, analytics and batching. Unsupported +operations and a batch containing QdFp or the cash-dividend model receive HTTP 400 +(`APIError`); batches contain 1–100 items. Account limits may be lower. + +A Greek may be omitted with an entry in `greeks.unavailable`, whose value contains +`code`, `message` and optionally `field`. Python exposes the omitted quantity as +`None`; it is never replaced by zero. `model_dump(exclude_unset=True)` preserves +wire omission and all reasons. DataFrame numeric cells remain missing, with +reasons available in the `result.greeks.unavailable` column and full `outcome`. + +The API owns these capabilities and may evolve them. Query discovery for current +capabilities and preserve actionable server errors instead of copying this table +into client admission logic. diff --git a/docs/NOTEBOOKS.md b/docs/NOTEBOOKS.md index dba634d..a02f963 100644 --- a/docs/NOTEBOOKS.md +++ b/docs/NOTEBOOKS.md @@ -10,9 +10,11 @@ docker compose up --build ``` Open the local token-authenticated URL printed by Jupyter to reach Start Here. -Choose [option pricing](../notebooks/pricing/options.ipynb) or +Choose [available models](../notebooks/pricing/models.ipynb), +[standard-five Greeks](../notebooks/pricing/standard-five.ipynb), +[cost quotation](../notebooks/cost/quotation.ipynb), [option pricing](../notebooks/pricing/options.ipynb) or [regime analysis](../notebooks/regime/identification.ipynb), then select -**Run → Run All Cells** to initialize the controls. The start page and both +**Run → Run All Cells** to initialize the controls. The start page and all workspaces are included in the image. Compose passes the key into the container at runtime; it is not baked into the image. The notebook connects to `https://api.morphiqlabs.com` automatically. Leave @@ -34,6 +36,8 @@ Choose the action that matches the analysis you need: | Action | Inputs and result | MorphIQ requests | | --- | --- | --- | | **Price option** | Supplied volatility and option terms → price and comparison history | 1 | +| **Load models** | Public model capabilities and constraints; no API key, 0 CU | 1 | +| **Calculate** (standard five) | Supplied inputs → price, delta, gamma, theta, vega, rho | 1 | | **Price + Greeks** | Supplied volatility and option terms → price and all ten Greeks | 1 | | **Solve IV** | Entered per-share option premium and terms → IV and solver diagnostics | 1 | | **Analyze premium** | Entered premium and terms → native combined IV, price and Greeks at solved IV | 1 | diff --git a/docs/PANDAS.md b/docs/PANDAS.md index dfedb89..1af7a23 100644 --- a/docs/PANDAS.md +++ b/docs/PANDAS.md @@ -13,7 +13,7 @@ even importing `morphiq_sdk.pandas` does not import pandas until conversion. ## Convert results Import `batch_to_frames`, `workflow_to_frames` and the returned `BatchFrames` type -from `morphiq_sdk.pandas`. All four batch operations are supported: pricing, +from `morphiq_sdk.pandas`. All five batch operations are supported: pricing, standard-five Greeks, Greeks, implied volatility and combined analytics. Responses from sync and async clients use the same synchronous conversion functions. diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 60afec4..f108ff5 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -106,8 +106,8 @@ They also run directly as `python examples/price_european.py` and `python examples/price_american.py`, with no repository helper imports. The [native reference fixtures](../tests/fixtures/pricing.json) record about -**10.450583572185538** for the European call (`black_scholes_merton/1`) and -**7.584143004464017** for the American put (`bjerksund_stensland_2002/1`). These +**10.45058357218557** for the European call (`black_scholes_merton/1`) and +**7.6165316765775835** for the American put (`qd_fixed_point/1`). These results belong to the recorded service/engine identity; they are not universal expectations for future models selected by `auto`. Request IDs vary. The fixture service ran in open mode and omitted commercial charges. @@ -216,3 +216,5 @@ For a controlled open local service only, set a non-secret placeholder such as `allow_unauthenticated=True` with no key when constructing a client. This does not authenticate against a protected service. Configure the local endpoint explicitly; the SDK does not start that service. + +See [cost schedules and request quotes](COST.md) for zero-CU discovery, conditional quotes, and the optional `max_cu` execution ceiling. diff --git a/docs/README.md b/docs/README.md index 1cc1cb1..784d414 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,11 +1,17 @@ # SDK documentation +See [API v0.5.0 model capabilities](MODELS.md) for supported operations, +100-item batch limits and unavailable-Greek semantics. + Start with [API access, installation and your first request](QUICKSTART.md). The [client and model reference](API.md) lists every supported method, its inputs, response types, configuration and errors. | Task | Guide | Executable examples | | --- | --- | --- | +| Discover models | [Model capabilities](MODELS.md) | [Public discovery](../examples/pricing_models.py) | +| Calculate standard-five Greeks | [Greeks and units](GREEKS.md) | [Standard five](../examples/greeks_standard.py) | +| Discover rates and quote requests | [Cost utilities](COST.md) | [Quote and optional capped execution](../examples/cost_quote.py) | | Price European/American options | [Pricing quickstart](QUICKSTART.md) | [European](../examples/price_european.py), [American](../examples/price_american.py), [async](../examples/price_async.py) | | Calculate price and ten Greeks | [Greeks and units](GREEKS.md) | [Sync](../examples/greeks.py), [async](../examples/greeks_async.py) | | Solve volatility from an option premium | [Implied volatility](IMPLIED_VOLATILITY.md) | [Sync](../examples/implied_volatility.py), [async](../examples/implied_volatility_async.py) | diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index ad7a88e..80725df 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -14,7 +14,7 @@ Both `MorphIQ` and `AsyncMorphIQ` provide these factories: | `analytics_workflow()` | `AnalyticsRequest` | `BatchAnalyticsResponse` | Every factory requires `chunk_size=`. Choose a positive integer allowed by your -account; the SDK checks the reviewed wire ceiling of 1024, not customer plan +account; the SDK checks the reviewed wire ceiling of 100, not customer plan limits. A smaller final chunk is allowed. Empty input completes without HTTP requests and has total cost zero. Execution is sequential by default. Async workflows accept an explicit @@ -119,7 +119,7 @@ whole-workflow total remains `None` because the work is incomplete. ## Opt-in async concurrency -All four async factories accept `max_concurrency=1`. Pass a positive integer to +All five async factories accept `max_concurrency=1`. Pass a positive integer to bound simultaneous batch requests, for example: ```python diff --git a/examples/analytics.py b/examples/analytics.py index 3ad8c35..1c5ae49 100644 --- a/examples/analytics.py +++ b/examples/analytics.py @@ -75,7 +75,9 @@ def main() -> None: "veta", "color", ): - print(f" {name}: {getattr(response.result.greeks, name)}") + value = getattr(response.result.greeks, name) + reason = response.result.greeks.unavailable.get(name) + print(f" {name}: {value if value is not None else reason}") # The combined response reports one charge, not one charge per result component. print(f"Model: {response.result.pricing_model} ({response.result.model_version})") print(f"Request ID: {response.metadata.request_id}") diff --git a/examples/analytics_async.py b/examples/analytics_async.py index 056d5dc..b726b6f 100644 --- a/examples/analytics_async.py +++ b/examples/analytics_async.py @@ -82,7 +82,9 @@ async def main() -> None: "veta", "color", ): - print(f" {name}: {getattr(response.result.greeks, name)}") + value = getattr(response.result.greeks, name) + reason = response.result.greeks.unavailable.get(name) + print(f" {name}: {value if value is not None else reason}") # The combined response reports one charge, not one charge per result component. print(f"Model: {response.result.pricing_model} ({response.result.model_version})") print(f"Request ID: {response.metadata.request_id}") diff --git a/examples/batch_price.py b/examples/batch_price.py index 1239225..451468a 100644 --- a/examples/batch_price.py +++ b/examples/batch_price.py @@ -38,7 +38,7 @@ def main() -> None: ), PriceRequest( exercise_style="american", - pricing_model="auto", + pricing_model="bjerksund_stensland_1993", option_type="put", spot=100.0, strike=105.0, diff --git a/examples/batch_price_async.py b/examples/batch_price_async.py index e2dc911..6753d79 100644 --- a/examples/batch_price_async.py +++ b/examples/batch_price_async.py @@ -39,7 +39,7 @@ async def main() -> None: ), PriceRequest( exercise_style="american", - pricing_model="auto", + pricing_model="bjerksund_stensland_1993", option_type="put", spot=100.0, strike=105.0, diff --git a/examples/cost_quote.py b/examples/cost_quote.py new file mode 100644 index 0000000..804dd49 --- /dev/null +++ b/examples/cost_quote.py @@ -0,0 +1,77 @@ +"""Two non-billable utility requests; --execute adds one capped pricing request.""" + +import argparse +import sys + +from morphiq_sdk import ( + APIError, + BatchPriceRequest, + BatchPriceResponse, + MorphIQ, + PriceRequest, + PriceResponse, +) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--execute", action="store_true", help="Execute once with the quoted CU ceiling" + ) + parser.add_argument("--batch", action="store_true", help="Quote two synthetic contracts") + parser.add_argument( + "--max-cu", type=int, help="Explicit execution ceiling; defaults to the quote" + ) + args = parser.parse_args() + if args.max_cu is not None and not 0 <= args.max_cu <= 2**64 - 1: + parser.error("--max-cu must be an unsigned 64-bit integer") + if args.max_cu is not None and not args.execute: + parser.error("--max-cu requires --execute") + request = PriceRequest( + exercise_style="european", + option_type="call", + spot=100.0, + strike=100.0, + time_to_expiry_years=1.0, + risk_free_rate=0.05, + dividend_yield=0.0, + volatility=0.2, + ) + with MorphIQ() as client: + schedule = client.cost_schedule(operation="price") + print(schedule.model_dump_json(indent=2)) + batch = BatchPriceRequest(requests=[request, request]) + quote = client.quote(batch if args.batch else request) + print(quote.model_dump_json(indent=2)) + print("Prospective execution CU:", quote.quote.total_fcu) + print("Utility CU charged:", quote.metadata.fcu_charged) + if args.execute: + ceiling = quote.quote.total_fcu if args.max_cu is None else args.max_cu + response: PriceResponse | BatchPriceResponse + try: + if args.batch: + response = client.price_batch(batch, max_cu=ceiling) + else: + response = client.price(request, max_cu=ceiling) + except APIError as error: + print(f"HTTP {error.status_code}: {error.code}", file=sys.stderr) + print("Request ID:", error.request_id, file=sys.stderr) + quoted = error.metadata.fcu_quoted if error.metadata else None + charged = error.metadata.fcu_charged if error.metadata else None + print( + "Execution quote CU:", + quoted if quoted is not None else "not reported", + file=sys.stderr, + ) + print( + "CU charged:", + charged if charged is not None else "not reported", + file=sys.stderr, + ) + print("No retry or automatic ceiling increase.", file=sys.stderr) + raise SystemExit(1) from None + print(response.model_dump_json(indent=2)) + + +if __name__ == "__main__": + main() diff --git a/examples/greeks.py b/examples/greeks.py index 0ffec1e..3147dea 100644 --- a/examples/greeks.py +++ b/examples/greeks.py @@ -65,7 +65,9 @@ def main() -> None: "veta", "color", ): - print(f" {name}: {getattr(response.result.greeks, name)}") + value = getattr(response.result.greeks, name) + reason = response.result.greeks.unavailable.get(name) + print(f" {name}: {value if value is not None else reason}") print(f"Model: {response.result.pricing_model} ({response.result.model_version})") print(f"Request ID: {response.metadata.request_id}") for engine in response.metadata.engines: diff --git a/examples/greeks_async.py b/examples/greeks_async.py index 289c491..d637ddc 100644 --- a/examples/greeks_async.py +++ b/examples/greeks_async.py @@ -66,7 +66,9 @@ async def main() -> None: "veta", "color", ): - print(f" {name}: {getattr(response.result.greeks, name)}") + value = getattr(response.result.greeks, name) + reason = response.result.greeks.unavailable.get(name) + print(f" {name}: {value if value is not None else reason}") print(f"Model: {response.result.pricing_model} ({response.result.model_version})") print(f"Request ID: {response.metadata.request_id}") for engine in response.metadata.engines: diff --git a/examples/greeks_standard.py b/examples/greeks_standard.py new file mode 100644 index 0000000..8282b59 --- /dev/null +++ b/examples/greeks_standard.py @@ -0,0 +1,76 @@ +"""Calculate the price and standard five Greeks for a synthetic European call. + +Run: python examples/greeks_standard.py +""" + +import os +import sys +from getpass import getpass + +from morphiq_sdk import APIError, GreeksStandardRequest, MorphIQ, ResponseError, TransportError + + +def main() -> None: + # Set MORPHIQ_API_KEY in your environment, or enter it at the masked prompt. + api_key = os.environ.get("MORPHIQ_API_KEY") or getpass("MorphIQ API key: ") + + # Synthetic European call; spot and strike use the same currency. + request = GreeksStandardRequest( + exercise_style="european", + option_type="call", + spot=100.0, + strike=100.0, + time_to_expiry_years=1.0, # Years from valuation. + risk_free_rate=0.05, # 5% annual rate. + dividend_yield=0.0, + volatility=0.20, # 20% annual volatility. + pricing_model="auto", + ) + + try: + # The client uses https://api.morphiqlabs.com and closes its connection pool here. + with MorphIQ(api_key=api_key) as client: + response = client.greeks_standard(request) + except APIError as error: + print(f"HTTP {error.status_code}: {error.code}: {error.message}", file=sys.stderr) + print(f"Request ID: {error.request_id}", file=sys.stderr) + charged = error.metadata.fcu_charged if error.metadata else None + print( + f"Commercial CU charged: {charged if charged is not None else 'not reported'}", + file=sys.stderr, + ) + raise SystemExit(1) from None + except TransportError: + # Includes timeouts. The service may already have committed a charge. + raise SystemExit( + "No complete response; execution and charges may be unknown. No retry." + ) from None + except ResponseError as error: + raise SystemExit( + f"Invalid response; request_id={error.request_id}. Charges may be unknown. No retry." + ) from None + + print(f"Price: {response.result.price}") + # API time derivatives are per year; vega/rho use unit volatility/rate. + print("Greeks (raw API units):") + for name in ( + "delta", + "gamma", + "theta", + "vega", + "rho", + ): + value = getattr(response.result.greeks, name) + reason = response.result.greeks.unavailable.get(name) + print(f" {name}: {value if value is not None else reason}") + print(f"Model: {response.result.pricing_model} ({response.result.model_version})") + print(f"Request ID: {response.metadata.request_id}") + for engine in response.metadata.engines: + print(f"Engine: {engine.name} {engine.version} ({engine.revision})") + # Missing commercial CU is unreported, not zero. + charged = response.metadata.fcu_charged + print("Commercial CU charged:", charged if charged is not None else "not reported") + + +if __name__ == "__main__": + main() diff --git a/examples/pricing_models.py b/examples/pricing_models.py new file mode 100644 index 0000000..0f38f11 --- /dev/null +++ b/examples/pricing_models.py @@ -0,0 +1,17 @@ +"""Inspect public model capabilities with one unauthenticated request.""" + +from morphiq_sdk import MorphIQ + + +def main() -> None: + with MorphIQ() as client: + response = client.pricing_models() + for model in response.models: + print(model.id, model.operations, model.batch_operations) + print("Constraints:", [item.model_dump(exclude_unset=True) for item in model.constraints]) + print("Engines:", [item.model_dump() for item in response.engines]) + print("Greek units:", response.greek_units) + + +if __name__ == "__main__": + main() diff --git a/notebooks/README.md b/notebooks/README.md index ef7dff6..7e62457 100644 --- a/notebooks/README.md +++ b/notebooks/README.md @@ -5,6 +5,9 @@ Use the controls to load data and run an analysis; no code edits are required. | Workspace | Tools | API requests per action | | --- | --- | --- | +| [Available models](pricing/models.ipynb) | Public capabilities, aliases, constraints and engine versions; no key needed | Load: 1 free request | +| [Standard-five Greeks](pricing/standard-five.ipynb) | BSM/BS1993 inputs, price, five primary sensitivities, unavailable reasons and CU | Calculate: 1 | +| [Cost quotation](cost/quotation.ipynb) | Account rates, synthetic option inputs, conditional quote, editable execution ceiling | Schedule: 1 utility; quote: 1 utility; execute: 1 capped pricing request | | [Option pricing](pricing/options.ipynb) | European/American and model selectors, call/put, option inputs, automatic Yahoo daily/intraday underlying prices, comparison table, all-ten-Greeks panel, implied-volatility solver, combined premium analysis, session CU ledger, spot-sensitivity chart | Price: 1; price + Greeks: 1; solve IV: 1; analyze premium: 1; sensitivity: 1 batch of 9 items | | [Regime analysis](regime/identification.ipynb) | Ticker and interval selection, one-click Yahoo history download and analysis, price-history chart, latest regime assessment, score charts, provenance | Analyze: 1 | diff --git a/notebooks/cost/quotation.ipynb b/notebooks/cost/quotation.ipynb new file mode 100644 index 0000000..8eae8b2 --- /dev/null +++ b/notebooks/cost/quotation.ipynb @@ -0,0 +1,167 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "33ea9e49", + "metadata": {}, + "source": [ + "# Explore request costs\n", + "\n", + "MorphIQ SDK for Python\n\n", + "Inspect your account’s rates, quote an option, then choose whether to execute.\n", + "All inputs start with synthetic values. Running setup or changing a control sends\n", + "no requests. Schedule and Quote each send one zero-CU utility request. Execute sends\n", + "one pricing request with your chosen CU ceiling. A quote does not lock a price or\n", + "guarantee funding, capacity or numerical success.\n", + "\n", + "Set `MORPHIQ_API_KEY` before opening Jupyter, or enter it in the masked setup prompt.\n", + "Use a deployment supporting cost utilities. Quote again after changing inputs.\n", + "\n", + "Read the output: `total_fcu` is the prospective execution charge; the utility\n", + "charge printed below it is zero. Rate lines group work by billing class. A minimum\n", + "is a floor on the whole request, not an extra fee. The Max CU control limits the\n", + "next execution only. A 409 `cost_limit_exceeded` refusal commits no charge; review\n", + "your budget before raising the control and clicking Execute again.\n", + "\n", + "For complete response examples, batch requests and error handling, see the\n", + "[cost guide](https://github.com/MorphIQ-Labs/morphiq-sdk-python/blob/main/docs/COST.md).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8d057cc6", + "metadata": {}, + "outputs": [], + "source": [ + "import getpass\n", + "import os\n", + "\n", + "import ipywidgets as widgets\n", + "from IPython.display import display\n", + "\n", + "from morphiq_sdk import APIError, MorphIQ, PriceRequest, TransportError\n", + "\n", + "api_key = os.environ.get(\"MORPHIQ_API_KEY\") or getpass.getpass(\"MorphIQ API key: \")\n", + "spot = widgets.FloatText(value=100.0, description=\"Spot\")\n", + "strike = widgets.FloatText(value=100.0, description=\"Strike\")\n", + "volatility = widgets.FloatText(value=0.2, description=\"Volatility\")\n", + "years = widgets.FloatText(value=1.0, description=\"Years\")\n", + "rate = widgets.FloatText(value=0.05, description=\"Rate\")\n", + "yield_rate = widgets.FloatText(value=0.0, description=\"Yield\")\n", + "style = widgets.Dropdown(options=[\"european\", \"american\"], description=\"Exercise\")\n", + "option_type = widgets.Dropdown(options=[\"call\", \"put\"], description=\"Option\")\n", + "maximum = widgets.IntText(value=1, description=\"Max CU\")\n", + "schedule_button = widgets.Button(description=\"Rates · 1 utility\")\n", + "quote_button = widgets.Button(description=\"Quote · 1 utility\")\n", + "execute_button = widgets.Button(description=\"Execute · 1 request\", disabled=True)\n", + "output = widgets.Output()\n", + "last_quote = None\n", + "quoted_request = None\n", + "last_result = None\n", + "\n", + "\n", + "def current_request():\n", + " return PriceRequest(\n", + " exercise_style=style.value,\n", + " option_type=option_type.value,\n", + " spot=spot.value,\n", + " strike=strike.value,\n", + " volatility=volatility.value,\n", + " time_to_expiry_years=years.value,\n", + " risk_free_rate=rate.value,\n", + " dividend_yield=yield_rate.value,\n", + " )\n", + "\n", + "\n", + "def show_error(error):\n", + " if isinstance(error, APIError):\n", + " charged = error.metadata.fcu_charged if error.metadata else \"unknown\"\n", + " print(f\"API refused: {error.status_code}; {error.code}; charged CU: {charged}\")\n", + " else:\n", + " print(str(error))\n", + "\n", + "\n", + "def show_schedule(_):\n", + " with output:\n", + " output.clear_output()\n", + " try:\n", + " with MorphIQ(api_key=api_key) as client:\n", + " response = client.cost_schedule(operation=\"price\")\n", + " print(response.schedule.model_dump_json(indent=2))\n", + " print(\"Utility CU charged:\", response.metadata.fcu_charged)\n", + " except (APIError, TransportError, ValueError) as error:\n", + " show_error(error)\n", + "\n", + "\n", + "def quote_request(_):\n", + " global last_quote, quoted_request\n", + " execute_button.disabled = True\n", + " last_quote = quoted_request = None\n", + " with output:\n", + " output.clear_output()\n", + " try:\n", + " request = current_request()\n", + " with MorphIQ(api_key=api_key) as client:\n", + " response = client.quote(request)\n", + " last_quote, quoted_request = response, request.to_wire()\n", + " maximum.value = response.quote.total_fcu\n", + " execute_button.disabled = False\n", + " print(response.quote.model_dump_json(indent=2))\n", + " print(\"Selected models:\", response.resolved_models)\n", + " print(\"Utility CU charged:\", response.metadata.fcu_charged)\n", + " print(\"Execution is a separate action. Adjust Max CU if desired.\")\n", + " except (APIError, TransportError, ValueError) as error:\n", + " show_error(error)\n", + "\n", + "\n", + "def execute_request(_):\n", + " global last_result\n", + " with output:\n", + " output.clear_output()\n", + " try:\n", + " request = current_request()\n", + " if last_quote is None or request.to_wire() != quoted_request:\n", + " print(\"Inputs changed. Quote this request before executing.\")\n", + " return\n", + " with MorphIQ(api_key=api_key) as client:\n", + " last_result = client.price(request, max_cu=maximum.value)\n", + " print(\"Price:\", last_result.result.price)\n", + " print(\"CU charged:\", last_result.metadata.fcu_charged)\n", + " except (APIError, TransportError, ValueError) as error:\n", + " show_error(error)\n", + "\n", + "\n", + "def input_changed(_):\n", + " execute_button.disabled = True\n", + "\n", + "\n", + "for control in [spot, strike, volatility, years, rate, yield_rate, style, option_type]:\n", + " control.observe(input_changed, names=\"value\")\n", + "schedule_button.on_click(show_schedule)\n", + "quote_button.on_click(quote_request)\n", + "execute_button.on_click(execute_request)\n", + "display(\n", + " widgets.VBox(\n", + " [\n", + " widgets.HBox([spot, strike, volatility]),\n", + " widgets.HBox([years, rate, yield_rate]),\n", + " widgets.HBox([style, option_type, maximum]),\n", + " widgets.HBox([schedule_button, quote_button, execute_button]),\n", + " output,\n", + " ]\n", + " )\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/pricing/models.ipynb b/notebooks/pricing/models.ipynb new file mode 100644 index 0000000..8556dee --- /dev/null +++ b/notebooks/pricing/models.ipynb @@ -0,0 +1,72 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": "# MorphIQ SDK for Python — Available models\n\nInspect model capabilities, aliases, constraints, engine versions and Greek units.\nRun setup once, then click **Load models — 1 request, 0 CU**. No key is needed.\nSetup makes no requests. This catalog describes compiled capabilities; use the\ncost quotation workspace for account-specific rates and admission.\n\n[Back to start](../start-here.ipynb)\n" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "workspace", + "metadata": {}, + "outputs": [], + "source": [ + "import ipywidgets as widgets\n", + "from IPython.display import display\n", + "\n", + "from morphiq_sdk import MorphIQ, MorphIQError\n", + "\n", + "load_button = widgets.Button(\n", + " description=\"Load models — 1 request, 0 CU\", layout=widgets.Layout(width=\"300px\")\n", + ")\n", + "output = widgets.Output()\n", + "last_catalog = None\n", + "\n", + "\n", + "def load_models(_):\n", + " global last_catalog\n", + " last_catalog = None\n", + " load_button.disabled = True\n", + " with output:\n", + " output.clear_output(wait=True)\n", + " try:\n", + " with MorphIQ() as client:\n", + " last_catalog = client.pricing_models()\n", + " for model in last_catalog.models:\n", + " print(model.name, \"—\", model.id, \"—\", model.model_version)\n", + " print(\"Single:\", \", \".join(model.operations))\n", + " print(\"Batch:\", \", \".join(model.batch_operations) or \"none\")\n", + " print(\"Aliases:\", model.aliases)\n", + " print(\"Guidance:\", model.guidance)\n", + " for constraint in model.constraints:\n", + " print(\"Constraint:\", constraint.model_dump(exclude_unset=True))\n", + " print()\n", + " print(\"Greek units:\", last_catalog.greek_units)\n", + " print(\"Engines:\", [engine.model_dump() for engine in last_catalog.engines])\n", + " except MorphIQError as error:\n", + " print(error)\n", + " finally:\n", + " load_button.disabled = False\n", + "\n", + "\n", + "load_button.on_click(load_models)\n", + "display(load_button, output)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/pricing/options.ipynb b/notebooks/pricing/options.ipynb index b8980af..a345da2 100644 --- a/notebooks/pricing/options.ipynb +++ b/notebooks/pricing/options.ipynb @@ -4,28 +4,7 @@ "cell_type": "markdown", "id": "welcome", "metadata": {}, - "source": [ - "# MorphIQ SDK for Python — Option pricing\n", - "\n", - "Value a call or put, compare assumptions, and examine sensitivity to the underlying price.\n", - "\n", - "**Run All once to open the workspace**, then use the controls below. Yahoo prices load automatically on setup and when the ticker or interval changes.\n", - "Press Enter or leave the ticker field to apply an edit. You can also enter a price manually. Choose European or American\n", - "exercise, the pricing model, strike, time to expiry, volatility, interest and dividend yield.\n", - "\n", - "**Price option** makes one API request. **Price + Greeks** returns the price and all\n", - "ten Greeks in one request using the selected option inputs. **Solve IV** uses an entered option premium to solve volatility in one request.\n", - "**Analyze premium** solves IV and evaluates price and all ten Greeks at that IV in one request.\n", - "**Spot sensitivity** submits nine scenarios in one batch request across ±20% of your underlying price, holding the other inputs fixed.\n", - "Each action may consume API credits. Changing controls and loading Yahoo data make\n", - "no MorphIQ requests. Completed valuations stay in the comparison table for this session.\n", - "\n", - "Docker includes everything required. Outside Docker, install this repository with\n", - "`pip install -e '.[notebooks]'`. Set `MORPHIQ_API_KEY` before starting, or enter it\n", - "at the masked prompt when running the workspace. [Get API access](https://developers.morphiqlabs.com).\n", - "\n", - "[Back to start](../start-here.ipynb)\n" - ] + "source": "# MorphIQ SDK for Python — Option pricing\n\nFor American Greeks, analytics or spot sensitivity, explicitly select BS1993.\nAuto American resolves to QdFp (single price and IV only). Unsupported operations\nreturn the server's error; no model is silently substituted.\n\nValue a call or put, compare assumptions, and examine sensitivity to the underlying price.\n\n**Run All once to open the workspace**, then use the controls below. Yahoo prices load automatically on setup and when the ticker or interval changes.\nPress Enter or leave the ticker field to apply an edit. You can also enter a price manually. Choose European or American\nexercise, the pricing model, strike, time to expiry, volatility, interest and dividend yield.\n\n**Price option** makes one API request. **Price + Greeks** returns the price and all\nten Greeks in one request using the selected option inputs. **Solve IV** uses an entered option premium to solve volatility in one request.\n**Analyze premium** solves IV and evaluates price and all ten Greeks at that IV in one request.\n**Spot sensitivity** submits nine scenarios in one batch request across ±20% of your underlying price, holding the other inputs fixed.\nEach action may consume API credits. Changing controls and loading Yahoo data make\nno MorphIQ requests. Completed valuations stay in the comparison table for this session.\n\nDocker includes everything required. Outside Docker, install this repository with\n`pip install -e '.[notebooks]'`. Set `MORPHIQ_API_KEY` before starting, or enter it\nat the masked prompt when running the workspace. [Get API access](https://developers.morphiqlabs.com).\n\n[Back to start](../start-here.ipynb)\n" }, { "cell_type": "code", @@ -192,7 +171,7 @@ " if exercise.value == \"european\"\n", " else [\n", " (\"Automatic\", \"auto\"),\n", - " (\"Bjerksund–Stensland 2002\", \"bjerksund_stensland_2002\"),\n", + " (\"QdFp\", \"black_scholes_merton_qdfp\"),\n", " (\"Bjerksund–Stensland 1993\", \"bjerksund_stensland_1993\"),\n", " ]\n", " )\n", @@ -452,7 +431,14 @@ " }\n", " greeks_table = pd.DataFrame(\n", " [\n", - " {\"Greek\": name, \"Value\": getattr(response.result.greeks, name), \"Basis\": basis}\n", + " {\n", + " \"Greek\": name,\n", + " \"Value\": getattr(response.result.greeks, name),\n", + " \"Basis\": basis,\n", + " \"Unavailable reason\": response.result.greeks.unavailable[name].message\n", + " if name in response.result.greeks.unavailable\n", + " else \"\",\n", + " }\n", " for name, basis in units.items()\n", " ]\n", " ).set_index(\"Greek\")\n", @@ -462,7 +448,9 @@ " \"Greeks use the API's raw units; no calendar-day, percentage-point or \"\n", " \"contract-multiplier conversion is applied.\\n\"\n", " )\n", - " greeks_output.append_display_data(greeks_table.style.format({\"Value\": \"{:.12g}\"}))\n", + " greeks_output.append_display_data(\n", + " greeks_table.style.format({\"Value\": \"{:.12g}\"}, na_rep=\"unavailable\")\n", + " )\n", " greeks_output.append_display_data(pd.DataFrame([greeks_snapshot]))\n", " greeks_output.append_stdout(\n", " \"Engines: \"\n", @@ -608,7 +596,14 @@ " }\n", " analytics_table = pd.DataFrame(\n", " [\n", - " {\"Greek\": name, \"Value\": getattr(response.result.greeks, name), \"Basis\": basis}\n", + " {\n", + " \"Greek\": name,\n", + " \"Value\": getattr(response.result.greeks, name),\n", + " \"Basis\": basis,\n", + " \"Unavailable reason\": response.result.greeks.unavailable[name].message\n", + " if name in response.result.greeks.unavailable\n", + " else \"\",\n", + " }\n", " for name, basis in units.items()\n", " ]\n", " ).set_index(\"Greek\")\n", @@ -630,7 +625,9 @@ " \"The service did not report an IV solve; \"\n", " \"the volatility used for these Greeks is unconfirmed.\\n\"\n", " )\n", - " analytics_output.append_display_data(analytics_table.style.format({\"Value\": \"{:.12g}\"}))\n", + " analytics_output.append_display_data(\n", + " analytics_table.style.format({\"Value\": \"{:.12g}\"}, na_rep=\"unavailable\")\n", + " )\n", " analytics_output.append_display_data(pd.DataFrame([analytics_snapshot]))\n", " analytics_output.append_stdout(\n", " \"Engines: \"\n", @@ -844,42 +841,7 @@ "cell_type": "markdown", "id": "interpretation", "metadata": {}, - "source": [ - "### Reading the analysis\n", - "\n", - "Prices are per share in the same currency as the underlying and strike; no contract\n", - "multiplier is applied. Daily mode uses the last completed close. Intraday mode uses the latest returned\n", - "regular-session bar and displays its timestamp and timezone; the bar may still be\n", - "forming and data may be delayed.\n", - "Volatility, rates and continuous dividend yield are your assumptions. Enter 20 for 20%.\n", - "Expiry is expressed in years. Cash-dividend schedules are outside these controls.\n", - "\n", - "The Greeks table labels each derivative. Theta, charm, veta and color use annual\n", - "time units; vega and rho are per unit volatility and rate, not per percentage point.\n", - "The snapshot below the table records the exact option inputs, model, request ID and CU.\n", - "\n", - "IV solves the volatility implied by your entered market premium under the selected\n", - "model. It does not fetch an option quote or replace your volatility assumption.\n", - "The result includes the residual and its basis: `forward_normalised_price` or\n", - "`option_price`. Residuals on different bases are not directly comparable.\n", - "`used_fallback` describes the algorithm, not solution quality; American bisection\n", - "reports it as true. Iterations and diagnostics are preserved as reported by the API.\n", - "\n", - "The sensitivity chart contains separate model valuations, not forecasts or quoted\n", - "market option prices. Each row records the inputs, resolved model, request ID and\n", - "reported commercial CU. A missing charge is unreported, not zero. If a request fails,\n", - "per-item errors remain visible alongside successes. Failed chart points are gaps;\n", - "no request or failed item is retried automatically.\n", - "\n", - "Results retain their original inputs when controls change. Clear outputs before sharing;\n", - "shut down the kernel when finished. Source code is available by expanding the hidden cell.\n", - "\n", - "Session CU sums only `fcu_charged`, once per actual request. It includes reported\n", - "charges on errors and mixed-outcome batches. Zero is counted as zero; missing charges and\n", - "timeouts remain unknown. The displayed known subtotal is not a complete total\n", - "while any charges are unknown. Quotes and internal compute units are not added.\n", - "Rerunning setup retains the ledger; restarting the kernel starts a new session.\n" - ] + "source": "### Reading the analysis\n\nPrices are per share in the same currency as the underlying and strike; no contract\nmultiplier is applied. Daily mode uses the last completed close. Intraday mode uses the latest returned\nregular-session bar and displays its timestamp and timezone; the bar may still be\nforming and data may be delayed.\nVolatility, rates and continuous dividend yield are your assumptions. Enter 20 for 20%.\nExpiry is expressed in years. Cash-dividend schedules are outside these controls.\n\nThe Greeks table labels each derivative. Theta, charm, veta and color use annual\ntime units; vega and rho are per unit volatility and rate, not per percentage point.\nThe snapshot below the table records the exact option inputs, model, request ID and CU.\n\nIV solves the volatility implied by your entered market premium under the selected\nmodel. It does not fetch an option quote or replace your volatility assumption.\nThe result includes the residual and its basis: `forward_normalised_price` or\n`option_price`. Residuals on different bases are not directly comparable.\n`used_fallback` describes the algorithm, not solution quality; American bisection\nreports it as true. Iterations and diagnostics are preserved as reported by the API.\n\nThe sensitivity chart contains separate model valuations, not forecasts or quoted\nmarket option prices. Each row records the inputs, resolved model, request ID and\nreported commercial CU. A missing charge is unreported, not zero. If a request fails,\nper-item errors remain visible alongside successes. Failed chart points are gaps;\nno request or failed item is retried automatically.\n\nResults retain their original inputs when controls change. Clear outputs before sharing;\nshut down the kernel when finished. Source code is available by expanding the hidden cell.\n\nSession CU sums only `fcu_charged`, once per actual request. It includes reported\ncharges on errors and mixed-outcome batches. Zero is counted as zero; missing charges and\ntimeouts remain unknown. The displayed known subtotal is not a complete total\nwhile any charges are unknown. Quotes and internal compute units are not added.\nRerunning setup retains the ledger; restarting the kernel starts a new session.\n" } ], "metadata": { diff --git a/notebooks/pricing/standard-five.ipynb b/notebooks/pricing/standard-five.ipynb new file mode 100644 index 0000000..407b73b --- /dev/null +++ b/notebooks/pricing/standard-five.ipynb @@ -0,0 +1,120 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": "# MorphIQ SDK for Python — Standard-five Greeks\n\nValue a synthetic option and inspect delta, gamma, theta, vega and rho.\nRun setup once, adjust inputs, then click **Calculate — 1 request**. Setup and\ncontrol changes make no API requests. BSM uses European exercise; BS1993 uses\nAmerican exercise. The displayed snapshot records the submitted inputs and CU.\nGreek units are unscaled API units: time derivatives per year, vega/rho per unit\nvolatility/rate. Unavailable values retain their reason; they are not zero.\nSet `MORPHIQ_API_KEY` before starting, or enter it at the masked prompt on execution.\n\n[Back to start](../start-here.ipynb)\n" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "workspace", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from getpass import getpass\n", + "\n", + "import ipywidgets as widgets\n", + "from IPython.display import display\n", + "\n", + "from morphiq_sdk import GreeksStandardRequest, MorphIQ, MorphIQError\n", + "\n", + "model = widgets.Dropdown(\n", + " options=[\n", + " (\"BSM (European)\", \"black_scholes_merton\"),\n", + " (\"BS1993 (American)\", \"bjerksund_stensland_1993\"),\n", + " ],\n", + " description=\"Model\",\n", + ")\n", + "option_type = widgets.Dropdown(options=[\"call\", \"put\"], description=\"Option\")\n", + "spot = widgets.FloatText(value=100, description=\"Spot\")\n", + "strike = widgets.FloatText(value=100, description=\"Strike\")\n", + "expiry = widgets.FloatText(value=1, description=\"Years\")\n", + "volatility = widgets.FloatText(value=0.2, description=\"Volatility\")\n", + "rate = widgets.FloatText(value=0.05, description=\"Rate\")\n", + "dividend_yield = widgets.FloatText(value=0, description=\"Yield\")\n", + "calculate_button = widgets.Button(\n", + " description=\"Calculate — 1 request\", layout=widgets.Layout(width=\"260px\")\n", + ")\n", + "output = widgets.Output()\n", + "last_result = None\n", + "last_request = None\n", + "\n", + "\n", + "def calculate(_):\n", + " global last_result, last_request\n", + " last_result = None\n", + " calculate_button.disabled = True\n", + " with output:\n", + " output.clear_output(wait=True)\n", + " try:\n", + " request = GreeksStandardRequest(\n", + " exercise_style=\"european\" if model.value == \"black_scholes_merton\" else \"american\",\n", + " pricing_model=model.value,\n", + " option_type=option_type.value,\n", + " spot=spot.value,\n", + " strike=strike.value,\n", + " time_to_expiry_years=expiry.value,\n", + " risk_free_rate=rate.value,\n", + " dividend_yield=dividend_yield.value,\n", + " volatility=volatility.value,\n", + " )\n", + " last_request = request.to_wire()\n", + " api_key = os.environ.get(\"MORPHIQ_API_KEY\") or getpass(\"MorphIQ API key: \")\n", + " with MorphIQ(api_key=api_key) as client:\n", + " last_result = client.greeks_standard(request)\n", + " print(\"Inputs:\", last_request)\n", + " print(\"Price:\", last_result.result.price)\n", + " for name in (\"delta\", \"gamma\", \"theta\", \"vega\", \"rho\"):\n", + " value = getattr(last_result.result.greeks, name)\n", + " reason = last_result.result.greeks.unavailable.get(name)\n", + " print(name, value if value is not None else reason)\n", + " print(\"Model:\", last_result.result.model_version)\n", + " print(\"Request ID:\", last_result.metadata.request_id)\n", + " charged = last_result.metadata.fcu_charged\n", + " print(\"CU charged:\", charged if charged is not None else \"not reported\")\n", + " except (MorphIQError, ValueError) as error:\n", + " print(error)\n", + " metadata = getattr(error, \"metadata\", None)\n", + " if metadata is not None:\n", + " print(\"Error evidence:\", metadata.model_dump(exclude_unset=True))\n", + " finally:\n", + " calculate_button.disabled = False\n", + "\n", + "\n", + "calculate_button.on_click(calculate)\n", + "display(\n", + " widgets.VBox(\n", + " [\n", + " model,\n", + " option_type,\n", + " spot,\n", + " strike,\n", + " expiry,\n", + " volatility,\n", + " rate,\n", + " dividend_yield,\n", + " calculate_button,\n", + " output,\n", + " ]\n", + " )\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/start-here.ipynb b/notebooks/start-here.ipynb index 9834df9..330317e 100644 --- a/notebooks/start-here.ipynb +++ b/notebooks/start-here.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "id": "welcome", "metadata": {}, - "source": "# MorphIQ SDK for Python — Start Here\n\n## Choose an analysis\n\n- [Option pricing](pricing/options.ipynb): value European or American calls and puts,\n load a daily or intraday underlying price from Yahoo, inspect Greeks, solve implied\n volatility, analyze a premium with Greeks, track session CU, and chart sensitivity.\n- [Regime analysis](regime/identification.ipynb): load daily or intraday price history\n and inspect the latest regime assessment, scores and model provenance.\n\nOpen a workspace and select **Run → Run All Cells** once to display its controls.\nThen use its buttons. Running the setup makes no MorphIQ requests; each action button\nstates how many requests it sends. All market data stays in memory.\n\n## API access\n\nGet your API key from [the developer portal](https://developers.morphiqlabs.com).\nSet `MORPHIQ_API_KEY` before starting Docker or Jupyter, or enter it at the masked\nprompt in a workspace. The SDK connects to `https://api.morphiqlabs.com`.\n\nDocker includes the SDK, widgets, plotting and Yahoo dependencies. Outside Docker,\ninstall this repository with `pip install -e '.[notebooks]'`, then open JupyterLab.\n\nWorkspaces retain results in the current kernel. Clear outputs before sharing and\nshut down the kernel when finished. Notebook edits persist across Docker restarts.\nThe executable Python scripts in `examples/` demonstrate SDK usage separately.\n" + "source": "# MorphIQ SDK for Python — Start Here\n\n## Choose an analysis\n\n- [Available models](pricing/models.ipynb): inspect public model capabilities and constraints.\n- [Standard-five Greeks](pricing/standard-five.ipynb): price an option and inspect\n its five primary sensitivities.\n- [Cost quotation](cost/quotation.ipynb): inspect active rates, quote a synthetic\n option, and explicitly execute with a maximum CU charge.\n- [Option pricing](pricing/options.ipynb): value European or American calls and puts,\n load a daily or intraday underlying price from Yahoo, inspect Greeks, solve implied\n volatility, analyze a premium with Greeks, track session CU, and chart sensitivity.\n- [Regime analysis](regime/identification.ipynb): load daily or intraday price history\n and inspect the latest regime assessment, scores and model provenance.\n\nOpen a workspace and select **Run → Run All Cells** once to display its controls.\nThen use its buttons. Running the setup makes no MorphIQ requests; each action button\nstates how many requests it sends. All market data stays in memory.\n\n## API access\n\nGet your API key from [the developer portal](https://developers.morphiqlabs.com).\nSet `MORPHIQ_API_KEY` before starting Docker or Jupyter, or enter it at the masked\nprompt in a workspace. The SDK connects to `https://api.morphiqlabs.com`.\n\nDocker includes the SDK, widgets, plotting and Yahoo dependencies. Outside Docker,\ninstall this repository with `pip install -e '.[notebooks]'`, then open JupyterLab.\n\nWorkspaces retain results in the current kernel. Clear outputs before sharing and\nshut down the kernel when finished. Notebook edits persist across Docker restarts.\nThe executable Python scripts in `examples/` demonstrate SDK usage separately.\n" } ], "metadata": { diff --git a/scripts/check_package.py b/scripts/check_package.py index b7348da..300e6b0 100644 --- a/scripts/check_package.py +++ b/scripts/check_package.py @@ -118,6 +118,7 @@ def main() -> None: from morphiq_sdk import ( MorphIQ, BatchPriceResponse, BatchGreeksResponse, BatchImpliedVolatilityResponse, BatchAnalyticsResponse, + BatchGreeksStandardResponse, ) from morphiq_sdk.pandas import batch_to_frames, workflow_to_frames assert find_spec('yfinance') is None @@ -141,9 +142,16 @@ def main() -> None: frames = workflow_to_frames(workflow) assert frames.results.empty and frames.requests.empty assert frames.total_fcu_charged == 0 - print('Isolated pandas extra and all four batch conversions verified') + standard = json.loads(Path(sys.argv[2]).read_text())['cases'][0]['response'] + body = {'metadata': standard['metadata'], 'results': [ + {'index': 0, 'status': 'success', 'result': standard['result']} + ]} + frames = batch_to_frames(BatchGreeksStandardResponse.model_validate(body)) + assert frames.results['outcome'].tolist() == body['results'] + print('Isolated pandas extra and all five batch conversions verified') """), str(root / "tests/fixtures/batch.json"), + str(root / "tests/fixtures/greeks_standard.json"), ], cwd=temporary, check=True, diff --git a/scripts/check_published_install.py b/scripts/check_published_install.py index 9dd1ed2..5a584f1 100644 --- a/scripts/check_published_install.py +++ b/scripts/check_published_install.py @@ -22,7 +22,9 @@ assert files('morphiq_sdk_yahoo').joinpath('py.typed').is_file() for client in (sdk.MorphIQ, sdk.AsyncMorphIQ): for method in ('price', 'greeks', 'implied_volatility', 'analytics', 'regime', - 'price_batch', 'greeks_batch', 'implied_volatility_batch', 'analytics_batch'): + 'price_batch', 'greeks_batch', 'implied_volatility_batch', 'analytics_batch', + 'greeks_standard', 'greeks_standard_batch', 'greeks_standard_workflow', + 'pricing_models', 'cost_schedule', 'quote'): assert callable(getattr(client, method)) with sdk.MorphIQ(api_key='offline-install-check') as client: workflow = client.price_workflow([], chunk_size=1) @@ -38,7 +40,8 @@ from pathlib import Path import ipykernel, ipywidgets, jupyterlab, matplotlib, pandas, yfinance from morphiq_sdk import (BatchPriceResponse, BatchGreeksResponse, - BatchImpliedVolatilityResponse, BatchAnalyticsResponse) + BatchImpliedVolatilityResponse, BatchAnalyticsResponse, + BatchGreeksStandardResponse) from morphiq_sdk.pandas import batch_to_frames cases = json.loads(Path(sys.argv[1]).read_text())['cases'] for cls, case in zip((BatchPriceResponse, BatchGreeksResponse, @@ -49,7 +52,14 @@ assert frames.results['outcome'].tolist() == body['results'] assert frames.results['identifier'].tolist() == [1, None, 2**100] assert frames.total_fcu_charged == 2**100 -print('All extras import; all batch conversions preserve outcomes and exact charges') +standard = json.loads(Path(sys.argv[1]).with_name('greeks_standard.json').read_text())['cases'][0] +body = {'metadata': {**standard['response']['metadata'], 'fcu_charged': 2**100}, 'results': [ + {'index': 0, 'status': 'success', 'result': standard['response']['result']} +]} +frames = batch_to_frames(BatchGreeksStandardResponse.model_validate(body)) +assert frames.results['outcome'].tolist() == body['results'] +assert frames.total_fcu_charged == 2**100 +print('All extras import; all five batch conversions preserve outcomes and exact charges') """ diff --git a/scripts/execute_notebook_fixture.py b/scripts/execute_notebook_fixture.py index cd4db73..f69b73f 100644 --- a/scripts/execute_notebook_fixture.py +++ b/scripts/execute_notebook_fixture.py @@ -16,6 +16,9 @@ NOTEBOOK_CASES = { "start-here.ipynb": (), + "pricing/models.ipynb": (), # Explicit actions tested in test_api_v050. + "pricing/standard-five.ipynb": (), + "cost/quotation.ipynb": (), # Setup is inert; test_cost exercises explicit actions. "pricing/options.ipynb": ( "batch-price-sweep", "european", @@ -43,17 +46,17 @@ def workspace_exercise(filename: str, prices: list[float]) -> str: use_yahoo_close(pd.DataFrame({'Close': [100.0]}, index=pd.to_datetime(['2026-01-02'])), 'SPY') assert spot.value == 100.0 and 'SPY' in spot_source price_button.click() -assert len(price_history) == 1 and last_response.result.price == 10.450583572185538 +assert len(price_history) == 1 and last_response.result.price == 10.45058357218557 greeks_button.click() -assert last_greeks_response.metadata.request_id == 'fixture-greeks-european' +assert last_greeks_response.metadata.request_id == 'fr-18e4e90129262501-0000000000000005' assert len(greeks_table) == 10 and greeks_table.loc['vega', 'Value'] == 37.52403469169381 -assert greeks_table.loc['theta', 'Value'] == -6.4140275464382 +assert greeks_table.loc['theta', 'Value'] == -6.414027546438199 assert sum('text/html' in o.get('data', {}) for o in greeks_output.outputs) == 2 assert greeks_snapshot['Style'] == 'european' and greeks_snapshot['CU charged'] == 'not reported' market_price.value = 10.450583572185538 volatility.value = 0 # Solving IV must not validate an unused volatility assumption. iv_button.click() -assert last_iv_response.metadata.request_id == 'fixture-iv-european' +assert last_iv_response.metadata.request_id == 'fr-18e4e90129262501-0000000000000007' assert abs(last_iv_response.result.implied_volatility - 0.2) < 1e-12 assert last_iv_response.result.used_fallback is False assert iv_snapshot['residual_basis'] == 'forward_normalised_price' @@ -81,19 +84,23 @@ def workspace_exercise(filename: str, prices: list[float]) -> str: assert current_request().volatility == 0.2 price_button.click() assert len(price_history) == 2 +model.value = 'bjerksund_stensland_1993' greeks_button.click() -assert last_greeks_response.metadata.request_id == 'fixture-greeks-american' +assert last_greeks_response.metadata.request_id == 'fr-18e4e90129262501-0000000000000006' assert greeks_snapshot['Style'] == 'american' -market_price.value = 7.584143004464017 +model.value = 'auto' +market_price.value = 7.6165316765775835 iv_button.click() -assert last_iv_response.metadata.request_id == 'fixture-iv-american' -assert last_iv_response.result.used_fallback is True +assert last_iv_response.metadata.request_id == 'fr-18e4e90129262501-0000000000000009' +assert last_iv_response.result.used_fallback is False assert iv_snapshot['residual_basis'] == 'option_price' assert iv_snapshot['market_price'] == market_price.value volatility.value = 35 +model.value = 'bjerksund_stensland_1993' +market_price.value = 7.5298454349124135 analytics_button.click() assert last_analytics_response.result.implied_volatility.used_fallback is True -assert analytics_snapshot['Requested model'] == 'auto' +assert analytics_snapshot['Requested model'] == 'bjerksund_stensland_1993' assert len(charge_history) == 9 and 'total incomplete' in usage_summary.value show_usage() assert len(charge_history) == 9 diff --git a/scripts/generate_notebook.py b/scripts/generate_notebook.py index 29dce06..69cde7e 100644 --- a/scripts/generate_notebook.py +++ b/scripts/generate_notebook.py @@ -72,6 +72,11 @@ def notebook() -> dict[str, Any]: ## Choose an analysis +- [Available models](pricing/models.ipynb): inspect public model capabilities and constraints. +- [Standard-five Greeks](pricing/standard-five.ipynb): price an option and inspect + its five primary sensitivities. +- [Cost quotation](cost/quotation.ipynb): inspect active rates, quote a synthetic + option, and explicitly execute with a maximum CU charge. - [Option pricing](pricing/options.ipynb): value European or American calls and puts, load a daily or intraday underlying price from Yahoo, inspect Greeks, solve implied volatility, analyze a premium with Greeks, track session CU, and chart sensitivity. @@ -109,7 +114,13 @@ def notebook() -> dict[str, Any]: def notebooks() -> dict[str, dict[str, Any]]: result = {"start-here.ipynb": notebook()} # Analysis notebooks are authored directly; tests verify their SDK requests. - for filename in ("pricing/options.ipynb", "regime/identification.ipynb"): + for filename in ( + "pricing/options.ipynb", + "pricing/models.ipynb", + "pricing/standard-five.ipynb", + "regime/identification.ipynb", + "cost/quotation.ipynb", + ): content = json.loads((ROOT / "notebooks" / filename).read_text()) if "widgets" in content["metadata"]: raise ValueError(f"{filename}: remove saved widget state before committing") diff --git a/src/morphiq_sdk/__init__.py b/src/morphiq_sdk/__init__.py index 7ede244..8f85402 100644 --- a/src/morphiq_sdk/__init__.py +++ b/src/morphiq_sdk/__init__.py @@ -21,29 +21,63 @@ BatchFailure, BatchGreeksRequest, BatchGreeksResponse, + BatchGreeksStandardRequest, + BatchGreeksStandardResponse, BatchImpliedVolatilityRequest, BatchImpliedVolatilityResponse, BatchPriceRequest, BatchPriceResponse, BatchSuccess, + CostBreakdown, + CostLine, + CostQuoteResponse, + CostRate, + CostSchedule, + CostScheduleResponse, DividendEvent, + ErrorDetails, Greeks, GreeksRequest, GreeksResponse, GreeksResult, + GreeksStandard, + GreeksStandardRequest, + GreeksStandardResponse, + GreeksStandardResult, ImpliedVolatilityRequest, ImpliedVolatilityResponse, ImpliedVolatilityResult, + ModelConstraint, PriceRequest, PriceResponse, + PricingModelCatalog, + PricingModelDescriptor, RegimeRequest, RegimeResponse, + ResolvedCostModel, ) from .workflows import AsyncBatchWorkflow, BatchWorkflow, ChunkAttempt __version__ = version("morphiq-sdk") __all__ = [ + "GreeksStandard", + "GreeksStandardRequest", + "GreeksStandardResult", + "GreeksStandardResponse", + "BatchGreeksStandardRequest", + "BatchGreeksStandardResponse", + "ModelConstraint", + "PricingModelDescriptor", + "PricingModelCatalog", + "ErrorDetails", + "CostRate", + "CostSchedule", + "CostLine", + "CostBreakdown", + "ResolvedCostModel", + "CostScheduleResponse", + "CostQuoteResponse", "AsyncBatchWorkflow", "BatchWorkflow", "ChunkAttempt", diff --git a/src/morphiq_sdk/client.py b/src/morphiq_sdk/client.py index a43132d..1d23f2c 100644 --- a/src/morphiq_sdk/client.py +++ b/src/morphiq_sdk/client.py @@ -19,17 +19,24 @@ BatchAnalyticsResponse, BatchGreeksRequest, BatchGreeksResponse, + BatchGreeksStandardRequest, + BatchGreeksStandardResponse, BatchImpliedVolatilityRequest, BatchImpliedVolatilityResponse, BatchPriceRequest, BatchPriceResponse, + CostQuoteResponse, + CostScheduleResponse, ErrorEnvelope, GreeksRequest, GreeksResponse, + GreeksStandardRequest, + GreeksStandardResponse, ImpliedVolatilityRequest, ImpliedVolatilityResponse, PriceRequest, PriceResponse, + PricingModelCatalog, RegimeRequest, RegimeResponse, ResponseMetadata, @@ -41,13 +48,18 @@ "_ResponseType", BatchPriceResponse, BatchGreeksResponse, + BatchGreeksStandardResponse, BatchImpliedVolatilityResponse, BatchAnalyticsResponse, PriceResponse, + PricingModelCatalog, RegimeResponse, GreeksResponse, + GreeksStandardResponse, ImpliedVolatilityResponse, AnalyticsResponse, + CostQuoteResponse, + CostScheduleResponse, ) @@ -90,6 +102,58 @@ def visit(value: Any) -> Any: return json.dumps(sanitized).encode() if changed else body +QuoteInput = ( + PriceRequest + | RegimeRequest + | GreeksRequest + | GreeksStandardRequest + | ImpliedVolatilityRequest + | AnalyticsRequest + | BatchPriceRequest + | BatchGreeksRequest + | BatchGreeksStandardRequest + | BatchImpliedVolatilityRequest + | BatchAnalyticsRequest +) +_QUOTE_OPERATIONS = { + PriceRequest: "price", + RegimeRequest: "regime", + GreeksRequest: "greeks", + GreeksStandardRequest: "greeks_standard", + ImpliedVolatilityRequest: "implied_volatility", + AnalyticsRequest: "analytics", + BatchPriceRequest: "price_batch", + BatchGreeksRequest: "greeks_batch", + BatchGreeksStandardRequest: "greeks_standard_batch", + BatchImpliedVolatilityRequest: "implied_volatility_batch", + BatchAnalyticsRequest: "analytics_batch", +} + + +def _quote_payload(request: QuoteInput) -> dict[str, Any]: + operation = _QUOTE_OPERATIONS.get(type(request)) + if operation is None: + raise TypeError("quote expects a supported MorphIQ request model") + return {"operation": operation, "request": request.to_wire()} + + +def _cost_headers(max_cu: int | None) -> dict[str, str]: + if max_cu is None: + return {} + if type(max_cu) is not int or not 0 <= max_cu <= 2**64 - 1: + raise ValueError("max_cu must be an unsigned 64-bit integer") + return {"X-MorphIQ-Max-CU": str(max_cu)} + + +def _transport_message(failure: str, billable: bool) -> str: + evidence = ( + "Execution and CU charges may be unknown" + if billable + else "This utility request performs no calculation or CU charge" + ) + return f"The request {failure}. {evidence}; no retry was attempted." + + class _Configuration: def __init__( self, *, base_url: str | None, api_key: str | None, allow_unauthenticated: bool @@ -117,12 +181,16 @@ def __init__( key = api_key if api_key is not None else os.environ.get("MORPHIQ_API_KEY") if key is not None and (not key or not key.isascii() or any(c.isspace() for c in key)): raise ValueError("API key must be a non-empty ASCII token without whitespace.") - if not key and not allow_unauthenticated: + self._key = key + self._allow_unauthenticated = allow_unauthenticated + + def require_auth(self) -> None: + if not self._key and not self._allow_unauthenticated: raise ValueError( - "Pass api_key or set MORPHIQ_API_KEY. Only a controlled open development " + "Pass api_key or set MORPHIQ_API_KEY for authenticated operations. " + "pricing_models() is public. Only a controlled open development " "service should use allow_unauthenticated=True." ) - self._key = key def headers(self) -> dict[str, str]: result = {"Accept": "application/json", "User-Agent": "morphiq-sdk-python"} @@ -202,52 +270,82 @@ def __init__( trust_env=False, ) - def price(self, request: PriceRequest) -> PriceResponse: + def price(self, request: PriceRequest, *, max_cu: int | None = None) -> PriceResponse: """Price one option; preserve server model selection and charge evidence.""" - return self._post("v1/price", request.to_wire(), PriceResponse) + return self._post("v1/price", request.to_wire(), PriceResponse, max_cu=max_cu) - def regime(self, request: RegimeRequest) -> RegimeResponse: + def regime(self, request: RegimeRequest, *, max_cu: int | None = None) -> RegimeResponse: """Identify regime heads; preserve scores, applicability and provenance.""" - return self._post("v1/regime", request.to_wire(), RegimeResponse) + return self._post("v1/regime", request.to_wire(), RegimeResponse, max_cu=max_cu) + + def greeks_standard( + self, request: GreeksStandardRequest, *, max_cu: int | None = None + ) -> GreeksStandardResponse: + """Return the option price and standard five Greeks, preserving the API's units.""" + return self._post( + "v1/greeks/standard", request.to_wire(), GreeksStandardResponse, max_cu=max_cu + ) - def greeks(self, request: GreeksRequest) -> GreeksResponse: + def greeks(self, request: GreeksRequest, *, max_cu: int | None = None) -> GreeksResponse: """Return the option price and all ten Greeks, preserving the API's units.""" - return self._post("v1/greeks", request.to_wire(), GreeksResponse) + return self._post("v1/greeks", request.to_wire(), GreeksResponse, max_cu=max_cu) - def implied_volatility(self, request: ImpliedVolatilityRequest) -> ImpliedVolatilityResponse: + def implied_volatility( + self, request: ImpliedVolatilityRequest, *, max_cu: int | None = None + ) -> ImpliedVolatilityResponse: """Solve IV from an option premium; preserve residual basis and charge evidence.""" - return self._post("v1/implied-volatility", request.to_wire(), ImpliedVolatilityResponse) + return self._post( + "v1/implied-volatility", request.to_wire(), ImpliedVolatilityResponse, max_cu=max_cu + ) - def analytics(self, request: AnalyticsRequest) -> AnalyticsResponse: + def analytics( + self, request: AnalyticsRequest, *, max_cu: int | None = None + ) -> AnalyticsResponse: """Return combined analytics and one server-reported commercial charge.""" - return self._post("v1/analytics", request.to_wire(), AnalyticsResponse) + return self._post("v1/analytics", request.to_wire(), AnalyticsResponse, max_cu=max_cu) - def price_batch(self, request: BatchPriceRequest) -> BatchPriceResponse: + def price_batch( + self, request: BatchPriceRequest, *, max_cu: int | None = None + ) -> BatchPriceResponse: """Submit one batch; preserve ordered outcomes and count its charge once.""" payload = request.to_wire() - response = self._post("v1/price:batch", payload, BatchPriceResponse) + response = self._post("v1/price:batch", payload, BatchPriceResponse, max_cu=max_cu) return _check_batch(response, len(payload["requests"])) - def greeks_batch(self, request: BatchGreeksRequest) -> BatchGreeksResponse: + def greeks_standard_batch( + self, request: BatchGreeksStandardRequest, *, max_cu: int | None = None + ) -> BatchGreeksStandardResponse: """Submit one batch; preserve ordered outcomes and count its charge once.""" payload = request.to_wire() - response = self._post("v1/greeks:batch", payload, BatchGreeksResponse) + response = self._post( + "v1/greeks/standard:batch", payload, BatchGreeksStandardResponse, max_cu=max_cu + ) + return _check_batch(response, len(payload["requests"])) + + def greeks_batch( + self, request: BatchGreeksRequest, *, max_cu: int | None = None + ) -> BatchGreeksResponse: + """Submit one batch; preserve ordered outcomes and count its charge once.""" + payload = request.to_wire() + response = self._post("v1/greeks:batch", payload, BatchGreeksResponse, max_cu=max_cu) return _check_batch(response, len(payload["requests"])) def implied_volatility_batch( - self, request: BatchImpliedVolatilityRequest + self, request: BatchImpliedVolatilityRequest, *, max_cu: int | None = None ) -> BatchImpliedVolatilityResponse: """Submit one batch; preserve ordered outcomes and count its charge once.""" payload = request.to_wire() response = self._post( - "v1/implied-volatility:batch", payload, BatchImpliedVolatilityResponse + "v1/implied-volatility:batch", payload, BatchImpliedVolatilityResponse, max_cu=max_cu ) return _check_batch(response, len(payload["requests"])) - def analytics_batch(self, request: BatchAnalyticsRequest) -> BatchAnalyticsResponse: + def analytics_batch( + self, request: BatchAnalyticsRequest, *, max_cu: int | None = None + ) -> BatchAnalyticsResponse: """Submit one batch; preserve ordered outcomes and count its charge once.""" payload = request.to_wire() - response = self._post("v1/analytics:batch", payload, BatchAnalyticsResponse) + response = self._post("v1/analytics:batch", payload, BatchAnalyticsResponse, max_cu=max_cu) return _check_batch(response, len(payload["requests"])) def price_workflow( @@ -258,6 +356,15 @@ def price_workflow( _prepare_chunks(requests, BatchPriceRequest, chunk_size), self.price_batch ) + def greeks_standard_workflow( + self, requests: Sequence[GreeksStandardRequest], *, chunk_size: int + ) -> BatchWorkflow[BatchGreeksStandardRequest, BatchGreeksStandardResponse]: + """Prepare explicit sequential chunks without sending requests; call run() once.""" + return BatchWorkflow( + _prepare_chunks(requests, BatchGreeksStandardRequest, chunk_size), + self.greeks_standard_batch, + ) + def greeks_workflow( self, requests: Sequence[GreeksRequest], *, chunk_size: int ) -> BatchWorkflow[BatchGreeksRequest, BatchGreeksResponse]: @@ -283,20 +390,68 @@ def analytics_workflow( _prepare_chunks(requests, BatchAnalyticsRequest, chunk_size), self.analytics_batch ) + def pricing_models(self) -> PricingModelCatalog: + """Discover compiled models without a key, execution, or CU charge.""" + return self._request( + "GET", "v1/pricing/models", None, PricingModelCatalog, billable=False, public=True + ) + + def cost_schedule( + self, *, operation: str | None = None, model: str | None = None + ) -> CostScheduleResponse: + """Read your active schedule, optionally filtered; consumes no commercial CUs.""" + params = { + key: value + for key, value in {"operation": operation, "model": model}.items() + if value is not None + } + return self._request( + "GET", "v1/cost/schedule", None, CostScheduleResponse, params=params, billable=False + ) + + def quote(self, request: QuoteInput) -> CostQuoteResponse: + """Quote the same typed request you will execute, without running its calculation.""" + return self._request( + "POST", "v1/cost/quote", _quote_payload(request), CostQuoteResponse, billable=False + ) + def _post( - self, path: str, payload: dict[str, Any], model: type[_ResponseType] + self, + path: str, + payload: dict[str, Any], + model: type[_ResponseType], + *, + max_cu: int | None = None, + ) -> _ResponseType: + return self._request("POST", path, payload, model, max_cu=max_cu) + + def _request( + self, + method: str, + path: str, + payload: dict[str, Any] | None, + model: type[_ResponseType], + *, + params: dict[str, str] | None = None, + max_cu: int | None = None, + billable: bool = True, + public: bool = False, ) -> _ResponseType: + if not public: + self._config.require_auth() + headers = _cost_headers(max_cu) try: - response = self._http.post(path, json=payload) + request = self._http.build_request( + method, path, json=payload, params=params, headers=headers + ) + if public: + request.headers.pop("Authorization", None) + response = self._http.send(request) except httpx.TimeoutException: - raise RequestTimeout( - "The request timed out; execution and CU charges may be unknown. " - "No retry was attempted." - ) from None + raise RequestTimeout(_transport_message("timed out", billable)) from None except httpx.RequestError: raise TransportError( - "The request failed before a complete response was received. " - "Execution and CU charges may be unknown; no retry was attempted." + _transport_message("failed before a complete response", billable) ) from None return self._config.decode(response, model) @@ -340,56 +495,84 @@ def __init__( trust_env=False, ) - async def price(self, request: PriceRequest) -> PriceResponse: + async def price(self, request: PriceRequest, *, max_cu: int | None = None) -> PriceResponse: """Price one option; preserve server model selection and charge evidence.""" - return await self._post("v1/price", request.to_wire(), PriceResponse) + return await self._post("v1/price", request.to_wire(), PriceResponse, max_cu=max_cu) - async def regime(self, request: RegimeRequest) -> RegimeResponse: + async def regime(self, request: RegimeRequest, *, max_cu: int | None = None) -> RegimeResponse: """Identify regime heads; preserve scores, applicability and provenance.""" - return await self._post("v1/regime", request.to_wire(), RegimeResponse) + return await self._post("v1/regime", request.to_wire(), RegimeResponse, max_cu=max_cu) - async def greeks(self, request: GreeksRequest) -> GreeksResponse: + async def greeks_standard( + self, request: GreeksStandardRequest, *, max_cu: int | None = None + ) -> GreeksStandardResponse: + """Return the option price and standard five Greeks, preserving the API's units.""" + return await self._post( + "v1/greeks/standard", request.to_wire(), GreeksStandardResponse, max_cu=max_cu + ) + + async def greeks(self, request: GreeksRequest, *, max_cu: int | None = None) -> GreeksResponse: """Return the option price and all ten Greeks, preserving the API's units.""" - return await self._post("v1/greeks", request.to_wire(), GreeksResponse) + return await self._post("v1/greeks", request.to_wire(), GreeksResponse, max_cu=max_cu) async def implied_volatility( - self, request: ImpliedVolatilityRequest + self, request: ImpliedVolatilityRequest, *, max_cu: int | None = None ) -> ImpliedVolatilityResponse: """Solve IV from an option premium; preserve residual basis and charge evidence.""" return await self._post( - "v1/implied-volatility", request.to_wire(), ImpliedVolatilityResponse + "v1/implied-volatility", request.to_wire(), ImpliedVolatilityResponse, max_cu=max_cu ) - async def analytics(self, request: AnalyticsRequest) -> AnalyticsResponse: + async def analytics( + self, request: AnalyticsRequest, *, max_cu: int | None = None + ) -> AnalyticsResponse: """Return combined analytics and one server-reported commercial charge.""" - return await self._post("v1/analytics", request.to_wire(), AnalyticsResponse) + return await self._post("v1/analytics", request.to_wire(), AnalyticsResponse, max_cu=max_cu) - async def price_batch(self, request: BatchPriceRequest) -> BatchPriceResponse: + async def price_batch( + self, request: BatchPriceRequest, *, max_cu: int | None = None + ) -> BatchPriceResponse: """Submit one batch; preserve ordered outcomes and count its charge once.""" payload = request.to_wire() - response = await self._post("v1/price:batch", payload, BatchPriceResponse) + response = await self._post("v1/price:batch", payload, BatchPriceResponse, max_cu=max_cu) return _check_batch(response, len(payload["requests"])) - async def greeks_batch(self, request: BatchGreeksRequest) -> BatchGreeksResponse: + async def greeks_standard_batch( + self, request: BatchGreeksStandardRequest, *, max_cu: int | None = None + ) -> BatchGreeksStandardResponse: """Submit one batch; preserve ordered outcomes and count its charge once.""" payload = request.to_wire() - response = await self._post("v1/greeks:batch", payload, BatchGreeksResponse) + response = await self._post( + "v1/greeks/standard:batch", payload, BatchGreeksStandardResponse, max_cu=max_cu + ) + return _check_batch(response, len(payload["requests"])) + + async def greeks_batch( + self, request: BatchGreeksRequest, *, max_cu: int | None = None + ) -> BatchGreeksResponse: + """Submit one batch; preserve ordered outcomes and count its charge once.""" + payload = request.to_wire() + response = await self._post("v1/greeks:batch", payload, BatchGreeksResponse, max_cu=max_cu) return _check_batch(response, len(payload["requests"])) async def implied_volatility_batch( - self, request: BatchImpliedVolatilityRequest + self, request: BatchImpliedVolatilityRequest, *, max_cu: int | None = None ) -> BatchImpliedVolatilityResponse: """Submit one batch; preserve ordered outcomes and count its charge once.""" payload = request.to_wire() response = await self._post( - "v1/implied-volatility:batch", payload, BatchImpliedVolatilityResponse + "v1/implied-volatility:batch", payload, BatchImpliedVolatilityResponse, max_cu=max_cu ) return _check_batch(response, len(payload["requests"])) - async def analytics_batch(self, request: BatchAnalyticsRequest) -> BatchAnalyticsResponse: + async def analytics_batch( + self, request: BatchAnalyticsRequest, *, max_cu: int | None = None + ) -> BatchAnalyticsResponse: """Submit one batch; preserve ordered outcomes and count its charge once.""" payload = request.to_wire() - response = await self._post("v1/analytics:batch", payload, BatchAnalyticsResponse) + response = await self._post( + "v1/analytics:batch", payload, BatchAnalyticsResponse, max_cu=max_cu + ) return _check_batch(response, len(payload["requests"])) def price_workflow( @@ -402,6 +585,20 @@ def price_workflow( max_concurrency=max_concurrency, ) + def greeks_standard_workflow( + self, + requests: Sequence[GreeksStandardRequest], + *, + chunk_size: int, + max_concurrency: int = 1, + ) -> AsyncBatchWorkflow[BatchGreeksStandardRequest, BatchGreeksStandardResponse]: + """Prepare chunks without HTTP; max_concurrency explicitly bounds async execution.""" + return AsyncBatchWorkflow( + _prepare_chunks(requests, BatchGreeksStandardRequest, chunk_size), + self.greeks_standard_batch, + max_concurrency=max_concurrency, + ) + def greeks_workflow( self, requests: Sequence[GreeksRequest], *, chunk_size: int, max_concurrency: int = 1 ) -> AsyncBatchWorkflow[BatchGreeksRequest, BatchGreeksResponse]: @@ -436,20 +633,68 @@ def analytics_workflow( max_concurrency=max_concurrency, ) + async def pricing_models(self) -> PricingModelCatalog: + """Discover compiled models without a key, execution, or CU charge.""" + return await self._request( + "GET", "v1/pricing/models", None, PricingModelCatalog, billable=False, public=True + ) + + async def cost_schedule( + self, *, operation: str | None = None, model: str | None = None + ) -> CostScheduleResponse: + """Read your active schedule, optionally filtered; consumes no commercial CUs.""" + params = { + key: value + for key, value in {"operation": operation, "model": model}.items() + if value is not None + } + return await self._request( + "GET", "v1/cost/schedule", None, CostScheduleResponse, params=params, billable=False + ) + + async def quote(self, request: QuoteInput) -> CostQuoteResponse: + """Quote the same typed request you will execute, without running its calculation.""" + return await self._request( + "POST", "v1/cost/quote", _quote_payload(request), CostQuoteResponse, billable=False + ) + async def _post( - self, path: str, payload: dict[str, Any], model: type[_ResponseType] + self, + path: str, + payload: dict[str, Any], + model: type[_ResponseType], + *, + max_cu: int | None = None, + ) -> _ResponseType: + return await self._request("POST", path, payload, model, max_cu=max_cu) + + async def _request( + self, + method: str, + path: str, + payload: dict[str, Any] | None, + model: type[_ResponseType], + *, + params: dict[str, str] | None = None, + max_cu: int | None = None, + billable: bool = True, + public: bool = False, ) -> _ResponseType: + if not public: + self._config.require_auth() + headers = _cost_headers(max_cu) try: - response = await self._http.post(path, json=payload) + request = self._http.build_request( + method, path, json=payload, params=params, headers=headers + ) + if public: + request.headers.pop("Authorization", None) + response = await self._http.send(request) except httpx.TimeoutException: - raise RequestTimeout( - "The request timed out; execution and CU charges may be unknown. " - "No retry was attempted." - ) from None + raise RequestTimeout(_transport_message("timed out", billable)) from None except httpx.RequestError: raise TransportError( - "The request failed before a complete response was received. " - "Execution and CU charges may be unknown; no retry was attempted." + _transport_message("failed before a complete response", billable) ) from None return self._config.decode(response, model) diff --git a/src/morphiq_sdk/models.py b/src/morphiq_sdk/models.py index 7b18295..eac4798 100644 --- a/src/morphiq_sdk/models.py +++ b/src/morphiq_sdk/models.py @@ -1,13 +1,31 @@ """API wire models for the reviewed HTTP contract; no pricing logic lives here.""" +from __future__ import annotations + from typing import Annotated, Any, Generic, Literal, TypeVar -from pydantic import BaseModel, ConfigDict, Field, FiniteFloat, PrivateAttr +from pydantic import ( + BaseModel, + ConfigDict, + Field, + FiniteFloat, + PrivateAttr, + ValidationInfo, + field_validator, + model_validator, +) ExerciseStyle = Literal["european", "american"] OptionType = Literal["call", "put"] PricingModel = Literal[ - "auto", "black_scholes_merton", "bjerksund_stensland_2002", "bjerksund_stensland_1993" + "auto", + "black_scholes_merton", + "bjerksund_stensland_2002", + "bjerksund_stensland_1993", + "black_scholes_merton_qdfp", + "black_scholes_merton_fd", + "qd_fixed_point", + "cash_dividend_american", ] @@ -75,6 +93,16 @@ class _Response(BaseModel): model_config = ConfigDict(extra="allow", strict=True, allow_inf_nan=False) +class ErrorDetails(_Response): + code: str + message: str + field: str | None = None + + +class GreeksStandardRequest(PriceRequest): + """Price and delta, gamma, theta, vega and rho for a supported model.""" + + class GreeksRequest(PriceRequest): """Price and all ten Greeks at a supplied volatility, using the pricing inputs. @@ -93,22 +121,55 @@ class AnalyticsRequest(PriceRequest): market_price: FiniteFloat | None = None -class Greeks(_Response): - """Unscaled API values: time derivatives per year; volatility/rates per unit. +class GreeksStandard(_Response): + """Unscaled derivatives; omitted quantities have typed unavailable reasons. - No calendar-day, percentage-point or contract-multiplier conversion occurs. + Time derivatives are per year, volatility/rate derivatives per unit. + A resolved zero stays 0.0; unavailable quantities are never filled with zero. + Use model_dump(exclude_unset=True) to preserve the original wire presence. """ - delta: FiniteFloat - gamma: FiniteFloat - theta: FiniteFloat - vega: FiniteFloat - rho: FiniteFloat - vanna: FiniteFloat - volga: FiniteFloat - charm: FiniteFloat - veta: FiniteFloat - color: FiniteFloat + delta: FiniteFloat | None = None + gamma: FiniteFloat | None = None + theta: FiniteFloat | None = None + vega: FiniteFloat | None = None + rho: FiniteFloat | None = None + unavailable: dict[str, ErrorDetails] = Field(default_factory=dict) + + @field_validator("*", mode="before") + @classmethod + def reject_null_quantity(cls, value: Any, info: ValidationInfo) -> Any: + if info.field_name != "unavailable" and value is None: + raise ValueError("Unavailable Greeks must be omitted, not null") + return value + + @model_validator(mode="after") + def require_availability_evidence(self) -> GreeksStandard: + for name in type(self).model_fields: + if name == "unavailable": + continue + present = getattr(self, name) is not None + explained = name in self.unavailable + if present == explained: + raise ValueError(f"{name} must have either a value or an unavailable reason") + return self + + +class Greeks(GreeksStandard): + """All ten derivatives, retaining partial results and unavailable reasons.""" + + vanna: FiniteFloat | None = None + volga: FiniteFloat | None = None + charm: FiniteFloat | None = None + veta: FiniteFloat | None = None + color: FiniteFloat | None = None + + +class GreeksStandardResult(_Response): + price: FiniteFloat + greeks: GreeksStandard + pricing_model: str + model_version: str class GreeksResult(_Response): @@ -214,6 +275,11 @@ class PriceResponse(_EvidenceResponse): metadata: ResponseMetadata +class GreeksStandardResponse(_EvidenceResponse): + result: GreeksStandardResult + metadata: ResponseMetadata + + class GreeksResponse(_EvidenceResponse): result: GreeksResult metadata: ResponseMetadata @@ -367,12 +433,6 @@ class RegimeResponse(_EvidenceResponse): metadata: RegimeMetadata -class ErrorDetails(_Response): - code: str - message: str - field: str | None = None - - class ErrorEnvelope(_Response): error: ErrorDetails metadata: ResponseMetadata | ServiceErrorMetadata @@ -384,7 +444,7 @@ class ErrorEnvelope(_Response): class _BatchRequest(_Request, Generic[_OptionRequestType]): # This is the reviewed wire limit, not an account/plan limit. - requests: list[_OptionRequestType] = Field(min_length=1, max_length=1024) + requests: list[_OptionRequestType] = Field(min_length=1, max_length=100) def to_wire(self) -> dict[str, Any]: validated = type(self).model_validate({"requests": self.requests}) @@ -395,6 +455,10 @@ class BatchPriceRequest(_BatchRequest[PriceRequest]): """One explicit batch of pricing requests in caller order.""" +class BatchGreeksStandardRequest(_BatchRequest[GreeksStandardRequest]): + """One ordered batch of standard-five requests, at most 100 contracts.""" + + class BatchGreeksRequest(_BatchRequest[GreeksRequest]): """One explicit batch of price-and-Greeks requests.""" @@ -430,6 +494,10 @@ class BatchPriceResponse(_BatchResponse[PriceResult]): """Ordered outcomes and one metadata/charge block for the entire batch.""" +class BatchGreeksStandardResponse(_BatchResponse[GreeksStandardResult]): + """Ordered standard-five outcomes with batch-level charge evidence.""" + + class BatchGreeksResponse(_BatchResponse[GreeksResult]): """Ordered Greeks outcomes and batch-level charge evidence.""" @@ -440,3 +508,95 @@ class BatchImpliedVolatilityResponse(_BatchResponse[ImpliedVolatilityResult]): class BatchAnalyticsResponse(_BatchResponse[AnalyticsResult]): """Ordered combined analytics outcomes and batch-level charge evidence.""" + + +class CostRate(_Response): + """One published rate; its presence does not establish entitlement or availability.""" + + operation: str + model: str + billing_unit: str + fcu_per_unit: int + max_discrete_dividends: int + + +class CostSchedule(_Response): + schedule_id: str + schedule_version: str + record_version: int + policy_revision: str + effective_from: str + effective_until: str | None + fixed_request_fcu: int + minimum_charge_fcu: int + rounding: str + failure_charging: str + rates: list[CostRate] + + +class CostLine(_Response): + operation: str + model: str + billing_unit: str + quantity: int + fcu_per_unit: int + variable_fcu: int + + +class CostBreakdown(_Response): + total_fcu: int + fixed_fcu: int + variable_fcu: int + minimum_charge_fcu: int + lines: list[CostLine] + + +class ResolvedCostModel(_Response): + model: str + quantity: int + + +class CostScheduleResponse(_EvidenceResponse): + schedule: CostSchedule + metadata: ServiceErrorMetadata + + +class CostQuoteResponse(_EvidenceResponse): + """Exact conditional quote. No funds, execution capacity, or price are reserved.""" + + quote: CostBreakdown + resolved_models: list[ResolvedCostModel] + metadata: ServiceErrorMetadata + + +class ModelConstraint(_Response): + quantity: str + description: str + minimum: FiniteFloat | None = None + maximum: FiniteFloat | None = None + + +class PricingModelDescriptor(_Response): + id: str + name: str + version: str + model_version: str + aliases: list[str] + underlying_model: str + method: str + exercise_styles: list[ExerciseStyle] + dividend_support: list[str] + operations: list[str] + batch_operations: list[str] + greeks: list[str] + guidance: str + constraints: list[ModelConstraint] + notes: str + + +class PricingModelCatalog(_EvidenceResponse): + """Public compiled capabilities; account entitlements and rates are separate.""" + + models: list[PricingModelDescriptor] + engines: list[EngineIdentity] + greek_units: dict[str, str] diff --git a/src/morphiq_sdk/pandas.py b/src/morphiq_sdk/pandas.py index 03b6ef3..13ebd0a 100644 --- a/src/morphiq_sdk/pandas.py +++ b/src/morphiq_sdk/pandas.py @@ -17,6 +17,7 @@ from .models import ( BatchAnalyticsResponse, BatchGreeksResponse, + BatchGreeksStandardResponse, BatchImpliedVolatilityResponse, BatchPriceResponse, ) @@ -28,6 +29,7 @@ _BatchResponse = ( BatchPriceResponse | BatchGreeksResponse + | BatchGreeksStandardResponse | BatchImpliedVolatilityResponse | BatchAnalyticsResponse ) @@ -99,7 +101,7 @@ def _fields(model: BaseModel, prefix: str) -> dict[str, Any]: if isinstance(value, BaseModel): result.update(_fields(value, key)) else: - result[key] = deepcopy(value) + result[key] = deepcopy(model.model_dump(exclude_unset=True)[name]) return result diff --git a/tests/fixtures/analytics.json b/tests/fixtures/analytics.json index 5ae04fe..d93a337 100644 --- a/tests/fixtures/analytics.json +++ b/tests/fixtures/analytics.json @@ -1,8 +1,8 @@ { - "source_commit": "b2cb8d72b049d61c062f787724c1c02c15dd12be", - "contract_sha256": "09527006497b4f23bbf174f4a5b8bd9d02f75d02554b3d489eaba953ec5a18f1", - "admission": "open", - "note": "Synthetic inputs evaluated by the native API built with --locked from the pinned source commit. Served OpenAPI digest verified. Request IDs normalized; open mode does not report commercial CU. No Python numerical implementation.", + "source_commit": "2284d802b5ac0b37b43ee269bbf4bfeec3777776", + "contract_sha256": "e206327e13aee32c02dbfa22e1d8e8a80ea2d549083971a1cc87aa2b67f401e7", + "admission": "open (isolated loopback service; no commercial charge)", + "note": "Synthetic requests evaluated by a --locked native build of deployed API v0.5.0. American Greeks/analytics/batches explicitly use BS1993; Auto American singles resolve to QdFp.", "cases": [ { "name": "analytics-european-volatility", @@ -20,27 +20,27 @@ }, "response": { "metadata": { - "request_id": "fixture-analytics-european-volatility", + "request_id": "fr-18e4e90129262501-000000000000000a", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 10, "compute_schedule_version": "2026-09-11" }, "result": { - "price": 10.450583572185538, + "price": 10.45058357218557, "greeks": { "delta": 0.6368306511756191, "gamma": 0.018762017345846895, - "theta": -6.4140275464382, + "theta": -6.414027546438199, "vega": 37.52403469169381, - "rho": 53.23248154537637, + "rho": 53.232481545376345, "vanna": -0.28143026018770334, "volga": 9.850059106569617, "charm": -0.06566706071046415, @@ -69,14 +69,14 @@ }, "response": { "metadata": { - "request_id": "fixture-analytics-european-premium", + "request_id": "fr-18e4e90129262501-000000000000000b", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 10, @@ -85,22 +85,22 @@ "result": { "price": 10.450583572185543, "greeks": { - "delta": 0.6368306511756191, - "gamma": 0.018762017345846888, - "theta": -6.414027546438201, - "vega": 37.52403469169381, - "rho": 53.23248154537637, - "vanna": -0.2814302601877032, - "volga": 9.850059106569608, - "charm": -0.06566706071046412, - "veta": -16.46367022098066, - "color": 0.010530182235356567 + "delta": 0.6368306511756192, + "gamma": 0.018762017345846965, + "theta": -6.414027546438186, + "vega": 37.5240346916938, + "rho": 53.232481545376395, + "vanna": -0.28143026018770706, + "volga": 9.850059106569766, + "charm": -0.06566706071046423, + "veta": -16.46367022098065, + "color": 0.010530182235356613 }, "implied_volatility": { - "implied_volatility": 0.20000000000000007, + "implied_volatility": 0.1999999999999992, "iterations": 2, "used_fallback": false, - "residual": 1.3877787807814457e-17, + "residual": 1.3200960109770371e-17, "residual_basis": "forward_normalised_price", "pricing_model": "black_scholes_merton", "model_version": "black_scholes_merton/1" @@ -115,7 +115,7 @@ "path": "/v1/analytics", "request": { "exercise_style": "american", - "pricing_model": "auto", + "pricing_model": "bjerksund_stensland_1993", "option_type": "put", "spot": 100.0, "strike": 105.0, @@ -126,35 +126,35 @@ }, "response": { "metadata": { - "request_id": "fixture-analytics-american-volatility", + "request_id": "fr-18e4e90129262501-000000000000000c", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 5400, "compute_schedule_version": "2026-09-11" }, "result": { - "price": 7.584143004464017, + "price": 7.5298454349124135, "greeks": { - "delta": -0.5897928172087801, - "gamma": 0.03222261065667377, - "theta": -3.6051933523200046, - "vega": 26.684513084217365, - "rho": -21.188538179070036, - "vanna": 0.8767139435075082, - "volga": 22.095184006704915, - "charm": -0.18487751813050776, - "veta": -28.54236738380589, - "color": 0.02499731184924664 + "delta": -0.5892018845247824, + "gamma": 0.03283315892329423, + "theta": -3.55494205524821, + "vega": 26.586081744012873, + "rho": -21.61207168023723, + "vanna": 0.8950133905908548, + "volga": 24.916694215773163, + "charm": -0.18410996878530597, + "veta": -28.20742602649876, + "color": 0.025532151457727196 }, - "pricing_model": "bjerksund_stensland_2002", - "model_version": "bjerksund_stensland_2002/1" + "pricing_model": "bjerksund_stensland_1993", + "model_version": "bjerksund_stensland_1993/1" } } }, @@ -163,7 +163,7 @@ "path": "/v1/analytics", "request": { "exercise_style": "american", - "pricing_model": "auto", + "pricing_model": "bjerksund_stensland_1993", "option_type": "put", "spot": 100.0, "strike": 105.0, @@ -171,48 +171,48 @@ "risk_free_rate": 0.05, "dividend_yield": 0.01, "volatility": 0.35, - "market_price": 7.584143004464017 + "market_price": 7.5298454349124135 }, "response": { "metadata": { - "request_id": "fixture-analytics-american-premium", + "request_id": "fr-18e4e90129262501-000000000000000e", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 5400, "compute_schedule_version": "2026-09-11" }, "result": { - "price": 7.584143004464022, + "price": 7.529845434912404, "greeks": { - "delta": -0.5897928172087792, - "gamma": 0.03222261065667396, - "theta": -3.6051933523200215, - "vega": 26.6845130842174, - "rho": -21.188538179070036, - "vanna": 0.8767139435075141, - "volga": 22.095184006705093, - "charm": -0.18487751813050896, - "veta": -28.542367383805793, - "color": 0.0249973118491495 + "delta": -0.589201884524784, + "gamma": 0.032833158923294165, + "theta": -3.5549420552482323, + "vega": 26.586081744012873, + "rho": -21.612071680237182, + "vanna": 0.8950133905908535, + "volga": 24.916694215773237, + "charm": -0.18410996878530728, + "veta": -28.207426026498748, + "color": 0.025532151457727564 }, "implied_volatility": { - "implied_volatility": 0.19999999999999996, + "implied_volatility": 0.20000000000000026, "iterations": 9, "used_fallback": true, - "residual": 5.329070518200751e-15, + "residual": 9.769962616701378e-15, "residual_basis": "option_price", - "pricing_model": "bjerksund_stensland_2002", - "model_version": "bjerksund_stensland_2002/1" + "pricing_model": "bjerksund_stensland_1993", + "model_version": "bjerksund_stensland_1993/1" }, - "pricing_model": "bjerksund_stensland_2002", - "model_version": "bjerksund_stensland_2002/1" + "pricing_model": "bjerksund_stensland_1993", + "model_version": "bjerksund_stensland_1993/1" } } } diff --git a/tests/fixtures/batch.json b/tests/fixtures/batch.json index 4c1442e..5427d52 100644 --- a/tests/fixtures/batch.json +++ b/tests/fixtures/batch.json @@ -1,8 +1,8 @@ { - "source_commit": "b2cb8d72b049d61c062f787724c1c02c15dd12be", - "contract_sha256": "09527006497b4f23bbf174f4a5b8bd9d02f75d02554b3d489eaba953ec5a18f1", - "admission": "open", - "note": "Synthetic inputs evaluated by the native API built with --locked from the pinned source commit. Served OpenAPI digest verified. Request IDs normalized; open mode does not report commercial CU. No Python numerical implementation.", + "source_commit": "2284d802b5ac0b37b43ee269bbf4bfeec3777776", + "contract_sha256": "e206327e13aee32c02dbfa22e1d8e8a80ea2d549083971a1cc87aa2b67f401e7", + "admission": "open (isolated loopback service; no commercial charge)", + "note": "Synthetic requests evaluated by a --locked native build of deployed API v0.5.0. American Greeks/analytics/batches explicitly use BS1993; Auto American singles resolve to QdFp.", "cases": [ { "name": "batch-price", @@ -22,7 +22,7 @@ }, { "exercise_style": "american", - "pricing_model": "auto", + "pricing_model": "bjerksund_stensland_1993", "option_type": "put", "spot": -1, "strike": 105.0, @@ -33,7 +33,7 @@ }, { "exercise_style": "american", - "pricing_model": "auto", + "pricing_model": "bjerksund_stensland_1993", "option_type": "put", "spot": 100.0, "strike": 105.0, @@ -46,14 +46,14 @@ }, "response": { "metadata": { - "request_id": "fixture-batch-price", + "request_id": "fr-18e4e90129262501-000000000000000f", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 101, @@ -64,7 +64,7 @@ "index": 0, "status": "success", "result": { - "price": 10.450583572185538, + "price": 10.45058357218557, "exercise_style": "european", "option_type": "call", "pricing_model": "black_scholes_merton", @@ -84,11 +84,11 @@ "index": 2, "status": "success", "result": { - "price": 7.584143004464017, + "price": 7.5298454349124135, "exercise_style": "american", "option_type": "put", - "pricing_model": "bjerksund_stensland_2002", - "model_version": "bjerksund_stensland_2002/1" + "pricing_model": "bjerksund_stensland_1993", + "model_version": "bjerksund_stensland_1993/1" } } ] @@ -112,7 +112,7 @@ }, { "exercise_style": "american", - "pricing_model": "auto", + "pricing_model": "bjerksund_stensland_1993", "option_type": "put", "spot": 100.0, "strike": 105.0, @@ -123,7 +123,7 @@ }, { "exercise_style": "american", - "pricing_model": "auto", + "pricing_model": "bjerksund_stensland_1993", "option_type": "put", "spot": 100.0, "strike": 105.0, @@ -136,14 +136,14 @@ }, "response": { "metadata": { - "request_id": "fixture-batch-greeks", + "request_id": "fr-18e4e90129262501-0000000000000010", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 806, @@ -154,13 +154,13 @@ "index": 0, "status": "success", "result": { - "price": 10.450583572185538, + "price": 10.45058357218557, "greeks": { "delta": 0.6368306511756191, "gamma": 0.018762017345846895, - "theta": -6.4140275464382, + "theta": -6.414027546438199, "vega": 37.52403469169381, - "rho": 53.23248154537637, + "rho": 53.232481545376345, "vanna": -0.28143026018770334, "volga": 9.850059106569617, "charm": -0.06566706071046415, @@ -183,21 +183,21 @@ "index": 2, "status": "success", "result": { - "price": 7.584143004464017, + "price": 7.5298454349124135, "greeks": { - "delta": -0.5897928172087801, - "gamma": 0.03222261065667377, - "theta": -3.6051933523200046, - "vega": 26.684513084217365, - "rho": -21.188538179070036, - "vanna": 0.8767139435075082, - "volga": 22.095184006704915, - "charm": -0.18487751813050776, - "veta": -28.54236738380589, - "color": 0.02499731184924664 + "delta": -0.5892018845247824, + "gamma": 0.03283315892329423, + "theta": -3.55494205524821, + "vega": 26.586081744012873, + "rho": -21.61207168023723, + "vanna": 0.8950133905908548, + "volga": 24.916694215773163, + "charm": -0.18410996878530597, + "veta": -28.20742602649876, + "color": 0.025532151457727196 }, - "pricing_model": "bjerksund_stensland_2002", - "model_version": "bjerksund_stensland_2002/1" + "pricing_model": "bjerksund_stensland_1993", + "model_version": "bjerksund_stensland_1993/1" } } ] @@ -221,38 +221,38 @@ }, { "exercise_style": "american", - "pricing_model": "auto", + "pricing_model": "bjerksund_stensland_1993", "option_type": "put", "spot": 100.0, "strike": 105.0, "time_to_expiry_years": 0.5, "risk_free_rate": 0.05, "dividend_yield": 0.01, - "market_price": 1000 + "market_price": 7.5298454349124135 }, { "exercise_style": "american", - "pricing_model": "auto", + "pricing_model": "bjerksund_stensland_1993", "option_type": "put", "spot": 100.0, "strike": 105.0, "time_to_expiry_years": 0.5, "risk_free_rate": 0.05, "dividend_yield": 0.01, - "market_price": 7.584143004464017 + "market_price": 7.5298454349124135 } ] }, "response": { "metadata": { - "request_id": "fixture-batch-implied-volatility", + "request_id": "fr-18e4e90129262501-0000000000000013", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 10004, @@ -263,10 +263,10 @@ "index": 0, "status": "success", "result": { - "implied_volatility": 0.20000000000000007, + "implied_volatility": 0.1999999999999992, "iterations": 2, "used_fallback": false, - "residual": 1.3877787807814457e-17, + "residual": 1.3200960109770371e-17, "residual_basis": "forward_normalised_price", "pricing_model": "black_scholes_merton", "model_version": "black_scholes_merton/1" @@ -274,23 +274,28 @@ }, { "index": 1, - "status": "error", - "error": { - "code": "pricing_failed", - "message": "the pricing engine could not produce a result" + "status": "success", + "result": { + "implied_volatility": 0.20000000000000026, + "iterations": 9, + "used_fallback": true, + "residual": 9.769962616701378e-15, + "residual_basis": "option_price", + "pricing_model": "bjerksund_stensland_1993", + "model_version": "bjerksund_stensland_1993/1" } }, { "index": 2, "status": "success", "result": { - "implied_volatility": 0.19999999999999996, + "implied_volatility": 0.20000000000000026, "iterations": 9, "used_fallback": true, - "residual": 5.329070518200751e-15, + "residual": 9.769962616701378e-15, "residual_basis": "option_price", - "pricing_model": "bjerksund_stensland_2002", - "model_version": "bjerksund_stensland_2002/1" + "pricing_model": "bjerksund_stensland_1993", + "model_version": "bjerksund_stensland_1993/1" } } ] @@ -315,7 +320,7 @@ }, { "exercise_style": "american", - "pricing_model": "auto", + "pricing_model": "bjerksund_stensland_1993", "option_type": "put", "spot": 100.0, "strike": 105.0, @@ -323,11 +328,11 @@ "risk_free_rate": 0.05, "dividend_yield": 0.01, "volatility": 0.35, - "market_price": 1000 + "market_price": 7.5298454349124135 }, { "exercise_style": "american", - "pricing_model": "auto", + "pricing_model": "bjerksund_stensland_1993", "option_type": "put", "spot": 100.0, "strike": 105.0, @@ -335,20 +340,20 @@ "risk_free_rate": 0.05, "dividend_yield": 0.01, "volatility": 0.35, - "market_price": 7.584143004464017 + "market_price": 7.5298454349124135 } ] }, "response": { "metadata": { - "request_id": "fixture-batch-analytics", + "request_id": "fr-18e4e90129262501-0000000000000016", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 10810, @@ -361,22 +366,22 @@ "result": { "price": 10.450583572185543, "greeks": { - "delta": 0.6368306511756191, - "gamma": 0.018762017345846888, - "theta": -6.414027546438201, - "vega": 37.52403469169381, - "rho": 53.23248154537637, - "vanna": -0.2814302601877032, - "volga": 9.850059106569608, - "charm": -0.06566706071046412, - "veta": -16.46367022098066, - "color": 0.010530182235356567 + "delta": 0.6368306511756192, + "gamma": 0.018762017345846965, + "theta": -6.414027546438186, + "vega": 37.5240346916938, + "rho": 53.232481545376395, + "vanna": -0.28143026018770706, + "volga": 9.850059106569766, + "charm": -0.06566706071046423, + "veta": -16.46367022098065, + "color": 0.010530182235356613 }, "implied_volatility": { - "implied_volatility": 0.20000000000000007, + "implied_volatility": 0.1999999999999992, "iterations": 2, "used_fallback": false, - "residual": 1.3877787807814457e-17, + "residual": 1.3200960109770371e-17, "residual_basis": "forward_normalised_price", "pricing_model": "black_scholes_merton", "model_version": "black_scholes_merton/1" @@ -387,40 +392,62 @@ }, { "index": 1, - "status": "error", - "error": { - "code": "pricing_failed", - "message": "the pricing engine could not produce a result" + "status": "success", + "result": { + "price": 7.529845434912404, + "greeks": { + "delta": -0.589201884524784, + "gamma": 0.032833158923294165, + "theta": -3.5549420552482323, + "vega": 26.586081744012873, + "rho": -21.612071680237182, + "vanna": 0.8950133905908535, + "volga": 24.916694215773237, + "charm": -0.18410996878530728, + "veta": -28.207426026498748, + "color": 0.025532151457727564 + }, + "implied_volatility": { + "implied_volatility": 0.20000000000000026, + "iterations": 9, + "used_fallback": true, + "residual": 9.769962616701378e-15, + "residual_basis": "option_price", + "pricing_model": "bjerksund_stensland_1993", + "model_version": "bjerksund_stensland_1993/1" + }, + "pricing_model": "bjerksund_stensland_1993", + "model_version": "bjerksund_stensland_1993/1" } }, { "index": 2, "status": "success", "result": { - "price": 7.584143004464022, + "price": 7.529845434912404, "greeks": { - "delta": -0.5897928172087792, - "gamma": 0.03222261065667396, - "theta": -3.6051933523200215, - "vega": 26.6845130842174, - "rho": -21.188538179070036, - "vanna": 0.8767139435075141, - "volga": 22.095184006705093, - "charm": -0.18487751813050896, - "veta": -28.542367383805793, - "color": 0.0249973118491495 + "delta": -0.589201884524784, + "gamma": 0.032833158923294165, + "theta": -3.5549420552482323, + "vega": 26.586081744012873, + "rho": -21.612071680237182, + "vanna": 0.8950133905908535, + "volga": 24.916694215773237, + "charm": -0.18410996878530728, + "veta": -28.207426026498748, + "color": 0.025532151457727564 }, "implied_volatility": { - "implied_volatility": 0.19999999999999996, + "implied_volatility": 0.20000000000000026, "iterations": 9, "used_fallback": true, - "residual": 5.329070518200751e-15, + "residual": 9.769962616701378e-15, "residual_basis": "option_price", - "pricing_model": "bjerksund_stensland_2002", - "model_version": "bjerksund_stensland_2002/1" + "pricing_model": "bjerksund_stensland_1993", + "model_version": "bjerksund_stensland_1993/1" }, - "pricing_model": "bjerksund_stensland_2002", - "model_version": "bjerksund_stensland_2002/1" + "pricing_model": "bjerksund_stensland_1993", + "model_version": "bjerksund_stensland_1993/1" } } ] @@ -534,14 +561,14 @@ }, "response": { "metadata": { - "request_id": "fixture-batch-price-sweep", + "request_id": "fr-18e4e90129262501-0000000000000017", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 9, @@ -552,7 +579,7 @@ "index": 0, "status": "success", "result": { - "price": 1.8594195728121798, + "price": 1.8594195728121832, "exercise_style": "european", "option_type": "call", "pricing_model": "black_scholes_merton", @@ -563,7 +590,7 @@ "index": 1, "status": "success", "result": { - "price": 3.2135985531478717, + "price": 3.2135985531478806, "exercise_style": "european", "option_type": "call", "pricing_model": "black_scholes_merton", @@ -574,7 +601,7 @@ "index": 2, "status": "success", "result": { - "price": 5.091222078817525, + "price": 5.09122207881755, "exercise_style": "european", "option_type": "call", "pricing_model": "black_scholes_merton", @@ -585,7 +612,7 @@ "index": 3, "status": "success", "result": { - "price": 7.510872178352632, + "price": 7.5108721783527015, "exercise_style": "european", "option_type": "call", "pricing_model": "black_scholes_merton", @@ -596,7 +623,7 @@ "index": 4, "status": "success", "result": { - "price": 10.450583572185538, + "price": 10.45058357218557, "exercise_style": "european", "option_type": "call", "pricing_model": "black_scholes_merton", @@ -607,7 +634,7 @@ "index": 5, "status": "success", "result": { - "price": 13.857906267073098, + "price": 13.85790626707311, "exercise_style": "european", "option_type": "call", "pricing_model": "black_scholes_merton", @@ -668,7 +695,7 @@ }, { "exercise_style": "american", - "pricing_model": "auto", + "pricing_model": "bjerksund_stensland_1993", "option_type": "put", "spot": 100.0, "strike": 105.0, @@ -681,14 +708,14 @@ }, "response": { "metadata": { - "request_id": "fixture-batch-price-example", + "request_id": "fr-18e4e90129262501-0000000000000018", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 51, @@ -699,7 +726,7 @@ "index": 0, "status": "success", "result": { - "price": 10.450583572185538, + "price": 10.45058357218557, "exercise_style": "european", "option_type": "call", "pricing_model": "black_scholes_merton", @@ -710,11 +737,11 @@ "index": 1, "status": "success", "result": { - "price": 7.584143004464017, + "price": 7.5298454349124135, "exercise_style": "american", "option_type": "put", - "pricing_model": "bjerksund_stensland_2002", - "model_version": "bjerksund_stensland_2002/1" + "pricing_model": "bjerksund_stensland_1993", + "model_version": "bjerksund_stensland_1993/1" } } ] diff --git a/tests/fixtures/greeks.json b/tests/fixtures/greeks.json index e562c90..4a8ae4c 100644 --- a/tests/fixtures/greeks.json +++ b/tests/fixtures/greeks.json @@ -1,8 +1,8 @@ { - "source_commit": "b2cb8d72b049d61c062f787724c1c02c15dd12be", - "contract_sha256": "09527006497b4f23bbf174f4a5b8bd9d02f75d02554b3d489eaba953ec5a18f1", - "admission": "open", - "note": "Synthetic inputs evaluated by the native API built with --locked from the pinned source commit. Served OpenAPI digest verified. Request IDs normalized; open mode does not report commercial CU. No Python numerical implementation.", + "source_commit": "2284d802b5ac0b37b43ee269bbf4bfeec3777776", + "contract_sha256": "e206327e13aee32c02dbfa22e1d8e8a80ea2d549083971a1cc87aa2b67f401e7", + "admission": "open (isolated loopback service; no commercial charge)", + "note": "Synthetic requests evaluated by a --locked native build of deployed API v0.5.0. American Greeks/analytics/batches explicitly use BS1993; Auto American singles resolve to QdFp.", "cases": [ { "name": "greeks-european", @@ -20,27 +20,27 @@ }, "response": { "metadata": { - "request_id": "fixture-greeks-european", + "request_id": "fr-18e4e90129262501-0000000000000005", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 6, "compute_schedule_version": "2026-09-11" }, "result": { - "price": 10.450583572185538, + "price": 10.45058357218557, "greeks": { "delta": 0.6368306511756191, "gamma": 0.018762017345846895, - "theta": -6.4140275464382, + "theta": -6.414027546438199, "vega": 37.52403469169381, - "rho": 53.23248154537637, + "rho": 53.232481545376345, "vanna": -0.28143026018770334, "volga": 9.850059106569617, "charm": -0.06566706071046415, @@ -57,7 +57,7 @@ "path": "/v1/greeks", "request": { "exercise_style": "american", - "pricing_model": "auto", + "pricing_model": "bjerksund_stensland_1993", "option_type": "put", "spot": 100.0, "strike": 105.0, @@ -68,35 +68,35 @@ }, "response": { "metadata": { - "request_id": "fixture-greeks-american", + "request_id": "fr-18e4e90129262501-0000000000000006", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 400, "compute_schedule_version": "2026-09-11" }, "result": { - "price": 7.584143004464017, + "price": 7.5298454349124135, "greeks": { - "delta": -0.5897928172087801, - "gamma": 0.03222261065667377, - "theta": -3.6051933523200046, - "vega": 26.684513084217365, - "rho": -21.188538179070036, - "vanna": 0.8767139435075082, - "volga": 22.095184006704915, - "charm": -0.18487751813050776, - "veta": -28.54236738380589, - "color": 0.02499731184924664 + "delta": -0.5892018845247824, + "gamma": 0.03283315892329423, + "theta": -3.55494205524821, + "vega": 26.586081744012873, + "rho": -21.61207168023723, + "vanna": 0.8950133905908548, + "volga": 24.916694215773163, + "charm": -0.18410996878530597, + "veta": -28.20742602649876, + "color": 0.025532151457727196 }, - "pricing_model": "bjerksund_stensland_2002", - "model_version": "bjerksund_stensland_2002/1" + "pricing_model": "bjerksund_stensland_1993", + "model_version": "bjerksund_stensland_1993/1" } } } diff --git a/tests/fixtures/greeks_standard.json b/tests/fixtures/greeks_standard.json new file mode 100644 index 0000000..5f23507 --- /dev/null +++ b/tests/fixtures/greeks_standard.json @@ -0,0 +1,92 @@ +{ + "source_commit": "2284d802b5ac0b37b43ee269bbf4bfeec3777776", + "contract_sha256": "e206327e13aee32c02dbfa22e1d8e8a80ea2d549083971a1cc87aa2b67f401e7", + "cases": [ + { + "name": "greeks-standard-european", + "path": "/v1/greeks/standard", + "request": { + "exercise_style": "european", + "pricing_model": "auto", + "option_type": "call", + "spot": 100.0, + "strike": 100.0, + "time_to_expiry_years": 1, + "risk_free_rate": 0.05, + "dividend_yield": 0, + "volatility": 0.2 + }, + "response": { + "metadata": { + "request_id": "fr-18e4e90129262501-0000000000000019", + "api_version": "v1", + "engine_version": "0.43.5", + "engines": [ + { + "name": "morphiq-risk", + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" + } + ], + "units_charged": 6, + "compute_schedule_version": "2026-09-11" + }, + "result": { + "price": 10.45058357218557, + "greeks": { + "delta": 0.6368306511756191, + "gamma": 0.018762017345846895, + "theta": -6.414027546438199, + "vega": 37.52403469169381, + "rho": 53.232481545376345 + }, + "pricing_model": "black_scholes_merton", + "model_version": "black_scholes_merton/1" + } + } + }, + { + "name": "greeks-standard-american", + "path": "/v1/greeks/standard", + "request": { + "exercise_style": "american", + "pricing_model": "bjerksund_stensland_1993", + "option_type": "put", + "spot": 100.0, + "strike": 105.0, + "time_to_expiry_years": 0.5, + "risk_free_rate": 0.05, + "dividend_yield": 0.01, + "volatility": 0.2 + }, + "response": { + "metadata": { + "request_id": "fr-18e4e90129262501-000000000000001a", + "api_version": "v1", + "engine_version": "0.43.5", + "engines": [ + { + "name": "morphiq-risk", + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" + } + ], + "units_charged": 400, + "compute_schedule_version": "2026-09-11" + }, + "result": { + "price": 7.5298454349124135, + "greeks": { + "delta": -0.5892018845247824, + "gamma": 0.03283315892329423, + "theta": -3.55494205524821, + "vega": 26.586081744012873, + "rho": -21.61207168023723 + }, + "pricing_model": "bjerksund_stensland_1993", + "model_version": "bjerksund_stensland_1993/1" + } + } + } + ] +} diff --git a/tests/fixtures/implied_volatility.json b/tests/fixtures/implied_volatility.json index 1b92f82..bd46ca2 100644 --- a/tests/fixtures/implied_volatility.json +++ b/tests/fixtures/implied_volatility.json @@ -1,8 +1,8 @@ { - "source_commit": "b2cb8d72b049d61c062f787724c1c02c15dd12be", - "contract_sha256": "09527006497b4f23bbf174f4a5b8bd9d02f75d02554b3d489eaba953ec5a18f1", - "admission": "open", - "note": "Synthetic inputs evaluated by the native API built with --locked from the pinned source commit. Served OpenAPI digest verified. Request IDs normalized; open mode does not report commercial CU. No Python numerical implementation.", + "source_commit": "2284d802b5ac0b37b43ee269bbf4bfeec3777776", + "contract_sha256": "e206327e13aee32c02dbfa22e1d8e8a80ea2d549083971a1cc87aa2b67f401e7", + "admission": "open (isolated loopback service; no commercial charge)", + "note": "Synthetic requests evaluated by a --locked native build of deployed API v0.5.0. American Greeks/analytics/batches explicitly use BS1993; Auto American singles resolve to QdFp.", "cases": [ { "name": "iv-european", @@ -20,24 +20,24 @@ }, "response": { "metadata": { - "request_id": "fixture-iv-european", + "request_id": "fr-18e4e90129262501-0000000000000007", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 4, "compute_schedule_version": "2026-09-11" }, "result": { - "implied_volatility": 0.20000000000000007, + "implied_volatility": 0.1999999999999992, "iterations": 2, "used_fallback": false, - "residual": 1.3877787807814457e-17, + "residual": 1.3200960109770371e-17, "residual_basis": "forward_normalised_price", "pricing_model": "black_scholes_merton", "model_version": "black_scholes_merton/1" @@ -56,31 +56,31 @@ "time_to_expiry_years": 0.5, "risk_free_rate": 0.05, "dividend_yield": 0.01, - "market_price": 7.584143004464017 + "market_price": 7.6165316765775835 }, "response": { "metadata": { - "request_id": "fixture-iv-american", + "request_id": "fr-18e4e90129262501-0000000000000009", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 5000, "compute_schedule_version": "2026-09-11" }, "result": { - "implied_volatility": 0.19999999999999996, - "iterations": 9, - "used_fallback": true, - "residual": 5.329070518200751e-15, + "implied_volatility": 0.19999999999999987, + "iterations": 6, + "used_fallback": false, + "residual": 0.0, "residual_basis": "option_price", - "pricing_model": "bjerksund_stensland_2002", - "model_version": "bjerksund_stensland_2002/1" + "pricing_model": "black_scholes_merton_qdfp", + "model_version": "qd_fixed_point/1" } } } diff --git a/tests/fixtures/pricing.json b/tests/fixtures/pricing.json index 2cf936d..928c57f 100644 --- a/tests/fixtures/pricing.json +++ b/tests/fixtures/pricing.json @@ -1,8 +1,8 @@ { - "source_commit": "b2cb8d72b049d61c062f787724c1c02c15dd12be", - "contract_sha256": "09527006497b4f23bbf174f4a5b8bd9d02f75d02554b3d489eaba953ec5a18f1", - "admission": "open", - "note": "Synthetic inputs evaluated by the pinned native API. Request IDs normalized. Commercial CU metadata is absent in open mode.", + "source_commit": "2284d802b5ac0b37b43ee269bbf4bfeec3777776", + "contract_sha256": "e206327e13aee32c02dbfa22e1d8e8a80ea2d549083971a1cc87aa2b67f401e7", + "admission": "open (isolated loopback service; no commercial charge)", + "note": "Synthetic requests evaluated by a --locked native build of deployed API v0.5.0. American Greeks/analytics/batches explicitly use BS1993; Auto American singles resolve to QdFp.", "cases": [ { "name": "european", @@ -19,21 +19,21 @@ }, "response": { "metadata": { - "request_id": "fixture-european", + "request_id": "fr-18e4e90129262501-0000000000000003", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 1, "compute_schedule_version": "2026-09-11" }, "result": { - "price": 10.450583572185538, + "price": 10.45058357218557, "exercise_style": "european", "option_type": "call", "pricing_model": "black_scholes_merton", @@ -56,25 +56,25 @@ }, "response": { "metadata": { - "request_id": "fixture-american", + "request_id": "fr-18e4e90129262501-0000000000000004", "api_version": "v1", - "engine_version": "0.39.1", + "engine_version": "0.43.5", "engines": [ { "name": "morphiq-risk", - "version": "0.39.1", - "revision": "b4c29d492ff96dddf9b25f745d2be4d253202164" + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" } ], "units_charged": 50, "compute_schedule_version": "2026-09-11" }, "result": { - "price": 7.584143004464017, + "price": 7.6165316765775835, "exercise_style": "american", "option_type": "put", - "pricing_model": "bjerksund_stensland_2002", - "model_version": "bjerksund_stensland_2002/1" + "pricing_model": "black_scholes_merton_qdfp", + "model_version": "qd_fixed_point/1" } } } diff --git a/tests/fixtures/pricing_models.json b/tests/fixtures/pricing_models.json new file mode 100644 index 0000000..2c8352e --- /dev/null +++ b/tests/fixtures/pricing_models.json @@ -0,0 +1,205 @@ +{ + "models": [ + { + "id": "black_scholes_merton", + "name": "Black\u2013Scholes\u2013Merton", + "version": "1", + "model_version": "black_scholes_merton/1", + "aliases": [], + "underlying_model": "black_scholes_merton", + "method": "analytic", + "exercise_styles": [ + "european" + ], + "dividend_support": [ + "continuous_yield" + ], + "operations": [ + "price", + "implied_volatility", + "greeks_standard", + "greeks", + "analytics" + ], + "batch_operations": [ + "price", + "implied_volatility", + "greeks_standard", + "greeks", + "analytics" + ], + "greeks": [ + "delta", + "gamma", + "theta", + "vega", + "rho", + "vanna", + "volga", + "charm", + "veta", + "color" + ], + "guidance": "European calls and puts.", + "constraints": [], + "notes": "The engine enforces model-specific domains and accuracy regions. Implemented Greeks may be unavailable for particular inputs, including expiry, exercise-boundary kinks and unresolved derivatives. An empty constraints list does not imply an unrestricted domain." + }, + { + "id": "black_scholes_merton_qdfp", + "name": "American Black\u2013Scholes\u2013Merton \u2014 QdFp", + "version": "1", + "model_version": "qd_fixed_point/1", + "aliases": [ + "qd_fixed_point" + ], + "underlying_model": "black_scholes_merton", + "method": "qd_fixed_point", + "exercise_styles": [ + "american" + ], + "dividend_support": [ + "continuous_yield" + ], + "operations": [ + "price", + "implied_volatility" + ], + "batch_operations": [], + "greeks": [], + "guidance": "American calls and puts with a continuous dividend yield.", + "constraints": [], + "notes": "Risk v0.43.5 uses optimized fixed-work pricing with absolute error at most 1e-4 * max(spot, strike) against the independent American value, including reference uncertainty. The API accepts price and IV only. Standard-five, all-ten and analytics requests return HTTP 400 unsupported_operation, including Auto resolving to QdFp. Engine derivative implementations are retained but not exposed. IV inverts the served price; its residual is not an exact-American volatility error bound. Numerical values and inverse availability change from v0.42.x despite the unchanged model_version. Engine domains and per-input refusals remain authoritative." + }, + { + "id": "black_scholes_merton_fd", + "name": "Black\u2013Scholes finite difference (cash dividends)", + "version": "1", + "model_version": "cash_dividend_american/1", + "aliases": [ + "cash_dividend_american" + ], + "underlying_model": "black_scholes_merton", + "method": "finite_difference_tr_bdf2", + "exercise_styles": [ + "american" + ], + "dividend_support": [ + "continuous_yield", + "discrete_cash" + ], + "operations": [ + "price", + "implied_volatility" + ], + "batch_operations": [], + "greeks": [], + "guidance": "American options with scheduled cash dividends.", + "constraints": [ + { + "quantity": "spot / strike", + "minimum": 0.8, + "maximum": 1.2, + "description": "Live-contract moneyness." + }, + { + "quantity": "time_to_expiry_years", + "minimum": 0.2493150684931507, + "maximum": 2.0, + "description": "Live-contract expiry in years; exact expiry has separate payoff handling." + }, + { + "quantity": "volatility", + "minimum": 0.15, + "maximum": 0.5, + "description": "Annual lognormal volatility; also the implied-volatility search band." + }, + { + "quantity": "risk_free_rate", + "minimum": 0.0, + "maximum": 0.05, + "description": "Annual continuously compounded rate." + }, + { + "quantity": "dividend_yield", + "minimum": 0.0, + "maximum": 0.02, + "description": "Annual continuous dividend yield, in addition to cash events." + }, + { + "quantity": "live_dividend_count", + "maximum": 2.0, + "description": "Cash events strictly before expiry; the request separately allows at most 16 supplied events." + }, + { + "quantity": "live_dividend_sum / spot", + "maximum": 0.125, + "description": "Sum of cash amounts strictly before expiry divided by spot." + } + ], + "notes": "Live-contract ranges describe the pinned finite-difference solver; expiry price is intrinsic. IV searches the stated volatility band. Events at or after expiry do not affect price. The API accepts price and IV only. Standard-five, all-ten and analytics requests return HTTP 400 unsupported_operation, including Auto resolving to this model, before execution or charge. Engine derivative implementations are retained but not exposed." + }, + { + "id": "bjerksund_stensland_1993", + "name": "Bjerksund\u2013Stensland 1993", + "version": "1", + "model_version": "bjerksund_stensland_1993/1", + "aliases": [], + "underlying_model": "black_scholes_merton", + "method": "bjerksund_stensland_1993_approximation", + "exercise_styles": [ + "american" + ], + "dividend_support": [ + "continuous_yield" + ], + "operations": [ + "price", + "implied_volatility", + "greeks_standard", + "greeks", + "analytics" + ], + "batch_operations": [ + "price", + "implied_volatility", + "greeks_standard", + "greeks", + "analytics" + ], + "greeks": [ + "delta", + "gamma", + "theta", + "vega", + "rho", + "vanna", + "volga", + "charm", + "veta", + "color" + ], + "guidance": "American option pricing using the BS1993 approximation.", + "constraints": [], + "notes": "The engine enforces model-specific domains and accuracy regions. Implemented Greeks may be unavailable for particular inputs, including expiry, exercise-boundary kinks and unresolved derivatives. An empty constraints list does not imply an unrestricted domain." + } + ], + "engines": [ + { + "name": "morphiq-risk", + "version": "0.43.5", + "revision": "43ca7f93b9edda6078b49950de6a8b7d51a3e3ef" + } + ], + "greek_units": { + "charm": "`d\u00b2V/(dS dt)`, **per year**.", + "color": "`d\u00b3V/(dS\u00b2 dt)`, **per year**.", + "delta": "`dV/dS`.", + "gamma": "`d\u00b2V/dS\u00b2`.", + "rho": "`dV/dr`, per unit rate.", + "theta": "`dV/dt`, **per year**.", + "vanna": "`d\u00b2V/(dS d\u03c3)`, per unit volatility.", + "vega": "`dV/d\u03c3`, per unit volatility.", + "veta": "`d\u00b2V/(d\u03c3 dt)`, **per year** per unit volatility.", + "volga": "`d\u00b2V/d\u03c3\u00b2`, per unit volatility squared." + } +} diff --git a/tests/test_analytics.py b/tests/test_analytics.py index 812f4f5..0492ec3 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -147,7 +147,7 @@ def handle(request): assert len(calls) == 1 if failure != "timeout": assert caught.value.metadata.fcu_charged == charge - assert caught.value.request_id == "fixture-analytics-european-premium" + assert caught.value.request_id == analytics_cases[1]["response"]["metadata"]["request_id"] assert "analytics-test-key" not in str(caught.value) + caught.value.body.decode() diff --git a/tests/test_api_v050.py b/tests/test_api_v050.py new file mode 100644 index 0000000..16d0908 --- /dev/null +++ b/tests/test_api_v050.py @@ -0,0 +1,233 @@ +"""Wire parity for discovery, standard five and explicit unavailable Greeks.""" + +import asyncio +import copy +import json +from pathlib import Path + +import httpx +import pytest +from jsonschema import Draft202012Validator +from pydantic import ValidationError + +from morphiq_sdk import ( + AsyncMorphIQ, + BatchGreeksStandardRequest, + BatchGreeksStandardResponse, + Greeks, + GreeksStandard, + GreeksStandardRequest, + GreeksStandardResponse, + ModelConstraint, + MorphIQ, + PriceRequest, + PricingModelCatalog, + PricingModelDescriptor, +) +from morphiq_sdk.client import _quote_payload + +ROOT = Path(__file__).parents[1] +STANDARD = json.loads((ROOT / "tests/fixtures/greeks_standard.json").read_text())["cases"] +CATALOG = json.loads((ROOT / "tests/fixtures/pricing_models.json").read_text()) + + +def invoke(mode, handler, action, key=None): + transport = httpx.MockTransport(handler) + if mode == "sync": + with MorphIQ(api_key=key, transport=transport) as client: + return action(client) + + async def run(): + async with AsyncMorphIQ(api_key=key, transport=transport) as client: + return await action(client) + + return asyncio.run(run()) + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +@pytest.mark.parametrize("key", [None, "private-test-key"]) +def test_discovery_is_public_and_never_sends_credentials(mode, key): + calls = [] + payload = {**CATALOG, "future_catalog_property": {"version": 3}} + + def handle(request): + calls.append(request) + assert request.method == "GET" and request.url.path == "/v1/pricing/models" + assert "Authorization" not in request.headers + assert not request.content + return httpx.Response(200, json=payload) + + response = invoke(mode, handle, lambda c: c.pricing_models(), key) + assert len(calls) == 1 + assert response.model_dump(exclude_unset=True) == payload + assert json.loads(response.raw_body) == payload + assert {m.id for m in response.models} >= {"black_scholes_merton", "black_scholes_merton_fd"} + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +def test_protected_call_without_key_fails_before_transport(mode): + def handle(request): + pytest.fail("Unauthenticated protected call reached transport") + + with pytest.raises(ValueError, match="MORPHIQ_API_KEY"): + invoke(mode, handle, lambda c: c.cost_schedule()) + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +@pytest.mark.parametrize("batch", [False, True]) +def test_standard_single_batch_preserve_wire_values_and_spend_cap(mode, batch): + calls = [] + case = STANDARD[1] + request = GreeksStandardRequest(**case["request"]) + payload = copy.deepcopy(case["response"]) + payload["metadata"]["fcu_charged"] = 2 + if batch: + request = BatchGreeksStandardRequest(requests=[request]) + payload = { + "metadata": payload["metadata"], + "results": [{"index": 0, "status": "success", "result": payload["result"]}], + } + method = "greeks_standard_batch" if batch else "greeks_standard" + + def handle(incoming): + calls.append(incoming) + assert incoming.url.path == case["path"] + (":batch" if batch else "") + assert incoming.headers["x-morphiq-max-cu"] == "5" + assert json.loads(incoming.content) == request.to_wire() + return httpx.Response(200, json=payload) + + response = invoke(mode, handle, lambda c: getattr(c, method)(request, max_cu=5), "test-key") + assert len(calls) == 1 + assert response.model_dump(exclude_unset=True) == payload + assert response.metadata.fcu_charged == 2 + assert _quote_payload(request) == {"operation": method, "request": request.to_wire()} + + +def test_new_wire_models_and_fixture_schema(): + schemas = json.loads((ROOT / "contracts/openapi.json").read_text())["components"]["schemas"] + for model, name in [ + (GreeksStandard, "GreeksStandard"), + (Greeks, "GreeksAllTen"), + (GreeksStandardRequest, "GreeksStandardRequest"), + (GreeksStandardResponse, "GreeksStandardResponse"), + (BatchGreeksStandardRequest, "BatchGreeksStandardRequest"), + (BatchGreeksStandardResponse, "BatchGreeksStandardResponse"), + (PricingModelCatalog, "PricingModelCatalog"), + (PricingModelDescriptor, "PricingModelDescriptor"), + (ModelConstraint, "ModelConstraint"), + ]: + assert set(model.model_fields) == set(schemas[name]["properties"]) + for name, payload in [("PricingModelCatalog", CATALOG)] + [ + ("GreeksStandardResponse", c["response"]) for c in STANDARD + ]: + Draft202012Validator( + {"$ref": "#/components/schemas/" + name, "components": {"schemas": schemas}} + ).validate(payload) + + +@pytest.mark.parametrize("model", [GreeksStandard, Greeks]) +def test_unavailable_quantities_are_distinct_from_real_zero(model): + values = {name: 0.0 for name in model.model_fields if name != "unavailable"} + values.pop("rho") + values["unavailable"] = {"rho": {"code": "unsupported_quantity", "message": "Not available"}} + result = model.model_validate(values) + assert result.rho is None and result.delta == 0.0 + assert result.unavailable["rho"].code == "unsupported_quantity" + assert result.model_dump(exclude_unset=True) == values + for invalid in [ + {**values, "rho": None}, + {**values, "rho": 0.0}, + {**values, "unavailable": {}}, + ]: + with pytest.raises(ValidationError): + model.model_validate(invalid) + + +@pytest.mark.parametrize( + "selector", + [ + "black_scholes_merton_qdfp", + "qd_fixed_point", + "black_scholes_merton_fd", + "cash_dividend_american", + "bjerksund_stensland_2002", + ], +) +def test_selectors_are_forwarded_without_client_model_substitution(selector): + request = PriceRequest(**{**STANDARD[1]["request"], "pricing_model": selector}) + assert request.to_wire()["pricing_model"] == selector + + +def test_standard_batch_ceiling_and_partial_greeks_dataframe(): + from morphiq_sdk.pandas import batch_to_frames + + request = GreeksStandardRequest(**STANDARD[0]["request"]) + assert len(BatchGreeksStandardRequest(requests=[request] * 100).requests) == 100 + with pytest.raises(ValidationError): + BatchGreeksStandardRequest(requests=[request] * 101) + partial = copy.deepcopy(STANDARD[0]["response"]["result"]) + partial["greeks"].pop("rho") + partial["greeks"]["delta"] = 0.0 + partial["greeks"]["unavailable"] = {"rho": {"code": "unavailable", "message": "No result"}} + response = BatchGreeksStandardResponse.model_validate( + { + "metadata": STANDARD[0]["response"]["metadata"], + "results": [ + {"index": 0, "status": "success", "result": partial}, + {"index": 1, "status": "success", "result": STANDARD[0]["response"]["result"]}, + ], + } + ) + frames = batch_to_frames(response) + import pandas as pd + + assert pd.isna(frames.results.loc[0, "result.greeks.rho"]) + assert frames.results.loc[0, "result.greeks.delta"] == 0.0 + assert frames.results.loc[0, "result.greeks.unavailable"]["rho"]["code"] == "unavailable" + assert "rho" not in frames.results.loc[0, "outcome"]["result"]["greeks"] + + +@pytest.mark.parametrize( + "filename,button,result", + [ + ("pricing/models.ipynb", "load_button", "last_catalog"), + ("pricing/standard-five.ipynb", "calculate_button", "last_result"), + ], +) +def test_new_notebook_setup_and_changes_are_inert_actions_are_explicit( + filename, button, result, monkeypatch +): + import morphiq_sdk as sdk + + seen = [] + + def handle(request): + seen.append(request) + if filename.endswith("models.ipynb"): + assert request.url.path == "/v1/pricing/models" + assert "Authorization" not in request.headers + return httpx.Response(200, json=CATALOG) + assert request.url.path == "/v1/greeks/standard" + expected = {**STANDARD[0]["request"], "pricing_model": "black_scholes_merton"} + assert json.loads(request.content) == expected + return httpx.Response(200, json=STANDARD[0]["response"]) + + monkeypatch.setenv("MORPHIQ_API_KEY", "notebook-test-key") + original = sdk.MorphIQ + monkeypatch.setattr( + sdk, "MorphIQ", lambda **kwargs: original(**kwargs, transport=httpx.MockTransport(handle)) + ) + scope = {} + notebook = json.loads((ROOT / "notebooks" / filename).read_text()) + for cell in notebook["cells"]: + if cell["cell_type"] == "code": + assert not cell["outputs"] and cell["execution_count"] is None + exec("".join(cell["source"]), scope) + assert not seen + if "spot" in scope: + scope["spot"].value = 105 + scope["spot"].value = 100 + assert not seen + scope[button].click() + assert len(seen) == 1 and scope[result] is not None + assert not scope[button].disabled diff --git a/tests/test_batch.py b/tests/test_batch.py index cfb9309..31a694d 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -90,7 +90,9 @@ def handle(request): assert len(calls) == 1 assert result.model_dump(exclude_unset=True) == case["response"] assert json.loads(result.raw_body) == case["response"] - assert [x.status for x in result.results] == ["success", "error", "success"] + assert [x.status for x in result.results] == ( + ["success", "error", "success"] if index < 2 else ["success"] * 3 + ) assert [x.index for x in result.results] == [0, 1, 2] assert result.metadata.fcu_charged == 2**53 + 1 @@ -102,8 +104,8 @@ def test_batch_inputs_revalidate_edits_without_plan_assumptions(index, batch_cas with pytest.raises(ValidationError): cls(requests=[]) with pytest.raises(ValidationError): - cls(requests=[values[0]] * 1025) - assert len(cls(requests=[values[0]] * 1024).to_wire()["requests"]) == 1024 + cls(requests=[values[0]] * 101) + assert len(cls(requests=[values[0]] * 100).to_wire()["requests"]) == 100 request = cls(requests=values) request.requests[0].dividend_schedule.append( {"ex_time_years": float("nan"), "cash_amount": 1.0} diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py index 1bfd25b..e13c14c 100644 --- a/tests/test_concurrency.py +++ b/tests/test_concurrency.py @@ -19,7 +19,13 @@ @pytest.mark.parametrize("operation", range(4)) def test_out_of_order_completions_keep_indices_and_exact_charges(operation, batch_cases): factory, request_type = OPERATIONS[operation] - case = batch_cases[operation] + case = copy.deepcopy(batch_cases[operation]) + # Explicit synthetic item error; native IV validation now rejects the whole request. + case["response"]["results"][1] = { + "index": 1, + "status": "error", + "error": {"code": "pricing_failed", "message": "Synthetic failure"}, + } requests = ( request_type(**case["request"]).requests * 5 + request_type(**case["request"]).requests[:1] ) diff --git a/tests/test_cost.py b/tests/test_cost.py new file mode 100644 index 0000000..4e419d9 --- /dev/null +++ b/tests/test_cost.py @@ -0,0 +1,321 @@ +import asyncio +import json +from pathlib import Path + +import httpx +import pytest +from jsonschema import Draft202012Validator + +import morphiq_sdk as sdk + +ROOT = Path(__file__).resolve().parents[1] +METADATA = { + "request_id": "cost-fixture", + "api_version": "v1", + "service_version": "0.5.0", + "fcu_charged": 0, +} +QUOTE = { + "quote": { + "total_fcu": 1, + "fixed_fcu": 0, + "variable_fcu": 1, + "minimum_charge_fcu": 0, + "lines": [ + { + "operation": "price", + "model": "black_scholes_merton/1", + "billing_unit": "contract", + "quantity": 1, + "fcu_per_unit": 1, + "variable_fcu": 1, + } + ], + }, + "resolved_models": [{"model": "black_scholes_merton/1", "quantity": 1}], + "metadata": METADATA, +} +SCHEDULE = { + "schedule": { + "schedule_id": "test", + "schedule_version": "1", + "record_version": 1, + "policy_revision": "a" * 64, + "effective_from": "2026-10-19T00:00:00Z", + "effective_until": None, + "fixed_request_fcu": 0, + "minimum_charge_fcu": 0, + "rounding": "none", + "failure_charging": "commit_at_execution_start", + "rates": [ + { + "operation": "price", + "model": "black_scholes_merton/1", + "billing_unit": "contract", + "fcu_per_unit": 1, + "max_discrete_dividends": 0, + } + ], + }, + "metadata": METADATA, +} + + +def cases(): + for file, model, operation in [ + ("pricing", sdk.PriceRequest, "price"), + ("regime", sdk.RegimeRequest, "regime"), + ("greeks", sdk.GreeksRequest, "greeks"), + ("greeks_standard", sdk.GreeksStandardRequest, "greeks_standard"), + ("implied_volatility", sdk.ImpliedVolatilityRequest, "implied_volatility"), + ("analytics", sdk.AnalyticsRequest, "analytics"), + ]: + case = json.loads((ROOT / f"tests/fixtures/{file}.json").read_text())["cases"][0] + yield model.model_validate(case["request"]), operation, case + batches = json.loads((ROOT / "tests/fixtures/batch.json").read_text())["cases"] + for index, (model, operation) in enumerate( + [ + (sdk.BatchPriceRequest, "price_batch"), + (sdk.BatchGreeksRequest, "greeks_batch"), + (sdk.BatchImpliedVolatilityRequest, "implied_volatility_batch"), + (sdk.BatchAnalyticsRequest, "analytics_batch"), + ] + ): + yield model.model_validate(batches[index]["request"]), operation, batches[index] + + case = json.loads((ROOT / "tests/fixtures/greeks_standard.json").read_text())["cases"][0] + request = sdk.BatchGreeksStandardRequest(requests=[case["request"]]) + yield ( + request, + "greeks_standard_batch", + { + "path": "/v1/greeks/standard:batch", + "request": request.to_wire(), + "response": { + "metadata": case["response"]["metadata"], + "results": [ + {"index": 0, "status": "success", "result": case["response"]["result"]} + ], + }, + }, + ) + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +@pytest.mark.parametrize("input_request,operation,case", list(cases())) +def test_quote_uses_the_execution_body_and_ceiling_is_per_call( + mode, input_request, operation, case +): + seen = [] + + def handler(http_request): + assert http_request.headers["Authorization"] == "Bearer synthetic-test-key" + seen.append(http_request) + if http_request.url.path == "/v1/cost/quote": + assert json.loads(http_request.content) == { + "operation": operation, + "request": input_request.to_wire(), + } + assert "x-morphiq-max-cu" not in http_request.headers + return httpx.Response(200, json=QUOTE) + assert http_request.url.path == case.get("path", "/v1/price") + assert json.loads(http_request.content) == input_request.to_wire() + assert http_request.headers["x-morphiq-max-cu"] == "1" + return httpx.Response(200, json=case["response"]) + + settings = {"api_key": "synthetic-test-key", "transport": httpx.MockTransport(handler)} + if mode == "sync": + with sdk.MorphIQ(**settings) as client: + quote = client.quote(input_request) + getattr(client, operation)(input_request, max_cu=quote.quote.total_fcu) + else: + + async def run(): + async with sdk.AsyncMorphIQ(**settings) as client: + quote = await client.quote(input_request) + await getattr(client, operation)(input_request, max_cu=quote.quote.total_fcu) + + asyncio.run(run()) + assert len(seen) == 2 + + +@pytest.mark.parametrize("mode", ["sync", "async"]) +def test_schedule_filter_encoding_and_evidence(mode): + def handler(request): + assert request.method == "GET" and not request.content + assert dict(request.url.params) == {"operation": "price", "model": "black_scholes_merton/1"} + return httpx.Response(200, json=SCHEDULE, headers={"x-request-id": "cost-fixture"}) + + settings = {"api_key": "synthetic-test-key", "transport": httpx.MockTransport(handler)} + if mode == "sync": + with sdk.MorphIQ(**settings) as client: + result = client.cost_schedule(operation="price", model="black_scholes_merton/1") + else: + + async def run(): + async with sdk.AsyncMorphIQ(**settings) as client: + return await client.cost_schedule(operation="price", model="black_scholes_merton/1") + + result = asyncio.run(run()) + assert result.metadata.fcu_charged == 0 + assert result.schedule.rates[0].fcu_per_unit == 1 + assert json.loads(result.raw_body) == SCHEDULE + assert result.headers["x-request-id"] == "cost-fixture" + + +@pytest.mark.parametrize("maximum", [-1, True, 1.5, "1", 2**64]) +def test_invalid_ceiling_sends_nothing(maximum): + def handler(_): + pytest.fail("invalid ceiling reached transport") + + with sdk.MorphIQ( + api_key="synthetic-test-key", transport=httpx.MockTransport(handler) + ) as client: + with pytest.raises(ValueError, match="max_cu"): + client.price(next(cases())[0], max_cu=maximum) + + +def test_quote_timeout_is_non_billable_and_not_retried(): + seen = [] + + def handler(request): + seen.append(request) + raise httpx.ReadTimeout("fixture") + + with sdk.MorphIQ( + api_key="synthetic-test-key", transport=httpx.MockTransport(handler) + ) as client: + with pytest.raises(sdk.RequestTimeout, match="no calculation or CU charge"): + client.quote(next(cases())[0]) + assert len(seen) == 1 + + +def test_cost_wire_contract(): + schema = json.loads((ROOT / "contracts/openapi.json").read_text()) + for name, value in [("CostScheduleResponse", SCHEDULE), ("CostQuoteResponse", QUOTE)]: + Draft202012Validator( + {"$ref": f"#/components/schemas/{name}", "components": schema["components"]} + ).validate(value) + validator = Draft202012Validator( + {"$ref": "#/components/schemas/CostQuoteRequest", "components": schema["components"]} + ) + for request, operation, _ in cases(): + validator.validate({"operation": operation, "request": request.to_wire()}) + + +def test_cost_notebook_actions_are_explicit_and_ceiling_is_forwarded(monkeypatch): + """Execute setup and real button callbacks against a local mock transport.""" + notebook = json.loads((ROOT / "notebooks/cost/quotation.ipynb").read_text()) + recorded = [] + price_case = next(cases())[2] + + def handler(request): + recorded.append(request) + if request.url.path == "/v1/cost/schedule": + return httpx.Response(200, json=SCHEDULE) + if request.url.path == "/v1/cost/quote": + return httpx.Response(200, json=QUOTE) + assert request.headers["x-morphiq-max-cu"] == "1" + return httpx.Response(200, json=price_case["response"]) + + original = sdk.MorphIQ + monkeypatch.setenv("MORPHIQ_API_KEY", "synthetic-test-key") + monkeypatch.setattr( + sdk, "MorphIQ", lambda **kwargs: original(**kwargs, transport=httpx.MockTransport(handler)) + ) + scope = {} + for cell in notebook["cells"]: + if cell["cell_type"] == "code": + assert cell["outputs"] == [] and cell["execution_count"] is None + source = cell["source"] + exec("".join(source) if isinstance(source, list) else source, scope) + assert not recorded + scope["spot"].value = 105.0 + assert not recorded and scope["execute_button"].disabled + scope["spot"].value = 100.0 + scope["schedule_button"].click() + scope["quote_button"].click() + assert len(recorded) == 2 and scope["last_quote"] is not None + scope["execute_button"].click() + assert len(recorded) == 3 and scope["last_result"] is not None + scope["strike"].value = 101.0 + scope["execute_request"](None) + assert len(recorded) == 3 and scope["execute_button"].disabled + + +@pytest.mark.parametrize( + "arguments,quantity,execute,ceiling", + [ + ([], 1, False, None), + (["--batch"], 2, False, None), + (["--execute"], 1, True, 1), + (["--batch", "--execute"], 2, True, 2), + (["--batch", "--execute", "--max-cu", "1"], 2, True, 1), + ], +) +def test_cost_example_requests_and_refusal_evidence( + monkeypatch, capsys, arguments, quantity, execute, ceiling +): + import copy + import runpy + import sys + + recorded = [] + price_case = next(cases())[2] + quoted = copy.deepcopy(QUOTE) + quoted["quote"].update(total_fcu=quantity, variable_fcu=quantity) + quoted["quote"]["lines"][0].update(quantity=quantity, variable_fcu=quantity) + quoted["resolved_models"][0]["quantity"] = quantity + + def handler(request): + recorded.append(request) + if request.url.path == "/v1/cost/schedule": + return httpx.Response(200, json=SCHEDULE) + body = json.loads(request.content) + if request.url.path == "/v1/cost/quote": + assert body["operation"] == ("price_batch" if quantity == 2 else "price") + return httpx.Response(200, json=quoted) + assert request.headers["x-morphiq-max-cu"] == str(ceiling) + assert body == json.loads(recorded[1].content)["request"] + if quantity == 2: + assert request.url.path == "/v1/price:batch" and len(body["requests"]) == 2 + else: + assert request.url.path == "/v1/price" + if ceiling < quantity: + return httpx.Response( + 409, + json={ + "metadata": {**METADATA, "fcu_quoted": quantity}, + "error": {"code": "cost_limit_exceeded", "message": "ceiling exceeded"}, + }, + ) + response = price_case["response"] + if quantity == 2: + response = { + "metadata": response["metadata"], + "results": [ + {"index": index, "status": "success", "result": response["result"]} + for index in range(quantity) + ], + } + return httpx.Response(200, json=response) + + original = sdk.MorphIQ + monkeypatch.setenv("MORPHIQ_API_KEY", "synthetic-test-key") + monkeypatch.setattr(sdk, "MorphIQ", lambda: original(transport=httpx.MockTransport(handler))) + monkeypatch.setattr(sys, "argv", ["cost_quote.py", *arguments]) + if execute and ceiling < quantity: + with pytest.raises(SystemExit) as failure: + runpy.run_path(str(ROOT / "examples/cost_quote.py"), run_name="__main__") + assert failure.value.code == 1 + else: + runpy.run_path(str(ROOT / "examples/cost_quote.py"), run_name="__main__") + assert len(recorded) == (3 if execute else 2) + output = capsys.readouterr() + assert "synthetic-test-key" not in output.out + output.err + assert f"Prospective execution CU: {quantity}" in output.out + assert "Utility CU charged: 0" in output.out + if execute and ceiling < quantity: + assert "409: cost_limit_exceeded" in output.err + assert "Execution quote CU: 2" in output.err and "CU charged: 0" in output.err + assert "No retry or automatic ceiling increase." in output.err diff --git a/tests/test_examples.py b/tests/test_examples.py index 3af0e3d..eb2feb8 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -25,7 +25,7 @@ def test_examples_import_without_credentials_or_requests(): [ sys.executable, "-c", - "import examples.price_european, examples.price_american, " + "import examples.cost_quote, examples.price_european, examples.price_american, " "examples.price_async, examples.quickstart, " "examples.identify_regime, examples.identify_regime_async, " "examples.greeks, examples.greeks_async, " diff --git a/tests/test_integration.py b/tests/test_integration.py index f546d9a..2f5852b 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -43,7 +43,11 @@ async def run_async(request): sync = client.price(request) asynchronous = asyncio.run(run_async(request)) raw = httpx.post(f"{ENDPOINT}/v1/price", json=request.to_wire()).json() - assert sync.result.model_dump() == asynchronous.result.model_dump() == raw["result"] + assert ( + sync.result.model_dump(exclude_unset=True) + == asynchronous.result.model_dump(exclude_unset=True) + == raw["result"] + ) assert sync.result.price == pytest.approx( case["response"]["result"]["price"], abs=1e-12 ) @@ -72,8 +76,12 @@ async def run_async(request): sync = client.greeks(request) asynchronous = asyncio.run(run_async(request)) raw = httpx.post(f"{ENDPOINT}/v1/greeks", json=request.to_wire()).json() - assert sync.result.model_dump() == asynchronous.result.model_dump() == raw["result"] - assert sync.result.model_dump() == case["response"]["result"] + assert ( + sync.result.model_dump(exclude_unset=True) + == asynchronous.result.model_dump(exclude_unset=True) + == raw["result"] + ) + assert sync.result.model_dump(exclude_unset=True) == case["response"]["result"] assert sync.metadata.fcu_charged is None request = GreeksRequest(**{**greeks_cases[0]["request"], "spot": -1}) with pytest.raises(APIError) as error: @@ -128,12 +136,16 @@ async def run_async(request): sync = client.implied_volatility(request) asynchronous = asyncio.run(run_async(request)) raw = httpx.post(f"{ENDPOINT}/v1/implied-volatility", json=request.to_wire()).json() - assert sync.result.model_dump() == asynchronous.result.model_dump() == raw["result"] - assert sync.result.model_dump() == case["response"]["result"] + assert ( + sync.result.model_dump(exclude_unset=True) + == asynchronous.result.model_dump(exclude_unset=True) + == raw["result"] + ) + assert sync.result.model_dump(exclude_unset=True) == case["response"]["result"] assert sync.result.implied_volatility == pytest.approx(0.2, abs=1e-10) assert sync.result.residual_basis in ("forward_normalised_price", "option_price") assert sync.metadata.fcu_charged is None - for premium, status in [(-1, 422), (1000, 500)]: + for premium, status in [(-1, 422), (1000, 422)]: request = ImpliedVolatilityRequest( **{**iv_cases[1]["request"], "market_price": premium} ) @@ -141,8 +153,8 @@ async def run_async(request): client.implied_volatility(request) assert error.value.status_code == status and error.value.request_id if premium == 1000: - # The pinned service reports an unbracketable American solve as an engine error. - assert error.value.code == "pricing_failed" + # Out-of-bounds premiums fail request validation before numerical execution. + assert error.value.code == "invalid_pricing_request" def test_native_regime_sync_async_and_wire_response(regime_cases): @@ -202,7 +214,9 @@ async def run(): assert [item.model_dump(exclude_unset=True) for item in sync.results] == raw["results"] assert [item.model_dump(exclude_unset=True) for item in asynchronous.results] == raw["results"] assert raw["results"] == case["response"]["results"] - assert [item.status for item in sync.results] == ["success", "error", "success"] + assert [item.status for item in sync.results] == ( + ["success", "error", "success"] if index < 2 else ["success"] * 3 + ) assert sync.metadata.fcu_charged is None @@ -243,3 +257,65 @@ async def run(): assert [item.model_dump(exclude_unset=True) for item in attempt.response.results] == ( case["response"]["results"] ) + + +def test_native_discovery_standard_and_model_policy(cases): + from morphiq_sdk import BatchGreeksStandardRequest, BatchPriceRequest, GreeksStandardRequest + + with MorphIQ(base_url=ENDPOINT, allow_unauthenticated=True) as client: + catalog = client.pricing_models() + assert ( + catalog.model_dump(exclude_unset=True) + == httpx.get(f"{ENDPOINT}/v1/pricing/models").json() + ) + request = GreeksStandardRequest(**cases[0]["request"]) + response = client.greeks_standard(request) + raw = httpx.post(f"{ENDPOINT}/v1/greeks/standard", json=request.to_wire()).json() + assert response.result.model_dump(exclude_unset=True) == raw["result"] + batch = client.greeks_standard_batch(BatchGreeksStandardRequest(requests=[request] * 100)) + assert len(batch.results) == 100 + assert all(x.result.model_dump(exclude_unset=True) == raw["result"] for x in batch.results) + workflow = client.greeks_standard_workflow([request] * 3, chunk_size=2) + workflow.run() + assert [(a.start, a.stop) for a in workflow.attempts] == [(0, 2), (2, 3)] + for selector in ["black_scholes_merton_qdfp", "black_scholes_merton_fd"]: + values = {**cases[1]["request"], "pricing_model": selector} + price = client.price(PriceRequest(**values)) + iv_request = {k: v for k, v in values.items() if k != "volatility"} + iv = client.implied_volatility( + ImpliedVolatilityRequest(**iv_request, market_price=price.result.price) + ) + assert iv.result.implied_volatility == pytest.approx(0.2, abs=1e-6) + for method, model in [ + ("greeks", GreeksRequest), + ("greeks_standard", GreeksStandardRequest), + ("analytics", AnalyticsRequest), + ]: + with pytest.raises(APIError) as error: + getattr(client, method)(model(**values)) + assert error.value.status_code == 400 + assert error.value.code == "unsupported_operation" + with pytest.raises(APIError) as error: + client.price_batch(BatchPriceRequest(requests=[PriceRequest(**values)])) + assert error.value.status_code == 400 + with pytest.raises(APIError) as retired: + client.price( + PriceRequest(**{**cases[1]["request"], "pricing_model": "bjerksund_stensland_2002"}) + ) + assert retired.value.status_code == 422 + assert retired.value.code == "invalid_field" + assert "retired" in retired.value.message + + async def run(): + async with AsyncMorphIQ(base_url=ENDPOINT, allow_unauthenticated=True) as client: + assert (await client.pricing_models()).model_dump() == catalog.model_dump() + assert ( + await client.greeks_standard(request) + ).result.model_dump() == response.result.model_dump() + workflow = client.greeks_standard_workflow( + [request] * 3, chunk_size=2, max_concurrency=2 + ) + await workflow.run() + assert workflow.state == "completed" and len(workflow.attempts) == 2 + + asyncio.run(run()) diff --git a/tests/test_pandas.py b/tests/test_pandas.py index 018bdd6..44ff4a0 100644 --- a/tests/test_pandas.py +++ b/tests/test_pandas.py @@ -33,6 +33,12 @@ @pytest.mark.parametrize("charge", [None, 0, 2**53 + 1, 2**100]) def test_batch_tables_preserve_lossless_rows_and_one_exact_charge(operation, charge, batch_cases): body = copy.deepcopy(batch_cases[operation]["response"]) + # Synthetic per-item failure to exercise lossless mixed outcomes. + body["results"][1] = { + "index": 1, + "status": "error", + "error": {"code": "pricing_failed", "message": "Synthetic failure"}, + } body["metadata"].update(fcu_charged=charge, fcu_quoted=999999) body["future_root"] = {"version": 2**100} # Future keys, even dotted names, must survive without colliding with typed columns. @@ -76,6 +82,12 @@ def test_batch_tables_preserve_lossless_rows_and_one_exact_charge(operation, cha def test_integer_and_boolean_diagnostics_and_float_units_are_unchanged(batch_cases): body = copy.deepcopy(batch_cases[2]["response"]) + # Synthetic per-item failure to exercise lossless mixed outcomes. + body["results"][1] = { + "index": 1, + "status": "error", + "error": {"code": "pricing_failed", "message": "Synthetic failure"}, + } body["results"][0]["result"].update(iterations=2**100, used_fallback=False, residual=-0.0) frames = batch_to_frames(BatchImpliedVolatilityResponse.model_validate(body)) assert frames.results["result.iterations"].dtype == object @@ -94,7 +106,7 @@ def test_integer_and_boolean_diagnostics_and_float_units_are_unchanged(batch_cas ]: result = cls.model_validate(case["response"]) frame = batch_to_frames(result).results - for name, value in result.results[0].result.greeks.model_dump().items(): + for name, value in result.results[0].result.greeks.model_dump(exclude_unset=True).items(): assert frame.loc[0, f"result.greeks.{name}"] == value diff --git a/tests/test_pricing.py b/tests/test_pricing.py index 507acbd..5c493be 100644 --- a/tests/test_pricing.py +++ b/tests/test_pricing.py @@ -319,7 +319,8 @@ def test_environment_configuration_and_representations(monkeypatch): assert "explicit-key" not in repr(client) monkeypatch.delenv("MORPHIQ_API_KEY") with pytest.raises(ValueError, match="MORPHIQ_API_KEY"): - MorphIQ() + with MorphIQ() as client: + client.cost_schedule() with MorphIQ(allow_unauthenticated=True): pass diff --git a/tests/test_workflows.py b/tests/test_workflows.py index c906efe..dd2401c 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -83,7 +83,13 @@ def test_sequential_chunks_keep_outcomes_indices_wire_and_exact_charge( mode, operation, batch_cases ): factory, request_type = OPERATIONS[operation] - case = batch_cases[operation] + case = copy.deepcopy(batch_cases[operation]) + # Explicit synthetic item error; native IV validation now rejects the whole request. + case["response"]["results"][1] = { + "index": 1, + "status": "error", + "error": {"code": "pricing_failed", "message": "Synthetic failure"}, + } requests = ( request_type(**case["request"]).requests * 2 + request_type(**case["request"]).requests[:1] ) @@ -239,10 +245,10 @@ def test_validate_entire_plan_before_network_and_snapshot_inputs(operation, batc factory, request_type = OPERATIONS[operation] requests = request_type(**batch_cases[operation]["request"]).requests with MorphIQ(api_key="workflow-test") as client: - for size in [0, -1, True, 1.5, 1025]: + for size in [0, -1, True, 1.5, 101]: with pytest.raises(ValueError): getattr(client, factory)(requests, chunk_size=size) - assert getattr(client, factory)(requests, chunk_size=1024).planned_chunk_count == 1 + assert getattr(client, factory)(requests, chunk_size=100).planned_chunk_count == 1 # Invalid later chunk fails preparation; no earlier request has been made. requests[-1].dividend_schedule.append({"ex_time_years": float("nan"), "cash_amount": 1}) with pytest.raises(ValidationError):