questdb-fxmacrodata 0.1.0__tar.gz

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,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,11 @@
1
+ include LICENSE
2
+ include README.md
3
+ include pyproject.toml
4
+ include PUBLIC_FILES.json
5
+ include MANIFEST.in
6
+ include src/fxmacrodata_questdb/__init__.py
7
+ include src/fxmacrodata_questdb/py.typed
8
+ include tests/test_loader.py
9
+ include examples/event_study.sql
10
+ global-exclude *.pyc *.pyo
11
+ global-exclude __pycache__/*
@@ -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,11 @@
1
+ [
2
+ "LICENSE",
3
+ "README.md",
4
+ "pyproject.toml",
5
+ "PUBLIC_FILES.json",
6
+ "MANIFEST.in",
7
+ "src/fxmacrodata_questdb/__init__.py",
8
+ "src/fxmacrodata_questdb/py.typed",
9
+ "tests/test_loader.py",
10
+ "examples/event_study.sql"
11
+ ]
@@ -0,0 +1,127 @@
1
+ # FXMacroData for QuestDB
2
+
3
+ Load FXMacroData public responses into QuestDB with the official `questdb`
4
+ Python client's ingestion `Sender`. Keep macro announcements next to your
5
+ time-series market data while retaining the source payload and revision
6
+ metadata.
7
+
8
+ [FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=questdb-fxmacrodata&utm_content=readme)
9
+
10
+ ## Install and connect
11
+
12
+ Requires Python 3.10 or later. Install this package and its adjacent public
13
+ client dependencies from the distribution supplied to you:
14
+
15
+ ```sh
16
+ python -m pip install /path/to/fxmacrodata-public-client /path/to/fxmacrodata-platform-core /path/to/questdb-fxmacrodata
17
+ ```
18
+
19
+ Configure `FXMD_API_KEY` and `QDB_CLIENT_CONF` (the QuestDB client's own
20
+ configuration string, including any token) in your runtime secret store. Then
21
+ load into a customer-owned QuestDB instance:
22
+
23
+ ```python
24
+ from questdb.ingress import Sender
25
+ from fxmacrodata_platform_core import read
26
+ from fxmacrodata_questdb import load
27
+
28
+ with Sender.from_env() as sender:
29
+ batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
30
+ result = load(batch, sender, "fxmacrodata", batch_size=1000)
31
+ ```
32
+
33
+ USD operations work without a key. For other currencies pass
34
+ `api_key_env="FXMD_API_KEY"` to `read` or `load_operation`.
35
+
36
+ You open and close the `Sender`; the loader only calls `row` and `flush`.
37
+ QuestDB creates the table on first ingestion. Table names are one simple
38
+ identifier such as `fxmacrodata`; dotted names, SQL fragments and URL-like
39
+ names are rejected.
40
+
41
+ Every operation exposed by `fxmacrodata_platform_core.operations()` is available,
42
+ including REST and MCP operations. The loader adds a tabular envelope without
43
+ changing the original public response. `record_json` retains the original record;
44
+ `response_json` retains the complete response, including pagination and metadata.
45
+ An empty response produces one envelope with `record_json = 'null'`, so absence of
46
+ data and the server's metadata remain inspectable. It is not an observation.
47
+
48
+ Columns are `operation`, `source_url`, `record_id`, `currency`, `indicator`,
49
+ `date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
50
+ `response_json`, and the loader's `content_hash`. `operation`, `currency` and
51
+ `indicator` are sent as QuestDB symbols; only `value` is numeric (a double);
52
+ everything else is a string. Null values are omitted from the row, which
53
+ QuestDB stores as `NULL`.
54
+
55
+ Every row is stamped with QuestDB's server timestamp at ingestion
56
+ (`ServerTimestamp`). The designated timestamp therefore records when you
57
+ loaded the data, never when a release was published. `date` and
58
+ `announcement_datetime` remain source strings in their own columns; no
59
+ publication time is inferred, and no missing value is filled. `value_text` and
60
+ JSON preserve source numbers supplied as strings. JSON numeric tokens follow the
61
+ public Python client parser, so original lexical precision is not guaranteed.
62
+
63
+ Select operations with the shared catalogue rather than constructing URLs:
64
+
65
+ ```python
66
+ from fxmacrodata_platform_core import operations, schemas
67
+
68
+ available = operations()
69
+ input_schemas = schemas()
70
+ ```
71
+
72
+ `load_operation` accepts `api_key_env`, `complete_history`, `max_pages`, and
73
+ `client_factory` for the public source reader. `max_pages` bounds history
74
+ pagination; an incomplete read raises an error before a destination load starts.
75
+ `batch_size` bounds destination blocks to 1–10,000 rows; the sender is flushed
76
+ after each block. `max_batch_bytes` also bounds each block using a conservative
77
+ JSON byte estimate (16 MiB by default, 128 MiB maximum). An oversized individual
78
+ envelope is rejected before destination changes; narrow the source date range
79
+ to reduce the response size. The source response is materialized before
80
+ loading; choose a source date range suitable for memory. Read the installed
81
+ operation's schema for required arguments and date filters.
82
+
83
+ Supply source credentials through your runtime secret store, exposing only the
84
+ environment variable name to the adapter. Destination authentication belongs to
85
+ the `Sender` you construct. The adapter neither saves nor prints credentials
86
+ and never constructs connection configuration for you.
87
+
88
+ The package licence covers this adapter code. FXMacroData data access and onward
89
+ redistribution remain subject to your subscription and applicable licence. Loading
90
+ data into a database does not grant permission to publish or resell it.
91
+
92
+ ## Retries and querying
93
+
94
+ Delivery is at-least-once. QuestDB's ingestion protocol has no unique key, and
95
+ because rows carry a server timestamp, a retried batch appends rows again with
96
+ a later timestamp. Every row carries `content_hash` (a digest of the ten source
97
+ columns), so duplicates are exact and easy to collapse:
98
+
99
+ ```sql
100
+ SELECT currency, indicator, date, value, record_json
101
+ FROM fxmacrodata
102
+ WHERE record_json <> 'null'
103
+ LATEST ON timestamp PARTITION BY content_hash;
104
+ ```
105
+
106
+ Changed record data or response metadata produces a distinct content hash and is
107
+ retained alongside the earlier version. Blocks are not retried automatically.
108
+ On a failure, earlier blocks were already flushed; retrying the same source
109
+ batch is safe when you deduplicate on `content_hash` as above.
110
+ `LoadResult.rows` counts envelopes submitted.
111
+
112
+ See [event_study.sql](examples/event_study.sql) for an announcement/price join.
113
+ The example expects your own licensed market-price table and uses source
114
+ announcement timestamps only. Timestamp presence alone is not evidence that a
115
+ release had already been published; use source publication metadata when
116
+ building a point-in-time backtest.
117
+
118
+ ## Development
119
+
120
+ Tests are offline and fake the sender:
121
+
122
+ ```sh
123
+ python -m pytest tests -q
124
+ ```
125
+
126
+ Official client reference:
127
+ [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,24 @@
1
+ -- Requires loaded announcement envelopes and your market_prices(currency SYMBOL, ts TIMESTAMP, close DOUBLE).
2
+ -- announcement_datetime is a source string; it is parsed here, not inferred.
3
+ -- Inspect publisher/publication metadata before using results in a point-in-time backtest.
4
+ WITH events AS (
5
+ SELECT DISTINCT record_id, currency,
6
+ to_timestamp(announcement_datetime, 'yyyy-MM-ddTHH:mm:ssZ') AS event_time
7
+ FROM fxmacrodata
8
+ WHERE announcement_datetime IS NOT NULL AND record_json <> 'null'
9
+ ), pre AS (
10
+ SELECT e.record_id, e.currency, e.event_time, last(p.close) AS pre_price
11
+ FROM events e
12
+ JOIN market_prices p ON p.currency = e.currency
13
+ WHERE p.ts BETWEEN dateadd('m', -5, e.event_time) AND e.event_time
14
+ GROUP BY e.record_id, e.currency, e.event_time
15
+ ), post AS (
16
+ SELECT e.record_id, first(p.close) AS post_price
17
+ FROM events e
18
+ JOIN market_prices p ON p.currency = e.currency
19
+ WHERE p.ts BETWEEN dateadd('m', 5, e.event_time) AND dateadd('m', 10, e.event_time)
20
+ GROUP BY e.record_id
21
+ )
22
+ SELECT pre.record_id, pre.currency, pre.event_time, pre.pre_price, post.post_price,
23
+ (post.post_price / nullif(pre.pre_price, 0) - 1) * 100 AS return_pct
24
+ FROM pre JOIN post ON pre.record_id = post.record_id;
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "questdb-fxmacrodata"
7
+ version = "0.1.0"
8
+ description = "Load public FXMacroData responses into QuestDB"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ dependencies = ["fxmacrodata-platform-core==0.1.0", "questdb>=5.0.0,<6"]
13
+
14
+ [project.urls]
15
+ Documentation = "https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=questdb-fxmacrodata&utm_content=package"
16
+
17
+ [project.optional-dependencies]
18
+ test = ["pytest>=8,<9"]
19
+
20
+ [tool.setuptools]
21
+ packages = ["fxmacrodata_questdb"]
22
+ package-dir = {"" = "src"}
23
+ include-package-data = false
24
+
25
+ [tool.setuptools.package-data]
26
+ fxmacrodata_questdb = ["py.typed"]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+ addopts = "-q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"]
@@ -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,14 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ PUBLIC_FILES.json
4
+ README.md
5
+ pyproject.toml
6
+ examples/event_study.sql
7
+ src/fxmacrodata_questdb/__init__.py
8
+ src/fxmacrodata_questdb/py.typed
9
+ src/questdb_fxmacrodata.egg-info/PKG-INFO
10
+ src/questdb_fxmacrodata.egg-info/SOURCES.txt
11
+ src/questdb_fxmacrodata.egg-info/dependency_links.txt
12
+ src/questdb_fxmacrodata.egg-info/requires.txt
13
+ src/questdb_fxmacrodata.egg-info/top_level.txt
14
+ tests/test_loader.py
@@ -0,0 +1,5 @@
1
+ fxmacrodata-platform-core==0.1.0
2
+ questdb<6,>=5.0.0
3
+
4
+ [test]
5
+ pytest<9,>=8
@@ -0,0 +1 @@
1
+ fxmacrodata_questdb
@@ -0,0 +1,148 @@
1
+ import json
2
+
3
+ import pytest
4
+ from questdb import ServerTimestamp
5
+
6
+ from fxmacrodata_platform_core import Batch
7
+ import fxmacrodata_questdb as adapter
8
+
9
+
10
+ class Sender:
11
+ def __init__(self, fail_block=None):
12
+ self.rows = []
13
+ self.flushes = 0
14
+ self.pending = 0
15
+ self.fail_block = fail_block
16
+
17
+ def row(self, table_name, *, symbols=None, columns=None, at=None):
18
+ self.rows.append((table_name, dict(symbols or {}), dict(columns or {}), at))
19
+ self.pending += 1
20
+
21
+ def flush(self):
22
+ self.flushes += 1
23
+ self.pending = 0
24
+ if self.flushes == self.fail_block:
25
+ raise RuntimeError("private destination diagnostic")
26
+
27
+
28
+ def batch(count=3, metadata=None):
29
+ return Batch("indicator_history", {
30
+ "data": [{"currency": "USD", "indicator": "policy_rate", "value": str(i),
31
+ "date": "2026-10-01", "publication_time_status": "unknown"}
32
+ for i in range(count)],
33
+ "metadata": metadata or {"revision": 1},
34
+ })
35
+
36
+
37
+ def test_rows_preserve_full_payload_with_server_timestamps():
38
+ source, sender = batch(), Sender()
39
+ result = adapter.load(source, sender, "fxmacrodata", batch_size=2)
40
+ assert (result.rows, result.batches) == (3, 2)
41
+ assert len(sender.rows) == 3 and sender.flushes == 2 and sender.pending == 0
42
+ table, symbols, columns, at = sender.rows[0]
43
+ assert table == "fxmacrodata"
44
+ assert at is ServerTimestamp
45
+ assert symbols == {"operation": "indicator_history", "currency": "USD", "indicator": "policy_rate"}
46
+ assert columns["date"] == "2026-10-01" and isinstance(columns["date"], str)
47
+ assert "announcement_datetime" not in columns
48
+ assert columns["value"] == 0.0 and isinstance(columns["value"], float)
49
+ assert columns["value_text"] == "0"
50
+ assert json.loads(columns["record_json"])["publication_time_status"] == "unknown"
51
+ assert json.loads(columns["response_json"]) == source.payload
52
+ assert set(symbols) | set(columns) <= set(adapter.COLUMNS)
53
+ assert set(symbols) | set(columns) == set(adapter.COLUMNS) - {"announcement_datetime"}
54
+
55
+
56
+ def test_empty_response_is_a_metadata_envelope_not_an_observation():
57
+ source, sender = batch(0), Sender()
58
+ result = adapter.load(source, sender, "fxmacrodata")
59
+ table, symbols, columns, at = sender.rows[0]
60
+ assert result.rows == 1 and columns["record_json"] == "null"
61
+ assert symbols == {"operation": "indicator_history"}
62
+ assert "value" not in columns and "date" not in columns
63
+ assert json.loads(columns["response_json"]) == source.payload
64
+ assert at is ServerTimestamp
65
+
66
+
67
+ def test_retries_have_stable_keys_and_changed_metadata_is_retained():
68
+ first, retry, changed = Sender(), Sender(), Sender()
69
+ adapter.load(batch(1), first, "fxmacrodata")
70
+ adapter.load(batch(1), retry, "fxmacrodata")
71
+ adapter.load(batch(1, {"revision": 2}), changed, "fxmacrodata")
72
+ assert first.rows == retry.rows
73
+ assert first.rows[0][2]["record_id"] == changed.rows[0][2]["record_id"]
74
+ assert first.rows[0][2]["content_hash"] != changed.rows[0][2]["content_hash"]
75
+
76
+
77
+ @pytest.mark.parametrize("table", ["db.table", "a b", "https://host/table", "a`b", "", None, "x" * 129])
78
+ def test_bad_identifiers_never_execute(table):
79
+ sender = Sender()
80
+ with pytest.raises(ValueError):
81
+ adapter.load(batch(), sender, table)
82
+ assert sender.rows == [] and sender.flushes == 0
83
+
84
+
85
+ @pytest.mark.parametrize("size", [0, -1, 10001, True, 2.5])
86
+ def test_batch_bounds(size):
87
+ sender = Sender()
88
+ with pytest.raises(ValueError):
89
+ adapter.load(batch(), sender, "fxmacrodata", batch_size=size)
90
+ assert sender.rows == []
91
+
92
+
93
+ def test_failed_block_is_not_retried_or_exposed():
94
+ sender = Sender(fail_block=2)
95
+ with pytest.raises(adapter.LoadError) as error:
96
+ adapter.load(batch(), sender, "fxmacrodata", batch_size=1)
97
+ assert "block 2" in str(error.value)
98
+ assert "private destination diagnostic" not in str(error.value)
99
+ assert sender.flushes == 2 and len(sender.rows) == 2
100
+
101
+
102
+ def test_row_rejection_is_not_exposed():
103
+ class Broken(Sender):
104
+ def row(self, *args, **kwargs):
105
+ raise RuntimeError("private destination diagnostic")
106
+ with pytest.raises(adapter.LoadError) as error:
107
+ adapter.load(batch(), Broken(), "fxmacrodata")
108
+ assert "private destination diagnostic" not in str(error.value)
109
+
110
+
111
+ def test_operation_forwards_public_reader_options(monkeypatch):
112
+ calls = []
113
+ monkeypatch.setattr(adapter, "read", lambda *args, **kw: calls.append((args, kw)) or batch(1))
114
+ factory = object()
115
+ adapter.load_operation("indicator_history", {"currency": "usd", "indicator": "policy_rate"},
116
+ Sender(), "fxmacrodata", api_key_env="FXMD_API_KEY",
117
+ max_pages=12, client_factory=factory)
118
+ assert calls[0][1] == dict(api_key_env="FXMD_API_KEY", complete_history=True,
119
+ max_pages=12, client_factory=factory)
120
+
121
+
122
+ def test_credential_arguments_are_rejected_by_the_reader():
123
+ from fxmacrodata_platform_core import IntegrationError
124
+ with pytest.raises(IntegrationError):
125
+ adapter.load_operation("indicator_history",
126
+ {"currency": "usd", "indicator": "policy_rate", "api_key": "x"},
127
+ Sender(), "fxmacrodata")
128
+
129
+
130
+ def test_byte_limit_splits_blocks_and_rejects_oversized_envelopes_before_sending():
131
+ source, sender = batch(), Sender()
132
+ budget = max(len(json.dumps(r, ensure_ascii=True).encode()) + 2 for r in adapter._rows(source))
133
+ result = adapter.load(source, sender, "fxmacrodata", batch_size=100, max_batch_bytes=budget)
134
+ assert result.batches == 3 and sender.flushes == 3
135
+ untouched = Sender()
136
+ with pytest.raises(ValueError, match="narrow the source date range"):
137
+ adapter.load(source, untouched, "fxmacrodata", max_batch_bytes=1)
138
+ assert untouched.rows == [] and untouched.flushes == 0
139
+
140
+
141
+ def test_invalid_loader_configuration_never_reads_source(monkeypatch):
142
+ def unexpected(*args, **kwargs):
143
+ pytest.fail("source must not be called for invalid loader configuration")
144
+ monkeypatch.setattr(adapter, "read", unexpected)
145
+ for options in ({"batch_size": 0}, {"max_batch_bytes": 0}, {"table": "a.b"}):
146
+ with pytest.raises(ValueError):
147
+ adapter.load_operation("release_calendar", {"currency": "usd"}, Sender(),
148
+ **{"table": "fxmacrodata", **options})