Spaces:
Sleeping
Sleeping
Upload 10 files
Browse files- Dockerfile +34 -0
- clickhouse-config.xml +51 -0
- gateway.py +192 -0
- init_clickhouse.sh +74 -0
- refresh_sources.py +152 -0
- refresh_sources.sh +126 -0
- requirements.txt +3 -0
- server.py +68 -0
- sources.yaml +85 -0
Dockerfile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM ubuntu:22.04
|
| 2 |
+
|
| 3 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 4 |
+
|
| 5 |
+
RUN apt-get update && apt-get install -y \
|
| 6 |
+
git curl python3 python3-pip \
|
| 7 |
+
&& pip3 install --no-cache-dir pyyaml \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Install ClickHouse static binary
|
| 11 |
+
RUN curl https://clickhouse.com/ | sh && \
|
| 12 |
+
mv clickhouse /usr/local/bin/ && \
|
| 13 |
+
chmod +x /usr/local/bin/clickhouse
|
| 14 |
+
|
| 15 |
+
# Install yq β YAML parser for bash (used by refresh_sources.sh)
|
| 16 |
+
RUN curl -fsSL https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 \
|
| 17 |
+
-o /usr/local/bin/yq && \
|
| 18 |
+
chmod +x /usr/local/bin/yq
|
| 19 |
+
|
| 20 |
+
WORKDIR /app
|
| 21 |
+
COPY . .
|
| 22 |
+
|
| 23 |
+
# Make scripts executable
|
| 24 |
+
RUN chmod +x init_clickhouse.sh refresh_sources.sh
|
| 25 |
+
|
| 26 |
+
# Create all directories ClickHouse needs β writable by uid 1000 (HF default user)
|
| 27 |
+
RUN mkdir -p /app/ch/data /app/ch/tmp /app/ch/user_files /app/ch/format_schemas \
|
| 28 |
+
/app/ch/access /app/ch/log /app/data && \
|
| 29 |
+
chmod -R 777 /app
|
| 30 |
+
|
| 31 |
+
# HF Spaces only exposes this port β gateway sits here
|
| 32 |
+
EXPOSE 7860
|
| 33 |
+
|
| 34 |
+
CMD ["bash", "init_clickhouse.sh"]
|
clickhouse-config.xml
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<clickhouse>
|
| 2 |
+
<!-- Redirect ALL storage paths to /app/ch (writable by HF uid 1000) -->
|
| 3 |
+
<path>/app/ch/data/</path>
|
| 4 |
+
<tmp_path>/app/ch/tmp/</tmp_path>
|
| 5 |
+
<user_files_path>/app/ch/user_files/</user_files_path>
|
| 6 |
+
<format_schema_path>/app/ch/format_schemas/</format_schema_path>
|
| 7 |
+
<access_control_path>/app/ch/access/</access_control_path>
|
| 8 |
+
|
| 9 |
+
<logger>
|
| 10 |
+
<log>/app/ch/log/clickhouse-server.log</log>
|
| 11 |
+
<errorlog>/app/ch/log/clickhouse-server.err.log</errorlog>
|
| 12 |
+
<level>warning</level>
|
| 13 |
+
</logger>
|
| 14 |
+
|
| 15 |
+
<!-- ClickHouse HTTP on 8123 (internal) β gateway proxies from 7860 -->
|
| 16 |
+
<listen_host>0.0.0.0</listen_host>
|
| 17 |
+
<http_port>8123</http_port>
|
| 18 |
+
<tcp_port>9000</tcp_port>
|
| 19 |
+
|
| 20 |
+
<!-- Default profiles -->
|
| 21 |
+
<profiles>
|
| 22 |
+
<default/>
|
| 23 |
+
</profiles>
|
| 24 |
+
|
| 25 |
+
<!-- Default user β open for HF Space (no external network access anyway) -->
|
| 26 |
+
<users>
|
| 27 |
+
<default>
|
| 28 |
+
<password></password>
|
| 29 |
+
<networks>
|
| 30 |
+
<ip>::/0</ip>
|
| 31 |
+
</networks>
|
| 32 |
+
<profile>default</profile>
|
| 33 |
+
<quota>default</quota>
|
| 34 |
+
<access_management>1</access_management>
|
| 35 |
+
</default>
|
| 36 |
+
</users>
|
| 37 |
+
|
| 38 |
+
<!-- Default quota (unlimited) -->
|
| 39 |
+
<quotas>
|
| 40 |
+
<default>
|
| 41 |
+
<interval>
|
| 42 |
+
<duration>3600</duration>
|
| 43 |
+
<queries>0</queries>
|
| 44 |
+
<errors>0</errors>
|
| 45 |
+
<result_rows>0</result_rows>
|
| 46 |
+
<read_rows>0</read_rows>
|
| 47 |
+
<execution_time>0</execution_time>
|
| 48 |
+
</interval>
|
| 49 |
+
</default>
|
| 50 |
+
</quotas>
|
| 51 |
+
</clickhouse>
|
gateway.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
gateway.py β Lightweight HTTP gateway for the Observatory
|
| 4 |
+
|
| 5 |
+
Sits on port 7860 (the only port HF Spaces exposes) and routes:
|
| 6 |
+
|
| 7 |
+
GET /api/refresh β git pull all repos + recreate all views (on-demand)
|
| 8 |
+
GET /api/sources β return current sources.yaml as JSON
|
| 9 |
+
* /* β proxy everything else to ClickHouse on 8123
|
| 10 |
+
|
| 11 |
+
This lets you push to GitHub, then immediately:
|
| 12 |
+
curl https://your-space.hf.space/api/refresh
|
| 13 |
+
curl https://your-space.hf.space/?query=SELECT * FROM ohlc LIMIT 5
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
import subprocess
|
| 19 |
+
import sys
|
| 20 |
+
import time
|
| 21 |
+
import urllib.request
|
| 22 |
+
import urllib.error
|
| 23 |
+
import yaml
|
| 24 |
+
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
| 25 |
+
|
| 26 |
+
CLICKHOUSE_URL = "http://127.0.0.1:8123"
|
| 27 |
+
CONFIG_PATH = "/app/sources.yaml"
|
| 28 |
+
REFRESH_SCRIPT = "/app/refresh_sources.py"
|
| 29 |
+
GATEWAY_PORT = 7860
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class GatewayHandler(BaseHTTPRequestHandler):
|
| 33 |
+
|
| 34 |
+
# ββ /api/refresh βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
def _handle_refresh(self):
|
| 36 |
+
"""Run refresh_sources.sh and return JSON result."""
|
| 37 |
+
try:
|
| 38 |
+
start = time.time()
|
| 39 |
+
result = subprocess.run(
|
| 40 |
+
["bash", REFRESH_SCRIPT, "full"],
|
| 41 |
+
capture_output=True, text=True, timeout=300,
|
| 42 |
+
)
|
| 43 |
+
elapsed = round(time.time() - start, 2)
|
| 44 |
+
|
| 45 |
+
body = {
|
| 46 |
+
"action": "refresh",
|
| 47 |
+
"elapsed_seconds": elapsed,
|
| 48 |
+
"exit_code": result.returncode,
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
# Try to parse script's JSON output
|
| 52 |
+
try:
|
| 53 |
+
body["result"] = json.loads(result.stdout)
|
| 54 |
+
except (json.JSONDecodeError, ValueError):
|
| 55 |
+
body["stdout"] = result.stdout
|
| 56 |
+
|
| 57 |
+
if result.stderr:
|
| 58 |
+
body["stderr"] = result.stderr
|
| 59 |
+
|
| 60 |
+
self._json_response(200, body)
|
| 61 |
+
|
| 62 |
+
except subprocess.TimeoutExpired:
|
| 63 |
+
self._json_response(504, {"error": "refresh timed out after 300s"})
|
| 64 |
+
except Exception as e:
|
| 65 |
+
self._json_response(500, {"error": str(e)})
|
| 66 |
+
|
| 67 |
+
# ββ /api/sources βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 68 |
+
def _handle_sources(self):
|
| 69 |
+
"""Return current sources.yaml as JSON."""
|
| 70 |
+
try:
|
| 71 |
+
with open(CONFIG_PATH, "r") as f:
|
| 72 |
+
config = yaml.safe_load(f)
|
| 73 |
+
self._json_response(200, config)
|
| 74 |
+
except Exception as e:
|
| 75 |
+
self._json_response(500, {"error": str(e)})
|
| 76 |
+
|
| 77 |
+
# ββ Proxy to ClickHouse ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 78 |
+
def _proxy_to_clickhouse(self):
|
| 79 |
+
"""Forward the request to ClickHouse HTTP interface."""
|
| 80 |
+
# Read request body if present
|
| 81 |
+
content_length = int(self.headers.get("Content-Length", 0))
|
| 82 |
+
body = self.rfile.read(content_length) if content_length else None
|
| 83 |
+
|
| 84 |
+
url = f"{CLICKHOUSE_URL}{self.path}"
|
| 85 |
+
|
| 86 |
+
req = urllib.request.Request(url, data=body, method=self.command)
|
| 87 |
+
|
| 88 |
+
# Forward relevant headers
|
| 89 |
+
for header in self.headers:
|
| 90 |
+
lower = header.lower()
|
| 91 |
+
if lower not in ("host", "content-length", "transfer-encoding"):
|
| 92 |
+
req.add_header(header, self.headers[header])
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
resp = urllib.request.urlopen(req, timeout=600)
|
| 96 |
+
self.send_response(resp.status)
|
| 97 |
+
# Forward response headers
|
| 98 |
+
for key, val in resp.headers.items():
|
| 99 |
+
if key.lower() != "transfer-encoding":
|
| 100 |
+
self.send_header(key, val)
|
| 101 |
+
# Add CORS headers for browser-based SQL playgrounds
|
| 102 |
+
self.send_header("Access-Control-Allow-Origin", "*")
|
| 103 |
+
self.end_headers()
|
| 104 |
+
# Stream response body
|
| 105 |
+
while True:
|
| 106 |
+
chunk = resp.read(65536)
|
| 107 |
+
if not chunk:
|
| 108 |
+
break
|
| 109 |
+
self.wfile.write(chunk)
|
| 110 |
+
|
| 111 |
+
except urllib.error.HTTPError as e:
|
| 112 |
+
self.send_response(e.code)
|
| 113 |
+
for key, val in e.headers.items():
|
| 114 |
+
if key.lower() != "transfer-encoding":
|
| 115 |
+
self.send_header(key, val)
|
| 116 |
+
self.send_header("Access-Control-Allow-Origin", "*")
|
| 117 |
+
self.end_headers()
|
| 118 |
+
self.wfile.write(e.read())
|
| 119 |
+
|
| 120 |
+
except Exception as e:
|
| 121 |
+
self._json_response(502, {"error": f"ClickHouse unreachable: {e}"})
|
| 122 |
+
|
| 123 |
+
# ββ HTTP method handlers βββββββββββββββββββββββββββββββββββββββββββββ
|
| 124 |
+
def do_GET(self):
|
| 125 |
+
if self.path == "/api/refresh":
|
| 126 |
+
self._handle_refresh()
|
| 127 |
+
elif self.path == "/api/sources":
|
| 128 |
+
self._handle_sources()
|
| 129 |
+
else:
|
| 130 |
+
self._proxy_to_clickhouse()
|
| 131 |
+
|
| 132 |
+
def do_POST(self):
|
| 133 |
+
self._proxy_to_clickhouse()
|
| 134 |
+
|
| 135 |
+
def do_OPTIONS(self):
|
| 136 |
+
"""CORS preflight."""
|
| 137 |
+
self.send_response(204)
|
| 138 |
+
self.send_header("Access-Control-Allow-Origin", "*")
|
| 139 |
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
| 140 |
+
self.send_header("Access-Control-Allow-Headers", "*")
|
| 141 |
+
self.end_headers()
|
| 142 |
+
|
| 143 |
+
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 144 |
+
def _json_response(self, code, data):
|
| 145 |
+
body = json.dumps(data, indent=2).encode()
|
| 146 |
+
self.send_response(code)
|
| 147 |
+
self.send_header("Content-Type", "application/json")
|
| 148 |
+
self.send_header("Content-Length", str(len(body)))
|
| 149 |
+
self.send_header("Access-Control-Allow-Origin", "*")
|
| 150 |
+
self.end_headers()
|
| 151 |
+
self.wfile.write(body)
|
| 152 |
+
|
| 153 |
+
def log_message(self, format, *args):
|
| 154 |
+
"""Cleaner log format."""
|
| 155 |
+
sys.stderr.write(f"[gateway] {self.address_string()} {format % args}\n")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def wait_for_clickhouse(timeout=60):
|
| 159 |
+
"""Block until ClickHouse is responding on 8123."""
|
| 160 |
+
print(f"[gateway] Waiting for ClickHouse on {CLICKHOUSE_URL}...")
|
| 161 |
+
for i in range(timeout):
|
| 162 |
+
try:
|
| 163 |
+
urllib.request.urlopen(f"{CLICKHOUSE_URL}/ping", timeout=2)
|
| 164 |
+
print(f"[gateway] ClickHouse is ready (took {i+1}s)")
|
| 165 |
+
return True
|
| 166 |
+
except Exception:
|
| 167 |
+
time.sleep(1)
|
| 168 |
+
print(f"[gateway] WARNING: ClickHouse not ready after {timeout}s, starting anyway")
|
| 169 |
+
return False
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
if __name__ == "__main__":
|
| 173 |
+
# Wait for ClickHouse to be ready before accepting traffic
|
| 174 |
+
wait_for_clickhouse()
|
| 175 |
+
|
| 176 |
+
# Run initial full refresh (clone repos + create views)
|
| 177 |
+
print("[gateway] Running initial data sync + view creation...")
|
| 178 |
+
result = subprocess.run(
|
| 179 |
+
["python3", REFRESH_SCRIPT, "full"],
|
| 180 |
+
capture_output=True, text=True, timeout=300,
|
| 181 |
+
)
|
| 182 |
+
print(result.stdout)
|
| 183 |
+
if result.stderr:
|
| 184 |
+
print(f"[gateway] stderr: {result.stderr}", file=sys.stderr)
|
| 185 |
+
|
| 186 |
+
# Start gateway
|
| 187 |
+
server = ThreadingHTTPServer(("0.0.0.0", GATEWAY_PORT), GatewayHandler)
|
| 188 |
+
print(f"[gateway] Listening on port {GATEWAY_PORT}")
|
| 189 |
+
print(f"[gateway] /api/refresh β on-demand git pull + view recreation")
|
| 190 |
+
print(f"[gateway] /api/sources β current config as JSON")
|
| 191 |
+
print(f"[gateway] /* β proxy to ClickHouse")
|
| 192 |
+
server.serve_forever()
|
init_clickhouse.sh
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# Market-Data Observatory β Init Script
|
| 4 |
+
#
|
| 5 |
+
# Boot sequence:
|
| 6 |
+
# 1. Start ClickHouse server (port 8123, internal)
|
| 7 |
+
# 2. Start background refresh loop (git pull every N seconds)
|
| 8 |
+
# 3. Start Python gateway (port 7860, public-facing)
|
| 9 |
+
# β gateway waits for ClickHouse, then does initial clone + view creation
|
| 10 |
+
#
|
| 11 |
+
# The gateway handles:
|
| 12 |
+
# /api/refresh β on-demand git pull + view recreation
|
| 13 |
+
# /api/sources β current config as JSON
|
| 14 |
+
# /* β proxy to ClickHouse
|
| 15 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 16 |
+
set -e
|
| 17 |
+
|
| 18 |
+
CONFIG="/app/sources.yaml"
|
| 19 |
+
|
| 20 |
+
echo "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"
|
| 21 |
+
echo "β Market-Data Observatory β Boot Sequence β"
|
| 22 |
+
echo "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"
|
| 23 |
+
echo ""
|
| 24 |
+
|
| 25 |
+
# ββ Validate config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
if [ ! -f "$CONFIG" ]; then
|
| 27 |
+
echo "[FATAL] Config file $CONFIG not found!"
|
| 28 |
+
exit 1
|
| 29 |
+
fi
|
| 30 |
+
|
| 31 |
+
REFRESH_INTERVAL=$(yq '.refresh_interval_seconds // 300' "$CONFIG")
|
| 32 |
+
SOURCE_COUNT=$(yq '.sources | length' "$CONFIG")
|
| 33 |
+
echo "[config] $SOURCE_COUNT source(s), background refresh every ${REFRESH_INTERVAL}s"
|
| 34 |
+
echo ""
|
| 35 |
+
|
| 36 |
+
# ββ 1. Start ClickHouse (background, port 8123) βββββββββββββββββββββββββββββ
|
| 37 |
+
echo "[1/3] Starting ClickHouse on port 8123 (internal)..."
|
| 38 |
+
clickhouse server --config-file=/app/clickhouse-config.xml &
|
| 39 |
+
CH_PID=$!
|
| 40 |
+
echo " PID: $CH_PID"
|
| 41 |
+
echo ""
|
| 42 |
+
|
| 43 |
+
# ββ 2. Background auto-refresh loop βββββββββββββββββββββββββββββββββββββββββ
|
| 44 |
+
if [ "$REFRESH_INTERVAL" -gt 0 ] 2>/dev/null; then
|
| 45 |
+
echo "[2/3] Starting background refresh loop (every ${REFRESH_INTERVAL}s)..."
|
| 46 |
+
(
|
| 47 |
+
# Wait for initial setup to complete before starting loop
|
| 48 |
+
sleep "$REFRESH_INTERVAL"
|
| 49 |
+
while true; do
|
| 50 |
+
echo "[refresh-loop] Syncing all sources..."
|
| 51 |
+
python3 /app/refresh_sources.py sync_only > /dev/null 2>&1 || true
|
| 52 |
+
sleep "$REFRESH_INTERVAL"
|
| 53 |
+
done
|
| 54 |
+
) &
|
| 55 |
+
else
|
| 56 |
+
echo "[2/3] Background refresh loop DISABLED (refresh_interval_seconds is 0)."
|
| 57 |
+
echo " You must manually call /api/refresh to update data."
|
| 58 |
+
fi
|
| 59 |
+
echo ""
|
| 60 |
+
|
| 61 |
+
# ββ 3. Start gateway (foreground, port 7860) ββββββββββββββββββββββββββββββββ
|
| 62 |
+
echo "[3/3] Starting gateway on port 7860 (public)..."
|
| 63 |
+
echo ""
|
| 64 |
+
echo "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"
|
| 65 |
+
echo "β Endpoints: β"
|
| 66 |
+
echo "β /api/refresh β instant git pull + view recreation β"
|
| 67 |
+
echo "β /api/sources β current config as JSON β"
|
| 68 |
+
echo "β /?query=... β ClickHouse SQL (proxied) β"
|
| 69 |
+
echo "ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"
|
| 70 |
+
echo ""
|
| 71 |
+
|
| 72 |
+
# Gateway runs in foreground β keeps the container alive
|
| 73 |
+
# If it dies, the container restarts
|
| 74 |
+
exec python3 /app/gateway.py
|
refresh_sources.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
refresh_sources.py β Standalone refresh script in Python
|
| 4 |
+
|
| 5 |
+
Actions:
|
| 6 |
+
β’ git clone (first run) or git pull (subsequent) for each source
|
| 7 |
+
β’ Inject auth token if `auth_env_var` is defined in config
|
| 8 |
+
β’ CREATE OR REPLACE VIEW for each defined view
|
| 9 |
+
"""
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
import json
|
| 13 |
+
import yaml
|
| 14 |
+
import urllib.request
|
| 15 |
+
import urllib.error
|
| 16 |
+
import subprocess
|
| 17 |
+
|
| 18 |
+
# Allow local testing gracefully
|
| 19 |
+
CONFIG = "/app/sources.yaml" if os.path.exists("/app/sources.yaml") else "sources.yaml"
|
| 20 |
+
USER_FILES = "/app/ch/user_files" if os.path.exists("/app") else "user_files"
|
| 21 |
+
CH_URL = "http://127.0.0.1:8123"
|
| 22 |
+
|
| 23 |
+
def run_subprocess(cmd, cwd=None, env=None):
|
| 24 |
+
try:
|
| 25 |
+
result = subprocess.run(
|
| 26 |
+
cmd, cwd=cwd, env=env,
|
| 27 |
+
capture_output=True, text=True, check=True
|
| 28 |
+
)
|
| 29 |
+
return result.stdout.strip(), True
|
| 30 |
+
except subprocess.CalledProcessError as e:
|
| 31 |
+
return e.output.strip() + "\n" + e.stderr.strip(), False
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def execute_clickhouse_query(sql):
|
| 35 |
+
req = urllib.request.Request(CH_URL, data=sql.encode('utf-8'), method='POST')
|
| 36 |
+
try:
|
| 37 |
+
resp = urllib.request.urlopen(req, timeout=10)
|
| 38 |
+
return resp.read().decode('utf-8'), True
|
| 39 |
+
except urllib.error.URLError as e:
|
| 40 |
+
return str(e), False
|
| 41 |
+
|
| 42 |
+
def main():
|
| 43 |
+
mode = "full"
|
| 44 |
+
if len(sys.argv) > 1:
|
| 45 |
+
mode = sys.argv[1]
|
| 46 |
+
|
| 47 |
+
if not os.path.exists(CONFIG):
|
| 48 |
+
print(json.dumps({"error": f"Config not found at {CONFIG}"}))
|
| 49 |
+
sys.exit(1)
|
| 50 |
+
|
| 51 |
+
with open(CONFIG, "r") as f:
|
| 52 |
+
config_data = yaml.safe_load(f)
|
| 53 |
+
|
| 54 |
+
sources = config_data.get("sources", [])
|
| 55 |
+
|
| 56 |
+
result = {
|
| 57 |
+
"mode": mode,
|
| 58 |
+
"source_count": len(sources),
|
| 59 |
+
"sources": []
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
env_vars = os.environ.copy()
|
| 63 |
+
|
| 64 |
+
for source in sources:
|
| 65 |
+
name = source.get("name", "unknown")
|
| 66 |
+
repo_url = source.get("repo_url")
|
| 67 |
+
local_dir = source.get("local_dir")
|
| 68 |
+
clone_depth = str(source.get("clone_depth", 1))
|
| 69 |
+
branch = source.get("branch", "")
|
| 70 |
+
auth_env_var = source.get("auth_env_var", "")
|
| 71 |
+
|
| 72 |
+
target_dir = os.path.join(USER_FILES, local_dir)
|
| 73 |
+
|
| 74 |
+
# Build git clone/pull options
|
| 75 |
+
auth_flag = []
|
| 76 |
+
if auth_env_var and auth_env_var in env_vars:
|
| 77 |
+
token = env_vars[auth_env_var]
|
| 78 |
+
auth_flag = ["-c", f"http.extraHeader=Authorization: Bearer {token}"]
|
| 79 |
+
|
| 80 |
+
branch_flag = []
|
| 81 |
+
if branch:
|
| 82 |
+
branch_flag = ["--branch", branch]
|
| 83 |
+
|
| 84 |
+
git_status = "unknown"
|
| 85 |
+
|
| 86 |
+
# Git Sync
|
| 87 |
+
if not os.path.isdir(target_dir):
|
| 88 |
+
cmd = ["git", "clone", "--depth", clone_depth] + branch_flag + auth_flag + [repo_url, target_dir]
|
| 89 |
+
output, success = run_subprocess(cmd)
|
| 90 |
+
git_status = "cloned" if success else f"error_clone: {output}"
|
| 91 |
+
else:
|
| 92 |
+
# We want to fetch and hard reset
|
| 93 |
+
cmd_fetch = ["git"] + auth_flag + ["fetch", "--depth", "1", "origin"]
|
| 94 |
+
output_f, success_f = run_subprocess(cmd_fetch, cwd=target_dir)
|
| 95 |
+
if success_f:
|
| 96 |
+
# get current branch
|
| 97 |
+
rev_cmd = ["git", "rev-parse", "--abbrev-ref", "HEAD"]
|
| 98 |
+
curr_branch, _ = run_subprocess(rev_cmd, cwd=target_dir)
|
| 99 |
+
reset_cmd = ["git", "reset", "--hard", f"origin/{curr_branch}"]
|
| 100 |
+
output_r, success_r = run_subprocess(reset_cmd, cwd=target_dir)
|
| 101 |
+
git_status = "pulled" if success_r else f"error_reset: {output_r}"
|
| 102 |
+
else:
|
| 103 |
+
git_status = f"error_fetch: {output_f}"
|
| 104 |
+
|
| 105 |
+
latest_commit, _ = run_subprocess(["git", "log", "-1", "--format=%h %s"], cwd=target_dir)
|
| 106 |
+
if not latest_commit:
|
| 107 |
+
latest_commit = "unknown"
|
| 108 |
+
|
| 109 |
+
source_info = {
|
| 110 |
+
"name": name,
|
| 111 |
+
"git_status": git_status,
|
| 112 |
+
"latest_commit": latest_commit
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
# View Creation
|
| 116 |
+
if mode == "full":
|
| 117 |
+
views_info = []
|
| 118 |
+
views = source.get("views", [])
|
| 119 |
+
for view in views:
|
| 120 |
+
view_name = view.get("view_name")
|
| 121 |
+
file_glob = view.get("file_glob")
|
| 122 |
+
format_ = view.get("format", "Parquet")
|
| 123 |
+
columns = view.get("columns", [])
|
| 124 |
+
|
| 125 |
+
full_glob = f"{local_dir}/{file_glob}"
|
| 126 |
+
|
| 127 |
+
if columns:
|
| 128 |
+
cols_str = ", ".join(columns)
|
| 129 |
+
select_clause = f"SELECT {cols_str}"
|
| 130 |
+
else:
|
| 131 |
+
select_clause = "SELECT *"
|
| 132 |
+
|
| 133 |
+
sql = f"CREATE OR REPLACE VIEW {view_name} AS {select_clause} FROM file('{full_glob}', {format_})"
|
| 134 |
+
|
| 135 |
+
_, success = execute_clickhouse_query(sql)
|
| 136 |
+
view_status = "ok" if success else "error"
|
| 137 |
+
|
| 138 |
+
views_info.append({
|
| 139 |
+
"name": view_name,
|
| 140 |
+
"glob": full_glob,
|
| 141 |
+
"select": select_clause,
|
| 142 |
+
"status": view_status
|
| 143 |
+
})
|
| 144 |
+
|
| 145 |
+
source_info["views"] = views_info
|
| 146 |
+
|
| 147 |
+
result["sources"].append(source_info)
|
| 148 |
+
|
| 149 |
+
print(json.dumps(result, indent=2))
|
| 150 |
+
|
| 151 |
+
if __name__ == "__main__":
|
| 152 |
+
main()
|
refresh_sources.sh
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3 |
+
# refresh_sources.sh β Standalone refresh script
|
| 4 |
+
#
|
| 5 |
+
# Can be called:
|
| 6 |
+
# 1. At boot (from init_clickhouse.sh)
|
| 7 |
+
# 2. On-demand (from gateway.py when you hit /api/refresh)
|
| 8 |
+
# 3. Periodically (from background loop)
|
| 9 |
+
#
|
| 10 |
+
# Actions:
|
| 11 |
+
# β’ git clone (first run) or git pull (subsequent) for each source
|
| 12 |
+
# β’ CREATE OR REPLACE VIEW for each defined view
|
| 13 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 14 |
+
set -e
|
| 15 |
+
|
| 16 |
+
CONFIG="/app/sources.yaml"
|
| 17 |
+
USER_FILES="/app/ch/user_files"
|
| 18 |
+
CH_URL="http://127.0.0.1:8123"
|
| 19 |
+
|
| 20 |
+
# Optional argument: "sync_only" = just git pull, no view creation
|
| 21 |
+
# (used by background loop to avoid hammering ClickHouse with DDL)
|
| 22 |
+
MODE="${1:-full}"
|
| 23 |
+
|
| 24 |
+
SOURCE_COUNT=$(yq '.sources | length' "$CONFIG")
|
| 25 |
+
|
| 26 |
+
echo "{"
|
| 27 |
+
echo " \"mode\": \"$MODE\","
|
| 28 |
+
echo " \"source_count\": $SOURCE_COUNT,"
|
| 29 |
+
echo " \"sources\": ["
|
| 30 |
+
|
| 31 |
+
for i in $(seq 0 $((SOURCE_COUNT - 1))); do
|
| 32 |
+
NAME=$(yq ".sources[$i].name" "$CONFIG")
|
| 33 |
+
REPO_URL=$(yq ".sources[$i].repo_url" "$CONFIG")
|
| 34 |
+
LOCAL_DIR=$(yq ".sources[$i].local_dir" "$CONFIG")
|
| 35 |
+
CLONE_DEPTH=$(yq ".sources[$i].clone_depth // 1" "$CONFIG")
|
| 36 |
+
BRANCH=$(yq ".sources[$i].branch // \"\"" "$CONFIG")
|
| 37 |
+
|
| 38 |
+
TARGET="$USER_FILES/$LOCAL_DIR"
|
| 39 |
+
|
| 40 |
+
BRANCH_FLAG=""
|
| 41 |
+
if [ -n "$BRANCH" ] && [ "$BRANCH" != "null" ] && [ "$BRANCH" != "" ]; then
|
| 42 |
+
BRANCH_FLAG="--branch $BRANCH"
|
| 43 |
+
fi
|
| 44 |
+
|
| 45 |
+
# ββ Git sync ββ
|
| 46 |
+
GIT_STATUS="unknown"
|
| 47 |
+
GIT_OUTPUT=""
|
| 48 |
+
if [ ! -d "$TARGET" ]; then
|
| 49 |
+
GIT_OUTPUT=$(git clone --depth "$CLONE_DEPTH" $BRANCH_FLAG "$REPO_URL" "$TARGET" 2>&1) || true
|
| 50 |
+
GIT_STATUS="cloned"
|
| 51 |
+
else
|
| 52 |
+
GIT_OUTPUT=$(cd "$TARGET" && git fetch --depth 1 origin && git reset --hard origin/$(git rev-parse --abbrev-ref HEAD) 2>&1) || true
|
| 53 |
+
GIT_STATUS="pulled"
|
| 54 |
+
fi
|
| 55 |
+
|
| 56 |
+
LATEST_COMMIT=$(cd "$TARGET" && git log -1 --format='%h %s' 2>/dev/null || echo "unknown")
|
| 57 |
+
|
| 58 |
+
# ββ Trailing comma handling ββ
|
| 59 |
+
COMMA=","
|
| 60 |
+
if [ $i -eq $((SOURCE_COUNT - 1)) ] && [ "$MODE" = "sync_only" ]; then
|
| 61 |
+
COMMA=""
|
| 62 |
+
fi
|
| 63 |
+
|
| 64 |
+
# ββ View creation (only in "full" mode) ββ
|
| 65 |
+
VIEWS_JSON=""
|
| 66 |
+
if [ "$MODE" = "full" ]; then
|
| 67 |
+
NUM_VIEWS=$(yq ".sources[$i].views | length" "$CONFIG")
|
| 68 |
+
VIEWS_JSON="\"views\": ["
|
| 69 |
+
|
| 70 |
+
for v in $(seq 0 $((NUM_VIEWS - 1))); do
|
| 71 |
+
VIEW_NAME=$(yq ".sources[$i].views[$v].view_name" "$CONFIG")
|
| 72 |
+
FILE_GLOB=$(yq ".sources[$i].views[$v].file_glob" "$CONFIG")
|
| 73 |
+
FORMAT=$(yq ".sources[$i].views[$v].format // \"Parquet\"" "$CONFIG")
|
| 74 |
+
NUM_COLS=$(yq ".sources[$i].views[$v].columns | length // 0" "$CONFIG")
|
| 75 |
+
|
| 76 |
+
FULL_GLOB="${LOCAL_DIR}/${FILE_GLOB}"
|
| 77 |
+
|
| 78 |
+
# Build SELECT clause β explicit columns if defined, else SELECT *
|
| 79 |
+
if [ "$NUM_COLS" -gt 0 ] 2>/dev/null; then
|
| 80 |
+
COLS=""
|
| 81 |
+
for c in $(seq 0 $((NUM_COLS - 1))); do
|
| 82 |
+
COL=$(yq ".sources[$i].views[$v].columns[$c]" "$CONFIG")
|
| 83 |
+
if [ -z "$COLS" ]; then
|
| 84 |
+
COLS="$COL"
|
| 85 |
+
else
|
| 86 |
+
COLS="$COLS, $COL"
|
| 87 |
+
fi
|
| 88 |
+
done
|
| 89 |
+
SELECT_CLAUSE="SELECT ${COLS}"
|
| 90 |
+
else
|
| 91 |
+
SELECT_CLAUSE="SELECT *"
|
| 92 |
+
fi
|
| 93 |
+
|
| 94 |
+
SQL="CREATE OR REPLACE VIEW ${VIEW_NAME} AS ${SELECT_CLAUSE} FROM file('${FULL_GLOB}', ${FORMAT})"
|
| 95 |
+
|
| 96 |
+
VIEW_STATUS="ok"
|
| 97 |
+
if ! curl -sf "$CH_URL" --data "$SQL" 2>/dev/null; then
|
| 98 |
+
VIEW_STATUS="error"
|
| 99 |
+
fi
|
| 100 |
+
|
| 101 |
+
VIEW_COMMA=","
|
| 102 |
+
if [ $v -eq $((NUM_VIEWS - 1)) ]; then
|
| 103 |
+
VIEW_COMMA=""
|
| 104 |
+
fi
|
| 105 |
+
|
| 106 |
+
VIEWS_JSON="${VIEWS_JSON}{\"name\":\"${VIEW_NAME}\",\"glob\":\"${FULL_GLOB}\",\"select\":\"${SELECT_CLAUSE}\",\"status\":\"${VIEW_STATUS}\"}${VIEW_COMMA}"
|
| 107 |
+
done
|
| 108 |
+
|
| 109 |
+
VIEWS_JSON="${VIEWS_JSON}]"
|
| 110 |
+
fi
|
| 111 |
+
|
| 112 |
+
# Determine trailing comma for this source
|
| 113 |
+
COMMA=","
|
| 114 |
+
if [ $i -eq $((SOURCE_COUNT - 1)) ]; then
|
| 115 |
+
COMMA=""
|
| 116 |
+
fi
|
| 117 |
+
|
| 118 |
+
if [ "$MODE" = "full" ]; then
|
| 119 |
+
echo " {\"name\":\"$NAME\",\"git_status\":\"$GIT_STATUS\",\"latest_commit\":\"$LATEST_COMMIT\",$VIEWS_JSON}$COMMA"
|
| 120 |
+
else
|
| 121 |
+
echo " {\"name\":\"$NAME\",\"git_status\":\"$GIT_STATUS\",\"latest_commit\":\"$LATEST_COMMIT\"}$COMMA"
|
| 122 |
+
fi
|
| 123 |
+
done
|
| 124 |
+
|
| 125 |
+
echo " ]"
|
| 126 |
+
echo "}"
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
httpx
|
server.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, Request, HTTPException
|
| 2 |
+
from fastapi.responses import StreamingResponse, HTMLResponse
|
| 3 |
+
import subprocess
|
| 4 |
+
import httpx
|
| 5 |
+
|
| 6 |
+
app = FastAPI(title="Market-Data Observatory")
|
| 7 |
+
|
| 8 |
+
# Async HTTP client for proxying to ClickHouse's internal HTTP interface
|
| 9 |
+
ch_client = httpx.AsyncClient(base_url="http://127.0.0.1:8123", timeout=60.0)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# ββ Refresh endpoint (git pull) ββ
|
| 13 |
+
@app.post("/refresh")
|
| 14 |
+
def refresh():
|
| 15 |
+
try:
|
| 16 |
+
result = subprocess.check_output(
|
| 17 |
+
"cd /app/data/ohlc_data && git pull",
|
| 18 |
+
shell=True, stderr=subprocess.STDOUT
|
| 19 |
+
)
|
| 20 |
+
return {"status": "updated", "details": result.decode().strip()}
|
| 21 |
+
except subprocess.CalledProcessError as e:
|
| 22 |
+
msg = e.output.decode() if e.output else str(e)
|
| 23 |
+
raise HTTPException(status_code=500, detail={"error": "Refresh failed", "message": msg})
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ββ Health check ββ
|
| 27 |
+
@app.get("/health")
|
| 28 |
+
async def health():
|
| 29 |
+
try:
|
| 30 |
+
r = await ch_client.get("/ping")
|
| 31 |
+
return {"clickhouse": r.text.strip(), "status": "ok"}
|
| 32 |
+
except Exception as e:
|
| 33 |
+
raise HTTPException(status_code=503, detail={"status": "unhealthy", "error": str(e)})
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ββ Catch-all reverse proxy to ClickHouse ββ
|
| 37 |
+
# This gives you /play, /dashboard, native HTTP API, everything.
|
| 38 |
+
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"])
|
| 39 |
+
async def proxy_to_clickhouse(request: Request, path: str):
|
| 40 |
+
url = httpx.URL(path=f"/{path}", query=request.url.query.encode("utf-8"))
|
| 41 |
+
|
| 42 |
+
# Forward headers, strip hop-by-hop
|
| 43 |
+
headers = dict(request.headers)
|
| 44 |
+
for h in ("host", "content-length", "transfer-encoding"):
|
| 45 |
+
headers.pop(h, None)
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
body = await request.body()
|
| 49 |
+
req = ch_client.build_request(
|
| 50 |
+
method=request.method,
|
| 51 |
+
url=url,
|
| 52 |
+
headers=headers,
|
| 53 |
+
content=body,
|
| 54 |
+
)
|
| 55 |
+
r = await ch_client.send(req, stream=True)
|
| 56 |
+
|
| 57 |
+
# Pass through ClickHouse response headers
|
| 58 |
+
resp_headers = dict(r.headers)
|
| 59 |
+
for h in ("content-length", "content-encoding", "transfer-encoding"):
|
| 60 |
+
resp_headers.pop(h, None)
|
| 61 |
+
|
| 62 |
+
return StreamingResponse(
|
| 63 |
+
r.aiter_raw(),
|
| 64 |
+
status_code=r.status_code,
|
| 65 |
+
headers=resp_headers,
|
| 66 |
+
)
|
| 67 |
+
except httpx.RequestError as exc:
|
| 68 |
+
raise HTTPException(status_code=502, detail=f"ClickHouse proxy error: {exc}")
|
sources.yaml
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
# β Market-Data Observatory β Data Source Registry β
|
| 3 |
+
# β β
|
| 4 |
+
# β To add a new data source, simply append a new entry to the list. β
|
| 5 |
+
# β The init script reads this file at boot and handles everything: β
|
| 6 |
+
# β β’ git clone / pull β
|
| 7 |
+
# β β’ ClickHouse VIEW creation (CREATE OR REPLACE) β
|
| 8 |
+
# β β’ background auto-refresh β
|
| 9 |
+
# β β
|
| 10 |
+
# β On-demand refresh: curl https://<space>/api/refresh β
|
| 11 |
+
# β No code changes needed β just edit this YAML file. β
|
| 12 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 13 |
+
|
| 14 |
+
# Global settings
|
| 15 |
+
refresh_interval_seconds: 300 # background git-pull interval (safety net)
|
| 16 |
+
|
| 17 |
+
# Data sources β each entry = git clone + ClickHouse view(s)
|
| 18 |
+
sources:
|
| 19 |
+
|
| 20 |
+
- name: hf_ohlc_data
|
| 21 |
+
description: "HuggingFace OHLCV Dataset: altool/ohlcdata"
|
| 22 |
+
repo_url: "https://huggingface.co/datasets/altool/ohlcdata"
|
| 23 |
+
branch: "main"
|
| 24 |
+
auth_env_var: "HF_TOKEN" # Allows pulling from private HF dataset natively
|
| 25 |
+
clone_depth: 1
|
| 26 |
+
local_dir: "hf_ohlc_data" # β /app/ch/user_files/hf_ohlc_data/
|
| 27 |
+
views:
|
| 28 |
+
- view_name: hf_ohlc
|
| 29 |
+
file_glob: "**/*.parquet"
|
| 30 |
+
format: Parquet
|
| 31 |
+
description: "Full OHLCV data from HF dataset"
|
| 32 |
+
# columns: # explicit column list for the VIEW
|
| 33 |
+
# - timestamp
|
| 34 |
+
# - symbol
|
| 35 |
+
# - isin
|
| 36 |
+
# - series
|
| 37 |
+
# - open
|
| 38 |
+
# - high
|
| 39 |
+
# - low
|
| 40 |
+
# - close
|
| 41 |
+
# - volume
|
| 42 |
+
# - interval_minutes
|
| 43 |
+
# - segment
|
| 44 |
+
# - exchange
|
| 45 |
+
|
| 46 |
+
# ββ EXAMPLE: Adding a second data source ββββββββββββββββββββββββββββββ
|
| 47 |
+
# Just uncomment and edit:
|
| 48 |
+
#
|
| 49 |
+
# - name: options_chain
|
| 50 |
+
# description: "NSE options chain snapshots"
|
| 51 |
+
# repo_url: "https://github.com/youruser/options_chain_data.git"
|
| 52 |
+
# branch: "main"
|
| 53 |
+
# clone_depth: 1
|
| 54 |
+
# local_dir: "options_chain"
|
| 55 |
+
# views:
|
| 56 |
+
# - view_name: options
|
| 57 |
+
# file_glob: "**/*.parquet"
|
| 58 |
+
# format: Parquet
|
| 59 |
+
# description: "Options chain snapshots"
|
| 60 |
+
# columns: # omit 'columns' to use SELECT *
|
| 61 |
+
# - strike
|
| 62 |
+
# - expiry
|
| 63 |
+
# - option_type
|
| 64 |
+
# - open_interest
|
| 65 |
+
# - volume
|
| 66 |
+
# - ltp
|
| 67 |
+
# - iv
|
| 68 |
+
#
|
| 69 |
+
# ββ EXAMPLE: Multiple views from the same repo βββββββββββββββββββββββ
|
| 70 |
+
#
|
| 71 |
+
# - name: market_indices
|
| 72 |
+
# description: "Broad market index data"
|
| 73 |
+
# repo_url: "https://github.com/youruser/index_data.git"
|
| 74 |
+
# branch: "main"
|
| 75 |
+
# clone_depth: 1
|
| 76 |
+
# local_dir: "indices"
|
| 77 |
+
# views:
|
| 78 |
+
# - view_name: nifty50
|
| 79 |
+
# file_glob: "nifty50/**/*.parquet"
|
| 80 |
+
# format: Parquet
|
| 81 |
+
# description: "Nifty 50 index"
|
| 82 |
+
# - view_name: banknifty
|
| 83 |
+
# file_glob: "banknifty/**/*.parquet"
|
| 84 |
+
# format: Parquet
|
| 85 |
+
# description: "Bank Nifty index"
|