Commit ·
6ddec3a
1
Parent(s): 535b973
Fix provider routing and overhaul Space 4 dashboard
Browse files- ENTERPRISE_DATA_BRAIN_V4_INSTALL_REPORT.md +0 -60
- SPACE4_AUDIT_REPORT_2026-08-25.md +279 -0
- apex_strategy_routes.py +82 -6
- api_compat_routes.py +18 -3
- api_server_extended.py +103 -16
- enterprise_data_hub_v3.py +0 -1519
- enterprise_data_hub_v3_data_brain_v4.py +0 -1990
- enterprise_router_v5.py +0 -756
- help.html +7 -6
- index.html +21 -922
- providers/registry.py +1 -2
- tests/test_space4_audit_fixes.py +69 -0
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
|
| 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":"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
| 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
|
| 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 -
|
| 43 |
-
|
|
|
|
|
|
|
| 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 |
-
"""
|
|
|
|
| 2541 |
try:
|
| 2542 |
from backend.services.hf_registry import REGISTRY
|
| 2543 |
-
|
| 2544 |
-
|
| 2545 |
-
|
| 2546 |
-
|
| 2547 |
-
|
| 2548 |
-
|
| 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">
|
| 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&interval=4h&start=1640995200000&end=1735689599999&limit=10000"</pre><pre>const body = await fetch(`${base}/api/history?symbol=BTCUSDT&interval=4h&start=${start}&end=${end}&limit=10000`).then(r=>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>
|
| 7 |
curl "$BASE/api/apex/open-interest/BTCUSDT?period=1h&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=>r.json());</pre><details><summary>
|
| 10 |
-
openInterest:
|
| 11 |
-
|
|
|
|
| 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&limit=120"</pre><pre>const snap = await fetch(`${base}/api/short-hunter/snapshot/BTCUSDT`).then(r=>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
|
|
|
|
| 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&interval=4h&start=1640995200000&end=1735689599999&limit=10000"</pre><pre>const body = await fetch(`${base}/api/history?symbol=BTCUSDT&interval=4h&start=${start}&end=${end}&limit=10000`).then(r=>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&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=>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=>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&limit=120"</pre><pre>const snap = await fetch(`${base}/api/short-hunter/snapshot/BTCUSDT`).then(r=>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 |
-
<!
|
| 2 |
-
<html lang="en"
|
| 3 |
-
<
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 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=
|
| 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
|