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.
@@ -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
+ ]
@@ -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