questdb-fxmacrodata 0.1.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.
- fxmacrodata_questdb/__init__.py +139 -0
- fxmacrodata_questdb/py.typed +0 -0
- questdb_fxmacrodata-0.1.0.dist-info/METADATA +142 -0
- questdb_fxmacrodata-0.1.0.dist-info/RECORD +7 -0
- questdb_fxmacrodata-0.1.0.dist-info/WHEEL +5 -0
- questdb_fxmacrodata-0.1.0.dist-info/licenses/LICENSE +21 -0
- questdb_fxmacrodata-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Load public FXMacroData responses into a customer-owned QuestDB table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
try: # questdb >= 3 exports the sentinel at top level; older paths keep the ingress module.
|
|
13
|
+
from questdb import ServerTimestamp
|
|
14
|
+
except ImportError: # pragma: no cover - exercised only on older client layouts
|
|
15
|
+
from questdb.ingress import ServerTimestamp
|
|
16
|
+
|
|
17
|
+
from fxmacrodata_platform_core import read
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
COLUMNS = (
|
|
22
|
+
"operation", "source_url", "record_id", "currency", "indicator", "date",
|
|
23
|
+
"announcement_datetime", "value", "value_text", "record_json", "response_json",
|
|
24
|
+
"content_hash",
|
|
25
|
+
)
|
|
26
|
+
SYMBOLS = ("operation", "currency", "indicator")
|
|
27
|
+
_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,127}\Z")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LoadError(RuntimeError):
|
|
31
|
+
"""A destination operation failed; inspect the destination before retrying."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class LoadResult:
|
|
36
|
+
table: str
|
|
37
|
+
rows: int
|
|
38
|
+
batches: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _table(table: str) -> str:
|
|
42
|
+
if not isinstance(table, str) or not _IDENTIFIER.fullmatch(table):
|
|
43
|
+
raise ValueError("table must be one simple identifier")
|
|
44
|
+
return table
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _rows(batch: Any) -> list[dict[str, Any]]:
|
|
48
|
+
rows = []
|
|
49
|
+
for source in batch.rows():
|
|
50
|
+
row = {name: source.get(name) for name in COLUMNS[:-1]}
|
|
51
|
+
if not isinstance(row["operation"], str) or not isinstance(row["record_id"], str):
|
|
52
|
+
raise ValueError("every row requires string operation and record_id")
|
|
53
|
+
for name, value in row.items():
|
|
54
|
+
if name == "value":
|
|
55
|
+
if value is not None and (
|
|
56
|
+
isinstance(value, bool) or not isinstance(value, (int, float)) or
|
|
57
|
+
not math.isfinite(value)
|
|
58
|
+
):
|
|
59
|
+
raise ValueError("value must be a finite number or null")
|
|
60
|
+
elif value is not None and not isinstance(value, str):
|
|
61
|
+
raise ValueError(f"{name} must be a string or null")
|
|
62
|
+
encoded = json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
63
|
+
row["content_hash"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
64
|
+
rows.append(row)
|
|
65
|
+
return rows
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _blocks(rows: list[dict[str, Any]], batch_size: int, max_batch_bytes: int) -> list[list[dict[str, Any]]]:
|
|
69
|
+
if isinstance(batch_size, bool) or not isinstance(batch_size, int) or not 1 <= batch_size <= 10000:
|
|
70
|
+
raise ValueError("batch_size must be an integer between 1 and 10000")
|
|
71
|
+
if isinstance(max_batch_bytes, bool) or not isinstance(max_batch_bytes, int) or not 1 <= max_batch_bytes <= 128 * 1024 * 1024:
|
|
72
|
+
raise ValueError("max_batch_bytes must be an integer between 1 and 134217728")
|
|
73
|
+
output, block, size = [], [], 0
|
|
74
|
+
for row in rows:
|
|
75
|
+
length = len(json.dumps(row, ensure_ascii=True).encode("utf-8")) + 2
|
|
76
|
+
if length > max_batch_bytes:
|
|
77
|
+
raise ValueError("one envelope exceeds max_batch_bytes; narrow the source date range")
|
|
78
|
+
if block and (len(block) >= batch_size or size + length > max_batch_bytes):
|
|
79
|
+
output.append(block)
|
|
80
|
+
block, size = [], 0
|
|
81
|
+
block.append(row)
|
|
82
|
+
size += length
|
|
83
|
+
if block:
|
|
84
|
+
output.append(block)
|
|
85
|
+
return output
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def row_arguments(row: dict[str, Any]) -> tuple[dict[str, str], dict[str, Any]]:
|
|
89
|
+
"""Split one envelope into ``Sender.row`` symbols and columns, omitting nulls."""
|
|
90
|
+
symbols = {name: row[name] for name in SYMBOLS if row.get(name) is not None}
|
|
91
|
+
columns = {name: row[name] for name in COLUMNS if name not in SYMBOLS and row.get(name) is not None}
|
|
92
|
+
if "value" in columns:
|
|
93
|
+
columns["value"] = float(columns["value"])
|
|
94
|
+
return symbols, columns
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load(batch: Any, sender: Any, table: str, *, batch_size: int = 1000,
|
|
98
|
+
max_batch_bytes: int = 16 * 1024 * 1024) -> LoadResult:
|
|
99
|
+
"""Send bounded blocks through an open questdb ``Sender`` and flush after each.
|
|
100
|
+
|
|
101
|
+
Rows are stamped with QuestDB's server timestamp at ingestion; the source
|
|
102
|
+
``date`` and ``announcement_datetime`` remain string columns. A failed
|
|
103
|
+
block is never retried automatically.
|
|
104
|
+
"""
|
|
105
|
+
table = _table(table)
|
|
106
|
+
if isinstance(batch_size, bool) or not isinstance(batch_size, int) or not 1 <= batch_size <= 10000:
|
|
107
|
+
raise ValueError("batch_size must be an integer between 1 and 10000")
|
|
108
|
+
rows = _rows(batch)
|
|
109
|
+
blocks = _blocks(rows, batch_size, max_batch_bytes)
|
|
110
|
+
count = 0
|
|
111
|
+
for block in blocks:
|
|
112
|
+
try:
|
|
113
|
+
for row in block:
|
|
114
|
+
symbols, columns = row_arguments(row)
|
|
115
|
+
sender.row(table, symbols=symbols, columns=columns, at=ServerTimestamp)
|
|
116
|
+
sender.flush()
|
|
117
|
+
except Exception:
|
|
118
|
+
raise LoadError(
|
|
119
|
+
f"QuestDB ingestion block {count + 1} failed; earlier blocks were flushed. "
|
|
120
|
+
"Inspect the destination and deduplicate on content_hash after a retry."
|
|
121
|
+
) from None
|
|
122
|
+
count += 1
|
|
123
|
+
return LoadResult(table, len(rows), count)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_operation(operation: str, args: dict[str, Any] | None, sender: Any, table: str,
|
|
127
|
+
*, api_key_env: str | None = None, complete_history: bool = True,
|
|
128
|
+
max_pages: int = 10000, client_factory: Any = None,
|
|
129
|
+
batch_size: int = 1000, max_batch_bytes: int = 16 * 1024 * 1024) -> LoadResult:
|
|
130
|
+
"""Read one public operation and load its complete response envelope."""
|
|
131
|
+
_table(table)
|
|
132
|
+
_blocks([], batch_size, max_batch_bytes)
|
|
133
|
+
batch = read(operation, args, api_key_env=api_key_env,
|
|
134
|
+
complete_history=complete_history, max_pages=max_pages,
|
|
135
|
+
client_factory=client_factory)
|
|
136
|
+
return load(batch, sender, table, batch_size=batch_size, max_batch_bytes=max_batch_bytes)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
__all__ = ["COLUMNS", "SYMBOLS", "LoadError", "LoadResult", "row_arguments", "load", "load_operation"]
|
|
File without changes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: questdb-fxmacrodata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Load public FXMacroData responses into QuestDB
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Documentation, https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=questdb-fxmacrodata&utm_content=package
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: fxmacrodata-platform-core==0.1.0
|
|
11
|
+
Requires-Dist: questdb<6,>=5.0.0
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest<9,>=8; extra == "test"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# FXMacroData for QuestDB
|
|
17
|
+
|
|
18
|
+
Load FXMacroData public responses into QuestDB with the official `questdb`
|
|
19
|
+
Python client's ingestion `Sender`. Keep macro announcements next to your
|
|
20
|
+
time-series market data while retaining the source payload and revision
|
|
21
|
+
metadata.
|
|
22
|
+
|
|
23
|
+
[FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=questdb-fxmacrodata&utm_content=readme)
|
|
24
|
+
|
|
25
|
+
## Install and connect
|
|
26
|
+
|
|
27
|
+
Requires Python 3.10 or later. Install this package and its adjacent public
|
|
28
|
+
client dependencies from the distribution supplied to you:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
python -m pip install /path/to/fxmacrodata-public-client /path/to/fxmacrodata-platform-core /path/to/questdb-fxmacrodata
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Configure `FXMD_API_KEY` and `QDB_CLIENT_CONF` (the QuestDB client's own
|
|
35
|
+
configuration string, including any token) in your runtime secret store. Then
|
|
36
|
+
load into a customer-owned QuestDB instance:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from questdb.ingress import Sender
|
|
40
|
+
from fxmacrodata_platform_core import read
|
|
41
|
+
from fxmacrodata_questdb import load
|
|
42
|
+
|
|
43
|
+
with Sender.from_env() as sender:
|
|
44
|
+
batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
|
|
45
|
+
result = load(batch, sender, "fxmacrodata", batch_size=1000)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
USD operations work without a key. For other currencies pass
|
|
49
|
+
`api_key_env="FXMD_API_KEY"` to `read` or `load_operation`.
|
|
50
|
+
|
|
51
|
+
You open and close the `Sender`; the loader only calls `row` and `flush`.
|
|
52
|
+
QuestDB creates the table on first ingestion. Table names are one simple
|
|
53
|
+
identifier such as `fxmacrodata`; dotted names, SQL fragments and URL-like
|
|
54
|
+
names are rejected.
|
|
55
|
+
|
|
56
|
+
Every operation exposed by `fxmacrodata_platform_core.operations()` is available,
|
|
57
|
+
including REST and MCP operations. The loader adds a tabular envelope without
|
|
58
|
+
changing the original public response. `record_json` retains the original record;
|
|
59
|
+
`response_json` retains the complete response, including pagination and metadata.
|
|
60
|
+
An empty response produces one envelope with `record_json = 'null'`, so absence of
|
|
61
|
+
data and the server's metadata remain inspectable. It is not an observation.
|
|
62
|
+
|
|
63
|
+
Columns are `operation`, `source_url`, `record_id`, `currency`, `indicator`,
|
|
64
|
+
`date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
|
|
65
|
+
`response_json`, and the loader's `content_hash`. `operation`, `currency` and
|
|
66
|
+
`indicator` are sent as QuestDB symbols; only `value` is numeric (a double);
|
|
67
|
+
everything else is a string. Null values are omitted from the row, which
|
|
68
|
+
QuestDB stores as `NULL`.
|
|
69
|
+
|
|
70
|
+
Every row is stamped with QuestDB's server timestamp at ingestion
|
|
71
|
+
(`ServerTimestamp`). The designated timestamp therefore records when you
|
|
72
|
+
loaded the data, never when a release was published. `date` and
|
|
73
|
+
`announcement_datetime` remain source strings in their own columns; no
|
|
74
|
+
publication time is inferred, and no missing value is filled. `value_text` and
|
|
75
|
+
JSON preserve source numbers supplied as strings. JSON numeric tokens follow the
|
|
76
|
+
public Python client parser, so original lexical precision is not guaranteed.
|
|
77
|
+
|
|
78
|
+
Select operations with the shared catalogue rather than constructing URLs:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from fxmacrodata_platform_core import operations, schemas
|
|
82
|
+
|
|
83
|
+
available = operations()
|
|
84
|
+
input_schemas = schemas()
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`load_operation` accepts `api_key_env`, `complete_history`, `max_pages`, and
|
|
88
|
+
`client_factory` for the public source reader. `max_pages` bounds history
|
|
89
|
+
pagination; an incomplete read raises an error before a destination load starts.
|
|
90
|
+
`batch_size` bounds destination blocks to 1–10,000 rows; the sender is flushed
|
|
91
|
+
after each block. `max_batch_bytes` also bounds each block using a conservative
|
|
92
|
+
JSON byte estimate (16 MiB by default, 128 MiB maximum). An oversized individual
|
|
93
|
+
envelope is rejected before destination changes; narrow the source date range
|
|
94
|
+
to reduce the response size. The source response is materialized before
|
|
95
|
+
loading; choose a source date range suitable for memory. Read the installed
|
|
96
|
+
operation's schema for required arguments and date filters.
|
|
97
|
+
|
|
98
|
+
Supply source credentials through your runtime secret store, exposing only the
|
|
99
|
+
environment variable name to the adapter. Destination authentication belongs to
|
|
100
|
+
the `Sender` you construct. The adapter neither saves nor prints credentials
|
|
101
|
+
and never constructs connection configuration for you.
|
|
102
|
+
|
|
103
|
+
The package licence covers this adapter code. FXMacroData data access and onward
|
|
104
|
+
redistribution remain subject to your subscription and applicable licence. Loading
|
|
105
|
+
data into a database does not grant permission to publish or resell it.
|
|
106
|
+
|
|
107
|
+
## Retries and querying
|
|
108
|
+
|
|
109
|
+
Delivery is at-least-once. QuestDB's ingestion protocol has no unique key, and
|
|
110
|
+
because rows carry a server timestamp, a retried batch appends rows again with
|
|
111
|
+
a later timestamp. Every row carries `content_hash` (a digest of the ten source
|
|
112
|
+
columns), so duplicates are exact and easy to collapse:
|
|
113
|
+
|
|
114
|
+
```sql
|
|
115
|
+
SELECT currency, indicator, date, value, record_json
|
|
116
|
+
FROM fxmacrodata
|
|
117
|
+
WHERE record_json <> 'null'
|
|
118
|
+
LATEST ON timestamp PARTITION BY content_hash;
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Changed record data or response metadata produces a distinct content hash and is
|
|
122
|
+
retained alongside the earlier version. Blocks are not retried automatically.
|
|
123
|
+
On a failure, earlier blocks were already flushed; retrying the same source
|
|
124
|
+
batch is safe when you deduplicate on `content_hash` as above.
|
|
125
|
+
`LoadResult.rows` counts envelopes submitted.
|
|
126
|
+
|
|
127
|
+
See [event_study.sql](examples/event_study.sql) for an announcement/price join.
|
|
128
|
+
The example expects your own licensed market-price table and uses source
|
|
129
|
+
announcement timestamps only. Timestamp presence alone is not evidence that a
|
|
130
|
+
release had already been published; use source publication metadata when
|
|
131
|
+
building a point-in-time backtest.
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
Tests are offline and fake the sender:
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
python -m pytest tests -q
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Official client reference:
|
|
142
|
+
[QuestDB Python client](https://py-questdb-client.readthedocs.io/?utm_source=pypi&utm_medium=referral&utm_campaign=questdb-fxmacrodata&utm_content=upstream-reference).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
fxmacrodata_questdb/__init__.py,sha256=2M0k7qjh2AsZ3BugrtB8hRXcEjz0nHvfPuOa6A7ls1s,5934
|
|
2
|
+
fxmacrodata_questdb/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
questdb_fxmacrodata-0.1.0.dist-info/licenses/LICENSE,sha256=rklK7Fl8aL1eWGubsrozZ7rCNTsN-MATw4M6LQIMv10,1092
|
|
4
|
+
questdb_fxmacrodata-0.1.0.dist-info/METADATA,sha256=wulqXlplww7IL7PJTvrDRqItglAi5mqOUs9YnjTVmuQ,6646
|
|
5
|
+
questdb_fxmacrodata-0.1.0.dist-info/WHEEL,sha256=16fc_9Rp854SLcdhaDEjr0u65p0e3FhZxihNgcnCE98,91
|
|
6
|
+
questdb_fxmacrodata-0.1.0.dist-info/top_level.txt,sha256=Q8oDNb5DYbVh36LSVbMAzbsjzpkQog7iOs3YA9b0SUs,20
|
|
7
|
+
questdb_fxmacrodata-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Tidball
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fxmacrodata_questdb
|