pulse-data 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- inventzia/pulse/data/__init__.py +19 -0
- inventzia/pulse/data/datum/__init__.py +29 -0
- inventzia/pulse/data/datum/codec.py +94 -0
- inventzia/pulse/data/datum/datum.py +50 -0
- inventzia/pulse/data/datum/provider.py +67 -0
- inventzia/pulse/data/datum/registry.py +269 -0
- inventzia/pulse/data/schemas/common/vector_value.py +56 -0
- inventzia/pulse/data/schemas/marketdata/cdf_bar.py +70 -0
- inventzia/pulse/data/schemas/platform/heart_beat.py +43 -0
- inventzia/pulse/data/schemas/platform/text_message.py +45 -0
- inventzia/pulse/data/schemas/provider.py +45 -0
- inventzia/pulse/data/schemas/registry.py +51 -0
- pulse_data-0.2.2.dist-info/METADATA +259 -0
- pulse_data-0.2.2.dist-info/RECORD +19 -0
- pulse_data-0.2.2.dist-info/WHEEL +5 -0
- pulse_data-0.2.2.dist-info/licenses/LICENSE-AGPL-3.0 +668 -0
- pulse_data-0.2.2.dist-info/licenses/LICENSE-COMMERCIAL.txt +22 -0
- pulse_data-0.2.2.dist-info/licenses/NOTICE +16 -0
- pulse_data-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-data.
|
|
5
|
+
#
|
|
6
|
+
# pulse-data is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""pulse-data: the Datum routing contract and generated event schemas.
|
|
11
|
+
|
|
12
|
+
See the :mod:`inventzia.pulse.data.datum` subpackage for the ``Datum`` protocol
|
|
13
|
+
and JSON codecs, and ``inventzia.pulse.data.schemas`` for the generated models.
|
|
14
|
+
|
|
15
|
+
``inventzia`` and ``inventzia.pulse`` are deliberately PEP 420 namespace packages
|
|
16
|
+
(no ``__init__.py``) so pulse-data and pulse-beacon can share the
|
|
17
|
+
``inventzia.pulse.*`` prefix and install side by side; from here inward the
|
|
18
|
+
hand-written packages are regular packages with deliberate exports.
|
|
19
|
+
"""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-data.
|
|
5
|
+
#
|
|
6
|
+
# pulse-data is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""The Datum routing contract and its JSON / tagged-JSON codecs.
|
|
11
|
+
|
|
12
|
+
from inventzia.pulse.data.datum import Datum, to_tagged_json, from_tagged_json
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from inventzia.pulse.data.datum.datum import Datum
|
|
16
|
+
from inventzia.pulse.data.datum.codec import (
|
|
17
|
+
from_json,
|
|
18
|
+
from_tagged_json,
|
|
19
|
+
to_json,
|
|
20
|
+
to_tagged_json,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Datum",
|
|
25
|
+
"to_json",
|
|
26
|
+
"from_json",
|
|
27
|
+
"to_tagged_json",
|
|
28
|
+
"from_tagged_json",
|
|
29
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-data.
|
|
5
|
+
#
|
|
6
|
+
# pulse-data is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""
|
|
11
|
+
The Python counterpart of the Java ``DatumCodec``.
|
|
12
|
+
|
|
13
|
+
pulse-data owns how a :class:`~inventzia.pulse.data.datum.datum.Datum` becomes
|
|
14
|
+
JSON in *both* languages, so a value produced in one is consumed verbatim in the
|
|
15
|
+
other. Pydantic does the field-level (de)serialisation; this module adds the two
|
|
16
|
+
forms the transport layer uses:
|
|
17
|
+
|
|
18
|
+
* **type-directed** — :func:`to_json` / :func:`from_json`, when the caller knows
|
|
19
|
+
the concrete model class (e.g. it knows the topic's payload type);
|
|
20
|
+
* **self-describing (tagged)** — :func:`to_tagged_json` / :func:`from_tagged_json`,
|
|
21
|
+
which embed the ``TYPE_ID`` in a small envelope so a receiver can recover the
|
|
22
|
+
type from the message itself. Required wherever the type is not known ahead of
|
|
23
|
+
time: the in-process cross-language bridge, and later the socket/ZMQ transport.
|
|
24
|
+
The type is resolved through the composite
|
|
25
|
+
:mod:`inventzia.pulse.data.datum.registry` (the mirror of Java's
|
|
26
|
+
``DatumTypeRegistry``). The tagged functions default to the process-wide registry;
|
|
27
|
+
pass ``registry=`` (or use :class:`TaggedCodec`) to bind an isolated one in tests.
|
|
28
|
+
|
|
29
|
+
The tagged envelope is identical to the Java side::
|
|
30
|
+
|
|
31
|
+
{"typeId": "<TYPE_ID>", "payload": { ...fields... }}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
from typing import Optional, TypeVar
|
|
36
|
+
|
|
37
|
+
from inventzia.pulse.data.datum.registry import Registry, default_registry
|
|
38
|
+
|
|
39
|
+
_FIELD_TYPE_ID = "typeId"
|
|
40
|
+
_FIELD_PAYLOAD = "payload"
|
|
41
|
+
|
|
42
|
+
T = TypeVar("T")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def to_json(datum) -> str:
|
|
46
|
+
"""Serialise a datum to a single-line JSON string (flat, field names = wire names).
|
|
47
|
+
|
|
48
|
+
``exclude_none=True`` omits absent optional fields, matching the Java codec's
|
|
49
|
+
``@JsonInclude(NON_NULL)`` so the two languages produce identical envelopes.
|
|
50
|
+
"""
|
|
51
|
+
return datum.model_dump_json(by_alias=True, exclude_none=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def from_json(json_str: str, model_class: type[T]) -> T:
|
|
55
|
+
"""Deserialise a JSON string into the given model class."""
|
|
56
|
+
return model_class.model_validate_json(json_str)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def to_tagged_json(datum, registry: Optional[Registry] = None) -> str:
|
|
60
|
+
"""Serialise a datum to the self-describing envelope ``{"typeId", "payload"}``.
|
|
61
|
+
|
|
62
|
+
``registry`` defaults to the process-wide composite registry; pass an isolated one
|
|
63
|
+
(from ``build_registry``) to encode against a custom type universe.
|
|
64
|
+
"""
|
|
65
|
+
reg = registry if registry is not None else default_registry()
|
|
66
|
+
payload = json.loads(datum.model_dump_json(by_alias=True, exclude_none=True))
|
|
67
|
+
return json.dumps({_FIELD_TYPE_ID: reg.type_id_of(datum), _FIELD_PAYLOAD: payload})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def from_tagged_json(json_str: str, registry: Optional[Registry] = None):
|
|
71
|
+
"""Deserialise a tagged envelope, recovering the concrete type from its ``typeId``.
|
|
72
|
+
|
|
73
|
+
``registry`` defaults to the process-wide composite registry.
|
|
74
|
+
"""
|
|
75
|
+
reg = registry if registry is not None else default_registry()
|
|
76
|
+
envelope = json.loads(json_str)
|
|
77
|
+
type_id = envelope.get(_FIELD_TYPE_ID)
|
|
78
|
+
if not isinstance(type_id, str):
|
|
79
|
+
raise ValueError(f"Tagged JSON missing textual {_FIELD_TYPE_ID!r}: {json_str}")
|
|
80
|
+
model_class = reg.class_for(type_id)
|
|
81
|
+
return model_class.model_validate(envelope.get(_FIELD_PAYLOAD))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TaggedCodec:
|
|
85
|
+
"""A tagged codec bound to a specific :class:`Registry` (for tests / isolated universes)."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, registry: Registry):
|
|
88
|
+
self._registry = registry
|
|
89
|
+
|
|
90
|
+
def to_tagged_json(self, datum) -> str:
|
|
91
|
+
return to_tagged_json(datum, self._registry)
|
|
92
|
+
|
|
93
|
+
def from_tagged_json(self, json_str: str):
|
|
94
|
+
return from_tagged_json(json_str, self._registry)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-data.
|
|
5
|
+
#
|
|
6
|
+
# pulse-data is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""
|
|
11
|
+
Routing contract for all data types transported on the pulse-beacon bus.
|
|
12
|
+
|
|
13
|
+
Python equivalent of the Java ``Datum`` interface. Uses structural typing
|
|
14
|
+
(``Protocol``) so generated Pydantic models satisfy the contract without
|
|
15
|
+
explicit inheritance — they simply need ``datum_key`` and ``datum_time``
|
|
16
|
+
properties.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from typing import Protocol, runtime_checkable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class Datum(Protocol):
|
|
24
|
+
"""
|
|
25
|
+
Every generated schema class satisfies this protocol by exposing
|
|
26
|
+
``datum_key`` and ``datum_time`` properties whose implementations
|
|
27
|
+
are generated from the ``x-datum-key`` and ``x-datum-time`` YAML
|
|
28
|
+
annotations on the source schema.
|
|
29
|
+
|
|
30
|
+
In Python the contract is structural: no explicit ``implements`` is
|
|
31
|
+
needed. The pulse-beacon Python infrastructure reads these properties
|
|
32
|
+
by duck typing.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def datum_key(self) -> str:
|
|
37
|
+
"""
|
|
38
|
+
The routing key for this datum instance — e.g. an instrument
|
|
39
|
+
symbol, session ID, or tenant. Never ``None``; return an empty
|
|
40
|
+
string when no key applies.
|
|
41
|
+
"""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def datum_time(self) -> int:
|
|
46
|
+
"""
|
|
47
|
+
The logical time of this datum in epoch milliseconds — when the
|
|
48
|
+
underlying event occurred, not when it was published or received.
|
|
49
|
+
"""
|
|
50
|
+
...
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-data.
|
|
5
|
+
#
|
|
6
|
+
# pulse-data is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""The datum-type service-provider interface (SPI).
|
|
11
|
+
|
|
12
|
+
Each package that contributes routed :class:`Datum` types (pulse-data itself, and
|
|
13
|
+
independent extensions such as an adapter) publishes a :class:`DatumTypeProvider`.
|
|
14
|
+
A provider is pure declarative metadata plus binding descriptors, discovered once and
|
|
15
|
+
composed into an immutable registry (see :mod:`inventzia.pulse.data.datum.registry`).
|
|
16
|
+
Provider construction and loading must have no network, authentication, or gateway
|
|
17
|
+
side effects.
|
|
18
|
+
|
|
19
|
+
Extensions register a provider through the ``inventzia.pulse.datum_types`` packaging
|
|
20
|
+
entry-point group, whose value is a public zero-argument provider class; the registry
|
|
21
|
+
instantiates it exactly once. The core provider is generated and seeded directly, not
|
|
22
|
+
discovered.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Optional, Protocol, runtime_checkable
|
|
27
|
+
|
|
28
|
+
#: The SPI contract version this pulse-data implements. Providers must match it exactly.
|
|
29
|
+
SPI_VERSION = 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class DatumTypeBinding:
|
|
34
|
+
"""One contributed type: its ``TYPE_ID``, ``TYPE_VERSION``, and model class.
|
|
35
|
+
|
|
36
|
+
Built from the class's own constants (``Model.TYPE_ID`` / ``Model.TYPE_VERSION``),
|
|
37
|
+
so nothing is duplicated. Mirrors the Java ``DatumTypeBinding`` record.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
type_id: str
|
|
41
|
+
type_version: int
|
|
42
|
+
datum_class: type
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@runtime_checkable
|
|
46
|
+
class DatumTypeProvider(Protocol):
|
|
47
|
+
"""Declares a set of :class:`Datum` types contributed by one package."""
|
|
48
|
+
|
|
49
|
+
def provider_id(self) -> str:
|
|
50
|
+
"""Reverse-DNS namespace root; every contributed ``TYPE_ID`` starts with it + '.'."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
def spi_version(self) -> int:
|
|
54
|
+
"""The SPI contract version this provider was built against."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def package_version(self) -> str:
|
|
58
|
+
"""The contributing distribution's version (informational; baked at build time)."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
def bindings(self) -> "list[DatumTypeBinding]":
|
|
62
|
+
"""The type bindings this provider contributes."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
def manifest(self) -> Optional[str]:
|
|
66
|
+
"""Reserved for Phase 2 (schema-manifest fingerprinting). ``None`` in Phase 1."""
|
|
67
|
+
...
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-data.
|
|
5
|
+
#
|
|
6
|
+
# pulse-data is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
"""The composite datum-type registry: TYPE_ID <-> model class.
|
|
11
|
+
|
|
12
|
+
Built once from the core provider (seeded directly) plus discovered extension
|
|
13
|
+
providers, validated, then frozen. The tagged codec resolves types through it. This is
|
|
14
|
+
hand-written infrastructure; the per-provider bindings are generated. Mirror of the Java
|
|
15
|
+
``DatumTypeRegistry``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import re
|
|
20
|
+
import threading
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from importlib.metadata import entry_points
|
|
23
|
+
|
|
24
|
+
import pydantic
|
|
25
|
+
|
|
26
|
+
from inventzia.pulse.data.datum.provider import SPI_VERSION, DatumTypeBinding, DatumTypeProvider
|
|
27
|
+
|
|
28
|
+
_ENTRY_POINT_GROUP = "inventzia.pulse.datum_types"
|
|
29
|
+
_ID_RE = re.compile(r"[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+") # reverse-DNS-ish: dotted segments
|
|
30
|
+
_HEX64 = re.compile(r"[0-9a-f]{64}")
|
|
31
|
+
_MANIFEST_FORMAT = "pdm1"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ProviderInfo:
|
|
36
|
+
"""Retained, immutable metadata for one contributing provider (for diagnostics / audit)."""
|
|
37
|
+
|
|
38
|
+
provider_id: str
|
|
39
|
+
package_version: str
|
|
40
|
+
manifest: "str | None" # canonical pdm1 string, or None (unverifiable)
|
|
41
|
+
entries: "tuple | None" # ((type_id, type_version, fingerprint), ...) or None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Registry:
|
|
45
|
+
"""An immutable TYPE_ID <-> class registry. Build via :func:`build_registry`."""
|
|
46
|
+
|
|
47
|
+
__slots__ = ("_by_id", "_by_class", "_providers")
|
|
48
|
+
|
|
49
|
+
def __init__(self, by_id: dict, by_class: dict, providers: tuple):
|
|
50
|
+
self._by_id = by_id
|
|
51
|
+
self._by_class = by_class
|
|
52
|
+
self._providers = providers
|
|
53
|
+
|
|
54
|
+
def providers(self) -> tuple:
|
|
55
|
+
"""Immutable :class:`ProviderInfo` snapshot for every contributing provider, in order."""
|
|
56
|
+
return self._providers
|
|
57
|
+
|
|
58
|
+
def unverifiable_providers(self) -> tuple:
|
|
59
|
+
"""Provider IDs with no manifest (predating Phase 2); a fingerprint cannot include them."""
|
|
60
|
+
return tuple(pi.provider_id for pi in self._providers if pi.manifest is None)
|
|
61
|
+
|
|
62
|
+
def fingerprint(self) -> "str | None":
|
|
63
|
+
"""SHA-256 hex of the providers' manifests (sorted by provider_id), or None if any is absent.
|
|
64
|
+
|
|
65
|
+
None means at least one provider is unverifiable (see :meth:`unverifiable_providers`);
|
|
66
|
+
the cross-language bridge treats that as fail-closed.
|
|
67
|
+
"""
|
|
68
|
+
if any(pi.manifest is None for pi in self._providers):
|
|
69
|
+
return None
|
|
70
|
+
joined = "\n".join(pi.manifest for pi in sorted(self._providers, key=lambda pi: pi.provider_id))
|
|
71
|
+
return hashlib.sha256(joined.encode("utf-8")).hexdigest()
|
|
72
|
+
|
|
73
|
+
def class_for(self, type_id: str) -> type:
|
|
74
|
+
"""Return the model class registered for a TYPE_ID."""
|
|
75
|
+
try:
|
|
76
|
+
return self._by_id[type_id]
|
|
77
|
+
except KeyError:
|
|
78
|
+
raise KeyError(f"Unknown TYPE_ID: {type_id!r}") from None
|
|
79
|
+
|
|
80
|
+
def type_id_of(self, datum) -> str:
|
|
81
|
+
"""Return the TYPE_ID of a datum, verified against the registry.
|
|
82
|
+
|
|
83
|
+
Encoding must not emit a tagged envelope for a class that is not the registered
|
|
84
|
+
binding for its declared TYPE_ID, or a receiver could get a typeId no runtime can
|
|
85
|
+
decode. Mirrors the Java ``DatumTypeRegistry.typeIdOf`` check.
|
|
86
|
+
"""
|
|
87
|
+
cls = type(datum)
|
|
88
|
+
type_id = self._by_class.get(cls)
|
|
89
|
+
if type_id is None:
|
|
90
|
+
raise KeyError(f"Unregistered datum type: {cls.__name__}")
|
|
91
|
+
return type_id
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fail(msg: str) -> "None":
|
|
95
|
+
raise ValueError(f"invalid datum-type registry: {msg}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _validate_class(provider_id: str, b: DatumTypeBinding) -> None:
|
|
99
|
+
cls = b.datum_class
|
|
100
|
+
if cls is None:
|
|
101
|
+
_fail(f"provider '{provider_id}' has a binding with no class")
|
|
102
|
+
if not isinstance(cls, type) or not issubclass(cls, pydantic.BaseModel):
|
|
103
|
+
_fail(f"provider '{provider_id}' type '{b.type_id}': class {cls!r} is not a pydantic BaseModel "
|
|
104
|
+
"(the codec calls model_validate_json / model_dump_json)")
|
|
105
|
+
for prop in ("datum_key", "datum_time"):
|
|
106
|
+
if not hasattr(cls, prop):
|
|
107
|
+
_fail(f"provider '{provider_id}' type '{b.type_id}': class {cls.__name__} lacks '{prop}'")
|
|
108
|
+
cls_type_id = getattr(cls, "TYPE_ID", None)
|
|
109
|
+
if cls_type_id != b.type_id:
|
|
110
|
+
_fail(f"provider '{provider_id}': binding type_id '{b.type_id}' does not match "
|
|
111
|
+
f"{cls.__name__}.TYPE_ID {cls_type_id!r} (descriptor drift)")
|
|
112
|
+
tv = b.type_version
|
|
113
|
+
if type(tv) is not int or tv <= 0:
|
|
114
|
+
_fail(f"provider '{provider_id}' type '{b.type_id}': TYPE_VERSION must be a positive int, got {tv!r}")
|
|
115
|
+
cls_type_version = getattr(cls, "TYPE_VERSION", None)
|
|
116
|
+
if cls_type_version != tv:
|
|
117
|
+
_fail(f"provider '{provider_id}' type '{b.type_id}': binding type_version {tv} does not match "
|
|
118
|
+
f"{cls.__name__}.TYPE_VERSION {cls_type_version!r} (descriptor drift)")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_manifest(provider, bindings) -> ProviderInfo:
|
|
122
|
+
"""Parse and validate a provider's baked manifest against its bindings (see the SPI spec)."""
|
|
123
|
+
pid = provider.provider_id()
|
|
124
|
+
m = provider.manifest()
|
|
125
|
+
if m is None:
|
|
126
|
+
return ProviderInfo(pid, provider.package_version(), None, None)
|
|
127
|
+
if "\n" in m or "\r" in m:
|
|
128
|
+
_fail(f"provider '{pid}' manifest contains a newline")
|
|
129
|
+
parts = m.split("|")
|
|
130
|
+
if len(parts) != 3 or parts[0] != _MANIFEST_FORMAT:
|
|
131
|
+
_fail(f"provider '{pid}' manifest is not a {_MANIFEST_FORMAT} manifest")
|
|
132
|
+
_, embedded_pid, body = parts
|
|
133
|
+
if embedded_pid != pid:
|
|
134
|
+
_fail(f"provider '{pid}' manifest embeds provider id '{embedded_pid}'")
|
|
135
|
+
|
|
136
|
+
entries = []
|
|
137
|
+
type_ids = []
|
|
138
|
+
if body:
|
|
139
|
+
for entry in body.split(";"):
|
|
140
|
+
fields = entry.split(":")
|
|
141
|
+
if len(fields) != 3:
|
|
142
|
+
_fail(f"provider '{pid}' manifest has a malformed entry '{entry}'")
|
|
143
|
+
tid, ver_s, fp = fields
|
|
144
|
+
if not _HEX64.fullmatch(fp):
|
|
145
|
+
_fail(f"provider '{pid}' type '{tid}': fingerprint is not 64 lowercase hex")
|
|
146
|
+
try:
|
|
147
|
+
ver = int(ver_s)
|
|
148
|
+
except ValueError:
|
|
149
|
+
_fail(f"provider '{pid}' type '{tid}': non-integer version '{ver_s}'")
|
|
150
|
+
entries.append((tid, ver, fp))
|
|
151
|
+
type_ids.append(tid)
|
|
152
|
+
if type_ids != sorted(type_ids):
|
|
153
|
+
_fail(f"provider '{pid}' manifest entries are not sorted by TYPE_ID")
|
|
154
|
+
if len(set(type_ids)) != len(type_ids):
|
|
155
|
+
_fail(f"provider '{pid}' manifest has duplicate entries")
|
|
156
|
+
|
|
157
|
+
binding_ver = {b.type_id: b.type_version for b in bindings}
|
|
158
|
+
entry_ver = {tid: ver for (tid, ver, _) in entries}
|
|
159
|
+
if set(binding_ver) != set(entry_ver):
|
|
160
|
+
_fail(f"provider '{pid}' manifest does not match its bindings one-to-one")
|
|
161
|
+
for tid, ver in entry_ver.items():
|
|
162
|
+
if binding_ver[tid] != ver:
|
|
163
|
+
_fail(f"provider '{pid}' type '{tid}': manifest version {ver} != binding version {binding_ver[tid]}")
|
|
164
|
+
return ProviderInfo(pid, provider.package_version(), m, tuple(entries))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_registry(providers) -> Registry:
|
|
168
|
+
"""Validate ``providers`` and build an immutable composite registry.
|
|
169
|
+
|
|
170
|
+
``providers`` is an ordered iterable of :class:`DatumTypeProvider` (core first). The
|
|
171
|
+
validation rejects, with the offending provider/type named: null providers or bindings;
|
|
172
|
+
empty or malformed provider / type IDs; blank package versions; an SPI version mismatch;
|
|
173
|
+
an empty provider; a class that is not a valid pydantic datum; TYPE_VERSION that is not a
|
|
174
|
+
positive int; a TYPE_ID outside ``provider_id + '.'``; a duplicate class within a provider;
|
|
175
|
+
a duplicate TYPE_ID across providers; and a class bound to more than one TYPE_ID.
|
|
176
|
+
"""
|
|
177
|
+
by_id: dict = {}
|
|
178
|
+
by_class: dict = {}
|
|
179
|
+
seen_provider_ids: set = set()
|
|
180
|
+
provider_infos: list = []
|
|
181
|
+
|
|
182
|
+
for provider in providers:
|
|
183
|
+
if provider is None:
|
|
184
|
+
_fail("a provider is None")
|
|
185
|
+
pid = provider.provider_id()
|
|
186
|
+
if not pid or not pid.strip() or not _ID_RE.fullmatch(pid):
|
|
187
|
+
_fail(f"provider has an empty or malformed provider_id: {pid!r}")
|
|
188
|
+
if pid in seen_provider_ids:
|
|
189
|
+
_fail(f"duplicate provider_id '{pid}'")
|
|
190
|
+
seen_provider_ids.add(pid)
|
|
191
|
+
if provider.spi_version() != SPI_VERSION:
|
|
192
|
+
_fail(f"provider '{pid}' targets SPI version {provider.spi_version()}, "
|
|
193
|
+
f"this pulse-data supports {SPI_VERSION}")
|
|
194
|
+
pv = provider.package_version()
|
|
195
|
+
if not pv or not pv.strip():
|
|
196
|
+
_fail(f"provider '{pid}' has a missing or blank package_version")
|
|
197
|
+
|
|
198
|
+
bindings = provider.bindings()
|
|
199
|
+
if bindings is None:
|
|
200
|
+
_fail(f"provider '{pid}' returned no bindings collection")
|
|
201
|
+
bindings = sorted(bindings, key=lambda b: (b.type_id or ""))
|
|
202
|
+
if not bindings:
|
|
203
|
+
_fail(f"provider '{pid}' contributes no datum types")
|
|
204
|
+
|
|
205
|
+
provider_classes: set = set()
|
|
206
|
+
for b in bindings:
|
|
207
|
+
if b is None:
|
|
208
|
+
_fail(f"provider '{pid}' has a None binding")
|
|
209
|
+
if not b.type_id or not b.type_id.strip():
|
|
210
|
+
_fail(f"provider '{pid}' has an empty or malformed type_id")
|
|
211
|
+
if not b.type_id.startswith(pid + "."):
|
|
212
|
+
_fail(f"provider '{pid}' type '{b.type_id}' is not under its namespace '{pid}.'")
|
|
213
|
+
_validate_class(pid, b)
|
|
214
|
+
if b.datum_class in provider_classes:
|
|
215
|
+
_fail(f"provider '{pid}' binds class {b.datum_class.__name__} more than once")
|
|
216
|
+
provider_classes.add(b.datum_class)
|
|
217
|
+
if b.type_id in by_id:
|
|
218
|
+
_fail(f"duplicate TYPE_ID '{b.type_id}' (provider '{pid}' conflicts with an earlier provider)")
|
|
219
|
+
if b.datum_class in by_class:
|
|
220
|
+
_fail(f"class {b.datum_class.__name__} is bound to more than one TYPE_ID")
|
|
221
|
+
by_id[b.type_id] = b.datum_class
|
|
222
|
+
by_class[b.datum_class] = b.type_id
|
|
223
|
+
|
|
224
|
+
provider_infos.append(_parse_manifest(provider, bindings))
|
|
225
|
+
|
|
226
|
+
return Registry(by_id, by_class, tuple(provider_infos))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _discover_providers() -> list:
|
|
230
|
+
"""Seed the core provider directly, then discover extension providers.
|
|
231
|
+
|
|
232
|
+
Core is instantiated directly (never via discovery), so a source-tree or mis-packaged
|
|
233
|
+
install cannot lose its core types and core-only parity is guaranteed. Extensions are
|
|
234
|
+
loaded from the entry-point group and sorted by provider_id for determinism.
|
|
235
|
+
"""
|
|
236
|
+
from inventzia.pulse.data.schemas.provider import CoreDatumTypeProvider
|
|
237
|
+
|
|
238
|
+
providers = [CoreDatumTypeProvider()]
|
|
239
|
+
extensions = []
|
|
240
|
+
for ep in entry_points(group=_ENTRY_POINT_GROUP):
|
|
241
|
+
provider_cls = ep.load() # entry point resolves to the class, not an instance
|
|
242
|
+
extensions.append(provider_cls())
|
|
243
|
+
extensions.sort(key=lambda p: p.provider_id())
|
|
244
|
+
providers.extend(extensions)
|
|
245
|
+
return providers
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
_default_lock = threading.Lock()
|
|
249
|
+
_default_registry: "Registry | None" = None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def default_registry() -> Registry:
|
|
253
|
+
"""The process-wide composite registry, built once (lazily) and frozen."""
|
|
254
|
+
global _default_registry
|
|
255
|
+
if _default_registry is None:
|
|
256
|
+
with _default_lock:
|
|
257
|
+
if _default_registry is None:
|
|
258
|
+
_default_registry = build_registry(_discover_providers())
|
|
259
|
+
return _default_registry
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def class_for(type_id: str) -> type:
|
|
263
|
+
"""Facade over :func:`default_registry`: TYPE_ID -> model class."""
|
|
264
|
+
return default_registry().class_for(type_id)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def type_id_of(datum) -> str:
|
|
268
|
+
"""Facade over :func:`default_registry`: verified TYPE_ID of a datum."""
|
|
269
|
+
return default_registry().type_id_of(datum)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-data.
|
|
5
|
+
#
|
|
6
|
+
# pulse-data is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
#
|
|
11
|
+
# THIS FILE IS GENERATED. DO NOT EDIT MANUALLY.
|
|
12
|
+
# Source: schemas_yaml/common/vector_value.yaml
|
|
13
|
+
# Regenerate: python schemas/schemas-generators/generate_python.py
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
18
|
+
from typing import ClassVar, Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VectorValue(BaseModel):
|
|
22
|
+
"""
|
|
23
|
+
A generic timestamped vector of scalar observations, optionally labelled. A scalar value is simply the length-1 case. Suited to indicators that emit M components per timestamp (e.g. MACD -> [macd, signal, histogram]).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
27
|
+
|
|
28
|
+
TYPE_ID: ClassVar[str] = "com.inventzia.pulse.data.schemas.common.VectorValue"
|
|
29
|
+
TYPE_VERSION: ClassVar[int] = 1
|
|
30
|
+
|
|
31
|
+
key: str
|
|
32
|
+
"""The series or observation-source identifier"""
|
|
33
|
+
time: int
|
|
34
|
+
"""Epoch milliseconds of the observation"""
|
|
35
|
+
values: tuple[Decimal, ...]
|
|
36
|
+
"""The M scalar observations (length 1 for a scalar value)"""
|
|
37
|
+
|
|
38
|
+
value_ids: Optional[tuple[str, ...]] = Field(None, alias="valueIds")
|
|
39
|
+
"""Optional labels, parallel to values (positional if absent)"""
|
|
40
|
+
|
|
41
|
+
@model_validator(mode="after")
|
|
42
|
+
def _check_parallel_lengths(self):
|
|
43
|
+
if self.value_ids is not None and len(self.value_ids) != len(self.values):
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"value_ids length ({len(self.value_ids)}) must equal values length ({len(self.values)})")
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
# -- Datum protocol ---------------------------------------------------
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def datum_key(self) -> str:
|
|
52
|
+
return self.key
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def datum_time(self) -> int:
|
|
56
|
+
return self.time
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
|
|
2
|
+
# Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
|
|
3
|
+
#
|
|
4
|
+
# This file is part of pulse-data.
|
|
5
|
+
#
|
|
6
|
+
# pulse-data is dual-licensed:
|
|
7
|
+
# - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
|
|
8
|
+
# - Under a commercial license (see LICENSE-COMMERCIAL.txt).
|
|
9
|
+
# Contact operations@inventzia.com.
|
|
10
|
+
#
|
|
11
|
+
# THIS FILE IS GENERATED. DO NOT EDIT MANUALLY.
|
|
12
|
+
# Source: schemas_yaml/marketdata/cdf_bar.yaml
|
|
13
|
+
# Regenerate: python schemas/schemas-generators/generate_python.py
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
from datetime import date
|
|
17
|
+
from decimal import Decimal
|
|
18
|
+
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
|
|
19
|
+
from typing import ClassVar, Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CdfBar(BaseModel):
|
|
23
|
+
"""
|
|
24
|
+
Common data format (CDF) market data bar.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
28
|
+
|
|
29
|
+
TYPE_ID: ClassVar[str] = "com.inventzia.pulse.data.schemas.marketdata.CdfBar"
|
|
30
|
+
TYPE_VERSION: ClassVar[int] = 1
|
|
31
|
+
|
|
32
|
+
symb: str
|
|
33
|
+
"""Instrument symbol or identifier"""
|
|
34
|
+
timestamp: int
|
|
35
|
+
"""Epoch milliseconds representing the bar open time"""
|
|
36
|
+
op: Decimal
|
|
37
|
+
"""Open price"""
|
|
38
|
+
hi: Decimal
|
|
39
|
+
"""High price"""
|
|
40
|
+
lo: Decimal
|
|
41
|
+
"""Low price"""
|
|
42
|
+
cl: Decimal
|
|
43
|
+
"""Close price"""
|
|
44
|
+
vlm: Decimal
|
|
45
|
+
"""Volume traded during this bar"""
|
|
46
|
+
datetime: AwareDatetime
|
|
47
|
+
"""ISO 8601 datetime of the bar open time"""
|
|
48
|
+
date: date
|
|
49
|
+
"""Trading date for the bar"""
|
|
50
|
+
|
|
51
|
+
vwap: Optional[Decimal] = None
|
|
52
|
+
"""Volume-weighted average price (optional)"""
|
|
53
|
+
count: Optional[int] = None
|
|
54
|
+
"""Number of trades aggregated in this bar (optional)"""
|
|
55
|
+
expiry: Optional[str] = None
|
|
56
|
+
"""Option or futures expiry (optional)"""
|
|
57
|
+
strike: Optional[Decimal] = None
|
|
58
|
+
"""Option strike price (optional)"""
|
|
59
|
+
sym_exp: Optional[str] = Field(None, alias="symExp")
|
|
60
|
+
"""Symbol + expiry composite identifier (optional)"""
|
|
61
|
+
|
|
62
|
+
# -- Datum protocol ---------------------------------------------------
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def datum_key(self) -> str:
|
|
66
|
+
return self.symb
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def datum_time(self) -> int:
|
|
70
|
+
return self.timestamp
|