From 3735afca3f7dfa64d7cd482f2522f8ddee511bc6 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Mon, 25 May 2026 22:33:14 -0700 Subject: [PATCH] backport OptionsChainsData serialize schema fix from v5 branch (#7511) --- .../standard_models/options_chains.py | 62 +++++++++++++++- .../standard_models/test_options_chains.py | 73 +++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 openbb_platform/core/tests/provider/standard_models/test_options_chains.py diff --git a/openbb_platform/core/openbb_core/provider/standard_models/options_chains.py b/openbb_platform/core/openbb_core/provider/standard_models/options_chains.py index 839c89b7cc3..a34578df4e4 100644 --- a/openbb_platform/core/openbb_core/provider/standard_models/options_chains.py +++ b/openbb_platform/core/openbb_core/provider/standard_models/options_chains.py @@ -4,6 +4,16 @@ from datetime import ( date as dateType, datetime, ) +from typing import Any, get_args, get_origin + +from pydantic import ( + Field, + GetJsonSchemaHandler, + create_model, + field_validator, + model_serializer, +) +from pydantic.json_schema import JsonSchemaValue from openbb_core.provider.abstract.query_params import QueryParams from openbb_core.provider.utils.descriptions import ( @@ -11,7 +21,6 @@ from openbb_core.provider.utils.descriptions import ( QUERY_DESCRIPTIONS, ) from openbb_core.provider.utils.options_chains_properties import OptionsChainsProperties -from pydantic import Field, field_validator, model_serializer class OptionsChainsQueryParams(QueryParams): @@ -369,3 +378,54 @@ class OptionsChainsData(OptionsChainsProperties): records = [dict(zip(data.keys(), values)) for values in zip(*data.values())] return records + + @classmethod + def __get_pydantic_json_schema__( + cls, + core_schema: Any, + handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + """Return a JSON schema that matches serialized row records.""" + _ = (core_schema, handler) + + row_fields: dict[str, Any] = {} + for field_name, field_info in cls.model_fields.items(): + annotation = field_info.annotation + inner = ( + get_args(annotation)[0] + if get_origin(annotation) is list + else annotation + ) + + if inner is datetime or (get_origin(inner) is None and inner is dateType): + inner = str | None + + row_fields[field_name] = ( + ( + inner | None + if get_origin(inner) is None and inner is not type(None) + else inner + ), + Field( + default=None, + description=field_info.description, + json_schema_extra=field_info.json_schema_extra, + ), + ) + + row_model = create_model(f"{cls.__name__}Item", **row_fields) # type: ignore[call-overload] + items_schema = row_model.model_json_schema( + ref_template="#/components/schemas/{model}", + ) + defs = items_schema.pop("$defs", None) + + schema: JsonSchemaValue = { + "type": "array", + "description": (cls.__doc__ or "").split("\n\n", maxsplit=1)[0].strip(), + "items": items_schema, + "title": cls.__name__, + } + if defs: + schema["$defs"] = defs + + return schema diff --git a/openbb_platform/core/tests/provider/standard_models/test_options_chains.py b/openbb_platform/core/tests/provider/standard_models/test_options_chains.py new file mode 100644 index 00000000000..2622c460c74 --- /dev/null +++ b/openbb_platform/core/tests/provider/standard_models/test_options_chains.py @@ -0,0 +1,73 @@ +"""Tests for options chains serialization and schema shape.""" + +from datetime import date, datetime + +from fastapi import FastAPI + +from openbb_core.app.model.obbject import OBBject +from openbb_core.provider.standard_models.options_chains import OptionsChainsData + + +def _sample_payload() -> dict: + return { + "underlying_symbol": ["FAKE", "FAKE"], + "underlying_price": [100.0, 100.0], + "contract_symbol": ["FAKE240117C00100000", "FAKE240117P00100000"], + "expiration": [date(2030, 1, 17), date(2030, 1, 17)], + "strike": [100.0, 100.0], + "option_type": ["call", "put"], + "open_interest": [10, 5], + "volume": [100, 50], + "bid": [1.5, 1.4], + "ask": [1.6, 1.5], + "last_trade_time": [datetime(2026, 1, 1, 15, 30), datetime(2026, 1, 1, 15, 31)], + } + + +def test_options_chains_model_dump_is_row_records() -> None: + data = OptionsChainsData(**_sample_payload()) + dumped = data.model_dump() + + assert isinstance(dumped, list) + assert len(dumped) == 2 + assert dumped[0]["option_type"] == "call" + assert dumped[1]["option_type"] == "put" + assert isinstance(dumped[0]["last_trade_time"], str) + + +def test_options_chains_json_schema_is_array_of_rows() -> None: + schema = OptionsChainsData.model_json_schema() + + assert schema["type"] == "array" + assert schema["items"]["type"] == "object" + + strike_types = { + entry.get("type") for entry in schema["items"]["properties"]["strike"]["anyOf"] + } + assert "number" in strike_types + assert "null" in strike_types + + +def test_fastapi_openapi_results_references_row_schema() -> None: + app = FastAPI() + + @app.get("/chains") + def chains() -> OBBject[OptionsChainsData]: + return OBBject(results=[]) + + openapi = app.openapi() + components = openapi["components"]["schemas"] + + assert components["OptionsChainsData"]["type"] == "array" + + route_schema = openapi["paths"]["/chains"]["get"]["responses"]["200"]["content"][ + "application/json" + ]["schema"] + wrapper_name = route_schema["$ref"].rsplit("/", 1)[-1] + results_schema = components[wrapper_name]["properties"]["results"] + refs = [ + entry.get("$ref") + for entry in results_schema.get("anyOf", [results_schema]) + if entry.get("$ref") + ] + assert any(ref.endswith("OptionsChainsData") for ref in refs)