fathom-charts 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fathom_charts/__init__.py +154 -0
- fathom_charts/errors.py +91 -0
- fathom_charts/generate_types.py +237 -0
- fathom_charts/protocol.py +216 -0
- fathom_charts/py.typed +0 -0
- fathom_charts/rest.py +664 -0
- fathom_charts/stream.py +804 -0
- fathom_charts-0.2.0.dist-info/METADATA +161 -0
- fathom_charts-0.2.0.dist-info/RECORD +12 -0
- fathom_charts-0.2.0.dist-info/WHEEL +4 -0
- fathom_charts-0.2.0.dist-info/entry_points.txt +2 -0
- fathom_charts-0.2.0.dist-info/licenses/LICENSE +38 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Fathom Charts client: real-time order-flow indicator streams (WebSocket) and history (REST)."""
|
|
2
|
+
|
|
3
|
+
from .errors import ERROR_CODES, ErrorCode, FathomChartsError, ProblemFieldError
|
|
4
|
+
from .protocol import (
|
|
5
|
+
AuthMessage,
|
|
6
|
+
ClientMessage,
|
|
7
|
+
CloseCode,
|
|
8
|
+
CursorFrom,
|
|
9
|
+
ErrorMessage,
|
|
10
|
+
From,
|
|
11
|
+
HeartbeatMessage,
|
|
12
|
+
MaintenanceWindow,
|
|
13
|
+
Mode,
|
|
14
|
+
NoticeKind,
|
|
15
|
+
NoticeMessage,
|
|
16
|
+
PingMessage,
|
|
17
|
+
PongMessage,
|
|
18
|
+
RemoveMessage,
|
|
19
|
+
ResetMessage,
|
|
20
|
+
ResetReason,
|
|
21
|
+
ServerMessage,
|
|
22
|
+
SnapshotItem,
|
|
23
|
+
SnapshotMessage,
|
|
24
|
+
SourceState,
|
|
25
|
+
StatusMessage,
|
|
26
|
+
SubscribedMessage,
|
|
27
|
+
SubscribeMessage,
|
|
28
|
+
TimeFrom,
|
|
29
|
+
UnsubscribeMessage,
|
|
30
|
+
UpsertMessage,
|
|
31
|
+
compare_cursors,
|
|
32
|
+
)
|
|
33
|
+
from .rest import (
|
|
34
|
+
Catalog,
|
|
35
|
+
CatalogField,
|
|
36
|
+
CatalogIndicator,
|
|
37
|
+
CatalogObject,
|
|
38
|
+
CatalogParam,
|
|
39
|
+
Estimate,
|
|
40
|
+
ExportResume,
|
|
41
|
+
ExportStream,
|
|
42
|
+
FathomChartsRest,
|
|
43
|
+
HistorySnapshot,
|
|
44
|
+
Instrument,
|
|
45
|
+
Instruments,
|
|
46
|
+
Limits,
|
|
47
|
+
Mutation,
|
|
48
|
+
OfferCode,
|
|
49
|
+
Offers,
|
|
50
|
+
Page,
|
|
51
|
+
PageQuery,
|
|
52
|
+
PageSnapshot,
|
|
53
|
+
PlanCode,
|
|
54
|
+
RangeQuery,
|
|
55
|
+
Resolved,
|
|
56
|
+
ServiceStatus,
|
|
57
|
+
StatusMaintenance,
|
|
58
|
+
StreamToken,
|
|
59
|
+
Usage,
|
|
60
|
+
)
|
|
61
|
+
from .stream import (
|
|
62
|
+
ConnectionState,
|
|
63
|
+
FathomChartsStream,
|
|
64
|
+
Item,
|
|
65
|
+
Notice,
|
|
66
|
+
Reconnect,
|
|
67
|
+
Remove,
|
|
68
|
+
Reset,
|
|
69
|
+
Snapshot,
|
|
70
|
+
Status,
|
|
71
|
+
StreamEvent,
|
|
72
|
+
Subscribed,
|
|
73
|
+
SubscribeRequest,
|
|
74
|
+
Subscription,
|
|
75
|
+
Upsert,
|
|
76
|
+
WebSocketFactory,
|
|
77
|
+
WebSocketLike,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"ERROR_CODES",
|
|
82
|
+
"AuthMessage",
|
|
83
|
+
"Catalog",
|
|
84
|
+
"CatalogField",
|
|
85
|
+
"CatalogIndicator",
|
|
86
|
+
"CatalogObject",
|
|
87
|
+
"CatalogParam",
|
|
88
|
+
"ClientMessage",
|
|
89
|
+
"CloseCode",
|
|
90
|
+
"ConnectionState",
|
|
91
|
+
"CursorFrom",
|
|
92
|
+
"ErrorCode",
|
|
93
|
+
"ErrorMessage",
|
|
94
|
+
"Estimate",
|
|
95
|
+
"ExportResume",
|
|
96
|
+
"ExportStream",
|
|
97
|
+
"FathomChartsError",
|
|
98
|
+
"FathomChartsRest",
|
|
99
|
+
"FathomChartsStream",
|
|
100
|
+
"From",
|
|
101
|
+
"HeartbeatMessage",
|
|
102
|
+
"HistorySnapshot",
|
|
103
|
+
"Instrument",
|
|
104
|
+
"Instruments",
|
|
105
|
+
"Item",
|
|
106
|
+
"Limits",
|
|
107
|
+
"MaintenanceWindow",
|
|
108
|
+
"Mode",
|
|
109
|
+
"Mutation",
|
|
110
|
+
"Notice",
|
|
111
|
+
"NoticeKind",
|
|
112
|
+
"NoticeMessage",
|
|
113
|
+
"OfferCode",
|
|
114
|
+
"Offers",
|
|
115
|
+
"Page",
|
|
116
|
+
"PageQuery",
|
|
117
|
+
"PageSnapshot",
|
|
118
|
+
"PingMessage",
|
|
119
|
+
"PlanCode",
|
|
120
|
+
"PongMessage",
|
|
121
|
+
"ProblemFieldError",
|
|
122
|
+
"RangeQuery",
|
|
123
|
+
"Reconnect",
|
|
124
|
+
"Remove",
|
|
125
|
+
"RemoveMessage",
|
|
126
|
+
"Reset",
|
|
127
|
+
"ResetMessage",
|
|
128
|
+
"ResetReason",
|
|
129
|
+
"Resolved",
|
|
130
|
+
"ServerMessage",
|
|
131
|
+
"ServiceStatus",
|
|
132
|
+
"Snapshot",
|
|
133
|
+
"SnapshotItem",
|
|
134
|
+
"SnapshotMessage",
|
|
135
|
+
"SourceState",
|
|
136
|
+
"Status",
|
|
137
|
+
"StatusMaintenance",
|
|
138
|
+
"StatusMessage",
|
|
139
|
+
"StreamEvent",
|
|
140
|
+
"StreamToken",
|
|
141
|
+
"SubscribeMessage",
|
|
142
|
+
"SubscribeRequest",
|
|
143
|
+
"Subscribed",
|
|
144
|
+
"SubscribedMessage",
|
|
145
|
+
"Subscription",
|
|
146
|
+
"TimeFrom",
|
|
147
|
+
"UnsubscribeMessage",
|
|
148
|
+
"Upsert",
|
|
149
|
+
"UpsertMessage",
|
|
150
|
+
"Usage",
|
|
151
|
+
"WebSocketFactory",
|
|
152
|
+
"WebSocketLike",
|
|
153
|
+
"compare_cursors",
|
|
154
|
+
]
|
fathom_charts/errors.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Stable error codes and the error the SDK raises."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal, TypedDict
|
|
6
|
+
|
|
7
|
+
ErrorCode = Literal[
|
|
8
|
+
"INVALID_PARAMETERS", "UNKNOWN_INDICATOR", "UNKNOWN_INSTRUMENT", "NOT_COVERED", "WARMING", "QUOTA_EXCEEDED",
|
|
9
|
+
"RATE_LIMITED", "CAPACITY", "CURSOR_EXPIRED", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "METHOD_NOT_ALLOWED", "CONFLICT",
|
|
10
|
+
"WITHDRAWAL_WAIVER_REQUIRED", "SOURCE_DOWN", "UPSTREAM_ERROR", "INTERNAL",
|
|
11
|
+
"CONFIGURATION", "CLOSED", "KEY_REVOKED", "ENTITLEMENTS_CHANGED", "TOO_MANY_CONNECTIONS", "PROTOCOL_ERROR", "BINARY_NOT_SUPPORTED",
|
|
12
|
+
"INVALID_UTF8", "MESSAGE_TOO_BIG", "ABORTED", "NETWORK_ERROR",
|
|
13
|
+
]
|
|
14
|
+
"""A code of `ERROR_CODES`. An HTTP error answer without a problem document has the code `HTTP_<status>` (e.g. `HTTP_502`)."""
|
|
15
|
+
|
|
16
|
+
ERROR_CODES: tuple[ErrorCode, ...] = (
|
|
17
|
+
"INVALID_PARAMETERS", "UNKNOWN_INDICATOR", "UNKNOWN_INSTRUMENT", "NOT_COVERED", "WARMING", "QUOTA_EXCEEDED",
|
|
18
|
+
"RATE_LIMITED", "CAPACITY", "CURSOR_EXPIRED", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "METHOD_NOT_ALLOWED", "CONFLICT",
|
|
19
|
+
"WITHDRAWAL_WAIVER_REQUIRED", "SOURCE_DOWN", "UPSTREAM_ERROR", "INTERNAL",
|
|
20
|
+
"CONFIGURATION", "CLOSED", "KEY_REVOKED", "ENTITLEMENTS_CHANGED", "TOO_MANY_CONNECTIONS", "PROTOCOL_ERROR", "BINARY_NOT_SUPPORTED",
|
|
21
|
+
"INVALID_UTF8", "MESSAGE_TOO_BIG", "ABORTED", "NETWORK_ERROR",
|
|
22
|
+
)
|
|
23
|
+
"""
|
|
24
|
+
Stable error codes.
|
|
25
|
+
|
|
26
|
+
Codes of the API (RFC 9457 `code`) and of the WebSocket `error` messages: INVALID_PARAMETERS to INTERNAL. FORBIDDEN covers
|
|
27
|
+
the rights of the offers: historical data (queries, estimates, exports, `from:{time}`) needs the Historical offer, real time
|
|
28
|
+
the Live offer. WITHDRAWAL_WAIVER_REQUIRED: a checkout without the waiver of the right of withdrawal.
|
|
29
|
+
|
|
30
|
+
Codes raised by the SDK itself:
|
|
31
|
+
- CONFIGURATION: invalid client options.
|
|
32
|
+
- CLOSED: `subscribe()` on a stream closed by `close()`.
|
|
33
|
+
- KEY_REVOKED: the gateway closed the connection (4003) because the API key was revoked.
|
|
34
|
+
- ENTITLEMENTS_CHANGED: the gateway closed the connection (4003) because the account's rights no longer cover it (offer
|
|
35
|
+
ended, account suspended, subscription quota exceeded).
|
|
36
|
+
- TOO_MANY_CONNECTIONS: the connection quota of the plan stayed reached (4029) for `Reconnect.quota_retry`.
|
|
37
|
+
- PROTOCOL_ERROR, BINARY_NOT_SUPPORTED, INVALID_UTF8, MESSAGE_TOO_BIG: the gateway refused a message (close 1002, 1003,
|
|
38
|
+
1007, 1009); it would refuse it again, so the stream fails.
|
|
39
|
+
- ABORTED: kept for the other SDKs (a call stopped by its signal); in Python a call is stopped by cancelling its task
|
|
40
|
+
(`asyncio.timeout()`, `Task.cancel()`), and the `asyncio.CancelledError` goes through untouched, as asyncio requires.
|
|
41
|
+
- NETWORK_ERROR: the API could not be reached, or the connection was lost during an answer (DNS, refused or reset
|
|
42
|
+
connection, TLS); the error of httpx is the `__cause__`.
|
|
43
|
+
- `HTTP_<status>` (e.g. `HTTP_502`): an HTTP error answer without a problem document.
|
|
44
|
+
|
|
45
|
+
Every error the SDK raises or ends an iteration with is a `FathomChartsError`; the error it stems from, if any, is its `__cause__`.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ProblemFieldError(TypedDict):
|
|
50
|
+
"""One invalid field of a request: JSON Pointer from the root of the body, and what is wrong with it."""
|
|
51
|
+
|
|
52
|
+
path: str
|
|
53
|
+
message: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FathomChartsError(Exception):
|
|
57
|
+
"""Error raised by the SDK: a stable code (`ErrorCode`), the HTTP status or WebSocket close code when known."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
code: str,
|
|
62
|
+
message: str,
|
|
63
|
+
*,
|
|
64
|
+
status: int | None = None,
|
|
65
|
+
close_code: int | None = None,
|
|
66
|
+
retry_after: float | None = None,
|
|
67
|
+
problem: dict[str, Any] | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
super().__init__(message)
|
|
70
|
+
self.code = code
|
|
71
|
+
self.message = message
|
|
72
|
+
self.status = status
|
|
73
|
+
self.close_code = close_code
|
|
74
|
+
self.retry_after = retry_after
|
|
75
|
+
self.problem = problem
|
|
76
|
+
"""The RFC 9457 problem document of an HTTP error, with its `errors` (invalid fields, `ProblemFieldError`) when the API
|
|
77
|
+
gives them; None when the answer is not a problem document."""
|
|
78
|
+
|
|
79
|
+
def __repr__(self) -> str:
|
|
80
|
+
return f"FathomChartsError({self.code!r}, {self.message!r})"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def api_key_problem(api_key: object) -> str | None:
|
|
84
|
+
"""Why `api_key` cannot be used (an unset or empty environment variable, typically), or None."""
|
|
85
|
+
if api_key is None:
|
|
86
|
+
return "api_key is missing: is the variable holding the API key set?"
|
|
87
|
+
if not isinstance(api_key, str):
|
|
88
|
+
return f"api_key must be a string, not {type(api_key).__name__}"
|
|
89
|
+
if api_key.strip() == "":
|
|
90
|
+
return "api_key is an empty string: is the variable holding the API key set and not empty?"
|
|
91
|
+
return None
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generates Python types (TypedDict) of indicator parameters and mutation data from the indicator catalog (GET /v1/indicators),
|
|
3
|
+
with its descriptions as docstrings.
|
|
4
|
+
|
|
5
|
+
Usage: fathom-charts-types <catalog.json | catalog URL> <out.py>
|
|
6
|
+
|
|
7
|
+
JSON Schema → Python: an object is a TypedDict (a nested one is named after its parent and its key), an optional property
|
|
8
|
+
`NotRequired`, `const` and `enum` a `Literal`, `prefixItems` a tuple. A tuple whose last elements are optional (from `minItems`
|
|
9
|
+
on) is the union of its allowed lengths, e.g. `tuple[int, int, int] | tuple[int, int, int, int]`; one that allows more items
|
|
10
|
+
ends with `*tuple[T, ...]`. JSON arrays still arrive as lists: the tuple types describe their positions.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import keyword
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
_INTEGER_OR_STRING = (bool, int, str)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _pascal(id: str) -> str:
|
|
28
|
+
return "".join(p[0].upper() + p[1:] for p in re.split(r"[^A-Za-z0-9]+", id) if p)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _identifier(key: str) -> bool:
|
|
32
|
+
return re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key) is not None and not keyword.iskeyword(key)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _docstring(text: str, indent: str) -> str:
|
|
36
|
+
"""One-line docstring of a description."""
|
|
37
|
+
one_line = re.sub(r"\s*\n\s*", " ", text.strip()).replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
|
|
38
|
+
if one_line.endswith('"'):
|
|
39
|
+
one_line = one_line[:-1] + '\\"'
|
|
40
|
+
return f'{indent}"""{one_line}"""\n'
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _comment(text: str, indent: str) -> str:
|
|
44
|
+
"""One-line comment of a description."""
|
|
45
|
+
one_line = re.sub(r"\s+", " ", text.strip())
|
|
46
|
+
return f"{indent}# {one_line}\n"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _description(schema: Any) -> str | None:
|
|
50
|
+
if isinstance(schema, dict) and isinstance(schema.get("description"), str) and schema["description"].strip():
|
|
51
|
+
text: str = schema["description"]
|
|
52
|
+
return text
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _literal(value: Any) -> str:
|
|
57
|
+
if value is None:
|
|
58
|
+
return "None"
|
|
59
|
+
if isinstance(value, bool):
|
|
60
|
+
return "True" if value else "False"
|
|
61
|
+
return json.dumps(value, ensure_ascii=False) if isinstance(value, str) else str(value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Generator:
|
|
65
|
+
"""Turns JSON Schemas (the subset the catalog uses) into Python type expressions; objects become named TypedDicts, defined in
|
|
66
|
+
`definitions` before their use."""
|
|
67
|
+
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
self.definitions: list[str] = []
|
|
70
|
+
self.names: set[str] = set()
|
|
71
|
+
self.typing: set[str] = set()
|
|
72
|
+
|
|
73
|
+
def _use(self, name: str) -> str:
|
|
74
|
+
self.typing.add(name)
|
|
75
|
+
return name
|
|
76
|
+
|
|
77
|
+
def type_of(self, schema: Any, name: str = "Object") -> str:
|
|
78
|
+
"""The type of `schema`; `name` is the name of the TypedDict an object schema becomes."""
|
|
79
|
+
if not isinstance(schema, dict) or len(schema) == 0:
|
|
80
|
+
return self._use("Any")
|
|
81
|
+
if "const" in schema:
|
|
82
|
+
return self._literals([schema["const"]])
|
|
83
|
+
if isinstance(schema.get("enum"), list):
|
|
84
|
+
return self._literals(schema["enum"])
|
|
85
|
+
for k in ("oneOf", "anyOf"):
|
|
86
|
+
if isinstance(schema.get(k), list):
|
|
87
|
+
return " | ".join(self.type_of(s, f"{name}{i + 1}") for i, s in enumerate(schema[k]))
|
|
88
|
+
if isinstance(schema.get("type"), list):
|
|
89
|
+
return " | ".join(self.type_of({**schema, "type": t}, name) for t in schema["type"])
|
|
90
|
+
match schema.get("type"):
|
|
91
|
+
case "integer":
|
|
92
|
+
return "int"
|
|
93
|
+
case "number":
|
|
94
|
+
return "float"
|
|
95
|
+
case "string":
|
|
96
|
+
return "str"
|
|
97
|
+
case "boolean":
|
|
98
|
+
return "bool"
|
|
99
|
+
case "null":
|
|
100
|
+
return "None"
|
|
101
|
+
case "array":
|
|
102
|
+
if isinstance(schema.get("prefixItems"), list):
|
|
103
|
+
return self._tuple_of(schema, name)
|
|
104
|
+
return f"list[{self.type_of(schema.get('items'), f'{name}Item')}]"
|
|
105
|
+
case "object":
|
|
106
|
+
properties = schema.get("properties") or {}
|
|
107
|
+
if schema.get("additionalProperties") is not False and len(properties) == 0:
|
|
108
|
+
return f"dict[str, {self._use('Any')}]"
|
|
109
|
+
return self.define(name, schema)
|
|
110
|
+
case _:
|
|
111
|
+
return self._use("Any")
|
|
112
|
+
|
|
113
|
+
def _literals(self, values: list[Any]) -> str:
|
|
114
|
+
"""`Literal` of JSON values; a float is not a valid `Literal` value: its type stands for it."""
|
|
115
|
+
exact = [v for v in values if v is None or isinstance(v, _INTEGER_OR_STRING)]
|
|
116
|
+
others = sorted({"float" for v in values if isinstance(v, float)})
|
|
117
|
+
parts = [f"{self._use('Literal')}[{', '.join(_literal(v) for v in exact)}]"] if exact else []
|
|
118
|
+
return " | ".join(parts + others)
|
|
119
|
+
|
|
120
|
+
def _tuple_of(self, schema: dict[str, Any], name: str) -> str:
|
|
121
|
+
"""Tuple of `prefixItems`: elements from `minItems` on are optional (a union of the allowed lengths); a rest element when
|
|
122
|
+
more items are allowed."""
|
|
123
|
+
prefix = [self.type_of(s, f"{name}{i + 1}") for i, s in enumerate(schema["prefixItems"])]
|
|
124
|
+
minimum = min(schema.get("minItems", 0), len(prefix))
|
|
125
|
+
items = schema.get("items")
|
|
126
|
+
closed = items is False or (isinstance(schema.get("maxItems"), int) and schema["maxItems"] <= len(prefix))
|
|
127
|
+
rest = None if closed else f"*tuple[{self.type_of(items, f'{name}Item') if isinstance(items, dict) else self._use('Any')}, ...]"
|
|
128
|
+
variants = []
|
|
129
|
+
for length in range(minimum, len(prefix) + 1):
|
|
130
|
+
elements = prefix[:length] + ([rest] if rest is not None and length == len(prefix) else [])
|
|
131
|
+
variants.append(f"tuple[{', '.join(elements)}]" if elements else "tuple[()]")
|
|
132
|
+
return " | ".join(variants)
|
|
133
|
+
|
|
134
|
+
def define(self, name: str, schema: dict[str, Any], doc: str | None = None) -> str:
|
|
135
|
+
"""Defines the TypedDict `name` of an object schema; returns its (unique) name."""
|
|
136
|
+
unique, n = name, 1
|
|
137
|
+
while unique in self.names:
|
|
138
|
+
n += 1
|
|
139
|
+
unique = f"{name}{n}"
|
|
140
|
+
self.names.add(unique)
|
|
141
|
+
self._use("TypedDict")
|
|
142
|
+
required = set(schema.get("required") or [])
|
|
143
|
+
fields = []
|
|
144
|
+
for key, s in (schema.get("properties") or {}).items():
|
|
145
|
+
t = self.type_of(s, f"{unique}{_pascal(key)}")
|
|
146
|
+
fields.append((key, t if key in required else f"{self._use('NotRequired')}[{t}]", _description(s)))
|
|
147
|
+
if all(_identifier(key) for key, _, _ in fields):
|
|
148
|
+
lines = [_docstring(doc, " ") + ("\n" if fields else "")] if doc else []
|
|
149
|
+
for key, t, d in fields:
|
|
150
|
+
lines.append(f" {key}: {t}\n" + (_docstring(d, " ") if d else ""))
|
|
151
|
+
self.definitions.append(f"class {unique}(TypedDict):\n" + ("".join(lines) or " pass\n"))
|
|
152
|
+
else:
|
|
153
|
+
# Keys that are not identifiers: functional syntax, descriptions as comments.
|
|
154
|
+
entries = "".join(
|
|
155
|
+
(_comment(d, " ") if d else "") + f" {json.dumps(key, ensure_ascii=False)}: {t},\n" for key, t, d in fields
|
|
156
|
+
)
|
|
157
|
+
self.definitions.append((_comment(doc, "") if doc else "") + f'{unique} = TypedDict(\n "{unique}",\n {{\n{entries} }},\n)\n')
|
|
158
|
+
return unique
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def type_of(schema: Any, name: str = "Object") -> str:
|
|
162
|
+
"""Python type expression of a JSON Schema alone (the TypedDicts it needs are not returned)."""
|
|
163
|
+
return Generator().type_of(schema, name)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def generate(catalog: Any) -> str:
|
|
167
|
+
if not isinstance(catalog, dict) or catalog.get("version") != 1 or not isinstance(catalog.get("indicators"), list):
|
|
168
|
+
raise ValueError("Not a version 1 catalog")
|
|
169
|
+
g = Generator()
|
|
170
|
+
g._use("TypedDict")
|
|
171
|
+
g._use("Literal")
|
|
172
|
+
for ind in catalog["indicators"]:
|
|
173
|
+
name = _pascal(ind["id"])
|
|
174
|
+
summary = f"{ind['name']}: {ind.get('summary') or ''}"
|
|
175
|
+
_named(g, f"{name}Params", ind.get("paramsSchema"), summary)
|
|
176
|
+
_named(g, f"{name}Data", ind.get("dataSchema"), _description(ind.get("dataSchema")))
|
|
177
|
+
ids = [ind["id"] for ind in catalog["indicators"]]
|
|
178
|
+
for kind in ("Params", "Data"):
|
|
179
|
+
entries = ", ".join(f"{json.dumps(i, ensure_ascii=False)}: {_pascal(i)}{kind}" for i in ids)
|
|
180
|
+
g.definitions.append(f'Indicator{kind} = TypedDict("Indicator{kind}", {{{entries}}})\n')
|
|
181
|
+
g.definitions.append(f"IndicatorId = Literal[{', '.join(json.dumps(i, ensure_ascii=False) for i in ids)}]\n")
|
|
182
|
+
header = [
|
|
183
|
+
f"# Generated from the indicator catalog (engine {catalog.get('engineFingerprint')}). Do not edit.",
|
|
184
|
+
"",
|
|
185
|
+
f"from typing import {', '.join(sorted(g.typing))}",
|
|
186
|
+
"",
|
|
187
|
+
]
|
|
188
|
+
return "\n".join(header) + "\n" + "\n\n".join(g.definitions)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _named(g: Generator, name: str, schema: Any, doc: str | None) -> None:
|
|
192
|
+
"""Defines `name` for an indicator schema: a TypedDict for an object with properties, an alias otherwise."""
|
|
193
|
+
if isinstance(schema, dict) and schema.get("type") == "object" and (schema.get("properties") or schema.get("additionalProperties") is False):
|
|
194
|
+
g.define(name, schema, doc)
|
|
195
|
+
return
|
|
196
|
+
expression = g.type_of(schema, name)
|
|
197
|
+
g.names.add(name)
|
|
198
|
+
g.definitions.append((_comment(doc, "") if doc else "") + f"{name} = {expression}\n")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
USAGE = (
|
|
202
|
+
"Usage: fathom-charts-types <catalog.json | catalog URL> <out.py>\n"
|
|
203
|
+
" The catalog is a file saved from GET /v1/indicators, or its URL (https://api.fathomcharts.com/v1/indicators).\n"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def parse_args(argv: list[str]) -> tuple[str, str] | None:
|
|
208
|
+
"""Arguments of the command: the two paths, or None when they do not fit the usage."""
|
|
209
|
+
if len(argv) != 2 or any(not a or a.startswith("--") for a in argv):
|
|
210
|
+
return None
|
|
211
|
+
return argv[0], argv[1]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def load_catalog(source: str) -> Any:
|
|
215
|
+
"""The catalog of `source`: a file, or an http(s) URL."""
|
|
216
|
+
if not re.match(r"https?://", source):
|
|
217
|
+
return json.loads(Path(source).read_text(encoding="utf-8"))
|
|
218
|
+
response = httpx.get(source, headers={"accept": "application/json"})
|
|
219
|
+
if not response.is_success:
|
|
220
|
+
raise RuntimeError(f"GET {source} answered {response.status_code}")
|
|
221
|
+
return response.json()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def main(argv: list[str] | None = None) -> None:
|
|
225
|
+
"""The `fathom-charts-types` command."""
|
|
226
|
+
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
227
|
+
if args is None:
|
|
228
|
+
sys.stderr.write(USAGE)
|
|
229
|
+
raise SystemExit(2)
|
|
230
|
+
source = generate(load_catalog(args[0]))
|
|
231
|
+
output = Path(args[1])
|
|
232
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
output.write_text(source, encoding="utf-8", newline="\n")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
main()
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Public WebSocket protocol. Every message is a JSON object whose `t` field names it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import IntEnum
|
|
9
|
+
from typing import Any, Generic, Literal, NotRequired, TypedDict, TypeVar
|
|
10
|
+
|
|
11
|
+
from .errors import FathomChartsError
|
|
12
|
+
|
|
13
|
+
D = TypeVar("D")
|
|
14
|
+
|
|
15
|
+
Mode = Literal["live", "confirmed"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CursorFrom(TypedDict):
|
|
19
|
+
"""After a known cursor. `engine` is the engine build that cursor comes from (`subscribed.engine`): under another build the
|
|
20
|
+
subscription restarts from a snapshot after `reset` (`engine_change`) instead of resuming state computed differently."""
|
|
21
|
+
|
|
22
|
+
cursor: str
|
|
23
|
+
engine: NotRequired[str]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TimeFrom(TypedDict):
|
|
27
|
+
"""From a time (Unix ns, decimal string) through history into live."""
|
|
28
|
+
|
|
29
|
+
time: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
From = Literal["live"] | CursorFrom | TimeFrom
|
|
33
|
+
"""Start point: live edge, after a known cursor, or from a time through history into live."""
|
|
34
|
+
|
|
35
|
+
SourceState = Literal["live", "warming", "source_delayed", "source_down", "market_closed"]
|
|
36
|
+
ResetReason = Literal["roll", "slow_consumer", "cursor_expired", "entitlement_changed", "engine_change"]
|
|
37
|
+
NoticeKind = Literal["reconnect", "maintenance", "maintenance-cancelled", "breaking-change"]
|
|
38
|
+
"""
|
|
39
|
+
`reconnect`: the gateway restarts (deployment) and closes the connection (1012) within seconds; `maintenance`: a window
|
|
40
|
+
(`id`, `effectiveAt` to `endsAt`, `message`) during which the service may pause; `maintenance-cancelled`: the window
|
|
41
|
+
`id` is cancelled; `breaking-change`: a protocol change takes effect at `effectiveAt`.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class MaintenanceWindow:
|
|
47
|
+
"""A maintenance window announced and not cancelled: the service may pause between `starts_at` and `ends_at` (Unix ns)."""
|
|
48
|
+
|
|
49
|
+
id: str
|
|
50
|
+
starts_at: str
|
|
51
|
+
ends_at: str
|
|
52
|
+
message: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SnapshotItem(TypedDict, Generic[D]):
|
|
56
|
+
"""One object of a snapshot, as on the wire: the latest state of an indicator object."""
|
|
57
|
+
|
|
58
|
+
id: str
|
|
59
|
+
final: bool
|
|
60
|
+
ts: str
|
|
61
|
+
data: D
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Client → server
|
|
65
|
+
class AuthMessage(TypedDict):
|
|
66
|
+
t: Literal["auth"]
|
|
67
|
+
token: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
SubscribeMessage = TypedDict(
|
|
71
|
+
"SubscribeMessage",
|
|
72
|
+
{"t": Literal["subscribe"], "sub": str, "instrument": str, "indicator": str, "params": dict[str, Any], "mode": Mode, "from": From},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class UnsubscribeMessage(TypedDict):
|
|
77
|
+
t: Literal["unsubscribe"]
|
|
78
|
+
sub: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PingMessage(TypedDict):
|
|
82
|
+
t: Literal["ping"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
ClientMessage = AuthMessage | SubscribeMessage | UnsubscribeMessage | PingMessage
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Server → client
|
|
89
|
+
class SubscribedMessage(TypedDict):
|
|
90
|
+
"""The subscription is served: its topic key and the contract it follows (an alias resolved, e.g. `NQ.front` -> `NQZ6`), with
|
|
91
|
+
its price increment (1e-9 units, decimal string), and the fingerprint of the engine build computing it (`engine`, given back
|
|
92
|
+
with a cursor to resume). The starting cursor is the one of the snapshot, or of the resumed records."""
|
|
93
|
+
|
|
94
|
+
t: Literal["subscribed"]
|
|
95
|
+
sub: str
|
|
96
|
+
topic: str
|
|
97
|
+
instrument: str
|
|
98
|
+
tickSizeNanos: str
|
|
99
|
+
engine: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SnapshotMessage(TypedDict, Generic[D]):
|
|
103
|
+
t: Literal["snapshot"]
|
|
104
|
+
sub: str
|
|
105
|
+
cursor: str
|
|
106
|
+
items: list[SnapshotItem[D]]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class UpsertMessage(TypedDict, Generic[D]):
|
|
110
|
+
t: Literal["upsert"]
|
|
111
|
+
sub: str
|
|
112
|
+
cursor: str
|
|
113
|
+
id: str
|
|
114
|
+
final: bool
|
|
115
|
+
ts: str
|
|
116
|
+
data: D
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class RemoveMessage(TypedDict):
|
|
120
|
+
t: Literal["remove"]
|
|
121
|
+
sub: str
|
|
122
|
+
cursor: str
|
|
123
|
+
id: str
|
|
124
|
+
final: bool
|
|
125
|
+
ts: str
|
|
126
|
+
data: NotRequired[Any]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class StatusMessage(TypedDict):
|
|
130
|
+
t: Literal["status"]
|
|
131
|
+
sub: str
|
|
132
|
+
state: SourceState
|
|
133
|
+
lagMs: int
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ResetMessage(TypedDict):
|
|
137
|
+
t: Literal["reset"]
|
|
138
|
+
sub: str
|
|
139
|
+
reason: ResetReason
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ErrorMessage(TypedDict):
|
|
143
|
+
t: Literal["error"]
|
|
144
|
+
sub: NotRequired[str]
|
|
145
|
+
code: str
|
|
146
|
+
message: str
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class HeartbeatMessage(TypedDict):
|
|
150
|
+
t: Literal["hb"]
|
|
151
|
+
cursors: dict[str, str]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class NoticeMessage(TypedDict):
|
|
155
|
+
"""A notice to the connection (all its subscriptions). Times: Unix ns, decimal strings."""
|
|
156
|
+
|
|
157
|
+
t: Literal["notice"]
|
|
158
|
+
kind: NoticeKind
|
|
159
|
+
id: NotRequired[str]
|
|
160
|
+
effectiveAt: NotRequired[str]
|
|
161
|
+
endsAt: NotRequired[str]
|
|
162
|
+
message: NotRequired[str]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class PongMessage(TypedDict):
|
|
166
|
+
"""Answer to a `ping`, sent ahead of the queued messages."""
|
|
167
|
+
|
|
168
|
+
t: Literal["pong"]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
ServerMessage = (
|
|
172
|
+
SubscribedMessage | SnapshotMessage[Any] | UpsertMessage[Any] | RemoveMessage | StatusMessage | ResetMessage | ErrorMessage
|
|
173
|
+
| HeartbeatMessage | NoticeMessage | PongMessage
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class CloseCode(IntEnum):
|
|
178
|
+
"""WebSocket close codes of the gateway."""
|
|
179
|
+
|
|
180
|
+
NORMAL = 1000
|
|
181
|
+
PROTOCOL_ERROR = 1002
|
|
182
|
+
BINARY_NOT_SUPPORTED = 1003
|
|
183
|
+
INVALID_UTF8 = 1007
|
|
184
|
+
RATE_LIMITED = 1008
|
|
185
|
+
MESSAGE_TOO_BIG = 1009
|
|
186
|
+
SERVICE_RESTART = 1012
|
|
187
|
+
TRY_AGAIN_LATER = 1013
|
|
188
|
+
UNAUTHORIZED = 4001
|
|
189
|
+
FORBIDDEN = 4003
|
|
190
|
+
SLOW_CONSUMER = 4008
|
|
191
|
+
TOO_MANY_CONNECTIONS = 4029
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
_CURSOR = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _cursor_parts(cursor: str) -> tuple[int, int, int]:
|
|
198
|
+
match = _CURSOR.fullmatch(cursor) if isinstance(cursor, str) else None
|
|
199
|
+
if match is None:
|
|
200
|
+
raise FathomChartsError(
|
|
201
|
+
"INVALID_PARAMETERS",
|
|
202
|
+
f"Invalid cursor {json.dumps(cursor, ensure_ascii=False)}: expected <day>.<rank>.<k>, decimal integers without leading zeros",
|
|
203
|
+
)
|
|
204
|
+
return int(match[1]), int(match[2]), int(match[3])
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def compare_cursors(a: str, b: str) -> int:
|
|
208
|
+
"""
|
|
209
|
+
Orders two cursors `<day>.<rank>.<k>` (decimal integers without leading zeros), part by part. Returns a negative number, zero
|
|
210
|
+
or a positive number. Raises a `FathomChartsError` INVALID_PARAMETERS when either is not a well-formed cursor.
|
|
211
|
+
"""
|
|
212
|
+
x, y = _cursor_parts(a), _cursor_parts(b)
|
|
213
|
+
for i in range(3):
|
|
214
|
+
if x[i] != y[i]:
|
|
215
|
+
return -1 if x[i] < y[i] else 1
|
|
216
|
+
return 0
|