sqlite-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_sqlite/__init__.py
7
+ include src/fxmacrodata_sqlite/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,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlite-fxmacrodata
3
+ Version: 0.1.0
4
+ Summary: Load public FXMacroData responses into SQLite
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=sqlite-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
+ Provides-Extra: test
12
+ Requires-Dist: pytest<9,>=8; extra == "test"
13
+ Dynamic: license-file
14
+
15
+ # FXMacroData for SQLite
16
+
17
+ Load FXMacroData public responses into a SQLite database file with the Python
18
+ standard library's `sqlite3` module. No third-party database driver is
19
+ required, which makes this the simplest way to keep a local, queryable copy of
20
+ macro announcements while retaining the source payload and revision metadata.
21
+
22
+ [FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=sqlite-fxmacrodata&utm_content=readme)
23
+
24
+ ## Install and connect
25
+
26
+ Requires Python 3.10 or later. Install this package and its adjacent public
27
+ client dependencies from the distribution supplied to you:
28
+
29
+ ```sh
30
+ python -m pip install /path/to/fxmacrodata-public-client /path/to/fxmacrodata-platform-core /path/to/sqlite-fxmacrodata
31
+ ```
32
+
33
+ Then load into a database file you own:
34
+
35
+ ```python
36
+ import sqlite3
37
+ from fxmacrodata_platform_core import read
38
+ from fxmacrodata_sqlite import load
39
+
40
+ with sqlite3.connect("fxmacrodata.db") as connection:
41
+ batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
42
+ result = load(batch, connection, "fxmacrodata", batch_size=1000)
43
+ ```
44
+
45
+ USD operations work without a key. For other currencies configure
46
+ `FXMD_API_KEY` in your runtime secret store and pass
47
+ `api_key_env="FXMD_API_KEY"` to `read` or `load_operation`.
48
+
49
+ By default the loader creates the envelope table if absent. Set
50
+ `create_table=False` to use a table you provisioned yourself;
51
+ `create_table_sql(table)` returns the required DDL and `insert_sql(table)` the
52
+ statement used for every block. Identifiers accept one or two simple names,
53
+ such as `fxmacrodata` or `analytics.fxmacrodata` for an attached database, and
54
+ are always double-quoted. Quoted names, SQL fragments and URL-like names are
55
+ rejected.
56
+
57
+ Every operation exposed by `fxmacrodata_platform_core.operations()` is available,
58
+ including REST and MCP operations. The loader adds a tabular envelope without
59
+ changing the original public response. `record_json` retains the original record;
60
+ `response_json` retains the complete response, including pagination and metadata.
61
+ An empty response produces one envelope with `record_json = 'null'`, so absence of
62
+ data and the server's metadata remain inspectable. It is not an observation.
63
+
64
+ Columns are `operation`, `source_url`, `record_id`, `currency`, `indicator`,
65
+ `date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
66
+ `response_json`, and the loader's `content_hash`. Only `value` is numeric
67
+ (`REAL`); everything else is `TEXT`. Dates and announcement times remain source
68
+ strings. No publication time is inferred, and no missing value is filled.
69
+ `value_text` and JSON preserve source numbers supplied as strings. JSON numeric
70
+ tokens follow the public Python client parser, so original lexical precision is
71
+ not guaranteed. SQLite's JSON functions (`json_extract`) work directly on the
72
+ JSON columns.
73
+
74
+ Select operations with the shared catalogue rather than constructing URLs:
75
+
76
+ ```python
77
+ from fxmacrodata_platform_core import operations, schemas
78
+
79
+ available = operations()
80
+ input_schemas = schemas()
81
+ ```
82
+
83
+ `load_operation` accepts `api_key_env`, `complete_history`, `max_pages`, and
84
+ `client_factory` for the public source reader. `max_pages` bounds history
85
+ pagination; an incomplete read raises an error before a destination load starts.
86
+ `batch_size` bounds destination blocks to 1–10,000 rows. `max_batch_bytes` also
87
+ bounds each block using a conservative JSON byte estimate (16 MiB by default,
88
+ 128 MiB maximum). An oversized individual envelope is rejected before destination
89
+ changes; narrow the source date range to reduce the response size. The source
90
+ response is materialized before loading; choose a source date range suitable for
91
+ memory. Read the installed operation's schema for required arguments and date filters.
92
+
93
+ Supply source credentials through your runtime secret store, exposing only the
94
+ environment variable name to the adapter. The database file is opened by you;
95
+ the adapter never chooses a path, saves or prints credentials.
96
+
97
+ The package licence covers this adapter code. FXMacroData data access and onward
98
+ redistribution remain subject to your subscription and applicable licence. Loading
99
+ data into a database does not grant permission to publish or resell it.
100
+
101
+ ## Retries and querying
102
+
103
+ The table's primary key is `(operation, record_id, content_hash)` and every
104
+ block is inserted with `INSERT OR IGNORE` inside its own transaction. Retrying
105
+ the same source batch is idempotent: identical envelopes are skipped, while
106
+ changed record data or response metadata produces a distinct content hash and
107
+ is retained alongside the earlier version.
108
+
109
+ ```sql
110
+ SELECT currency, indicator, date, value,
111
+ json_extract(record_json, '$.publication_time_status') AS status
112
+ FROM fxmacrodata
113
+ WHERE record_json <> 'null';
114
+ ```
115
+
116
+ Delivery is at-least-once with an idempotent key. Blocks are not retried
117
+ automatically. On a failure the failing block is rolled back; earlier blocks
118
+ are already committed, and retrying the same source batch is safe.
119
+ `LoadResult.rows` counts envelopes submitted, rather than newly created rows.
120
+
121
+ See [event_study.sql](examples/event_study.sql) for an announcement/price join.
122
+ The example expects your own licensed market-price table and uses source
123
+ announcement timestamps only. Timestamp presence alone is not evidence that a
124
+ release had already been published; use source publication metadata when
125
+ building a point-in-time backtest.
126
+
127
+ ## Development
128
+
129
+ Tests are offline and use an in-memory database:
130
+
131
+ ```sh
132
+ python -m pytest tests -q
133
+ ```
134
+
135
+ Standard library reference:
136
+ [sqlite3 documentation](https://docs.python.org/3/library/sqlite3.html?utm_source=pypi&utm_medium=referral&utm_campaign=sqlite-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_sqlite/__init__.py",
8
+ "src/fxmacrodata_sqlite/py.typed",
9
+ "tests/test_loader.py",
10
+ "examples/event_study.sql"
11
+ ]
@@ -0,0 +1,122 @@
1
+ # FXMacroData for SQLite
2
+
3
+ Load FXMacroData public responses into a SQLite database file with the Python
4
+ standard library's `sqlite3` module. No third-party database driver is
5
+ required, which makes this the simplest way to keep a local, queryable copy of
6
+ macro announcements while retaining the source payload and revision metadata.
7
+
8
+ [FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=sqlite-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/sqlite-fxmacrodata
17
+ ```
18
+
19
+ Then load into a database file you own:
20
+
21
+ ```python
22
+ import sqlite3
23
+ from fxmacrodata_platform_core import read
24
+ from fxmacrodata_sqlite import load
25
+
26
+ with sqlite3.connect("fxmacrodata.db") as connection:
27
+ batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
28
+ result = load(batch, connection, "fxmacrodata", batch_size=1000)
29
+ ```
30
+
31
+ USD operations work without a key. For other currencies configure
32
+ `FXMD_API_KEY` in your runtime secret store and pass
33
+ `api_key_env="FXMD_API_KEY"` to `read` or `load_operation`.
34
+
35
+ By default the loader creates the envelope table if absent. Set
36
+ `create_table=False` to use a table you provisioned yourself;
37
+ `create_table_sql(table)` returns the required DDL and `insert_sql(table)` the
38
+ statement used for every block. Identifiers accept one or two simple names,
39
+ such as `fxmacrodata` or `analytics.fxmacrodata` for an attached database, and
40
+ are always double-quoted. Quoted names, SQL fragments and URL-like names are
41
+ rejected.
42
+
43
+ Every operation exposed by `fxmacrodata_platform_core.operations()` is available,
44
+ including REST and MCP operations. The loader adds a tabular envelope without
45
+ changing the original public response. `record_json` retains the original record;
46
+ `response_json` retains the complete response, including pagination and metadata.
47
+ An empty response produces one envelope with `record_json = 'null'`, so absence of
48
+ data and the server's metadata remain inspectable. It is not an observation.
49
+
50
+ Columns are `operation`, `source_url`, `record_id`, `currency`, `indicator`,
51
+ `date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
52
+ `response_json`, and the loader's `content_hash`. Only `value` is numeric
53
+ (`REAL`); everything else is `TEXT`. Dates and announcement times remain source
54
+ strings. No publication time is inferred, and no missing value is filled.
55
+ `value_text` and JSON preserve source numbers supplied as strings. JSON numeric
56
+ tokens follow the public Python client parser, so original lexical precision is
57
+ not guaranteed. SQLite's JSON functions (`json_extract`) work directly on the
58
+ JSON columns.
59
+
60
+ Select operations with the shared catalogue rather than constructing URLs:
61
+
62
+ ```python
63
+ from fxmacrodata_platform_core import operations, schemas
64
+
65
+ available = operations()
66
+ input_schemas = schemas()
67
+ ```
68
+
69
+ `load_operation` accepts `api_key_env`, `complete_history`, `max_pages`, and
70
+ `client_factory` for the public source reader. `max_pages` bounds history
71
+ pagination; an incomplete read raises an error before a destination load starts.
72
+ `batch_size` bounds destination blocks to 1–10,000 rows. `max_batch_bytes` also
73
+ bounds each block using a conservative JSON byte estimate (16 MiB by default,
74
+ 128 MiB maximum). An oversized individual envelope is rejected before destination
75
+ changes; narrow the source date range to reduce the response size. The source
76
+ response is materialized before loading; choose a source date range suitable for
77
+ memory. Read the installed operation's schema for required arguments and date filters.
78
+
79
+ Supply source credentials through your runtime secret store, exposing only the
80
+ environment variable name to the adapter. The database file is opened by you;
81
+ the adapter never chooses a path, saves or prints credentials.
82
+
83
+ The package licence covers this adapter code. FXMacroData data access and onward
84
+ redistribution remain subject to your subscription and applicable licence. Loading
85
+ data into a database does not grant permission to publish or resell it.
86
+
87
+ ## Retries and querying
88
+
89
+ The table's primary key is `(operation, record_id, content_hash)` and every
90
+ block is inserted with `INSERT OR IGNORE` inside its own transaction. Retrying
91
+ the same source batch is idempotent: identical envelopes are skipped, while
92
+ changed record data or response metadata produces a distinct content hash and
93
+ is retained alongside the earlier version.
94
+
95
+ ```sql
96
+ SELECT currency, indicator, date, value,
97
+ json_extract(record_json, '$.publication_time_status') AS status
98
+ FROM fxmacrodata
99
+ WHERE record_json <> 'null';
100
+ ```
101
+
102
+ Delivery is at-least-once with an idempotent key. Blocks are not retried
103
+ automatically. On a failure the failing block is rolled back; earlier blocks
104
+ are already committed, and retrying the same source batch is safe.
105
+ `LoadResult.rows` counts envelopes submitted, rather than newly created rows.
106
+
107
+ See [event_study.sql](examples/event_study.sql) for an announcement/price join.
108
+ The example expects your own licensed market-price table and uses source
109
+ announcement timestamps only. Timestamp presence alone is not evidence that a
110
+ release had already been published; use source publication metadata when
111
+ building a point-in-time backtest.
112
+
113
+ ## Development
114
+
115
+ Tests are offline and use an in-memory database:
116
+
117
+ ```sh
118
+ python -m pytest tests -q
119
+ ```
120
+
121
+ Standard library reference:
122
+ [sqlite3 documentation](https://docs.python.org/3/library/sqlite3.html?utm_source=pypi&utm_medium=referral&utm_campaign=sqlite-fxmacrodata&utm_content=upstream-reference).
@@ -0,0 +1,26 @@
1
+ -- Requires loaded announcement envelopes and your market_prices(currency, ts, close),
2
+ -- where ts is an ISO-8601 UTC text timestamp comparable with announcement_datetime.
3
+ -- This uses announcement times, not inferred publication times.
4
+ -- Inspect publisher/publication metadata before using results in a point-in-time backtest.
5
+ WITH events AS (
6
+ SELECT DISTINCT record_id, currency, announcement_datetime AS event_time
7
+ FROM fxmacrodata
8
+ WHERE announcement_datetime IS NOT NULL AND record_json <> 'null'
9
+ ), windows AS (
10
+ SELECT e.record_id, e.currency, e.event_time,
11
+ (SELECT p.close FROM market_prices p
12
+ WHERE p.currency = e.currency
13
+ AND p.ts <= e.event_time
14
+ AND p.ts >= datetime(e.event_time, '-5 minutes')
15
+ ORDER BY p.ts DESC LIMIT 1) AS pre_price,
16
+ (SELECT p.close FROM market_prices p
17
+ WHERE p.currency = e.currency
18
+ AND p.ts >= datetime(e.event_time, '+5 minutes')
19
+ AND p.ts <= datetime(e.event_time, '+10 minutes')
20
+ ORDER BY p.ts ASC LIMIT 1) AS post_price
21
+ FROM events e
22
+ )
23
+ SELECT record_id, currency, event_time, pre_price, post_price,
24
+ (post_price / NULLIF(pre_price, 0) - 1) * 100 AS return_pct
25
+ FROM windows
26
+ WHERE pre_price IS NOT NULL AND post_price IS NOT NULL;
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sqlite-fxmacrodata"
7
+ version = "0.1.0"
8
+ description = "Load public FXMacroData responses into SQLite"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ dependencies = ["fxmacrodata-platform-core==0.1.0"]
13
+
14
+ [project.urls]
15
+ Documentation = "https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=sqlite-fxmacrodata&utm_content=package"
16
+
17
+ [project.optional-dependencies]
18
+ test = ["pytest>=8,<9"]
19
+
20
+ [tool.setuptools]
21
+ packages = ["fxmacrodata_sqlite"]
22
+ package-dir = {"" = "src"}
23
+ include-package-data = false
24
+
25
+ [tool.setuptools.package-data]
26
+ fxmacrodata_sqlite = ["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,158 @@
1
+ """Load public FXMacroData responses into a customer-owned SQLite 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
+ from fxmacrodata_platform_core import read
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ COLUMNS = (
17
+ "operation", "source_url", "record_id", "currency", "indicator", "date",
18
+ "announcement_datetime", "value", "value_text", "record_json", "response_json",
19
+ "content_hash",
20
+ )
21
+ TYPES = tuple(
22
+ "REAL" if name == "value" else
23
+ "TEXT NOT NULL" if name in {"operation", "record_id", "content_hash"} else "TEXT"
24
+ for name in COLUMNS
25
+ )
26
+ _IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,127}\Z")
27
+
28
+
29
+ class LoadError(RuntimeError):
30
+ """A destination operation failed; inspect the destination before retrying."""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class LoadResult:
35
+ table: str
36
+ rows: int
37
+ batches: int
38
+
39
+
40
+ def _table(table: str) -> tuple[str, str]:
41
+ if not isinstance(table, str):
42
+ raise ValueError("table must be a schema.table or table identifier")
43
+ parts = table.split(".")
44
+ if not 1 <= len(parts) <= 2 or any(not _IDENTIFIER.fullmatch(p) for p in parts):
45
+ raise ValueError("table must contain one or two simple SQL identifiers")
46
+ return ".".join(parts), ".".join(f'"{p}"' for p in parts)
47
+
48
+
49
+ def _rows(batch: Any) -> list[dict[str, Any]]:
50
+ rows = []
51
+ for source in batch.rows():
52
+ row = {name: source.get(name) for name in COLUMNS[:-1]}
53
+ if not isinstance(row["operation"], str) or not isinstance(row["record_id"], str):
54
+ raise ValueError("every row requires string operation and record_id")
55
+ for name, value in row.items():
56
+ if name == "value":
57
+ if value is not None and (
58
+ isinstance(value, bool) or not isinstance(value, (int, float)) or
59
+ not math.isfinite(value)
60
+ ):
61
+ raise ValueError("value must be a finite number or null")
62
+ elif value is not None and not isinstance(value, str):
63
+ raise ValueError(f"{name} must be a string or null")
64
+ encoded = json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
65
+ row["content_hash"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
66
+ rows.append(row)
67
+ return rows
68
+
69
+
70
+ def create_table_sql(table: str) -> str:
71
+ """Return DDL for a lossless envelope table keyed for idempotent retries."""
72
+ _, quoted = _table(table)
73
+ fields = ",\n ".join(f'"{name}" {kind}' for name, kind in zip(COLUMNS, TYPES))
74
+ return (
75
+ f"CREATE TABLE IF NOT EXISTS {quoted} (\n {fields},\n"
76
+ ' PRIMARY KEY ("operation", "record_id", "content_hash")\n)'
77
+ )
78
+
79
+
80
+ def insert_sql(table: str) -> str:
81
+ """Return the idempotent INSERT statement used for every block."""
82
+ _, quoted = _table(table)
83
+ names = ", ".join(f'"{name}"' for name in COLUMNS)
84
+ placeholders = ", ".join("?" for _ in COLUMNS)
85
+ return f"INSERT OR IGNORE INTO {quoted} ({names}) VALUES ({placeholders})"
86
+
87
+
88
+ def _blocks(rows: list[dict[str, Any]], batch_size: int, max_batch_bytes: int) -> list[list[dict[str, Any]]]:
89
+ if isinstance(batch_size, bool) or not isinstance(batch_size, int) or not 1 <= batch_size <= 10000:
90
+ raise ValueError("batch_size must be an integer between 1 and 10000")
91
+ if isinstance(max_batch_bytes, bool) or not isinstance(max_batch_bytes, int) or not 1 <= max_batch_bytes <= 128 * 1024 * 1024:
92
+ raise ValueError("max_batch_bytes must be an integer between 1 and 134217728")
93
+ output, block, size = [], [], 0
94
+ for row in rows:
95
+ length = len(json.dumps(row, ensure_ascii=True).encode("utf-8")) + 2
96
+ if length > max_batch_bytes:
97
+ raise ValueError("one envelope exceeds max_batch_bytes; narrow the source date range")
98
+ if block and (len(block) >= batch_size or size + length > max_batch_bytes):
99
+ output.append(block)
100
+ block, size = [], 0
101
+ block.append(row)
102
+ size += length
103
+ if block:
104
+ output.append(block)
105
+ return output
106
+
107
+
108
+ def load(batch: Any, connection: Any, table: str, *, batch_size: int = 1000,
109
+ max_batch_bytes: int = 16 * 1024 * 1024, create_table: bool = True) -> LoadResult:
110
+ """Insert bounded blocks through a ``sqlite3.Connection``, one transaction per block.
111
+
112
+ Every block is an ``INSERT OR IGNORE`` on the envelope primary key, so
113
+ retrying the same source batch stores nothing twice. A failed block is
114
+ rolled back and never retried automatically.
115
+ """
116
+ table, _ = _table(table)
117
+ if isinstance(batch_size, bool) or not isinstance(batch_size, int) or not 1 <= batch_size <= 10000:
118
+ raise ValueError("batch_size must be an integer between 1 and 10000")
119
+ rows = _rows(batch)
120
+ blocks = _blocks(rows, batch_size, max_batch_bytes)
121
+ if create_table:
122
+ try:
123
+ with connection:
124
+ connection.execute(create_table_sql(table))
125
+ except Exception:
126
+ raise LoadError("SQLite table setup failed") from None
127
+ statement = insert_sql(table)
128
+ count = 0
129
+ for block in blocks:
130
+ try:
131
+ with connection:
132
+ connection.executemany(statement, [tuple(row[name] for name in COLUMNS) for row in block])
133
+ except Exception:
134
+ raise LoadError(
135
+ f"SQLite insert block {count + 1} failed and was rolled back; earlier blocks "
136
+ "are committed. Inspect the destination; retrying the same batch is idempotent."
137
+ ) from None
138
+ count += 1
139
+ return LoadResult(table, len(rows), count)
140
+
141
+
142
+ def load_operation(operation: str, args: dict[str, Any] | None, connection: Any, table: str,
143
+ *, api_key_env: str | None = None, complete_history: bool = True,
144
+ max_pages: int = 10000, client_factory: Any = None,
145
+ batch_size: int = 1000, max_batch_bytes: int = 16 * 1024 * 1024,
146
+ create_table: bool = True) -> LoadResult:
147
+ """Read one public operation and load its complete response envelope."""
148
+ _table(table)
149
+ _blocks([], batch_size, max_batch_bytes)
150
+ batch = read(operation, args, api_key_env=api_key_env,
151
+ complete_history=complete_history, max_pages=max_pages,
152
+ client_factory=client_factory)
153
+ return load(batch, connection, table, batch_size=batch_size,
154
+ max_batch_bytes=max_batch_bytes, create_table=create_table)
155
+
156
+
157
+ __all__ = ["COLUMNS", "TYPES", "LoadError", "LoadResult", "create_table_sql", "insert_sql",
158
+ "load", "load_operation"]
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlite-fxmacrodata
3
+ Version: 0.1.0
4
+ Summary: Load public FXMacroData responses into SQLite
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=sqlite-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
+ Provides-Extra: test
12
+ Requires-Dist: pytest<9,>=8; extra == "test"
13
+ Dynamic: license-file
14
+
15
+ # FXMacroData for SQLite
16
+
17
+ Load FXMacroData public responses into a SQLite database file with the Python
18
+ standard library's `sqlite3` module. No third-party database driver is
19
+ required, which makes this the simplest way to keep a local, queryable copy of
20
+ macro announcements while retaining the source payload and revision metadata.
21
+
22
+ [FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=sqlite-fxmacrodata&utm_content=readme)
23
+
24
+ ## Install and connect
25
+
26
+ Requires Python 3.10 or later. Install this package and its adjacent public
27
+ client dependencies from the distribution supplied to you:
28
+
29
+ ```sh
30
+ python -m pip install /path/to/fxmacrodata-public-client /path/to/fxmacrodata-platform-core /path/to/sqlite-fxmacrodata
31
+ ```
32
+
33
+ Then load into a database file you own:
34
+
35
+ ```python
36
+ import sqlite3
37
+ from fxmacrodata_platform_core import read
38
+ from fxmacrodata_sqlite import load
39
+
40
+ with sqlite3.connect("fxmacrodata.db") as connection:
41
+ batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
42
+ result = load(batch, connection, "fxmacrodata", batch_size=1000)
43
+ ```
44
+
45
+ USD operations work without a key. For other currencies configure
46
+ `FXMD_API_KEY` in your runtime secret store and pass
47
+ `api_key_env="FXMD_API_KEY"` to `read` or `load_operation`.
48
+
49
+ By default the loader creates the envelope table if absent. Set
50
+ `create_table=False` to use a table you provisioned yourself;
51
+ `create_table_sql(table)` returns the required DDL and `insert_sql(table)` the
52
+ statement used for every block. Identifiers accept one or two simple names,
53
+ such as `fxmacrodata` or `analytics.fxmacrodata` for an attached database, and
54
+ are always double-quoted. Quoted names, SQL fragments and URL-like names are
55
+ rejected.
56
+
57
+ Every operation exposed by `fxmacrodata_platform_core.operations()` is available,
58
+ including REST and MCP operations. The loader adds a tabular envelope without
59
+ changing the original public response. `record_json` retains the original record;
60
+ `response_json` retains the complete response, including pagination and metadata.
61
+ An empty response produces one envelope with `record_json = 'null'`, so absence of
62
+ data and the server's metadata remain inspectable. It is not an observation.
63
+
64
+ Columns are `operation`, `source_url`, `record_id`, `currency`, `indicator`,
65
+ `date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
66
+ `response_json`, and the loader's `content_hash`. Only `value` is numeric
67
+ (`REAL`); everything else is `TEXT`. Dates and announcement times remain source
68
+ strings. No publication time is inferred, and no missing value is filled.
69
+ `value_text` and JSON preserve source numbers supplied as strings. JSON numeric
70
+ tokens follow the public Python client parser, so original lexical precision is
71
+ not guaranteed. SQLite's JSON functions (`json_extract`) work directly on the
72
+ JSON columns.
73
+
74
+ Select operations with the shared catalogue rather than constructing URLs:
75
+
76
+ ```python
77
+ from fxmacrodata_platform_core import operations, schemas
78
+
79
+ available = operations()
80
+ input_schemas = schemas()
81
+ ```
82
+
83
+ `load_operation` accepts `api_key_env`, `complete_history`, `max_pages`, and
84
+ `client_factory` for the public source reader. `max_pages` bounds history
85
+ pagination; an incomplete read raises an error before a destination load starts.
86
+ `batch_size` bounds destination blocks to 1–10,000 rows. `max_batch_bytes` also
87
+ bounds each block using a conservative JSON byte estimate (16 MiB by default,
88
+ 128 MiB maximum). An oversized individual envelope is rejected before destination
89
+ changes; narrow the source date range to reduce the response size. The source
90
+ response is materialized before loading; choose a source date range suitable for
91
+ memory. Read the installed operation's schema for required arguments and date filters.
92
+
93
+ Supply source credentials through your runtime secret store, exposing only the
94
+ environment variable name to the adapter. The database file is opened by you;
95
+ the adapter never chooses a path, saves or prints credentials.
96
+
97
+ The package licence covers this adapter code. FXMacroData data access and onward
98
+ redistribution remain subject to your subscription and applicable licence. Loading
99
+ data into a database does not grant permission to publish or resell it.
100
+
101
+ ## Retries and querying
102
+
103
+ The table's primary key is `(operation, record_id, content_hash)` and every
104
+ block is inserted with `INSERT OR IGNORE` inside its own transaction. Retrying
105
+ the same source batch is idempotent: identical envelopes are skipped, while
106
+ changed record data or response metadata produces a distinct content hash and
107
+ is retained alongside the earlier version.
108
+
109
+ ```sql
110
+ SELECT currency, indicator, date, value,
111
+ json_extract(record_json, '$.publication_time_status') AS status
112
+ FROM fxmacrodata
113
+ WHERE record_json <> 'null';
114
+ ```
115
+
116
+ Delivery is at-least-once with an idempotent key. Blocks are not retried
117
+ automatically. On a failure the failing block is rolled back; earlier blocks
118
+ are already committed, and retrying the same source batch is safe.
119
+ `LoadResult.rows` counts envelopes submitted, rather than newly created rows.
120
+
121
+ See [event_study.sql](examples/event_study.sql) for an announcement/price join.
122
+ The example expects your own licensed market-price table and uses source
123
+ announcement timestamps only. Timestamp presence alone is not evidence that a
124
+ release had already been published; use source publication metadata when
125
+ building a point-in-time backtest.
126
+
127
+ ## Development
128
+
129
+ Tests are offline and use an in-memory database:
130
+
131
+ ```sh
132
+ python -m pytest tests -q
133
+ ```
134
+
135
+ Standard library reference:
136
+ [sqlite3 documentation](https://docs.python.org/3/library/sqlite3.html?utm_source=pypi&utm_medium=referral&utm_campaign=sqlite-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_sqlite/__init__.py
8
+ src/fxmacrodata_sqlite/py.typed
9
+ src/sqlite_fxmacrodata.egg-info/PKG-INFO
10
+ src/sqlite_fxmacrodata.egg-info/SOURCES.txt
11
+ src/sqlite_fxmacrodata.egg-info/dependency_links.txt
12
+ src/sqlite_fxmacrodata.egg-info/requires.txt
13
+ src/sqlite_fxmacrodata.egg-info/top_level.txt
14
+ tests/test_loader.py
@@ -0,0 +1,4 @@
1
+ fxmacrodata-platform-core==0.1.0
2
+
3
+ [test]
4
+ pytest<9,>=8
@@ -0,0 +1 @@
1
+ fxmacrodata_sqlite
@@ -0,0 +1,170 @@
1
+ import json
2
+ import sqlite3
3
+
4
+ import pytest
5
+
6
+ from fxmacrodata_platform_core import Batch
7
+ import fxmacrodata_sqlite as adapter
8
+
9
+
10
+ def batch(count=3, metadata=None):
11
+ return Batch("indicator_history", {
12
+ "data": [{"currency": "USD", "indicator": "policy_rate", "value": str(i),
13
+ "date": "2026-10-01", "publication_time_status": "unknown"}
14
+ for i in range(count)],
15
+ "metadata": metadata or {"revision": 1},
16
+ })
17
+
18
+
19
+ @pytest.fixture
20
+ def connection():
21
+ with sqlite3.connect(":memory:") as db:
22
+ yield db
23
+
24
+
25
+ def stored(connection, table="fxmacrodata"):
26
+ names = ", ".join(f'"{n}"' for n in adapter.COLUMNS)
27
+ cursor = connection.execute(f'SELECT {names} FROM "{table}" ORDER BY "value_text"')
28
+ return [dict(zip(adapter.COLUMNS, row)) for row in cursor.fetchall()]
29
+
30
+
31
+ def test_rows_round_trip_through_a_real_connection(connection):
32
+ source = batch()
33
+ result = adapter.load(source, connection, "fxmacrodata", batch_size=2)
34
+ assert (result.rows, result.batches) == (3, 2)
35
+ rows = stored(connection)
36
+ assert len(rows) == 3
37
+ row = rows[0]
38
+ assert row["operation"] == "indicator_history"
39
+ assert row["date"] == "2026-10-01"
40
+ assert row["announcement_datetime"] is None
41
+ assert row["value"] == 0.0 and isinstance(row["value"], float)
42
+ assert row["value_text"] == "0"
43
+ assert json.loads(row["record_json"])["publication_time_status"] == "unknown"
44
+ assert json.loads(row["response_json"]) == source.payload
45
+ assert len(row["content_hash"]) == 64
46
+ columns = {c[1]: c for c in connection.execute('PRAGMA table_info("fxmacrodata")')}
47
+ assert columns["value"][2] == "REAL" and columns["record_id"][3] == 1
48
+ assert [c[1] for c in sorted(columns.values(), key=lambda c: c[5]) if c[5]] == ["operation", "record_id", "content_hash"]
49
+
50
+
51
+ def test_empty_response_is_a_metadata_envelope_not_an_observation(connection):
52
+ source = batch(0)
53
+ result = adapter.load(source, connection, "fxmacrodata")
54
+ rows = stored(connection)
55
+ assert result.rows == 1 and len(rows) == 1
56
+ assert rows[0]["record_json"] == "null"
57
+ assert rows[0]["value"] is None and rows[0]["date"] is None
58
+ assert json.loads(rows[0]["response_json"]) == source.payload
59
+
60
+
61
+ def test_retry_is_idempotent_and_changed_metadata_adds_a_row(connection):
62
+ adapter.load(batch(1), connection, "fxmacrodata")
63
+ adapter.load(batch(1), connection, "fxmacrodata")
64
+ assert len(stored(connection)) == 1
65
+ adapter.load(batch(1, {"revision": 2}), connection, "fxmacrodata")
66
+ rows = stored(connection)
67
+ assert len(rows) == 2
68
+ assert rows[0]["record_id"] == rows[1]["record_id"]
69
+ assert rows[0]["content_hash"] != rows[1]["content_hash"]
70
+
71
+
72
+ def test_schema_qualified_table_is_quoted(connection):
73
+ connection.execute("ATTACH DATABASE ':memory:' AS analytics")
74
+ adapter.load(batch(2), connection, "analytics.fxmacrodata")
75
+ count = connection.execute('SELECT COUNT(*) FROM "analytics"."fxmacrodata"').fetchone()[0]
76
+ assert count == 2
77
+ assert 'CREATE TABLE IF NOT EXISTS "analytics"."fxmacrodata"' in adapter.create_table_sql("analytics.fxmacrodata")
78
+ assert adapter.insert_sql("fxmacrodata").startswith('INSERT OR IGNORE INTO "fxmacrodata" (')
79
+
80
+
81
+ @pytest.mark.parametrize("table", ['t"; DROP TABLE t; --', "a.b.c", "https://host/table", 'a."b"', "a b", "", None])
82
+ def test_bad_identifiers_never_execute(connection, table):
83
+ with pytest.raises(ValueError):
84
+ adapter.load(batch(), connection, table)
85
+ assert connection.execute("SELECT COUNT(*) FROM sqlite_master").fetchone()[0] == 0
86
+
87
+
88
+ @pytest.mark.parametrize("size", [0, -1, 10001, True, 2.5])
89
+ def test_batch_bounds(connection, size):
90
+ with pytest.raises(ValueError):
91
+ adapter.load(batch(), connection, "fxmacrodata", batch_size=size)
92
+ assert connection.execute("SELECT COUNT(*) FROM sqlite_master").fetchone()[0] == 0
93
+
94
+
95
+ def test_failed_block_is_rolled_back_not_retried_or_exposed(connection):
96
+ class Failing:
97
+ def __init__(self, inner, fail_block):
98
+ self.inner, self.fail_block, self.blocks = inner, fail_block, 0
99
+
100
+ def __enter__(self):
101
+ return self.inner.__enter__()
102
+
103
+ def __exit__(self, *args):
104
+ return self.inner.__exit__(*args)
105
+
106
+ def execute(self, *args):
107
+ return self.inner.execute(*args)
108
+
109
+ def executemany(self, sql, params):
110
+ self.blocks += 1
111
+ self.inner.executemany(sql, params)
112
+ if self.blocks == self.fail_block:
113
+ raise RuntimeError("private destination diagnostic")
114
+
115
+ wrapped = Failing(connection, 2)
116
+ with pytest.raises(adapter.LoadError) as error:
117
+ adapter.load(batch(), wrapped, "fxmacrodata", batch_size=1)
118
+ assert "block 2" in str(error.value)
119
+ assert "private destination diagnostic" not in str(error.value)
120
+ assert wrapped.blocks == 2
121
+ assert len(stored(connection)) == 1
122
+
123
+
124
+ def test_preprovisioned_table_skips_ddl(connection):
125
+ with pytest.raises(adapter.LoadError):
126
+ adapter.load(batch(1), connection, "fxmacrodata", create_table=False)
127
+ connection.execute(adapter.create_table_sql("fxmacrodata"))
128
+ adapter.load(batch(1), connection, "fxmacrodata", create_table=False)
129
+ assert len(stored(connection)) == 1
130
+
131
+
132
+ def test_operation_forwards_public_reader_options(monkeypatch, connection):
133
+ calls = []
134
+ monkeypatch.setattr(adapter, "read", lambda *args, **kw: calls.append((args, kw)) or batch(1))
135
+ factory = object()
136
+ adapter.load_operation("indicator_history", {"currency": "usd", "indicator": "policy_rate"},
137
+ connection, "fxmacrodata", api_key_env="FXMD_API_KEY",
138
+ max_pages=12, client_factory=factory)
139
+ assert calls[0][1] == dict(api_key_env="FXMD_API_KEY", complete_history=True,
140
+ max_pages=12, client_factory=factory)
141
+ assert len(stored(connection)) == 1
142
+
143
+
144
+ def test_credential_arguments_are_rejected_by_the_reader(connection):
145
+ from fxmacrodata_platform_core import IntegrationError
146
+ with pytest.raises(IntegrationError):
147
+ adapter.load_operation("indicator_history",
148
+ {"currency": "usd", "indicator": "policy_rate", "api_key": "x"},
149
+ connection, "fxmacrodata")
150
+
151
+
152
+ def test_byte_limit_splits_blocks_and_rejects_oversized_envelopes_before_ddl(connection):
153
+ source = batch()
154
+ budget = max(len(json.dumps(r, ensure_ascii=True).encode()) + 2 for r in adapter._rows(source))
155
+ result = adapter.load(source, connection, "fxmacrodata", batch_size=100, max_batch_bytes=budget)
156
+ assert result.batches == 3
157
+ with sqlite3.connect(":memory:") as untouched:
158
+ with pytest.raises(ValueError, match="narrow the source date range"):
159
+ adapter.load(source, untouched, "fxmacrodata", max_batch_bytes=1)
160
+ assert untouched.execute("SELECT COUNT(*) FROM sqlite_master").fetchone()[0] == 0
161
+
162
+
163
+ def test_invalid_loader_configuration_never_reads_source(monkeypatch, connection):
164
+ def unexpected(*args, **kwargs):
165
+ pytest.fail("source must not be called for invalid loader configuration")
166
+ monkeypatch.setattr(adapter, "read", unexpected)
167
+ for options in ({"batch_size": 0}, {"max_batch_bytes": 0}, {"table": "a.b.c"}):
168
+ with pytest.raises(ValueError):
169
+ adapter.load_operation("release_calendar", {"currency": "usd"}, connection,
170
+ **{"table": "fxmacrodata", **options})