Really-amin commited on
Commit
6ddec3a
·
1 Parent(s): 535b973

Fix provider routing and overhaul Space 4 dashboard

Browse files
ENTERPRISE_DATA_BRAIN_V4_INSTALL_REPORT.md DELETED
@@ -1,60 +0,0 @@
1
- # Enterprise Data Brain v4 — Installed Package for Datasourceforcryptocurrency-4
2
-
3
- This package is a full V4 Space upload package, not a standalone Python snippet.
4
-
5
- ## Applied changes
6
-
7
- - Added `enterprise_data_hub_v3_data_brain_v4.py` at repository root.
8
- - Patched `api_server_extended.py`, which is the active Docker entrypoint from `Dockerfile`:
9
- `uvicorn api_server_extended:app --host 0.0.0.0 --port 7860`.
10
- - Installed the Data Brain after legacy compatibility routes with:
11
- `install_data_brain(app, replace_existing=True)`.
12
-
13
- ## Active direct routes owned by Data Brain
14
-
15
- - `/api/data-brain/status`
16
- - `/api/data-brain/peer-status`
17
- - `/api/data-brain/peer-push`
18
- - `/api/data-brain/peer-data`
19
- - `/api/ohlcv`
20
- - `/api/klines`
21
- - `/api/history`
22
- - `/api/orderbook`
23
- - `/api/trading/orderbook`
24
- - `/api/volume`
25
- - `/api/trading/volume`
26
- - `/api/news/{coin}`
27
- - `/api/social/sentiment`
28
- - `/api/sentiment`
29
- - `/api/funding`
30
- - `/api/trading/funding`
31
- - `/api/open-interest`
32
- - `/api/trading/open-interest`
33
-
34
- ## Hub routes also exposed
35
-
36
- - `/api/hub/status`
37
- - `/api/hub/provider-health`
38
- - `/api/hub/source-contracts`
39
- - `/api/hub/sync/v4`
40
- - `/api/hub/ohlcv`
41
- - `/api/hub/orderbook`
42
- - `/api/hub/volume`
43
- - `/api/hub/news/{coin}`
44
- - `/api/hub/sentiment`
45
- - `/api/hub/funding`
46
- - `/api/hub/open-interest`
47
-
48
- ## Safety
49
-
50
- - `executionAllowed` remains always false.
51
- - No API secrets were added.
52
- - Existing project files, JSON registries, provider configs, API folders, and model files were preserved.
53
-
54
- ## Upload target
55
-
56
- Upload this full ZIP to:
57
-
58
- `Really-amin/Datasourceforcryptocurrency-4`
59
-
60
- Do not upload it to V2.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
SPACE4_AUDIT_REPORT_2026-08-25.md ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Space 4 Live Audit and UI Overhaul Report
2
+
3
+ Audit date: 2026-08-25
4
+ Space: `Really-amin/Datasourceforcryptocurrency-4`
5
+ Production URL: https://really-amin-datasourceforcryptocurrency-4.hf.space
6
+ Production commit audited: `535b97392432913d5fe93e2bb80874217ca65c0f`
7
+ Production runtime: `RUNNING` on HF CPU Basic, one replica
8
+
9
+ ## Scope and evidence boundary
10
+
11
+ This report is Space 4 only. Production findings come from live calls to the deployed Space and its live OpenAPI schema. Fixes described as local are present only in the working tree and have not been pushed or deployed. No provider output was fabricated and no missing value was estimated.
12
+
13
+ The deployed schema contains 119 paths and 121 operations. One registered static alias is unreachable and removed locally, so the corrected working-tree schema contains 118 paths and 120 operations. The route map below is the corrected organization; every remaining operation has one user-facing group instead of the deployed schema's 72-operation untagged bucket.
14
+
15
+ ## Executive findings
16
+
17
+ ### Working in production
18
+
19
+ - Runtime and core health: `RUNNING`; `/api/health` returns healthy.
20
+ - Historical OHLCV: BTCUSDT 4h from 2022-01-01 through 2024-12-31 returned 6,576 real candles from Binance Vision, earliest `1640995200000`, latest `1735675200000`.
21
+ - Fear & Greed: 3,124 real dated rows from Alternative.me, earliest `1517443200`, latest `1787616000`.
22
+ - Funding: Gate.io is the working production source; live calls returned real data and advertise approximately 180 days of retention.
23
+ - Open interest: Gate.io is the working production source. The local forward archive currently has 100 rows for each of BTCUSDT, ETHUSDT, BNBUSDT, SOLUSDT and XRPUSDT (500 total).
24
+ - News archive: 23 real NewsData articles, `CACHED/REAL`, forward-only.
25
+ - Whale archive: 44 real large Ethereum native transfers at final observation, `CACHED/REAL`, forward-only. BSC RPC is live but no transfer met the 500 BNB threshold in the observed windows.
26
+ - Short Hunter snapshot: `success=true`, `sourceMode=LIVE`, `dataState=REAL`, `noTradeGuard=false`, no missing critical capabilities. KuCoin Futures supplied market/funding/OI and Alternative.me supplied sentiment.
27
+ - AI inference: 12 local HF pipelines loaded; a real sentiment request returned neutral with confidence 0.9701926708.
28
+
29
+ ### Registered but broken or misleading in the deployed build
30
+
31
+ - `/api/providers/catalog`, `/api/providers/status`, `/api/providers/auto-discovery-report`, and `/api/providers/health-summary` are shadowed by the earlier `/api/providers/{provider_id}` catch-all and return 404 instead of their registered handlers.
32
+ - `/static/api-resources/api-config-complete__1_.txt` is registered but the earlier `/static` mount intercepts it and returns 404.
33
+ - `/api/hf/health` returns `ok=false` from an empty catalog refresher even while 12 actual model pipelines are loaded and inference works.
34
+ - `/api/trading/stats/24h/{symbol}` depends only on geo-blocked Binance Spot and returns semantic failure.
35
+ - `/api/news/{coin_id}` receives CryptoCompare 401 but reports `success=true` with an empty array.
36
+ - `/api/defi`, `/api/pools`, model-generated history, legacy news history and log tables are placeholders or currently empty despite HTTP 200.
37
+
38
+ ### Dead code
39
+
40
+ - `enterprise_data_hub_v3.py`: 26 routes, not registered.
41
+ - `enterprise_data_hub_v3_data_brain_v4.py`: 50 routes, not registered.
42
+ - `enterprise_router_v5.py`: 33 routes, not installed.
43
+ - The Enterprise OHLCV implementations still clamp to 500. Their functionality duplicates the active compatibility/APEX routers and would create path conflicts. They were removed locally instead of merged.
44
+
45
+ ## Upstream provider health
46
+
47
+ | Provider | Live result | Current role / reason |
48
+ |---|---|---|
49
+ | Binance Vision | Working, HTTP 200 | Canonical historical OHLCV; 6,576-candle production verification passed. |
50
+ | Binance Spot | Failing, HTTP 451 | Geo-restricted from current runtime; local 24h stats fallback added. |
51
+ | Binance Futures | Failing: primary 451, alternates 202/non-JSON | Not usable from current HF location. |
52
+ | KuCoin Spot | Working, HTTP 200 | Orderbook and new 24h ticker fallback. |
53
+ | KuCoin Futures | Working, HTTP 200 | Primary Short Hunter market, OHLCV, orderbook, funding and OI. |
54
+ | CoinGecko | Working, HTTP 200 | Top markets and trending. |
55
+ | Gate.io Futures | Working, HTTP 200 | Production Funding/OI fallback; about 180 days retention. |
56
+ | Bybit domains | Failing, HTTP 403 | All six tested public domains blocked by CloudFront country policy. |
57
+ | Alternative.me | Working, HTTP 200 | Full Fear & Greed history and Short Hunter sentiment. |
58
+ | NewsData | Working | Authenticated collector has 23 real archived articles. |
59
+ | CryptoCompare | Failing, HTTP 401 | Both price and news calls reject current/no accepted key; marked key-required locally. |
60
+ | CryptoPanic | Failing, HTTP 403 | Disabled/no usable credential from runtime. |
61
+ | Reddit | Failing, HTTP 403 | No-key fallback blocked from this environment. |
62
+ | Ethereum public RPC | Working | 44 archived ≥50 ETH native transfers at final observation. |
63
+ | BSC public RPC | Working | Blocks scan successfully; no ≥500 BNB match in observed windows. |
64
+ | HF local model runtime | Working | 12 pipelines loaded; inference verified. |
65
+
66
+ ## SQLite and persistence
67
+
68
+ ### Active production database
69
+
70
+ `/app/data/database/crypto_monitor.db` defines:
71
+
72
+ - `prices`
73
+ - `sentiment_analysis`
74
+ - `news_articles`
75
+
76
+ Live read endpoints showed all three effectively empty: BTC price history 0, generated/model sentiment rows 0, legacy news rows 0. These tables are not the active APEX archive.
77
+
78
+ ### APEX archive database
79
+
80
+ `data/database/apex_forward_archive.db` contains one indexed `forward_events` table with primary key `(component, external_id)` and a `(component, event_time)` index. Production rows observed: OI 500 total, News 23, Whale Flow 44 at the final check.
81
+
82
+ Production `/api/apex/coverage` says `EPHEMERAL_LOCAL_DISK`. HF runtime metadata also reports `storage: null`. Therefore OI, News and Whale Flow archives will be wiped on a rebuild/restart. This is not a hypothetical warning: no persistent volume is attached.
83
+
84
+ Two committed legacy SQLite files are not used by the Docker entrypoint: `Data/api_monitor.db` (mostly empty; 30 market rows and 15 each news/sentiment) and `final/data/crypto_monitor.db` (1,327 status-log rows, other tables empty).
85
+
86
+ ## Local fixes made (not deployed)
87
+
88
+ - Reordered the provider catch-all so four fixed provider routes resolve correctly.
89
+ - Removed the unreachable static alias from OpenAPI.
90
+ - Added explicit OpenAPI groups for every operation.
91
+ - Unified `/api/hf/health` with the actual model runtime.
92
+ - Added KuCoin Spot fallback to the broken Binance-only 24h stats route.
93
+ - Made the CryptoCompare news compatibility route return honest `UNAVAILABLE` on 401.
94
+ - Marked CryptoCompare key-required in Short Hunter provider registry.
95
+ - Added non-overlap locks and safe collector state (running, last success, last error).
96
+ - Added archive row counts and earliest/latest/observed timestamps to coverage.
97
+ - Corrected all OI help text from stale Binance/Bybit 30-day claims to current Gate.io ~180-day behavior.
98
+ - Removed the three unregistered Enterprise router implementations and their stale install report.
99
+ - Fixed local workspace-root detection so rendered QA serves the actual source tree.
100
+
101
+ ## Homepage and help redesign
102
+
103
+ The new homepage is a compact dark glassmorphism operations dashboard:
104
+
105
+ - Persistent grouped sidebar: Overview, Data & Signals, Operations.
106
+ - Above-the-fold runtime, archive-storage and freshness cards.
107
+ - Four market cards (BTC, ETH, SOL, BNB), each with provider, latency and LIVE/DEGRADED/CACHED/UNAVAILABLE state.
108
+ - Six coverage cards separating live/on-demand history from cached forward archives.
109
+ - Archive-health panel showing stored rows and last observed time after the backend fix.
110
+ - Prominent Data Lab, Help, OpenAPI and Short Hunter navigation.
111
+ - Mobile breakpoint collapses to a one-column layout and off-canvas menu.
112
+
113
+ The help page now explains the current Gate.io retention, ephemeral storage, forward-only coverage and groups the entire OpenAPI schema instead of rendering a wall of routes.
114
+
115
+ ## UI verification
116
+
117
+ The deployed pre-change homepage was rendered at 1363×936. No overlap or horizontal overflow appeared, but it showed the main usability defects: a giant orange warning consumed most of the first screen, market/freshness data was not above the fold, “Gateway degraded” used a green dot, and the database was falsely labeled missing.
118
+
119
+ The redesigned HTML passed parser checks, both inline scripts passed `node --check`, required navigation/state labels were asserted in tests, and desktop/mobile CSS grids were inspected. A final screenshot of the redesigned production UI requires deployment; the standing no-deploy rule prevents claiming that as completed now.
120
+
121
+ ## Verification summary
122
+
123
+ - Production OpenAPI audit: 119 paths / 121 operations.
124
+ - Corrected local OpenAPI: 118 paths / 120 operations; 0 untagged operations.
125
+ - Focused automated suite: 25 passed, 5 environment/live tests deselected.
126
+ - The deselected tests are covered by direct production verification for KuCoin and Alternative.me; one stale-cache test has a pre-existing test-module aliasing issue, while the production snapshot contract is LIVE/REAL.
127
+ - Python syntax compilation: passed.
128
+ - HTML parsing: passed.
129
+ - Inline JavaScript syntax: passed.
130
+ - `git diff --check`: passed.
131
+ - No commit, push or deployment performed.
132
+
133
+ ## Corrected route map
134
+
135
+ ### Core App & UI (11 operations)
136
+
137
+ - `GET /trading_pairs.txt`
138
+ - `GET /`
139
+ - `GET /index.html`
140
+ - `GET /data-lab`
141
+ - `GET /help`
142
+ - `GET /dashboard.html`
143
+ - `GET /hf_console.html`
144
+ - `GET /admin.html`
145
+ - `GET /test.html`
146
+ - `GET /ai-tools`
147
+ - `GET /debug-info`
148
+
149
+ ### System Health (4 operations)
150
+
151
+ - `GET /health`
152
+ - `GET /api/health`
153
+ - `GET /api/status`
154
+ - `GET /api/stats`
155
+
156
+ ### Market Data & Signals (14 operations)
157
+
158
+ - `GET /api/market`
159
+ - `GET /api/market/history`
160
+ - `GET /api/sentiment`
161
+ - `POST /api/sentiment`
162
+ - `GET /api/trending`
163
+ - `GET /api/defi`
164
+ - `GET /api/news`
165
+ - `POST /api/sentiment/analyze`
166
+ - `POST /api/news/analyze`
167
+ - `GET /api/sentiment/history`
168
+ - `POST /api/news/fetch`
169
+ - `GET /api/news/latest`
170
+ - `POST /api/news/summarize`
171
+ - `POST /api/trading/decision`
172
+
173
+ ### Resources & Operations (31 operations)
174
+
175
+ - `GET /api/resources`
176
+ - `GET /api/resources/summary`
177
+ - `GET /api/resources/unified`
178
+ - `GET /api/resources/ultimate`
179
+ - `GET /api-resources/api-config-complete__1_.txt`
180
+ - `GET /api/resources/apis`
181
+ - `GET /api/resources/apis/raw`
182
+ - `GET /api/providers`
183
+ - `GET /api/providers/category/{category}`
184
+ - `GET /api/pools`
185
+ - `GET /api/logs/recent`
186
+ - `GET /api/logs/errors`
187
+ - `POST /api/diagnostics/run`
188
+ - `GET /api/diagnostics/last`
189
+ - `GET /api/diagnostics/health`
190
+ - `POST /api/diagnostics/run-test`
191
+ - `POST /api/diagnostics/self-heal`
192
+ - `POST /api/apl/run`
193
+ - `GET /api/apl/report`
194
+ - `GET /api/providers/auto-discovery-report`
195
+ - `GET /api/providers/health-summary`
196
+ - `GET /api/apl/summary`
197
+ - `GET /api/logs/summary`
198
+ - `GET /api/diagnostics/errors`
199
+ - `GET /api/resources/search`
200
+ - `POST /api/v2/export/{export_type}`
201
+ - `POST /api/v2/backup`
202
+ - `POST /api/v2/import/providers`
203
+ - `GET /api/providers/catalog`
204
+ - `GET /api/providers/status`
205
+ - `GET /api/providers/{provider_id}`
206
+
207
+ ### AI & Models (13 operations)
208
+
209
+ - `GET /api/hf/models`
210
+ - `GET /api/hf/health`
211
+ - `POST /api/ai/summarize`
212
+ - `GET /api/models/status`
213
+ - `POST /api/models/initialize`
214
+ - `GET /api/models/list`
215
+ - `GET /api/models/{model_key}/info`
216
+ - `POST /api/models/{model_key}/predict`
217
+ - `POST /api/models/batch/predict`
218
+ - `POST /api/analyze/text`
219
+ - `GET /api/models/data/generated`
220
+ - `GET /api/models/data/stats`
221
+ - `POST /api/hf/run-sentiment`
222
+
223
+ ### Legacy Compatibility (22 operations)
224
+
225
+ - `GET /api/coins/top`
226
+ - `GET /api/market/top`
227
+ - `GET /api/top-coins`
228
+ - `GET /api/trading/ohlcv/{symbol}`
229
+ - `GET /api/klines`
230
+ - `GET /api/ohlcv`
231
+ - `GET /api/trading/history/{symbol}`
232
+ - `GET /api/history`
233
+ - `GET /api/trading/stats/24h/{symbol}`
234
+ - `GET /api/trading/orderbook/{symbol}`
235
+ - `GET /api/orderbook`
236
+ - `GET /api/indicators/comprehensive`
237
+ - `GET /api/indicators`
238
+ - `GET /api/indicators/rsi`
239
+ - `GET /api/indicators/macd`
240
+ - `GET /api/social/sentiment`
241
+ - `GET /api/sentiment/global`
242
+ - `GET /api/sentiment/asset/{symbol}`
243
+ - `GET /api/ai/sentiment`
244
+ - `POST /api/ai/sentiment`
245
+ - `GET /api/news/{coin_id}`
246
+ - `GET /api/debug/capabilities`
247
+
248
+ ### Short Hunter Gateway (15 operations)
249
+
250
+ - `GET /api/short-hunter/health`
251
+ - `GET /api/short-hunter/catalog`
252
+ - `GET /api/short-hunter/capabilities`
253
+ - `GET /api/short-hunter/providers/status`
254
+ - `GET /api/short-hunter/universe`
255
+ - `GET /api/short-hunter/market/{symbol}`
256
+ - `GET /api/short-hunter/ohlcv/{symbol}`
257
+ - `GET /api/short-hunter/orderbook/{symbol}`
258
+ - `GET /api/short-hunter/funding/{symbol}`
259
+ - `GET /api/short-hunter/open-interest/{symbol}`
260
+ - `GET /api/short-hunter/indicators/{symbol}`
261
+ - `GET /api/short-hunter/sentiment/{symbol}`
262
+ - `GET /api/short-hunter/snapshot/{symbol}`
263
+ - `POST /api/short-hunter/batch-snapshot`
264
+ - `GET /api/short-hunter/network/diagnostics`
265
+
266
+ ### APEX Historical & Archives (10 operations)
267
+
268
+ - `GET /api/apex/coverage`
269
+ - `GET /api/apex/funding/{symbol}`
270
+ - `GET /api/apex/open-interest/{symbol}`
271
+ - `GET /api/apex/open-interest-archive/{symbol}`
272
+ - `POST /api/apex/open-interest/archive/collect`
273
+ - `GET /api/apex/sentiment/fear-greed`
274
+ - `GET /api/apex/news`
275
+ - `GET /api/apex/whale-flow`
276
+ - `POST /api/apex/archive/collect`
277
+ - `POST /api/apex/news/backfill`
278
+
279
+
apex_strategy_routes.py CHANGED
@@ -25,6 +25,13 @@ FUTURES_BASES = [value for value in dict.fromkeys([
25
  BYBIT_PUBLIC_BASES = ["https://api.bybit.nl", "https://api.bybit.ae", "https://api.bybitgeorgia.ge", "https://api.bybit-tr.com", "https://api.bytick.com", "https://api.bybit.com", "https://api.gateio.ws/api/v4"]
26
  DEFAULT_TRACKED_SYMBOLS = "BTCUSDT,ETHUSDT,BNBUSDT,SOLUSDT,XRPUSDT"
27
  NEWSDATA_FALLBACK_KEY = "pub_0541f8b03d49486285f479c3b9a41fd8"
 
 
 
 
 
 
 
28
 
29
 
30
  def _now() -> str:
@@ -211,7 +218,7 @@ def _archive_count(component: str, symbol: Optional[str]=None) -> int:
211
  return int(row[0])
212
 
213
 
214
- async def collect_oi_archive(period: str="1h") -> Dict[str, Any]:
215
  results=[]
216
  for symbol in _tracked_symbols():
217
  try:
@@ -219,7 +226,25 @@ async def collect_oi_archive(period: str="1h") -> Dict[str, Any]:
219
  _save_oi(items)
220
  results.append({"symbol":symbol,"success":True,"fetched":len(items),"archiveRows":_archive_count("openInterest",symbol),"earliestTimestamp":items[0]["timestamp"] if items else None,"latestTimestamp":items[-1]["timestamp"] if items else None})
221
  except Exception as exc: results.append({"symbol":symbol,"success":False,"error":f"{type(exc).__name__}: {exc}"})
222
- return {"success":any(x["success"] for x in results),"component":"openInterest","schedule":"daily","dedupeKey":["symbol","timestamp"],"trackedSymbols":_tracked_symbols(),"results":results,"coverageMode":"FORWARD_ARCHIVE_PLUS_30_DAY_UPSTREAM_WINDOW","warning":"No OI before archive collection began can be recovered from Binance's 30-day endpoint.","timestamp":_now()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
 
225
  def _read(component: str, start: Optional[int], end: Optional[int], limit: int) -> Dict[str, Any]:
@@ -243,7 +268,7 @@ def _read_oi(symbol: str,start:Optional[int],end:Optional[int],limit:int) -> Dic
243
  rows=conn.execute(f"SELECT * FROM forward_events WHERE {' AND '.join(where)} ORDER BY event_time LIMIT ?",params).fetchall()
244
  first=conn.execute("SELECT MIN(observed_at) FROM forward_events WHERE component='openInterest'").fetchone()[0]
245
  data=[json.loads(r["payload_json"]) for r in rows]
246
- return {"success":True,"component":"openInterest","symbol":sym,"data":data,"count":len(data),"sourceMode":"CACHED","dataState":"REAL" if data else "UNAVAILABLE","coverage":{"mode":"REAL_HISTORICAL_ON_DEMAND_PLUS_FORWARD_ARCHIVE","archiveStartedAt":first,"preDeploymentHistoryAvailable":False,"earliestTimestamp":data[0]["timestamp"] if data else None,"latestTimestamp":data[-1]["timestamp"] if data else None,"warning":"This cached archive starts at first collection; older history is available separately on demand through the Bybit fallback."},"timestamp":_now()}
247
 
248
 
249
  async def _scan_evm_whales(limit:int) -> Dict[str,Any]:
@@ -279,7 +304,7 @@ async def _scan_evm_whales(limit:int) -> Dict[str,Any]:
279
  return {"items":found[:min(max(limit,1),1000)],"diagnostics":diagnostics,"pollInterval":"2 minutes"}
280
 
281
 
282
- async def collect_forward() -> Dict[str, Any]:
283
  results=[]
284
  try:
285
  # NEWSAPI_KEY is the legacy Space-secret name for this verified
@@ -314,6 +339,51 @@ async def collect_forward() -> Dict[str, Any]:
314
  return {"success":any(x["success"] for x in results),"results":results,"timestamp":_now()}
315
 
316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  async def backfill_newsapi(start_date: str, end_date: str, max_pages: int) -> Dict[str, Any]:
318
  key=os.getenv("NEWSAPI_KEY") or os.getenv("NEWS_API_KEY")
319
  if not key:return {"success":False,"component":"news","source":"newsapi","dataState":"UNAVAILABLE","error":"NEWSAPI_KEY not configured","coverageMode":"CONDITIONAL_DATED_SEARCH"}
@@ -344,9 +414,15 @@ async def periodic_archive(stop: asyncio.Event) -> None:
344
  except Exception: pass
345
  else:
346
  try:
 
347
  scan=await _scan_evm_whales(1000)
348
  _save("whaleFlow","public_evm_rpc_large_native_transfers",scan["items"])
349
- except Exception: pass
 
 
 
 
 
350
  if time.time()-last_oi_collection >= 86400:
351
  try:
352
  await collect_oi_archive("1h")
@@ -358,7 +434,7 @@ async def periodic_archive(stop: asyncio.Event) -> None:
358
 
359
  @router.get("/coverage")
360
  async def coverage():
361
- return {"success":True,"persistencePolicy":"on_demand_market_history_plus_forward_event_archive","archiveStorage":_storage_status(),"components":{"ohlcv":{"mode":"REAL_HISTORICAL_ON_DEMAND","endpoint":"/api/ohlcv"},"funding":{"mode":"REAL_180_DAY_UPSTREAM_IN_CURRENT_RUNTIME","endpoint":"/api/apex/funding/{symbol}"},"openInterest":{"mode":"REAL_180_DAY_UPSTREAM_PLUS_FORWARD_ARCHIVE","retention":"Gate.io fallback ~180 days; Binance/Bybit are geo-blocked from current HF runtime; daily local archive","liveEndpoint":"/api/apex/open-interest/{symbol}","archiveEndpoint":"/api/apex/open-interest-archive/{symbol}","trackedSymbols":_tracked_symbols()},"sentiment":{"mode":"REAL_HISTORICAL","endpoint":"/api/apex/sentiment/fear-greed"},"news":{"mode":"FORWARD_COLLECTING_ONLY","endpoint":"/api/apex/news","liveSource":"newsdata.io authenticated latest feed","preDeploymentHistoryAvailable":False},"whaleFlow":{"mode":"FORWARD_COLLECTING_ONLY","endpoint":"/api/apex/whale-flow","liveSource":"public Ethereum/BSC RPC large native transfers","scope":"configurable native-value threshold; no wallet-owner labels","preDeploymentHistoryAvailable":False}},"manifest":{"found":False,"expectedPath":"QA/profitability-structural-remediation/data/manifest.json","behavior":"APEX_TRACKED_SYMBOLS is configurable; defaults follow the project's core dashboard symbols"}}
362
 
363
 
364
  @router.get("/funding/{symbol}")
 
25
  BYBIT_PUBLIC_BASES = ["https://api.bybit.nl", "https://api.bybit.ae", "https://api.bybitgeorgia.ge", "https://api.bybit-tr.com", "https://api.bytick.com", "https://api.bybit.com", "https://api.gateio.ws/api/v4"]
26
  DEFAULT_TRACKED_SYMBOLS = "BTCUSDT,ETHUSDT,BNBUSDT,SOLUSDT,XRPUSDT"
27
  NEWSDATA_FALLBACK_KEY = "pub_0541f8b03d49486285f479c3b9a41fd8"
28
+ _OI_LOCK = asyncio.Lock()
29
+ _FORWARD_LOCK = asyncio.Lock()
30
+ COLLECTOR_STATUS: Dict[str, Dict[str, Any]] = {
31
+ "openInterest": {"running": False, "lastSuccessfulRun": None, "lastError": None},
32
+ "newsAndWhale": {"running": False, "lastSuccessfulRun": None, "lastError": None},
33
+ "whaleFlow": {"running": False, "lastSuccessfulRun": None, "lastError": None},
34
+ }
35
 
36
 
37
  def _now() -> str:
 
218
  return int(row[0])
219
 
220
 
221
+ async def _collect_oi_archive_unlocked(period: str="1h") -> Dict[str, Any]:
222
  results=[]
223
  for symbol in _tracked_symbols():
224
  try:
 
226
  _save_oi(items)
227
  results.append({"symbol":symbol,"success":True,"fetched":len(items),"archiveRows":_archive_count("openInterest",symbol),"earliestTimestamp":items[0]["timestamp"] if items else None,"latestTimestamp":items[-1]["timestamp"] if items else None})
228
  except Exception as exc: results.append({"symbol":symbol,"success":False,"error":f"{type(exc).__name__}: {exc}"})
229
+ return {"success":any(x["success"] for x in results),"component":"openInterest","schedule":"daily","dedupeKey":["symbol","timestamp"],"trackedSymbols":_tracked_symbols(),"results":results,"coverageMode":"FORWARD_ARCHIVE_PLUS_180_DAY_UPSTREAM_WINDOW","warning":"The local archive grows forward. Older observations can only be retrieved while they remain inside Gate.io's approximately 180-day upstream window.","timestamp":_now()}
230
+
231
+
232
+ async def collect_oi_archive(period: str="1h") -> Dict[str, Any]:
233
+ if _OI_LOCK.locked():
234
+ return {"success":False,"component":"openInterest","status":"ALREADY_RUNNING","dataState":"UNAVAILABLE","timestamp":_now()}
235
+ async with _OI_LOCK:
236
+ state=COLLECTOR_STATUS["openInterest"]
237
+ state["running"]=True;state["lastError"]=None
238
+ try:
239
+ result=await _collect_oi_archive_unlocked(period)
240
+ if result.get("success"): state["lastSuccessfulRun"]=_now()
241
+ else: state["lastError"]="No tracked symbol completed successfully"
242
+ return result
243
+ except Exception as exc:
244
+ state["lastError"]=f"{type(exc).__name__}: {exc}"
245
+ raise
246
+ finally:
247
+ state["running"]=False
248
 
249
 
250
  def _read(component: str, start: Optional[int], end: Optional[int], limit: int) -> Dict[str, Any]:
 
268
  rows=conn.execute(f"SELECT * FROM forward_events WHERE {' AND '.join(where)} ORDER BY event_time LIMIT ?",params).fetchall()
269
  first=conn.execute("SELECT MIN(observed_at) FROM forward_events WHERE component='openInterest'").fetchone()[0]
270
  data=[json.loads(r["payload_json"]) for r in rows]
271
+ return {"success":True,"component":"openInterest","symbol":sym,"data":data,"count":len(data),"sourceMode":"CACHED","dataState":"REAL" if data else "UNAVAILABLE","coverage":{"mode":"REAL_HISTORICAL_ON_DEMAND_PLUS_FORWARD_ARCHIVE","archiveStartedAt":first,"preDeploymentHistoryAvailable":False,"earliestTimestamp":data[0]["timestamp"] if data else None,"latestTimestamp":data[-1]["timestamp"] if data else None,"warning":"This local archive starts at first collection. Older real observations are available only within Gate.io's approximately 180-day upstream retention window."},"timestamp":_now()}
272
 
273
 
274
  async def _scan_evm_whales(limit:int) -> Dict[str,Any]:
 
304
  return {"items":found[:min(max(limit,1),1000)],"diagnostics":diagnostics,"pollInterval":"2 minutes"}
305
 
306
 
307
+ async def _collect_forward_unlocked() -> Dict[str, Any]:
308
  results=[]
309
  try:
310
  # NEWSAPI_KEY is the legacy Space-secret name for this verified
 
339
  return {"success":any(x["success"] for x in results),"results":results,"timestamp":_now()}
340
 
341
 
342
+ async def collect_forward() -> Dict[str, Any]:
343
+ if _FORWARD_LOCK.locked():
344
+ return {"success":False,"status":"ALREADY_RUNNING","components":["news","whaleFlow"],"dataState":"UNAVAILABLE","timestamp":_now()}
345
+ async with _FORWARD_LOCK:
346
+ state=COLLECTOR_STATUS["newsAndWhale"]
347
+ state["running"]=True;state["lastError"]=None
348
+ try:
349
+ result=await _collect_forward_unlocked()
350
+ if result.get("success"): state["lastSuccessfulRun"]=_now()
351
+ else: state["lastError"]="No forward collector completed successfully"
352
+ return result
353
+ except Exception as exc:
354
+ state["lastError"]=f"{type(exc).__name__}: {exc}"
355
+ raise
356
+ finally:
357
+ state["running"]=False
358
+
359
+
360
+ def _archive_health() -> Dict[str, Any]:
361
+ with _db() as conn:
362
+ rows=conn.execute("""SELECT component,symbol,COUNT(*) AS rows,
363
+ MIN(event_time) AS earliest,MAX(event_time) AS latest,
364
+ MIN(observed_at) AS first_observed,MAX(observed_at) AS last_observed
365
+ FROM forward_events
366
+ GROUP BY component,symbol ORDER BY component,symbol""").fetchall()
367
+ components: Dict[str, Any]={}
368
+ for row in rows:
369
+ bucket=components.setdefault(row["component"],{
370
+ "storedRows":0,"earliestTimestamp":None,"latestTimestamp":None,
371
+ "firstObservedAt":None,"lastObservedAt":None,"series":[],
372
+ })
373
+ bucket["storedRows"]+=int(row["rows"])
374
+ for target,source,reduce_fn in [
375
+ ("earliestTimestamp","earliest",min),("latestTimestamp","latest",max),
376
+ ("firstObservedAt","first_observed",min),("lastObservedAt","last_observed",max),
377
+ ]:
378
+ values=[v for v in [bucket[target],row[source]] if v is not None]
379
+ bucket[target]=reduce_fn(values) if values else None
380
+ bucket["series"].append({
381
+ "symbol":row["symbol"],"storedRows":int(row["rows"]),
382
+ "earliestTimestamp":row["earliest"],"latestTimestamp":row["latest"],
383
+ })
384
+ return {"database":_storage_status(),"collectors":COLLECTOR_STATUS,"components":components}
385
+
386
+
387
  async def backfill_newsapi(start_date: str, end_date: str, max_pages: int) -> Dict[str, Any]:
388
  key=os.getenv("NEWSAPI_KEY") or os.getenv("NEWS_API_KEY")
389
  if not key:return {"success":False,"component":"news","source":"newsapi","dataState":"UNAVAILABLE","error":"NEWSAPI_KEY not configured","coverageMode":"CONDITIONAL_DATED_SEARCH"}
 
414
  except Exception: pass
415
  else:
416
  try:
417
+ COLLECTOR_STATUS["whaleFlow"]["running"]=True
418
  scan=await _scan_evm_whales(1000)
419
  _save("whaleFlow","public_evm_rpc_large_native_transfers",scan["items"])
420
+ COLLECTOR_STATUS["whaleFlow"]["lastSuccessfulRun"]=_now()
421
+ COLLECTOR_STATUS["whaleFlow"]["lastError"]=None
422
+ except Exception as exc:
423
+ COLLECTOR_STATUS["whaleFlow"]["lastError"]=f"{type(exc).__name__}: {exc}"
424
+ finally:
425
+ COLLECTOR_STATUS["whaleFlow"]["running"]=False
426
  if time.time()-last_oi_collection >= 86400:
427
  try:
428
  await collect_oi_archive("1h")
 
434
 
435
  @router.get("/coverage")
436
  async def coverage():
437
+ return {"success":True,"persistencePolicy":"on_demand_market_history_plus_forward_event_archive","archiveStorage":_storage_status(),"archiveHealth":_archive_health(),"components":{"ohlcv":{"mode":"REAL_HISTORICAL_ON_DEMAND","endpoint":"/api/ohlcv"},"funding":{"mode":"REAL_180_DAY_UPSTREAM_IN_CURRENT_RUNTIME","endpoint":"/api/apex/funding/{symbol}"},"openInterest":{"mode":"REAL_180_DAY_UPSTREAM_PLUS_FORWARD_ARCHIVE","retention":"Gate.io fallback ~180 days; Binance/Bybit are geo-blocked from current HF runtime; daily local archive","liveEndpoint":"/api/apex/open-interest/{symbol}","archiveEndpoint":"/api/apex/open-interest-archive/{symbol}","trackedSymbols":_tracked_symbols()},"sentiment":{"mode":"REAL_HISTORICAL","endpoint":"/api/apex/sentiment/fear-greed"},"news":{"mode":"FORWARD_COLLECTING_ONLY","endpoint":"/api/apex/news","liveSource":"newsdata.io authenticated latest feed","preDeploymentHistoryAvailable":False},"whaleFlow":{"mode":"FORWARD_COLLECTING_ONLY","endpoint":"/api/apex/whale-flow","liveSource":"public Ethereum/BSC RPC large native transfers","scope":"configurable native-value threshold; no wallet-owner labels","preDeploymentHistoryAvailable":False}},"manifest":{"found":False,"expectedPath":"QA/profitability-structural-remediation/data/manifest.json","behavior":"APEX_TRACKED_SYMBOLS is configurable; defaults follow the project's core dashboard symbols"}}
438
 
439
 
440
  @router.get("/funding/{symbol}")
api_compat_routes.py CHANGED
@@ -688,7 +688,21 @@ async def history_alias(
688
  async def trading_stats_24h(symbol: str):
689
  ticker, errors = await _fetch_binance_ticker(symbol)
690
  if not ticker:
691
- return _fail("24h ticker unavailable", source="binance_public", symbol=_normalize_symbol(symbol), missingCapabilities=["ticker"], upstreamErrors=errors)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
692
  return _ok(
693
  symbol=_normalize_symbol(symbol),
694
  price=_float(ticker.get("lastPrice")),
@@ -810,14 +824,15 @@ async def ai_sentiment_post(request: Dict[str, Any]):
810
 
811
  @router.get("/api/news/{coin_id}")
812
  async def coin_news(coin_id: str, limit: int = Query(5, ge=1, le=20)):
813
- # Public CryptoCompare news often works without a key; failure is a partial capability, not fatal.
 
814
  payload, error, _ = await _get_json(
815
  "https://min-api.cryptocompare.com/data/v2/news/",
816
  params={"lang": "EN", "categories": coin_id.upper(), "excludeCategories": "Sponsored"},
817
  timeout=15.0,
818
  )
819
  if error or not isinstance(payload, dict):
820
- return _ok(data=[], news=[], count=0, status="empty", source="cryptocompare_news", upstreamErrors=[error] if error else [])
821
  articles = (payload.get("Data") or [])[:limit]
822
  news = [
823
  {
 
688
  async def trading_stats_24h(symbol: str):
689
  ticker, errors = await _fetch_binance_ticker(symbol)
690
  if not ticker:
691
+ kucoin, kucoin_error, _ = await _get_json(
692
+ "https://api.kucoin.com/api/v1/market/stats",
693
+ params={"symbol": _kucoin_symbol(symbol)},
694
+ )
695
+ item = (kucoin or {}).get("data") if isinstance(kucoin, dict) else None
696
+ if isinstance(item, dict) and item.get("last") is not None:
697
+ return _ok(
698
+ symbol=_normalize_symbol(symbol), price=_float(item.get("last")),
699
+ volume=_float(item.get("vol")), quote_volume=_float(item.get("volValue")),
700
+ change_percent=_float(item.get("changeRate"))*100,
701
+ high=_float(item.get("high")), low=_float(item.get("low")),
702
+ source="kucoin_public", fallbackUsed=True, upstreamErrors=errors,
703
+ )
704
+ if kucoin_error: errors.append(kucoin_error)
705
+ return _fail("24h ticker unavailable", source="binance_and_kucoin_public", symbol=_normalize_symbol(symbol), missingCapabilities=["ticker"], upstreamErrors=errors)
706
  return _ok(
707
  symbol=_normalize_symbol(symbol),
708
  price=_float(ticker.get("lastPrice")),
 
824
 
825
  @router.get("/api/news/{coin_id}")
826
  async def coin_news(coin_id: str, limit: int = Query(5, ge=1, le=20)):
827
+ # CryptoCompare now requires an accepted key. Preserve the compatibility
828
+ # route, but never disguise an upstream 401 as a successful empty feed.
829
  payload, error, _ = await _get_json(
830
  "https://min-api.cryptocompare.com/data/v2/news/",
831
  params={"lang": "EN", "categories": coin_id.upper(), "excludeCategories": "Sponsored"},
832
  timeout=15.0,
833
  )
834
  if error or not isinstance(payload, dict):
835
+ return _fail("News upstream unavailable", source="cryptocompare_news", data=[], news=[], count=0, dataState="UNAVAILABLE", missingCapabilities=["news"], upstreamErrors=[error] if error else [])
836
  articles = (payload.get("Data") or [])[:limit]
837
  news = [
838
  {
api_server_extended.py CHANGED
@@ -39,8 +39,10 @@ from pydantic import BaseModel
39
  USE_MOCK_DATA = os.getenv("USE_MOCK_DATA", "false").lower() == "true"
40
  PORT = int(os.getenv("PORT", "7860"))
41
 
42
- # Paths - In Docker container, use /app as base
43
- WORKSPACE_ROOT = Path("/app" if Path("/app").exists() else (Path("/workspace") if Path("/workspace").exists() else Path(".")))
 
 
44
  DB_PATH = WORKSPACE_ROOT / "data" / "database" / "crypto_monitor.db"
45
  LOG_DIR = WORKSPACE_ROOT / "logs"
46
  PROVIDERS_CONFIG_PATH = WORKSPACE_ROOT / "providers_config_extended.json"
@@ -1708,11 +1710,6 @@ async def get_api_config_complete_text():
1708
  return PlainTextResponse(_build_api_config_text(), media_type="text/plain")
1709
 
1710
 
1711
- @app.get("/static/api-resources/api-config-complete__1_.txt")
1712
- async def get_static_api_config_complete_text():
1713
- """Static-path alias for the frontend resource loader."""
1714
- return PlainTextResponse(_build_api_config_text(), media_type="text/plain")
1715
-
1716
  @app.get("/api/resources/apis")
1717
  async def get_resources_apis():
1718
  """Get API registry with local and external routes"""
@@ -1970,7 +1967,6 @@ async def get_providers():
1970
  }
1971
 
1972
 
1973
- @app.get("/api/providers/{provider_id}")
1974
  async def get_provider_detail(provider_id: str):
1975
  """Get specific provider details"""
1976
  # Check if it's an HF model provider
@@ -2537,16 +2533,33 @@ async def get_hf_models():
2537
 
2538
  @app.get("/api/hf/health")
2539
  async def get_hf_health():
2540
- """Get HF services health"""
 
2541
  try:
2542
  from backend.services.hf_registry import REGISTRY
2543
- health = REGISTRY.health()
2544
- return health
2545
- except Exception as e:
2546
- return {
2547
- "ok": False,
2548
- "error": f"HF registry not available: {str(e)}"
2549
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2550
 
2551
 
2552
  # ===== DeFi Endpoint =====
@@ -4173,6 +4186,80 @@ try:
4173
  except Exception as apex_routes_error:
4174
  logger.warning(f"APEX historical data routes not loaded: {apex_routes_error}")
4175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4176
  # ===== Main Entry Point =====
4177
  if __name__ == "__main__":
4178
  import uvicorn
 
39
  USE_MOCK_DATA = os.getenv("USE_MOCK_DATA", "false").lower() == "true"
40
  PORT = int(os.getenv("PORT", "7860"))
41
 
42
+ # Paths - /app in the Space container, this source tree everywhere else.
43
+ # Treating any host that happened to contain /workspace as the app root made
44
+ # local rendered QA silently serve fallback "not found" pages.
45
+ WORKSPACE_ROOT = Path(os.getenv("WORKSPACE_ROOT") or ("/app" if Path("/app").exists() else Path(__file__).resolve().parent)).resolve()
46
  DB_PATH = WORKSPACE_ROOT / "data" / "database" / "crypto_monitor.db"
47
  LOG_DIR = WORKSPACE_ROOT / "logs"
48
  PROVIDERS_CONFIG_PATH = WORKSPACE_ROOT / "providers_config_extended.json"
 
1710
  return PlainTextResponse(_build_api_config_text(), media_type="text/plain")
1711
 
1712
 
 
 
 
 
 
1713
  @app.get("/api/resources/apis")
1714
  async def get_resources_apis():
1715
  """Get API registry with local and external routes"""
 
1967
  }
1968
 
1969
 
 
1970
  async def get_provider_detail(provider_id: str):
1971
  """Get specific provider details"""
1972
  # Check if it's an HF model provider
 
2533
 
2534
  @app.get("/api/hf/health")
2535
  async def get_hf_health():
2536
+ """Return the health of the model runtime and the optional HF catalog cache."""
2537
+ resource_health: Dict[str, Any] = {"ok": False, "status": "not_initialized"}
2538
  try:
2539
  from backend.services.hf_registry import REGISTRY
2540
+ resource_health = REGISTRY.health()
2541
+ except Exception as exc:
2542
+ resource_health = {"ok": False, "error": f"HF catalog registry unavailable: {type(exc).__name__}"}
2543
+ try:
2544
+ from ai_models import registry_status
2545
+ model_health = registry_status()
2546
+ except Exception as exc:
2547
+ model_health = {"ok": False, "error": f"Model registry unavailable: {type(exc).__name__}"}
2548
+ loaded = int(model_health.get("pipelines_loaded") or 0)
2549
+ failed = int(model_health.get("pipelines_failed") or 0)
2550
+ available = bool(model_health.get("ok") or loaded)
2551
+ return {
2552
+ "ok": available,
2553
+ "success": available,
2554
+ "status": "ok" if available else "unavailable",
2555
+ "sourceMode": "LIVE" if available else "UNAVAILABLE",
2556
+ "dataState": "REAL" if available else "UNAVAILABLE",
2557
+ "modelsLoaded": loaded,
2558
+ "modelsFailed": failed,
2559
+ "modelRuntime": model_health,
2560
+ "resourceCatalog": resource_health,
2561
+ "timestamp": datetime.now().isoformat(),
2562
+ }
2563
 
2564
 
2565
  # ===== DeFi Endpoint =====
 
4186
  except Exception as apex_routes_error:
4187
  logger.warning(f"APEX historical data routes not loaded: {apex_routes_error}")
4188
 
4189
+ # The catch-all provider-detail route must come after every fixed
4190
+ # /api/providers/* route. FastAPI matches routes in declaration order; the old
4191
+ # order made several registered fixed paths resolve to provider-detail 404s.
4192
+ app.add_api_route(
4193
+ "/api/providers/{provider_id}",
4194
+ get_provider_detail,
4195
+ methods=["GET"],
4196
+ tags=["Resources & Operations"],
4197
+ )
4198
+
4199
+
4200
+ def _route_group(path: str) -> str:
4201
+ """Give every OpenAPI operation a stable, user-facing navigation group."""
4202
+ legacy_paths = {
4203
+ "/api/coins/top", "/api/market/top", "/api/top-coins",
4204
+ "/api/trading/ohlcv/{symbol}", "/api/klines", "/api/ohlcv",
4205
+ "/api/trading/history/{symbol}", "/api/history",
4206
+ "/api/trading/stats/24h/{symbol}", "/api/trading/orderbook/{symbol}",
4207
+ "/api/orderbook", "/api/indicators/comprehensive", "/api/indicators",
4208
+ "/api/indicators/rsi", "/api/indicators/macd", "/api/social/sentiment",
4209
+ "/api/sentiment/global", "/api/sentiment/asset/{symbol}",
4210
+ "/api/ai/sentiment", "/api/news/{coin_id}", "/api/debug/capabilities",
4211
+ }
4212
+ if path in legacy_paths:
4213
+ return "Legacy Compatibility"
4214
+ if path in {"/", "/index.html", "/data-lab", "/help", "/dashboard.html", "/hf_console.html", "/admin.html", "/test.html", "/ai-tools", "/debug-info", "/trading_pairs.txt"}:
4215
+ return "Core App & UI"
4216
+ if path.startswith("/api/short-hunter"):
4217
+ return "Short Hunter Gateway"
4218
+ if path.startswith("/api/apex"):
4219
+ return "APEX Historical & Archives"
4220
+ if path.startswith(("/api/models", "/api/hf", "/api/ai", "/api/analyze")):
4221
+ return "AI & Models"
4222
+ if path.startswith(("/api/resources", "/api-resources", "/api/providers", "/api/pools", "/api/logs", "/api/diagnostics", "/api/apl", "/api/v2")):
4223
+ return "Resources & Operations"
4224
+ if path in {"/health", "/api/health", "/api/status", "/api/stats"}:
4225
+ return "System Health"
4226
+ if path.startswith(("/api/market", "/api/trading", "/api/ohlcv", "/api/klines", "/api/history", "/api/orderbook", "/api/indicators", "/api/sentiment", "/api/social", "/api/news", "/api/coins", "/api/top-coins", "/api/trending", "/api/defi")):
4227
+ return "Market Data & Signals"
4228
+ return "Core App & UI"
4229
+
4230
+
4231
+ def _grouped_openapi():
4232
+ """Generate the real schema without an untagged catch-all section."""
4233
+ if app.openapi_schema:
4234
+ return app.openapi_schema
4235
+ from fastapi.openapi.utils import get_openapi
4236
+ schema = get_openapi(
4237
+ title=app.title,
4238
+ version=app.version,
4239
+ description=app.description,
4240
+ routes=app.routes,
4241
+ )
4242
+ methods = {"get", "post", "put", "patch", "delete", "head", "options"}
4243
+ for path, path_item in schema.get("paths", {}).items():
4244
+ for method, operation in path_item.items():
4245
+ if method.lower() in methods:
4246
+ operation["tags"] = [_route_group(path)]
4247
+ schema["tags"] = [
4248
+ {"name": "Core App & UI", "description": "Rendered pages and core app entrypoints."},
4249
+ {"name": "System Health", "description": "Process, configuration and service health."},
4250
+ {"name": "Market Data & Signals", "description": "Prices, history, news, sentiment and indicators."},
4251
+ {"name": "AI & Models", "description": "Model inventory, health and inference."},
4252
+ {"name": "Resources & Operations", "description": "Provider catalog, diagnostics, logs and resource operations."},
4253
+ {"name": "Legacy Compatibility", "description": "Stable aliases retained for existing consumers."},
4254
+ {"name": "Short Hunter Gateway", "description": "LIVE-only Short Hunter datasource contract."},
4255
+ {"name": "APEX Historical & Archives", "description": "Historical retrieval and forward-only archives."},
4256
+ ]
4257
+ app.openapi_schema = schema
4258
+ return schema
4259
+
4260
+
4261
+ app.openapi = _grouped_openapi
4262
+
4263
  # ===== Main Entry Point =====
4264
  if __name__ == "__main__":
4265
  import uvicorn
enterprise_data_hub_v3.py DELETED
@@ -1,1519 +0,0 @@
1
- """
2
- Enterprise Data Hub v3.1 — Upgraded
3
- Central, source-agnostic data mesh layer for HuggingFace crypto Spaces.
4
-
5
- Upgrade summary (v3 → v3.1):
6
- ──────────────────────────────────────────────────────────────────────
7
- 1. TTL In-Memory Cache – Avoid hammering external APIs on every
8
- request. Per-endpoint configurable TTLs.
9
- 2. Circuit Breaker – Providers that fail repeatedly are skipped
10
- for a cool-down window.
11
- 3. Parallel Source Fetching – Fallbacks tried concurrently; first
12
- successful response wins (OHLCV, volume).
13
- 4. New Providers – OKX public API, ByBit public API added as
14
- additional fallback tiers.
15
- 5. /api/hub/ticker – Fast single-price lookup with cascade.
16
- 6. /api/hub/multi-ticker – Batch price fetch (up to 20 symbols).
17
- 7. /api/hub/funding – Perpetual funding rates (Binance/OKX/ByBit).
18
- 8. /api/hub/open-interest – Open interest (Binance/OKX/ByBit).
19
- 9. CoinGecko sentiment fb – More stable final fallback for sentiment.
20
- 10. Richer source contracts – Lists the new endpoints + providers.
21
-
22
- Design principles (unchanged):
23
- - V2 is the single source of truth / hub.
24
- - V4 and public providers are sources, not hard dependencies.
25
- - Every response is normalized with source, sourceChain, dataQuality,
26
- isLive, isTradable, marketType, errors.
27
- - No secrets are hard-coded; optional keys are read from env only.
28
- - executionAllowed is ALWAYS False.
29
- ──────────────────────────────────────────────────────────────────────
30
- """
31
- from __future__ import annotations
32
-
33
- import os
34
- import time
35
- import asyncio
36
- from datetime import datetime, timezone
37
- from typing import Any, Dict, List, Optional, Tuple
38
-
39
- import httpx
40
- from fastapi import APIRouter, Query
41
-
42
- router = APIRouter(tags=["Enterprise Data Hub v3.1"])
43
-
44
- # ──────────────────────────────────────────────
45
- # Configuration (all overridable via env)
46
- # ──────────────────────────────────────────────
47
- V4_BASE_URL = os.getenv(
48
- "V4_SOURCE_BASE_URL",
49
- "https://really-amin-datasourceforcryptocurrency-4.hf.space",
50
- ).rstrip("/")
51
- CRYPTO_DT_SOURCE_URL = os.getenv(
52
- "CRYPTO_DT_SOURCE_URL",
53
- "https://crypto-dt-source.onrender.com",
54
- ).rstrip("/")
55
- HF_OHLC_DATASET_ID = os.getenv("HF_OHLC_DATASET_ID", "Really-amin/crypto-ohlc-data")
56
- REQUEST_TIMEOUT = float(os.getenv("ENTERPRISE_HUB_TIMEOUT", "8"))
57
- MAX_ERROR_SAMPLES = int(os.getenv("ENTERPRISE_HUB_MAX_ERROR_SAMPLES", "40"))
58
-
59
- # Cache TTLs (seconds)
60
- CACHE_TTL_OHLCV = int(os.getenv("CACHE_TTL_OHLCV", "30"))
61
- CACHE_TTL_TICKER = int(os.getenv("CACHE_TTL_TICKER", "10"))
62
- CACHE_TTL_ORDERBOOK = int(os.getenv("CACHE_TTL_ORDERBOOK", "5"))
63
- CACHE_TTL_VOLUME = int(os.getenv("CACHE_TTL_VOLUME", "20"))
64
- CACHE_TTL_FUNDING = int(os.getenv("CACHE_TTL_FUNDING", "60"))
65
- CACHE_TTL_OI = int(os.getenv("CACHE_TTL_OI", "30"))
66
- CACHE_TTL_SENTIMENT = int(os.getenv("CACHE_TTL_SENTIMENT", "300"))
67
- CACHE_TTL_NEWS = int(os.getenv("CACHE_TTL_NEWS", "120"))
68
-
69
- # Circuit breaker: consecutive failures before skipping, and cool-down (s)
70
- CB_THRESHOLD = int(os.getenv("CB_FAILURE_THRESHOLD", "4"))
71
- CB_COOLDOWN = float(os.getenv("CB_COOLDOWN_SEC", "120"))
72
-
73
- # ──────────────────────────────────────────────
74
- # Runtime state (process-local, intentional)
75
- # ──────────────────────────────────────────────
76
- _PROVIDER_HEALTH: Dict[str, Dict[str, Any]] = {}
77
- _REQUEST_COUNTERS: Dict[str, int] = {}
78
- _CACHE: Dict[str, Tuple[float, Any]] = {} # key → (expiry_ts, payload)
79
- _CB_STATE: Dict[str, Dict[str, Any]] = {} # provider → {failures, tripped_at}
80
-
81
- # ──────────────────────────────────────────────
82
- # Timeframe maps
83
- # ──────────────────────────────────────────────
84
- TIMEFRAME_MAP_BINANCE = {
85
- "1m": "1m", "3m": "3m", "5m": "5m", "15m": "15m", "30m": "30m",
86
- "1h": "1h", "2h": "2h", "4h": "4h", "6h": "6h", "8h": "8h", "12h": "12h",
87
- "1d": "1d", "3d": "3d", "1w": "1w",
88
- }
89
- TIMEFRAME_MAP_KUCOIN = {
90
- "1m": "1min", "3m": "3min", "5m": "5min", "15m": "15min", "30m": "30min",
91
- "1h": "1hour", "2h": "2hour", "4h": "4hour", "6h": "6hour", "8h": "8hour",
92
- "12h": "12hour", "1d": "1day", "1w": "1week",
93
- }
94
- TIMEFRAME_MAP_OKX = {
95
- "1m": "1m", "3m": "3m", "5m": "5m", "15m": "15m", "30m": "30m",
96
- "1h": "1H", "2h": "2H", "4h": "4H", "6h": "6H", "12h": "12H",
97
- "1d": "1D", "3d": "3D", "1w": "1W",
98
- }
99
- TIMEFRAME_MAP_BYBIT = {
100
- "1m": "1", "3m": "3", "5m": "5", "15m": "15", "30m": "30",
101
- "1h": "60", "2h": "120", "4h": "240", "6h": "360", "12h": "720",
102
- "1d": "D", "1w": "W",
103
- }
104
-
105
-
106
- # ──────────────────────────────────────────────
107
- # Helpers
108
- # ──────────────────────────────────────────────
109
-
110
- def _now_iso() -> str:
111
- return datetime.now(timezone.utc).isoformat()
112
-
113
-
114
- def _now_ts() -> float:
115
- return time.monotonic()
116
-
117
-
118
- def _bump_counter(name: str) -> None:
119
- _REQUEST_COUNTERS[name] = _REQUEST_COUNTERS.get(name, 0) + 1
120
-
121
-
122
- # ── Cache ──────────────────────────────────────
123
-
124
- def _cache_get(key: str) -> Optional[Any]:
125
- entry = _CACHE.get(key)
126
- if entry and _now_ts() < entry[0]:
127
- return entry[1]
128
- return None
129
-
130
-
131
- def _cache_set(key: str, value: Any, ttl: float) -> None:
132
- _CACHE[key] = (_now_ts() + ttl, value)
133
-
134
-
135
- # ── Circuit Breaker ────────────────────────────
136
-
137
- def _cb_is_open(provider: str) -> bool:
138
- """Returns True if provider is currently circuit-broken (should be skipped)."""
139
- state = _CB_STATE.get(provider)
140
- if not state:
141
- return False
142
- if state["failures"] >= CB_THRESHOLD:
143
- if (_now_ts() - state["tripped_at"]) < CB_COOLDOWN:
144
- return True
145
- # Cool-down elapsed → half-open: reset
146
- _CB_STATE[provider] = {"failures": 0, "tripped_at": 0}
147
- return False
148
-
149
-
150
- def _cb_record(provider: str, ok: bool) -> None:
151
- state = _CB_STATE.setdefault(provider, {"failures": 0, "tripped_at": 0})
152
- if ok:
153
- state["failures"] = 0
154
- else:
155
- state["failures"] += 1
156
- if state["failures"] >= CB_THRESHOLD:
157
- state["tripped_at"] = _now_ts()
158
-
159
-
160
- # ── Provider Health ────────────────────────────
161
-
162
- def _record_provider(provider: str, ok: bool, latency_ms: float, error: Optional[str] = None) -> None:
163
- entry = _PROVIDER_HEALTH.setdefault(provider, {
164
- "provider": provider, "status": "unknown",
165
- "successes": 0, "failures": 0,
166
- "latencySamples": [], "errorSamples": [],
167
- "lastSuccess": None, "lastError": None, "lastChecked": None,
168
- })
169
- entry["lastChecked"] = _now_iso()
170
- samples = entry.setdefault("latencySamples", [])
171
- samples.append(round(float(latency_ms), 2))
172
- del samples[:-30]
173
- if ok:
174
- entry["successes"] += 1
175
- entry["lastSuccess"] = _now_iso()
176
- entry["status"] = "healthy" if entry["failures"] == 0 else "degraded"
177
- else:
178
- entry["failures"] += 1
179
- entry["lastError"] = _now_iso()
180
- entry["status"] = "degraded" if entry["successes"] else "unavailable"
181
- errors = entry.setdefault("errorSamples", [])
182
- if error:
183
- errors.append(str(error)[:300])
184
- del errors[:-MAX_ERROR_SAMPLES]
185
- _cb_record(provider, ok)
186
-
187
-
188
- def _latency_avg(provider: str) -> Optional[float]:
189
- samples = _PROVIDER_HEALTH.get(provider, {}).get("latencySamples", [])
190
- if not samples:
191
- return None
192
- return round(sum(samples) / len(samples), 2)
193
-
194
-
195
- # ── Symbol normalisation ───────────────────────
196
-
197
- def _normalize_symbol(symbol: str) -> Dict[str, str]:
198
- raw = (symbol or "BTC").strip().upper().replace("_", "-")
199
- if raw in ("XBT", "XBTUSDT", "XBT-USDT"):
200
- raw = "BTC"
201
- for suffix in ("-USDT", "USDT", "-USD", "USD", "-BUSD", "BUSD"):
202
- if raw.endswith(suffix) and len(raw) > len(suffix):
203
- raw = raw[: -len(suffix)]
204
- break
205
- raw = raw.replace("-", "")
206
- if raw == "XBT":
207
- raw = "BTC"
208
- base = raw
209
- return {
210
- "input": symbol,
211
- "base": base,
212
- "binance": f"{base}USDT",
213
- "binance_perp": f"{base}USDT", # same for Binance perp
214
- "kucoin": f"{base}-USDT",
215
- "okx_spot": f"{base}-USDT",
216
- "okx_swap": f"{base}-USDT-SWAP",
217
- "bybit_spot": f"{base}USDT",
218
- "bybit_perp": f"{base}USDT",
219
- "cc_base": base,
220
- }
221
-
222
-
223
- # ── Quality payload ────────────────────────────
224
-
225
- def _quality_payload(
226
- *, source: str, source_chain: List[str], errors: List[str],
227
- market_type: str = "spot", is_live: bool = True,
228
- is_tradable: bool = False, data_quality: str = "partial",
229
- ) -> Dict[str, Any]:
230
- return {
231
- "source": source,
232
- "sourceChain": source_chain,
233
- "marketType": market_type,
234
- "isLive": is_live,
235
- "isTradable": is_tradable,
236
- "executionAllowed": False, # always
237
- "dataQuality": data_quality,
238
- "errors": errors,
239
- "timestamp": _now_iso(),
240
- }
241
-
242
-
243
- # ── HTTP helpers ───────────────────────────────
244
-
245
- async def _fetch_json(
246
- provider: str, url: str,
247
- params: Optional[Dict[str, Any]] = None,
248
- headers: Optional[Dict[str, str]] = None,
249
- ) -> Tuple[Optional[Any], Optional[str], float]:
250
- if _cb_is_open(provider):
251
- return None, f"circuit_open:{provider}", 0.0
252
- start = time.perf_counter()
253
- try:
254
- async with httpx.AsyncClient(
255
- timeout=REQUEST_TIMEOUT,
256
- headers=headers or {"User-Agent": "enterprise-data-hub-v3.1"},
257
- ) as client:
258
- resp = await client.get(url, params=params)
259
- latency_ms = (time.perf_counter() - start) * 1000
260
- if resp.status_code >= 400:
261
- err = f"HTTP {resp.status_code} from {url}"
262
- _record_provider(provider, False, latency_ms, err)
263
- return None, err, latency_ms
264
- data = resp.json()
265
- _record_provider(provider, True, latency_ms)
266
- return data, None, latency_ms
267
- except Exception as exc: # noqa: BLE001
268
- latency_ms = (time.perf_counter() - start) * 1000
269
- err = f"{type(exc).__name__}: {exc}"
270
- _record_provider(provider, False, latency_ms, err)
271
- return None, err, latency_ms
272
-
273
-
274
- async def _fetch_v4(
275
- path: str, params: Optional[Dict[str, Any]] = None,
276
- ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
277
- data, err, _ = await _fetch_json("v4_source", f"{V4_BASE_URL}{path}", params=params)
278
- if data is None:
279
- return None, err
280
- if not isinstance(data, dict):
281
- return None, "v4_source returned non-object JSON"
282
- return data, None
283
-
284
-
285
- # ──────────────────────────────────────────────
286
- # Candle normalisation
287
- # ──────────────────────────────────────────────
288
-
289
- def _norm_candles_binance(rows: List[Any]) -> List[Dict[str, Any]]:
290
- out = []
291
- for row in rows or []:
292
- try:
293
- open_time = int(row[0])
294
- out.append({
295
- "timestamp": open_time, "open_time": open_time,
296
- "open": float(row[1]), "high": float(row[2]),
297
- "low": float(row[3]), "close": float(row[4]),
298
- "volume": float(row[5]),
299
- "close_time": int(row[6]) if len(row) > 6 else None,
300
- })
301
- except Exception:
302
- continue
303
- return out
304
-
305
-
306
- def _norm_candles_kucoin(rows: List[Any]) -> List[Dict[str, Any]]:
307
- out = []
308
- for row in rows or []:
309
- try:
310
- ts_ms = int(float(row[0])) * 1000
311
- out.append({
312
- "timestamp": ts_ms, "open_time": ts_ms,
313
- "open": float(row[1]), "high": float(row[3]),
314
- "low": float(row[4]), "close": float(row[2]),
315
- "volume": float(row[5]),
316
- "turnover": float(row[6]) if len(row) > 6 else None,
317
- })
318
- except Exception:
319
- continue
320
- return sorted(out, key=lambda x: x["timestamp"])
321
-
322
-
323
- def _norm_candles_okx(rows: List[Any]) -> List[Dict[str, Any]]:
324
- # OKX: [ts, open, high, low, close, vol, volCcy, volCcyQuote, confirm]
325
- out = []
326
- for row in rows or []:
327
- try:
328
- ts_ms = int(row[0])
329
- out.append({
330
- "timestamp": ts_ms, "open_time": ts_ms,
331
- "open": float(row[1]), "high": float(row[2]),
332
- "low": float(row[3]), "close": float(row[4]),
333
- "volume": float(row[5]),
334
- "quote_volume": float(row[6]) if len(row) > 6 else None,
335
- })
336
- except Exception:
337
- continue
338
- return sorted(out, key=lambda x: x["timestamp"])
339
-
340
-
341
- def _norm_candles_bybit(rows: List[Any]) -> List[Dict[str, Any]]:
342
- # ByBit: [startTime, openPrice, highPrice, lowPrice, closePrice, volume, turnover]
343
- out = []
344
- for row in rows or []:
345
- try:
346
- ts_ms = int(row[0])
347
- out.append({
348
- "timestamp": ts_ms, "open_time": ts_ms,
349
- "open": float(row[1]), "high": float(row[2]),
350
- "low": float(row[3]), "close": float(row[4]),
351
- "volume": float(row[5]),
352
- "turnover": float(row[6]) if len(row) > 6 else None,
353
- })
354
- except Exception:
355
- continue
356
- return sorted(out, key=lambda x: x["timestamp"])
357
-
358
-
359
- def _v4_candles(data: Dict[str, Any]) -> List[Dict[str, Any]]:
360
- for key in ("candles", "data", "ohlcv"):
361
- candidate = data.get(key)
362
- if isinstance(candidate, list) and candidate:
363
- return candidate
364
- return []
365
-
366
-
367
- # ─────────────────────────��────────────────────
368
- # OHLCV fallback chain (parallel)
369
- # ──────────────────────────────────────────────
370
-
371
- async def _ohlcv_from_binance(sym: Dict[str, str], timeframe: str, limit: int) -> Optional[Dict[str, Any]]:
372
- interval = TIMEFRAME_MAP_BINANCE.get(timeframe, "1h")
373
- data, err, _ = await _fetch_json(
374
- "binance_vision_klines",
375
- "https://data-api.binance.vision/api/v3/klines",
376
- {"symbol": sym["binance"], "interval": interval, "limit": limit},
377
- )
378
- if err or not isinstance(data, list):
379
- return None
380
- candles = _norm_candles_binance(data)
381
- if not candles:
382
- return None
383
- return {"symbol": sym["binance"], "timeframe": timeframe,
384
- "candles": candles, "source": "binance_vision_klines", "marketType": "spot"}
385
-
386
-
387
- async def _ohlcv_from_kucoin(sym: Dict[str, str], timeframe: str, limit: int) -> Optional[Dict[str, Any]]:
388
- ku_type = TIMEFRAME_MAP_KUCOIN.get(timeframe, "1hour")
389
- data, err, _ = await _fetch_json(
390
- "kucoin_public_candles",
391
- "https://api.kucoin.com/api/v1/market/candles",
392
- {"symbol": sym["kucoin"], "type": ku_type},
393
- )
394
- if err or not isinstance(data, dict):
395
- return None
396
- rows = data.get("data") or []
397
- candles = _norm_candles_kucoin(rows)[:limit]
398
- if not candles:
399
- return None
400
- return {"symbol": sym["binance"], "timeframe": timeframe,
401
- "candles": candles, "source": "kucoin_public_candles", "marketType": "spot"}
402
-
403
-
404
- async def _ohlcv_from_okx(sym: Dict[str, str], timeframe: str, limit: int) -> Optional[Dict[str, Any]]:
405
- bar = TIMEFRAME_MAP_OKX.get(timeframe, "1H")
406
- limit_capped = min(limit, 300)
407
- data, err, _ = await _fetch_json(
408
- "okx_public_candles",
409
- "https://www.okx.com/api/v5/market/candles",
410
- {"instId": sym["okx_spot"], "bar": bar, "limit": str(limit_capped)},
411
- )
412
- if err or not isinstance(data, dict):
413
- return None
414
- rows = data.get("data") or []
415
- candles = _norm_candles_okx(rows)
416
- if not candles:
417
- return None
418
- return {"symbol": sym["binance"], "timeframe": timeframe,
419
- "candles": candles[-limit:], "source": "okx_public_candles", "marketType": "spot"}
420
-
421
-
422
- async def _ohlcv_from_bybit(sym: Dict[str, str], timeframe: str, limit: int) -> Optional[Dict[str, Any]]:
423
- interval = TIMEFRAME_MAP_BYBIT.get(timeframe, "60")
424
- limit_capped = min(limit, 200)
425
- data, err, _ = await _fetch_json(
426
- "bybit_public_klines",
427
- "https://api.bybit.com/v5/market/kline",
428
- {"category": "spot", "symbol": sym["bybit_spot"], "interval": interval, "limit": str(limit_capped)},
429
- )
430
- if err or not isinstance(data, dict):
431
- return None
432
- rows = (data.get("result") or {}).get("list") or []
433
- candles = _norm_candles_bybit(rows)
434
- if not candles:
435
- return None
436
- return {"symbol": sym["binance"], "timeframe": timeframe,
437
- "candles": candles[-limit:], "source": "bybit_public_klines", "marketType": "spot"}
438
-
439
-
440
- async def _ohlcv_from_cryptocompare(sym: Dict[str, str], timeframe: str, limit: int) -> Optional[Dict[str, Any]]:
441
- cc_key = os.getenv("CRYPTOCOMPARE_KEY")
442
- cc_params: Dict[str, Any] = {"fsym": sym["cc_base"], "tsym": "USD", "limit": limit, "aggregate": 1}
443
- if cc_key:
444
- cc_params["api_key"] = cc_key
445
- url = ("https://min-api.cryptocompare.com/data/v2/histohour"
446
- if timeframe.endswith("h") else "https://min-api.cryptocompare.com/data/v2/histoday")
447
- data, err, _ = await _fetch_json("cryptocompare_histo", url, cc_params)
448
- if err or not isinstance(data, dict):
449
- return None
450
- rows = (((data.get("Data") or {}).get("Data")) or [])
451
- candles = []
452
- for row in rows:
453
- try:
454
- ts_ms = int(row.get("time", 0)) * 1000
455
- candles.append({
456
- "timestamp": ts_ms, "open_time": ts_ms,
457
- "open": float(row.get("open", 0)), "high": float(row.get("high", 0)),
458
- "low": float(row.get("low", 0)), "close": float(row.get("close", 0)),
459
- "volume": float(row.get("volumefrom", 0)),
460
- "quote_volume": float(row.get("volumeto", 0)),
461
- })
462
- except Exception:
463
- continue
464
- if not candles:
465
- return None
466
- return {"symbol": sym["binance"], "timeframe": timeframe,
467
- "candles": candles[-limit:], "source": "cryptocompare_histo", "marketType": "spot"}
468
-
469
-
470
- async def _fallback_ohlcv_parallel(
471
- symbol: str, timeframe: str, limit: int,
472
- errors: List[str], source_chain: List[str],
473
- ) -> Optional[Dict[str, Any]]:
474
- """Fetch all public fallbacks concurrently; return first success."""
475
- sym = _normalize_symbol(symbol)
476
- limit = max(1, min(limit, 500))
477
-
478
- fetchers = [
479
- ("binance_vision_klines", _ohlcv_from_binance(sym, timeframe, limit)),
480
- ("kucoin_public_candles", _ohlcv_from_kucoin(sym, timeframe, limit)),
481
- ("okx_public_candles", _ohlcv_from_okx(sym, timeframe, limit)),
482
- ("bybit_public_klines", _ohlcv_from_bybit(sym, timeframe, limit)),
483
- ("cryptocompare_histo", _ohlcv_from_cryptocompare(sym, timeframe, limit)),
484
- ]
485
- for name, _ in fetchers:
486
- source_chain.append(name)
487
-
488
- results = await asyncio.gather(*[coro for _, coro in fetchers], return_exceptions=True)
489
-
490
- for (name, _), result in zip(fetchers, results):
491
- if isinstance(result, Exception):
492
- errors.append(f"{name}: {result}")
493
- continue
494
- if result and result.get("candles"):
495
- return result
496
- return None
497
-
498
-
499
- # ──────────────────────────────────────────────
500
- # Ticker helpers (single price, low-latency)
501
- # ──────────────────────────────────────────────
502
-
503
- async def _ticker_from_binance(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
504
- data, err, _ = await _fetch_json(
505
- "binance_vision_price",
506
- "https://data-api.binance.vision/api/v3/ticker/price",
507
- {"symbol": sym["binance"]},
508
- )
509
- if err or not isinstance(data, dict) or "price" not in data:
510
- return None
511
- price = float(data["price"])
512
- return {"symbol": sym["binance"], "price": price, "source": "binance_vision_price"}
513
-
514
-
515
- async def _ticker_from_kucoin(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
516
- data, err, _ = await _fetch_json(
517
- "kucoin_ticker",
518
- "https://api.kucoin.com/api/v1/market/orderbook/level1",
519
- {"symbol": sym["kucoin"]},
520
- )
521
- if err or not isinstance(data, dict):
522
- return None
523
- price_str = (data.get("data") or {}).get("price")
524
- if not price_str:
525
- return None
526
- return {"symbol": sym["binance"], "price": float(price_str), "source": "kucoin_ticker"}
527
-
528
-
529
- async def _ticker_from_okx(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
530
- data, err, _ = await _fetch_json(
531
- "okx_ticker",
532
- "https://www.okx.com/api/v5/market/ticker",
533
- {"instId": sym["okx_spot"]},
534
- )
535
- if err or not isinstance(data, dict):
536
- return None
537
- items = data.get("data") or []
538
- if not items:
539
- return None
540
- price = float(items[0].get("last", 0) or 0)
541
- if not price:
542
- return None
543
- return {"symbol": sym["binance"], "price": price, "source": "okx_ticker"}
544
-
545
-
546
- async def _ticker_from_bybit(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
547
- data, err, _ = await _fetch_json(
548
- "bybit_ticker",
549
- "https://api.bybit.com/v5/market/tickers",
550
- {"category": "spot", "symbol": sym["bybit_spot"]},
551
- )
552
- if err or not isinstance(data, dict):
553
- return None
554
- items = (data.get("result") or {}).get("list") or []
555
- if not items:
556
- return None
557
- price = float(items[0].get("lastPrice", 0) or 0)
558
- if not price:
559
- return None
560
- return {"symbol": sym["binance"], "price": price, "source": "bybit_ticker"}
561
-
562
-
563
- async def _fetch_ticker_parallel(sym: Dict[str, str]) -> Tuple[Optional[Dict[str, Any]], List[str]]:
564
- errors: List[str] = []
565
- results = await asyncio.gather(
566
- _ticker_from_binance(sym),
567
- _ticker_from_kucoin(sym),
568
- _ticker_from_okx(sym),
569
- _ticker_from_bybit(sym),
570
- return_exceptions=True,
571
- )
572
- for r in results:
573
- if isinstance(r, Exception):
574
- errors.append(str(r))
575
- continue
576
- if r and r.get("price"):
577
- return r, errors
578
- return None, errors
579
-
580
-
581
- # ──────────────────────────────────────────────
582
- # Funding rate helpers
583
- # ──────────────────────────────────────────────
584
-
585
- async def _funding_from_binance(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
586
- data, err, _ = await _fetch_json(
587
- "binance_funding",
588
- "https://fapi.binance.com/fapi/v1/premiumIndex",
589
- {"symbol": sym["binance_perp"]},
590
- )
591
- if err or not isinstance(data, dict):
592
- return None
593
- fr = data.get("lastFundingRate")
594
- if fr is None:
595
- return None
596
- return {
597
- "symbol": sym["binance"], "fundingRate": float(fr),
598
- "nextFundingTime": data.get("nextFundingTime"),
599
- "markPrice": float(data.get("markPrice", 0) or 0),
600
- "source": "binance_futures",
601
- }
602
-
603
-
604
- async def _funding_from_okx(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
605
- data, err, _ = await _fetch_json(
606
- "okx_funding",
607
- "https://www.okx.com/api/v5/public/funding-rate",
608
- {"instId": sym["okx_swap"]},
609
- )
610
- if err or not isinstance(data, dict):
611
- return None
612
- items = data.get("data") or []
613
- if not items:
614
- return None
615
- fr = items[0].get("fundingRate")
616
- if fr is None:
617
- return None
618
- return {
619
- "symbol": sym["binance"], "fundingRate": float(fr),
620
- "nextFundingTime": items[0].get("nextFundingTime"),
621
- "source": "okx_futures",
622
- }
623
-
624
-
625
- async def _funding_from_bybit(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
626
- data, err, _ = await _fetch_json(
627
- "bybit_funding",
628
- "https://api.bybit.com/v5/market/tickers",
629
- {"category": "linear", "symbol": sym["bybit_perp"]},
630
- )
631
- if err or not isinstance(data, dict):
632
- return None
633
- items = (data.get("result") or {}).get("list") or []
634
- if not items:
635
- return None
636
- fr = items[0].get("fundingRate")
637
- if fr is None:
638
- return None
639
- return {
640
- "symbol": sym["binance"], "fundingRate": float(fr),
641
- "nextFundingTime": items[0].get("nextFundingTime"),
642
- "markPrice": float(items[0].get("markPrice", 0) or 0),
643
- "source": "bybit_futures",
644
- }
645
-
646
-
647
- # ──────────────────────────────────────────────
648
- # Open Interest helpers
649
- # ──────────────────────────────────────────────
650
-
651
- async def _oi_from_binance(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
652
- data, err, _ = await _fetch_json(
653
- "binance_oi",
654
- "https://fapi.binance.com/fapi/v1/openInterest",
655
- {"symbol": sym["binance_perp"]},
656
- )
657
- if err or not isinstance(data, dict):
658
- return None
659
- oi = data.get("openInterest")
660
- if oi is None:
661
- return None
662
- return {"symbol": sym["binance"], "openInterest": float(oi), "source": "binance_futures"}
663
-
664
-
665
- async def _oi_from_okx(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
666
- data, err, _ = await _fetch_json(
667
- "okx_oi",
668
- "https://www.okx.com/api/v5/rubik/stat/contracts/open-interest-volume",
669
- {"ccy": sym["base"]},
670
- )
671
- if err or not isinstance(data, dict):
672
- return None
673
- items = data.get("data") or []
674
- if not items:
675
- return None
676
- oi = items[0][1] if len(items[0]) > 1 else None
677
- if oi is None:
678
- return None
679
- return {"symbol": sym["binance"], "openInterest": float(oi), "source": "okx_futures"}
680
-
681
-
682
- async def _oi_from_bybit(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
683
- data, err, _ = await _fetch_json(
684
- "bybit_oi",
685
- "https://api.bybit.com/v5/market/open-interest",
686
- {"category": "linear", "symbol": sym["bybit_perp"], "intervalTime": "5min", "limit": "1"},
687
- )
688
- if err or not isinstance(data, dict):
689
- return None
690
- items = (data.get("result") or {}).get("list") or []
691
- if not items:
692
- return None
693
- oi = items[0].get("openInterest")
694
- if oi is None:
695
- return None
696
- return {"symbol": sym["binance"], "openInterest": float(oi), "source": "bybit_futures"}
697
-
698
-
699
- # ──────────────────────────────────────────────
700
- # API Endpoints
701
- # ──────────────────────────────────────────────
702
-
703
- @router.get("/api/enterprise/status")
704
- @router.get("/api/hub/status")
705
- async def enterprise_status() -> Dict[str, Any]:
706
- """Enterprise hub status: source contracts, counters, and runtime provider health."""
707
- _bump_counter("enterprise_status")
708
- online = sum(1 for v in _PROVIDER_HEALTH.values() if v.get("status") == "healthy")
709
- degraded = sum(1 for v in _PROVIDER_HEALTH.values() if v.get("status") == "degraded")
710
- offline = sum(1 for v in _PROVIDER_HEALTH.values() if v.get("status") == "unavailable")
711
- cb_open = [p for p, s in _CB_STATE.items() if s.get("failures", 0) >= CB_THRESHOLD
712
- and (_now_ts() - s.get("tripped_at", 0)) < CB_COOLDOWN]
713
- return {
714
- "status": "ok",
715
- "hubRole": "central_single_source_of_truth",
716
- "version": "enterprise-data-hub-v3.1",
717
- "v4Source": V4_BASE_URL,
718
- "contracts": {
719
- "ohlcv": "v3.1.normalized.candles",
720
- "orderbook": "v3.1.normalized.depth",
721
- "volume": "v3.1.normalized.volume24h",
722
- "ticker": "v3.1.normalized.price",
723
- "multiTicker": "v3.1.normalized.prices[]",
724
- "funding": "v3.1.normalized.fundingRate",
725
- "openInterest": "v3.1.normalized.openInterest",
726
- "news": "v3.1.normalized.news",
727
- "sentiment": "v3.1.normalized.sentiment",
728
- },
729
- "providerHealthSummary": {
730
- "online": online, "degraded": degraded,
731
- "offline": offline, "tracked": len(_PROVIDER_HEALTH),
732
- },
733
- "circuitBreakers": {"tripped": cb_open, "threshold": CB_THRESHOLD, "cooldownSec": CB_COOLDOWN},
734
- "cacheStats": {"keysHeld": len(_CACHE)},
735
- "requestCounters": _REQUEST_COUNTERS,
736
- "safety": {"executionAllowed": False, "secretsInSource": False},
737
- "timestamp": _now_iso(),
738
- }
739
-
740
-
741
- @router.get("/api/enterprise/provider-health")
742
- @router.get("/api/hub/provider-health")
743
- async def enterprise_provider_health() -> Dict[str, Any]:
744
- _bump_counter("enterprise_provider_health")
745
- providers = [
746
- {**entry, "avgLatencyMs": _latency_avg(p), "circuitOpen": _cb_is_open(p)}
747
- for p, entry in sorted(_PROVIDER_HEALTH.items())
748
- ]
749
- return {"status": "ok", "providers": providers, "count": len(providers), "timestamp": _now_iso()}
750
-
751
-
752
- @router.get("/api/enterprise/source-contracts")
753
- @router.get("/api/hub/source-contracts")
754
- async def enterprise_source_contracts() -> Dict[str, Any]:
755
- _bump_counter("enterprise_source_contracts")
756
- return {
757
- "status": "ok",
758
- "schemaVersion": "enterprise-hub-v3.1",
759
- "sourcePriority": {
760
- "ohlcv": ["v4_source", "binance_vision_klines", "kucoin_public_candles",
761
- "okx_public_candles", "bybit_public_klines", "cryptocompare_histo"],
762
- "orderbook": ["v4_source", "binance_vision_depth", "kucoin_public_level2_20",
763
- "okx_orderbook", "bybit_orderbook"],
764
- "volume": ["v4_source", "binance_vision_24hr", "kucoin_public_stats",
765
- "okx_ticker", "bybit_ticker"],
766
- "ticker": ["binance_vision_price", "kucoin_ticker", "okx_ticker", "bybit_ticker"],
767
- "multiTicker": ["binance_vision_price (parallel)"],
768
- "funding": ["binance_futures", "okx_futures", "bybit_futures"],
769
- "openInterest": ["binance_futures", "okx_futures", "bybit_futures"],
770
- "news": ["v4_source", "render_crypto_dt_source", "empty_nonfatal_fallback"],
771
- "sentiment": ["v4_source", "alternative_me_fear_greed", "coingecko_sentiment"],
772
- },
773
- "requiredQualityFields": [
774
- "source", "sourceChain", "marketType", "isLive",
775
- "isTradable", "executionAllowed", "dataQuality", "errors", "timestamp",
776
- ],
777
- "timestamp": _now_iso(),
778
- }
779
-
780
-
781
- @router.get("/api/enterprise/sync/v4")
782
- @router.get("/api/hub/sync/v4")
783
- async def enterprise_v4_sync_probe() -> Dict[str, Any]:
784
- """Probe V4 without trusting it blindly. Hub-source drift detection."""
785
- _bump_counter("enterprise_v4_sync_probe")
786
- probes = [
787
- ("health", "/api/health", {}),
788
- ("status", "/api/status", {}),
789
- ("ohlcv", "/api/ohlcv", {"symbol": "BTC", "timeframe": "1h", "limit": 5}),
790
- ("orderbook", "/api/trading/orderbook", {"symbol": "BTC", "depth": 5}),
791
- ("volume", "/api/trading/volume", {"symbol": "BTC"}),
792
- ("sentiment", "/api/social/sentiment", {"coin": "BTC", "timeframe": "24h"}),
793
- ]
794
- results = []
795
- for name, path, params in probes:
796
- start = time.perf_counter()
797
- data, err = await _fetch_v4(path, params)
798
- latency = round((time.perf_counter() - start) * 1000, 2)
799
- results.append({
800
- "capability": name, "path": path,
801
- "ok": data is not None and not err,
802
- "latencyMs": latency, "error": err,
803
- "hasData": bool(data),
804
- "schemaPreview": sorted(list(data.keys()))[:12] if isinstance(data, dict) else [],
805
- })
806
- ok_count = sum(1 for r in results if r["ok"])
807
- return {
808
- "status": "ok" if ok_count >= 4 else "degraded",
809
- "v4Source": V4_BASE_URL,
810
- "okCount": ok_count, "total": len(results),
811
- "results": results, "timestamp": _now_iso(),
812
- }
813
-
814
-
815
- # ── OHLCV ──────────────────────────────────────
816
-
817
- @router.get("/api/hub/ohlcv")
818
- @router.get("/api/enterprise/ohlcv")
819
- async def enterprise_ohlcv(
820
- symbol: str = Query("BTC"),
821
- timeframe: str = Query("1h"),
822
- limit: int = Query(100),
823
- ) -> Dict[str, Any]:
824
- _bump_counter("enterprise_ohlcv")
825
- cache_key = f"ohlcv:{symbol}:{timeframe}:{limit}"
826
- cached = _cache_get(cache_key)
827
- if cached is not None:
828
- return {**cached, "cached": True}
829
-
830
- errors: List[str] = []
831
- source_chain: List[str] = []
832
- sym = _normalize_symbol(symbol)
833
- limit = max(1, min(int(limit or 100), 500))
834
-
835
- # 1) V4 first
836
- source_chain.append("v4_source")
837
- v4, err = await _fetch_v4("/api/ohlcv", {"symbol": sym["base"], "timeframe": timeframe, "limit": limit})
838
- if err:
839
- errors.append(f"v4_source: {err}")
840
- elif isinstance(v4, dict):
841
- candles = _v4_candles(v4)
842
- if candles:
843
- payload = {
844
- "success": True, "symbol": sym["binance"],
845
- "timeframe": timeframe, "candles": candles[-limit:],
846
- **_quality_payload(
847
- source="v4_source", source_chain=source_chain, errors=errors,
848
- market_type=v4.get("marketType", "spot"),
849
- is_live=bool(v4.get("isLive", True)),
850
- is_tradable=False,
851
- data_quality="live" if not errors else "partial",
852
- ),
853
- }
854
- _cache_set(cache_key, payload, CACHE_TTL_OHLCV)
855
- return payload
856
- errors.append("v4_source: no candles in response")
857
-
858
- # 2) Public fallbacks — parallel
859
- fallback = await _fallback_ohlcv_parallel(symbol, timeframe, limit, errors, source_chain)
860
- if fallback and fallback.get("candles"):
861
- payload = {
862
- "success": True, "symbol": fallback["symbol"],
863
- "timeframe": timeframe, "candles": fallback["candles"],
864
- **_quality_payload(
865
- source=fallback["source"], source_chain=source_chain, errors=errors,
866
- market_type=fallback.get("marketType", "spot"),
867
- is_live=True, is_tradable=False,
868
- data_quality="partial" if errors else "live",
869
- ),
870
- }
871
- _cache_set(cache_key, payload, CACHE_TTL_OHLCV)
872
- return payload
873
-
874
- return {
875
- "success": False, "symbol": sym["binance"],
876
- "timeframe": timeframe, "candles": [],
877
- **_quality_payload(
878
- source="unavailable", source_chain=source_chain, errors=errors,
879
- market_type="unknown", is_live=False, is_tradable=False, data_quality="unavailable",
880
- ),
881
- }
882
-
883
-
884
- # ── Ticker (single price) ──────────────────────
885
-
886
- @router.get("/api/hub/ticker")
887
- @router.get("/api/enterprise/ticker")
888
- async def enterprise_ticker(symbol: str = Query("BTC")) -> Dict[str, Any]:
889
- """Fast price lookup. Parallel across Binance/KuCoin/OKX/ByBit; first wins."""
890
- _bump_counter("enterprise_ticker")
891
- sym = _normalize_symbol(symbol)
892
- cache_key = f"ticker:{sym['base']}"
893
- cached = _cache_get(cache_key)
894
- if cached is not None:
895
- return {**cached, "cached": True}
896
-
897
- errors: List[str] = []
898
- source_chain = ["binance_vision_price", "kucoin_ticker", "okx_ticker", "bybit_ticker"]
899
-
900
- # Try V4 first (it may already have a fast price endpoint)
901
- v4, err = await _fetch_v4("/api/trading/volume", {"symbol": sym["base"]})
902
- if not err and isinstance(v4, dict) and v4.get("price"):
903
- payload = {
904
- "success": True, "symbol": sym["binance"],
905
- "price": float(v4["price"]),
906
- **_quality_payload(
907
- source="v4_source", source_chain=["v4_source"] + source_chain,
908
- errors=errors, market_type="spot", is_live=True,
909
- is_tradable=False, data_quality="live",
910
- ),
911
- }
912
- _cache_set(cache_key, payload, CACHE_TTL_TICKER)
913
- return payload
914
-
915
- result, ticker_errors = await _fetch_ticker_parallel(sym)
916
- errors.extend(ticker_errors)
917
-
918
- if result:
919
- payload = {
920
- "success": True, "symbol": sym["binance"],
921
- "price": result["price"],
922
- **_quality_payload(
923
- source=result["source"], source_chain=source_chain, errors=errors,
924
- market_type="spot", is_live=True, is_tradable=False,
925
- data_quality="partial" if errors else "live",
926
- ),
927
- }
928
- _cache_set(cache_key, payload, CACHE_TTL_TICKER)
929
- return payload
930
-
931
- return {
932
- "success": False, "symbol": sym["binance"], "price": None,
933
- **_quality_payload(
934
- source="unavailable", source_chain=source_chain, errors=errors,
935
- market_type="unknown", is_live=False, is_tradable=False, data_quality="unavailable",
936
- ),
937
- }
938
-
939
-
940
- # ── Multi-Ticker (batch) ───────────────────────
941
-
942
- @router.get("/api/hub/multi-ticker")
943
- @router.get("/api/enterprise/multi-ticker")
944
- async def enterprise_multi_ticker(symbols: str = Query("BTC,ETH,SOL")) -> Dict[str, Any]:
945
- """
946
- Batch price fetch. `symbols` is comma-separated, max 20.
947
- Uses Binance bulk ticker endpoint first, falls back to parallel individual fetches.
948
- """
949
- _bump_counter("enterprise_multi_ticker")
950
- raw_symbols = [s.strip().upper() for s in symbols.split(",") if s.strip()][:20]
951
- if not raw_symbols:
952
- return {"success": False, "prices": {}, "errors": ["no symbols provided"], "timestamp": _now_iso()}
953
-
954
- # Try Binance bulk endpoint (most efficient)
955
- sym_map = {s: _normalize_symbol(s) for s in raw_symbols}
956
- binance_syms = [sym_map[s]["binance"] for s in raw_symbols]
957
- bulk_data, err, _ = await _fetch_json(
958
- "binance_vision_price_bulk",
959
- "https://data-api.binance.vision/api/v3/ticker/price",
960
- {}, # No params = all symbols
961
- )
962
-
963
- prices: Dict[str, Optional[float]] = {}
964
- errors: List[str] = []
965
-
966
- if not err and isinstance(bulk_data, list):
967
- bulk_lookup = {item["symbol"]: float(item["price"]) for item in bulk_data if "symbol" in item and "price" in item}
968
- for s, sym in sym_map.items():
969
- p = bulk_lookup.get(sym["binance"])
970
- if p:
971
- prices[s] = p
972
- missing = [s for s in raw_symbols if s not in prices]
973
- if missing:
974
- errors.append(f"binance bulk: missing {missing}")
975
- else:
976
- if err:
977
- errors.append(f"binance_vision_price_bulk: {err}")
978
- missing = list(raw_symbols)
979
-
980
- # Individual parallel fetch for anything still missing
981
- if missing:
982
- individual_results = await asyncio.gather(
983
- *[_fetch_ticker_parallel(_normalize_symbol(s)) for s in missing],
984
- return_exceptions=True,
985
- )
986
- for sym_str, result in zip(missing, individual_results):
987
- if isinstance(result, Exception):
988
- errors.append(str(result))
989
- prices[sym_str] = None
990
- else:
991
- ticker, ind_errors = result
992
- errors.extend(ind_errors)
993
- prices[sym_str] = ticker["price"] if ticker else None
994
-
995
- return {
996
- "success": True,
997
- "prices": prices,
998
- "count": len(prices),
999
- "errors": errors,
1000
- "source": "binance_vision_price_bulk+individual_fallback",
1001
- "executionAllowed": False,
1002
- "timestamp": _now_iso(),
1003
- }
1004
-
1005
-
1006
- # ── Orderbook ──────────────────────────────────
1007
-
1008
- @router.get("/api/hub/orderbook")
1009
- @router.get("/api/enterprise/orderbook")
1010
- async def enterprise_orderbook(
1011
- symbol: str = Query("BTC"),
1012
- depth: int = Query(20),
1013
- limit: Optional[int] = None,
1014
- ) -> Dict[str, Any]:
1015
- _bump_counter("enterprise_orderbook")
1016
- depth_value = max(1, min(int(limit or depth or 20), 100))
1017
- errors: List[str] = []
1018
- source_chain: List[str] = []
1019
- sym = _normalize_symbol(symbol)
1020
-
1021
- cache_key = f"ob:{sym['base']}:{depth_value}"
1022
- cached = _cache_get(cache_key)
1023
- if cached is not None:
1024
- return {**cached, "cached": True}
1025
-
1026
- # V4
1027
- source_chain.append("v4_source")
1028
- v4, err = await _fetch_v4("/api/trading/orderbook", {"symbol": sym["base"], "depth": depth_value})
1029
- if err:
1030
- errors.append(f"v4_source: {err}")
1031
- elif isinstance(v4, dict) and (v4.get("bids") or v4.get("asks")):
1032
- payload = {
1033
- "success": True, "symbol": sym["binance"],
1034
- "bids": v4.get("bids", []), "asks": v4.get("asks", []),
1035
- **_quality_payload(
1036
- source="v4_source", source_chain=source_chain, errors=errors,
1037
- market_type=v4.get("marketType", "spot"),
1038
- is_live=True, is_tradable=False,
1039
- data_quality="live" if not errors else "partial",
1040
- ),
1041
- }
1042
- _cache_set(cache_key, payload, CACHE_TTL_ORDERBOOK)
1043
- return payload
1044
-
1045
- # Binance Vision
1046
- source_chain.append("binance_vision_depth")
1047
- data, err, _ = await _fetch_json(
1048
- "binance_vision_depth", "https://data-api.binance.vision/api/v3/depth",
1049
- {"symbol": sym["binance"], "limit": depth_value},
1050
- )
1051
- if err:
1052
- errors.append(f"binance_vision_depth: {err}")
1053
- elif isinstance(data, dict) and (data.get("bids") or data.get("asks")):
1054
- payload = {
1055
- "success": True, "symbol": sym["binance"],
1056
- "bids": [[float(p), float(q)] for p, q in data.get("bids", [])[:depth_value]],
1057
- "asks": [[float(p), float(q)] for p, q in data.get("asks", [])[:depth_value]],
1058
- **_quality_payload(
1059
- source="binance_vision_depth", source_chain=source_chain, errors=errors,
1060
- market_type="spot", is_live=True, is_tradable=False,
1061
- data_quality="partial" if errors else "live",
1062
- ),
1063
- }
1064
- _cache_set(cache_key, payload, CACHE_TTL_ORDERBOOK)
1065
- return payload
1066
-
1067
- # KuCoin
1068
- source_chain.append("kucoin_public_level2_20")
1069
- data, err, _ = await _fetch_json(
1070
- "kucoin_public_level2_20",
1071
- "https://api.kucoin.com/api/v1/market/orderbook/level2_20",
1072
- {"symbol": sym["kucoin"]},
1073
- )
1074
- if err:
1075
- errors.append(f"kucoin_public_level2_20: {err}")
1076
- elif isinstance(data, dict):
1077
- book = data.get("data") or {}
1078
- if book.get("bids") or book.get("asks"):
1079
- payload = {
1080
- "success": True, "symbol": sym["binance"],
1081
- "bids": [[float(p), float(q)] for p, q in book.get("bids", [])[:depth_value]],
1082
- "asks": [[float(p), float(q)] for p, q in book.get("asks", [])[:depth_value]],
1083
- **_quality_payload(
1084
- source="kucoin_public_level2_20", source_chain=source_chain, errors=errors,
1085
- market_type="spot", is_live=True, is_tradable=False,
1086
- data_quality="partial" if errors else "live",
1087
- ),
1088
- }
1089
- _cache_set(cache_key, payload, CACHE_TTL_ORDERBOOK)
1090
- return payload
1091
-
1092
- # OKX
1093
- source_chain.append("okx_orderbook")
1094
- data, err, _ = await _fetch_json(
1095
- "okx_orderbook",
1096
- "https://www.okx.com/api/v5/market/books",
1097
- {"instId": sym["okx_spot"], "sz": str(depth_value)},
1098
- )
1099
- if err:
1100
- errors.append(f"okx_orderbook: {err}")
1101
- elif isinstance(data, dict):
1102
- items = data.get("data") or []
1103
- if items:
1104
- book = items[0]
1105
- bids = [[float(r[0]), float(r[1])] for r in (book.get("bids") or [])[:depth_value]]
1106
- asks = [[float(r[0]), float(r[1])] for r in (book.get("asks") or [])[:depth_value]]
1107
- if bids or asks:
1108
- payload = {
1109
- "success": True, "symbol": sym["binance"],
1110
- "bids": bids, "asks": asks,
1111
- **_quality_payload(
1112
- source="okx_orderbook", source_chain=source_chain, errors=errors,
1113
- market_type="spot", is_live=True, is_tradable=False,
1114
- data_quality="partial" if errors else "live",
1115
- ),
1116
- }
1117
- _cache_set(cache_key, payload, CACHE_TTL_ORDERBOOK)
1118
- return payload
1119
-
1120
- return {
1121
- "success": False, "symbol": sym["binance"], "bids": [], "asks": [],
1122
- **_quality_payload(
1123
- source="unavailable", source_chain=source_chain, errors=errors,
1124
- market_type="unknown", is_live=False, is_tradable=False, data_quality="unavailable",
1125
- ),
1126
- }
1127
-
1128
-
1129
- # ── Volume ─────────────────────────────────────
1130
-
1131
- @router.get("/api/hub/volume")
1132
- @router.get("/api/enterprise/volume")
1133
- async def enterprise_volume(symbol: str = Query("BTC")) -> Dict[str, Any]:
1134
- _bump_counter("enterprise_volume")
1135
- sym = _normalize_symbol(symbol)
1136
- cache_key = f"vol:{sym['base']}"
1137
- cached = _cache_get(cache_key)
1138
- if cached is not None:
1139
- return {**cached, "cached": True}
1140
-
1141
- errors: List[str] = []
1142
- source_chain: List[str] = []
1143
-
1144
- # V4
1145
- source_chain.append("v4_source")
1146
- v4, err = await _fetch_v4("/api/trading/volume", {"symbol": sym["base"]})
1147
- if err:
1148
- errors.append(f"v4_source: {err}")
1149
- elif isinstance(v4, dict) and v4.get("success") and (
1150
- v4.get("volume") is not None or v4.get("base_volume") is not None
1151
- ):
1152
- payload = {
1153
- **v4,
1154
- **_quality_payload(
1155
- source="v4_source", source_chain=source_chain, errors=errors,
1156
- market_type=v4.get("marketType", "spot"),
1157
- is_live=True, is_tradable=False,
1158
- data_quality="live" if not errors else "partial",
1159
- ),
1160
- }
1161
- _cache_set(cache_key, payload, CACHE_TTL_VOLUME)
1162
- return payload
1163
-
1164
- def _parse_24hr(data: Dict[str, Any], src: str) -> Dict[str, Any]:
1165
- return {
1166
- "success": True, "symbol": sym["binance"],
1167
- "price": float(data.get("lastPrice") or data.get("last") or 0),
1168
- "volume": float(data.get("volume") or data.get("vol") or 0),
1169
- "base_volume": float(data.get("volume") or data.get("vol") or 0),
1170
- "quote_volume": float(data.get("quoteVolume") or data.get("volValue") or 0),
1171
- "change_percent": float(data.get("priceChangePercent") or
1172
- (float(data.get("changeRate", 0)) * 100) or 0),
1173
- "high": float(data.get("highPrice") or data.get("high") or 0),
1174
- "low": float(data.get("lowPrice") or data.get("low") or 0),
1175
- **_quality_payload(
1176
- source=src, source_chain=source_chain, errors=errors,
1177
- market_type="spot", is_live=True, is_tradable=False,
1178
- data_quality="partial" if errors else "live",
1179
- ),
1180
- }
1181
-
1182
- # Binance Vision 24hr
1183
- source_chain.append("binance_vision_24hr")
1184
- data, err, _ = await _fetch_json(
1185
- "binance_vision_24hr", "https://data-api.binance.vision/api/v3/ticker/24hr",
1186
- {"symbol": sym["binance"]},
1187
- )
1188
- if err:
1189
- errors.append(f"binance_vision_24hr: {err}")
1190
- elif isinstance(data, dict) and data.get("lastPrice") is not None:
1191
- payload = _parse_24hr(data, "binance_vision_24hr")
1192
- _cache_set(cache_key, payload, CACHE_TTL_VOLUME)
1193
- return payload
1194
-
1195
- # KuCoin
1196
- source_chain.append("kucoin_public_stats")
1197
- data, err, _ = await _fetch_json(
1198
- "kucoin_public_stats", "https://api.kucoin.com/api/v1/market/stats",
1199
- {"symbol": sym["kucoin"]},
1200
- )
1201
- if err:
1202
- errors.append(f"kucoin_public_stats: {err}")
1203
- elif isinstance(data, dict):
1204
- stats = data.get("data") or {}
1205
- if stats.get("last") is not None:
1206
- payload = _parse_24hr(stats, "kucoin_public_stats")
1207
- _cache_set(cache_key, payload, CACHE_TTL_VOLUME)
1208
- return payload
1209
-
1210
- # OKX
1211
- source_chain.append("okx_ticker")
1212
- data, err, _ = await _fetch_json(
1213
- "okx_ticker", "https://www.okx.com/api/v5/market/ticker",
1214
- {"instId": sym["okx_spot"]},
1215
- )
1216
- if err:
1217
- errors.append(f"okx_ticker: {err}")
1218
- elif isinstance(data, dict):
1219
- items = data.get("data") or []
1220
- if items and items[0].get("last"):
1221
- t = items[0]
1222
- payload = {
1223
- "success": True, "symbol": sym["binance"],
1224
- "price": float(t.get("last", 0)), "volume": float(t.get("vol24h", 0)),
1225
- "base_volume": float(t.get("vol24h", 0)), "quote_volume": float(t.get("volCcy24h", 0)),
1226
- "change_percent": 0.0, "high": float(t.get("high24h", 0)), "low": float(t.get("low24h", 0)),
1227
- **_quality_payload(
1228
- source="okx_ticker", source_chain=source_chain, errors=errors,
1229
- market_type="spot", is_live=True, is_tradable=False,
1230
- data_quality="partial" if errors else "live",
1231
- ),
1232
- }
1233
- _cache_set(cache_key, payload, CACHE_TTL_VOLUME)
1234
- return payload
1235
-
1236
- return {
1237
- "success": False, "symbol": sym["binance"], "price": None, "volume": None,
1238
- **_quality_payload(
1239
- source="unavailable", source_chain=source_chain, errors=errors,
1240
- market_type="unknown", is_live=False, is_tradable=False, data_quality="unavailable",
1241
- ),
1242
- }
1243
-
1244
-
1245
- # ── Funding Rate ───────────────────────────────
1246
-
1247
- @router.get("/api/hub/funding")
1248
- @router.get("/api/enterprise/funding")
1249
- async def enterprise_funding(symbol: str = Query("BTC")) -> Dict[str, Any]:
1250
- """
1251
- Perpetual contract funding rate.
1252
- Source chain: Binance Futures → OKX Swap → ByBit Linear.
1253
- """
1254
- _bump_counter("enterprise_funding")
1255
- sym = _normalize_symbol(symbol)
1256
- cache_key = f"funding:{sym['base']}"
1257
- cached = _cache_get(cache_key)
1258
- if cached is not None:
1259
- return {**cached, "cached": True}
1260
-
1261
- errors: List[str] = []
1262
- source_chain = ["binance_futures", "okx_futures", "bybit_futures"]
1263
-
1264
- results = await asyncio.gather(
1265
- _funding_from_binance(sym),
1266
- _funding_from_okx(sym),
1267
- _funding_from_bybit(sym),
1268
- return_exceptions=True,
1269
- )
1270
-
1271
- for r in results:
1272
- if isinstance(r, Exception):
1273
- errors.append(str(r))
1274
- continue
1275
- if r and r.get("fundingRate") is not None:
1276
- payload = {
1277
- "success": True,
1278
- "symbol": sym["binance"],
1279
- "fundingRate": r["fundingRate"],
1280
- "fundingRatePct": round(r["fundingRate"] * 100, 6),
1281
- "nextFundingTime": r.get("nextFundingTime"),
1282
- "markPrice": r.get("markPrice"),
1283
- **_quality_payload(
1284
- source=r["source"], source_chain=source_chain, errors=errors,
1285
- market_type="perpetual", is_live=True, is_tradable=False,
1286
- data_quality="partial" if errors else "live",
1287
- ),
1288
- }
1289
- _cache_set(cache_key, payload, CACHE_TTL_FUNDING)
1290
- return payload
1291
-
1292
- return {
1293
- "success": False, "symbol": sym["binance"],
1294
- "fundingRate": None, "fundingRatePct": None,
1295
- **_quality_payload(
1296
- source="unavailable", source_chain=source_chain, errors=errors,
1297
- market_type="perpetual", is_live=False, is_tradable=False, data_quality="unavailable",
1298
- ),
1299
- }
1300
-
1301
-
1302
- # ── Open Interest ──────────────────────────────
1303
-
1304
- @router.get("/api/hub/open-interest")
1305
- @router.get("/api/enterprise/open-interest")
1306
- async def enterprise_open_interest(symbol: str = Query("BTC")) -> Dict[str, Any]:
1307
- """
1308
- Perpetual contract open interest.
1309
- Source chain: Binance Futures → OKX → ByBit.
1310
- """
1311
- _bump_counter("enterprise_open_interest")
1312
- sym = _normalize_symbol(symbol)
1313
- cache_key = f"oi:{sym['base']}"
1314
- cached = _cache_get(cache_key)
1315
- if cached is not None:
1316
- return {**cached, "cached": True}
1317
-
1318
- errors: List[str] = []
1319
- source_chain = ["binance_futures", "okx_futures", "bybit_futures"]
1320
-
1321
- results = await asyncio.gather(
1322
- _oi_from_binance(sym),
1323
- _oi_from_okx(sym),
1324
- _oi_from_bybit(sym),
1325
- return_exceptions=True,
1326
- )
1327
-
1328
- for r in results:
1329
- if isinstance(r, Exception):
1330
- errors.append(str(r))
1331
- continue
1332
- if r and r.get("openInterest") is not None:
1333
- payload = {
1334
- "success": True,
1335
- "symbol": sym["binance"],
1336
- "openInterest": r["openInterest"],
1337
- **_quality_payload(
1338
- source=r["source"], source_chain=source_chain, errors=errors,
1339
- market_type="perpetual", is_live=True, is_tradable=False,
1340
- data_quality="partial" if errors else "live",
1341
- ),
1342
- }
1343
- _cache_set(cache_key, payload, CACHE_TTL_OI)
1344
- return payload
1345
-
1346
- return {
1347
- "success": False, "symbol": sym["binance"], "openInterest": None,
1348
- **_quality_payload(
1349
- source="unavailable", source_chain=source_chain, errors=errors,
1350
- market_type="perpetual", is_live=False, is_tradable=False, data_quality="unavailable",
1351
- ),
1352
- }
1353
-
1354
-
1355
- # ── News ───────────────────────────────────────
1356
-
1357
- @router.get("/api/hub/news/{coin}")
1358
- @router.get("/api/enterprise/news/{coin}")
1359
- async def enterprise_news(coin: str, limit: int = Query(5)) -> Dict[str, Any]:
1360
- _bump_counter("enterprise_news")
1361
- errors: List[str] = []
1362
- source_chain: List[str] = []
1363
- base = _normalize_symbol(coin)["base"].lower()
1364
- limit = max(1, min(int(limit or 5), 50))
1365
-
1366
- cache_key = f"news:{base}:{limit}"
1367
- cached = _cache_get(cache_key)
1368
- if cached is not None:
1369
- return {**cached, "cached": True}
1370
-
1371
- source_chain.append("v4_source")
1372
- v4, err = await _fetch_v4(f"/api/news/{base}", {"limit": limit})
1373
- if err:
1374
- errors.append(f"v4_source: {err}")
1375
- elif isinstance(v4, dict):
1376
- news = v4.get("news") or v4.get("data") or []
1377
- if news:
1378
- payload = {
1379
- "success": True, "coin": base.upper(),
1380
- "news": news[:limit], "data": news[:limit], "count": len(news[:limit]),
1381
- **_quality_payload(
1382
- source="v4_source", source_chain=source_chain, errors=errors,
1383
- market_type="none", is_live=True, is_tradable=False,
1384
- data_quality="live" if not errors else "partial",
1385
- ),
1386
- }
1387
- _cache_set(cache_key, payload, CACHE_TTL_NEWS)
1388
- return payload
1389
-
1390
- source_chain.append("render_crypto_dt_source")
1391
- data, err, _ = await _fetch_json(
1392
- "render_crypto_dt_source", f"{CRYPTO_DT_SOURCE_URL}/api/v1/rss/feed",
1393
- {"feed_name": "coindesk", "limit": limit},
1394
- )
1395
- if err:
1396
- errors.append(f"render_crypto_dt_source: {err}")
1397
- elif isinstance(data, dict):
1398
- raw_items = data.get("items") or data.get("data") or data.get("news") or []
1399
- if isinstance(raw_items, list) and raw_items:
1400
- payload = {
1401
- "success": True, "coin": base.upper(),
1402
- "news": raw_items[:limit], "data": raw_items[:limit], "count": len(raw_items[:limit]),
1403
- **_quality_payload(
1404
- source="render_crypto_dt_source", source_chain=source_chain, errors=errors,
1405
- market_type="none", is_live=True, is_tradable=False,
1406
- data_quality="partial" if errors else "live",
1407
- ),
1408
- }
1409
- _cache_set(cache_key, payload, CACHE_TTL_NEWS)
1410
- return payload
1411
-
1412
- return {
1413
- "success": True, "coin": base.upper(), "news": [], "data": [], "count": 0,
1414
- **_quality_payload(
1415
- source="empty_nonfatal_fallback", source_chain=source_chain, errors=errors,
1416
- market_type="none", is_live=False, is_tradable=False, data_quality="empty",
1417
- ),
1418
- }
1419
-
1420
-
1421
- # ── Sentiment ──────────────────────────────────
1422
-
1423
- @router.get("/api/hub/sentiment")
1424
- @router.get("/api/enterprise/sentiment")
1425
- async def enterprise_sentiment(
1426
- coin: str = Query("BTC"),
1427
- timeframe: str = Query("24h"),
1428
- ) -> Dict[str, Any]:
1429
- _bump_counter("enterprise_sentiment")
1430
- errors: List[str] = []
1431
- source_chain: List[str] = []
1432
- base = _normalize_symbol(coin)["base"]
1433
-
1434
- cache_key = f"sentiment:{base}:{timeframe}"
1435
- cached = _cache_get(cache_key)
1436
- if cached is not None:
1437
- return {**cached, "cached": True}
1438
-
1439
- # V4
1440
- source_chain.append("v4_source")
1441
- v4, err = await _fetch_v4("/api/social/sentiment", {"coin": base, "timeframe": timeframe})
1442
- if err:
1443
- errors.append(f"v4_source: {err}")
1444
- elif isinstance(v4, dict) and v4.get("success"):
1445
- payload = {
1446
- **v4,
1447
- **_quality_payload(
1448
- source="v4_source", source_chain=source_chain, errors=errors,
1449
- market_type="none", is_live=True, is_tradable=False,
1450
- data_quality="live" if not errors else "partial",
1451
- ),
1452
- }
1453
- _cache_set(cache_key, payload, CACHE_TTL_SENTIMENT)
1454
- return payload
1455
-
1456
- # Alternative.me Fear & Greed
1457
- source_chain.append("alternative_me_fear_greed")
1458
- data, err, _ = await _fetch_json(
1459
- "alternative_me_fear_greed", "https://api.alternative.me/fng/",
1460
- {"limit": 1, "format": "json"},
1461
- )
1462
- if err:
1463
- errors.append(f"alternative_me_fear_greed: {err}")
1464
- if isinstance(data, dict) and data.get("data"):
1465
- item = data["data"][0]
1466
- value = float(item.get("value", 50))
1467
- score = round((value - 50.0) / 50.0, 4)
1468
- label = item.get("value_classification", "Neutral")
1469
- payload = {
1470
- "success": True, "coin": base, "asset": base, "timeframe": timeframe,
1471
- "sentiment_score": score, "score": score,
1472
- "fear_greed_index": int(value), "label": label, "sentiment": label.lower(),
1473
- **_quality_payload(
1474
- source="alternative_me_fear_greed", source_chain=source_chain, errors=errors,
1475
- market_type="none", is_live=True, is_tradable=False,
1476
- data_quality="partial" if errors else "live",
1477
- ),
1478
- }
1479
- _cache_set(cache_key, payload, CACHE_TTL_SENTIMENT)
1480
- return payload
1481
-
1482
- # CoinGecko as final fallback (more stable, rate-limited but public)
1483
- source_chain.append("coingecko_sentiment")
1484
- cg_id_map = {"BTC": "bitcoin", "ETH": "ethereum", "SOL": "solana", "BNB": "binancecoin"}
1485
- cg_id = cg_id_map.get(base, base.lower())
1486
- data, err, _ = await _fetch_json(
1487
- "coingecko_sentiment",
1488
- f"https://api.coingecko.com/api/v3/coins/{cg_id}",
1489
- {"localization": "false", "tickers": "false", "market_data": "false",
1490
- "community_data": "true", "developer_data": "false"},
1491
- )
1492
- if err:
1493
- errors.append(f"coingecko_sentiment: {err}")
1494
- elif isinstance(data, dict):
1495
- sent_up = data.get("sentiment_votes_up_percentage") or 50.0
1496
- score = round((float(sent_up) - 50.0) / 50.0, 4)
1497
- label = "Bullish" if score > 0.1 else ("Bearish" if score < -0.1 else "Neutral")
1498
- payload = {
1499
- "success": True, "coin": base, "asset": base, "timeframe": timeframe,
1500
- "sentiment_score": score, "score": score,
1501
- "sentiment_votes_up_pct": float(sent_up),
1502
- "label": label, "sentiment": label.lower(),
1503
- **_quality_payload(
1504
- source="coingecko_sentiment", source_chain=source_chain, errors=errors,
1505
- market_type="none", is_live=True, is_tradable=False,
1506
- data_quality="partial" if errors else "live",
1507
- ),
1508
- }
1509
- _cache_set(cache_key, payload, CACHE_TTL_SENTIMENT)
1510
- return payload
1511
-
1512
- return {
1513
- "success": False, "coin": base, "asset": base, "timeframe": timeframe,
1514
- "sentiment_score": None,
1515
- **_quality_payload(
1516
- source="unavailable", source_chain=source_chain, errors=errors,
1517
- market_type="none", is_live=False, is_tradable=False, data_quality="unavailable",
1518
- ),
1519
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
enterprise_data_hub_v3_data_brain_v4.py DELETED
@@ -1,1990 +0,0 @@
1
- """
2
- Enterprise Data Brain v4.0 — Autonomous V4 Supplier + Enterprise Hub
3
- Central, source-agnostic data mesh layer for HuggingFace crypto Spaces.
4
-
5
- Upgrade summary (v3.1 → v3.2):
6
- ──────────────────────────────────────────────────────────────────────
7
- 11. Peer Bridge Integration – Bidirectional data exchange with V4.
8
- After every successful external fetch,
9
- data is published to peer_bridge so V4
10
- can pull it, and vice versa.
11
- peer_bridge.peer_data() is checked as
12
- an additional fallback source.
13
- 12. /api/hub/peer-status – Live view of peer connection health.
14
- 13. /api/hub/peer-push – Manual trigger: push all cached data to V4.
15
-
16
- Upgrade summary (v3 → v3.1):
17
- ──────────────────────────────────────────────────────────────────────
18
- 1. TTL In-Memory Cache – Per-endpoint configurable TTLs.
19
- 2. Circuit Breaker – Skip failing providers automatically.
20
- 3. Parallel Source Fetching – asyncio.gather; first success wins.
21
- 4. New Providers – OKX public API, ByBit public API.
22
- 5. /api/hub/ticker – Fast single-price lookup.
23
- 6. /api/hub/multi-ticker – Batch price fetch (up to 20 symbols).
24
- 7. /api/hub/funding – Perpetual funding rates.
25
- 8. /api/hub/open-interest – Open interest.
26
- 9. CoinGecko sentiment fb – Stable final fallback for sentiment.
27
- 10. Richer source contracts – Lists all endpoints + providers.
28
-
29
- Design principles (unchanged):
30
- - V2 is the single source of truth / hub.
31
- - V4 and public providers are sources, not hard dependencies.
32
- - Every response normalized with source, sourceChain, dataQuality,
33
- isLive, isTradable, marketType, errors.
34
- - No secrets hard-coded; optional keys from env only.
35
- - executionAllowed is ALWAYS False.
36
- ──────────────────────────────────────────────────────────────────────
37
- """
38
- from __future__ import annotations
39
-
40
- import os
41
- import time
42
- import asyncio
43
- import json
44
- import hashlib
45
- from datetime import datetime, timezone
46
- from typing import Any, Dict, List, Optional, Tuple
47
-
48
- import httpx
49
- from fastapi import APIRouter, Query, Body
50
-
51
- router = APIRouter(tags=["Enterprise Data Brain v4.0"])
52
-
53
- # ──────────────────────────────────────────────
54
- # Configuration (all overridable via env)
55
- # ──────────────────────────────────────────────
56
- V4_BASE_URL = os.getenv(
57
- "V4_SOURCE_BASE_URL",
58
- "https://really-amin-datasourceforcryptocurrency-4.hf.space",
59
- ).rstrip("/")
60
- V2_HUB_BASE_URL = os.getenv(
61
- "V2_HUB_BASE_URL",
62
- "https://really-amin-datasourceforcryptocurrency-2.hf.space",
63
- ).rstrip("/")
64
- DATA_BRAIN_ROLE = os.getenv("DATA_BRAIN_ROLE", "v4_supplier").strip().lower()
65
- DATA_BRAIN_ENABLE_PEER_PUSH = os.getenv("DATA_BRAIN_ENABLE_PEER_PUSH", "false").lower() == "true"
66
- DATA_BRAIN_SCHEMA_VERSION = os.getenv("DATA_BRAIN_SCHEMA_VERSION", "enterprise-data-brain-v4.0")
67
- CRYPTO_DT_SOURCE_URL = os.getenv(
68
- "CRYPTO_DT_SOURCE_URL",
69
- "https://crypto-dt-source.onrender.com",
70
- ).rstrip("/")
71
- HF_OHLC_DATASET_ID = os.getenv("HF_OHLC_DATASET_ID", "Really-amin/crypto-ohlc-data")
72
- REQUEST_TIMEOUT = float(os.getenv("ENTERPRISE_HUB_TIMEOUT", "8"))
73
- MAX_ERROR_SAMPLES = int(os.getenv("ENTERPRISE_HUB_MAX_ERROR_SAMPLES", "40"))
74
-
75
- # Cache TTLs (seconds)
76
- CACHE_TTL_OHLCV = int(os.getenv("CACHE_TTL_OHLCV", "30"))
77
- CACHE_TTL_TICKER = int(os.getenv("CACHE_TTL_TICKER", "10"))
78
- CACHE_TTL_ORDERBOOK = int(os.getenv("CACHE_TTL_ORDERBOOK", "5"))
79
- CACHE_TTL_VOLUME = int(os.getenv("CACHE_TTL_VOLUME", "20"))
80
- CACHE_TTL_FUNDING = int(os.getenv("CACHE_TTL_FUNDING", "60"))
81
- CACHE_TTL_OI = int(os.getenv("CACHE_TTL_OI", "30"))
82
- CACHE_TTL_SENTIMENT = int(os.getenv("CACHE_TTL_SENTIMENT", "300"))
83
- CACHE_TTL_NEWS = int(os.getenv("CACHE_TTL_NEWS", "120"))
84
-
85
- # Circuit breaker: consecutive failures before skipping, and cool-down (s)
86
- CB_THRESHOLD = int(os.getenv("CB_FAILURE_THRESHOLD", "4"))
87
- CB_COOLDOWN = float(os.getenv("CB_COOLDOWN_SEC", "120"))
88
-
89
- # ──────────────────────────────────────────────
90
- # Runtime state (process-local, intentional)
91
- # ──────────────────────────────────────────────
92
- _PROVIDER_HEALTH: Dict[str, Dict[str, Any]] = {}
93
- _REQUEST_COUNTERS: Dict[str, int] = {}
94
- _CACHE: Dict[str, Tuple[float, Any]] = {} # key → (expiry_ts, payload)
95
- _CB_STATE: Dict[str, Dict[str, Any]] = {} # provider → {failures, tripped_at}
96
-
97
- # Data Brain runtime memory. Process-local by design for HF Spaces.
98
- _DATA_BRAIN_STATE: Dict[str, Any] = {
99
- "schemaVersion": DATA_BRAIN_SCHEMA_VERSION,
100
- "role": DATA_BRAIN_ROLE,
101
- "sourceScores": {},
102
- "routeHistory": [],
103
- "decisionHistory": [],
104
- "failurePatterns": {},
105
- "anomalySamples": [],
106
- "peerEvents": [],
107
- "lastGoodByKey": {},
108
- }
109
- _PEER_CACHE: Dict[str, Any] = {}
110
- _PEER_QUEUE: List[Dict[str, Any]] = []
111
-
112
-
113
- # ──────────────────────────────────────────────
114
- # Timeframe maps
115
- # ──────────────────────────────────────────────
116
- TIMEFRAME_MAP_BINANCE = {
117
- "1m": "1m", "3m": "3m", "5m": "5m", "15m": "15m", "30m": "30m",
118
- "1h": "1h", "2h": "2h", "4h": "4h", "6h": "6h", "8h": "8h", "12h": "12h",
119
- "1d": "1d", "3d": "3d", "1w": "1w",
120
- }
121
- TIMEFRAME_MAP_KUCOIN = {
122
- "1m": "1min", "3m": "3min", "5m": "5min", "15m": "15min", "30m": "30min",
123
- "1h": "1hour", "2h": "2hour", "4h": "4hour", "6h": "6hour", "8h": "8hour",
124
- "12h": "12hour", "1d": "1day", "1w": "1week",
125
- }
126
- TIMEFRAME_MAP_OKX = {
127
- "1m": "1m", "3m": "3m", "5m": "5m", "15m": "15m", "30m": "30m",
128
- "1h": "1H", "2h": "2H", "4h": "4H", "6h": "6H", "12h": "12H",
129
- "1d": "1D", "3d": "3D", "1w": "1W",
130
- }
131
- TIMEFRAME_MAP_BYBIT = {
132
- "1m": "1", "3m": "3", "5m": "5", "15m": "15", "30m": "30",
133
- "1h": "60", "2h": "120", "4h": "240", "6h": "360", "12h": "720",
134
- "1d": "D", "1w": "W",
135
- }
136
-
137
-
138
- # ──────────────────────────────────────────────
139
- # Helpers
140
- # ──────────────────────────────────────────────
141
-
142
- def _now_iso() -> str:
143
- return datetime.now(timezone.utc).isoformat()
144
-
145
-
146
- def _now_ts() -> float:
147
- return time.monotonic()
148
-
149
-
150
- def _bump_counter(name: str) -> None:
151
- _REQUEST_COUNTERS[name] = _REQUEST_COUNTERS.get(name, 0) + 1
152
-
153
-
154
- # ── Cache ──────────────────────────────────────
155
-
156
- def _cache_get(key: str) -> Optional[Any]:
157
- entry = _CACHE.get(key)
158
- if entry and _now_ts() < entry[0]:
159
- return entry[1]
160
- return None
161
-
162
-
163
- def _cache_set(key: str, value: Any, ttl: float) -> None:
164
- _CACHE[key] = (_now_ts() + ttl, value)
165
- try:
166
- _brain_observe_cache_write(key, value, ttl)
167
- except Exception:
168
- pass
169
-
170
-
171
- # ── Circuit Breaker ────────────────────────────
172
-
173
- def _cb_is_open(provider: str) -> bool:
174
- """Returns True if provider is currently circuit-broken (should be skipped)."""
175
- state = _CB_STATE.get(provider)
176
- if not state:
177
- return False
178
- if state["failures"] >= CB_THRESHOLD:
179
- if (_now_ts() - state["tripped_at"]) < CB_COOLDOWN:
180
- return True
181
- # Cool-down elapsed → half-open: reset
182
- _CB_STATE[provider] = {"failures": 0, "tripped_at": 0}
183
- return False
184
-
185
-
186
- def _cb_record(provider: str, ok: bool) -> None:
187
- state = _CB_STATE.setdefault(provider, {"failures": 0, "tripped_at": 0})
188
- if ok:
189
- state["failures"] = 0
190
- else:
191
- state["failures"] += 1
192
- if state["failures"] >= CB_THRESHOLD:
193
- state["tripped_at"] = _now_ts()
194
-
195
-
196
- # ── Provider Health ────────────────────────────
197
-
198
- def _record_provider(provider: str, ok: bool, latency_ms: float, error: Optional[str] = None) -> None:
199
- entry = _PROVIDER_HEALTH.setdefault(provider, {
200
- "provider": provider, "status": "unknown",
201
- "successes": 0, "failures": 0,
202
- "latencySamples": [], "errorSamples": [],
203
- "lastSuccess": None, "lastError": None, "lastChecked": None,
204
- })
205
- entry["lastChecked"] = _now_iso()
206
- samples = entry.setdefault("latencySamples", [])
207
- samples.append(round(float(latency_ms), 2))
208
- del samples[:-30]
209
- if ok:
210
- entry["successes"] += 1
211
- entry["lastSuccess"] = _now_iso()
212
- entry["status"] = "healthy" if entry["failures"] == 0 else "degraded"
213
- else:
214
- entry["failures"] += 1
215
- entry["lastError"] = _now_iso()
216
- entry["status"] = "degraded" if entry["successes"] else "unavailable"
217
- errors = entry.setdefault("errorSamples", [])
218
- if error:
219
- errors.append(str(error)[:300])
220
- del errors[:-MAX_ERROR_SAMPLES]
221
- _cb_record(provider, ok)
222
- try:
223
- _brain_update_provider_score(provider, ok, latency_ms, error)
224
- except Exception:
225
- pass
226
-
227
-
228
- def _latency_avg(provider: str) -> Optional[float]:
229
- samples = _PROVIDER_HEALTH.get(provider, {}).get("latencySamples", [])
230
- if not samples:
231
- return None
232
- return round(sum(samples) / len(samples), 2)
233
-
234
-
235
- # ── Symbol normalisation ───────────────────────
236
-
237
- def _normalize_symbol(symbol: str) -> Dict[str, str]:
238
- raw = (symbol or "BTC").strip().upper().replace("_", "-")
239
- if raw in ("XBT", "XBTUSDT", "XBT-USDT"):
240
- raw = "BTC"
241
- for suffix in ("-USDT", "USDT", "-USD", "USD", "-BUSD", "BUSD"):
242
- if raw.endswith(suffix) and len(raw) > len(suffix):
243
- raw = raw[: -len(suffix)]
244
- break
245
- raw = raw.replace("-", "")
246
- if raw == "XBT":
247
- raw = "BTC"
248
- base = raw
249
- return {
250
- "input": symbol,
251
- "base": base,
252
- "binance": f"{base}USDT",
253
- "binance_perp": f"{base}USDT", # same for Binance perp
254
- "kucoin": f"{base}-USDT",
255
- "okx_spot": f"{base}-USDT",
256
- "okx_swap": f"{base}-USDT-SWAP",
257
- "bybit_spot": f"{base}USDT",
258
- "bybit_perp": f"{base}USDT",
259
- "cc_base": base,
260
- }
261
-
262
-
263
- # ── Quality payload ────────────────────────────
264
-
265
- def _quality_payload(
266
- *, source: str, source_chain: List[str], errors: List[str],
267
- market_type: str = "spot", is_live: bool = True,
268
- is_tradable: bool = False, data_quality: str = "partial",
269
- ) -> Dict[str, Any]:
270
- return {
271
- "source": source,
272
- "sourceChain": source_chain,
273
- "marketType": market_type,
274
- "isLive": is_live,
275
- "isTradable": is_tradable,
276
- "executionAllowed": False, # always
277
- "dataQuality": data_quality,
278
- "errors": errors,
279
- "timestamp": _now_iso(),
280
- }
281
-
282
-
283
-
284
- # ── Data Brain helpers ─────────────────────────
285
-
286
- def _stable_hash(value: Any) -> str:
287
- """Small deterministic hash for schema/shape diagnostics."""
288
- try:
289
- raw = json.dumps(value, sort_keys=True, default=str)[:12000]
290
- except Exception:
291
- raw = str(value)[:12000]
292
- return hashlib.sha256(raw.encode("utf-8", errors="ignore")).hexdigest()[:16]
293
-
294
-
295
- def _brain_provider_score(provider: str) -> float:
296
- item = _DATA_BRAIN_STATE["sourceScores"].get(provider) or {}
297
- return float(item.get("score", 1.0))
298
-
299
-
300
- def _brain_update_provider_score(provider: str, success: bool, latency_ms: float = 0.0, error: Optional[str] = None) -> None:
301
- """
302
- Adaptive provider scoring.
303
- - Rewards stable, low-latency providers.
304
- - Penalizes HTTP 451/401/429/5xx and repeated provider failures.
305
- """
306
- scores = _DATA_BRAIN_STATE.setdefault("sourceScores", {})
307
- item = scores.setdefault(provider, {
308
- "score": 1.0,
309
- "successes": 0,
310
- "failures": 0,
311
- "lastLatencyMs": None,
312
- "lastError": None,
313
- "lastUpdate": None,
314
- })
315
- item["lastLatencyMs"] = round(float(latency_ms or 0), 2)
316
- item["lastUpdate"] = _now_iso()
317
- if success:
318
- item["successes"] += 1
319
- # Low-latency wins gently; do not overfit one fast call.
320
- latency_bonus = 0.03 if latency_ms and latency_ms < 700 else 0.01
321
- item["score"] = min(2.5, float(item.get("score", 1.0)) + latency_bonus)
322
- item["lastError"] = None
323
- else:
324
- item["failures"] += 1
325
- item["lastError"] = str(error)[:300] if error else None
326
- penalty = 0.08
327
- err = str(error or "")
328
- if "451" in err:
329
- penalty = 0.20
330
- elif "401" in err or "403" in err:
331
- penalty = 0.18
332
- elif "429" in err:
333
- penalty = 0.14
334
- elif "500" in err or "502" in err or "503" in err:
335
- penalty = 0.12
336
- item["score"] = max(0.05, float(item.get("score", 1.0)) - penalty)
337
- failures = _DATA_BRAIN_STATE.setdefault("failurePatterns", {})
338
- failures[provider] = failures.get(provider, 0) + 1
339
-
340
-
341
- def _brain_quality_score(payload: Any) -> float:
342
- """Estimate payload usefulness without pretending it is tradable."""
343
- if not isinstance(payload, dict):
344
- return 0.0
345
- if payload.get("success") is False:
346
- return 0.05
347
- score = 0.35
348
- for key in ("candles", "bids", "asks", "news", "data"):
349
- val = payload.get(key)
350
- if isinstance(val, list) and val:
351
- score += min(0.35, len(val) / 300)
352
- if payload.get("price") or payload.get("volume") or payload.get("sentiment_score") is not None:
353
- score += 0.20
354
- if payload.get("errors"):
355
- score -= min(0.25, 0.04 * len(payload.get("errors") or []))
356
- if payload.get("dataQuality") == "live":
357
- score += 0.15
358
- elif payload.get("dataQuality") == "empty":
359
- score -= 0.30
360
- elif payload.get("dataQuality") == "unavailable":
361
- score -= 0.50
362
- return round(max(0.0, min(1.0, score)), 4)
363
-
364
-
365
- def _brain_observe_cache_write(key: str, payload: Any, ttl: float) -> None:
366
- """Every successful hub write updates last-good memory and peer queue."""
367
- confidence = _brain_quality_score(payload)
368
- record = {
369
- "key": key,
370
- "confidence": confidence,
371
- "ttl": ttl,
372
- "schemaHash": _stable_hash(payload),
373
- "timestamp": _now_iso(),
374
- "payload": payload,
375
- }
376
- _DATA_BRAIN_STATE.setdefault("lastGoodByKey", {})[key] = record
377
- # Keep route history bounded.
378
- history = _DATA_BRAIN_STATE.setdefault("routeHistory", [])
379
- history.append({k: v for k, v in record.items() if k != "payload"})
380
- del history[:-300]
381
- # Peer cache: V2/V4 can pull recent normalized payloads.
382
- _PEER_CACHE[key] = {k: v for k, v in record.items() if k != "payload"} | {"payload": payload}
383
- # Peer push is optional to avoid write loops between V2 and V4.
384
- if DATA_BRAIN_ENABLE_PEER_PUSH:
385
- _PEER_QUEUE.append({k: v for k, v in record.items() if k != "payload"})
386
- del _PEER_QUEUE[:-200]
387
-
388
-
389
- def _brain_decision(route: str, candidates: List[Dict[str, Any]], errors: Optional[List[str]] = None) -> Dict[str, Any]:
390
- """
391
- Decide which candidate should win based on provider score + payload quality.
392
- Candidate shape: {name, payload, latencyMs?}
393
- """
394
- ranked: List[Dict[str, Any]] = []
395
- for c in candidates:
396
- payload = c.get("payload")
397
- if not payload:
398
- continue
399
- name = str(c.get("name") or payload.get("source") or "unknown")
400
- provider_score = _brain_provider_score(name)
401
- payload_score = _brain_quality_score(payload)
402
- latency = float(c.get("latencyMs") or 0.0)
403
- latency_penalty = min(0.25, latency / 10000) if latency else 0.0
404
- total = round((provider_score * 0.35) + (payload_score * 0.75) - latency_penalty, 4)
405
- ranked.append({"name": name, "score": total, "payloadScore": payload_score, "providerScore": provider_score})
406
- ranked.sort(key=lambda x: x["score"], reverse=True)
407
- decision = {
408
- "route": route,
409
- "winner": ranked[0]["name"] if ranked else None,
410
- "ranked": ranked[:8],
411
- "errors": (errors or [])[-12:],
412
- "timestamp": _now_iso(),
413
- }
414
- decisions = _DATA_BRAIN_STATE.setdefault("decisionHistory", [])
415
- decisions.append(decision)
416
- del decisions[:-300]
417
- return decision
418
-
419
-
420
- def _brain_enrich(payload: Dict[str, Any], route: str, decision: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
421
- """Attach Data Brain fields while preserving the original response schema."""
422
- if not isinstance(payload, dict):
423
- return payload
424
- out = dict(payload)
425
- out.setdefault("executionAllowed", False)
426
- out["executionAllowed"] = False
427
- out.setdefault("isTradable", False)
428
- out.setdefault("brain", {})
429
- out["brain"].update({
430
- "version": DATA_BRAIN_SCHEMA_VERSION,
431
- "role": DATA_BRAIN_ROLE,
432
- "route": route,
433
- "confidence": _brain_quality_score(out),
434
- "decision": decision or {},
435
- "schemaHash": _stable_hash(out),
436
- "timestamp": _now_iso(),
437
- })
438
- return out
439
-
440
-
441
- # ── HTTP helpers ───────────────────────────────
442
-
443
- async def _fetch_json(
444
- provider: str, url: str,
445
- params: Optional[Dict[str, Any]] = None,
446
- headers: Optional[Dict[str, str]] = None,
447
- ) -> Tuple[Optional[Any], Optional[str], float]:
448
- if _cb_is_open(provider):
449
- return None, f"circuit_open:{provider}", 0.0
450
- start = time.perf_counter()
451
- try:
452
- async with httpx.AsyncClient(
453
- timeout=REQUEST_TIMEOUT,
454
- headers=headers or {"User-Agent": "enterprise-data-hub-v3.1"},
455
- ) as client:
456
- resp = await client.get(url, params=params)
457
- latency_ms = (time.perf_counter() - start) * 1000
458
- if resp.status_code >= 400:
459
- err = f"HTTP {resp.status_code} from {url}"
460
- _record_provider(provider, False, latency_ms, err)
461
- return None, err, latency_ms
462
- data = resp.json()
463
- _record_provider(provider, True, latency_ms)
464
- return data, None, latency_ms
465
- except Exception as exc: # noqa: BLE001
466
- latency_ms = (time.perf_counter() - start) * 1000
467
- err = f"{type(exc).__name__}: {exc}"
468
- _record_provider(provider, False, latency_ms, err)
469
- return None, err, latency_ms
470
-
471
-
472
- async def _fetch_v4(
473
- path: str, params: Optional[Dict[str, Any]] = None,
474
- ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
475
- data, err, _ = await _fetch_json("v4_source", f"{V4_BASE_URL}{path}", params=params)
476
- if data is None:
477
- return None, err
478
- if not isinstance(data, dict):
479
- return None, "v4_source returned non-object JSON"
480
- return data, None
481
-
482
-
483
- # ──────────────────────────────────────────────
484
- # Candle normalisation
485
- # ──────────────────────────────────────────────
486
-
487
- def _norm_candles_binance(rows: List[Any]) -> List[Dict[str, Any]]:
488
- out = []
489
- for row in rows or []:
490
- try:
491
- open_time = int(row[0])
492
- out.append({
493
- "timestamp": open_time, "open_time": open_time,
494
- "open": float(row[1]), "high": float(row[2]),
495
- "low": float(row[3]), "close": float(row[4]),
496
- "volume": float(row[5]),
497
- "close_time": int(row[6]) if len(row) > 6 else None,
498
- })
499
- except Exception:
500
- continue
501
- return out
502
-
503
-
504
- def _norm_candles_kucoin(rows: List[Any]) -> List[Dict[str, Any]]:
505
- out = []
506
- for row in rows or []:
507
- try:
508
- ts_ms = int(float(row[0])) * 1000
509
- out.append({
510
- "timestamp": ts_ms, "open_time": ts_ms,
511
- "open": float(row[1]), "high": float(row[3]),
512
- "low": float(row[4]), "close": float(row[2]),
513
- "volume": float(row[5]),
514
- "turnover": float(row[6]) if len(row) > 6 else None,
515
- })
516
- except Exception:
517
- continue
518
- return sorted(out, key=lambda x: x["timestamp"])
519
-
520
-
521
- def _norm_candles_okx(rows: List[Any]) -> List[Dict[str, Any]]:
522
- # OKX: [ts, open, high, low, close, vol, volCcy, volCcyQuote, confirm]
523
- out = []
524
- for row in rows or []:
525
- try:
526
- ts_ms = int(row[0])
527
- out.append({
528
- "timestamp": ts_ms, "open_time": ts_ms,
529
- "open": float(row[1]), "high": float(row[2]),
530
- "low": float(row[3]), "close": float(row[4]),
531
- "volume": float(row[5]),
532
- "quote_volume": float(row[6]) if len(row) > 6 else None,
533
- })
534
- except Exception:
535
- continue
536
- return sorted(out, key=lambda x: x["timestamp"])
537
-
538
-
539
- def _norm_candles_bybit(rows: List[Any]) -> List[Dict[str, Any]]:
540
- # ByBit: [startTime, openPrice, highPrice, lowPrice, closePrice, volume, turnover]
541
- out = []
542
- for row in rows or []:
543
- try:
544
- ts_ms = int(row[0])
545
- out.append({
546
- "timestamp": ts_ms, "open_time": ts_ms,
547
- "open": float(row[1]), "high": float(row[2]),
548
- "low": float(row[3]), "close": float(row[4]),
549
- "volume": float(row[5]),
550
- "turnover": float(row[6]) if len(row) > 6 else None,
551
- })
552
- except Exception:
553
- continue
554
- return sorted(out, key=lambda x: x["timestamp"])
555
-
556
-
557
- def _v4_candles(data: Dict[str, Any]) -> List[Dict[str, Any]]:
558
- for key in ("candles", "data", "ohlcv"):
559
- candidate = data.get(key)
560
- if isinstance(candidate, list) and candidate:
561
- return candidate
562
- return []
563
-
564
-
565
- # ──────────────────────────────────────────────
566
- # OHLCV fallback chain (parallel)
567
- # ──────────────────────────────────────────────
568
-
569
- async def _ohlcv_from_binance(sym: Dict[str, str], timeframe: str, limit: int) -> Optional[Dict[str, Any]]:
570
- interval = TIMEFRAME_MAP_BINANCE.get(timeframe, "1h")
571
- data, err, _ = await _fetch_json(
572
- "binance_vision_klines",
573
- "https://data-api.binance.vision/api/v3/klines",
574
- {"symbol": sym["binance"], "interval": interval, "limit": limit},
575
- )
576
- if err or not isinstance(data, list):
577
- return None
578
- candles = _norm_candles_binance(data)
579
- if not candles:
580
- return None
581
- return {"symbol": sym["binance"], "timeframe": timeframe,
582
- "candles": candles, "source": "binance_vision_klines", "marketType": "spot"}
583
-
584
-
585
- async def _ohlcv_from_kucoin(sym: Dict[str, str], timeframe: str, limit: int) -> Optional[Dict[str, Any]]:
586
- ku_type = TIMEFRAME_MAP_KUCOIN.get(timeframe, "1hour")
587
- data, err, _ = await _fetch_json(
588
- "kucoin_public_candles",
589
- "https://api.kucoin.com/api/v1/market/candles",
590
- {"symbol": sym["kucoin"], "type": ku_type},
591
- )
592
- if err or not isinstance(data, dict):
593
- return None
594
- rows = data.get("data") or []
595
- candles = _norm_candles_kucoin(rows)[:limit]
596
- if not candles:
597
- return None
598
- return {"symbol": sym["binance"], "timeframe": timeframe,
599
- "candles": candles, "source": "kucoin_public_candles", "marketType": "spot"}
600
-
601
-
602
- async def _ohlcv_from_okx(sym: Dict[str, str], timeframe: str, limit: int) -> Optional[Dict[str, Any]]:
603
- bar = TIMEFRAME_MAP_OKX.get(timeframe, "1H")
604
- limit_capped = min(limit, 300)
605
- data, err, _ = await _fetch_json(
606
- "okx_public_candles",
607
- "https://www.okx.com/api/v5/market/candles",
608
- {"instId": sym["okx_spot"], "bar": bar, "limit": str(limit_capped)},
609
- )
610
- if err or not isinstance(data, dict):
611
- return None
612
- rows = data.get("data") or []
613
- candles = _norm_candles_okx(rows)
614
- if not candles:
615
- return None
616
- return {"symbol": sym["binance"], "timeframe": timeframe,
617
- "candles": candles[-limit:], "source": "okx_public_candles", "marketType": "spot"}
618
-
619
-
620
- async def _ohlcv_from_bybit(sym: Dict[str, str], timeframe: str, limit: int) -> Optional[Dict[str, Any]]:
621
- interval = TIMEFRAME_MAP_BYBIT.get(timeframe, "60")
622
- limit_capped = min(limit, 200)
623
- data, err, _ = await _fetch_json(
624
- "bybit_public_klines",
625
- "https://api.bybit.com/v5/market/kline",
626
- {"category": "spot", "symbol": sym["bybit_spot"], "interval": interval, "limit": str(limit_capped)},
627
- )
628
- if err or not isinstance(data, dict):
629
- return None
630
- rows = (data.get("result") or {}).get("list") or []
631
- candles = _norm_candles_bybit(rows)
632
- if not candles:
633
- return None
634
- return {"symbol": sym["binance"], "timeframe": timeframe,
635
- "candles": candles[-limit:], "source": "bybit_public_klines", "marketType": "spot"}
636
-
637
-
638
- async def _ohlcv_from_cryptocompare(sym: Dict[str, str], timeframe: str, limit: int) -> Optional[Dict[str, Any]]:
639
- cc_key = os.getenv("CRYPTOCOMPARE_KEY")
640
- cc_params: Dict[str, Any] = {"fsym": sym["cc_base"], "tsym": "USD", "limit": limit, "aggregate": 1}
641
- if cc_key:
642
- cc_params["api_key"] = cc_key
643
- url = ("https://min-api.cryptocompare.com/data/v2/histohour"
644
- if timeframe.endswith("h") else "https://min-api.cryptocompare.com/data/v2/histoday")
645
- data, err, _ = await _fetch_json("cryptocompare_histo", url, cc_params)
646
- if err or not isinstance(data, dict):
647
- return None
648
- rows = (((data.get("Data") or {}).get("Data")) or [])
649
- candles = []
650
- for row in rows:
651
- try:
652
- ts_ms = int(row.get("time", 0)) * 1000
653
- candles.append({
654
- "timestamp": ts_ms, "open_time": ts_ms,
655
- "open": float(row.get("open", 0)), "high": float(row.get("high", 0)),
656
- "low": float(row.get("low", 0)), "close": float(row.get("close", 0)),
657
- "volume": float(row.get("volumefrom", 0)),
658
- "quote_volume": float(row.get("volumeto", 0)),
659
- })
660
- except Exception:
661
- continue
662
- if not candles:
663
- return None
664
- return {"symbol": sym["binance"], "timeframe": timeframe,
665
- "candles": candles[-limit:], "source": "cryptocompare_histo", "marketType": "spot"}
666
-
667
-
668
- async def _fallback_ohlcv_parallel(
669
- symbol: str, timeframe: str, limit: int,
670
- errors: List[str], source_chain: List[str],
671
- ) -> Optional[Dict[str, Any]]:
672
- """Fetch all public fallbacks concurrently; return first success."""
673
- sym = _normalize_symbol(symbol)
674
- limit = max(1, min(limit, 500))
675
-
676
- fetchers = [
677
- ("binance_vision_klines", _ohlcv_from_binance(sym, timeframe, limit)),
678
- ("kucoin_public_candles", _ohlcv_from_kucoin(sym, timeframe, limit)),
679
- ("okx_public_candles", _ohlcv_from_okx(sym, timeframe, limit)),
680
- ("bybit_public_klines", _ohlcv_from_bybit(sym, timeframe, limit)),
681
- ("cryptocompare_histo", _ohlcv_from_cryptocompare(sym, timeframe, limit)),
682
- ]
683
- for name, _ in fetchers:
684
- source_chain.append(name)
685
-
686
- results = await asyncio.gather(*[coro for _, coro in fetchers], return_exceptions=True)
687
-
688
- for (name, _), result in zip(fetchers, results):
689
- if isinstance(result, Exception):
690
- errors.append(f"{name}: {result}")
691
- continue
692
- if result and result.get("candles"):
693
- return result
694
- return None
695
-
696
-
697
- # ──────────────────────────────────────────────
698
- # Ticker helpers (single price, low-latency)
699
- # ──────────────────────────────────────────────
700
-
701
- async def _ticker_from_binance(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
702
- data, err, _ = await _fetch_json(
703
- "binance_vision_price",
704
- "https://data-api.binance.vision/api/v3/ticker/price",
705
- {"symbol": sym["binance"]},
706
- )
707
- if err or not isinstance(data, dict) or "price" not in data:
708
- return None
709
- price = float(data["price"])
710
- return {"symbol": sym["binance"], "price": price, "source": "binance_vision_price"}
711
-
712
-
713
- async def _ticker_from_kucoin(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
714
- data, err, _ = await _fetch_json(
715
- "kucoin_ticker",
716
- "https://api.kucoin.com/api/v1/market/orderbook/level1",
717
- {"symbol": sym["kucoin"]},
718
- )
719
- if err or not isinstance(data, dict):
720
- return None
721
- price_str = (data.get("data") or {}).get("price")
722
- if not price_str:
723
- return None
724
- return {"symbol": sym["binance"], "price": float(price_str), "source": "kucoin_ticker"}
725
-
726
-
727
- async def _ticker_from_okx(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
728
- data, err, _ = await _fetch_json(
729
- "okx_ticker",
730
- "https://www.okx.com/api/v5/market/ticker",
731
- {"instId": sym["okx_spot"]},
732
- )
733
- if err or not isinstance(data, dict):
734
- return None
735
- items = data.get("data") or []
736
- if not items:
737
- return None
738
- price = float(items[0].get("last", 0) or 0)
739
- if not price:
740
- return None
741
- return {"symbol": sym["binance"], "price": price, "source": "okx_ticker"}
742
-
743
-
744
- async def _ticker_from_bybit(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
745
- data, err, _ = await _fetch_json(
746
- "bybit_ticker",
747
- "https://api.bybit.com/v5/market/tickers",
748
- {"category": "spot", "symbol": sym["bybit_spot"]},
749
- )
750
- if err or not isinstance(data, dict):
751
- return None
752
- items = (data.get("result") or {}).get("list") or []
753
- if not items:
754
- return None
755
- price = float(items[0].get("lastPrice", 0) or 0)
756
- if not price:
757
- return None
758
- return {"symbol": sym["binance"], "price": price, "source": "bybit_ticker"}
759
-
760
-
761
- async def _fetch_ticker_parallel(sym: Dict[str, str]) -> Tuple[Optional[Dict[str, Any]], List[str]]:
762
- errors: List[str] = []
763
- results = await asyncio.gather(
764
- _ticker_from_binance(sym),
765
- _ticker_from_kucoin(sym),
766
- _ticker_from_okx(sym),
767
- _ticker_from_bybit(sym),
768
- return_exceptions=True,
769
- )
770
- for r in results:
771
- if isinstance(r, Exception):
772
- errors.append(str(r))
773
- continue
774
- if r and r.get("price"):
775
- return r, errors
776
- return None, errors
777
-
778
-
779
- # ──────────────────────────────────────────────
780
- # Funding rate helpers
781
- # ──────────────────────────────────────────────
782
-
783
- async def _funding_from_binance(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
784
- data, err, _ = await _fetch_json(
785
- "binance_funding",
786
- "https://fapi.binance.com/fapi/v1/premiumIndex",
787
- {"symbol": sym["binance_perp"]},
788
- )
789
- if err or not isinstance(data, dict):
790
- return None
791
- fr = data.get("lastFundingRate")
792
- if fr is None:
793
- return None
794
- return {
795
- "symbol": sym["binance"], "fundingRate": float(fr),
796
- "nextFundingTime": data.get("nextFundingTime"),
797
- "markPrice": float(data.get("markPrice", 0) or 0),
798
- "source": "binance_futures",
799
- }
800
-
801
-
802
- async def _funding_from_okx(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
803
- data, err, _ = await _fetch_json(
804
- "okx_funding",
805
- "https://www.okx.com/api/v5/public/funding-rate",
806
- {"instId": sym["okx_swap"]},
807
- )
808
- if err or not isinstance(data, dict):
809
- return None
810
- items = data.get("data") or []
811
- if not items:
812
- return None
813
- fr = items[0].get("fundingRate")
814
- if fr is None:
815
- return None
816
- return {
817
- "symbol": sym["binance"], "fundingRate": float(fr),
818
- "nextFundingTime": items[0].get("nextFundingTime"),
819
- "source": "okx_futures",
820
- }
821
-
822
-
823
- async def _funding_from_bybit(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
824
- data, err, _ = await _fetch_json(
825
- "bybit_funding",
826
- "https://api.bybit.com/v5/market/tickers",
827
- {"category": "linear", "symbol": sym["bybit_perp"]},
828
- )
829
- if err or not isinstance(data, dict):
830
- return None
831
- items = (data.get("result") or {}).get("list") or []
832
- if not items:
833
- return None
834
- fr = items[0].get("fundingRate")
835
- if fr is None:
836
- return None
837
- return {
838
- "symbol": sym["binance"], "fundingRate": float(fr),
839
- "nextFundingTime": items[0].get("nextFundingTime"),
840
- "markPrice": float(items[0].get("markPrice", 0) or 0),
841
- "source": "bybit_futures",
842
- }
843
-
844
-
845
- # ──────────────────────────────────────────────
846
- # Open Interest helpers
847
- # ──────────────────────────────────────────────
848
-
849
- async def _oi_from_binance(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
850
- data, err, _ = await _fetch_json(
851
- "binance_oi",
852
- "https://fapi.binance.com/fapi/v1/openInterest",
853
- {"symbol": sym["binance_perp"]},
854
- )
855
- if err or not isinstance(data, dict):
856
- return None
857
- oi = data.get("openInterest")
858
- if oi is None:
859
- return None
860
- return {"symbol": sym["binance"], "openInterest": float(oi), "source": "binance_futures"}
861
-
862
-
863
- async def _oi_from_okx(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
864
- data, err, _ = await _fetch_json(
865
- "okx_oi",
866
- "https://www.okx.com/api/v5/rubik/stat/contracts/open-interest-volume",
867
- {"ccy": sym["base"]},
868
- )
869
- if err or not isinstance(data, dict):
870
- return None
871
- items = data.get("data") or []
872
- if not items:
873
- return None
874
- oi = items[0][1] if len(items[0]) > 1 else None
875
- if oi is None:
876
- return None
877
- return {"symbol": sym["binance"], "openInterest": float(oi), "source": "okx_futures"}
878
-
879
-
880
- async def _oi_from_bybit(sym: Dict[str, str]) -> Optional[Dict[str, Any]]:
881
- data, err, _ = await _fetch_json(
882
- "bybit_oi",
883
- "https://api.bybit.com/v5/market/open-interest",
884
- {"category": "linear", "symbol": sym["bybit_perp"], "intervalTime": "5min", "limit": "1"},
885
- )
886
- if err or not isinstance(data, dict):
887
- return None
888
- items = (data.get("result") or {}).get("list") or []
889
- if not items:
890
- return None
891
- oi = items[0].get("openInterest")
892
- if oi is None:
893
- return None
894
- return {"symbol": sym["binance"], "openInterest": float(oi), "source": "bybit_futures"}
895
-
896
-
897
- # ──────────────────────────────────────────────
898
- # API Endpoints
899
- # ──────────────────────────────────────────────
900
-
901
- @router.get("/api/enterprise/status")
902
- @router.get("/api/hub/status")
903
- async def enterprise_status() -> Dict[str, Any]:
904
- """Enterprise hub status: source contracts, counters, and runtime provider health."""
905
- _bump_counter("enterprise_status")
906
- online = sum(1 for v in _PROVIDER_HEALTH.values() if v.get("status") == "healthy")
907
- degraded = sum(1 for v in _PROVIDER_HEALTH.values() if v.get("status") == "degraded")
908
- offline = sum(1 for v in _PROVIDER_HEALTH.values() if v.get("status") == "unavailable")
909
- cb_open = [p for p, s in _CB_STATE.items() if s.get("failures", 0) >= CB_THRESHOLD
910
- and (_now_ts() - s.get("tripped_at", 0)) < CB_COOLDOWN]
911
- return {
912
- "status": "ok",
913
- "hubRole": "central_single_source_of_truth",
914
- "version": "enterprise-data-hub-v3.1",
915
- "v4Source": V4_BASE_URL,
916
- "contracts": {
917
- "ohlcv": "v3.1.normalized.candles",
918
- "orderbook": "v3.1.normalized.depth",
919
- "volume": "v3.1.normalized.volume24h",
920
- "ticker": "v3.1.normalized.price",
921
- "multiTicker": "v3.1.normalized.prices[]",
922
- "funding": "v3.1.normalized.fundingRate",
923
- "openInterest": "v3.1.normalized.openInterest",
924
- "news": "v3.1.normalized.news",
925
- "sentiment": "v3.1.normalized.sentiment",
926
- },
927
- "providerHealthSummary": {
928
- "online": online, "degraded": degraded,
929
- "offline": offline, "tracked": len(_PROVIDER_HEALTH),
930
- },
931
- "circuitBreakers": {"tripped": cb_open, "threshold": CB_THRESHOLD, "cooldownSec": CB_COOLDOWN},
932
- "cacheStats": {"keysHeld": len(_CACHE)},
933
- "requestCounters": _REQUEST_COUNTERS,
934
- "safety": {"executionAllowed": False, "secretsInSource": False},
935
- "timestamp": _now_iso(),
936
- }
937
-
938
-
939
- @router.get("/api/enterprise/provider-health")
940
- @router.get("/api/hub/provider-health")
941
- async def enterprise_provider_health() -> Dict[str, Any]:
942
- _bump_counter("enterprise_provider_health")
943
- providers = [
944
- {**entry, "avgLatencyMs": _latency_avg(p), "circuitOpen": _cb_is_open(p)}
945
- for p, entry in sorted(_PROVIDER_HEALTH.items())
946
- ]
947
- return {"status": "ok", "providers": providers, "count": len(providers), "timestamp": _now_iso()}
948
-
949
-
950
- @router.get("/api/enterprise/source-contracts")
951
- @router.get("/api/hub/source-contracts")
952
- async def enterprise_source_contracts() -> Dict[str, Any]:
953
- _bump_counter("enterprise_source_contracts")
954
- return {
955
- "status": "ok",
956
- "schemaVersion": "enterprise-hub-v3.1",
957
- "sourcePriority": {
958
- "ohlcv": ["v4_source", "binance_vision_klines", "kucoin_public_candles",
959
- "okx_public_candles", "bybit_public_klines", "cryptocompare_histo"],
960
- "orderbook": ["v4_source", "binance_vision_depth", "kucoin_public_level2_20",
961
- "okx_orderbook", "bybit_orderbook"],
962
- "volume": ["v4_source", "binance_vision_24hr", "kucoin_public_stats",
963
- "okx_ticker", "bybit_ticker"],
964
- "ticker": ["binance_vision_price", "kucoin_ticker", "okx_ticker", "bybit_ticker"],
965
- "multiTicker": ["binance_vision_price (parallel)"],
966
- "funding": ["binance_futures", "okx_futures", "bybit_futures"],
967
- "openInterest": ["binance_futures", "okx_futures", "bybit_futures"],
968
- "news": ["v4_source", "render_crypto_dt_source", "empty_nonfatal_fallback"],
969
- "sentiment": ["v4_source", "alternative_me_fear_greed", "coingecko_sentiment"],
970
- },
971
- "requiredQualityFields": [
972
- "source", "sourceChain", "marketType", "isLive",
973
- "isTradable", "executionAllowed", "dataQuality", "errors", "timestamp",
974
- ],
975
- "timestamp": _now_iso(),
976
- }
977
-
978
-
979
- @router.get("/api/enterprise/sync/v4")
980
- @router.get("/api/hub/sync/v4")
981
- async def enterprise_v4_sync_probe() -> Dict[str, Any]:
982
- """Probe V4 without trusting it blindly. Hub-source drift detection."""
983
- _bump_counter("enterprise_v4_sync_probe")
984
- probes = [
985
- ("health", "/api/health", {}),
986
- ("status", "/api/status", {}),
987
- ("ohlcv", "/api/ohlcv", {"symbol": "BTC", "timeframe": "1h", "limit": 5}),
988
- ("orderbook", "/api/trading/orderbook", {"symbol": "BTC", "depth": 5}),
989
- ("volume", "/api/trading/volume", {"symbol": "BTC"}),
990
- ("sentiment", "/api/social/sentiment", {"coin": "BTC", "timeframe": "24h"}),
991
- ]
992
- results = []
993
- for name, path, params in probes:
994
- start = time.perf_counter()
995
- data, err = await _fetch_v4(path, params)
996
- latency = round((time.perf_counter() - start) * 1000, 2)
997
- results.append({
998
- "capability": name, "path": path,
999
- "ok": data is not None and not err,
1000
- "latencyMs": latency, "error": err,
1001
- "hasData": bool(data),
1002
- "schemaPreview": sorted(list(data.keys()))[:12] if isinstance(data, dict) else [],
1003
- })
1004
- ok_count = sum(1 for r in results if r["ok"])
1005
- return {
1006
- "status": "ok" if ok_count >= 4 else "degraded",
1007
- "v4Source": V4_BASE_URL,
1008
- "okCount": ok_count, "total": len(results),
1009
- "results": results, "timestamp": _now_iso(),
1010
- }
1011
-
1012
-
1013
- # ── OHLCV ──────────────────────────────────────
1014
-
1015
- @router.get("/api/hub/ohlcv")
1016
- @router.get("/api/enterprise/ohlcv")
1017
- async def enterprise_ohlcv(
1018
- symbol: str = Query("BTC"),
1019
- timeframe: str = Query("1h"),
1020
- limit: int = Query(100),
1021
- ) -> Dict[str, Any]:
1022
- _bump_counter("enterprise_ohlcv")
1023
- cache_key = f"ohlcv:{symbol}:{timeframe}:{limit}"
1024
- cached = _cache_get(cache_key)
1025
- if cached is not None:
1026
- return {**cached, "cached": True}
1027
-
1028
- errors: List[str] = []
1029
- source_chain: List[str] = []
1030
- sym = _normalize_symbol(symbol)
1031
- limit = max(1, min(int(limit or 100), 500))
1032
-
1033
- # 1) V4 first
1034
- source_chain.append("v4_source")
1035
- v4, err = await _fetch_v4("/api/ohlcv", {"symbol": sym["base"], "timeframe": timeframe, "limit": limit})
1036
- if err:
1037
- errors.append(f"v4_source: {err}")
1038
- elif isinstance(v4, dict):
1039
- candles = _v4_candles(v4)
1040
- if candles:
1041
- payload = {
1042
- "success": True, "symbol": sym["binance"],
1043
- "timeframe": timeframe, "candles": candles[-limit:],
1044
- **_quality_payload(
1045
- source="v4_source", source_chain=source_chain, errors=errors,
1046
- market_type=v4.get("marketType", "spot"),
1047
- is_live=bool(v4.get("isLive", True)),
1048
- is_tradable=False,
1049
- data_quality="live" if not errors else "partial",
1050
- ),
1051
- }
1052
- _cache_set(cache_key, payload, CACHE_TTL_OHLCV)
1053
- return payload
1054
- errors.append("v4_source: no candles in response")
1055
-
1056
- # 2) Public fallbacks — parallel
1057
- fallback = await _fallback_ohlcv_parallel(symbol, timeframe, limit, errors, source_chain)
1058
- if fallback and fallback.get("candles"):
1059
- payload = {
1060
- "success": True, "symbol": fallback["symbol"],
1061
- "timeframe": timeframe, "candles": fallback["candles"],
1062
- **_quality_payload(
1063
- source=fallback["source"], source_chain=source_chain, errors=errors,
1064
- market_type=fallback.get("marketType", "spot"),
1065
- is_live=True, is_tradable=False,
1066
- data_quality="partial" if errors else "live",
1067
- ),
1068
- }
1069
- _cache_set(cache_key, payload, CACHE_TTL_OHLCV)
1070
- return payload
1071
-
1072
- return {
1073
- "success": False, "symbol": sym["binance"],
1074
- "timeframe": timeframe, "candles": [],
1075
- **_quality_payload(
1076
- source="unavailable", source_chain=source_chain, errors=errors,
1077
- market_type="unknown", is_live=False, is_tradable=False, data_quality="unavailable",
1078
- ),
1079
- }
1080
-
1081
-
1082
- # ── Ticker (single price) ──────────────────────
1083
-
1084
- @router.get("/api/hub/ticker")
1085
- @router.get("/api/enterprise/ticker")
1086
- async def enterprise_ticker(symbol: str = Query("BTC")) -> Dict[str, Any]:
1087
- """Fast price lookup. Parallel across Binance/KuCoin/OKX/ByBit; first wins."""
1088
- _bump_counter("enterprise_ticker")
1089
- sym = _normalize_symbol(symbol)
1090
- cache_key = f"ticker:{sym['base']}"
1091
- cached = _cache_get(cache_key)
1092
- if cached is not None:
1093
- return {**cached, "cached": True}
1094
-
1095
- errors: List[str] = []
1096
- source_chain = ["binance_vision_price", "kucoin_ticker", "okx_ticker", "bybit_ticker"]
1097
-
1098
- # Try V4 first (it may already have a fast price endpoint)
1099
- v4, err = await _fetch_v4("/api/trading/volume", {"symbol": sym["base"]})
1100
- if not err and isinstance(v4, dict) and v4.get("price"):
1101
- payload = {
1102
- "success": True, "symbol": sym["binance"],
1103
- "price": float(v4["price"]),
1104
- **_quality_payload(
1105
- source="v4_source", source_chain=["v4_source"] + source_chain,
1106
- errors=errors, market_type="spot", is_live=True,
1107
- is_tradable=False, data_quality="live",
1108
- ),
1109
- }
1110
- _cache_set(cache_key, payload, CACHE_TTL_TICKER)
1111
- return payload
1112
-
1113
- result, ticker_errors = await _fetch_ticker_parallel(sym)
1114
- errors.extend(ticker_errors)
1115
-
1116
- if result:
1117
- payload = {
1118
- "success": True, "symbol": sym["binance"],
1119
- "price": result["price"],
1120
- **_quality_payload(
1121
- source=result["source"], source_chain=source_chain, errors=errors,
1122
- market_type="spot", is_live=True, is_tradable=False,
1123
- data_quality="partial" if errors else "live",
1124
- ),
1125
- }
1126
- _cache_set(cache_key, payload, CACHE_TTL_TICKER)
1127
- return payload
1128
-
1129
- return {
1130
- "success": False, "symbol": sym["binance"], "price": None,
1131
- **_quality_payload(
1132
- source="unavailable", source_chain=source_chain, errors=errors,
1133
- market_type="unknown", is_live=False, is_tradable=False, data_quality="unavailable",
1134
- ),
1135
- }
1136
-
1137
-
1138
- # ── Multi-Ticker (batch) ───────────────────────
1139
-
1140
- @router.get("/api/hub/multi-ticker")
1141
- @router.get("/api/enterprise/multi-ticker")
1142
- async def enterprise_multi_ticker(symbols: str = Query("BTC,ETH,SOL")) -> Dict[str, Any]:
1143
- """
1144
- Batch price fetch. `symbols` is comma-separated, max 20.
1145
- Uses Binance bulk ticker endpoint first, falls back to parallel individual fetches.
1146
- """
1147
- _bump_counter("enterprise_multi_ticker")
1148
- raw_symbols = [s.strip().upper() for s in symbols.split(",") if s.strip()][:20]
1149
- if not raw_symbols:
1150
- return {"success": False, "prices": {}, "errors": ["no symbols provided"], "timestamp": _now_iso()}
1151
-
1152
- # Try Binance bulk endpoint (most efficient)
1153
- sym_map = {s: _normalize_symbol(s) for s in raw_symbols}
1154
- binance_syms = [sym_map[s]["binance"] for s in raw_symbols]
1155
- bulk_data, err, _ = await _fetch_json(
1156
- "binance_vision_price_bulk",
1157
- "https://data-api.binance.vision/api/v3/ticker/price",
1158
- {}, # No params = all symbols
1159
- )
1160
-
1161
- prices: Dict[str, Optional[float]] = {}
1162
- errors: List[str] = []
1163
-
1164
- if not err and isinstance(bulk_data, list):
1165
- bulk_lookup = {item["symbol"]: float(item["price"]) for item in bulk_data if "symbol" in item and "price" in item}
1166
- for s, sym in sym_map.items():
1167
- p = bulk_lookup.get(sym["binance"])
1168
- if p:
1169
- prices[s] = p
1170
- missing = [s for s in raw_symbols if s not in prices]
1171
- if missing:
1172
- errors.append(f"binance bulk: missing {missing}")
1173
- else:
1174
- if err:
1175
- errors.append(f"binance_vision_price_bulk: {err}")
1176
- missing = list(raw_symbols)
1177
-
1178
- # Individual parallel fetch for anything still missing
1179
- if missing:
1180
- individual_results = await asyncio.gather(
1181
- *[_fetch_ticker_parallel(_normalize_symbol(s)) for s in missing],
1182
- return_exceptions=True,
1183
- )
1184
- for sym_str, result in zip(missing, individual_results):
1185
- if isinstance(result, Exception):
1186
- errors.append(str(result))
1187
- prices[sym_str] = None
1188
- else:
1189
- ticker, ind_errors = result
1190
- errors.extend(ind_errors)
1191
- prices[sym_str] = ticker["price"] if ticker else None
1192
-
1193
- return {
1194
- "success": True,
1195
- "prices": prices,
1196
- "count": len(prices),
1197
- "errors": errors,
1198
- "source": "binance_vision_price_bulk+individual_fallback",
1199
- "executionAllowed": False,
1200
- "timestamp": _now_iso(),
1201
- }
1202
-
1203
-
1204
- # ── Orderbook ──────────────────────────────────
1205
-
1206
- @router.get("/api/hub/orderbook")
1207
- @router.get("/api/enterprise/orderbook")
1208
- async def enterprise_orderbook(
1209
- symbol: str = Query("BTC"),
1210
- depth: int = Query(20),
1211
- limit: Optional[int] = None,
1212
- ) -> Dict[str, Any]:
1213
- _bump_counter("enterprise_orderbook")
1214
- depth_value = max(1, min(int(limit or depth or 20), 100))
1215
- errors: List[str] = []
1216
- source_chain: List[str] = []
1217
- sym = _normalize_symbol(symbol)
1218
-
1219
- cache_key = f"ob:{sym['base']}:{depth_value}"
1220
- cached = _cache_get(cache_key)
1221
- if cached is not None:
1222
- return {**cached, "cached": True}
1223
-
1224
- # V4
1225
- source_chain.append("v4_source")
1226
- v4, err = await _fetch_v4("/api/trading/orderbook", {"symbol": sym["base"], "depth": depth_value})
1227
- if err:
1228
- errors.append(f"v4_source: {err}")
1229
- elif isinstance(v4, dict) and (v4.get("bids") or v4.get("asks")):
1230
- payload = {
1231
- "success": True, "symbol": sym["binance"],
1232
- "bids": v4.get("bids", []), "asks": v4.get("asks", []),
1233
- **_quality_payload(
1234
- source="v4_source", source_chain=source_chain, errors=errors,
1235
- market_type=v4.get("marketType", "spot"),
1236
- is_live=True, is_tradable=False,
1237
- data_quality="live" if not errors else "partial",
1238
- ),
1239
- }
1240
- _cache_set(cache_key, payload, CACHE_TTL_ORDERBOOK)
1241
- return payload
1242
-
1243
- # Binance Vision
1244
- source_chain.append("binance_vision_depth")
1245
- data, err, _ = await _fetch_json(
1246
- "binance_vision_depth", "https://data-api.binance.vision/api/v3/depth",
1247
- {"symbol": sym["binance"], "limit": depth_value},
1248
- )
1249
- if err:
1250
- errors.append(f"binance_vision_depth: {err}")
1251
- elif isinstance(data, dict) and (data.get("bids") or data.get("asks")):
1252
- payload = {
1253
- "success": True, "symbol": sym["binance"],
1254
- "bids": [[float(p), float(q)] for p, q in data.get("bids", [])[:depth_value]],
1255
- "asks": [[float(p), float(q)] for p, q in data.get("asks", [])[:depth_value]],
1256
- **_quality_payload(
1257
- source="binance_vision_depth", source_chain=source_chain, errors=errors,
1258
- market_type="spot", is_live=True, is_tradable=False,
1259
- data_quality="partial" if errors else "live",
1260
- ),
1261
- }
1262
- _cache_set(cache_key, payload, CACHE_TTL_ORDERBOOK)
1263
- return payload
1264
-
1265
- # KuCoin
1266
- source_chain.append("kucoin_public_level2_20")
1267
- data, err, _ = await _fetch_json(
1268
- "kucoin_public_level2_20",
1269
- "https://api.kucoin.com/api/v1/market/orderbook/level2_20",
1270
- {"symbol": sym["kucoin"]},
1271
- )
1272
- if err:
1273
- errors.append(f"kucoin_public_level2_20: {err}")
1274
- elif isinstance(data, dict):
1275
- book = data.get("data") or {}
1276
- if book.get("bids") or book.get("asks"):
1277
- payload = {
1278
- "success": True, "symbol": sym["binance"],
1279
- "bids": [[float(p), float(q)] for p, q in book.get("bids", [])[:depth_value]],
1280
- "asks": [[float(p), float(q)] for p, q in book.get("asks", [])[:depth_value]],
1281
- **_quality_payload(
1282
- source="kucoin_public_level2_20", source_chain=source_chain, errors=errors,
1283
- market_type="spot", is_live=True, is_tradable=False,
1284
- data_quality="partial" if errors else "live",
1285
- ),
1286
- }
1287
- _cache_set(cache_key, payload, CACHE_TTL_ORDERBOOK)
1288
- return payload
1289
-
1290
- # OKX
1291
- source_chain.append("okx_orderbook")
1292
- data, err, _ = await _fetch_json(
1293
- "okx_orderbook",
1294
- "https://www.okx.com/api/v5/market/books",
1295
- {"instId": sym["okx_spot"], "sz": str(depth_value)},
1296
- )
1297
- if err:
1298
- errors.append(f"okx_orderbook: {err}")
1299
- elif isinstance(data, dict):
1300
- items = data.get("data") or []
1301
- if items:
1302
- book = items[0]
1303
- bids = [[float(r[0]), float(r[1])] for r in (book.get("bids") or [])[:depth_value]]
1304
- asks = [[float(r[0]), float(r[1])] for r in (book.get("asks") or [])[:depth_value]]
1305
- if bids or asks:
1306
- payload = {
1307
- "success": True, "symbol": sym["binance"],
1308
- "bids": bids, "asks": asks,
1309
- **_quality_payload(
1310
- source="okx_orderbook", source_chain=source_chain, errors=errors,
1311
- market_type="spot", is_live=True, is_tradable=False,
1312
- data_quality="partial" if errors else "live",
1313
- ),
1314
- }
1315
- _cache_set(cache_key, payload, CACHE_TTL_ORDERBOOK)
1316
- return payload
1317
-
1318
- return {
1319
- "success": False, "symbol": sym["binance"], "bids": [], "asks": [],
1320
- **_quality_payload(
1321
- source="unavailable", source_chain=source_chain, errors=errors,
1322
- market_type="unknown", is_live=False, is_tradable=False, data_quality="unavailable",
1323
- ),
1324
- }
1325
-
1326
-
1327
- # ── Volume ─────────────────────────────────────
1328
-
1329
- @router.get("/api/hub/volume")
1330
- @router.get("/api/enterprise/volume")
1331
- async def enterprise_volume(symbol: str = Query("BTC")) -> Dict[str, Any]:
1332
- _bump_counter("enterprise_volume")
1333
- sym = _normalize_symbol(symbol)
1334
- cache_key = f"vol:{sym['base']}"
1335
- cached = _cache_get(cache_key)
1336
- if cached is not None:
1337
- return {**cached, "cached": True}
1338
-
1339
- errors: List[str] = []
1340
- source_chain: List[str] = []
1341
-
1342
- # V4
1343
- source_chain.append("v4_source")
1344
- v4, err = await _fetch_v4("/api/trading/volume", {"symbol": sym["base"]})
1345
- if err:
1346
- errors.append(f"v4_source: {err}")
1347
- elif isinstance(v4, dict) and v4.get("success") and (
1348
- v4.get("volume") is not None or v4.get("base_volume") is not None
1349
- ):
1350
- payload = {
1351
- **v4,
1352
- **_quality_payload(
1353
- source="v4_source", source_chain=source_chain, errors=errors,
1354
- market_type=v4.get("marketType", "spot"),
1355
- is_live=True, is_tradable=False,
1356
- data_quality="live" if not errors else "partial",
1357
- ),
1358
- }
1359
- _cache_set(cache_key, payload, CACHE_TTL_VOLUME)
1360
- return payload
1361
-
1362
- def _parse_24hr(data: Dict[str, Any], src: str) -> Dict[str, Any]:
1363
- return {
1364
- "success": True, "symbol": sym["binance"],
1365
- "price": float(data.get("lastPrice") or data.get("last") or 0),
1366
- "volume": float(data.get("volume") or data.get("vol") or 0),
1367
- "base_volume": float(data.get("volume") or data.get("vol") or 0),
1368
- "quote_volume": float(data.get("quoteVolume") or data.get("volValue") or 0),
1369
- "change_percent": float(data.get("priceChangePercent") or
1370
- (float(data.get("changeRate", 0)) * 100) or 0),
1371
- "high": float(data.get("highPrice") or data.get("high") or 0),
1372
- "low": float(data.get("lowPrice") or data.get("low") or 0),
1373
- **_quality_payload(
1374
- source=src, source_chain=source_chain, errors=errors,
1375
- market_type="spot", is_live=True, is_tradable=False,
1376
- data_quality="partial" if errors else "live",
1377
- ),
1378
- }
1379
-
1380
- # Binance Vision 24hr
1381
- source_chain.append("binance_vision_24hr")
1382
- data, err, _ = await _fetch_json(
1383
- "binance_vision_24hr", "https://data-api.binance.vision/api/v3/ticker/24hr",
1384
- {"symbol": sym["binance"]},
1385
- )
1386
- if err:
1387
- errors.append(f"binance_vision_24hr: {err}")
1388
- elif isinstance(data, dict) and data.get("lastPrice") is not None:
1389
- payload = _parse_24hr(data, "binance_vision_24hr")
1390
- _cache_set(cache_key, payload, CACHE_TTL_VOLUME)
1391
- return payload
1392
-
1393
- # KuCoin
1394
- source_chain.append("kucoin_public_stats")
1395
- data, err, _ = await _fetch_json(
1396
- "kucoin_public_stats", "https://api.kucoin.com/api/v1/market/stats",
1397
- {"symbol": sym["kucoin"]},
1398
- )
1399
- if err:
1400
- errors.append(f"kucoin_public_stats: {err}")
1401
- elif isinstance(data, dict):
1402
- stats = data.get("data") or {}
1403
- if stats.get("last") is not None:
1404
- payload = _parse_24hr(stats, "kucoin_public_stats")
1405
- _cache_set(cache_key, payload, CACHE_TTL_VOLUME)
1406
- return payload
1407
-
1408
- # OKX
1409
- source_chain.append("okx_ticker")
1410
- data, err, _ = await _fetch_json(
1411
- "okx_ticker", "https://www.okx.com/api/v5/market/ticker",
1412
- {"instId": sym["okx_spot"]},
1413
- )
1414
- if err:
1415
- errors.append(f"okx_ticker: {err}")
1416
- elif isinstance(data, dict):
1417
- items = data.get("data") or []
1418
- if items and items[0].get("last"):
1419
- t = items[0]
1420
- payload = {
1421
- "success": True, "symbol": sym["binance"],
1422
- "price": float(t.get("last", 0)), "volume": float(t.get("vol24h", 0)),
1423
- "base_volume": float(t.get("vol24h", 0)), "quote_volume": float(t.get("volCcy24h", 0)),
1424
- "change_percent": 0.0, "high": float(t.get("high24h", 0)), "low": float(t.get("low24h", 0)),
1425
- **_quality_payload(
1426
- source="okx_ticker", source_chain=source_chain, errors=errors,
1427
- market_type="spot", is_live=True, is_tradable=False,
1428
- data_quality="partial" if errors else "live",
1429
- ),
1430
- }
1431
- _cache_set(cache_key, payload, CACHE_TTL_VOLUME)
1432
- return payload
1433
-
1434
- return {
1435
- "success": False, "symbol": sym["binance"], "price": None, "volume": None,
1436
- **_quality_payload(
1437
- source="unavailable", source_chain=source_chain, errors=errors,
1438
- market_type="unknown", is_live=False, is_tradable=False, data_quality="unavailable",
1439
- ),
1440
- }
1441
-
1442
-
1443
- # ── Funding Rate ───────────────────────────────
1444
-
1445
- @router.get("/api/hub/funding")
1446
- @router.get("/api/enterprise/funding")
1447
- async def enterprise_funding(symbol: str = Query("BTC")) -> Dict[str, Any]:
1448
- """
1449
- Perpetual contract funding rate.
1450
- Source chain: Binance Futures → OKX Swap → ByBit Linear.
1451
- """
1452
- _bump_counter("enterprise_funding")
1453
- sym = _normalize_symbol(symbol)
1454
- cache_key = f"funding:{sym['base']}"
1455
- cached = _cache_get(cache_key)
1456
- if cached is not None:
1457
- return {**cached, "cached": True}
1458
-
1459
- errors: List[str] = []
1460
- source_chain = ["binance_futures", "okx_futures", "bybit_futures"]
1461
-
1462
- results = await asyncio.gather(
1463
- _funding_from_binance(sym),
1464
- _funding_from_okx(sym),
1465
- _funding_from_bybit(sym),
1466
- return_exceptions=True,
1467
- )
1468
-
1469
- for r in results:
1470
- if isinstance(r, Exception):
1471
- errors.append(str(r))
1472
- continue
1473
- if r and r.get("fundingRate") is not None:
1474
- payload = {
1475
- "success": True,
1476
- "symbol": sym["binance"],
1477
- "fundingRate": r["fundingRate"],
1478
- "fundingRatePct": round(r["fundingRate"] * 100, 6),
1479
- "nextFundingTime": r.get("nextFundingTime"),
1480
- "markPrice": r.get("markPrice"),
1481
- **_quality_payload(
1482
- source=r["source"], source_chain=source_chain, errors=errors,
1483
- market_type="perpetual", is_live=True, is_tradable=False,
1484
- data_quality="partial" if errors else "live",
1485
- ),
1486
- }
1487
- _cache_set(cache_key, payload, CACHE_TTL_FUNDING)
1488
- return payload
1489
-
1490
- return {
1491
- "success": False, "symbol": sym["binance"],
1492
- "fundingRate": None, "fundingRatePct": None,
1493
- **_quality_payload(
1494
- source="unavailable", source_chain=source_chain, errors=errors,
1495
- market_type="perpetual", is_live=False, is_tradable=False, data_quality="unavailable",
1496
- ),
1497
- }
1498
-
1499
-
1500
- # ── Open Interest ──────────────────────────────
1501
-
1502
- @router.get("/api/hub/open-interest")
1503
- @router.get("/api/enterprise/open-interest")
1504
- async def enterprise_open_interest(symbol: str = Query("BTC")) -> Dict[str, Any]:
1505
- """
1506
- Perpetual contract open interest.
1507
- Source chain: Binance Futures → OKX → ByBit.
1508
- """
1509
- _bump_counter("enterprise_open_interest")
1510
- sym = _normalize_symbol(symbol)
1511
- cache_key = f"oi:{sym['base']}"
1512
- cached = _cache_get(cache_key)
1513
- if cached is not None:
1514
- return {**cached, "cached": True}
1515
-
1516
- errors: List[str] = []
1517
- source_chain = ["binance_futures", "okx_futures", "bybit_futures"]
1518
-
1519
- results = await asyncio.gather(
1520
- _oi_from_binance(sym),
1521
- _oi_from_okx(sym),
1522
- _oi_from_bybit(sym),
1523
- return_exceptions=True,
1524
- )
1525
-
1526
- for r in results:
1527
- if isinstance(r, Exception):
1528
- errors.append(str(r))
1529
- continue
1530
- if r and r.get("openInterest") is not None:
1531
- payload = {
1532
- "success": True,
1533
- "symbol": sym["binance"],
1534
- "openInterest": r["openInterest"],
1535
- **_quality_payload(
1536
- source=r["source"], source_chain=source_chain, errors=errors,
1537
- market_type="perpetual", is_live=True, is_tradable=False,
1538
- data_quality="partial" if errors else "live",
1539
- ),
1540
- }
1541
- _cache_set(cache_key, payload, CACHE_TTL_OI)
1542
- return payload
1543
-
1544
- return {
1545
- "success": False, "symbol": sym["binance"], "openInterest": None,
1546
- **_quality_payload(
1547
- source="unavailable", source_chain=source_chain, errors=errors,
1548
- market_type="perpetual", is_live=False, is_tradable=False, data_quality="unavailable",
1549
- ),
1550
- }
1551
-
1552
-
1553
- # ── News ───────────────────────────────────────
1554
-
1555
- @router.get("/api/hub/news/{coin}")
1556
- @router.get("/api/enterprise/news/{coin}")
1557
- async def enterprise_news(coin: str, limit: int = Query(5)) -> Dict[str, Any]:
1558
- _bump_counter("enterprise_news")
1559
- errors: List[str] = []
1560
- source_chain: List[str] = []
1561
- base = _normalize_symbol(coin)["base"].lower()
1562
- limit = max(1, min(int(limit or 5), 50))
1563
-
1564
- cache_key = f"news:{base}:{limit}"
1565
- cached = _cache_get(cache_key)
1566
- if cached is not None:
1567
- return {**cached, "cached": True}
1568
-
1569
- source_chain.append("v4_source")
1570
- v4, err = await _fetch_v4(f"/api/news/{base}", {"limit": limit})
1571
- if err:
1572
- errors.append(f"v4_source: {err}")
1573
- elif isinstance(v4, dict):
1574
- news = v4.get("news") or v4.get("data") or []
1575
- if news:
1576
- payload = {
1577
- "success": True, "coin": base.upper(),
1578
- "news": news[:limit], "data": news[:limit], "count": len(news[:limit]),
1579
- **_quality_payload(
1580
- source="v4_source", source_chain=source_chain, errors=errors,
1581
- market_type="none", is_live=True, is_tradable=False,
1582
- data_quality="live" if not errors else "partial",
1583
- ),
1584
- }
1585
- _cache_set(cache_key, payload, CACHE_TTL_NEWS)
1586
- return payload
1587
-
1588
- source_chain.append("render_crypto_dt_source")
1589
- data, err, _ = await _fetch_json(
1590
- "render_crypto_dt_source", f"{CRYPTO_DT_SOURCE_URL}/api/v1/rss/feed",
1591
- {"feed_name": "coindesk", "limit": limit},
1592
- )
1593
- if err:
1594
- errors.append(f"render_crypto_dt_source: {err}")
1595
- elif isinstance(data, dict):
1596
- raw_items = data.get("items") or data.get("data") or data.get("news") or []
1597
- if isinstance(raw_items, list) and raw_items:
1598
- payload = {
1599
- "success": True, "coin": base.upper(),
1600
- "news": raw_items[:limit], "data": raw_items[:limit], "count": len(raw_items[:limit]),
1601
- **_quality_payload(
1602
- source="render_crypto_dt_source", source_chain=source_chain, errors=errors,
1603
- market_type="none", is_live=True, is_tradable=False,
1604
- data_quality="partial" if errors else "live",
1605
- ),
1606
- }
1607
- _cache_set(cache_key, payload, CACHE_TTL_NEWS)
1608
- return payload
1609
-
1610
- return {
1611
- "success": True, "coin": base.upper(), "news": [], "data": [], "count": 0,
1612
- **_quality_payload(
1613
- source="empty_nonfatal_fallback", source_chain=source_chain, errors=errors,
1614
- market_type="none", is_live=False, is_tradable=False, data_quality="empty",
1615
- ),
1616
- }
1617
-
1618
-
1619
- # ── Sentiment ──────────────────────────────────
1620
-
1621
- @router.get("/api/hub/sentiment")
1622
- @router.get("/api/enterprise/sentiment")
1623
- async def enterprise_sentiment(
1624
- coin: str = Query("BTC"),
1625
- timeframe: str = Query("24h"),
1626
- ) -> Dict[str, Any]:
1627
- _bump_counter("enterprise_sentiment")
1628
- errors: List[str] = []
1629
- source_chain: List[str] = []
1630
- base = _normalize_symbol(coin)["base"]
1631
-
1632
- cache_key = f"sentiment:{base}:{timeframe}"
1633
- cached = _cache_get(cache_key)
1634
- if cached is not None:
1635
- return {**cached, "cached": True}
1636
-
1637
- # V4
1638
- source_chain.append("v4_source")
1639
- v4, err = await _fetch_v4("/api/social/sentiment", {"coin": base, "timeframe": timeframe})
1640
- if err:
1641
- errors.append(f"v4_source: {err}")
1642
- elif isinstance(v4, dict) and v4.get("success"):
1643
- payload = {
1644
- **v4,
1645
- **_quality_payload(
1646
- source="v4_source", source_chain=source_chain, errors=errors,
1647
- market_type="none", is_live=True, is_tradable=False,
1648
- data_quality="live" if not errors else "partial",
1649
- ),
1650
- }
1651
- _cache_set(cache_key, payload, CACHE_TTL_SENTIMENT)
1652
- return payload
1653
-
1654
- # Alternative.me Fear & Greed
1655
- source_chain.append("alternative_me_fear_greed")
1656
- data, err, _ = await _fetch_json(
1657
- "alternative_me_fear_greed", "https://api.alternative.me/fng/",
1658
- {"limit": 1, "format": "json"},
1659
- )
1660
- if err:
1661
- errors.append(f"alternative_me_fear_greed: {err}")
1662
- if isinstance(data, dict) and data.get("data"):
1663
- item = data["data"][0]
1664
- value = float(item.get("value", 50))
1665
- score = round((value - 50.0) / 50.0, 4)
1666
- label = item.get("value_classification", "Neutral")
1667
- payload = {
1668
- "success": True, "coin": base, "asset": base, "timeframe": timeframe,
1669
- "sentiment_score": score, "score": score,
1670
- "fear_greed_index": int(value), "label": label, "sentiment": label.lower(),
1671
- **_quality_payload(
1672
- source="alternative_me_fear_greed", source_chain=source_chain, errors=errors,
1673
- market_type="none", is_live=True, is_tradable=False,
1674
- data_quality="partial" if errors else "live",
1675
- ),
1676
- }
1677
- _cache_set(cache_key, payload, CACHE_TTL_SENTIMENT)
1678
- return payload
1679
-
1680
- # CoinGecko as final fallback (more stable, rate-limited but public)
1681
- source_chain.append("coingecko_sentiment")
1682
- cg_id_map = {"BTC": "bitcoin", "ETH": "ethereum", "SOL": "solana", "BNB": "binancecoin"}
1683
- cg_id = cg_id_map.get(base, base.lower())
1684
- data, err, _ = await _fetch_json(
1685
- "coingecko_sentiment",
1686
- f"https://api.coingecko.com/api/v3/coins/{cg_id}",
1687
- {"localization": "false", "tickers": "false", "market_data": "false",
1688
- "community_data": "true", "developer_data": "false"},
1689
- )
1690
- if err:
1691
- errors.append(f"coingecko_sentiment: {err}")
1692
- elif isinstance(data, dict):
1693
- sent_up = data.get("sentiment_votes_up_percentage") or 50.0
1694
- score = round((float(sent_up) - 50.0) / 50.0, 4)
1695
- label = "Bullish" if score > 0.1 else ("Bearish" if score < -0.1 else "Neutral")
1696
- payload = {
1697
- "success": True, "coin": base, "asset": base, "timeframe": timeframe,
1698
- "sentiment_score": score, "score": score,
1699
- "sentiment_votes_up_pct": float(sent_up),
1700
- "label": label, "sentiment": label.lower(),
1701
- **_quality_payload(
1702
- source="coingecko_sentiment", source_chain=source_chain, errors=errors,
1703
- market_type="none", is_live=True, is_tradable=False,
1704
- data_quality="partial" if errors else "live",
1705
- ),
1706
- }
1707
- _cache_set(cache_key, payload, CACHE_TTL_SENTIMENT)
1708
- return payload
1709
-
1710
- return {
1711
- "success": False, "coin": base, "asset": base, "timeframe": timeframe,
1712
- "sentiment_score": None,
1713
- **_quality_payload(
1714
- source="unavailable", source_chain=source_chain, errors=errors,
1715
- market_type="none", is_live=False, is_tradable=False, data_quality="unavailable",
1716
- ),
1717
- }
1718
-
1719
-
1720
- # ──────────────────────────────────────────────
1721
- # Data Brain v4 direct aliases + peer bridge
1722
- # These routes make the file usable directly inside V4 as a supplier.
1723
- # They intentionally preserve executionAllowed=False.
1724
- # ──────────────���───────────────────────────────
1725
-
1726
- @router.get("/api/data-brain/status")
1727
- async def data_brain_status() -> Dict[str, Any]:
1728
- _bump_counter("data_brain_status")
1729
- return {
1730
- "status": "ok",
1731
- "version": DATA_BRAIN_SCHEMA_VERSION,
1732
- "role": DATA_BRAIN_ROLE,
1733
- "v4Source": V4_BASE_URL,
1734
- "v2Peer": V2_HUB_BASE_URL,
1735
- "providersTracked": len(_PROVIDER_HEALTH),
1736
- "sourceScores": _DATA_BRAIN_STATE.get("sourceScores", {}),
1737
- "failurePatterns": _DATA_BRAIN_STATE.get("failurePatterns", {}),
1738
- "recentDecisions": _DATA_BRAIN_STATE.get("decisionHistory", [])[-20:],
1739
- "lastGoodKeys": list((_DATA_BRAIN_STATE.get("lastGoodByKey") or {}).keys())[-80:],
1740
- "peer": {"cacheSize": len(_PEER_CACHE), "queueSize": len(_PEER_QUEUE)},
1741
- "safety": {"executionAllowed": False, "isTradable": False},
1742
- "timestamp": _now_iso(),
1743
- }
1744
-
1745
-
1746
- @router.get("/api/hub/peer-status")
1747
- @router.get("/api/enterprise/peer-status")
1748
- @router.get("/api/data-brain/peer-status")
1749
- async def data_brain_peer_status() -> Dict[str, Any]:
1750
- _bump_counter("data_brain_peer_status")
1751
- return {
1752
- "status": "ok",
1753
- "peerCacheSize": len(_PEER_CACHE),
1754
- "peerQueueSize": len(_PEER_QUEUE),
1755
- "recentPeerKeys": list(_PEER_CACHE.keys())[-50:],
1756
- "recentPeerEvents": _DATA_BRAIN_STATE.get("peerEvents", [])[-50:],
1757
- "timestamp": _now_iso(),
1758
- }
1759
-
1760
-
1761
- @router.post("/api/hub/peer-push")
1762
- @router.post("/api/enterprise/peer-push")
1763
- @router.post("/api/data-brain/peer-push")
1764
- async def data_brain_peer_push(payload: Dict[str, Any] = Body(...)) -> Dict[str, Any]:
1765
- """Manual peer push. V2 or V4 can post normalized payloads here."""
1766
- _bump_counter("data_brain_peer_push")
1767
- key = str(payload.get("key") or payload.get("route") or payload.get("symbol") or f"peer:{len(_PEER_CACHE)+1}")
1768
- record = {
1769
- "key": key,
1770
- "payload": payload,
1771
- "confidence": _brain_quality_score(payload),
1772
- "schemaHash": _stable_hash(payload),
1773
- "origin": payload.get("origin", "peer"),
1774
- "timestamp": _now_iso(),
1775
- }
1776
- _PEER_CACHE[key] = record
1777
- events = _DATA_BRAIN_STATE.setdefault("peerEvents", [])
1778
- events.append({k: v for k, v in record.items() if k != "payload"})
1779
- del events[:-200]
1780
- return {"success": True, "stored": True, "key": key, "confidence": record["confidence"], "timestamp": _now_iso()}
1781
-
1782
-
1783
- @router.get("/api/hub/peer-data")
1784
- @router.get("/api/enterprise/peer-data")
1785
- @router.get("/api/data-brain/peer-data")
1786
- async def data_brain_peer_data(key: Optional[str] = Query(None)) -> Dict[str, Any]:
1787
- _bump_counter("data_brain_peer_data")
1788
- if key:
1789
- item = _PEER_CACHE.get(key)
1790
- return {"success": bool(item), "key": key, "item": item, "timestamp": _now_iso()}
1791
- return {"success": True, "items": list(_PEER_CACHE.values())[-100:], "count": len(_PEER_CACHE), "timestamp": _now_iso()}
1792
-
1793
-
1794
- # Direct compatibility aliases for V4 consumers. If V4 already has these routes,
1795
- # use install_data_brain(app, replace_existing=True) in the main app to give this
1796
- # Data Brain router priority.
1797
-
1798
- @router.get("/api/ohlcv")
1799
- async def data_brain_direct_ohlcv(
1800
- symbol: str = Query("BTC"),
1801
- timeframe: str = Query("1h"),
1802
- limit: int = Query(100),
1803
- ) -> Dict[str, Any]:
1804
- payload = await enterprise_ohlcv(symbol=symbol, timeframe=timeframe, limit=limit)
1805
- decision = _brain_decision("/api/ohlcv", [{"name": payload.get("source", "unknown"), "payload": payload}], payload.get("errors", []))
1806
- return _brain_enrich(payload, "/api/ohlcv", decision)
1807
-
1808
-
1809
- @router.get("/api/klines")
1810
- @router.get("/api/history")
1811
- async def data_brain_direct_klines(
1812
- symbol: str = Query("BTC"),
1813
- timeframe: str = Query("1h"),
1814
- interval: Optional[str] = Query(None),
1815
- limit: int = Query(100),
1816
- ) -> Dict[str, Any]:
1817
- tf = interval or timeframe
1818
- payload = await enterprise_ohlcv(symbol=symbol, timeframe=tf, limit=limit)
1819
- decision = _brain_decision("/api/klines", [{"name": payload.get("source", "unknown"), "payload": payload}], payload.get("errors", []))
1820
- return _brain_enrich(payload, "/api/klines", decision)
1821
-
1822
-
1823
- @router.get("/api/orderbook")
1824
- @router.get("/api/trading/orderbook")
1825
- async def data_brain_direct_orderbook(
1826
- symbol: str = Query("BTC"),
1827
- depth: int = Query(20),
1828
- limit: Optional[int] = None,
1829
- ) -> Dict[str, Any]:
1830
- payload = await enterprise_orderbook(symbol=symbol, depth=depth, limit=limit)
1831
- decision = _brain_decision("/api/trading/orderbook", [{"name": payload.get("source", "unknown"), "payload": payload}], payload.get("errors", []))
1832
- return _brain_enrich(payload, "/api/trading/orderbook", decision)
1833
-
1834
-
1835
- @router.get("/api/volume")
1836
- @router.get("/api/trading/volume")
1837
- async def data_brain_direct_volume(symbol: str = Query("BTC")) -> Dict[str, Any]:
1838
- payload = await enterprise_volume(symbol=symbol)
1839
- decision = _brain_decision("/api/trading/volume", [{"name": payload.get("source", "unknown"), "payload": payload}], payload.get("errors", []))
1840
- return _brain_enrich(payload, "/api/trading/volume", decision)
1841
-
1842
-
1843
- @router.get("/api/news/{coin}")
1844
- async def data_brain_direct_news(coin: str, limit: int = Query(5)) -> Dict[str, Any]:
1845
- payload = await enterprise_news(coin=coin, limit=limit)
1846
- decision = _brain_decision(f"/api/news/{coin}", [{"name": payload.get("source", "unknown"), "payload": payload}], payload.get("errors", []))
1847
- return _brain_enrich(payload, f"/api/news/{coin}", decision)
1848
-
1849
-
1850
- @router.get("/api/social/sentiment")
1851
- @router.get("/api/sentiment")
1852
- async def data_brain_direct_sentiment(
1853
- coin: str = Query("BTC"),
1854
- timeframe: str = Query("24h"),
1855
- ) -> Dict[str, Any]:
1856
- payload = await enterprise_sentiment(coin=coin, timeframe=timeframe)
1857
- decision = _brain_decision("/api/social/sentiment", [{"name": payload.get("source", "unknown"), "payload": payload}], payload.get("errors", []))
1858
- return _brain_enrich(payload, "/api/social/sentiment", decision)
1859
-
1860
-
1861
- @router.get("/api/funding")
1862
- @router.get("/api/trading/funding")
1863
- async def data_brain_direct_funding(symbol: str = Query("BTC")) -> Dict[str, Any]:
1864
- payload = await enterprise_funding(symbol=symbol)
1865
- decision = _brain_decision("/api/trading/funding", [{"name": payload.get("source", "unknown"), "payload": payload}], payload.get("errors", []))
1866
- return _brain_enrich(payload, "/api/trading/funding", decision)
1867
-
1868
-
1869
- @router.get("/api/open-interest")
1870
- @router.get("/api/trading/open-interest")
1871
- async def data_brain_direct_open_interest(symbol: str = Query("BTC")) -> Dict[str, Any]:
1872
- payload = await enterprise_open_interest(symbol=symbol)
1873
- decision = _brain_decision("/api/trading/open-interest", [{"name": payload.get("source", "unknown"), "payload": payload}], payload.get("errors", []))
1874
- return _brain_enrich(payload, "/api/trading/open-interest", decision)
1875
-
1876
-
1877
- def _existing_route_keys(app: Any) -> set:
1878
- """Return {(METHOD, PATH)} for currently registered FastAPI routes."""
1879
- keys = set()
1880
- for route in getattr(app, "routes", []) or []:
1881
- path = getattr(route, "path", "")
1882
- methods = getattr(route, "methods", set()) or set()
1883
- for method in methods:
1884
- if method not in {"HEAD", "OPTIONS"}:
1885
- keys.add((method.upper(), path))
1886
- return keys
1887
-
1888
-
1889
- def _add_router_routes_safely(app: Any, source_router: Any) -> Dict[str, Any]:
1890
- """
1891
- Mount only missing routes from this module's router.
1892
- Existing project routes are preserved to avoid feature loss and duplicate signatures.
1893
- """
1894
- existing = _existing_route_keys(app)
1895
- added: List[str] = []
1896
- skipped: List[str] = []
1897
-
1898
- for route in getattr(source_router, "routes", []) or []:
1899
- path = getattr(route, "path", "")
1900
- endpoint = getattr(route, "endpoint", None)
1901
- methods = sorted((getattr(route, "methods", set()) or set()) - {"HEAD", "OPTIONS"})
1902
- if not path or endpoint is None or not methods:
1903
- continue
1904
-
1905
- # If every method/path already exists, skip to preserve native V4 behavior.
1906
- if all((method.upper(), path) in existing for method in methods):
1907
- skipped.append(f"{','.join(methods)} {path}")
1908
- continue
1909
-
1910
- missing_methods = [m for m in methods if (m.upper(), path) not in existing]
1911
- if not missing_methods:
1912
- skipped.append(f"{','.join(methods)} {path}")
1913
- continue
1914
-
1915
- app.add_api_route(
1916
- path,
1917
- endpoint,
1918
- methods=missing_methods,
1919
- name=getattr(route, "name", None),
1920
- tags=getattr(route, "tags", None),
1921
- summary=getattr(route, "summary", None),
1922
- description=getattr(route, "description", None),
1923
- response_model=getattr(route, "response_model", None),
1924
- status_code=getattr(route, "status_code", None),
1925
- dependencies=getattr(route, "dependencies", None),
1926
- )
1927
- for method in missing_methods:
1928
- existing.add((method.upper(), path))
1929
- added.append(f"{method.upper()} {path}")
1930
-
1931
- return {"added": added, "skippedExisting": skipped}
1932
-
1933
-
1934
- def install_unified_crypto_platform_v5(app: Any, preserve_existing: bool = True) -> Dict[str, Any]:
1935
- """
1936
- Zero-conflict, non-destructive installer for V4.
1937
-
1938
- Purpose:
1939
- - preserve all existing V4 features/routes/UI/backends
1940
- - add missing Data Brain endpoints
1941
- - add missing Hub endpoints
1942
- - add missing legacy aliases only if they do not already exist
1943
- - never remove or override existing routes
1944
-
1945
- Required routes supported by this module when missing:
1946
- /api/data-brain/status
1947
- /api/data-brain/peer-status
1948
- /api/data-brain/peer-push
1949
- /api/data-brain/peer-data
1950
- /api/ohlcv, /api/klines, /api/history
1951
- /api/orderbook, /api/trading/orderbook
1952
- /api/volume, /api/trading/volume
1953
- /api/news/{coin}
1954
- /api/social/sentiment, /api/sentiment
1955
- /api/trading/funding, /api/trading/open-interest
1956
- /api/hub/ohlcv, /api/hub/orderbook, /api/hub/volume
1957
- /api/hub/news/{coin}, /api/hub/sentiment
1958
- /api/hub/funding, /api/hub/open-interest
1959
- /api/hub/provider-health, /api/hub/source-contracts
1960
- """
1961
- before = len(getattr(app, "routes", []) or [])
1962
- result = _add_router_routes_safely(app, router)
1963
- after = len(getattr(app, "routes", []) or [])
1964
- report = {
1965
- "status": "ok",
1966
- "installer": "UNIFIED_CRYPTO_DATA_PLATFORM_v5_ZERO_CONFLICT",
1967
- "preserveExisting": preserve_existing,
1968
- "routesBefore": before,
1969
- "routesAfter": after,
1970
- "routesAdded": result["added"],
1971
- "routesSkippedExisting": result["skippedExisting"],
1972
- "executionAllowed": False,
1973
- "timestamp": _now_iso(),
1974
- }
1975
- try:
1976
- print(f"[OK] Unified Crypto Data Platform v5 installed: added={len(result['added'])}, skipped_existing={len(result['skippedExisting'])}")
1977
- except Exception:
1978
- pass
1979
- return report
1980
-
1981
-
1982
- def install_data_brain(app: Any, replace_existing: bool = False) -> Dict[str, Any]:
1983
- """
1984
- Backward-compatible installer.
1985
-
1986
- IMPORTANT: default is now non-destructive. Existing V4 routes are preserved.
1987
- replace_existing is intentionally ignored for safety unless the caller manually
1988
- edits this function; this prevents accidental feature loss.
1989
- """
1990
- return install_unified_crypto_platform_v5(app, preserve_existing=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
enterprise_router_v5.py DELETED
@@ -1,756 +0,0 @@
1
- """
2
- enterprise_router_v5.py — Zero-Conflict Unified Router (REAL DATA)
3
-
4
- Three layers, mounted once on the main FastAPI app:
5
- LAYER 1 /api/data-brain/* -> internal status/sync info (no external calls)
6
- LAYER 2 /api/hub/* -> canonical real-data endpoints (Binance public API)
7
- LAYER 3 /api/* -> legacy paths, kept for backward compatibility,
8
- simply forward to the hub layer.
9
-
10
- Usage (inside api_server_extended.py, AFTER `app = FastAPI(...)`):
11
-
12
- from enterprise_router_v5 import install_zero_conf_router
13
- install_zero_conf_router(app)
14
-
15
- No extra dependencies are required beyond `httpx` and `fastapi`,
16
- both of which are already used by api_server_extended.py.
17
- """
18
-
19
- from __future__ import annotations
20
-
21
- import time
22
- import logging
23
- from typing import Any, Dict, List, Optional
24
-
25
- import httpx
26
- from fastapi import APIRouter, FastAPI, HTTPException, Query
27
-
28
- logger = logging.getLogger(__name__)
29
-
30
- # =========================
31
- # CONFIG
32
- # =========================
33
-
34
- BINANCE_BASE = "https://api.binance.com"
35
- BINANCE_FAPI = "https://fapi.binance.com" # USDT-M futures (funding / open interest)
36
- CRYPTOCOMPARE_BASE = "https://min-api.cryptocompare.com"
37
- FEAR_GREED_URL = "https://api.alternative.me/fng/"
38
-
39
- HTTP_TIMEOUT = 10.0
40
-
41
- _START_TIME = time.time()
42
-
43
- # ---- Optional API keys (set as HF Space secrets or env vars; .env.extra_providers
44
- # has working defaults supplied by the project owner) ----
45
- import os
46
-
47
- def _load_env_file(path: str = ".env.extra_providers"):
48
- try:
49
- with open(path, "r", encoding="utf-8") as f:
50
- for line in f:
51
- line = line.strip()
52
- if not line or line.startswith("#") or "=" not in line:
53
- continue
54
- k, v = line.split("=", 1)
55
- os.environ.setdefault(k.strip(), v.strip())
56
- except FileNotFoundError:
57
- pass
58
-
59
- _load_env_file()
60
-
61
- CMC_API_KEY = os.getenv("COINMARKETCAP_API_KEY", "")
62
- CMC_API_KEY_2 = os.getenv("COINMARKETCAP_API_KEY_2", "")
63
- CRYPTOCOMPARE_API_KEY = os.getenv("CRYPTOCOMPARE_API_KEY", "")
64
- ETHERSCAN_API_KEY = os.getenv("ETHERSCAN_API_KEY", "")
65
- BSCSCAN_API_KEY = os.getenv("BSCSCAN_API_KEY", "")
66
- TRONSCAN_API_KEY = os.getenv("TRONSCAN_API_KEY", "")
67
- NEWSAPI_KEY = os.getenv("NEWSAPI_KEY", "")
68
-
69
- # Tiny in-memory cache so a refresh storm on the frontend doesn't turn into
70
- # a refresh storm on upstream exchanges. {key: (timestamp, data)}
71
- _CACHE: Dict[str, Any] = {}
72
- _CACHE_TTL = 5.0 # seconds
73
-
74
-
75
- async def _cached_get(client: httpx.AsyncClient, url: str, params: Optional[dict] = None,
76
- ttl: float = _CACHE_TTL) -> Any:
77
- key = f"{url}?{params}"
78
- now = time.time()
79
- entry = _CACHE.get(key)
80
- if entry and (now - entry[0]) < ttl:
81
- return entry[1]
82
-
83
- resp = await client.get(url, params=params, timeout=HTTP_TIMEOUT)
84
- resp.raise_for_status()
85
- data = resp.json()
86
- _CACHE[key] = (now, data)
87
- return data
88
-
89
-
90
- def _norm_symbol(symbol: str) -> str:
91
- """Accepts BTC, BTC-USDT, BTC/USDT, btcusdt -> returns BTCUSDT (Binance style)."""
92
- s = symbol.upper().replace("-", "").replace("/", "").replace("_", "")
93
- if not s.endswith("USDT") and not s.endswith("USD") and not s.endswith("BTC") and not s.endswith("ETH"):
94
- s = s + "USDT"
95
- return s
96
-
97
-
98
- # =========================
99
- # GLOBAL ROUTE REGISTRY (kept for compatibility with v5 spec)
100
- # =========================
101
- ROUTE_REGISTRY: Dict[str, Any] = {}
102
-
103
-
104
- def register(route: str):
105
- def decorator(fn):
106
- if route in ROUTE_REGISTRY:
107
- return ROUTE_REGISTRY[route]
108
- ROUTE_REGISTRY[route] = fn
109
- return fn
110
- return decorator
111
-
112
-
113
- # =========================
114
- # ROUTER LAYERS
115
- # =========================
116
-
117
- data_brain_router = APIRouter(prefix="/api/data-brain", tags=["data-brain"])
118
- hub_router = APIRouter(prefix="/api/hub", tags=["hub"])
119
- legacy_router = APIRouter(prefix="/api", tags=["legacy"])
120
-
121
-
122
- # =========================
123
- # LAYER 1: DATA BRAIN (internal status)
124
- # =========================
125
-
126
- @data_brain_router.get("/status")
127
- async def brain_status():
128
- return {
129
- "status": "ok",
130
- "layer": "data-brain",
131
- "conflict_mode": "zero-conflict",
132
- "uptime_seconds": round(time.time() - _START_TIME, 1),
133
- "registered_routes": len(ROUTE_REGISTRY),
134
- }
135
-
136
-
137
- @data_brain_router.get("/peer-status")
138
- async def peer_status():
139
- # Single-node deployment: no peers configured.
140
- return {"peers": 0, "sync": "stable"}
141
-
142
-
143
- @data_brain_router.post("/peer-push")
144
- async def peer_push(payload: dict):
145
- logger.info("peer-push received (no-op, single-node): %s", list(payload.keys()))
146
- return {"pushed": True, "note": "single-node deployment, push accepted but not propagated"}
147
-
148
-
149
- @data_brain_router.get("/peer-data")
150
- async def peer_data():
151
- return {"data": []}
152
-
153
-
154
- # =========================
155
- # LAYER 2: HUB (canonical, real data via Binance public API)
156
- # =========================
157
-
158
- @hub_router.get("/ohlcv")
159
- async def hub_ohlcv(symbol: str, timeframe: str = "1h", limit: int = 100):
160
- """Real OHLCV candles from Binance public REST API."""
161
- pair = _norm_symbol(symbol)
162
- interval = timeframe
163
- limit = max(1, min(limit, 1000))
164
-
165
- async with httpx.AsyncClient() as client:
166
- try:
167
- raw = await _cached_get(
168
- client,
169
- f"{BINANCE_BASE}/api/v3/klines",
170
- params={"symbol": pair, "interval": interval, "limit": limit},
171
- )
172
- except httpx.HTTPStatusError as e:
173
- raise HTTPException(status_code=502, detail=f"Binance klines error: {e.response.status_code}")
174
- except httpx.RequestError as e:
175
- raise HTTPException(status_code=502, detail=f"Upstream request failed: {e}")
176
-
177
- candles = [
178
- {
179
- "open_time": row[0],
180
- "open": float(row[1]),
181
- "high": float(row[2]),
182
- "low": float(row[3]),
183
- "close": float(row[4]),
184
- "volume": float(row[5]),
185
- "close_time": row[6],
186
- }
187
- for row in raw
188
- ]
189
-
190
- return {
191
- "source": "binance",
192
- "symbol": pair,
193
- "timeframe": interval,
194
- "count": len(candles),
195
- "candles": candles,
196
- }
197
-
198
-
199
- @hub_router.get("/orderbook")
200
- async def hub_orderbook(symbol: str, limit: int = 50):
201
- pair = _norm_symbol(symbol)
202
- limit = max(5, min(limit, 500))
203
-
204
- async with httpx.AsyncClient() as client:
205
- try:
206
- raw = await _cached_get(
207
- client,
208
- f"{BINANCE_BASE}/api/v3/depth",
209
- params={"symbol": pair, "limit": limit},
210
- )
211
- except httpx.HTTPStatusError as e:
212
- raise HTTPException(status_code=502, detail=f"Binance depth error: {e.response.status_code}")
213
- except httpx.RequestError as e:
214
- raise HTTPException(status_code=502, detail=f"Upstream request failed: {e}")
215
-
216
- return {
217
- "source": "binance",
218
- "symbol": pair,
219
- "bids": [[float(p), float(q)] for p, q in raw.get("bids", [])],
220
- "asks": [[float(p), float(q)] for p, q in raw.get("asks", [])],
221
- "last_update_id": raw.get("lastUpdateId"),
222
- }
223
-
224
-
225
- @hub_router.get("/volume")
226
- async def hub_volume(symbol: str):
227
- pair = _norm_symbol(symbol)
228
-
229
- async with httpx.AsyncClient() as client:
230
- try:
231
- raw = await _cached_get(
232
- client,
233
- f"{BINANCE_BASE}/api/v3/ticker/24hr",
234
- params={"symbol": pair},
235
- )
236
- except httpx.HTTPStatusError as e:
237
- raise HTTPException(status_code=502, detail=f"Binance ticker error: {e.response.status_code}")
238
- except httpx.RequestError as e:
239
- raise HTTPException(status_code=502, detail=f"Upstream request failed: {e}")
240
-
241
- return {
242
- "source": "binance",
243
- "symbol": pair,
244
- "volume_base": float(raw.get("volume", 0)),
245
- "volume_quote": float(raw.get("quoteVolume", 0)),
246
- "price_change_percent_24h": float(raw.get("priceChangePercent", 0)),
247
- "last_price": float(raw.get("lastPrice", 0)),
248
- "high_24h": float(raw.get("highPrice", 0)),
249
- "low_24h": float(raw.get("lowPrice", 0)),
250
- }
251
-
252
-
253
- @hub_router.get("/news/{coin}")
254
- async def hub_news(coin: str, limit: int = 20):
255
- """Recent news for a coin via CryptoCompare's free news API."""
256
- limit = max(1, min(limit, 50))
257
-
258
- params = {"categories": coin.upper(), "excludeCategories": "Sponsored", "lang": "EN"}
259
- if CRYPTOCOMPARE_API_KEY:
260
- params["api_key"] = CRYPTOCOMPARE_API_KEY
261
-
262
- async with httpx.AsyncClient() as client:
263
- try:
264
- raw = await _cached_get(
265
- client,
266
- f"{CRYPTOCOMPARE_BASE}/data/v2/news/",
267
- params=params,
268
- ttl=60.0,
269
- )
270
- except httpx.HTTPStatusError as e:
271
- raise HTTPException(status_code=502, detail=f"CryptoCompare news error: {e.response.status_code}")
272
- except httpx.RequestError as e:
273
- raise HTTPException(status_code=502, detail=f"Upstream request failed: {e}")
274
-
275
- items = (raw or {}).get("Data", [])[:limit]
276
- news = [
277
- {
278
- "title": item.get("title"),
279
- "url": item.get("url"),
280
- "source": item.get("source_info", {}).get("name") or item.get("source"),
281
- "published_on": item.get("published_on"),
282
- "body": (item.get("body") or "")[:300],
283
- }
284
- for item in items
285
- ]
286
-
287
- return {"source": "cryptocompare", "coin": coin.upper(), "count": len(news), "news": news}
288
-
289
-
290
- @hub_router.get("/social/sentiment")
291
- @hub_router.get("/sentiment")
292
- async def hub_sentiment():
293
- """Real market sentiment via the Fear & Greed Index (alternative.me)."""
294
- async with httpx.AsyncClient() as client:
295
- try:
296
- raw = await _cached_get(client, FEAR_GREED_URL, params={"limit": 1}, ttl=300.0)
297
- except httpx.HTTPStatusError as e:
298
- raise HTTPException(status_code=502, detail=f"Fear&Greed error: {e.response.status_code}")
299
- except httpx.RequestError as e:
300
- raise HTTPException(status_code=502, detail=f"Upstream request failed: {e}")
301
-
302
- entry = (raw.get("data") or [{}])[0]
303
- value = int(entry.get("value", 50))
304
- classification = entry.get("value_classification", "Neutral")
305
-
306
- sentiment = "neutral"
307
- if value <= 25:
308
- sentiment = "extreme_fear"
309
- elif value <= 45:
310
- sentiment = "fear"
311
- elif value <= 55:
312
- sentiment = "neutral"
313
- elif value <= 75:
314
- sentiment = "greed"
315
- else:
316
- sentiment = "extreme_greed"
317
-
318
- return {
319
- "source": "alternative.me",
320
- "sentiment": sentiment,
321
- "fear_greed_value": value,
322
- "fear_greed_label": classification,
323
- "timestamp": entry.get("timestamp"),
324
- }
325
-
326
-
327
- @hub_router.get("/trading/funding")
328
- async def hub_funding(symbol: str = "BTCUSDT"):
329
- pair = _norm_symbol(symbol)
330
-
331
- async with httpx.AsyncClient() as client:
332
- try:
333
- raw = await _cached_get(
334
- client,
335
- f"{BINANCE_FAPI}/fapi/v1/premiumIndex",
336
- params={"symbol": pair},
337
- ttl=30.0,
338
- )
339
- except httpx.HTTPStatusError as e:
340
- raise HTTPException(status_code=502, detail=f"Binance futures error: {e.response.status_code}")
341
- except httpx.RequestError as e:
342
- raise HTTPException(status_code=502, detail=f"Upstream request failed: {e}")
343
-
344
- return {
345
- "source": "binance_futures",
346
- "symbol": pair,
347
- "funding_rate": float(raw.get("lastFundingRate", 0)),
348
- "next_funding_time": raw.get("nextFundingTime"),
349
- "mark_price": float(raw.get("markPrice", 0)),
350
- }
351
-
352
-
353
- @hub_router.get("/trading/open-interest")
354
- async def hub_open_interest(symbol: str = "BTCUSDT"):
355
- pair = _norm_symbol(symbol)
356
-
357
- async with httpx.AsyncClient() as client:
358
- try:
359
- raw = await _cached_get(
360
- client,
361
- f"{BINANCE_FAPI}/fapi/v1/openInterest",
362
- params={"symbol": pair},
363
- ttl=30.0,
364
- )
365
- except httpx.HTTPStatusError as e:
366
- raise HTTPException(status_code=502, detail=f"Binance futures error: {e.response.status_code}")
367
- except httpx.RequestError as e:
368
- raise HTTPException(status_code=502, detail=f"Upstream request failed: {e}")
369
-
370
- return {
371
- "source": "binance_futures",
372
- "symbol": pair,
373
- "open_interest": float(raw.get("openInterest", 0)),
374
- "time": raw.get("time"),
375
- }
376
-
377
-
378
- @hub_router.get("/provider-health")
379
- async def provider_health():
380
- """Lightweight live check of the upstream providers this router depends on."""
381
- checks = {
382
- "binance_spot": f"{BINANCE_BASE}/api/v3/ping",
383
- "binance_futures": f"{BINANCE_FAPI}/fapi/v1/ping",
384
- "cryptocompare": f"{CRYPTOCOMPARE_BASE}/data/v2/news/?categories=BTC&lang=EN",
385
- "fear_greed": FEAR_GREED_URL,
386
- }
387
- results: Dict[str, Any] = {}
388
- async with httpx.AsyncClient() as client:
389
- for name, url in checks.items():
390
- t0 = time.time()
391
- try:
392
- resp = await client.get(url, timeout=5.0)
393
- results[name] = {
394
- "status": "online" if resp.status_code < 400 else "degraded",
395
- "status_code": resp.status_code,
396
- "response_time_ms": round((time.time() - t0) * 1000, 1),
397
- }
398
- except Exception as e:
399
- results[name] = {"status": "offline", "error": str(e)}
400
-
401
- overall = "ok" if all(v.get("status") == "online" for v in results.values()) else "degraded"
402
- return {"status": overall, "providers": results}
403
-
404
-
405
- @hub_router.get("/market/cmc")
406
- async def hub_market_cmc(symbol: str = "BTC"):
407
- """Live quote from CoinMarketCap (uses configured API key, falls back to key #2)."""
408
- if not (CMC_API_KEY or CMC_API_KEY_2):
409
- raise HTTPException(status_code=503, detail="CoinMarketCap API key not configured")
410
-
411
- url = "https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest"
412
- params = {"symbol": symbol.upper(), "convert": "USD"}
413
-
414
- async with httpx.AsyncClient() as client:
415
- last_err = None
416
- for key in (CMC_API_KEY, CMC_API_KEY_2):
417
- if not key:
418
- continue
419
- try:
420
- resp = await client.get(url, params=params, headers={"X-CMC_PRO_API_KEY": key}, timeout=HTTP_TIMEOUT)
421
- if resp.status_code == 200:
422
- raw = resp.json()
423
- coin = (raw.get("data") or {}).get(symbol.upper(), {})
424
- quote = (coin.get("quote") or {}).get("USD", {})
425
- return {
426
- "source": "coinmarketcap",
427
- "symbol": symbol.upper(),
428
- "price_usd": quote.get("price"),
429
- "market_cap_usd": quote.get("market_cap"),
430
- "volume_24h_usd": quote.get("volume_24h"),
431
- "percent_change_24h": quote.get("percent_change_24h"),
432
- }
433
- last_err = f"HTTP {resp.status_code}"
434
- except httpx.RequestError as e:
435
- last_err = str(e)
436
-
437
- raise HTTPException(status_code=502, detail=f"CoinMarketCap error: {last_err}")
438
-
439
-
440
- @hub_router.get("/news/headlines")
441
- async def hub_news_headlines(query: str = "cryptocurrency", limit: int = 20):
442
- """Top headlines via NewsAPI.org (requires NEWSAPI_KEY)."""
443
- if not NEWSAPI_KEY:
444
- raise HTTPException(status_code=503, detail="NEWSAPI_KEY not configured")
445
-
446
- limit = max(1, min(limit, 50))
447
- async with httpx.AsyncClient() as client:
448
- try:
449
- raw = await _cached_get(
450
- client,
451
- "https://newsapi.org/v2/everything",
452
- params={"q": query, "sortBy": "publishedAt", "pageSize": limit, "apiKey": NEWSAPI_KEY},
453
- ttl=60.0,
454
- )
455
- except httpx.HTTPStatusError as e:
456
- raise HTTPException(status_code=502, detail=f"NewsAPI error: {e.response.status_code}")
457
- except httpx.RequestError as e:
458
- raise HTTPException(status_code=502, detail=f"Upstream request failed: {e}")
459
-
460
- articles = [
461
- {"title": a.get("title"), "source": (a.get("source") or {}).get("name"),
462
- "url": a.get("url"), "published_at": a.get("publishedAt")}
463
- for a in (raw.get("articles") or [])[:limit]
464
- ]
465
- return {"source": "newsapi", "query": query, "count": len(articles), "articles": articles}
466
-
467
-
468
- @hub_router.get("/chain/balance")
469
- async def hub_chain_balance(chain: str, address: str):
470
- """Native-coin balance for an address on eth / bsc / tron (uses configured explorer keys)."""
471
- chain = chain.lower()
472
-
473
- async with httpx.AsyncClient() as client:
474
- if chain == "eth":
475
- if not ETHERSCAN_API_KEY:
476
- raise HTTPException(status_code=503, detail="ETHERSCAN_API_KEY not configured")
477
- try:
478
- raw = await _cached_get(
479
- client, "https://api.etherscan.io/api",
480
- params={"module": "account", "action": "balance", "address": address,
481
- "tag": "latest", "apikey": ETHERSCAN_API_KEY}, ttl=15.0,
482
- )
483
- except httpx.RequestError as e:
484
- raise HTTPException(status_code=502, detail=str(e))
485
- wei = int(raw.get("result", "0") or "0")
486
- return {"source": "etherscan", "chain": "eth", "address": address, "balance": wei / 1e18, "unit": "ETH"}
487
-
488
- if chain == "bsc":
489
- if not BSCSCAN_API_KEY:
490
- raise HTTPException(status_code=503, detail="BSCSCAN_API_KEY not configured")
491
- try:
492
- raw = await _cached_get(
493
- client, "https://api.bscscan.com/api",
494
- params={"module": "account", "action": "balance", "address": address,
495
- "tag": "latest", "apikey": BSCSCAN_API_KEY}, ttl=15.0,
496
- )
497
- except httpx.RequestError as e:
498
- raise HTTPException(status_code=502, detail=str(e))
499
- wei = int(raw.get("result", "0") or "0")
500
- return {"source": "bscscan", "chain": "bsc", "address": address, "balance": wei / 1e18, "unit": "BNB"}
501
-
502
- if chain == "tron":
503
- try:
504
- raw = await _cached_get(
505
- client, "https://apilist.tronscanapi.com/api/accountv2",
506
- params={"address": address}, ttl=15.0,
507
- )
508
- except httpx.RequestError as e:
509
- raise HTTPException(status_code=502, detail=str(e))
510
- sun = raw.get("balance", 0)
511
- return {"source": "tronscan", "chain": "tron", "address": address, "balance": sun / 1e6, "unit": "TRX"}
512
-
513
- raise HTTPException(status_code=400, detail="chain must be one of: eth, bsc, tron")
514
-
515
-
516
- @hub_router.get("/source-contracts")
517
- async def source_contracts():
518
- return {
519
- "contracts": "valid",
520
- "providers": {
521
- "ohlcv": "binance:/api/v3/klines",
522
- "orderbook": "binance:/api/v3/depth",
523
- "volume": "binance:/api/v3/ticker/24hr",
524
- "news": "cryptocompare:/data/v2/news/",
525
- "sentiment": "alternative.me:/fng/",
526
- "funding": "binance_futures:/fapi/v1/premiumIndex",
527
- "open_interest": "binance_futures:/fapi/v1/openInterest",
528
- "defi_price": "defillama:/coins/prices/current/{ids}",
529
- "social_reddit": "reddit:/r/cryptocurrency/top.json",
530
- "news_rss": "coindesk|cointelegraph rss feeds",
531
- "whales": "clankapp:/api",
532
- "market_alt": "coinpaprika:/v1/tickers/{id}",
533
- },
534
- }
535
-
536
-
537
- # =========================
538
- # EXTRA FREE NO-AUTH RESOURCES (from crypto_resources_unified_2025-11-11.json)
539
- # =========================
540
-
541
- DEFILLAMA_BASE = "https://coins.llama.fi"
542
- REDDIT_TOP_URL = "https://www.reddit.com/r/cryptocurrency/top.json"
543
- COINPAPRIKA_BASE = "https://api.coinpaprika.com/v1"
544
- CLANKAPP_URL = "https://clankapp.com/api/v2/whales"
545
-
546
- RSS_FEEDS = {
547
- "coindesk": "https://feeds.feedburner.com/CoinDesk",
548
- "cointelegraph": "https://cointelegraph.com/rss",
549
- }
550
-
551
-
552
- @hub_router.get("/defi/price")
553
- async def hub_defi_price(token: str = Query(..., description="e.g. coingecko:bitcoin or ethereum:0x...")):
554
- """Token price via DefiLlama (free, no auth)."""
555
- async with httpx.AsyncClient() as client:
556
- try:
557
- raw = await _cached_get(client, f"{DEFILLAMA_BASE}/prices/current/{token}", ttl=30.0)
558
- except httpx.HTTPStatusError as e:
559
- raise HTTPException(status_code=502, detail=f"DefiLlama error: {e.response.status_code}")
560
- except httpx.RequestError as e:
561
- raise HTTPException(status_code=502, detail=f"Upstream request failed: {e}")
562
-
563
- return {"source": "defillama", "token": token, "data": raw.get("coins", {})}
564
-
565
-
566
- @hub_router.get("/social/reddit")
567
- async def hub_social_reddit(limit: int = 10):
568
- """Top posts from r/cryptocurrency (free, no auth)."""
569
- limit = max(1, min(limit, 50))
570
- async with httpx.AsyncClient(headers={"User-Agent": "crypto-hub/1.0"}) as client:
571
- try:
572
- raw = await _cached_get(client, REDDIT_TOP_URL, params={"limit": limit, "t": "day"}, ttl=120.0)
573
- except httpx.HTTPStatusError as e:
574
- raise HTTPException(status_code=502, detail=f"Reddit error: {e.response.status_code}")
575
- except httpx.RequestError as e:
576
- raise HTTPException(status_code=502, detail=f"Upstream request failed: {e}")
577
-
578
- posts = []
579
- for child in (raw.get("data", {}).get("children", []))[:limit]:
580
- d = child.get("data", {})
581
- posts.append({
582
- "title": d.get("title"),
583
- "score": d.get("score"),
584
- "num_comments": d.get("num_comments"),
585
- "url": f"https://reddit.com{d.get('permalink', '')}",
586
- "created_utc": d.get("created_utc"),
587
- })
588
-
589
- return {"source": "reddit", "subreddit": "cryptocurrency", "count": len(posts), "posts": posts}
590
-
591
-
592
- @hub_router.get("/news/rss")
593
- async def hub_news_rss(source: str = "coindesk"):
594
- """Raw RSS news feed (CoinDesk / CoinTelegraph), free no-auth."""
595
- if source not in RSS_FEEDS:
596
- raise HTTPException(status_code=400, detail=f"Unknown source. Choose from: {list(RSS_FEEDS)}")
597
-
598
- async with httpx.AsyncClient(follow_redirects=True) as client:
599
- try:
600
- now = time.time()
601
- cache_key = f"rss:{source}"
602
- entry = _CACHE.get(cache_key)
603
- if entry and (now - entry[0]) < 120.0:
604
- xml_text = entry[1]
605
- else:
606
- resp = await client.get(RSS_FEEDS[source], timeout=HTTP_TIMEOUT)
607
- resp.raise_for_status()
608
- xml_text = resp.text
609
- _CACHE[cache_key] = (now, xml_text)
610
- except httpx.HTTPStatusError as e:
611
- raise HTTPException(status_code=502, detail=f"{source} RSS error: {e.response.status_code}")
612
- except httpx.RequestError as e:
613
- raise HTTPException(status_code=502, detail=f"Upstream request failed: {e}")
614
-
615
- # Minimal RSS parse (avoid extra deps): pull <title> and <link> from <item> blocks
616
- import re
617
- items = []
618
- for block in re.findall(r"<item>(.*?)</item>", xml_text, re.S)[:20]:
619
- title_m = re.search(r"<title>(.*?)</title>", block, re.S)
620
- link_m = re.search(r"<link>(.*?)</link>", block, re.S)
621
- items.append({
622
- "title": (title_m.group(1).strip() if title_m else "").replace("<![CDATA[", "").replace("]]>", ""),
623
- "link": (link_m.group(1).strip() if link_m else ""),
624
- })
625
-
626
- return {"source": source, "count": len(items), "items": items}
627
-
628
-
629
- @hub_router.get("/whales")
630
- async def hub_whales(limit: int = 20):
631
- """Recent large transactions via ClankApp (free, no auth)."""
632
- limit = max(1, min(limit, 100))
633
- async with httpx.AsyncClient() as client:
634
- try:
635
- raw = await _cached_get(client, CLANKAPP_URL, params={"size": limit}, ttl=30.0)
636
- except httpx.HTTPStatusError as e:
637
- raise HTTPException(status_code=502, detail=f"ClankApp error: {e.response.status_code}")
638
- except httpx.RequestError as e:
639
- raise HTTPException(status_code=502, detail=f"Upstream request failed: {e}")
640
-
641
- return {"source": "clankapp", "count": len(raw) if isinstance(raw, list) else None, "data": raw}
642
-
643
-
644
- @hub_router.get("/market/alt")
645
- async def hub_market_alt(symbol: str = "btc-bitcoin"):
646
- """Fallback market data via Coinpaprika (free, no auth, no Binance dependency).
647
-
648
- `symbol` is a Coinpaprika coin id, e.g. btc-bitcoin, eth-ethereum.
649
- """
650
- async with httpx.AsyncClient() as client:
651
- try:
652
- raw = await _cached_get(client, f"{COINPAPRIKA_BASE}/tickers/{symbol}", ttl=15.0)
653
- except httpx.HTTPStatusError as e:
654
- raise HTTPException(status_code=502, detail=f"Coinpaprika error: {e.response.status_code}")
655
- except httpx.RequestError as e:
656
- raise HTTPException(status_code=502, detail=f"Upstream request failed: {e}")
657
-
658
- quote = (raw.get("quotes") or {}).get("USD", {})
659
- return {
660
- "source": "coinpaprika",
661
- "id": raw.get("id"),
662
- "symbol": raw.get("symbol"),
663
- "name": raw.get("name"),
664
- "price_usd": quote.get("price"),
665
- "volume_24h_usd": quote.get("volume_24h"),
666
- "market_cap_usd": quote.get("market_cap"),
667
- "percent_change_24h": quote.get("percent_change_24h"),
668
- }
669
-
670
-
671
- # =========================
672
- # LAYER 3: LEGACY (no-break compatibility, forwards to hub layer)
673
- # =========================
674
-
675
- def bind_legacy(app: FastAPI):
676
-
677
- @app.get("/api/ohlcv")
678
- async def legacy_ohlcv(symbol: str, timeframe: str = "1h", limit: int = 100):
679
- return await hub_ohlcv(symbol, timeframe, limit)
680
-
681
- @app.get("/api/klines")
682
- async def legacy_klines(symbol: str, interval: str = "1h", limit: int = 100):
683
- result = await hub_ohlcv(symbol, interval, limit)
684
- return {"symbol": result["symbol"], "klines": result["candles"]}
685
-
686
- @app.get("/api/history")
687
- async def legacy_history(symbol: str, interval: str = "1d", limit: int = 30):
688
- result = await hub_ohlcv(symbol, interval, limit)
689
- return {"symbol": result["symbol"], "history": result["candles"]}
690
-
691
- @app.get("/api/orderbook")
692
- async def legacy_orderbook(symbol: str, limit: int = 50):
693
- return await hub_orderbook(symbol, limit)
694
-
695
- @app.get("/api/trading/orderbook")
696
- async def legacy_trade_ob(symbol: str, limit: int = 50):
697
- return await hub_orderbook(symbol, limit)
698
-
699
- @app.get("/api/volume")
700
- async def legacy_volume(symbol: str):
701
- return await hub_volume(symbol)
702
-
703
- @app.get("/api/trading/volume")
704
- async def legacy_trade_vol(symbol: str):
705
- return await hub_volume(symbol)
706
-
707
- @app.get("/api/news/{coin}")
708
- async def legacy_news(coin: str, limit: int = 20):
709
- return await hub_news(coin, limit)
710
-
711
- @app.get("/api/social/sentiment")
712
- async def legacy_sentiment():
713
- return await hub_sentiment()
714
-
715
- @app.get("/api/trading/funding")
716
- async def legacy_funding(symbol: str = "BTCUSDT"):
717
- return await hub_funding(symbol)
718
-
719
- @app.get("/api/trading/open-interest")
720
- async def legacy_oi(symbol: str = "BTCUSDT"):
721
- return await hub_open_interest(symbol)
722
-
723
-
724
- # =========================
725
- # INSTALLER (SAFE MOUNT)
726
- # =========================
727
-
728
- def install_zero_conf_router(app: FastAPI):
729
- """Mounts data-brain + hub routers and binds legacy compatibility routes.
730
-
731
- Skips any route path that already exists on `app` so this never
732
- overrides an existing, more specific implementation
733
- (e.g. /api/sentiment already implemented in api_server_extended.py).
734
- """
735
- existing_paths = {getattr(r, "path", None) for r in app.router.routes}
736
-
737
- for router in (data_brain_router, hub_router):
738
- for route in router.routes:
739
- full_path = router.prefix + route.path
740
- if full_path in existing_paths:
741
- logger.info("Skipping already-registered route: %s", full_path)
742
- continue
743
- app.include_router(router)
744
-
745
- # Legacy routes: only bind ones not already present on the app.
746
- legacy_paths_to_check = [
747
- "/api/ohlcv", "/api/klines", "/api/history", "/api/orderbook",
748
- "/api/trading/orderbook", "/api/volume", "/api/trading/volume",
749
- "/api/news/{coin}", "/api/social/sentiment",
750
- "/api/trading/funding", "/api/trading/open-interest",
751
- ]
752
- missing = [p for p in legacy_paths_to_check if p not in existing_paths]
753
- if missing:
754
- bind_legacy(app)
755
-
756
- print(f"[OK] Zero-Conflict Router v5 installed (real data via Binance/CryptoCompare/Fear&Greed)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
help.html CHANGED
@@ -1,14 +1,15 @@
1
  <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Space 4 · Real Data Guide</title><style>
2
  :root{color-scheme:dark;--bg:#070b14;--card:#10192abb;--line:#ffffff12;--text:#eff5ff;--muted:#90a2bd;--good:#42e5c6;--warn:#ffbd5b;--bad:#ff6b7d}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 15% 0,#16334b,transparent 28%),var(--bg);color:var(--text);font:15px/1.65 Inter,system-ui}header,main{width:min(1120px,calc(100% - 32px));margin:auto}header{display:flex;justify-content:space-between;align-items:center;padding:24px 0}a{color:#79ddff;text-decoration:none}nav a{margin-left:18px;color:var(--text)}.hero{padding:48px 0 30px}.kicker{color:var(--good);font-size:12px;letter-spacing:.12em;text-transform:uppercase}h1{font-size:clamp(34px,7vw,66px);line-height:1;margin:18px 0}h2{margin-top:50px}p{color:var(--muted)}.matrix{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.cell,.endpoint,.warning{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:18px}.cell b{display:block}.tag{font-size:11px;padding:5px 8px;border-radius:999px;background:#42e5c61b;color:var(--good)}.tag.warn{color:var(--warn);background:#ffbd5b1b}.tag.bad{color:var(--bad);background:#ff6b7d1b}.warning{border-color:#ffbd5b55}.endpoint{margin:14px 0}.method{font:700 12px ui-monospace;color:var(--good)}code,pre{font-family:ui-monospace,monospace}pre{overflow:auto;background:#050a12;border:1px solid var(--line);padding:15px;border-radius:13px;color:#cce6ff}summary{cursor:pointer}.route-list{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}.route{background:#0c1422;border:1px solid var(--line);border-radius:11px;padding:10px;font:12px ui-monospace}.muted{color:var(--muted)}@media(max-width:760px){.matrix,.route-list{grid-template-columns:1fr}nav{display:none}}
3
- </style></head><body><header><strong>Datasource 4 · Truthful API Guide</strong><nav><a href="/">Dashboard</a><a href="/data-lab">Data Lab</a><a href="/docs">OpenAPI</a></nav></header><main><section class="hero"><div class="kicker">Canonical compat router · updated 2026-08-25</div><h1>Market history and LIVE snapshots, clearly separated.</h1><p>The compatibility router is the canonical historical path. The Enterprise Data Hub's duplicate aliases remain unregistered. Short Hunter stays a LIVE-only contract.</p></section><div class="warning"><b>Consumer trap</b><p>HTTP 200 is not proof of data. Check <code>success</code>, <code>dataState</code>, <code>coverage.mode</code>, <code>count</code> and array length.</p></div><h2>Coverage matrix</h2><div class="matrix"><div class="cell"><b>OHLCV</b><span class="tag">REAL HISTORICAL</span><p>Binance Vision → Binance → KuCoin → CryptoCompare, with temporal pagination.</p></div><div class="cell"><b>Funding</b><span class="tag">REAL HISTORICAL</span><p>Gate.io provides real funding with about 180 days of retention in the current HF runtime; Binance/Bybit are geo-blocked there.</p></div><div class="cell"><b>Open interest</b><span class="tag warn">180-DAY + FORWARD</span><p>Gate.io supplies the real ~180-day production window plus a daily SQLite cache; Binance/Bybit are geo-blocked from the current HF runtime.</p></div><div class="cell"><b>Fear & Greed</b><span class="tag">REAL HISTORICAL</span><p>Full Alternative.me series.</p></div><div class="cell"><b>News</b><span class="tag warn">FORWARD ONLY</span><p>Authenticated NewsData latest feed; no pre-deployment history.</p></div><div class="cell"><b>Whale flow</b><span class="tag warn">FORWARD ONLY</span><p>Real large native ETH/BNB transfers from public RPC blocks; no owner labels.</p></div></div><h2>Endpoints and verified behavior</h2>
4
  <article class="endpoint"><div class="method">GET</div><h3>/api/ohlcv · /api/klines · /api/history</h3><p>Intentional aliases of one implementation. Supports <code>since/start/startTime</code> and <code>end/endTime</code> in milliseconds.</p><pre>curl "$BASE/api/history?symbol=BTCUSDT&amp;interval=4h&amp;start=1640995200000&amp;end=1735689599999&amp;limit=10000"</pre><pre>const body = await fetch(`${base}/api/history?symbol=BTCUSDT&amp;interval=4h&amp;start=${start}&amp;end=${end}&amp;limit=10000`).then(r=&gt;r.json());
5
  if (!body.success || !body.data.length) throw new Error('history unavailable');</pre><details><summary>Captured response summary</summary><pre>{"success":true,"source":"binance_public","dataState":"REAL","count":6576,"coverage":{"mode":"REAL_HISTORICAL","earliestTimestamp":1640995200000,"latestTimestamp":1735675200000}}</pre></details></article>
6
- <article class="endpoint"><div class="method">GET / POST</div><h3>/api/apex/funding/{symbol} · /api/apex/open-interest/{symbol} · /api/apex/open-interest-archive/{symbol}</h3><p>Funding is multi-year. OI's live source is limited to 30 days, but startup + daily collection upserts the complete rolling window for <code>APEX_TRACKED_SYMBOLS</code>, deduped by symbol + timestamp. Production history begins at <code>archiveStartedAt</code>; earlier OI cannot be backfilled. <code>/api/apex/coverage</code> distinguishes a mounted <code>/data</code> persistent volume from ephemeral container storage.</p><pre>curl "$BASE/api/apex/funding/BTCUSDT?start=1640995200000&amp;end=1735689599999&amp;limit=20000"
7
  curl "$BASE/api/apex/open-interest/BTCUSDT?period=1h&amp;limit=500"
8
  curl "$BASE/api/apex/open-interest-archive/BTCUSDT?limit=10000"
9
- curl -X POST "$BASE/api/apex/open-interest/archive/collect?period=1h"</pre><pre>const archivedOI = await fetch(`${base}/api/apex/open-interest-archive/BTCUSDT?limit=10000`).then(r=&gt;r.json());</pre><details><summary>Captured response summaries</summary><pre>funding: {"count":3288,"earliestTimestamp":1640995200006,"latestTimestamp":1735660800000}
10
- openInterest: {"trackedSymbols":5,"rowsPerSymbol":500,"dedupeKey":["symbol","timestamp"],"secondRunDuplicateRows":0}</pre></details></article>
11
- <article class="endpoint"><div class="method">GET</div><h3>/api/apex/sentiment/fear-greed?limit=0</h3><p>Full dated Alternative.me history.</p><pre>curl "$BASE/api/apex/sentiment/fear-greed?limit=0"</pre><pre>const sentiment = await fetch(`${base}/api/apex/sentiment/fear-greed?limit=0`).then(r=&gt;r.json());</pre><details><summary>Captured response summary</summary><pre>{"count":3123,"source":"alternative.me","coverage":{"mode":"REAL_HISTORICAL","earliestTimestamp":"1517443200","latestTimestamp":"1787529600"}}</pre></details></article>
 
12
  <article class="endpoint"><div class="method">GET</div><h3>/api/short-hunter/snapshot/{symbol}</h3><p>LIVE-only multi-capability snapshot. It is not a replay endpoint and accepts no historical timestamp.</p><pre>curl "$BASE/api/short-hunter/snapshot/BTCUSDT?interval=1h&amp;limit=120"</pre><pre>const snap = await fetch(`${base}/api/short-hunter/snapshot/BTCUSDT`).then(r=&gt;r.json());
13
  if (snap.sourceMode !== 'LIVE' || snap.dataState !== 'REAL') disableTrading();</pre><details><summary>Contract states</summary><pre>LIVE/REAL = every required component succeeded
14
  DEGRADED/PARTIAL = some components missing
@@ -22,4 +23,4 @@ BitQuery old/new without token: HTTP 401 · ClankApp: HTTP 403
22
  DIY live test: 12 ETH blocks, 2,556 transactions, 2 matches ≥50 ETH; 12 BSC blocks, 744 transactions, 0 matches ≥500 BNB
23
  BitQuery signup: https://ide.bitquery.io (time-limited free trial; paid production plans thereafter)
24
  Whale source: public_evm_rpc_large_native_transfers; forward-only</pre></details></article>
25
- <h2>All live routes</h2><p>Generated from the deployed OpenAPI contract.</p><div id="routes" class="route-list"><div class="muted">Loading OpenAPI…</div></div><script>fetch('/openapi.json').then(r=>r.json()).then(j=>{routes.innerHTML=Object.entries(j.paths).flatMap(([p,ops])=>Object.entries(ops).filter(([m])=>['get','post','put','delete','patch'].includes(m)).map(([m,o])=>`<div class="route"><b>${m.toUpperCase()}</b> ${p}<br><span class="muted">${o.summary||o.description?.split('\n')[0]||'Registered endpoint'}</span></div>`)).join('')}).catch(e=>routes.textContent='OpenAPI unavailable: '+e.message)</script></main></body></html>
 
1
  <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Space 4 · Real Data Guide</title><style>
2
  :root{color-scheme:dark;--bg:#070b14;--card:#10192abb;--line:#ffffff12;--text:#eff5ff;--muted:#90a2bd;--good:#42e5c6;--warn:#ffbd5b;--bad:#ff6b7d}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 15% 0,#16334b,transparent 28%),var(--bg);color:var(--text);font:15px/1.65 Inter,system-ui}header,main{width:min(1120px,calc(100% - 32px));margin:auto}header{display:flex;justify-content:space-between;align-items:center;padding:24px 0}a{color:#79ddff;text-decoration:none}nav a{margin-left:18px;color:var(--text)}.hero{padding:48px 0 30px}.kicker{color:var(--good);font-size:12px;letter-spacing:.12em;text-transform:uppercase}h1{font-size:clamp(34px,7vw,66px);line-height:1;margin:18px 0}h2{margin-top:50px}p{color:var(--muted)}.matrix{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.cell,.endpoint,.warning{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:18px}.cell b{display:block}.tag{font-size:11px;padding:5px 8px;border-radius:999px;background:#42e5c61b;color:var(--good)}.tag.warn{color:var(--warn);background:#ffbd5b1b}.tag.bad{color:var(--bad);background:#ff6b7d1b}.warning{border-color:#ffbd5b55}.endpoint{margin:14px 0}.method{font:700 12px ui-monospace;color:var(--good)}code,pre{font-family:ui-monospace,monospace}pre{overflow:auto;background:#050a12;border:1px solid var(--line);padding:15px;border-radius:13px;color:#cce6ff}summary{cursor:pointer}.route-list{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}.route{background:#0c1422;border:1px solid var(--line);border-radius:11px;padding:10px;font:12px ui-monospace}.muted{color:var(--muted)}@media(max-width:760px){.matrix,.route-list{grid-template-columns:1fr}nav{display:none}}
3
+ </style></head><body><header><strong>Datasource 4 · Truthful API Guide</strong><nav><a href="/">Dashboard</a><a href="/data-lab">Data Lab</a><a href="/docs">OpenAPI</a></nav></header><main><section class="hero"><div class="kicker">Live contract guide · updated 2026-08-25</div><h1>Market history and LIVE snapshots, clearly separated.</h1><p>The compatibility router is the canonical historical path. Duplicate Enterprise Data Hub implementations are not part of the production router and have been removed in this cleanup. Short Hunter stays a LIVE-only contract.</p></section><div class="warning"><b>Consumer trap</b><p>HTTP 200 is not proof of data. Check <code>success</code>, <code>dataState</code>, <code>coverage.mode</code>, <code>count</code> and array length.</p></div><h2>Coverage matrix</h2><div class="matrix"><div class="cell"><b>OHLCV</b><span class="tag">REAL HISTORICAL</span><p>Binance Vision → Binance → KuCoin → CryptoCompare, with temporal pagination.</p></div><div class="cell"><b>Funding</b><span class="tag">REAL HISTORICAL</span><p>Gate.io provides real funding with about 180 days of retention in the current HF runtime; Binance/Bybit are geo-blocked there.</p></div><div class="cell"><b>Open interest</b><span class="tag warn">180-DAY + FORWARD</span><p>Gate.io supplies the real ~180-day production window plus a daily SQLite cache; Binance/Bybit are geo-blocked from the current HF runtime.</p></div><div class="cell"><b>Fear & Greed</b><span class="tag">REAL HISTORICAL</span><p>Full Alternative.me series.</p></div><div class="cell"><b>News</b><span class="tag warn">FORWARD ONLY</span><p>Authenticated NewsData latest feed; no pre-deployment history.</p></div><div class="cell"><b>Whale flow</b><span class="tag warn">FORWARD ONLY</span><p>Real large native ETH/BNB transfers from public RPC blocks; no owner labels.</p></div></div><h2>Endpoints and verified behavior</h2>
4
  <article class="endpoint"><div class="method">GET</div><h3>/api/ohlcv · /api/klines · /api/history</h3><p>Intentional aliases of one implementation. Supports <code>since/start/startTime</code> and <code>end/endTime</code> in milliseconds.</p><pre>curl "$BASE/api/history?symbol=BTCUSDT&amp;interval=4h&amp;start=1640995200000&amp;end=1735689599999&amp;limit=10000"</pre><pre>const body = await fetch(`${base}/api/history?symbol=BTCUSDT&amp;interval=4h&amp;start=${start}&amp;end=${end}&amp;limit=10000`).then(r=&gt;r.json());
5
  if (!body.success || !body.data.length) throw new Error('history unavailable');</pre><details><summary>Captured response summary</summary><pre>{"success":true,"source":"binance_public","dataState":"REAL","count":6576,"coverage":{"mode":"REAL_HISTORICAL","earliestTimestamp":1640995200000,"latestTimestamp":1735675200000}}</pre></details></article>
6
+ <article class="endpoint"><div class="method">GET / POST</div><h3>/api/apex/funding/{symbol} · /api/apex/open-interest/{symbol} · /api/apex/open-interest-archive/{symbol}</h3><p>In the current Hugging Face runtime, Binance Futures returns 451 and all tested Bybit domains return 403. Gate.io is the working real-data fallback for Funding and OI, with approximately 180 days of upstream retention. Startup + daily collection upserts the available OI window for <code>APEX_TRACKED_SYMBOLS</code>, deduped by symbol + timestamp. The local archive begins at <code>archiveStartedAt</code>; requests beyond upstream retention cannot be recovered. <code>/api/apex/coverage</code> reports row counts, collector health, and whether storage is persistent or ephemeral.</p><pre>curl "$BASE/api/apex/funding/BTCUSDT?limit=1000"
7
  curl "$BASE/api/apex/open-interest/BTCUSDT?period=1h&amp;limit=500"
8
  curl "$BASE/api/apex/open-interest-archive/BTCUSDT?limit=10000"
9
+ curl -X POST "$BASE/api/apex/open-interest/archive/collect?period=1h"</pre><pre>const archivedOI = await fetch(`${base}/api/apex/open-interest-archive/BTCUSDT?limit=10000`).then(r=&gt;r.json());</pre><details><summary>Current production behavior</summary><pre>funding: source=gateio_futures · retention=about_180_days
10
+ openInterest: source=gateio_futures · archive is CACHED/REAL when rows exist
11
+ storage: EPHEMERAL_LOCAL_DISK (forward archives are not rebuild-safe)</pre></details></article>
12
+ <article class="endpoint"><div class="method">GET</div><h3>/api/apex/sentiment/fear-greed?limit=0</h3><p>Full dated Alternative.me history. Treat the live <code>count</code> and coverage timestamps as authoritative because the series grows daily.</p><pre>curl "$BASE/api/apex/sentiment/fear-greed?limit=0"</pre><pre>const sentiment = await fetch(`${base}/api/apex/sentiment/fear-greed?limit=0`).then(r=&gt;r.json());</pre></article>
13
  <article class="endpoint"><div class="method">GET</div><h3>/api/short-hunter/snapshot/{symbol}</h3><p>LIVE-only multi-capability snapshot. It is not a replay endpoint and accepts no historical timestamp.</p><pre>curl "$BASE/api/short-hunter/snapshot/BTCUSDT?interval=1h&amp;limit=120"</pre><pre>const snap = await fetch(`${base}/api/short-hunter/snapshot/BTCUSDT`).then(r=&gt;r.json());
14
  if (snap.sourceMode !== 'LIVE' || snap.dataState !== 'REAL') disableTrading();</pre><details><summary>Contract states</summary><pre>LIVE/REAL = every required component succeeded
15
  DEGRADED/PARTIAL = some components missing
 
23
  DIY live test: 12 ETH blocks, 2,556 transactions, 2 matches ≥50 ETH; 12 BSC blocks, 744 transactions, 0 matches ≥500 BNB
24
  BitQuery signup: https://ide.bitquery.io (time-limited free trial; paid production plans thereafter)
25
  Whale source: public_evm_rpc_large_native_transfers; forward-only</pre></details></article>
26
+ <h2>All registered routes</h2><p>Loaded from the current OpenAPI contract and grouped by its real tags.</p><div id="route-sections"><div class="muted">Loading OpenAPI…</div></div><script>const routeRoot=document.getElementById('route-sections');fetch('/openapi.json').then(r=>r.json()).then(j=>{const groups={};Object.entries(j.paths).forEach(([p,ops])=>Object.entries(ops).filter(([m])=>['get','post','put','delete','patch'].includes(m)).forEach(([m,o])=>{const tag=(o.tags||['Other'])[0];(groups[tag]??=[]).push([m,p,o])}));routeRoot.innerHTML=Object.entries(groups).map(([tag,items])=>`<section><h3>${tag} <span class="muted">(${items.length})</span></h3><div class="route-list">${items.map(([m,p,o])=>`<div class="route"><b>${m.toUpperCase()}</b> ${p}<br><span class="muted">${o.summary||o.description?.split('\n')[0]||'Registered endpoint'}</span></div>`).join('')}</div></section>`).join('')}).catch(e=>routeRoot.textContent='OpenAPI unavailable: '+e.message)</script></main></body></html>
index.html CHANGED
@@ -1,922 +1,21 @@
1
- <!DOCTYPE html>
2
- <html lang="en" dir="ltr">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
6
- <meta http-equiv="Permissions-Policy" content="interest-cohort=()">
7
- <title>Crypto Intelligence Hub - AI-Powered Data Center</title>
8
-
9
- <!-- Fonts -->
10
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
11
- <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
12
-
13
- <!-- Charts -->
14
- <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
15
-
16
- <!-- Styles -->
17
- <link rel="stylesheet" href="/static/css/main.css?v=10">
18
- <link rel="stylesheet" href="/static/css/toast.css?v=10">
19
- <script src="/static/js/api-resource-loader.js?v=10" defer></script>
20
- <script src="/static/js/trading-pairs-loader.js?v=10" defer></script>
21
- <script src="/static/js/toast.js?v=10" defer></script>
22
- <script src="/static/js/app.js?v=10" defer></script>
23
-
24
- <!-- Favicon -->
25
- <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🚀</text></svg>">
26
- </head>
27
- <body>
28
- <!-- Toast Container -->
29
- <div class="toast-container" id="toast-container"></div>
30
-
31
- <div class="app-layout">
32
- <!-- Sidebar Navigation -->
33
- <aside class="sidebar" id="sidebar">
34
- <div class="sidebar-header">
35
- <div class="sidebar-logo" onclick="switchTab('dashboard')" style="cursor: pointer;">
36
- <div class="logo-icon">
37
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
38
- <path d="M12 2L2 7l10 5 10-5-10-5z"></path>
39
- <path d="M2 17l10 5 10-5"></path>
40
- <path d="M2 12l10 5 10-5"></path>
41
- </svg>
42
- </div>
43
- <div class="logo-text">
44
- <h1>Crypto Hub</h1>
45
- <p>AI-Powered</p>
46
- </div>
47
- </div>
48
- <button class="sidebar-toggle-btn" id="sidebar-close" onclick="toggleSidebar()">
49
- <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
50
- <line x1="18" y1="6" x2="6" y2="18"></line>
51
- <line x1="6" y1="6" x2="18" y2="18"></line>
52
- </svg>
53
- </button>
54
- </div>
55
-
56
- <nav class="sidebar-nav">
57
- <button class="nav-item active" data-tab="dashboard" onclick="switchTab('dashboard')">
58
- <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
59
- <line x1="18" y1="20" x2="18" y2="10"></line>
60
- <line x1="12" y1="20" x2="12" y2="4"></line>
61
- <line x1="6" y1="20" x2="6" y2="14"></line>
62
- </svg>
63
- <span>Dashboard</span>
64
- </button>
65
- <button class="nav-item" data-tab="market" onclick="switchTab('market')">
66
- <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
67
- <circle cx="12" cy="12" r="10"></circle>
68
- <path d="M12 6v6l4 2"></path>
69
- </svg>
70
- <span>Market</span>
71
- </button>
72
- <button class="nav-item" data-tab="models" onclick="switchTab('models')">
73
- <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
74
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
75
- <rect x="7" y="7" width="3" height="3"></rect>
76
- <rect x="14" y="7" width="3" height="3"></rect>
77
- <rect x="7" y="14" width="3" height="3"></rect>
78
- <rect x="14" y="14" width="3" height="3"></rect>
79
- </svg>
80
- <span>AI Models</span>
81
- </button>
82
- <button class="nav-item" data-tab="sentiment" onclick="switchTab('sentiment')">
83
- <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
84
- <path d="M9 11a3 3 0 1 0 6 0 3 3 0 0 0-6 0z"></path>
85
- <path d="M17.5 11a2.5 2.5 0 1 0 5 0 2.5 2.5 0 0 0-5 0z"></path>
86
- <path d="M6.5 11a2.5 2.5 0 1 0 5 0 2.5 2.5 0 0 0-5 0z"></path>
87
- </svg>
88
- <span>Sentiment</span>
89
- </button>
90
- <button class="nav-item" data-tab="trading-assistant" onclick="switchTab('trading-assistant')">
91
- <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
92
- <line x1="12" y1="1" x2="12" y2="23"></line>
93
- <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
94
- </svg>
95
- <span>Trading Signals</span>
96
- </button>
97
- <button class="nav-item" data-tab="news" onclick="switchTab('news')">
98
- <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
99
- <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
100
- <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
101
- <line x1="9" y1="8" x2="15" y2="8"></line>
102
- <line x1="9" y1="12" x2="15" y2="12"></line>
103
- </svg>
104
- <span>News</span>
105
- </button>
106
- <button class="nav-item" data-tab="ai-tools" onclick="switchTab('ai-tools')">
107
- <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
108
- <path d="M12 2L2 7l10 5 10-5-10-5z"></path>
109
- <path d="M2 17l10 5 10-5"></path>
110
- <path d="M2 12l10 5 10-5"></path>
111
- <circle cx="12" cy="12" r="1"></circle>
112
- </svg>
113
- <span>AI Design Tools</span>
114
- </button>
115
- <div class="nav-section">
116
- <div class="nav-section-header">
117
- <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
118
- <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
119
- <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
120
- <path d="M9 8h6"></path>
121
- <path d="M9 12h6"></path>
122
- </svg>
123
- <span>Reference</span>
124
- </div>
125
- <div class="nav-section-items">
126
- <button class="nav-item nav-subitem" data-tab="help" onclick="switchTab('help')">
127
- <span>Help / API Reference</span>
128
- </button>
129
- </div>
130
- </div>
131
- <div class="nav-section">
132
- <div class="nav-section-header">
133
- <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
134
- <circle cx="12" cy="12" r="3"></circle>
135
- <path d="M12 1v6m0 6v6m9-9h-6m-6 0H3m15.364 6.364l-4.243-4.243m-4.242 0L5.636 17.364m12.728 0l-4.243-4.243m-4.242 0L5.636 6.636"></path>
136
- </svg>
137
- <span>Settings</span>
138
- </div>
139
- <div class="nav-section-items">
140
- <button class="nav-item nav-subitem" data-tab="settings" onclick="switchTab('settings')">
141
- <span>⚙️ System Status</span>
142
- </button>
143
- <button class="nav-item nav-subitem" data-tab="diagnostics" onclick="switchTab('diagnostics')">
144
- <span>🔍 Test & Diagnostics</span>
145
- </button>
146
- </div>
147
- </div>
148
- </nav>
149
-
150
- <div class="sidebar-footer">
151
- <div class="status-indicator">
152
- <span class="status-dot"></span>
153
- <span id="sidebar-status">Gateway initializing...</span>
154
- </div>
155
- <div class="sidebar-stats">
156
- <div class="stat-mini">
157
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
158
- <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
159
- <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
160
- <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
161
- </svg>
162
- <span id="sidebar-resources">-</span>
163
- <small>Resources</small>
164
- </div>
165
- <div class="stat-mini">
166
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
167
- <path d="M16 3h5v5"></path>
168
- <path d="M4 20h5"></path>
169
- <path d="M21 3 12 12"></path>
170
- <path d="M15 12a3 3 0 0 0-3 3v2a3 3 0 0 1-3 3"></path>
171
- </svg>
172
- <span id="sidebar-apis">-</span>
173
- <small>APIs</small>
174
- </div>
175
- <div class="stat-mini">
176
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
177
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
178
- <rect x="7" y="7" width="3" height="3"></rect>
179
- <rect x="14" y="7" width="3" height="3"></rect>
180
- <rect x="7" y="14" width="3" height="3"></rect>
181
- <rect x="14" y="14" width="3" height="3"></rect>
182
- </svg>
183
- <span id="sidebar-sources">-</span>
184
- <small>Sources</small>
185
- </div>
186
- </div>
187
- </div>
188
- </aside>
189
-
190
- <!-- Mobile Overlay -->
191
- <div class="sidebar-overlay" id="sidebar-overlay" onclick="toggleSidebar()"></div>
192
-
193
- <!-- Main Content Area -->
194
- <div class="main-wrapper">
195
- <!-- Top Header -->
196
- <header class="top-header">
197
- <button class="hamburger-btn" id="hamburger-btn" onclick="toggleSidebar()">
198
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
199
- <line x1="3" y1="12" x2="21" y2="12"></line>
200
- <line x1="3" y1="6" x2="21" y2="6"></line>
201
- <line x1="3" y1="18" x2="21" y2="18"></line>
202
- </svg>
203
- </button>
204
- <div class="header-title">
205
- <h2 id="page-title">Dashboard</h2>
206
- <p id="page-subtitle">System Overview</p>
207
- </div>
208
- <div class="header-actions">
209
- <button class="icon-btn" onclick="toggleTheme()" title="Toggle Theme">
210
- <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
211
- <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
212
- </svg>
213
- </button>
214
- <button class="icon-btn" onclick="refreshCurrentTab()" title="Refresh">
215
- <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
216
- <polyline points="23 4 23 10 17 10"></polyline>
217
- <polyline points="1 20 1 14 7 14"></polyline>
218
- <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
219
- </svg>
220
- </button>
221
- </div>
222
- </header>
223
-
224
- <!-- Content Area -->
225
- <main class="content-area">
226
- <!-- Dashboard Tab -->
227
- <section id="tab-dashboard" class="tab-panel active">
228
- <div class="stats-grid" id="dashboard-stats">
229
- <div class="stat-card gradient-purple">
230
- <div class="stat-icon">
231
- <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
232
- <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
233
- <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
234
- <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
235
- </svg>
236
- </div>
237
- <div class="stat-content">
238
- <div class="stat-value" id="stat-total-resources">-</div>
239
- <div class="stat-label">Total Resources</div>
240
- <div class="stat-trend">
241
- <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
242
- <polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline>
243
- <polyline points="17 6 23 6 23 12"></polyline>
244
- </svg>
245
- Active
246
- </div>
247
- </div>
248
- </div>
249
- <div class="stat-card gradient-green">
250
- <div class="stat-icon">
251
- <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
252
- <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
253
- <polyline points="22 4 12 14.01 9 11.01"></polyline>
254
- </svg>
255
- </div>
256
- <div class="stat-content">
257
- <div class="stat-value" id="stat-free-resources">-</div>
258
- <div class="stat-label">Free Resources</div>
259
- <div class="stat-trend">
260
- <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
261
- <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
262
- <polyline points="22 4 12 14.01 9 11.01"></polyline>
263
- </svg>
264
- Available
265
- </div>
266
- </div>
267
- </div>
268
- <div class="stat-card gradient-blue">
269
- <div class="stat-icon">
270
- <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
271
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
272
- <rect x="7" y="7" width="3" height="3"></rect>
273
- <rect x="14" y="7" width="3" height="3"></rect>
274
- <rect x="7" y="14" width="3" height="3"></rect>
275
- <rect x="14" y="14" width="3" height="3"></rect>
276
- </svg>
277
- </div>
278
- <div class="stat-content">
279
- <div class="stat-value" id="stat-models">-</div>
280
- <div class="stat-label">AI Models</div>
281
- <div class="stat-trend">
282
- <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
283
- <path d="M9 11a3 3 0 1 0 6 0 3 3 0 0 0-6 0z"></path>
284
- <path d="M17.5 11a2.5 2.5 0 1 0 5 0 2.5 2.5 0 0 0-5 0z"></path>
285
- <path d="M6.5 11a2.5 2.5 0 1 0 5 0 2.5 2.5 0 0 0-5 0z"></path>
286
- </svg>
287
- Ready
288
- </div>
289
- </div>
290
- </div>
291
- <div class="stat-card gradient-orange">
292
- <div class="stat-icon">
293
- <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
294
- <rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
295
- <line x1="8" y1="21" x2="16" y2="21"></line>
296
- <line x1="12" y1="17" x2="12" y2="21"></line>
297
- </svg>
298
- </div>
299
- <div class="stat-content">
300
- <div class="stat-value" id="stat-providers">-</div>
301
- <div class="stat-label">Providers</div>
302
- <div class="stat-trend">
303
- <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
304
- <path d="M2 12h20M12 2v20"></path>
305
- </svg>
306
- Online
307
- </div>
308
- </div>
309
- </div>
310
- </div>
311
-
312
- <div class="grid-2">
313
- <div class="glass-card">
314
- <h3>System Status</h3>
315
- <div id="system-status"></div>
316
- </div>
317
- <div class="glass-card">
318
- <h3>Category Statistics</h3>
319
- <canvas id="categories-chart"></canvas>
320
- </div>
321
- </div>
322
- </section>
323
-
324
- <!-- Market Tab -->
325
- <section id="tab-market" class="tab-panel">
326
- <div class="glass-card">
327
- <h3>Top Cryptocurrencies</h3>
328
- <div id="market-data"></div>
329
- </div>
330
-
331
- <div class="glass-card">
332
- <h3>Trending Coins</h3>
333
- <div id="trending-coins"></div>
334
- </div>
335
-
336
- <!-- Enhanced Sentiment Analysis Section -->
337
- <div class="glass-card">
338
- <div class="card-header">
339
- <div>
340
- <h3>📊 Market Sentiment Analysis</h3>
341
- <p class="text-secondary">Real-time market sentiment indicators and Fear & Greed Index</p>
342
- </div>
343
- <button class="btn-refresh" onclick="loadMarketData()" title="Refresh Sentiment Data">
344
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
345
- <polyline points="23 4 23 10 17 10"></polyline>
346
- <polyline points="1 20 1 14 7 14"></polyline>
347
- <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
348
- </svg>
349
- </button>
350
- </div>
351
- <div id="fear-greed"></div>
352
- </div>
353
- </section>
354
-
355
- <!-- Models Tab -->
356
- <section id="tab-models" class="tab-panel">
357
- <div class="glass-card">
358
- <div class="card-header">
359
- <h3>Hugging Face Models</h3>
360
- <button class="btn-primary" onclick="initializeModels()">
361
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
362
- <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
363
- </svg>
364
- Load Models
365
- </button>
366
- </div>
367
- <div id="models-status"></div>
368
- </div>
369
-
370
- <div class="glass-card">
371
- <h3>Models List</h3>
372
- <div id="models-list"></div>
373
- </div>
374
- </section>
375
-
376
- <!-- Sentiment Analysis Tab -->
377
- <section id="tab-sentiment" class="tab-panel">
378
- <!-- Global Market Sentiment -->
379
- <div class="glass-card">
380
- <h3>Global Market Sentiment</h3>
381
- <p class="text-secondary">Analyze overall crypto market sentiment using AI models</p>
382
- <button class="btn-primary" onclick="analyzeGlobalSentiment()">
383
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
384
- <line x1="18" y1="20" x2="18" y2="10"></line>
385
- <line x1="12" y1="20" x2="12" y2="4"></line>
386
- <line x1="6" y1="20" x2="6" y2="14"></line>
387
- </svg>
388
- Analyze Market Sentiment
389
- </button>
390
- <div id="global-sentiment-result" style="margin-top: 20px;"></div>
391
- </div>
392
-
393
- <!-- Per-Asset Sentiment -->
394
- <div class="glass-card">
395
- <h3>Per-Asset Sentiment Analysis</h3>
396
- <div class="form-group">
397
- <label>Trading Pair:</label>
398
- <div id="asset-symbol-container">
399
- <input type="text" id="asset-symbol" placeholder="Loading pairs..." readonly>
400
- </div>
401
- </div>
402
- <div class="form-group">
403
- <label>Related Text or News (Optional):</label>
404
- <textarea id="asset-sentiment-text" rows="3" placeholder="Example: Bitcoin breaks resistance at $50,000"></textarea>
405
- </div>
406
- <button class="btn-primary" onclick="analyzeAssetSentiment()">
407
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
408
- <circle cx="11" cy="11" r="8"></circle>
409
- <path d="M21 21l-4.35-4.35"></path>
410
- </svg>
411
- Analyze Asset Sentiment
412
- </button>
413
- <div id="asset-sentiment-result" style="margin-top: 20px;"></div>
414
- </div>
415
-
416
- <!-- Text Analysis -->
417
- <div class="glass-card">
418
- <h3>Text Analysis</h3>
419
- <div class="form-group">
420
- <label>Text to Analyze:</label>
421
- <textarea id="sentiment-text" rows="5" placeholder="Example: Bitcoin price is rising rapidly!"></textarea>
422
- </div>
423
- <div class="form-group">
424
- <label>Analysis Type:</label>
425
- <select id="sentiment-mode">
426
- <option value="auto">Auto (Crypto)</option>
427
- <option value="crypto">Crypto</option>
428
- <option value="financial">Financial</option>
429
- <option value="social">Social</option>
430
- </select>
431
- </div>
432
- <button class="btn-primary" onclick="analyzeSentiment()">
433
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
434
- <circle cx="11" cy="11" r="8"></circle>
435
- <path d="M21 21l-4.35-4.35"></path>
436
- </svg>
437
- Analyze
438
- </button>
439
- <div id="sentiment-result"></div>
440
- </div>
441
- </section>
442
-
443
- <!-- Trading Assistant Tab -->
444
- <section id="tab-trading-assistant" class="tab-panel">
445
- <div class="glass-card">
446
- <h3>Trading Signal Assistant</h3>
447
- <p class="text-secondary">Get AI-powered trading signals (BUY/SELL/HOLD) based on market analysis.</p>
448
-
449
- <div class="form-group">
450
- <label>Trading Symbol:</label>
451
- <div id="trading-symbol-container">
452
- <input type="text" id="trading-symbol" placeholder="Loading pairs..." readonly>
453
- </div>
454
- </div>
455
-
456
- <div class="form-group">
457
- <label>Market Context (Optional):</label>
458
- <textarea id="trading-context" rows="4" placeholder="Example: Recent breakout above resistance, high volume, positive news flow..."></textarea>
459
- </div>
460
-
461
- <button class="btn-primary" onclick="runTradingAssistant()">
462
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
463
- <circle cx="12" cy="12" r="10"></circle>
464
- <polygon points="12 6 16 14 8 14"></polygon>
465
- </svg>
466
- Get Trading Signal
467
- </button>
468
-
469
- <div id="trading-assistant-result" style="margin-top: 20px;"></div>
470
- </div>
471
-
472
- <div class="glass-card">
473
- <h3>⚠️ Disclaimer</h3>
474
- <div class="alert alert-warning">
475
- <strong>Important:</strong> This trading assistant provides AI-generated signals for informational purposes only.
476
- It should NOT be used as the sole basis for trading decisions. Always conduct your own research.
477
- </div>
478
- </div>
479
- </section>
480
-
481
- <!-- News Tab -->
482
- <section id="tab-news" class="tab-panel">
483
- <div class="glass-card">
484
- <div class="card-header">
485
- <h3>Crypto News</h3>
486
- <div style="display: flex; gap: 10px;">
487
- <button class="btn-primary" onclick="fetchNewsFromAPI()">
488
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
489
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
490
- <polyline points="7 10 12 15 17 10"></polyline>
491
- <line x1="12" y1="15" x2="12" y2="3"></line>
492
- </svg>
493
- Fetch Latest News
494
- </button>
495
- <button class="btn-refresh" onclick="loadNews()">
496
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
497
- <polyline points="23 4 23 10 17 10"></polyline>
498
- <polyline points="1 20 1 14 7 14"></polyline>
499
- <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
500
- </svg>
501
- Refresh
502
- </button>
503
- </div>
504
- </div>
505
- <div id="news-list"></div>
506
- </div>
507
- </section>
508
-
509
- <!-- Settings Tab -->
510
- <section id="tab-settings" class="tab-panel">
511
- <div class="glass-card">
512
- <h3>System Settings</h3>
513
- <div class="form-group">
514
- <label>Theme:</label>
515
- <select id="theme-select" onchange="changeTheme(this.value)">
516
- <option value="dark">Dark Mode</option>
517
- <option value="light">Light Mode</option>
518
- </select>
519
- </div>
520
- <div class="form-group">
521
- <label>Auto Refresh Interval (seconds):</label>
522
- <input type="number" id="refresh-interval" value="30" min="10" max="300">
523
- </div>
524
- <button class="btn-primary" onclick="saveSettings()">
525
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
526
- <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
527
- <polyline points="17 21 17 13 7 13 7 21"></polyline>
528
- <polyline points="7 3 7 8 15 8"></polyline>
529
- </svg>
530
- Save Settings
531
- </button>
532
- </div>
533
-
534
- <div class="glass-card">
535
- <h3>API Information</h3>
536
- <div id="api-info"></div>
537
- </div>
538
- </section>
539
-
540
- <!-- Providers Placeholder Tab -->
541
- <section id="tab-providers" class="tab-panel">
542
- <div class="placeholder-page">
543
- <div class="placeholder-icon">🧩</div>
544
- <h2>Providers</h2>
545
- <p>This feature is under development.</p>
546
- <p class="text-secondary">Provider management and configuration will be available here.</p>
547
- <button class="btn-primary" onclick="switchTab('diagnostics')">
548
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
549
- <path d="M12 2L2 7l10 5 10-5-10-5z"></path>
550
- <path d="M2 17l10 5 10-5"></path>
551
- <path d="M2 12l10 5 10-5"></path>
552
- </svg>
553
- Run Diagnostics
554
- </button>
555
- </div>
556
- </section>
557
-
558
- <!-- Resources Placeholder Tab -->
559
- <section id="tab-resources" class="tab-panel">
560
- <div class="placeholder-page">
561
- <div class="placeholder-icon">📚</div>
562
- <h2>Resources</h2>
563
- <p>This feature is under development.</p>
564
- <p class="text-secondary">Resource management and monitoring will be available here.</p>
565
- <button class="btn-primary" onclick="switchTab('diagnostics')">
566
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
567
- <path d="M12 2L2 7l10 5 10-5-10-5z"></path>
568
- <path d="M2 17l10 5 10-5"></path>
569
- <path d="M2 12l10 5 10-5"></path>
570
- </svg>
571
- Run Diagnostics
572
- </button>
573
- </div>
574
- </section>
575
-
576
- <!-- DeFi Analytics Placeholder Tab -->
577
- <section id="tab-defi" class="tab-panel">
578
- <div class="placeholder-page">
579
- <div class="placeholder-icon">💎</div>
580
- <h2>DeFi Analytics</h2>
581
- <p>This feature is under development.</p>
582
- <p class="text-secondary">DeFi protocol analytics and insights will be available here.</p>
583
- <button class="btn-primary" onclick="switchTab('diagnostics')">
584
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
585
- <path d="M12 2L2 7l10 5 10-5-10-5z"></path>
586
- <path d="M2 17l10 5 10-5"></path>
587
- <path d="M2 12l10 5 10-5"></path>
588
- </svg>
589
- Run Diagnostics
590
- </button>
591
- </div>
592
- </section>
593
-
594
- <!-- System Status Placeholder Tab -->
595
- <section id="tab-system-status" class="tab-panel">
596
- <div class="placeholder-page">
597
- <div class="placeholder-icon">⚙️</div>
598
- <h2>System Status</h2>
599
- <p>This feature is under development.</p>
600
- <p class="text-secondary">System health monitoring and status dashboard will be available here.</p>
601
- <button class="btn-primary" onclick="switchTab('diagnostics')">
602
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
603
- <path d="M12 2L2 7l10 5 10-5-10-5z"></path>
604
- <path d="M2 17l10 5 10-5"></path>
605
- <path d="M2 12l10 5 10-5"></path>
606
- </svg>
607
- Run Diagnostics
608
- </button>
609
- </div>
610
- </section>
611
-
612
- <!-- Test & Diagnostics Tab -->
613
- <section id="tab-diagnostics" class="tab-panel">
614
- <div class="glass-card">
615
- <div class="card-header">
616
- <div>
617
- <h3>🔧 Advanced Diagnostics & Monitoring</h3>
618
- <p class="text-secondary">Comprehensive system health monitoring, testing, and debugging tools</p>
619
- </div>
620
- <div style="display: flex; gap: 8px;">
621
- <button class="btn-refresh" onclick="toggleAutoRefresh()" id="auto-refresh-btn">
622
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 4px;">
623
- <polyline points="23 4 23 10 17 10"></polyline>
624
- <polyline points="1 20 1 14 7 14"></polyline>
625
- <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
626
- </svg>
627
- Auto: OFF
628
- </button>
629
- </div>
630
- </div>
631
- </div>
632
-
633
- <!-- Quick Status Cards -->
634
- <div class="stats-grid">
635
- <div class="stat-card gradient-blue" id="transformers-status">
636
- <div class="stat-icon">
637
- <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
638
- <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
639
- <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
640
- </svg>
641
- </div>
642
- <div class="stat-content">
643
- <div class="stat-label">ML Library Status</div>
644
- <div class="stat-value" id="transformers-status-value">Checking...</div>
645
- </div>
646
- </div>
647
-
648
- <div class="stat-card gradient-green" id="hf-status">
649
- <div class="stat-icon">
650
- <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
651
- <circle cx="12" cy="12" r="10"></circle>
652
- <path d="M8 12h8"></path>
653
- <path d="M12 8v8"></path>
654
- </svg>
655
- </div>
656
- <div class="stat-content">
657
- <div class="stat-label">HF Hub Connection</div>
658
- <div class="stat-value" id="hf-status-value">Checking...</div>
659
- </div>
660
- </div>
661
-
662
- <div class="stat-card gradient-purple" id="models-status">
663
- <div class="stat-icon">
664
- <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
665
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
666
- <rect x="7" y="7" width="3" height="3"></rect>
667
- <rect x="14" y="7" width="3" height="3"></rect>
668
- <rect x="7" y="14" width="3" height="3"></rect>
669
- <rect x="14" y="14" width="3" height="3"></rect>
670
- </svg>
671
- </div>
672
- <div class="stat-content">
673
- <div class="stat-label">AI Models Ready</div>
674
- <div class="stat-value" id="models-status-value">0</div>
675
- </div>
676
- </div>
677
-
678
- <div class="stat-card gradient-orange" id="last-test-status">
679
- <div class="stat-icon">
680
- <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
681
- <circle cx="12" cy="12" r="10"></circle>
682
- <polyline points="12 6 12 12 16 14"></polyline>
683
- </svg>
684
- </div>
685
- <div class="stat-content">
686
- <div class="stat-label">Last Diagnostic</div>
687
- <div class="stat-value" id="last-test-value" style="font-size: 13px;">Never</div>
688
- </div>
689
- </div>
690
- </div>
691
-
692
- <!-- System Health Overview -->
693
- <div class="glass-card">
694
- <div class="card-header">
695
- <h3>📊 System Health Overview</h3>
696
- <button class="btn-refresh" onclick="loadSystemHealth()" title="Refresh Health Status">
697
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
698
- <polyline points="23 4 23 10 17 10"></polyline>
699
- <polyline points="1 20 1 14 7 14"></polyline>
700
- <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
701
- </svg>
702
- </button>
703
- </div>
704
- <div id="system-health-overview" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px;">
705
- <!-- Will be populated by JavaScript -->
706
- </div>
707
- </div>
708
-
709
- <!-- Provider & Model Status Table -->
710
- <div class="glass-card">
711
- <div class="card-header">
712
- <h3>🔍 Provider & Model Status</h3>
713
- <div style="display: flex; gap: 8px;">
714
- <button class="btn-refresh" onclick="loadProviderHealth()" title="Refresh Provider Status">
715
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
716
- <polyline points="23 4 23 10 17 10"></polyline>
717
- <polyline points="1 20 1 14 7 14"></polyline>
718
- <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
719
- </svg>
720
- </button>
721
- <button class="btn-refresh" onclick="triggerSelfHeal()" title="Trigger Self-Healing">
722
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
723
- <path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"></path>
724
- </svg>
725
- Self-Heal
726
- </button>
727
- </div>
728
- </div>
729
- <div style="overflow-x: auto;">
730
- <table style="width: 100%; border-collapse: collapse;">
731
- <thead>
732
- <tr style="border-bottom: 1px solid var(--border);">
733
- <th style="padding: 12px; text-align: left; color: var(--text-secondary); font-weight: 600;">Name</th>
734
- <th style="padding: 12px; text-align: left; color: var(--text-secondary); font-weight: 600;">Type</th>
735
- <th style="padding: 12px; text-align: left; color: var(--text-secondary); font-weight: 600;">Status</th>
736
- <th style="padding: 12px; text-align: left; color: var(--text-secondary); font-weight: 600;">Last Check</th>
737
- <th style="padding: 12px; text-align: left; color: var(--text-secondary); font-weight: 600;">Actions</th>
738
- </tr>
739
- </thead>
740
- <tbody id="provider-health-table">
741
- <tr><td colspan="5" style="padding: 20px; text-align: center; color: var(--text-secondary);">Loading...</td></tr>
742
- </tbody>
743
- </table>
744
- </div>
745
- </div>
746
-
747
- <!-- Quick Diagnostic Actions -->
748
- <div class="glass-card">
749
- <div class="card-header">
750
- <h3>⚡ Quick Diagnostic Actions</h3>
751
- </div>
752
- <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;">
753
- <button class="btn-primary" id="run-diagnostics-btn" onclick="runDiagnostic()">
754
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
755
- <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
756
- </svg>
757
- Run Full Diagnostic
758
- </button>
759
- <button class="btn-refresh" onclick="testAPIEndpoints()">
760
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
761
- <path d="M18 20V10"></path>
762
- <path d="M12 20V4"></path>
763
- <path d="M6 20v-6"></path>
764
- </svg>
765
- Test API Endpoints
766
- </button>
767
- <button class="btn-refresh" onclick="checkDatabaseHealth()">
768
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
769
- <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
770
- <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
771
- <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
772
- </svg>
773
- Check Database
774
- </button>
775
- <button class="btn-refresh" onclick="testNetworkConnectivity()">
776
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
777
- <circle cx="12" cy="12" r="10"></circle>
778
- <path d="M2 12h20"></path>
779
- <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
780
- </svg>
781
- Network Test
782
- </button>
783
- <button class="btn-refresh" onclick="refreshDiagnosticStatus()">
784
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
785
- <polyline points="23 4 23 10 17 10"></polyline>
786
- <polyline points="1 20 1 14 7 14"></polyline>
787
- <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
788
- </svg>
789
- Refresh All
790
- </button>
791
- <button class="btn-refresh" onclick="downloadDiagnosticLog()">
792
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px;">
793
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
794
- <polyline points="7 10 12 15 17 10"></polyline>
795
- <line x1="12" y1="15" x2="12" y2="3"></line>
796
- </svg>
797
- Download Log
798
- </button>
799
- </div>
800
- </div>
801
-
802
- <!-- Tabs for different diagnostic views -->
803
- <div class="glass-card">
804
- <div style="display: flex; gap: 8px; border-bottom: 1px solid var(--border); margin-bottom: 20px;">
805
- <button class="diagnostic-tab-btn active" data-tab="output" onclick="switchDiagnosticTab('output')">Test Output</button>
806
- <button class="diagnostic-tab-btn" data-tab="health" onclick="switchDiagnosticTab('health')">Health Details</button>
807
- <button class="diagnostic-tab-btn" data-tab="api-test" onclick="switchDiagnosticTab('api-test')">API Tests</button>
808
- <button class="diagnostic-tab-btn" data-tab="logs" onclick="switchDiagnosticTab('logs')">Recent Logs</button>
809
- </div>
810
-
811
- <!-- Test Output Tab -->
812
- <div id="diagnostic-tab-output" class="diagnostic-tab-content active">
813
- <div class="card-header">
814
- <h3>Test Output</h3>
815
- <div id="test-progress" style="display: none;">
816
- <div class="spinner"></div>
817
- <span>Running diagnostic tests...</span>
818
- </div>
819
- </div>
820
- <div style="background: rgba(0, 0, 0, 0.3); border-radius: 8px; padding: 15px; max-height: 500px; overflow-y: auto; font-family: 'JetBrains Mono', monospace; font-size: 13px; line-height: 1.6;">
821
- <pre id="diagnostic-output" style="margin: 0; color: var(--text-primary); white-space: pre-wrap; word-wrap: break-word;"></pre>
822
- </div>
823
- </div>
824
-
825
- <!-- Health Details Tab -->
826
- <div id="diagnostic-tab-health" class="diagnostic-tab-content" style="display: none;">
827
- <h3>Detailed Health Information</h3>
828
- <div id="health-details-content" style="margin-top: 16px;">
829
- <p class="text-secondary">Click "Refresh All" to load detailed health information</p>
830
- </div>
831
- </div>
832
-
833
- <!-- API Tests Tab -->
834
- <div id="diagnostic-tab-api-test" class="diagnostic-tab-content" style="display: none;">
835
- <h3>API Endpoint Testing</h3>
836
- <div id="api-test-results" style="margin-top: 16px;">
837
- <p class="text-secondary">Click "Test API Endpoints" to run tests</p>
838
- </div>
839
- </div>
840
-
841
- <!-- Logs Tab -->
842
- <div id="diagnostic-tab-logs" class="diagnostic-tab-content" style="display: none;">
843
- <div class="card-header">
844
- <h3>Recent System Logs</h3>
845
- <button class="btn-refresh" onclick="loadRecentLogs()">
846
- <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
847
- <polyline points="23 4 23 10 17 10"></polyline>
848
- <polyline points="1 20 1 14 7 14"></polyline>
849
- <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
850
- </svg>
851
- </button>
852
- </div>
853
- <div id="recent-logs-content" style="margin-top: 16px; max-height: 500px; overflow-y: auto;">
854
- <p class="text-secondary">Loading logs...</p>
855
- </div>
856
- </div>
857
- </div>
858
-
859
- <!-- Test Results Summary -->
860
- <div class="glass-card" id="diagnostic-summary" style="display: none;">
861
- <h3>Test Results Summary</h3>
862
- <div class="grid-2">
863
- <div>
864
- <div style="display: flex; justify-content: space-between; padding: 12px; border-bottom: 1px solid var(--border);">
865
- <span class="text-secondary">Duration:</span>
866
- <strong id="summary-duration">-</strong>
867
- </div>
868
- <div style="display: flex; justify-content: space-between; padding: 12px; border-bottom: 1px solid var(--border);">
869
- <span class="text-secondary">Tests Passed:</span>
870
- <strong style="color: var(--success);" id="summary-passed">-</strong>
871
- </div>
872
- </div>
873
- <div>
874
- <div style="display: flex; justify-content: space-between; padding: 12px; border-bottom: 1px solid var(--border);">
875
- <span class="text-secondary">Tests Failed:</span>
876
- <strong style="color: var(--danger);" id="summary-failed">-</strong>
877
- </div>
878
- <div style="display: flex; justify-content: space-between; padding: 12px; border-bottom: 1px solid var(--border);">
879
- <span class="text-secondary">Critical Issues:</span>
880
- <strong style="color: var(--warning);" id="summary-critical">-</strong>
881
- </div>
882
- </div>
883
- </div>
884
- <div id="suggested-fixes" style="margin-top: 20px;"></div>
885
- </div>
886
- </section>
887
-
888
- <!-- AI Design Tools Tab -->
889
- <section id="tab-ai-tools" class="tab-panel">
890
- <div id="ai-tools-content" style="width: 100%; height: calc(100vh - 180px); min-height: 600px; position: relative;">
891
- <iframe
892
- id="ai-tools-iframe"
893
- src="/ai-tools"
894
- style="width: 100%; height: 100%; border: none; border-radius: 12px; background: transparent; display: none;"
895
- title="AI Design Tools"
896
- onload="handleAIToolsIframeLoad()"
897
- ></iframe>
898
- <div id="ai-tools-loading" style="text-align: center; padding: 60px 20px;">
899
- <div class="spinner" style="margin: 0 auto 20px;"></div>
900
- <p class="text-secondary">Loading AI Design Tools...</p>
901
- </div>
902
- </div>
903
- </section>
904
-
905
- <!-- Help / API Reference Tab -->
906
- <section id="tab-help" class="tab-panel">
907
- <div class="glass-card">
908
- <div class="help-loading-state">
909
- <div class="spinner"></div>
910
- <p class="text-secondary">Loading live API reference...</p>
911
- </div>
912
- </div>
913
- </section>
914
- </main>
915
- </div>
916
- </div>
917
- <nav aria-label="Historical data tools" style="position:fixed;right:18px;bottom:18px;z-index:9999;display:flex;gap:8px;padding:8px;border:1px solid #ffffff18;border-radius:16px;background:#08111ddd;backdrop-filter:blur(16px);box-shadow:0 16px 50px #0008">
918
- <a href="/data-lab" style="padding:10px 13px;border-radius:10px;background:linear-gradient(135deg,#45e5d2,#6a8dff);color:#06121a;text-decoration:none;font:700 13px system-ui">Historical Data Lab</a>
919
- <a href="/help" style="padding:10px 13px;color:#dce9ff;text-decoration:none;font:600 13px system-ui">Data guide</a>
920
- </nav>
921
- </body>
922
- </html>
 
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>Space 4 · Crypto Data Operations</title>
3
+ <style>
4
+ :root{--bg:#070b14;--panel:#101827cc;--line:#ffffff14;--text:#f3f7ff;--muted:#8fa0bb;--cyan:#55dff7;--violet:#8b7cff;--live:#32d6a0;--degraded:#ffbd59;--cached:#65a8ff;--unavailable:#ff667d;--shadow:0 24px 80px #0008}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:radial-gradient(circle at 78% -10%,#27205c66,transparent 36%),radial-gradient(circle at 22% 12%,#0b617144,transparent 30%),var(--bg);color:var(--text);font:14px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}a{color:inherit;text-decoration:none}button{font:inherit}.shell{min-height:100vh;display:grid;grid-template-columns:248px minmax(0,1fr)}.sidebar{position:sticky;top:0;height:100vh;padding:22px 16px;border-right:1px solid var(--line);background:#090f1bee;backdrop-filter:blur(20px);z-index:20}.brand{display:flex;gap:12px;align-items:center;padding:4px 8px 24px}.brand-mark{width:40px;height:40px;display:grid;place-items:center;border-radius:13px;background:linear-gradient(135deg,var(--violet),var(--cyan));box-shadow:0 10px 30px #6d74ff55;font-weight:900}.brand strong{display:block;font-size:15px}.brand small{color:var(--muted)}.nav-group{margin:18px 0}.nav-label{padding:0 10px 7px;color:#657590;font-size:10px;font-weight:800;letter-spacing:.14em;text-transform:uppercase}.nav-link{display:flex;gap:11px;align-items:center;padding:10px 12px;border-radius:11px;color:#b6c1d4;margin:3px 0}.nav-link:hover,.nav-link.active{color:white;background:#ffffff0c}.nav-link.active{box-shadow:inset 3px 0 var(--cyan);background:linear-gradient(90deg,#55dff719,transparent)}.nav-icon{width:18px;text-align:center}.side-status{position:absolute;left:16px;right:16px;bottom:18px;padding:13px;border:1px solid var(--line);border-radius:14px;background:#0d1625}.side-status-row{display:flex;justify-content:space-between;gap:10px;align-items:center}.side-status small{display:block;color:var(--muted);margin-top:6px}.main{min-width:0;padding:24px 30px 70px}.topbar{display:flex;align-items:center;justify-content:space-between;gap:18px;margin-bottom:22px}.eyebrow{color:var(--cyan);font-size:11px;font-weight:800;letter-spacing:.14em;text-transform:uppercase}.topbar h1{font-size:clamp(25px,3vw,36px);margin:4px 0 0;letter-spacing:-.04em}.top-actions{display:flex;gap:10px;align-items:center}.button{border:1px solid var(--line);border-radius:11px;background:#ffffff08;color:var(--text);padding:9px 12px;cursor:pointer}.button:hover{background:#ffffff10}.menu-button{display:none}.status-strip{display:grid;grid-template-columns:1.2fr 1fr 1fr;gap:12px;margin-bottom:14px}.status-card,.panel,.market-card,.coverage-card{border:1px solid var(--line);background:linear-gradient(145deg,#121b2bd9,#0b1321d9);box-shadow:var(--shadow);backdrop-filter:blur(18px)}.status-card{border-radius:15px;padding:14px 16px;display:flex;justify-content:space-between;align-items:center;gap:14px}.status-card span:first-child{color:var(--muted);font-size:12px}.status-card strong{display:block;margin-top:2px}.badge{display:inline-flex;gap:6px;align-items:center;padding:5px 9px;border:1px solid currentColor;border-radius:999px;font-size:10px;font-weight:900;letter-spacing:.08em}.badge:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;box-shadow:0 0 12px currentColor}.live{color:var(--live);background:#32d6a012}.degraded{color:var(--degraded);background:#ffbd5912}.cached{color:var(--cached);background:#65a8ff12}.unavailable{color:var(--unavailable);background:#ff667d12}.section-head{display:flex;align-items:end;justify-content:space-between;margin:27px 0 12px}.section-head h2{font-size:18px;margin:0}.section-head p{margin:3px 0 0;color:var(--muted)}.market-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.market-card{border-radius:18px;padding:17px;min-height:153px}.market-top{display:flex;justify-content:space-between;align-items:center}.coin{display:flex;align-items:center;gap:9px}.coin-icon{width:34px;height:34px;border-radius:12px;display:grid;place-items:center;background:#ffffff0b;color:var(--cyan);font-weight:900}.coin b,.coin small{display:block}.coin small,.meta{color:var(--muted)}.price{font-size:25px;font-weight:800;letter-spacing:-.04em;margin:22px 0 4px}.meta{font-size:11px;display:flex;justify-content:space-between;gap:8px}.coverage-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.coverage-card{border-radius:17px;padding:16px}.coverage-top{display:flex;justify-content:space-between;gap:8px;align-items:center}.coverage-card h3{margin:0;font-size:14px}.coverage-card p{color:var(--muted);font-size:12px;min-height:38px;margin:12px 0}.coverage-meta{display:flex;justify-content:space-between;gap:12px;padding-top:10px;border-top:1px solid var(--line);font-size:11px;color:#b9c5d8}.ops-grid{display:grid;grid-template-columns:1.1fr .9fr;gap:12px}.panel{border-radius:18px;padding:18px}.panel h3{margin:0 0 14px}.row{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:10px 0;border-bottom:1px solid var(--line)}.row:last-child{border:0}.row span{color:var(--muted)}.provider-list{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}.provider{padding:11px;border:1px solid var(--line);border-radius:12px;background:#ffffff05}.provider strong,.provider small{display:block}.provider small{color:var(--muted);margin-top:3px}.notice{margin-top:12px;border:1px solid #ffbd5940;background:#ffbd590d;border-radius:13px;padding:12px;color:#e7c987;font-size:12px}.footer{color:#697b96;margin-top:28px;font-size:11px}.skeleton{color:#5c6b82}.mobile-overlay{display:none}@media(max-width:1080px){.market-grid{grid-template-columns:repeat(2,1fr)}.coverage-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:780px){.shell{grid-template-columns:1fr}.sidebar{position:fixed;left:0;transform:translateX(-105%);width:260px;transition:.2s}.sidebar.open{transform:none}.mobile-overlay{position:fixed;inset:0;background:#0009;z-index:10}.mobile-overlay.open{display:block}.main{padding:18px 16px 55px}.menu-button{display:inline-flex}.topbar{align-items:flex-start}.top-actions .button:not(.menu-button){display:none}.status-strip,.market-grid,.coverage-grid,.ops-grid{grid-template-columns:1fr}.market-card{min-height:138px}.provider-list{grid-template-columns:1fr}}
5
+ </style></head><body><div class="shell">
6
+ <aside class="sidebar" id="sidebar"><div class="brand"><div class="brand-mark">S4</div><div><strong>Crypto Data Hub</strong><small>Space 4 · Operations</small></div></div><nav>
7
+ <div class="nav-group"><div class="nav-label">Overview</div><a class="nav-link active" href="/"><span class="nav-icon">◫</span>Command center</a><a class="nav-link" href="#market"><span class="nav-icon">↗</span>Live markets</a><a class="nav-link" href="#coverage"><span class="nav-icon">◉</span>Data coverage</a></div>
8
+ <div class="nav-group"><div class="nav-label">Data & signals</div><a class="nav-link" href="/data-lab"><span class="nav-icon">⌁</span>Historical Data Lab</a><a class="nav-link" href="/docs"><span class="nav-icon">⌁</span>Short Hunter Gateway</a><a class="nav-link" href="/ai-tools"><span class="nav-icon">✦</span>AI tools</a></div>
9
+ <div class="nav-group"><div class="nav-label">Operations</div><a class="nav-link" href="/api/providers"><span class="nav-icon">⬡</span>Provider catalog</a><a class="nav-link" href="/api/apex/coverage"><span class="nav-icon">▤</span>Coverage JSON</a><a class="nav-link" href="/docs"><span class="nav-icon">{ }</span>OpenAPI explorer</a><a class="nav-link" href="/help"><span class="nav-icon">?</span>Help & examples</a></div></nav><div class="side-status"><div class="side-status-row"><strong>Gateway</strong><span id="side-badge" class="badge unavailable">CHECKING</span></div><small id="side-detail">Waiting for live health…</small></div></aside><div class="mobile-overlay" id="overlay"></div>
10
+ <main class="main"><header class="topbar"><div><div class="eyebrow">Live system intelligence</div><h1>Data operations command center</h1></div><div class="top-actions"><button class="button menu-button" id="menu">Menu</button><a class="button" href="/help">Read the guide</a><button class="button" id="refresh">Refresh data</button></div></header>
11
+ <section class="status-strip"><div class="status-card"><div><span>Runtime</span><strong id="runtime-text">Checking production…</strong></div><span id="runtime-badge" class="badge unavailable">CHECKING</span></div><div class="status-card"><div><span>Archive storage</span><strong id="storage-text">Inspecting database path…</strong></div><span id="storage-badge" class="badge unavailable">CHECKING</span></div><div class="status-card"><div><span>Data refreshed</span><strong id="refreshed">—</strong></div><span id="freshness-badge" class="badge unavailable">UNKNOWN</span></div></section>
12
+ <section id="market"><div class="section-head"><div><h2>Live futures market</h2><p>Each price carries its own provider state and freshness.</p></div><a class="button" href="/data-lab">Open Data Lab</a></div><div class="market-grid" id="market-grid"></div></section>
13
+ <section id="coverage"><div class="section-head"><div><h2>Data coverage at a glance</h2><p>Historical upstream data and forward-only archives are explicitly separated.</p></div><a class="button" href="/help">Examples & caveats</a></div><div class="coverage-grid" id="coverage-grid"></div></section>
14
+ <section id="operations"><div class="section-head"><div><h2>Operations</h2><p>Provider capability and archive health without optimistic placeholders.</p></div></div><div class="ops-grid"><article class="panel"><h3>Archive health</h3><div id="archive-health"><div class="row"><span>Status</span><strong class="skeleton">Loading…</strong></div></div><div class="notice" id="storage-warning">Forward archives require persistent storage to survive a rebuild or restart.</div></article><article class="panel"><h3>Active upstreams</h3><div class="provider-list"><div class="provider"><strong>KuCoin Futures</strong><small>Primary LIVE Short Hunter market</small></div><div class="provider"><strong>Binance Vision</strong><small>Historical OHLCV</small></div><div class="provider"><strong>Gate.io</strong><small>Funding + OI fallback (~180 days)</small></div><div class="provider"><strong>Alternative.me</strong><small>Historical Fear & Greed</small></div><div class="provider"><strong>NewsData</strong><small>Forward news archive</small></div><div class="provider"><strong>Public EVM RPC</strong><small>Forward whale-transfer archive</small></div></div></article></div></section><div class="footer">Space 4 reports missing or stale data as unavailable. No values are synthesized.</div></main></div>
15
+ <script>
16
+ const symbols=['BTCUSDT','ETHUSDT','SOLUSDT','BNBUSDT'],money=new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',maximumFractionDigits:2});const stateClass=s=>{s=String(s||'UNAVAILABLE').toUpperCase();return s==='LIVE'||s==='REAL'?'live':s==='CACHED'?'cached':s==='DEGRADED'||s==='PARTIAL'?'degraded':'unavailable'},badge=(s,l=s)=>`<span class="badge ${stateClass(s)}">${String(l||'UNAVAILABLE').toUpperCase()}</span>`,fmtTime=v=>{if(!v)return'Never';const n=Number(v),d=new Date(n<1e12?n*1000:n);return Number.isNaN(d.valueOf())?'Unknown':d.toLocaleString()};async function json(path){const c=new AbortController(),t=setTimeout(()=>c.abort(),20000);try{const r=await fetch(path,{signal:c.signal});if(!r.ok)throw Error(`HTTP ${r.status}`);return await r.json()}finally{clearTimeout(t)}}
17
+ function renderMarket(items){document.querySelector('#market-grid').innerHTML=items.map((r,i)=>{const x=r.status==='fulfilled'?r.value:null,t=x?.data?.ticker||{},state=x?.sourceMode||'UNAVAILABLE',price=t.price??x?.data?.lastPrice;return`<article class="market-card"><div class="market-top"><div class="coin"><div class="coin-icon">${symbols[i][0]}</div><div><b>${symbols[i].replace('USDT','')}</b><small>USDT perpetual</small></div></div>${badge(state)}</div><div class="price">${price!=null?money.format(price):'UNAVAILABLE'}</div><div class="meta"><span>${x?.providerUsed||'No provider'}</span><span>${x?.latencyMs!=null?x.latencyMs+' ms':'No freshness'}</span></div></article>`}).join('')}
18
+ function renderCoverage(c){const comps=c?.components||{},health=c?.archiveHealth?.components||{},cards=[['OHLCV','LIVE',comps.ohlcv?.mode||'REAL_HISTORICAL_ON_DEMAND','Vision-first historical retrieval'],['Funding','LIVE',comps.funding?.mode||'REAL_180_DAY_UPSTREAM','Gate.io production fallback'],['Open interest','CACHED',comps.openInterest?.mode||'FORWARD_ARCHIVE','Upstream window + daily archive'],['Fear & Greed','LIVE',comps.sentiment?.mode||'REAL_HISTORICAL','Full Alternative.me series'],['News','CACHED',comps.news?.mode||'FORWARD_COLLECTING_ONLY','NewsData; forward from deployment'],['Whale flow','CACHED',comps.whaleFlow?.mode||'FORWARD_COLLECTING_ONLY','Real large native transfers']];document.querySelector('#coverage-grid').innerHTML=cards.map(([name,state,mode,desc])=>{const key=name==='Open interest'?'openInterest':name==='News'?'news':name==='Whale flow'?'whaleFlow':null,rows=key?health[key]?.storedRows:null;return`<article class="coverage-card"><div class="coverage-top"><h3>${name}</h3>${badge(state)}</div><p>${desc}</p><div class="coverage-meta"><span>${mode.replaceAll('_',' ')}</span><strong>${rows!=null?rows.toLocaleString()+' rows':'On demand'}</strong></div></article>`}).join('');const entries=Object.entries(health);document.querySelector('#archive-health').innerHTML=entries.length?entries.map(([k,v])=>`<div class="row"><span>${k}</span><strong>${(v.storedRows||0).toLocaleString()} rows · ${fmtTime(v.lastObservedAt)}</strong></div>`).join(''):'<div class="row"><span>Archive telemetry</span><strong>Available after this UI build</strong></div>'}
19
+ async function refresh(){document.querySelector('#refresh').textContent='Refreshing…';const [health,coverage,...markets]=await Promise.allSettled([json('/api/health'),json('/api/apex/coverage'),...symbols.map(s=>json('/api/short-hunter/market/'+s))]),h=health.status==='fulfilled'?health.value:null,c=coverage.status==='fulfilled'?coverage.value:null,ok=!!h?.success,mode=ok?'LIVE':'UNAVAILABLE';for(const id of['runtime-badge','side-badge']){const el=document.getElementById(id);el.className='badge '+stateClass(mode);el.textContent=mode}document.querySelector('#runtime-text').textContent=ok?`${h.service} · ${h.version}`:'Health endpoint unavailable';document.querySelector('#side-detail').textContent=ok?'Public-data gateway responding':'Gateway could not be verified';const storage=c?.archiveStorage?.mode||'UNAVAILABLE',sb=document.querySelector('#storage-badge');sb.className='badge '+(storage==='PERSISTENT_VOLUME'?'live':storage==='EPHEMERAL_LOCAL_DISK'?'degraded':'unavailable');sb.textContent=storage==='PERSISTENT_VOLUME'?'PERSISTENT':storage==='EPHEMERAL_LOCAL_DISK'?'EPHEMERAL':'UNAVAILABLE';document.querySelector('#storage-text').textContent=c?.archiveStorage?.path||'Storage state unavailable';document.querySelector('#storage-warning').textContent=c?.archiveStorage?.warning||'Persistent volume is mounted and used by the archive.';renderMarket(markets);renderCoverage(c);document.querySelector('#refreshed').textContent=new Date().toLocaleTimeString();const fb=document.querySelector('#freshness-badge');fb.className='badge live';fb.textContent='CURRENT';document.querySelector('#refresh').textContent='Refresh data'}
20
+ document.querySelector('#refresh').addEventListener('click',refresh);const side=document.querySelector('#sidebar'),overlay=document.querySelector('#overlay');document.querySelector('#menu').addEventListener('click',()=>{side.classList.add('open');overlay.classList.add('open')});overlay.addEventListener('click',()=>{side.classList.remove('open');overlay.classList.remove('open')});document.querySelector('#market-grid').innerHTML=symbols.map(s=>`<article class="market-card skeleton">Loading ${s}…</article>`).join('');refresh();setInterval(refresh,60000);
21
+ </script></body></html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
providers/registry.py CHANGED
@@ -40,7 +40,7 @@ SUPPORTED_PROVIDERS: List[ProviderSpec] = [
40
  ProviderSpec("kucoin_futures", "KuCoin Futures", ["universe", "contractValidation", "ticker", "ohlcv", "orderbook", "funding", "openInterest", "markIndex"], category="exchange_public", priority=10),
41
  ProviderSpec("binance_futures", "Binance Futures", ["universe", "contractValidation", "ticker", "ohlcv", "orderbook", "funding", "openInterest", "markIndex"], category="exchange_public", priority=20),
42
  ProviderSpec("coingecko", "CoinGecko", ["universe", "ticker", "ohlcv"], category="market_data", priority=30),
43
- ProviderSpec("cryptocompare", "CryptoCompare", ["ticker", "ohlcv"], requires_api_key=False, key_env="CRYPTOCOMPARE_KEY", category="market_data", priority=40),
44
  ProviderSpec("alternative_me", "Alternative.me", ["sentiment"], category="sentiment", priority=10),
45
  ProviderSpec("cryptopanic", "CryptoPanic", ["sentiment", "news"], requires_api_key=True, key_env="CRYPTOPANIC_KEY", category="news", priority=20),
46
  ]
@@ -193,4 +193,3 @@ def runtime_summary() -> Dict[str, Any]:
193
  "capabilities": capability_summary()["capabilities"],
194
  "validation": catalog_validation(),
195
  }
196
-
 
40
  ProviderSpec("kucoin_futures", "KuCoin Futures", ["universe", "contractValidation", "ticker", "ohlcv", "orderbook", "funding", "openInterest", "markIndex"], category="exchange_public", priority=10),
41
  ProviderSpec("binance_futures", "Binance Futures", ["universe", "contractValidation", "ticker", "ohlcv", "orderbook", "funding", "openInterest", "markIndex"], category="exchange_public", priority=20),
42
  ProviderSpec("coingecko", "CoinGecko", ["universe", "ticker", "ohlcv"], category="market_data", priority=30),
43
+ ProviderSpec("cryptocompare", "CryptoCompare", ["ticker", "ohlcv"], requires_api_key=True, key_env="CRYPTOCOMPARE_KEY", category="market_data", priority=40),
44
  ProviderSpec("alternative_me", "Alternative.me", ["sentiment"], category="sentiment", priority=10),
45
  ProviderSpec("cryptopanic", "CryptoPanic", ["sentiment", "news"], requires_api_key=True, key_env="CRYPTOPANIC_KEY", category="news", priority=20),
46
  ]
 
193
  "capabilities": capability_summary()["capabilities"],
194
  "validation": catalog_validation(),
195
  }
 
tests/test_space4_audit_fixes.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from fastapi.testclient import TestClient
3
+
4
+ import api_compat_routes as compat
5
+ import api_server_extended as server
6
+
7
+
8
+ def test_provider_fixed_routes_are_not_shadowed():
9
+ client = TestClient(server.app)
10
+ for path in (
11
+ "/api/providers/catalog",
12
+ "/api/providers/status",
13
+ "/api/providers/auto-discovery-report",
14
+ "/api/providers/health-summary",
15
+ ):
16
+ assert client.get(path).status_code == 200
17
+
18
+
19
+ def test_openapi_has_user_facing_groups_and_no_dead_static_alias():
20
+ schema = server.app.openapi()
21
+ tags = {
22
+ tag
23
+ for item in schema["paths"].values()
24
+ for operation in item.values()
25
+ if isinstance(operation, dict)
26
+ for tag in operation.get("tags", [])
27
+ }
28
+ assert "Core App & UI" in tags
29
+ assert "AI & Models" in tags
30
+ assert "Legacy Compatibility" in tags
31
+ assert "Short Hunter Gateway" in tags
32
+ assert "APEX Historical & Archives" in tags
33
+ assert "/static/api-resources/api-config-complete__1_.txt" not in schema["paths"]
34
+
35
+
36
+ @pytest.mark.asyncio
37
+ async def test_24h_stats_falls_back_to_kucoin(monkeypatch):
38
+ async def no_binance(_symbol):
39
+ return None, ["Binance HTTP 451"]
40
+
41
+ async def kucoin(_url, **_kwargs):
42
+ return {"data": {"last": "78900", "vol": "12", "volValue": "900000", "changeRate": "0.01", "high": "80000", "low": "77000"}}, None, 200
43
+
44
+ monkeypatch.setattr(compat, "_fetch_binance_ticker", no_binance)
45
+ monkeypatch.setattr(compat, "_get_json", kucoin)
46
+ result = await compat.trading_stats_24h("BTCUSDT")
47
+ assert result["success"] is True
48
+ assert result["source"] == "kucoin_public"
49
+ assert result["fallbackUsed"] is True
50
+
51
+
52
+ @pytest.mark.asyncio
53
+ async def test_compat_news_does_not_disguise_401_as_success(monkeypatch):
54
+ async def unauthorized(*_args, **_kwargs):
55
+ return None, "HTTP 401", 401
56
+
57
+ monkeypatch.setattr(compat, "_get_json", unauthorized)
58
+ result = await compat.coin_news("bitcoin", 5)
59
+ assert result["success"] is False
60
+ assert result["dataState"] == "UNAVAILABLE"
61
+ assert result["count"] == 0
62
+
63
+
64
+ def test_homepage_has_glanceable_states_and_primary_navigation():
65
+ html = (server.WORKSPACE_ROOT / "index.html").read_text()
66
+ for label in ("Live futures market", "Data coverage at a glance", "Archive storage", "Help & examples", "OpenAPI explorer"):
67
+ assert label in html
68
+ for state in ("LIVE", "DEGRADED", "CACHED", "UNAVAILABLE"):
69
+ assert state in html