bedrok 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bedrok/__init__.py +22 -0
- bedrok/__main__.py +75 -0
- bedrok/core/__init__.py +5 -0
- bedrok/core/aliases.py +25 -0
- bedrok/core/charting.py +176 -0
- bedrok/core/config.py +228 -0
- bedrok/core/geography.py +97 -0
- bedrok/core/licensing.py +152 -0
- bedrok/core/query.py +47 -0
- bedrok/core/schema.py +46 -0
- bedrok/core/sources.py +84 -0
- bedrok/core/store.py +233 -0
- bedrok/core/units.py +64 -0
- bedrok/core/warnings.py +139 -0
- bedrok/engines/__init__.py +0 -0
- bedrok/engines/pctile.py +104 -0
- bedrok/engines/rates_infl.py +91 -0
- bedrok/ingest/__init__.py +4 -0
- bedrok/ingest/adapters/__init__.py +0 -0
- bedrok/ingest/adapters/_util.py +92 -0
- bedrok/ingest/adapters/bea.py +88 -0
- bedrok/ingest/adapters/bls.py +115 -0
- bedrok/ingest/adapters/boc.py +41 -0
- bedrok/ingest/adapters/boe.py +85 -0
- bedrok/ingest/adapters/census.py +81 -0
- bedrok/ingest/adapters/cftc.py +139 -0
- bedrok/ingest/adapters/destatis.py +107 -0
- bedrok/ingest/adapters/eia.py +51 -0
- bedrok/ingest/adapters/estat.py +73 -0
- bedrok/ingest/adapters/eurostat.py +112 -0
- bedrok/ingest/adapters/fedboard.py +139 -0
- bedrok/ingest/adapters/fedfiles.py +259 -0
- bedrok/ingest/adapters/fred.py +32 -0
- bedrok/ingest/adapters/ine.py +63 -0
- bedrok/ingest/adapters/mof_jp.py +64 -0
- bedrok/ingest/adapters/nber.py +46 -0
- bedrok/ingest/adapters/nyfed.py +118 -0
- bedrok/ingest/adapters/ons.py +78 -0
- bedrok/ingest/adapters/pxweb.py +91 -0
- bedrok/ingest/adapters/sdmx.py +143 -0
- bedrok/ingest/adapters/statcan.py +53 -0
- bedrok/ingest/adapters/worldbank.py +127 -0
- bedrok/ingest/backfill.py +74 -0
- bedrok/ingest/derived.py +225 -0
- bedrok/ingest/live.py +38 -0
- bedrok/ingest/pipeline.py +820 -0
- bedrok/ingest/refresh.py +73 -0
- bedrok/ingest/transforms.py +228 -0
- bedrok/ops/__init__.py +4 -0
- bedrok/ops/docs.py +219 -0
- bedrok/ops/doctor.py +142 -0
- bedrok/ops/freshness.py +69 -0
- bedrok/ops/smoke.py +108 -0
- bedrok/ops/tieout.py +201 -0
- bedrok/ops/validate.py +172 -0
- bedrok/seeds/charting.md +70 -0
- bedrok/seeds/gaps.md +51 -0
- bedrok/seeds/macro_catalog.csv +199 -0
- bedrok/seeds/sources.csv +32 -0
- bedrok/seeds/warnings.csv +44 -0
- bedrok/surfaces/__init__.py +5 -0
- bedrok/surfaces/mcp_server.py +950 -0
- bedrok-0.2.0.dist-info/METADATA +113 -0
- bedrok-0.2.0.dist-info/RECORD +68 -0
- bedrok-0.2.0.dist-info/WHEEL +5 -0
- bedrok-0.2.0.dist-info/entry_points.txt +2 -0
- bedrok-0.2.0.dist-info/licenses/LICENSE +21 -0
- bedrok-0.2.0.dist-info/top_level.txt +1 -0
bedrok/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Bedrok — a local store of public macroeconomic and market data, for analysis
|
|
2
|
+
from inside an AI assistant.
|
|
3
|
+
|
|
4
|
+
The package is layered, and the direction of dependency is one way:
|
|
5
|
+
|
|
6
|
+
surfaces -> ops -> engines -> ingest -> core
|
|
7
|
+
|
|
8
|
+
core/ configuration, the store, and shared vocabulary
|
|
9
|
+
ingest/ source adapters and the sync drivers
|
|
10
|
+
engines/ the analysis maths — percentiles, rates
|
|
11
|
+
ops/ the audit reports and the connectivity check
|
|
12
|
+
surfaces/ things that render the store for a human or an assistant
|
|
13
|
+
|
|
14
|
+
`config` and `store` are re-exported here because they are the documented
|
|
15
|
+
embedding entry point — a sandbox session does `from bedrok import config;
|
|
16
|
+
config.set_profile(...)`, and that recipe is written down in
|
|
17
|
+
docs/engineering/notes.md. Those two names do not move. Everything else should
|
|
18
|
+
be imported from its real home so the layer it belongs to stays visible.
|
|
19
|
+
"""
|
|
20
|
+
from bedrok.core import config, store # noqa: F401
|
|
21
|
+
|
|
22
|
+
__all__ = ["config", "store"]
|
bedrok/__main__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Bedrok CLI — one entry point over the store and the engines.
|
|
2
|
+
|
|
3
|
+
bedrok [--profile PATH] <command> [command args...]
|
|
4
|
+
|
|
5
|
+
Commands:
|
|
6
|
+
smoke connectivity check, one call per data source
|
|
7
|
+
backfill full deep historical pull (idempotent) [--only ids --stage S]
|
|
8
|
+
refresh incremental sync of the local store
|
|
9
|
+
validate coverage + trust audit (staleness, licence)
|
|
10
|
+
query read-only SQL over the store "SELECT ..." [--csv]
|
|
11
|
+
tieout compare re-pointed series with the store [--only ids]
|
|
12
|
+
doctor store + freshness report (offline; run this first)
|
|
13
|
+
schema the store's table/column layout
|
|
14
|
+
pctile percentile of a series' latest value SERIES_ID [--value V --since D]
|
|
15
|
+
rates EUR-investor rates/inflation toolkit [--csv]
|
|
16
|
+
serve MCP server over stdio, for Claude and other assistants
|
|
17
|
+
docs regenerate docs/SOURCES.md and docs/LICENSES.md [--check]
|
|
18
|
+
|
|
19
|
+
The profile (user config + data) resolves from --profile, $BEDROK_PROFILE, or
|
|
20
|
+
./profile. See docs/SETUP.md.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
_COMMANDS = {
|
|
27
|
+
"smoke": "bedrok.ops.smoke",
|
|
28
|
+
"backfill": "bedrok.ingest.backfill",
|
|
29
|
+
"refresh": "bedrok.ingest.refresh",
|
|
30
|
+
"validate": "bedrok.ops.validate",
|
|
31
|
+
"query": "bedrok.core.query",
|
|
32
|
+
"tieout": "bedrok.ops.tieout",
|
|
33
|
+
"doctor": "bedrok.ops.doctor",
|
|
34
|
+
"schema": "bedrok.core.schema",
|
|
35
|
+
"pctile": "bedrok.engines.pctile",
|
|
36
|
+
"rates": "bedrok.engines.rates_infl",
|
|
37
|
+
"serve": "bedrok.surfaces.mcp_server",
|
|
38
|
+
"docs": "bedrok.ops.docs",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main() -> int:
|
|
43
|
+
argv = sys.argv[1:]
|
|
44
|
+
|
|
45
|
+
profile = None
|
|
46
|
+
if "--profile" in argv:
|
|
47
|
+
i = argv.index("--profile")
|
|
48
|
+
try:
|
|
49
|
+
profile = argv[i + 1]
|
|
50
|
+
except IndexError:
|
|
51
|
+
print("--profile requires a path", file=sys.stderr)
|
|
52
|
+
return 2
|
|
53
|
+
argv = argv[:i] + argv[i + 2:]
|
|
54
|
+
|
|
55
|
+
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
56
|
+
print(__doc__)
|
|
57
|
+
return 0
|
|
58
|
+
cmd, rest = argv[0], argv[1:]
|
|
59
|
+
if cmd not in _COMMANDS:
|
|
60
|
+
print(f"unknown command {cmd!r}\n", file=sys.stderr)
|
|
61
|
+
print(__doc__)
|
|
62
|
+
return 2
|
|
63
|
+
|
|
64
|
+
from bedrok.core import config
|
|
65
|
+
if profile:
|
|
66
|
+
config.set_profile(profile)
|
|
67
|
+
|
|
68
|
+
import importlib
|
|
69
|
+
mod = importlib.import_module(_COMMANDS[cmd])
|
|
70
|
+
sys.argv = [f"bedrok {cmd}"] + rest
|
|
71
|
+
return mod.main()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
sys.exit(main())
|
bedrok/core/__init__.py
ADDED
bedrok/core/aliases.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""One id per concept: the ids that point at another.
|
|
2
|
+
|
|
3
|
+
The bulk families name a series by family and geography (`hicp_yoy_es`); the
|
|
4
|
+
seed names the most-asked ones by geography first (`es_hicp_yoy`), the
|
|
5
|
+
convention every instruction teaches. Where both exist they are the same
|
|
6
|
+
number from the same dataset, so the family id becomes an alias: it still
|
|
7
|
+
answers, with `alias_of` naming the canonical id, but it is neither stored nor
|
|
8
|
+
listed (session 11, after the feedback session found the duplicates).
|
|
9
|
+
|
|
10
|
+
Pure data, in core: nothing here imports a layer above.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
ALIASES: dict[str, str] = {
|
|
15
|
+
"hicp_yoy_ea": "ea_hicp_yoy",
|
|
16
|
+
"hicp_yoy_es": "es_hicp_yoy",
|
|
17
|
+
"hicp_yoy_de": "de_hicp_yoy",
|
|
18
|
+
"hicp_yoy_fr": "fr_hicp_yoy",
|
|
19
|
+
"gdp_qoq_ea20": "ea_gdp_qoq",
|
|
20
|
+
"gdp_qoq_es": "es_gdp_qoq",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def canonical(series_id: str) -> str:
|
|
25
|
+
return ALIASES.get(series_id, series_id)
|
bedrok/core/charting.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""The chart recipe: what every Bedrok chart says, whoever draws it.
|
|
2
|
+
|
|
3
|
+
Bedrok does not draw. The assistant in the host does, with whatever chart tool
|
|
4
|
+
that host has, so two charts drawn in two places used to look like two
|
|
5
|
+
products. What Bedrok can fix is the content: the same title rules, the same
|
|
6
|
+
subtitle carrying the units, the same axes, colours, labels, source line and
|
|
7
|
+
footnotes, every time. The prose version is bedrok/seeds/charting.md
|
|
8
|
+
(bedrok://charting); this module is the arithmetic of it.
|
|
9
|
+
|
|
10
|
+
The house decisions (2026-10-11): two vertical scales when units differ; units
|
|
11
|
+
in the subtitle and on the vertical axis, never in the title; a chart by
|
|
12
|
+
default, a table only when asked; short answers.
|
|
13
|
+
|
|
14
|
+
Pure data, in core: nothing here imports a layer above.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
from datetime import date
|
|
20
|
+
|
|
21
|
+
# Okabe–Ito, without its yellow (unreadable on white): seven hues that stay
|
|
22
|
+
# distinct for the common forms of colour blindness. Assigned in the order the
|
|
23
|
+
# series were asked for, so a series keeps its colour across charts.
|
|
24
|
+
PALETTE = ["#0072B2", "#E69F00", "#009E73", "#D55E00", "#56B4E9", "#CC79A7", "#7F7F7F"]
|
|
25
|
+
|
|
26
|
+
# The house style, repeated on every recipe so a host that never read
|
|
27
|
+
# bedrok://charting still draws it the same way.
|
|
28
|
+
LAYOUT = {
|
|
29
|
+
"container": "a card with a border",
|
|
30
|
+
"title": "top, bold; never carries units",
|
|
31
|
+
"subtitle": "under the title; carries the units and how the data is measured",
|
|
32
|
+
"legend": "below the chart; clicking a series hides or shows it",
|
|
33
|
+
"source": "one line under the legend",
|
|
34
|
+
"footnotes": "under the source line, smaller",
|
|
35
|
+
"toggle": "a switch between the chart and a table of the same numbers",
|
|
36
|
+
"y_axis": "labelled with the units; left scale solid lines, right scale dashed",
|
|
37
|
+
"x_axis": "the labels given, every `tick_every`-th one shown",
|
|
38
|
+
"gaps": "leave empty: never draw a line across a null",
|
|
39
|
+
"type": "lines; bars only when every series has a single period",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_HOW_WORDS = {
|
|
43
|
+
("weekly", "last"): "weekly closes", ("weekly", "mean"): "weekly averages",
|
|
44
|
+
("weekly", "sum"): "weekly totals", ("weekly", "first"): "first value of each week",
|
|
45
|
+
("monthly", "mean"): "monthly averages", ("monthly", "last"): "month-end values",
|
|
46
|
+
("monthly", "sum"): "monthly totals", ("monthly", "first"): "first value of each month",
|
|
47
|
+
("quarterly", "mean"): "quarterly averages", ("quarterly", "last"): "quarter-end values",
|
|
48
|
+
("quarterly", "sum"): "quarterly totals", ("quarterly", "first"): "first value of each quarter",
|
|
49
|
+
("annual", "mean"): "annual averages", ("annual", "last"): "year-end values",
|
|
50
|
+
("annual", "sum"): "annual totals", ("annual", "first"): "first value of each year",
|
|
51
|
+
}
|
|
52
|
+
_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
53
|
+
MAX_TICKS = 10
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def colour(i: int) -> str:
|
|
57
|
+
return PALETTE[i % len(PALETTE)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def title_suggestion(names: list[str]) -> str:
|
|
61
|
+
"""One series: its name. Two: both. More: the first and a count. The
|
|
62
|
+
assistant may always replace it; it never carries units."""
|
|
63
|
+
if not names:
|
|
64
|
+
return ""
|
|
65
|
+
if len(names) == 1:
|
|
66
|
+
return names[0]
|
|
67
|
+
if len(names) == 2:
|
|
68
|
+
return f"{names[0]} and {names[1]}"
|
|
69
|
+
return f"{names[0]} and {len(names) - 1} others"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def axes(series: list[tuple[str, str]]) -> dict:
|
|
73
|
+
"""[(id, units)] → which series go on the left scale and which on the right.
|
|
74
|
+
|
|
75
|
+
One unit: one scale. Two: the first-listed series' units on the left, the
|
|
76
|
+
other on the right. Three or more cannot be read on two scales: the first
|
|
77
|
+
two groups are placed and `problem` says what to do instead."""
|
|
78
|
+
groups: list[str] = []
|
|
79
|
+
for _, u in series:
|
|
80
|
+
if u not in groups:
|
|
81
|
+
groups.append(u)
|
|
82
|
+
left = [i for i, u in series if u == groups[0]] if groups else []
|
|
83
|
+
right = [i for i, u in series if len(groups) > 1 and u == groups[1]]
|
|
84
|
+
out = {"left": {"units": groups[0] if groups else None, "series": left},
|
|
85
|
+
"right": {"units": groups[1], "series": right} if len(groups) > 1 else None,
|
|
86
|
+
"problem": None}
|
|
87
|
+
if len(groups) > 2:
|
|
88
|
+
dropped = [i for i, u in series if u in groups[2:]]
|
|
89
|
+
out["problem"] = (f"{len(groups)} different units cannot share two scales; "
|
|
90
|
+
f"{', '.join(dropped)} are not placed. Draw two charts, or ask "
|
|
91
|
+
"for transform='index_to' so every series is an index.")
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _span_label(d: date, frequency: str) -> str:
|
|
96
|
+
if frequency == "quarterly":
|
|
97
|
+
return f"Q{(d.month - 1) // 3 + 1} {d.year}"
|
|
98
|
+
if frequency == "annual":
|
|
99
|
+
return str(d.year)
|
|
100
|
+
return f"{_MONTHS[d.month - 1]} {d.year}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def subtitle(placed: dict, frequency: str, how: str | None, first: date | None,
|
|
104
|
+
last: date | None) -> str:
|
|
105
|
+
"""Units first (per scale when there are two), then how the data is
|
|
106
|
+
measured, then the span: "Percent; weekly closes; Oct 2023 to Oct 2026"."""
|
|
107
|
+
def cap(u: str | None) -> str:
|
|
108
|
+
return (u[:1].upper() + u[1:]) if u else ""
|
|
109
|
+
if placed.get("right"):
|
|
110
|
+
units = f"Left: {placed['left']['units']}. Right: {placed['right']['units']}"
|
|
111
|
+
else:
|
|
112
|
+
units = cap(placed["left"]["units"])
|
|
113
|
+
parts = [units]
|
|
114
|
+
parts.append(_HOW_WORDS.get((frequency, how or ""), frequency))
|
|
115
|
+
if first and last:
|
|
116
|
+
parts.append(f"{_span_label(first, frequency)} to {_span_label(last, frequency)}")
|
|
117
|
+
return "; ".join(p for p in parts if p)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def x_labels(dates: list[date], frequency: str) -> dict:
|
|
121
|
+
"""Labels by frequency, and how many to skip so about ten show."""
|
|
122
|
+
def one(d: date) -> str:
|
|
123
|
+
if frequency == "weekly":
|
|
124
|
+
return f"{d.day} {_MONTHS[d.month - 1]} {d.year}"
|
|
125
|
+
if frequency == "monthly":
|
|
126
|
+
return f"{_MONTHS[d.month - 1]} {str(d.year)[2:]}"
|
|
127
|
+
if frequency == "quarterly":
|
|
128
|
+
return f"Q{(d.month - 1) // 3 + 1} {str(d.year)[2:]}"
|
|
129
|
+
return str(d.year)
|
|
130
|
+
n = len(dates)
|
|
131
|
+
return {"labels": [one(d) for d in dates],
|
|
132
|
+
"tick_every": max(1, math.ceil(n / MAX_TICKS))}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def source_line(publishers: list[tuple[str, bool, bool]]) -> str:
|
|
136
|
+
"""[(publisher name, personal use?, fetched live?)] → one line, each
|
|
137
|
+
publisher once, in order, with its condition when it has one."""
|
|
138
|
+
seen, parts = set(), []
|
|
139
|
+
for name, personal, live in publishers:
|
|
140
|
+
if name in seen:
|
|
141
|
+
continue
|
|
142
|
+
seen.add(name)
|
|
143
|
+
cond = [c for c, on in (("personal use only", personal), ("fetched live, not stored", live)) if on]
|
|
144
|
+
parts.append(name + (f" ({', '.join(cond)})" if cond else ""))
|
|
145
|
+
return "Source: " + "; ".join(parts) + "." if parts else ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def footnotes(entries: list[dict], table: dict, frequency: str) -> list[str]:
|
|
149
|
+
"""Everything the chart cannot say by itself, one short line each: the
|
|
150
|
+
loud warnings for the period shown, periods not over or missing a part,
|
|
151
|
+
a cut, and series drawn as steps."""
|
|
152
|
+
notes: list[str] = []
|
|
153
|
+
names = {e["series_id"]: e.get("name", e["series_id"]) for e in entries}
|
|
154
|
+
for e in entries:
|
|
155
|
+
for w in e.get("warnings") or []:
|
|
156
|
+
period = w.get("period_start") or ""
|
|
157
|
+
if w.get("period_end"):
|
|
158
|
+
period += f" to {w['period_end']}"
|
|
159
|
+
# The whole effect: warning texts are kept short in the seed, and a
|
|
160
|
+
# cut at the first ". " breaks on abbreviations ("incl.").
|
|
161
|
+
line = f"{names[e['series_id']]}, {period}: {(w.get('effect_on_data') or '').strip()}"
|
|
162
|
+
if line not in notes:
|
|
163
|
+
notes.append(line)
|
|
164
|
+
for sid, stepped in (table.get("stepped") or {}).items():
|
|
165
|
+
if stepped:
|
|
166
|
+
notes.append(f"{names.get(sid, sid)} is published less often than {frequency}; "
|
|
167
|
+
"it is drawn as a step, each value held until the next.")
|
|
168
|
+
for sid, partial in (table.get("partial_last") or {}).items():
|
|
169
|
+
if partial:
|
|
170
|
+
notes.append(f"{names.get(sid, sid)}: the latest period is not over yet.")
|
|
171
|
+
for sid, short in (table.get("incomplete") or {}).items():
|
|
172
|
+
notes.append(f"{names.get(sid, sid)}: {', '.join(short)} hold only part of their period "
|
|
173
|
+
"(a month or quarter not published).")
|
|
174
|
+
if table.get("cut"):
|
|
175
|
+
notes.append(table["cut"])
|
|
176
|
+
return notes
|
bedrok/core/config.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Bedrok configuration: profile resolution, profile.toml parsing, DB connection.
|
|
2
|
+
|
|
3
|
+
A *profile* (the Bedrok home folder) holds everything user-specific:
|
|
4
|
+
|
|
5
|
+
<home>/
|
|
6
|
+
├── profile.toml # the config parsed here (all keys optional)
|
|
7
|
+
├── data/market.duckdb # the store — one file; $BEDROK_DB overrides the path
|
|
8
|
+
└── .env # API keys, all optional (chmod 600); which source
|
|
9
|
+
# reads which: bedrok/seeds/sources.csv
|
|
10
|
+
|
|
11
|
+
The catalog and the source list are not in the profile: they ship inside the
|
|
12
|
+
package, in bedrok/seeds/ (SEEDS_DIR), so an installed wheel has them too.
|
|
13
|
+
|
|
14
|
+
Resolution order for the home folder: explicit `set_profile()` (CLI --profile)
|
|
15
|
+
→ $BEDROK_PROFILE (what the Claude Desktop bundle's folder field sets) →
|
|
16
|
+
legacy $FDP_ROOT → ./profile under the current directory → the per-user data
|
|
17
|
+
folder (~/Library/Application Support/bedrok on macOS, ~/.local/share/bedrok
|
|
18
|
+
on Linux). There is always a home, so a fresh install needs no setup: the
|
|
19
|
+
first `bedrok serve` creates the folder and the store. The DB path may be
|
|
20
|
+
overridden independently via $BEDROK_DB (legacy $FDP_DB).
|
|
21
|
+
|
|
22
|
+
An environment value that is an unfilled placeholder — `${user_config.x}`,
|
|
23
|
+
which a Claude Desktop bundle may pass for a blank optional field — counts as
|
|
24
|
+
unset, here and for API keys (core/sources.py).
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import importlib.resources
|
|
29
|
+
import os
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import tomllib # stdlib from 3.11
|
|
34
|
+
except ModuleNotFoundError: # 3.10 sandboxes (Cowork's system python) need the backport
|
|
35
|
+
import tomli as tomllib
|
|
36
|
+
|
|
37
|
+
import duckdb
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
from dotenv import load_dotenv
|
|
41
|
+
except ImportError: # read-only runs don't need python-dotenv
|
|
42
|
+
def load_dotenv(*_a, **_k):
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---- defaults (a missing/empty profile.toml gets exactly these) -------------
|
|
47
|
+
# Sections a profile.toml carries that are not listed here pass through _merge
|
|
48
|
+
# untouched and are simply never read — a [market] block from the days of the
|
|
49
|
+
# pack, or the portfolio-era [broker]/[sleeves]/[strategy], load without error.
|
|
50
|
+
_DEFAULTS = {
|
|
51
|
+
"owner": {"name": "Investor"},
|
|
52
|
+
"base": {"currency": "EUR", "symbol": "€"},
|
|
53
|
+
"macro": {"hicp_europe": True},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _merge(base: dict, over: dict) -> dict:
|
|
58
|
+
out = dict(base)
|
|
59
|
+
for k, v in over.items():
|
|
60
|
+
out[k] = _merge(base[k], v) if isinstance(v, dict) and isinstance(base.get(k), dict) else v
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# The seeds ship inside the package and are found through the package itself,
|
|
65
|
+
# never by counting ".parent" off a file: that once repointed every seed path
|
|
66
|
+
# when config.py moved into core/, and an installed wheel has no repo root at all.
|
|
67
|
+
SEEDS_DIR = Path(str(importlib.resources.files("bedrok") / "seeds"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def unset(value: str | None) -> bool:
|
|
71
|
+
"""True for a missing, blank, or unfilled-placeholder (`${...}`) value."""
|
|
72
|
+
v = (value or "").strip()
|
|
73
|
+
return not v or (v.startswith("${") and v.endswith("}"))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def default_home() -> Path:
|
|
77
|
+
"""The per-user data folder a fresh install uses."""
|
|
78
|
+
import platformdirs
|
|
79
|
+
return Path(platformdirs.user_data_dir("bedrok", appauthor=False))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_default_dir() -> Path:
|
|
83
|
+
for env in ("BEDROK_PROFILE", "FDP_ROOT"):
|
|
84
|
+
if not unset(os.getenv(env)):
|
|
85
|
+
return Path(os.getenv(env).strip()).expanduser()
|
|
86
|
+
cwd_profile = Path.cwd() / "profile"
|
|
87
|
+
if cwd_profile.is_dir():
|
|
88
|
+
return cwd_profile
|
|
89
|
+
return default_home()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---- module state (recomputed by set_profile) -------------------------------
|
|
93
|
+
PROFILE_DIR: Path | None = None
|
|
94
|
+
PROFILE: dict = dict(_DEFAULTS)
|
|
95
|
+
ROOT: Path | None = None
|
|
96
|
+
DATA_DIR: Path | None = None
|
|
97
|
+
DB_PATH: Path | None = None
|
|
98
|
+
BASE_CCY = "EUR"
|
|
99
|
+
BASE_SYMBOL = "€"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def fx_pairs() -> list[tuple[str, str, str]]:
|
|
104
|
+
"""(pair, source, code) list for sync_fx, derived from the base currency.
|
|
105
|
+
|
|
106
|
+
ECB reference rates are EUR-based; non-EUR bases get 'derived' cross pairs
|
|
107
|
+
computed from them at sync time (best-effort — EUR is the fully-tested path).
|
|
108
|
+
"""
|
|
109
|
+
# The engines look up f"{BASE_CCY}{ccy}", so the store carries the three
|
|
110
|
+
# fetchable pairs plus their reciprocals; EUR, GBP and USD bases all resolve.
|
|
111
|
+
fetchable = [
|
|
112
|
+
("EURUSD", "ecb", "EXR/D.USD.EUR.SP00.A"),
|
|
113
|
+
("EURGBP", "ecb", "EXR/D.GBP.EUR.SP00.A"),
|
|
114
|
+
("GBPUSD", "fedboard", "H10:RXI$US_N.B.UK"), # FRED's DEXUSUK is this H.10 series
|
|
115
|
+
]
|
|
116
|
+
derived = [
|
|
117
|
+
("USDEUR", "derived", "1/EURUSD"),
|
|
118
|
+
("GBPEUR", "derived", "1/EURGBP"),
|
|
119
|
+
("USDGBP", "derived", "1/GBPUSD"),
|
|
120
|
+
]
|
|
121
|
+
if BASE_CCY in ("EUR", "USD", "GBP"):
|
|
122
|
+
return fetchable + derived
|
|
123
|
+
raise SystemExit(
|
|
124
|
+
f"base currency {BASE_CCY!r} not supported yet "
|
|
125
|
+
"(EUR/USD/GBP ship as a full cross set; others need new pairs)")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def set_profile(path: str | Path | None = None) -> None:
|
|
129
|
+
"""Load (or reload) the profile and recompute all module paths/settings."""
|
|
130
|
+
global PROFILE_DIR, PROFILE, ROOT, DATA_DIR, DB_PATH
|
|
131
|
+
global BASE_CCY, BASE_SYMBOL
|
|
132
|
+
|
|
133
|
+
PROFILE_DIR = Path(path) if path else _resolve_default_dir()
|
|
134
|
+
toml_data = {}
|
|
135
|
+
if PROFILE_DIR and (PROFILE_DIR / "profile.toml").exists():
|
|
136
|
+
toml_data = tomllib.loads((PROFILE_DIR / "profile.toml").read_text())
|
|
137
|
+
PROFILE = _merge(_DEFAULTS, toml_data)
|
|
138
|
+
|
|
139
|
+
ROOT = PROFILE_DIR
|
|
140
|
+
DATA_DIR = PROFILE_DIR / "data" if PROFILE_DIR else None
|
|
141
|
+
db_env = next((os.getenv(e) for e in ("BEDROK_DB", "FDP_DB") if not unset(os.getenv(e))), None)
|
|
142
|
+
DB_PATH = Path(db_env) if db_env else (DATA_DIR / "market.duckdb" if DATA_DIR else None)
|
|
143
|
+
|
|
144
|
+
BASE_CCY = PROFILE["base"]["currency"].upper()
|
|
145
|
+
BASE_SYMBOL = PROFILE["base"]["symbol"]
|
|
146
|
+
|
|
147
|
+
# A Claude Desktop bundle sets every key it declares, blank or as the
|
|
148
|
+
# unfilled `${user_config.x}`, and load_dotenv never overrides a variable
|
|
149
|
+
# that exists — so a blank field would hide the same key in the home
|
|
150
|
+
# folder's .env. Clear blank and placeholder keys first, then load .env.
|
|
151
|
+
for name in [k for k, v in os.environ.items() if k.endswith("_API_KEY") and unset(v)]:
|
|
152
|
+
del os.environ[name]
|
|
153
|
+
if PROFILE_DIR:
|
|
154
|
+
load_dotenv(PROFILE_DIR / ".env")
|
|
155
|
+
# Keys stay in the environment: core/sources.py reads the one a source
|
|
156
|
+
# names (`key_env`) at the moment it is needed.
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def require_profile() -> Path:
|
|
160
|
+
if PROFILE_DIR is None:
|
|
161
|
+
raise SystemExit(
|
|
162
|
+
"No Bedrok home folder resolved. Pass --profile PATH or set "
|
|
163
|
+
"$BEDROK_PROFILE. See docs/INSTALL.md.")
|
|
164
|
+
return PROFILE_DIR
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def require_key(name: str) -> str:
|
|
168
|
+
val = os.getenv(name)
|
|
169
|
+
if not val:
|
|
170
|
+
raise RuntimeError(
|
|
171
|
+
f"{name} missing. Add it to "
|
|
172
|
+
f"{PROFILE_DIR / '.env' if PROFILE_DIR else 'your profile .env'}.")
|
|
173
|
+
return val
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def sandbox_copy_recipe() -> str:
|
|
177
|
+
"""The copy-store workaround, spelled out with this profile's real path."""
|
|
178
|
+
db = Path(DB_PATH) if DB_PATH else Path("profile/data/market.duckdb")
|
|
179
|
+
return (
|
|
180
|
+
" Writes are impossible against a read-only mount. To work anyway:\n"
|
|
181
|
+
" mkdir -p /tmp/bedrok/data\n"
|
|
182
|
+
f" cp '{db}'* /tmp/bedrok/data/\n"
|
|
183
|
+
f" export BEDROK_DB=/tmp/bedrok/data/{db.name}\n"
|
|
184
|
+
" then re-run. NOTE: everything written to /tmp DIES WITH THE SESSION —\n"
|
|
185
|
+
" replay the refresh Mac-side afterwards."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _open(path: Path, read_only: bool) -> duckdb.DuckDBPyConnection:
|
|
190
|
+
"""duckdb.connect, with the read-only-mount WAL failure made actionable.
|
|
191
|
+
|
|
192
|
+
DuckDB removes the `.wal` when it opens a database. The Cowork mount permits
|
|
193
|
+
write and truncate but NOT unlink, so any `.wal` left behind by an earlier
|
|
194
|
+
command makes every subsequent open fail — the first write of a session
|
|
195
|
+
works and the second does not, which reads as an intermittent fault rather
|
|
196
|
+
than a permission model. `store.close_checkpointed` stops us leaving one;
|
|
197
|
+
this turns somebody else's leftover into instructions instead of a traceback.
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
return duckdb.connect(str(path), read_only=read_only)
|
|
201
|
+
except duckdb.IOException as e:
|
|
202
|
+
msg = str(e)
|
|
203
|
+
if "Could not remove file" not in msg or ".wal" not in msg:
|
|
204
|
+
raise
|
|
205
|
+
raise SystemExit(
|
|
206
|
+
f"Cannot open {path}: a stale write-ahead log is in the way and this "
|
|
207
|
+
f"filesystem does not allow deleting it.\n"
|
|
208
|
+
f" {msg.strip()}\n\n"
|
|
209
|
+
f" This mount allows writing and truncating but not unlinking, so\n"
|
|
210
|
+
f" emptying the file works where removing it does not:\n"
|
|
211
|
+
f" : > '{path}.wal'\n"
|
|
212
|
+
f" then re-run. If that is also refused, copy the store instead:\n"
|
|
213
|
+
f"{sandbox_copy_recipe()}") from e
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def connect(read_only: bool = False) -> duckdb.DuckDBPyConnection:
|
|
217
|
+
"""Open the profile's one store at DB_PATH."""
|
|
218
|
+
require_profile()
|
|
219
|
+
if DB_PATH is None:
|
|
220
|
+
raise SystemExit("no store path resolved (profile has no data/ dir?)")
|
|
221
|
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
if read_only and not DB_PATH.exists():
|
|
223
|
+
raise SystemExit(f"no store at {DB_PATH} — run `bedrok backfill` to build it.")
|
|
224
|
+
return _open(DB_PATH, read_only)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# Initialise from environment/cwd at import; the CLI re-calls set_profile(--profile).
|
|
228
|
+
set_profile()
|
bedrok/core/geography.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Places: which geography a series is about, and which words name one.
|
|
2
|
+
|
|
3
|
+
One table, used by search (a place in the question filters the catalog rather
|
|
4
|
+
than being scored like any other word), by the warnings search, and by the
|
|
5
|
+
test that every name leads with its geography.
|
|
6
|
+
|
|
7
|
+
Series ids carry their geography in one of two places: first, by convention
|
|
8
|
+
(`es_hicp_yoy`, `us_10y`), or last, for the families the bulk stages build
|
|
9
|
+
(`hicp_core_es`, `gdp_qoq_ea20`, `oecd_cli_deu`). Commodity and positioning
|
|
10
|
+
series have no geography of their own: "us" in `wb_banana_us` is where the
|
|
11
|
+
banana price is quoted, not a country to search by.
|
|
12
|
+
|
|
13
|
+
Pure data, in core: nothing here imports a layer above.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
# id code → how names begin (the names test) and a readable label.
|
|
20
|
+
CODES = {
|
|
21
|
+
"us": "US", "ea": "Euro area", "eu": "EU", "uk": "UK", "jp": "Japan",
|
|
22
|
+
"cn": "China", "ca": "Canada", "au": "Australia", "ch": "Switzerland",
|
|
23
|
+
"de": "Germany", "fr": "France", "it": "Italy", "es": "Spain",
|
|
24
|
+
"nl": "Netherlands", "be": "Belgium", "at": "Austria", "pt": "Portugal",
|
|
25
|
+
"ie": "Ireland", "el": "Greece", "fi": "Finland", "se": "Sweden",
|
|
26
|
+
"no": "Norway", "dk": "Denmark",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Words a person types for a place → its code. Two-letter country codes that
|
|
30
|
+
# are also common words (it, no, de, es, at, be, ie) are deliberately absent:
|
|
31
|
+
# "it" in a question is a pronoun before it is Italy.
|
|
32
|
+
NAMES = {
|
|
33
|
+
"us": "us", "usa": "us", "america": "us", "american": "us", "united states": "us",
|
|
34
|
+
"uk": "uk", "britain": "uk", "british": "uk", "united kingdom": "uk", "england": "uk",
|
|
35
|
+
"euro area": "ea", "eurozone": "ea", "euro-area": "ea", "ea": "ea",
|
|
36
|
+
"eu": "eu", "european union": "eu",
|
|
37
|
+
"japan": "jp", "japanese": "jp", "china": "cn", "chinese": "cn",
|
|
38
|
+
"canada": "ca", "canadian": "ca", "australia": "au", "australian": "au",
|
|
39
|
+
"switzerland": "ch", "swiss": "ch", "germany": "de", "german": "de",
|
|
40
|
+
"france": "fr", "french": "fr", "italy": "it", "italian": "it",
|
|
41
|
+
"spain": "es", "spanish": "es", "netherlands": "nl", "dutch": "nl",
|
|
42
|
+
"belgium": "be", "belgian": "be", "austria": "at", "austrian": "at",
|
|
43
|
+
"portugal": "pt", "portuguese": "pt", "ireland": "ie", "irish": "ie",
|
|
44
|
+
"greece": "el", "greek": "el", "finland": "fi", "finnish": "fi",
|
|
45
|
+
"sweden": "se", "swedish": "se", "norway": "no", "norwegian": "no",
|
|
46
|
+
"denmark": "dk", "danish": "dk",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Where an id suffix spells a place differently: OECD's ISO-3 codes and
|
|
50
|
+
# Eurostat's euro-area and EU aggregates.
|
|
51
|
+
_SUFFIX = {"usa": "us", "deu": "de", "fra": "fr", "ita": "it", "esp": "es", "gbr": "uk",
|
|
52
|
+
"jpn": "jp", "chn": "cn", "ea20": "ea", "ea21": "ea", "eu27": "eu", "cn": "cn"}
|
|
53
|
+
|
|
54
|
+
# ISO 3166 alpha-2 where it differs from the id code (the warnings carry ISO).
|
|
55
|
+
ISO2 = {**{c: c.upper() for c in CODES}, "uk": "GB", "el": "GR", "ea": "", "eu": ""}
|
|
56
|
+
|
|
57
|
+
# Sources whose series are not about a country.
|
|
58
|
+
_PLACELESS_SOURCES = {"worldbank", "cftc"}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def of_series(series_id: str, source: str | None = None) -> str | None:
|
|
62
|
+
"""The geography code a series is about, or None."""
|
|
63
|
+
if source in _PLACELESS_SOURCES:
|
|
64
|
+
return None
|
|
65
|
+
sid = series_id.lower()
|
|
66
|
+
first = sid.split("_", 1)[0]
|
|
67
|
+
if first in CODES:
|
|
68
|
+
return first
|
|
69
|
+
sid = re.sub(r"_eu27_2020$", "_eu27", sid)
|
|
70
|
+
last = sid.rsplit("_", 1)[-1]
|
|
71
|
+
if last in _SUFFIX:
|
|
72
|
+
return _SUFFIX[last]
|
|
73
|
+
return last if last in CODES and "_" in sid else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def in_text(text: str) -> tuple[set[str], list[str]]:
|
|
77
|
+
"""(codes of the places named, the other words), two-word names first."""
|
|
78
|
+
words = re.findall(r"[a-z0-9-]+", text.lower())
|
|
79
|
+
codes, rest, i = set(), [], 0
|
|
80
|
+
while i < len(words):
|
|
81
|
+
pair = " ".join(words[i:i + 2])
|
|
82
|
+
if i + 1 < len(words) and pair in NAMES:
|
|
83
|
+
codes.add(NAMES[pair])
|
|
84
|
+
i += 2
|
|
85
|
+
continue
|
|
86
|
+
if words[i] in NAMES:
|
|
87
|
+
codes.add(NAMES[words[i]])
|
|
88
|
+
else:
|
|
89
|
+
rest.append(words[i])
|
|
90
|
+
i += 1
|
|
91
|
+
return codes, rest
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def words_for_iso2(iso2: str) -> set[str]:
|
|
95
|
+
"""Every single word in NAMES that names the country with this ISO code."""
|
|
96
|
+
codes = {c for c, iso in ISO2.items() if iso and iso == (iso2 or "").upper()}
|
|
97
|
+
return {w for w, c in NAMES.items() if c in codes and " " not in w}
|