Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from datetime import datetime

from openbb_core.provider.abstract.data import Data
from openbb_core.provider.abstract.query_params import QueryParams
from pydantic import Field


class MarketStateQueryParams(QueryParams):
exchange: str = Field(description="Exchange MIC, acronym, or name to check market state for.")


class MarketStateData(Data):
exchange: str = Field(description="Normalized exchange acronym.")
mic: str = Field(description="ISO 10383 MIC code for the exchange.")
status: str = Field(description="Raw market-state status returned by the provider.")
is_open: bool = Field(description="Fail-closed market state. Only `OPEN` evaluates to `True`.")
issued_at: datetime | None = Field(default=None, description="Receipt issue timestamp.")
expires_at: datetime | None = Field(default=None, description="Receipt expiry timestamp.")
ttl_seconds: int | None = Field(
default=None,
description="Receipt time-to-live in seconds when both timestamps are available.",
)
issuer: str | None = Field(default=None, description="Receipt issuer.")
source: str | None = Field(default=None, description="Market-state source.")
halt_detection: str | None = Field(default=None, description="Halt detection mode reported by the provider.")
receipt_mode: str | None = Field(default=None, description="Receipt mode reported by the provider.")
schema_version: str | None = Field(default=None, description="Provider receipt schema version.")
public_key_id: str | None = Field(default=None, description="Identifier for the signing public key.")
signature: str | None = Field(default=None, description="Signature attached to the receipt payload.")
25 changes: 8 additions & 17 deletions openbb_platform/dev_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
openbb-famafrench = { path = "./providers/famafrench", optional = true, develop = true }
openbb-finra = { path = "./providers/finra", optional = true, develop = true }
openbb-finviz = { path = "./providers/finviz", optional = true, develop = true }
openbb-headless-oracle = { path = "./providers/headless_oracle", optional = true, develop = true }
openbb-multpl = { path = "./providers/multpl", optional = true, develop = true }
openbb-nasdaq = { path = "./providers/nasdaq", optional = true, develop = true }
openbb-seeking-alpha = { path = "./providers/seeking_alpha", optional = true, develop = true }
Expand Down Expand Up @@ -91,11 +92,7 @@ def extract_dependencies(local_dep_path, dev: bool = False):
.get("dev", {})
.get("dependencies", {})
)
return (
package_pyproject_toml.get("tool", {})
.get("poetry", {})
.get("dependencies", {})
)
return package_pyproject_toml.get("tool", {}).get("poetry", {}).get("dependencies", {})
return {}


Expand All @@ -118,18 +115,14 @@ def install_platform_local(_extras: bool = False):
local_deps = loads(LOCAL_DEPS).get("tool", {}).get("poetry", {})["dependencies"]
with open(PYPROJECT) as f:
pyproject_toml = load(f)
pyproject_toml.get("tool", {}).get("poetry", {}).get("dependencies", {}).update(
local_deps
)
pyproject_toml.get("tool", {}).get("poetry", {}).get("dependencies", {}).update(local_deps)

if _extras:
dev_dependencies = get_all_dev_dependencies()
pyproject_toml.get("tool", {}).get("poetry", {}).setdefault(
"group", {}
).setdefault("dev", {}).setdefault("dependencies", {})
pyproject_toml.get("tool", {}).get("poetry", {})["group"]["dev"][
"dependencies"
].update(dev_dependencies)
pyproject_toml.get("tool", {}).get("poetry", {}).setdefault("group", {}).setdefault("dev", {}).setdefault(
"dependencies", {}
)
pyproject_toml.get("tool", {}).get("poetry", {})["group"]["dev"]["dependencies"].update(dev_dependencies)

TEMP_PYPROJECT = dumps(pyproject_toml)

Expand Down Expand Up @@ -173,9 +166,7 @@ def install_platform_cli():
pyproject_toml = load(f)

# remove "openbb" from dependencies
pyproject_toml.get("tool", {}).get("poetry", {}).get("dependencies", {}).pop(
"openbb", None
)
pyproject_toml.get("tool", {}).get("poetry", {}).get("dependencies", {}).pop("openbb", None)

TEMP_PYPROJECT = dumps(pyproject_toml)

Expand Down
29 changes: 20 additions & 9 deletions openbb_platform/extensions/equity/integration/test_equity_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -870,9 +870,7 @@ def test_equity_fundamental_revenue_per_segment(params, headers):
params = {p: v for p, v in params.items() if v}

