Subham9126 commited on
Commit
3fd3eea
Β·
verified Β·
1 Parent(s): 6e8fe98

Upload 10 files

Browse files
Files changed (9) hide show
  1. Dockerfile +34 -0
  2. clickhouse-config.xml +51 -0
  3. gateway.py +192 -0
  4. init_clickhouse.sh +74 -0
  5. refresh_sources.py +152 -0
  6. refresh_sources.sh +126 -0
  7. requirements.txt +3 -0
  8. server.py +68 -0
  9. 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"