Skip to content

Commit d957f14

Browse files
feat: timezone cleanup + readiness check + caching + concurrent reads
1 parent 78f13dd commit d957f14

15 files changed

Lines changed: 763 additions & 78 deletions

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,7 @@ EXPOSE 8000
2727
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
2828
CMD curl -fsS http://localhost:8000/healthcheck || exit 1
2929

30+
# Single worker on purpose: only one OS process can open the DuckDB file
31+
# read-write. Read concurrency comes from per-cursor reads + the threadpool
32+
# (see read_connection in src/core/datalake.py). Do NOT add --workers.
3033
CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]

scripts/migrate_tick_tz_xauusd.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""One-shot migration: correct broker-local-as-UTC timestamps in tick_data
2+
for XAUUSD.
3+
4+
Background — see quant-strategies-research/docs/RESEARCH_NOTES.md lesson #80
5+
and docs/TZ_FIX_PHASE2_DATALAKE_RECONCILIATION.md.
6+
7+
The XAUUSD ticks in tick_data were originally ingested from an MT5
8+
<DATE>/<TIME> CSV via _read_raw_tick(); that path treated broker-local
9+
clock values as UTC. This script applies a DST-aware Athens -> UTC
10+
correction in place via DuckDB's `timezone()` function.
11+
12+
Run on the VPS where the DuckDB file lives. Read-write transaction;
13+
backs up the affected rows to `tick_data_xauusd_backup_<run-id>` before
14+
UPDATE. Idempotent guard: refuses to run if a `tick_data_migrations`
15+
ledger already contains a matching entry.
16+
17+
Usage:
18+
# dry-run (default) — prints sample before/after, no writes:
19+
python scripts/migrate_tick_tz_xauusd.py
20+
21+
# apply:
22+
python scripts/migrate_tick_tz_xauusd.py --confirm
23+
24+
# ad-hoc broker tz override (default = Europe/Athens):
25+
MT5_BROKER_TZ=Europe/Bucharest python scripts/migrate_tick_tz_xauusd.py --confirm
26+
"""
27+
from __future__ import annotations
28+
29+
import argparse
30+
import os
31+
import sys
32+
import uuid
33+
from datetime import datetime, timezone
34+
from pathlib import Path
35+
36+
# Make sibling 'src/' importable so we share the lake's DB path config.
37+
_HERE = Path(__file__).resolve().parent
38+
sys.path.insert(0, str(_HERE.parent))
39+
40+
import duckdb # type: ignore
41+
42+
# Reuse the lake's own DB path config (avoid drift).
43+
try:
44+
from src.config import DUCKDB_PATH # type: ignore
45+
except Exception:
46+
DUCKDB_PATH = Path(os.getenv("DUCKDB_PATH", str(_HERE.parent / "datalake" / "ohlc.duckdb")))
47+
48+
49+
MIGRATION_ID = "tick_tz_xauusd_athens_2026_05_28"
50+
51+
52+
def ensure_ledger(con):
53+
con.execute("""
54+
CREATE TABLE IF NOT EXISTS tick_data_migrations (
55+
migration_id VARCHAR PRIMARY KEY,
56+
instrument VARCHAR,
57+
broker_tz VARCHAR,
58+
rows_affected BIGINT,
59+
backup_table VARCHAR,
60+
applied_at TIMESTAMP
61+
)
62+
""")
63+
64+
65+
def already_applied(con) -> bool:
66+
r = con.execute(
67+
"SELECT COUNT(*) FROM tick_data_migrations WHERE migration_id = ?",
68+
[MIGRATION_ID],
69+
).fetchone()
70+
return bool(r and r[0])
71+
72+
73+
def sample_rows(con, n=8) -> list:
74+
return con.execute(f"""
75+
SELECT timestamp, price, bid, ask
76+
FROM tick_data
77+
WHERE instrument = 'XAUUSD'
78+
ORDER BY timestamp
79+
LIMIT {n}
80+
""").fetchall()
81+
82+
83+
def sample_post(con, broker_tz: str, n=8) -> list:
84+
return con.execute(f"""
85+
SELECT
86+
timestamp AS before,
87+
timezone('UTC', timezone(?, timestamp)) AS after,
88+
price, bid, ask
89+
FROM tick_data
90+
WHERE instrument = 'XAUUSD'
91+
ORDER BY timestamp
92+
LIMIT {n}
93+
""", [broker_tz]).fetchall()
94+
95+
96+
def main() -> int:
97+
ap = argparse.ArgumentParser()
98+
ap.add_argument("--confirm", action="store_true", help="Apply the UPDATE (default: dry-run)")
99+
ap.add_argument("--broker-tz", default=os.getenv("MT5_BROKER_TZ", "Europe/Athens"))
100+
ap.add_argument("--db", default=str(DUCKDB_PATH))
101+
args = ap.parse_args()
102+
103+
db_path = Path(args.db)
104+
if not db_path.exists():
105+
print(f"DuckDB file not found: {db_path}")
106+
return 2
107+
108+
print(f"DB: {db_path}")
109+
print(f"broker_tz: {args.broker_tz}")
110+
print(f"migration_id: {MIGRATION_ID}")
111+
112+
con = duckdb.connect(str(db_path))
113+
114+
ensure_ledger(con)
115+
if already_applied(con):
116+
print("This migration is already recorded in tick_data_migrations. Refusing to re-run.")
117+
return 0
118+
119+
total = con.execute("SELECT COUNT(*) FROM tick_data WHERE instrument='XAUUSD'").fetchone()[0]
120+
print(f"\nXAUUSD tick rows to migrate: {total:,}")
121+
122+
print("\n--- BEFORE (first 8 rows) ---")
123+
for r in sample_rows(con):
124+
print(" ", r)
125+
126+
print(f"\n--- COMPUTED AFTER (via timezone('UTC', timezone('{args.broker_tz}', ts))) ---")
127+
for r in sample_post(con, args.broker_tz):
128+
print(" ", r)
129+
130+
if not args.confirm:
131+
print("\nDry-run. Re-run with --confirm to apply.")
132+
return 0
133+
134+
backup_table = f"tick_data_xauusd_backup_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}"
135+
print(f"\nBacking up to {backup_table} ...")
136+
con.execute(f"CREATE TABLE {backup_table} AS SELECT * FROM tick_data WHERE instrument='XAUUSD'")
137+
backup_count = con.execute(f"SELECT COUNT(*) FROM {backup_table}").fetchone()[0]
138+
print(f" backed up {backup_count:,} rows")
139+
assert backup_count == total, "Backup row count mismatch — aborting before UPDATE"
140+
141+
print(f"\nApplying UPDATE ...")
142+
# DuckDB UPDATE semantics: timezone('UTC', timezone(tz, ts)) returns the
143+
# naive TIMESTAMP at UTC corresponding to interpreting `ts` as wall-clock
144+
# in `tz`. DST-aware via the IANA zone tables.
145+
con.execute("""
146+
UPDATE tick_data
147+
SET timestamp = timezone('UTC', timezone(?, timestamp))
148+
WHERE instrument = 'XAUUSD'
149+
""", [args.broker_tz])
150+
# DuckDB doesn't expose row-count for UPDATE separately; the backup table
151+
# holds the authoritative pre-state count, which matches.
152+
rows = total
153+
154+
print(f"\n--- AFTER UPDATE (first 8 rows) ---")
155+
for r in sample_rows(con):
156+
print(" ", r)
157+
158+
new_min, new_max = con.execute(
159+
"SELECT MIN(timestamp), MAX(timestamp) FROM tick_data WHERE instrument='XAUUSD'"
160+
).fetchone()
161+
print(f"\nNew XAUUSD tick range: {new_min} -> {new_max}")
162+
163+
print(f"\nRecording migration in tick_data_migrations ledger ...")
164+
con.execute("""
165+
INSERT INTO tick_data_migrations
166+
(migration_id, instrument, broker_tz, rows_affected, backup_table, applied_at)
167+
VALUES (?, ?, ?, ?, ?, ?)
168+
""", [MIGRATION_ID, "XAUUSD", args.broker_tz, rows, backup_table, datetime.now(timezone.utc)])
169+
170+
print(f"Done. backup={backup_table} rows_affected={rows:,}")
171+
print(f"Rollback: UPDATE tick_data SET timestamp = b.timestamp FROM {backup_table} b "
172+
f"WHERE tick_data.instrument='XAUUSD' AND tick_data.<other PK columns> = b.<same>; "
173+
f"DELETE FROM tick_data_migrations WHERE migration_id = '{MIGRATION_ID}';")
174+
return 0
175+
176+
177+
if __name__ == "__main__":
178+
sys.exit(main())

scripts/mt5_bridge.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import os
2323
from datetime import datetime, timezone
2424
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
25+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
2526

2627
try:
2728
import MetaTrader5 as mt5
@@ -35,6 +36,20 @@
3536
# EnvironmentFile so it matches MT5_BRIDGE_KEY on the API container side.
3637
BRIDGE_KEY = os.getenv("MT5_BRIDGE_KEY", "")
3738

39+
# MT5's MqlRates.time is broker-server-local-time-as-int (NOT real UTC Unix
40+
# epoch). Localize as the broker tz then convert to real UTC before emitting.
41+
# Match scripts/mt5_fetch.py in the quant-strategies-research repo. Override
42+
# the broker tz via env (Eightcap = Europe/Athens / EET/EEST).
43+
# See quant-strategies-research/docs/RESEARCH_NOTES.md lesson #80 for context.
44+
BROKER_TZ_NAME = os.getenv("MT5_BROKER_TZ", "Europe/Athens")
45+
try:
46+
BROKER_TZ = ZoneInfo(BROKER_TZ_NAME)
47+
except ZoneInfoNotFoundError as _e:
48+
raise RuntimeError(
49+
f"MT5_BROKER_TZ={BROKER_TZ_NAME!r} not resolvable. On Windows/Wine you "
50+
f"may need `pip install tzdata`."
51+
) from _e
52+
3853
TIMEFRAME_MAP = {
3954
"M1": mt5.TIMEFRAME_M1,
4055
"M5": mt5.TIMEFRAME_M5,
@@ -75,7 +90,15 @@ def fetch_bars(symbol: str, timeframe: str, start: datetime, end: datetime) -> l
7590

7691
out = []
7792
for r in rates:
78-
ts = datetime.fromtimestamp(int(r["time"]), tz=timezone.utc)
93+
# MT5 `time` is broker-local-time-as-int. utcfromtimestamp gives a
94+
# naive datetime whose wall-clock value visually equals the broker
95+
# server clock. Attach the broker tz, then convert to real UTC.
96+
# fold=0 handles DST fall-back ambiguity by picking the first
97+
# (pre-transition) occurrence, which matches MT5's chronological
98+
# emission order; spring-forward gaps don't appear in MT5 output.
99+
naive = datetime.utcfromtimestamp(int(r["time"]))
100+
local = naive.replace(tzinfo=BROKER_TZ)
101+
ts = local.astimezone(timezone.utc)
79102
out.append({
80103
"timestamp": ts.isoformat(),
81104
"open": float(r["open"]),

src/api.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Main FastAPI application - wires up all route modules."""
2-
from fastapi import FastAPI, Depends, Response
2+
from fastapi import FastAPI, Depends, Request, Response
3+
from fastapi.responses import JSONResponse
34
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
45
from prometheus_fastapi_instrumentator import Instrumentator
56
from slowapi.errors import RateLimitExceeded
@@ -36,6 +37,21 @@
3637
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
3738
app.add_middleware(RequestLoggingMiddleware)
3839