query_str = get_querystring(params, [])
url = (
f"http://0.0.0.0:8000/api/v1/equity/fundamental/revenue_per_segment?{query_str}"
)
url = f"http://0.0.0.0:8000/api/v1/equity/fundamental/revenue_per_segment?{query_str}"
result = requests.get(url, headers=headers, timeout=10)
assert isinstance(result, requests.Response)
assert result.status_code == 200
Expand Down Expand Up @@ -1629,9 +1627,7 @@ def test_equity_discovery_aggressive_small_caps(params, headers):
params = {p: v for p, v in params.items() if v}

query_str = get_querystring(params, [])
url = (
f"http://0.0.0.0:8000/api/v1/equity/discovery/aggressive_small_caps?{query_str}"
)
url = f"http://0.0.0.0:8000/api/v1/equity/discovery/aggressive_small_caps?{query_str}"
result = requests.get(url, headers=headers, timeout=10)
assert isinstance(result, requests.Response)
assert result.status_code == 200
Expand Down Expand Up @@ -1832,6 +1828,23 @@ def test_equity_fundamental_historical_eps(params, headers):
assert result.status_code == 200


@pytest.mark.parametrize(
"params",
[
({"exchange": "XNYS", "provider": "headless_oracle"}),
],
)
@pytest.mark.integration
def test_equity_market_state(params, headers):
params = {p: v for p, v in params.items() if v}

query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/equity/market_state?{query_str}"
result = requests.get(url, headers=headers, timeout=10)
assert isinstance(result, requests.Response)
assert result.status_code == 200