40+
41+
@app.exception_handler(Exception)
42+
async def unhandled_exception_handler(request: Request, exc: Exception):
43+
"""
44+
Last-resort handler so an unexpected error returns a JSON `{error, detail}`
45+
envelope with a 500, never a bare gateway HTML body. Clients can always parse
46+
the response as JSON. HTTPException keeps FastAPI's own handler (correct status
47+
+ `{detail}`); this only catches what would otherwise be an uncaught 500.
48+
"""
49+
logger.error("Unhandled exception", exc_info=exc, extra={"path": request.url.path})
50+
return JSONResponse(
51+
status_code=500,
52+
content={"error": "internal_error", "detail": str(exc)},
53+
)
54+
3955
# Instrument every route with request counters + latency histograms.
4056
# /metrics is exposed manually below so we can gate it behind admin scope.
4157
Instrumentator(excluded_handlers=["/metrics", "/healthcheck", "/healthcheck/ready"]).instrument(app)

src/core/cache.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""
2+
In-memory, version-keyed cache for read endpoints whose answers only change on
3+
ingest/delete (e.g. /catalog, /instruments). Each cached value is tagged with the
4+
data-version token from src.core.datalake; a write bumps that token, so the next
5+
read recomputes and everything in between is served from memory without touching
6+
DuckDB. This keeps catalog/instrument listings from competing with data queries.
7+
See datalake-api-w9a.
8+
"""
9+
import threading
10+
from typing import Callable
11+
12+
from src.core.datalake import get_data_version
13+
14+
_lock = threading.Lock()
15+
_store: dict = {} # key -> (data_version, value)
16+
17+
18+
def get_or_compute(key: str, producer: Callable[[], object]) -> object:
19+
"""
20+
Return the cached value for `key` if it was computed at the current data
21+
version, else call `producer()`, cache, and return it. `producer` runs
22+
outside the lock so a slow build doesn't block other cache readers.
23+
"""
24+
version = get_data_version()
25+
with _lock:
26+
hit = _store.get(key)
27+
if hit is not None and hit[0] == version:
28+
return hit[1]
29+
30+
value = producer()
31+
32+
with _lock:
33+
# Re-tag with the version captured before producing. If a write landed
34+
# mid-build, the entry is already stale-by-version and the next read
35+
# recomputes — never serving data older than its tag claims.
36+
_store[key] = (version, value)
37+
return value
38+
39+
40+
def clear() -> None:
41+
"""Drop all cached entries. Mainly for tests; production self-invalidates by version."""
42+
with _lock:
43+
_store.clear()

src/core/concurrency.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""
2+
Backpressure for read endpoints.
3+
4+
DuckDB already parallelizes each query across cores, so a flood of concurrent
5+
heavy scans competes for the same CPU/memory (prod caps the container at 2 CPUs
6+
and DuckDB at a 2GB memory_limit). Rather than let that pile up until the gateway
7+
times out and emits a bare 502, we shed load honestly: once MAX_CONCURRENT_QUERIES
8+
are in flight, further read requests get a 503 + `Retry-After` so clients can back
9+
off and retry. A 503 means "busy, try again"; a 502 reads as "down". See
10+
datalake-api-c45.
11+
"""
12+
import os
13+
import threading
14+
15+
from fastapi import HTTPException
16+
17+
MAX_CONCURRENT_QUERIES = int(os.getenv("MAX_CONCURRENT_QUERIES", "8"))
18+
RETRY_AFTER_SECONDS = int(os.getenv("QUERY_RETRY_AFTER_SECONDS", "1"))
19+
20+
_query_semaphore = threading.BoundedSemaphore(MAX_CONCURRENT_QUERIES)
21+
22+
23+
def query_slot():
24+
"""
25+
FastAPI dependency: reserve one concurrent-read slot for the request's
26+
lifetime (including streamed response bodies). Returns 503 + Retry-After
27+
immediately when all slots are taken instead of queuing into a timeout.
28+
"""
29+
if not _query_semaphore.acquire(blocking=False):
30+
raise HTTPException(
31+
status_code=503,
32+
detail="Server busy — too many concurrent queries. Retry shortly.",
33+
headers={"Retry-After": str(RETRY_AFTER_SECONDS)},
34+
)
35+
try:
36+
yield
37+
finally:
38+
_query_semaphore.release()

0 commit comments

Comments
 (0)