@pytest.mark.parametrize(
"params",
[{"provider": "tiingo", "symbol": "AAPL", "limit": 10}],
Expand Down Expand Up @@ -1889,9 +1902,7 @@ def test_equity_fundamental_reported_financials(params, headers):
params = {p: v for p, v in params.items() if v}

query_str = get_querystring(params, [])
url = (
f"http://0.0.0.0:8000/api/v1/equity/fundamental/reported_financials?{query_str}"
)
url = f"http://0.0.0.0:8000/api/v1/equity/fundamental/reported_financials?{query_str}"
result = requests.get(url, headers=headers, timeout=10)
assert isinstance(result, requests.Response)
assert result.status_code == 200
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1712,6 +1712,22 @@ def test_equity_market_snapshots(params, obb):
assert len(result.results) > 0


@pytest.mark.parametrize(
"params",
[
({"exchange": "XNYS", "provider": "headless_oracle"}),
],
)
@pytest.mark.integration
def test_equity_market_state(params, obb):
result = obb.equity.market_state(**params)
assert result
assert isinstance(result, OBBject)
assert result.results is not None
assert result.results.mic == "XNYS"
assert result.results.status is not None


@pytest.mark.parametrize(
"params",
[
Expand Down
28 changes: 22 additions & 6 deletions openbb_platform/extensions/equity/openbb_equity/equity_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,7 @@ async def search(
return await OBBject.from_query(Query(**locals()))


@router.command(
model="EquityScreener", examples=[APIEx(parameters={"provider": "fmp"})]
)
@router.command(model="EquityScreener", examples=[APIEx(parameters={"provider": "fmp"})])
async def screener(
cc: CommandContext,
provider_choices: ProviderChoices,
Expand Down Expand Up @@ -89,9 +87,7 @@ async def profile(
return await OBBject.from_query(Query(**locals()))


@router.command(
model="MarketSnapshots", examples=[APIEx(parameters={"provider": "fmp"})]
)
@router.command(model="MarketSnapshots", examples=[APIEx(parameters={"provider": "fmp"})])
async def market_snapshots(
cc: CommandContext,
provider_choices: ProviderChoices,
Expand All @@ -102,6 +98,26 @@ async def market_snapshots(
return await OBBject.from_query(Query(**locals()))


@router.command(
model="MarketState",
examples=[
APIEx(parameters={"exchange": "XNYS", "provider": "headless_oracle"}),
APIEx(
description="Check market state using an exchange acronym.",
parameters={"exchange": "NASDAQ", "provider": "headless_oracle"},
),
],
)
async def market_state(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
"""Get a signed market-state receipt for an exchange."""
return await OBBject.from_query(Query(**locals()))


@router.command(
model="HistoricalMarketCap",
examples=[APIEx(parameters={"provider": "fmp", "symbol": "AAPL"})],
Expand Down
15 changes: 15 additions & 0 deletions openbb_platform/providers/headless_oracle/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Headless Oracle Provider

This provider exposes signed market-state receipts from Headless Oracle for OpenBB.

## Supported models

- `MarketState`

## Example

```python
from openbb import obb

obb.equity.market_state(exchange="XNYS", provider="headless_oracle")
```
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from openbb_core.provider.abstract.provider import Provider
from openbb_headless_oracle.models.market_state import HeadlessOracleMarketStateFetcher

headless_oracle_provider = Provider(
name="headless_oracle",
website="https://headlessoracle.com",
description="Signed market-state receipts for exchange-open verification.",
credentials=[],
fetcher_dict={
"MarketState": HeadlessOracleMarketStateFetcher,
},
repr_name="Headless Oracle",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from __future__ import annotations

from datetime import datetime
from typing import Any

from openbb_core.app.model.abstract.error import OpenBBError
from openbb_core.provider.abstract.fetcher import Fetcher
from openbb_core.provider.standard_models.market_state import (
MarketStateData,
MarketStateQueryParams,
)
from openbb_core.provider.utils.exchange_utils import Exchange
from pydantic import Field


class HeadlessOracleMarketStateQueryParams(MarketStateQueryParams):
exchange: Exchange = Field(description="Exchange MIC, acronym, or name to check market state for.")


class HeadlessOracleMarketStateFetcher(
Fetcher[
HeadlessOracleMarketStateQueryParams,
MarketStateData,
]
):
require_credentials = False

@staticmethod
def transform_query(params: dict[str, Any]) -> HeadlessOracleMarketStateQueryParams:
return HeadlessOracleMarketStateQueryParams(**params)

@staticmethod
def extract_data(
query: HeadlessOracleMarketStateQueryParams,
credentials: dict[str, str] | None = None,
**kwargs: Any,
) -> dict[str, Any]:
# pylint: disable=import-outside-toplevel
from openbb_core.provider.utils.helpers import make_request

url = f"https://headlessoracle.com/v5/demo?mic={query.exchange.mic}"

try:
response = make_request(url, timeout=30)
if response.status_code != 200:
raise OpenBBError(f"Headless Oracle request failed with status {response.status_code}: {response.text}")

return response.json()
except OpenBBError:
raise
except Exception as error:
raise OpenBBError(error) from error

@staticmethod
def transform_data(
query: HeadlessOracleMarketStateQueryParams,
data: dict[str, Any],
**kwargs: Any,
) -> MarketStateData:
payload = data.get("receipt")
if not isinstance(payload, dict):
raise OpenBBError("Headless Oracle response did not include a valid receipt payload.")

missing_fields = [field for field in ("mic", "status", "issued_at", "expires_at") if not payload.get(field)]
if missing_fields:
raise OpenBBError(
f"Headless Oracle receipt payload is missing required field(s): {', '.join(missing_fields)}"
)

status = str(payload.get("status") or "UNKNOWN").upper()
issued_at = HeadlessOracleMarketStateFetcher._parse_datetime(payload.get("issued_at"))
expires_at = HeadlessOracleMarketStateFetcher._parse_datetime(payload.get("expires_at"))
ttl_seconds = (
int((expires_at - issued_at).total_seconds()) if issued_at is not None and expires_at is not None else None
)

return MarketStateData(
exchange=query.exchange.acronym,
mic=str(payload.get("mic") or query.exchange.mic),
status=status,
is_open=status == "OPEN",
issued_at=issued_at,
expires_at=expires_at,
ttl_seconds=ttl_seconds,
issuer=payload.get("issuer"),
source=payload.get("source"),
halt_detection=payload.get("halt_detection"),
receipt_mode=payload.get("receipt_mode"),
schema_version=payload.get("schema_version"),
public_key_id=payload.get("public_key_id") or payload.get("key_id"),
signature=payload.get("signature"),
)

@staticmethod
def _parse_datetime(value: Any) -> datetime | None:
if value in (None, ""):
return None
if isinstance(value, datetime):
return value
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
19 changes: 19 additions & 0 deletions openbb_platform/providers/headless_oracle/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[tool.poetry]
name = "openbb-headless-oracle"
version = "1.0.0"
description = "Headless Oracle provider for OpenBB"
authors = ["OpenBB Team <hello@openbb.co>"]
license = "AGPL-3.0-only"
readme = "README.md"
packages = [{ include = "openbb_headless_oracle" }]

[tool.poetry.dependencies]
python = ">=3.10,<4"
openbb-core = "^1.6.7"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.poetry.plugins."openbb_provider_extension"]
headless_oracle = "openbb_headless_oracle:headless_oracle_provider"
Loading