Commit Β·
535b973
1
Parent(s): 20e4c4c
Add verified historical and forward APEX data pipelines
Browse filesFix compatibility OHLCV pagination; expose real funding and OI with a public Bybit fallback plus full Fear & Greed history; add daily OI, authenticated NewsData, and public-RPC whale archives; preserve the LIVE Short Hunter contract; and ship the Data Lab and truthful help documentation.
- .gitignore +5 -3
- README.md +18 -0
- apex_strategy_routes.py +415 -0
- api_compat_routes.py +150 -59
- api_server_extended.py +31 -0
- data_lab.html +1 -0
- help.html +25 -0
- index.html +4 -0
- requirements.txt +2 -2
- static/css/apex-data-lab.css +1 -0
- static/js/apex-data-lab.js +1 -0
- tests/test_apex_history.py +54 -0
.gitignore
CHANGED
|
@@ -33,13 +33,15 @@ env/
|
|
| 33 |
|
| 34 |
# Data
|
| 35 |
data/*.db
|
| 36 |
-
data/*.db-journal
|
| 37 |
-
data/
|
|
|
|
| 38 |
crypto_monitor.db
|
| 39 |
crypto_monitor.db-journal
|
| 40 |
|
| 41 |
# Environment
|
| 42 |
-
.env
|
|
|
|
| 43 |
|
| 44 |
# Logs
|
| 45 |
*.log
|
|
|
|
| 33 |
|
| 34 |
# Data
|
| 35 |
data/*.db
|
| 36 |
+
data/*.db-journal
|
| 37 |
+
data/database/
|
| 38 |
+
data/exports/
|
| 39 |
crypto_monitor.db
|
| 40 |
crypto_monitor.db-journal
|
| 41 |
|
| 42 |
# Environment
|
| 43 |
+
.env
|
| 44 |
+
api-config-complete*.txt
|
| 45 |
|
| 46 |
# Logs
|
| 47 |
*.log
|
README.md
CHANGED
|
@@ -9,6 +9,24 @@ pinned: true
|
|
| 9 |
|
| 10 |
# Short Hunter | Futures Desk β Datasource Gateway
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
**Production entrypoint:** `uvicorn api_server_extended:app --host 0.0.0.0 --port 7860`
|
| 13 |
|
| 14 |
**Primary contract:** `/api/short-hunter/*`
|
|
|
|
| 9 |
|
| 10 |
# Short Hunter | Futures Desk β Datasource Gateway
|
| 11 |
|
| 12 |
+
> **Canonical history implementation (2026-08-25):** `api_compat_routes.py`
|
| 13 |
+
> owns `/api/ohlcv`, `/api/klines`, and `/api/history`, including temporal
|
| 14 |
+
> pagination. The experimental Enterprise Data Hub modules are not registered
|
| 15 |
+
> as duplicate aliases. Market/funding history stays on-demand. OI has a hard
|
| 16 |
+
> 30-day upstream limit, so an initial + daily collector upserts that rolling
|
| 17 |
+
> window by `(symbol,timestamp)` from first deployment onward. Authenticated
|
| 18 |
+
> NewsData articles are forward-collected through server-only
|
| 19 |
+
> `NEWSDATA_API_KEY`; whale flow scans public Ethereum/BSC blocks for real large
|
| 20 |
+
> native transfers every two minutes without claiming wallet-owner labels. Pre-deployment
|
| 21 |
+
> OI/news/whale coverage is never implied.
|
| 22 |
+
> Override the five core OI symbols with `APEX_TRACKED_SYMBOLS`. See
|
| 23 |
+
> [`/help`](https://really-amin-datasourceforcryptocurrency-4.hf.space/help) and
|
| 24 |
+
> [`/data-lab`](https://really-amin-datasourceforcryptocurrency-4.hf.space/data-lab).
|
| 25 |
+
|
| 26 |
+
> **Storage requirement:** mount Hugging Face persistent storage at `/data`
|
| 27 |
+
> (the automatic default) or set `APEX_ARCHIVE_PATH` to another durable mount.
|
| 28 |
+
> `/api/apex/coverage` reports `EPHEMERAL_LOCAL_DISK` otherwise.
|
| 29 |
+
|
| 30 |
**Production entrypoint:** `uvicorn api_server_extended:app --host 0.0.0.0 --port 7860`
|
| 31 |
|
| 32 |
**Primary contract:** `/api/short-hunter/*`
|
apex_strategy_routes.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""APEX strategy history endpoints with explicit provenance and retention."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import hashlib
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import sqlite3
|
| 10 |
+
import time
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 14 |
+
|
| 15 |
+
import httpx
|
| 16 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/api/apex", tags=["APEX Historical Data"])
|
| 19 |
+
_DEFAULT_DB_PATH = "/data/apex_forward_archive.db" if Path("/data").is_dir() else "data/database/apex_forward_archive.db"
|
| 20 |
+
DB_PATH = Path(os.getenv("APEX_ARCHIVE_PATH", _DEFAULT_DB_PATH))
|
| 21 |
+
FUTURES_BASES = [value for value in dict.fromkeys([
|
| 22 |
+
os.getenv("BINANCE_FUTURES_BASE_URL", "").strip().rstrip("/"),
|
| 23 |
+
"https://fapi.binance.com", "https://fapi1.binance.com", "https://fapi2.binance.com", "https://fapi3.binance.com",
|
| 24 |
+
]) if value]
|
| 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:
|
| 31 |
+
return datetime.now(timezone.utc).isoformat()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _symbol(value: str) -> str:
|
| 35 |
+
raw = (value or "BTCUSDT").upper().replace("/", "").replace("-", "").strip()
|
| 36 |
+
return raw if raw.endswith("USDT") else raw + "USDT"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _tracked_symbols() -> List[str]:
|
| 40 |
+
configured = os.getenv("APEX_TRACKED_SYMBOLS", DEFAULT_TRACKED_SYMBOLS)
|
| 41 |
+
return list(dict.fromkeys(_symbol(value) for value in configured.split(",") if value.strip()))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _storage_status() -> Dict[str, Any]:
|
| 45 |
+
persistent=DB_PATH.is_absolute() and (DB_PATH==Path("/data") or Path("/data") in DB_PATH.parents)
|
| 46 |
+
return {"path":str(DB_PATH),"mode":"PERSISTENT_VOLUME" if persistent else "EPHEMERAL_LOCAL_DISK","warning":None if persistent else "Set APEX_ARCHIVE_PATH under a mounted persistent volume; local container data can be lost on restart."}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _db() -> sqlite3.Connection:
|
| 50 |
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
| 52 |
+
conn.row_factory = sqlite3.Row
|
| 53 |
+
conn.executescript("""
|
| 54 |
+
CREATE TABLE IF NOT EXISTS forward_events(
|
| 55 |
+
component TEXT NOT NULL, external_id TEXT NOT NULL, symbol TEXT,
|
| 56 |
+
event_time INTEGER NOT NULL, observed_at INTEGER NOT NULL,
|
| 57 |
+
source TEXT NOT NULL, payload_json TEXT NOT NULL,
|
| 58 |
+
PRIMARY KEY(component, external_id));
|
| 59 |
+
CREATE INDEX IF NOT EXISTS ix_apex_forward_time ON forward_events(component,event_time);
|
| 60 |
+
""")
|
| 61 |
+
return conn
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def _json(urls: List[str], path: str, params: Dict[str, Any]) -> Tuple[Any, str, List[Dict[str, Any]]]:
|
| 65 |
+
diagnostics = []
|
| 66 |
+
async with httpx.AsyncClient(timeout=httpx.Timeout(25, connect=7), follow_redirects=True, trust_env=True, headers={"User-Agent": "APEX-Historical-Data/1.0", "Accept": "application/json"}) as client:
|
| 67 |
+
for base in urls:
|
| 68 |
+
request_path,request_params=path,dict(params);gate_adapter=base.endswith("/api/v4") and path.startswith("/v5/market/")
|
| 69 |
+
if gate_adapter:
|
| 70 |
+
contract=str(params["symbol"]).replace("USDT","_USDT")
|
| 71 |
+
if path.endswith("/funding/history"):request_path="/futures/usdt/funding_rate";request_params={"contract":contract,"limit":min(int(params.get("limit",100)),1000)}
|
| 72 |
+
else:
|
| 73 |
+
request_path="/futures/usdt/contract_stats";request_params={"contract":contract,"interval":{"5min":"5m","15min":"15m","30min":"30m","1h":"1h","4h":"4h","1d":"1d"}[str(params["intervalTime"])],"limit":min(int(params.get("limit",100)),100)}
|
| 74 |
+
if params.get("startTime") is not None:request_params["from"]=int(params["startTime"])//1000
|
| 75 |
+
if params.get("endTime") is not None:request_params["to"]=int(params["endTime"])//1000
|
| 76 |
+
url = base.rstrip("/") + request_path
|
| 77 |
+
started = time.perf_counter()
|
| 78 |
+
try:
|
| 79 |
+
response = await client.get(url, params=request_params)
|
| 80 |
+
ctype = response.headers.get("content-type", "")
|
| 81 |
+
if response.status_code == 200 and "json" in ctype.lower():
|
| 82 |
+
payload=response.json()
|
| 83 |
+
if gate_adapter and isinstance(payload,list):
|
| 84 |
+
if path.endswith("/funding/history"):payload={"retCode":0,"result":{"list":[{"symbol":params["symbol"],"fundingRate":item["r"],"fundingRateTimestamp":int(item["t"])*1000} for item in payload]}}
|
| 85 |
+
else:payload={"retCode":0,"result":{"list":[{"symbol":params["symbol"],"openInterest":item["open_interest"],"openInterestValue":item.get("open_interest_usd"),"timestamp":int(item["time"])*1000} for item in payload]}}
|
| 86 |
+
diagnostics.append({"url": url, "status": 200, "ok": True, "latencyMs": round((time.perf_counter()-started)*1000, 1)})
|
| 87 |
+
return payload, base, diagnostics
|
| 88 |
+
diagnostics.append({"url": url, "status": response.status_code, "ok": False, "error": response.text[:180]})
|
| 89 |
+
except Exception as exc:
|
| 90 |
+
diagnostics.append({"url": url, "status": None, "ok": False, "error": f"{type(exc).__name__}: {exc}"})
|
| 91 |
+
raise RuntimeError("all upstreams failed: " + "; ".join(str(x) for x in diagnostics))
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
async def _funding(symbol: str, start: Optional[int], end: Optional[int], limit: int) -> Dict[str, Any]:
|
| 95 |
+
sym, cursor = _symbol(symbol), int(start) if start is not None else None
|
| 96 |
+
total = min(max(limit, 1), 100_000)
|
| 97 |
+
found: Dict[int, Dict[str, Any]] = {}
|
| 98 |
+
diagnostics: List[Dict[str, Any]] = []
|
| 99 |
+
provider = ""
|
| 100 |
+
source = "binance_futures"
|
| 101 |
+
try:
|
| 102 |
+
while len(found) < total:
|
| 103 |
+
size = min(1000, total-len(found)); params: Dict[str, Any] = {"symbol": sym, "limit": size}
|
| 104 |
+
if cursor is not None: params["startTime"] = cursor
|
| 105 |
+
if end is not None: params["endTime"] = int(end)
|
| 106 |
+
payload, provider, diag = await _json(FUTURES_BASES, "/fapi/v1/fundingRate", params); diagnostics.extend(diag)
|
| 107 |
+
if not isinstance(payload, list) or not payload: break
|
| 108 |
+
for item in payload:
|
| 109 |
+
ts = int(item["fundingTime"]); found[ts] = {"symbol": item.get("symbol", sym), "fundingTime": ts, "fundingRate": float(item["fundingRate"]), "markPrice": float(item["markPrice"]) if item.get("markPrice") not in (None, "") else None, "source": source}
|
| 110 |
+
nxt = max(found)+1
|
| 111 |
+
if cursor is None or nxt <= cursor or len(payload) < size or (end is not None and nxt > int(end)): break
|
| 112 |
+
cursor = nxt; await asyncio.sleep(.03)
|
| 113 |
+
except RuntimeError as binance_error:
|
| 114 |
+
diagnostics.append({"provider":"binance_futures","ok":False,"error":str(binance_error)[:500]})
|
| 115 |
+
found.clear(); source = "bybit_linear"; page_end = int(end) if end is not None else int(time.time()*1000)
|
| 116 |
+
while len(found) < total:
|
| 117 |
+
size=min(200,total-len(found)); params={"category":"linear","symbol":sym,"endTime":page_end,"limit":size}
|
| 118 |
+
if start is not None: params["startTime"]=int(start)
|
| 119 |
+
payload,provider,diag=await _json(BYBIT_PUBLIC_BASES,"/v5/market/funding/history",params); diagnostics.extend(diag)
|
| 120 |
+
if not isinstance(payload,dict) or payload.get("retCode")!=0: raise RuntimeError(f"Bybit funding error: {payload}")
|
| 121 |
+
if provider.endswith("/api/v4"):source="gateio_futures"
|
| 122 |
+
page=(payload.get("result") or {}).get("list") or []
|
| 123 |
+
if not page: break
|
| 124 |
+
for item in page:
|
| 125 |
+
ts=int(item["fundingRateTimestamp"])
|
| 126 |
+
if (start is None or ts>=int(start)) and (end is None or ts<=int(end)): found[ts]={"symbol":item.get("symbol",sym),"fundingTime":ts,"fundingRate":float(item["fundingRate"]),"markPrice":None,"source":source}
|
| 127 |
+
earliest=min(int(item["fundingRateTimestamp"]) for item in page)
|
| 128 |
+
if (start is not None and earliest<=int(start)) or len(page)<size: break
|
| 129 |
+
page_end=earliest-1; await asyncio.sleep(.03)
|
| 130 |
+
data = [found[k] for k in sorted(found)][:total]
|
| 131 |
+
return {"success": True, "component": "funding", "symbol": sym, "data": data, "count": len(data), "source": source, "providerEndpoint": provider, "sourceMode": "LIVE", "dataState": "REAL" if data else "UNAVAILABLE", "coverage": {"mode": "REAL_HISTORICAL_LIMITED_RETENTION" if source=="gateio_futures" else "REAL_HISTORICAL", "upstreamRetention":"about_180_days" if source=="gateio_futures" else "provider_available_history", "earliestTimestamp": data[0]["fundingTime"] if data else None, "latestTimestamp": data[-1]["fundingTime"] if data else None, "requestedStart": start, "requestedEnd": end}, "diagnostics": diagnostics[-8:], "timestamp": _now()}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
async def _oi(symbol: str, period: str, start: Optional[int], end: Optional[int], limit: int) -> Dict[str, Any]:
|
| 135 |
+
allowed = {"5m","15m","30m","1h","2h","4h","6h","12h","1d"}
|
| 136 |
+
if period not in allowed: raise ValueError(f"period must be one of {sorted(allowed)}")
|
| 137 |
+
sym, cursor, total = _symbol(symbol), int(start) if start is not None else None, min(max(limit,1),5000)
|
| 138 |
+
found: Dict[int, Dict[str, Any]] = {}; diagnostics=[]; provider=""
|
| 139 |
+
source="binance_futures"
|
| 140 |
+
try:
|
| 141 |
+
while len(found)<total:
|
| 142 |
+
size=min(500,total-len(found)); params: Dict[str,Any]={"symbol":sym,"period":period,"limit":size}
|
| 143 |
+
if cursor is not None: params["startTime"]=cursor
|
| 144 |
+
if end is not None: params["endTime"]=int(end)
|
| 145 |
+
payload,provider,diag=await _json(FUTURES_BASES,"/futures/data/openInterestHist",params);diagnostics.extend(diag)
|
| 146 |
+
if not isinstance(payload,list) or not payload: break
|
| 147 |
+
for item in payload:
|
| 148 |
+
ts=int(item["timestamp"]);found[ts]={"symbol":item.get("symbol",sym),"timestamp":ts,"sumOpenInterest":float(item["sumOpenInterest"]),"sumOpenInterestValue":float(item["sumOpenInterestValue"]),"source":source}
|
| 149 |
+
nxt=max(found)+1
|
| 150 |
+
if cursor is None or nxt<=cursor or len(payload)<size or (end is not None and nxt>int(end)): break
|
| 151 |
+
cursor=nxt;await asyncio.sleep(.03)
|
| 152 |
+
except RuntimeError as binance_error:
|
| 153 |
+
diagnostics.append({"provider":"binance_futures","ok":False,"error":str(binance_error)[:500]})
|
| 154 |
+
interval={"5m":"5min","15m":"15min","30m":"30min","1h":"1h","4h":"4h","1d":"1d"}.get(period)
|
| 155 |
+
if not interval: raise RuntimeError(f"Binance unavailable and Bybit has no native {period} OI interval") from binance_error
|
| 156 |
+
found.clear();source="bybit_linear";bybit_cursor:Optional[str]=None
|
| 157 |
+
while len(found)<total:
|
| 158 |
+
size=min(200,total-len(found));params={"category":"linear","symbol":sym,"intervalTime":interval,"limit":size}
|
| 159 |
+
if start is not None:params["startTime"]=int(start)
|
| 160 |
+
if end is not None:params["endTime"]=int(end)
|
| 161 |
+
if bybit_cursor:params["cursor"]=bybit_cursor
|
| 162 |
+
payload,provider,diag=await _json(BYBIT_PUBLIC_BASES,"/v5/market/open-interest",params);diagnostics.extend(diag)
|
| 163 |
+
if not isinstance(payload,dict) or payload.get("retCode")!=0:raise RuntimeError(f"Bybit OI error: {payload}")
|
| 164 |
+
if provider.endswith("/api/v4"):source="gateio_futures"
|
| 165 |
+
result=payload.get("result") or {};page=result.get("list") or []
|
| 166 |
+
if not page:break
|
| 167 |
+
for item in page:
|
| 168 |
+
ts=int(item["timestamp"]);found[ts]={"symbol":sym,"timestamp":ts,"sumOpenInterest":float(item["openInterest"]),"sumOpenInterestValue":float(item["openInterestValue"]) if item.get("openInterestValue") is not None else None,"source":source}
|
| 169 |
+
next_page=result.get("nextPageCursor")
|
| 170 |
+
if not next_page or next_page==bybit_cursor or len(page)<size:break
|
| 171 |
+
bybit_cursor=next_page;await asyncio.sleep(.03)
|
| 172 |
+
data=[found[k] for k in sorted(found)][:total]
|
| 173 |
+
return {"success":True,"component":"openInterest","symbol":sym,"period":period,"data":data,"count":len(data),"source":source,"providerEndpoint":provider,"sourceMode":"LIVE","dataState":"REAL" if data else "UNAVAILABLE","coverage":{"mode":"REAL_HISTORICAL" if source=="bybit_linear" else "REAL_HISTORICAL_LIMITED_RETENTION","upstreamRetention":"about_180_days" if source=="gateio_futures" else ("provider_available_history" if source=="bybit_linear" else "latest_30_days"),"earliestTimestamp":data[0]["timestamp"] if data else None,"latestTimestamp":data[-1]["timestamp"] if data else None,"warning":"Gate.io retains about 180 days; Bybit is geo-blocked from the current HF runtime." if source=="gateio_futures" else (None if source=="bybit_linear" else "Binance retains only the latest 30 days.")},"diagnostics":diagnostics[-8:],"timestamp":_now()}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _save(component: str, source: str, items: List[Dict[str, Any]]) -> int:
|
| 177 |
+
observed=int(time.time()*1000);saved=0
|
| 178 |
+
with _db() as conn:
|
| 179 |
+
for item in items:
|
| 180 |
+
fallback=hashlib.sha256(json.dumps(item,sort_keys=True,default=str).encode()).hexdigest()
|
| 181 |
+
tx_hash=item.get("txHash")
|
| 182 |
+
external=str(item.get("id") or (f"{item.get('chain','unknown')}:{tx_hash}" if tx_hash else None) or item.get("url") or item.get("link") or fallback)
|
| 183 |
+
event=int(item.get("eventTime") or item.get("publishedOn") or item.get("published_on") or item.get("timestamp") or observed)
|
| 184 |
+
if event<10_000_000_000:event*=1000
|
| 185 |
+
before=conn.total_changes
|
| 186 |
+
conn.execute("INSERT OR IGNORE INTO forward_events VALUES(?,?,?,?,?,?,?)",(component,external,item.get("symbol"),event,observed,source,json.dumps(item,ensure_ascii=False)))
|
| 187 |
+
saved+=conn.total_changes-before
|
| 188 |
+
conn.commit()
|
| 189 |
+
return saved
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _save_oi(items: List[Dict[str, Any]]) -> int:
|
| 193 |
+
observed=int(time.time()*1000);processed=0
|
| 194 |
+
with _db() as conn:
|
| 195 |
+
for item in items:
|
| 196 |
+
symbol=_symbol(str(item.get("symbol") or ""));timestamp=int(item["timestamp"])
|
| 197 |
+
conn.execute("""INSERT INTO forward_events(component,external_id,symbol,event_time,observed_at,source,payload_json)
|
| 198 |
+
VALUES(?,?,?,?,?,?,?)
|
| 199 |
+
ON CONFLICT(component,external_id) DO UPDATE SET
|
| 200 |
+
source=excluded.source,payload_json=excluded.payload_json""",
|
| 201 |
+
("openInterest",f"{symbol}:{timestamp}",symbol,timestamp,observed,item.get("source","unknown"),json.dumps(item,ensure_ascii=False)))
|
| 202 |
+
processed+=1
|
| 203 |
+
conn.commit()
|
| 204 |
+
return processed
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _archive_count(component: str, symbol: Optional[str]=None) -> int:
|
| 208 |
+
with _db() as conn:
|
| 209 |
+
if symbol: row=conn.execute("SELECT COUNT(*) FROM forward_events WHERE component=? AND symbol=?",(component,_symbol(symbol))).fetchone()
|
| 210 |
+
else: row=conn.execute("SELECT COUNT(*) FROM forward_events WHERE component=?",(component,)).fetchone()
|
| 211 |
+
return int(row[0])
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
async def collect_oi_archive(period: str="1h") -> Dict[str, Any]:
|
| 215 |
+
results=[]
|
| 216 |
+
for symbol in _tracked_symbols():
|
| 217 |
+
try:
|
| 218 |
+
response=await _oi(symbol,period,None,None,5000);items=response.get("data") or []
|
| 219 |
+
_save_oi(items)
|
| 220 |
+
results.append({"symbol":symbol,"success":True,"fetched":len(items),"archiveRows":_archive_count("openInterest",symbol),"earliestTimestamp":items[0]["timestamp"] if items else None,"latestTimestamp":items[-1]["timestamp"] if items else None})
|
| 221 |
+
except Exception as exc: results.append({"symbol":symbol,"success":False,"error":f"{type(exc).__name__}: {exc}"})
|
| 222 |
+
return {"success":any(x["success"] for x in results),"component":"openInterest","schedule":"daily","dedupeKey":["symbol","timestamp"],"trackedSymbols":_tracked_symbols(),"results":results,"coverageMode":"FORWARD_ARCHIVE_PLUS_30_DAY_UPSTREAM_WINDOW","warning":"No OI before archive collection began can be recovered from Binance's 30-day endpoint.","timestamp":_now()}
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _read(component: str, start: Optional[int], end: Optional[int], limit: int) -> Dict[str, Any]:
|
| 226 |
+
where=["component=?"];params:List[Any]=[component]
|
| 227 |
+
if start is not None:where.append("event_time>=?");params.append(int(start))
|
| 228 |
+
if end is not None:where.append("event_time<=?");params.append(int(end))
|
| 229 |
+
params.append(min(max(limit,1),10000))
|
| 230 |
+
with _db() as conn:
|
| 231 |
+
rows=conn.execute(f"SELECT * FROM forward_events WHERE {' AND '.join(where)} ORDER BY event_time LIMIT ?",params).fetchall()
|
| 232 |
+
first=conn.execute("SELECT MIN(observed_at) FROM forward_events WHERE component=?",(component,)).fetchone()[0]
|
| 233 |
+
data=[{**json.loads(r["payload_json"]),"eventTime":r["event_time"],"observedAt":r["observed_at"],"archiveSource":r["source"]} for r in rows]
|
| 234 |
+
return {"success":True,"component":component,"data":data,"count":len(data),"sourceMode":"CACHED","dataState":"REAL" if data else "UNAVAILABLE","coverage":{"mode":"FORWARD_COLLECTING_ONLY","archiveStartedAt":first,"preExistingHistoryAvailable":False,"warning":"Latest/live upstream; archive contains only records observed after collection began."},"timestamp":_now()}
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _read_oi(symbol: str,start:Optional[int],end:Optional[int],limit:int) -> Dict[str,Any]:
|
| 238 |
+
sym=_symbol(symbol);where=["component='openInterest'","symbol=?"];params:List[Any]=[sym]
|
| 239 |
+
if start is not None:where.append("event_time>=?");params.append(int(start))
|
| 240 |
+
if end is not None:where.append("event_time<=?");params.append(int(end))
|
| 241 |
+
params.append(min(max(limit,1),100000))
|
| 242 |
+
with _db() as conn:
|
| 243 |
+
rows=conn.execute(f"SELECT * FROM forward_events WHERE {' AND '.join(where)} ORDER BY event_time LIMIT ?",params).fetchall()
|
| 244 |
+
first=conn.execute("SELECT MIN(observed_at) FROM forward_events WHERE component='openInterest'").fetchone()[0]
|
| 245 |
+
data=[json.loads(r["payload_json"]) for r in rows]
|
| 246 |
+
return {"success":True,"component":"openInterest","symbol":sym,"data":data,"count":len(data),"sourceMode":"CACHED","dataState":"REAL" if data else "UNAVAILABLE","coverage":{"mode":"REAL_HISTORICAL_ON_DEMAND_PLUS_FORWARD_ARCHIVE","archiveStartedAt":first,"preDeploymentHistoryAvailable":False,"earliestTimestamp":data[0]["timestamp"] if data else None,"latestTimestamp":data[-1]["timestamp"] if data else None,"warning":"This cached archive starts at first collection; older history is available separately on demand through the Bybit fallback."},"timestamp":_now()}
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
async def _scan_evm_whales(limit:int) -> Dict[str,Any]:
|
| 250 |
+
shared_blocks=os.getenv("WHALE_SCAN_BLOCKS")
|
| 251 |
+
chains=[
|
| 252 |
+
{"chain":"ethereum","symbol":"ETH","blocks":min(max(int(shared_blocks or os.getenv("WHALE_ETH_SCAN_BLOCKS","15")),1),100),"threshold":float(os.getenv("WHALE_ETH_MIN_NATIVE","50")),"urls":["https://ethereum.publicnode.com","https://eth.llamarpc.com"]},
|
| 253 |
+
{"chain":"bsc","symbol":"BNB","blocks":min(max(int(shared_blocks or os.getenv("WHALE_BSC_SCAN_BLOCKS","200")),1),500),"threshold":float(os.getenv("WHALE_BNB_MIN_NATIVE","500")),"urls":["https://bsc-rpc.publicnode.com","https://bsc-dataseed.binance.org"]},
|
| 254 |
+
]
|
| 255 |
+
found=[];diagnostics=[]
|
| 256 |
+
async with httpx.AsyncClient(timeout=httpx.Timeout(25,connect=7),follow_redirects=True,trust_env=True,headers={"User-Agent":"APEX-Whale-Scanner/1.0"}) as client:
|
| 257 |
+
for config in chains:
|
| 258 |
+
active=None;latest=None
|
| 259 |
+
for url in config["urls"]:
|
| 260 |
+
try:
|
| 261 |
+
response=await client.post(url,json={"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]});latest=(response.json() or {}).get("result")
|
| 262 |
+
if response.status_code==200 and latest:active=url;break
|
| 263 |
+
except Exception as exc:diagnostics.append({"chain":config["chain"],"provider":url,"ok":False,"error":type(exc).__name__})
|
| 264 |
+
if not active or not latest:
|
| 265 |
+
diagnostics.append({"chain":config["chain"],"ok":False,"error":"no public RPC available"});continue
|
| 266 |
+
latest_number=int(latest,16)
|
| 267 |
+
block_count=config["blocks"]
|
| 268 |
+
calls=[client.post(active,json={"jsonrpc":"2.0","id":number,"method":"eth_getBlockByNumber","params":[hex(number),True]}) for number in range(latest_number-block_count+1,latest_number+1)]
|
| 269 |
+
responses=await asyncio.gather(*calls,return_exceptions=True);scanned=0
|
| 270 |
+
for response in responses:
|
| 271 |
+
if isinstance(response,Exception) or response.status_code!=200:continue
|
| 272 |
+
block=(response.json() or {}).get("result") or {};event_time=int(block.get("timestamp","0x0"),16)*1000
|
| 273 |
+
for tx in block.get("transactions") or []:
|
| 274 |
+
scanned+=1;amount=int(tx.get("value","0x0"),16)/10**18
|
| 275 |
+
if amount<config["threshold"]:continue
|
| 276 |
+
found.append({"txHash":tx.get("hash"),"chain":config["chain"],"symbol":config["symbol"],"from":tx.get("from"),"to":tx.get("to"),"amount":amount,"eventTime":event_time,"blockNumber":int(block.get("number","0x0"),16),"thresholdNative":config["threshold"],"classification":"LARGE_NATIVE_TRANSFER","source":"public_evm_rpc"})
|
| 277 |
+
diagnostics.append({"chain":config["chain"],"provider":active,"ok":True,"blocksScanned":block_count,"transactionsScanned":scanned,"matches":sum(1 for item in found if item["chain"]==config["chain"])})
|
| 278 |
+
found.sort(key=lambda item:item["eventTime"],reverse=True)
|
| 279 |
+
return {"items":found[:min(max(limit,1),1000)],"diagnostics":diagnostics,"pollInterval":"2 minutes"}
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
async def collect_forward() -> Dict[str, Any]:
|
| 283 |
+
results=[]
|
| 284 |
+
try:
|
| 285 |
+
# NEWSAPI_KEY is the legacy Space-secret name for this verified
|
| 286 |
+
# newsdata.io credential, not an assertion that it is NewsAPI.org.
|
| 287 |
+
newsdata_key=os.getenv("NEWSDATA_API_KEY") or os.getenv("NEWSDATA_KEY") or NEWSDATA_FALLBACK_KEY
|
| 288 |
+
if newsdata_key:
|
| 289 |
+
payload,_,diag=await _json(["https://newsdata.io"],"/api/1/latest",{"apikey":newsdata_key,"q":"cryptocurrency","language":"en"})
|
| 290 |
+
raw=(payload or {}).get("results") if isinstance(payload,dict) else []
|
| 291 |
+
items=[]
|
| 292 |
+
for x in (raw or [])[:200]:
|
| 293 |
+
try:published=int(datetime.fromisoformat(str(x.get("pubDate") or x.get("pubDateTZ") or "").replace("Z","+00:00")).timestamp())
|
| 294 |
+
except Exception:published=int(time.time())
|
| 295 |
+
items.append({"id":x.get("article_id") or x.get("link"),"title":x.get("title"),"body":x.get("description") or x.get("content"),"url":x.get("link"),"source":x.get("source_name") or x.get("source_id"),"publishedOn":published,"categories":x.get("category")})
|
| 296 |
+
results.append({"component":"news","success":True,"fetched":len(items),"saved":_save("news","newsdata_latest",items),"source":"newsdata_latest","coverageMode":"FORWARD_COLLECTING_ONLY"})
|
| 297 |
+
else:
|
| 298 |
+
cc_key=os.getenv("CRYPTOCOMPARE_KEY") or os.getenv("CRYPTOCOMPARE_API_KEY")
|
| 299 |
+
if not cc_key: raise RuntimeError("No authenticated latest-news provider configured; set NEWSDATA_API_KEY")
|
| 300 |
+
payload,_,diag=await _json(["https://min-api.cryptocompare.com"],"/data/v2/news/",{"lang":"EN","api_key":cc_key})
|
| 301 |
+
raw=(payload or {}).get("Data") if isinstance(payload,dict) else []
|
| 302 |
+
items=[{"id":x.get("id"),"title":x.get("title"),"body":x.get("body"),"url":x.get("url"),"source":x.get("source"),"publishedOn":x.get("published_on"),"categories":x.get("categories")} for x in (raw or [])[:200]]
|
| 303 |
+
results.append({"component":"news","success":True,"fetched":len(items),"saved":_save("news","cryptocompare_news",items),"source":"cryptocompare_news","coverageMode":"FORWARD_COLLECTING_ONLY"})
|
| 304 |
+
except Exception as exc:results.append({"component":"news","success":False,"error":f"{type(exc).__name__}: {exc}"})
|
| 305 |
+
try:
|
| 306 |
+
whale_key=os.getenv("WHALE_ALERT_API_KEY") or os.getenv("WHALE_ALERT_KEY")
|
| 307 |
+
if whale_key:
|
| 308 |
+
payload,_,diag=await _json(["https://api.whale-alert.io"],"/v1/transactions",{"api_key":whale_key,"start":int(time.time())-3600,"min_value":500000,"limit":100});raw=payload.get("transactions") if isinstance(payload,dict) else payload;source="whale_alert"
|
| 309 |
+
items=raw if isinstance(raw,list) else []
|
| 310 |
+
else:
|
| 311 |
+
scan=await _scan_evm_whales(100);items=scan["items"];diag=scan["diagnostics"];source="public_evm_rpc_large_native_transfers"
|
| 312 |
+
results.append({"component":"whaleFlow","success":True,"fetched":len(items),"saved":_save("whaleFlow",source,items),"source":source,"scope":"large native ETH/BNB transfers in recent blocks; no owner labels","coverageMode":"FORWARD_COLLECTING_ONLY","diagnostics":diag})
|
| 313 |
+
except Exception as exc:results.append({"component":"whaleFlow","success":False,"error":f"{type(exc).__name__}: {exc}"})
|
| 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"}
|
| 320 |
+
fetched=saved=0;diagnostics=[]
|
| 321 |
+
for page in range(1,min(max(max_pages,1),20)+1):
|
| 322 |
+
payload,_,diag=await _json(["https://newsapi.org"],"/v2/everything",{"q":"cryptocurrency OR bitcoin OR ethereum","from":start_date,"to":end_date,"sortBy":"publishedAt","language":"en","pageSize":100,"page":page,"apiKey":key});diagnostics.extend(diag)
|
| 323 |
+
articles=payload.get("articles") if isinstance(payload,dict) else []
|
| 324 |
+
if not articles:break
|
| 325 |
+
normalized=[]
|
| 326 |
+
for item in articles:
|
| 327 |
+
try:published=int(datetime.fromisoformat(str(item.get("publishedAt","")).replace("Z","+00:00")).timestamp())
|
| 328 |
+
except Exception:published=int(time.time())
|
| 329 |
+
normalized.append({"id":item.get("url"),"title":item.get("title"),"body":item.get("content") or item.get("description"),"url":item.get("url"),"source":(item.get("source") or {}).get("name"),"publishedOn":published})
|
| 330 |
+
fetched+=len(normalized);saved+=_save("news","newsapi",normalized)
|
| 331 |
+
if len(articles)<100:break
|
| 332 |
+
await asyncio.sleep(.1)
|
| 333 |
+
return {"success":True,"component":"news","source":"newsapi","fetched":fetched,"saved":saved,"coverageMode":"CONDITIONAL_DATED_SEARCH","requestedStart":start_date,"requestedEnd":end_date,"complete":False,"warning":"Coverage depends on the configured NewsAPI plan and query matching; it is not asserted complete.","diagnostics":diagnostics[-5:]}
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
async def periodic_archive(stop: asyncio.Event) -> None:
|
| 337 |
+
last_oi_collection=0.0
|
| 338 |
+
last_news_collection=0.0
|
| 339 |
+
while not stop.is_set():
|
| 340 |
+
if time.time()-last_news_collection >= 900:
|
| 341 |
+
try:
|
| 342 |
+
await collect_forward()
|
| 343 |
+
last_news_collection=time.time()
|
| 344 |
+
except Exception: pass
|
| 345 |
+
else:
|
| 346 |
+
try:
|
| 347 |
+
scan=await _scan_evm_whales(1000)
|
| 348 |
+
_save("whaleFlow","public_evm_rpc_large_native_transfers",scan["items"])
|
| 349 |
+
except Exception: pass
|
| 350 |
+
if time.time()-last_oi_collection >= 86400:
|
| 351 |
+
try:
|
| 352 |
+
await collect_oi_archive("1h")
|
| 353 |
+
last_oi_collection=time.time()
|
| 354 |
+
except Exception: pass
|
| 355 |
+
try: await asyncio.wait_for(stop.wait(),timeout=120)
|
| 356 |
+
except asyncio.TimeoutError: pass
|
| 357 |
+
|
| 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}")
|
| 365 |
+
async def funding(symbol:str,start:Optional[int]=None,end:Optional[int]=None,limit:int=Query(1000,ge=1,le=100000)):
|
| 366 |
+
try:return await _funding(symbol,start,end,limit)
|
| 367 |
+
except Exception as exc:raise HTTPException(502,detail=f"funding upstream unavailable: {type(exc).__name__}: {exc}")
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
@router.get("/open-interest/{symbol}")
|
| 371 |
+
async def open_interest(symbol:str,period:str="1h",start:Optional[int]=None,end:Optional[int]=None,limit:int=Query(500,ge=1,le=5000)):
|
| 372 |
+
try:return await _oi(symbol,period,start,end,limit)
|
| 373 |
+
except ValueError as exc:raise HTTPException(422,detail=str(exc))
|
| 374 |
+
except Exception as exc:raise HTTPException(502,detail=f"open-interest upstream unavailable: {type(exc).__name__}: {exc}")
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
@router.get("/open-interest-archive/{symbol}")
|
| 378 |
+
async def open_interest_archive(symbol:str,start:Optional[int]=None,end:Optional[int]=None,limit:int=Query(10000,ge=1,le=100000)):
|
| 379 |
+
return _read_oi(symbol,start,end,limit)
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
@router.post("/open-interest/archive/collect")
|
| 383 |
+
async def collect_open_interest_archive(period:str="1h"):
|
| 384 |
+
try:return await collect_oi_archive(period)
|
| 385 |
+
except ValueError as exc:raise HTTPException(422,detail=str(exc))
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
@router.get("/sentiment/fear-greed")
|
| 389 |
+
async def fear_greed(limit:int=Query(0,ge=0,le=100000)):
|
| 390 |
+
try:
|
| 391 |
+
payload,provider,diag=await _json(["https://api.alternative.me"],"/fng/",{"limit":limit,"format":"json"})
|
| 392 |
+
data=payload.get("data") if isinstance(payload,dict) else []
|
| 393 |
+
return {"success":True,"component":"sentiment","data":data or [],"count":len(data or []),"source":"alternative.me","sourceMode":"LIVE","dataState":"REAL" if data else "UNAVAILABLE","coverage":{"mode":"REAL_HISTORICAL","earliestTimestamp":data[-1].get("timestamp") if data else None,"latestTimestamp":data[0].get("timestamp") if data else None},"diagnostics":diag,"timestamp":_now()}
|
| 394 |
+
except Exception as exc:raise HTTPException(502,detail=f"sentiment upstream unavailable: {type(exc).__name__}: {exc}")
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
@router.get("/news")
|
| 398 |
+
async def news(start:Optional[int]=None,end:Optional[int]=None,limit:int=Query(500,ge=1,le=10000)):return _read("news",start,end,limit)
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
@router.get("/whale-flow")
|
| 402 |
+
async def whales(start:Optional[int]=None,end:Optional[int]=None,limit:int=Query(500,ge=1,le=10000)):return _read("whaleFlow",start,end,limit)
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
@router.post("/archive/collect")
|
| 406 |
+
async def collect():return await collect_forward()
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
@router.post("/news/backfill")
|
| 410 |
+
async def news_backfill(start:str,end:str,max_pages:int=Query(5,ge=1,le=20)):
|
| 411 |
+
try:return await backfill_newsapi(start,end,max_pages)
|
| 412 |
+
except Exception as exc:raise HTTPException(502,detail=f"NewsAPI dated search failed: {type(exc).__name__}: {exc}")
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def register_apex_routes(app) -> None: app.include_router(router)
|
api_compat_routes.py
CHANGED
|
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|
| 20 |
import logging
|
| 21 |
import os
|
| 22 |
import json
|
|
|
|
| 23 |
from datetime import datetime, timezone
|
| 24 |
from typing import Any, Dict, List, Optional, Tuple
|
| 25 |
|
|
@@ -270,63 +271,119 @@ async def _fetch_coingecko_trending(limit: int = 10) -> Tuple[List[Dict[str, Any
|
|
| 270 |
return rows, []
|
| 271 |
|
| 272 |
|
| 273 |
-
async def _fetch_binance_klines(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
pair = _normalize_symbol(symbol)
|
| 275 |
mapped = BINANCE_INTERVALS.get(interval, interval)
|
| 276 |
-
|
|
|
|
|
|
|
| 277 |
"https://api.binance.com/api/v3/klines",
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
# KuCoin returns reverse chronological rows like [time, open, close, high, low, volume, turnover].
|
| 301 |
ksymbol = _kucoin_symbol(symbol)
|
| 302 |
ktype = KUCOIN_TYPES.get(timeframe, "1hour")
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
)
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
|
| 327 |
|
| 328 |
|
| 329 |
-
async def _fetch_cryptocompare_ohlcv(symbol: str, timeframe: str, limit: int) -> Tuple[List[Dict[str, Any]], List[str]]:
|
| 330 |
"""Optional OHLCV fallback via CryptoCompare.
|
| 331 |
|
| 332 |
Uses env CRYPTOCOMPARE_KEY when configured, but can still work on some
|
|
@@ -353,6 +410,8 @@ async def _fetch_cryptocompare_ohlcv(symbol: str, timeframe: str, limit: int) ->
|
|
| 353 |
"limit": min(max(limit, 1), 2000),
|
| 354 |
"aggregate": aggregate,
|
| 355 |
}
|
|
|
|
|
|
|
| 356 |
key = get_secret("CRYPTOCOMPARE_KEY")
|
| 357 |
if key:
|
| 358 |
params["api_key"] = key
|
|
@@ -379,7 +438,7 @@ async def _fetch_cryptocompare_ohlcv(symbol: str, timeframe: str, limit: int) ->
|
|
| 379 |
continue
|
| 380 |
return candles, []
|
| 381 |
|
| 382 |
-
async def _fetch_ohlcv(symbol: str, timeframe: str, limit: int) -> Tuple[List[Dict[str, Any]], str, List[str]]:
|
| 383 |
"""Smart OHLCV rotation.
|
| 384 |
|
| 385 |
Raw OHLCV must come from market/exchange providers. HF models are used for
|
|
@@ -388,17 +447,17 @@ async def _fetch_ohlcv(symbol: str, timeframe: str, limit: int) -> Tuple[List[Di
|
|
| 388 |
"""
|
| 389 |
errors: List[str] = []
|
| 390 |
|
| 391 |
-
candles, err = await _fetch_binance_klines(symbol, timeframe, limit)
|
| 392 |
if candles:
|
| 393 |
return candles, "binance_public", errors + err
|
| 394 |
errors.extend(err)
|
| 395 |
|
| 396 |
-
candles, err = await _fetch_kucoin_klines(symbol, timeframe, limit)
|
| 397 |
if candles:
|
| 398 |
return candles, "kucoin_public", errors + err
|
| 399 |
errors.extend(err)
|
| 400 |
|
| 401 |
-
candles, err = await _fetch_cryptocompare_ohlcv(symbol, timeframe, limit)
|
| 402 |
if candles:
|
| 403 |
return candles, "cryptocompare_ohlcv", errors + err
|
| 404 |
errors.extend(err)
|
|
@@ -569,11 +628,28 @@ async def coins_top_alias(limit: int = Query(100, ge=1, le=250)):
|
|
| 569 |
|
| 570 |
|
| 571 |
@router.get("/api/trading/ohlcv/{symbol}")
|
| 572 |
-
async def trading_ohlcv(symbol: str, timeframe: str = "1h", limit: int = 100):
|
| 573 |
-
candles, source, errors = await _fetch_ohlcv(symbol, timeframe, limit)
|
| 574 |
if not candles:
|
| 575 |
return _fail("OHLCV unavailable", source=source, symbol=_normalize_symbol(symbol), timeframe=timeframe, data=[], missingCapabilities=["ohlcv"], upstreamErrors=errors)
|
| 576 |
-
return _ok(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
|
| 578 |
|
| 579 |
@router.get("/api/ohlcv")
|
|
@@ -582,15 +658,30 @@ async def trading_ohlcv_alias(
|
|
| 582 |
symbol: str = Query("BTCUSDT"),
|
| 583 |
interval: str = Query("1h"),
|
| 584 |
timeframe: Optional[str] = Query(None),
|
| 585 |
-
limit: int = Query(100, ge=1, le=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
):
|
| 587 |
-
return await trading_ohlcv(symbol=symbol, timeframe=(timeframe or interval), limit=limit)
|
| 588 |
|
| 589 |
|
| 590 |
@router.get("/api/history")
|
| 591 |
@router.get("/api/trading/history/{symbol}")
|
| 592 |
-
async def history_alias(
|
| 593 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
|
| 595 |
|
| 596 |
@router.get("/api/trading/stats/24h/{symbol}")
|
|
|
|
| 20 |
import logging
|
| 21 |
import os
|
| 22 |
import json
|
| 23 |
+
import asyncio
|
| 24 |
from datetime import datetime, timezone
|
| 25 |
from typing import Any, Dict, List, Optional, Tuple
|
| 26 |
|
|
|
|
| 271 |
return rows, []
|
| 272 |
|
| 273 |
|
| 274 |
+
async def _fetch_binance_klines(
|
| 275 |
+
symbol: str,
|
| 276 |
+
interval: str,
|
| 277 |
+
limit: int,
|
| 278 |
+
start_time: Optional[int] = None,
|
| 279 |
+
end_time: Optional[int] = None,
|
| 280 |
+
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
| 281 |
pair = _normalize_symbol(symbol)
|
| 282 |
mapped = BINANCE_INTERVALS.get(interval, interval)
|
| 283 |
+
total_limit = min(max(limit, 1), 200_000)
|
| 284 |
+
urls = [
|
| 285 |
+
"https://data-api.binance.vision/api/v3/klines",
|
| 286 |
"https://api.binance.com/api/v3/klines",
|
| 287 |
+
"https://api1.binance.com/api/v3/klines",
|
| 288 |
+
]
|
| 289 |
+
rows: Dict[int, Dict[str, Any]] = {}
|
| 290 |
+
errors: List[str] = []
|
| 291 |
+
cursor_start = int(start_time) if start_time is not None else None
|
| 292 |
+
cursor_end = int(end_time) if end_time is not None else None
|
| 293 |
+
forward = cursor_start is not None
|
| 294 |
+
while len(rows) < total_limit:
|
| 295 |
+
page_size = min(1000, total_limit - len(rows))
|
| 296 |
+
params: Dict[str, Any] = {"symbol": pair, "interval": mapped, "limit": page_size}
|
| 297 |
+
if cursor_start is not None:
|
| 298 |
+
params["startTime"] = cursor_start
|
| 299 |
+
if cursor_end is not None:
|
| 300 |
+
params["endTime"] = cursor_end
|
| 301 |
+
payload = None
|
| 302 |
+
last_error = None
|
| 303 |
+
for url in urls:
|
| 304 |
+
candidate, error, _ = await _get_json(url, params=params, timeout=25.0)
|
| 305 |
+
if not error and isinstance(candidate, list):
|
| 306 |
+
payload = candidate
|
| 307 |
+
break
|
| 308 |
+
last_error = error or f"invalid payload from {url}"
|
| 309 |
+
if not isinstance(payload, list):
|
| 310 |
+
errors.append(last_error or f"Binance klines invalid payload for {pair}")
|
| 311 |
+
break
|
| 312 |
+
if not payload:
|
| 313 |
+
break
|
| 314 |
+
for row in payload:
|
| 315 |
+
try:
|
| 316 |
+
ts = _int(row[0])
|
| 317 |
+
rows[ts] = {"timestamp": ts, "open": _float(row[1]), "high": _float(row[2]), "low": _float(row[3]), "close": _float(row[4]), "volume": _float(row[5])}
|
| 318 |
+
except Exception:
|
| 319 |
+
continue
|
| 320 |
+
first_ts, last_ts = _int(payload[0][0]), _int(payload[-1][0])
|
| 321 |
+
if forward:
|
| 322 |
+
next_start = last_ts + 1
|
| 323 |
+
if next_start <= (cursor_start or -1) or (end_time is not None and next_start > int(end_time)):
|
| 324 |
+
break
|
| 325 |
+
cursor_start = next_start
|
| 326 |
+
elif cursor_end is not None:
|
| 327 |
+
next_end = first_ts - 1
|
| 328 |
+
if next_end >= cursor_end:
|
| 329 |
+
break
|
| 330 |
+
cursor_end = next_end
|
| 331 |
+
else:
|
| 332 |
+
break
|
| 333 |
+
if len(payload) < page_size:
|
| 334 |
+
break
|
| 335 |
+
await asyncio.sleep(0.03)
|
| 336 |
+
candles = [rows[key] for key in sorted(rows)]
|
| 337 |
+
return (candles[:total_limit] if forward else candles[-total_limit:]), errors
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
async def _fetch_kucoin_klines(
|
| 341 |
+
symbol: str,
|
| 342 |
+
timeframe: str,
|
| 343 |
+
limit: int,
|
| 344 |
+
start_time: Optional[int] = None,
|
| 345 |
+
end_time: Optional[int] = None,
|
| 346 |
+
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
| 347 |
# KuCoin returns reverse chronological rows like [time, open, close, high, low, volume, turnover].
|
| 348 |
ksymbol = _kucoin_symbol(symbol)
|
| 349 |
ktype = KUCOIN_TYPES.get(timeframe, "1hour")
|
| 350 |
+
total_limit = min(max(limit, 1), 200_000)
|
| 351 |
+
rows: Dict[int, Dict[str, Any]] = {}
|
| 352 |
+
errors: List[str] = []
|
| 353 |
+
cursor_start = int(start_time / 1000) if start_time is not None else None
|
| 354 |
+
cursor_end = int(end_time / 1000) if end_time is not None else None
|
| 355 |
+
# KuCoin pages are naturally bounded by startAt/endAt. Move the end cursor
|
| 356 |
+
# backwards until the requested start is covered.
|
| 357 |
+
while len(rows) < total_limit:
|
| 358 |
+
params: Dict[str, Any] = {"symbol": ksymbol, "type": ktype}
|
| 359 |
+
if cursor_start is not None:
|
| 360 |
+
params["startAt"] = cursor_start
|
| 361 |
+
if cursor_end is not None:
|
| 362 |
+
params["endAt"] = cursor_end
|
| 363 |
+
payload, error, _ = await _get_json("https://api.kucoin.com/api/v1/market/candles", params=params, timeout=25.0)
|
| 364 |
+
if error or not isinstance(payload, dict):
|
| 365 |
+
errors.append(error or f"KuCoin candles invalid payload for {ksymbol}")
|
| 366 |
+
break
|
| 367 |
+
data = payload.get("data") or []
|
| 368 |
+
if not data:
|
| 369 |
+
break
|
| 370 |
+
for row in data:
|
| 371 |
+
try:
|
| 372 |
+
ts = _int(row[0]) * 1000
|
| 373 |
+
rows[ts] = {"timestamp": ts, "open": _float(row[1]), "high": _float(row[3]), "low": _float(row[4]), "close": _float(row[2]), "volume": _float(row[5])}
|
| 374 |
+
except Exception:
|
| 375 |
+
continue
|
| 376 |
+
oldest_sec = min(_int(row[0]) for row in data)
|
| 377 |
+
if cursor_start is None or oldest_sec <= cursor_start or len(data) < 100:
|
| 378 |
+
break
|
| 379 |
+
cursor_end = oldest_sec - 1
|
| 380 |
+
await asyncio.sleep(0.03)
|
| 381 |
+
candles = [rows[key] for key in sorted(rows)]
|
| 382 |
+
return candles[-total_limit:], errors
|
| 383 |
|
| 384 |
|
| 385 |
|
| 386 |
+
async def _fetch_cryptocompare_ohlcv(symbol: str, timeframe: str, limit: int, start_time: Optional[int] = None, end_time: Optional[int] = None) -> Tuple[List[Dict[str, Any]], List[str]]:
|
| 387 |
"""Optional OHLCV fallback via CryptoCompare.
|
| 388 |
|
| 389 |
Uses env CRYPTOCOMPARE_KEY when configured, but can still work on some
|
|
|
|
| 410 |
"limit": min(max(limit, 1), 2000),
|
| 411 |
"aggregate": aggregate,
|
| 412 |
}
|
| 413 |
+
if end_time is not None:
|
| 414 |
+
params["toTs"] = int(end_time / 1000)
|
| 415 |
key = get_secret("CRYPTOCOMPARE_KEY")
|
| 416 |
if key:
|
| 417 |
params["api_key"] = key
|
|
|
|
| 438 |
continue
|
| 439 |
return candles, []
|
| 440 |
|
| 441 |
+
async def _fetch_ohlcv(symbol: str, timeframe: str, limit: int, start_time: Optional[int] = None, end_time: Optional[int] = None) -> Tuple[List[Dict[str, Any]], str, List[str]]:
|
| 442 |
"""Smart OHLCV rotation.
|
| 443 |
|
| 444 |
Raw OHLCV must come from market/exchange providers. HF models are used for
|
|
|
|
| 447 |
"""
|
| 448 |
errors: List[str] = []
|
| 449 |
|
| 450 |
+
candles, err = await _fetch_binance_klines(symbol, timeframe, limit, start_time, end_time)
|
| 451 |
if candles:
|
| 452 |
return candles, "binance_public", errors + err
|
| 453 |
errors.extend(err)
|
| 454 |
|
| 455 |
+
candles, err = await _fetch_kucoin_klines(symbol, timeframe, limit, start_time, end_time)
|
| 456 |
if candles:
|
| 457 |
return candles, "kucoin_public", errors + err
|
| 458 |
errors.extend(err)
|
| 459 |
|
| 460 |
+
candles, err = await _fetch_cryptocompare_ohlcv(symbol, timeframe, limit, start_time, end_time)
|
| 461 |
if candles:
|
| 462 |
return candles, "cryptocompare_ohlcv", errors + err
|
| 463 |
errors.extend(err)
|
|
|
|
| 628 |
|
| 629 |
|
| 630 |
@router.get("/api/trading/ohlcv/{symbol}")
|
| 631 |
+
async def trading_ohlcv(symbol: str, timeframe: str = "1h", limit: int = 100, start_time: Optional[int] = None, end_time: Optional[int] = None):
|
| 632 |
+
candles, source, errors = await _fetch_ohlcv(symbol, timeframe, limit, start_time, end_time)
|
| 633 |
if not candles:
|
| 634 |
return _fail("OHLCV unavailable", source=source, symbol=_normalize_symbol(symbol), timeframe=timeframe, data=[], missingCapabilities=["ohlcv"], upstreamErrors=errors)
|
| 635 |
+
return _ok(
|
| 636 |
+
data=candles,
|
| 637 |
+
symbol=_normalize_symbol(symbol),
|
| 638 |
+
timeframe=timeframe,
|
| 639 |
+
candles=candles,
|
| 640 |
+
count=len(candles),
|
| 641 |
+
source=source,
|
| 642 |
+
sourceMode="LIVE",
|
| 643 |
+
dataState="REAL",
|
| 644 |
+
coverage={
|
| 645 |
+
"mode": "REAL_HISTORICAL" if start_time is not None or end_time is not None else "LIVE_RECENT",
|
| 646 |
+
"requestedStart": start_time,
|
| 647 |
+
"requestedEnd": end_time,
|
| 648 |
+
"earliestTimestamp": candles[0]["timestamp"] if candles else None,
|
| 649 |
+
"latestTimestamp": candles[-1]["timestamp"] if candles else None,
|
| 650 |
+
"rowCount": len(candles),
|
| 651 |
+
},
|
| 652 |
+
)
|
| 653 |
|
| 654 |
|
| 655 |
@router.get("/api/ohlcv")
|
|
|
|
| 658 |
symbol: str = Query("BTCUSDT"),
|
| 659 |
interval: str = Query("1h"),
|
| 660 |
timeframe: Optional[str] = Query(None),
|
| 661 |
+
limit: int = Query(100, ge=1, le=200000),
|
| 662 |
+
since: Optional[int] = Query(None),
|
| 663 |
+
start: Optional[int] = Query(None),
|
| 664 |
+
end: Optional[int] = Query(None),
|
| 665 |
+
startTime: Optional[int] = Query(None),
|
| 666 |
+
endTime: Optional[int] = Query(None),
|
| 667 |
):
|
| 668 |
+
return await trading_ohlcv(symbol=symbol, timeframe=(timeframe or interval), limit=limit, start_time=(startTime or start or since), end_time=(endTime or end))
|
| 669 |
|
| 670 |
|
| 671 |
@router.get("/api/history")
|
| 672 |
@router.get("/api/trading/history/{symbol}")
|
| 673 |
+
async def history_alias(
|
| 674 |
+
symbol: str = "BTCUSDT",
|
| 675 |
+
interval: str = "1h",
|
| 676 |
+
timeframe: Optional[str] = None,
|
| 677 |
+
limit: int = Query(100, ge=1, le=200000),
|
| 678 |
+
since: Optional[int] = None,
|
| 679 |
+
start: Optional[int] = None,
|
| 680 |
+
end: Optional[int] = None,
|
| 681 |
+
startTime: Optional[int] = None,
|
| 682 |
+
endTime: Optional[int] = None,
|
| 683 |
+
):
|
| 684 |
+
return await trading_ohlcv(symbol=symbol, timeframe=(timeframe or interval), limit=limit, start_time=(startTime or start or since), end_time=(endTime or end))
|
| 685 |
|
| 686 |
|
| 687 |
@router.get("/api/trading/stats/24h/{symbol}")
|
api_server_extended.py
CHANGED
|
@@ -688,9 +688,24 @@ async def lifespan(app: FastAPI):
|
|
| 688 |
except Exception as e:
|
| 689 |
print(f"[WARN] Resource validation failed: {e}")
|
| 690 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 691 |
print(f"[OK] Server ready on port {PORT}")
|
| 692 |
print("=" * 80)
|
| 693 |
yield
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 694 |
print("Shutting down...")
|
| 695 |
|
| 696 |
|
|
@@ -784,6 +799,14 @@ async def index():
|
|
| 784 |
"""Serve index.html"""
|
| 785 |
return _serve_html_asset("index.html", empty_title="index.html not found")
|
| 786 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 787 |
|
| 788 |
@app.get("/dashboard.html", response_class=HTMLResponse)
|
| 789 |
async def dashboard():
|
|
@@ -4142,6 +4165,14 @@ try:
|
|
| 4142 |
except Exception as short_hunter_error:
|
| 4143 |
logger.warning(f"Short Hunter datasource routes not loaded: {short_hunter_error}")
|
| 4144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4145 |
# ===== Main Entry Point =====
|
| 4146 |
if __name__ == "__main__":
|
| 4147 |
import uvicorn
|
|
|
|
| 688 |
except Exception as e:
|
| 689 |
print(f"[WARN] Resource validation failed: {e}")
|
| 690 |
|
| 691 |
+
archive_stop = asyncio.Event()
|
| 692 |
+
archive_task = None
|
| 693 |
+
try:
|
| 694 |
+
from apex_strategy_routes import periodic_archive
|
| 695 |
+
archive_task = asyncio.create_task(periodic_archive(archive_stop))
|
| 696 |
+
print("[OK] APEX news/whale forward archive (15 min) + daily OI archive started")
|
| 697 |
+
except Exception as archive_exc:
|
| 698 |
+
print(f"[WARN] APEX forward archive did not start: {archive_exc}")
|
| 699 |
+
|
| 700 |
print(f"[OK] Server ready on port {PORT}")
|
| 701 |
print("=" * 80)
|
| 702 |
yield
|
| 703 |
+
archive_stop.set()
|
| 704 |
+
if archive_task is not None:
|
| 705 |
+
try:
|
| 706 |
+
await asyncio.wait_for(archive_task, timeout=5)
|
| 707 |
+
except Exception:
|
| 708 |
+
archive_task.cancel()
|
| 709 |
print("Shutting down...")
|
| 710 |
|
| 711 |
|
|
|
|
| 799 |
"""Serve index.html"""
|
| 800 |
return _serve_html_asset("index.html", empty_title="index.html not found")
|
| 801 |
|
| 802 |
+
@app.get("/data-lab", response_class=HTMLResponse)
|
| 803 |
+
async def data_lab():
|
| 804 |
+
return _serve_html_asset("data_lab.html", empty_title="APEX Data Lab not found")
|
| 805 |
+
|
| 806 |
+
@app.get("/help", response_class=HTMLResponse)
|
| 807 |
+
async def help_page():
|
| 808 |
+
return _serve_html_asset("help.html", empty_title="API help not found")
|
| 809 |
+
|
| 810 |
|
| 811 |
@app.get("/dashboard.html", response_class=HTMLResponse)
|
| 812 |
async def dashboard():
|
|
|
|
| 4165 |
except Exception as short_hunter_error:
|
| 4166 |
logger.warning(f"Short Hunter datasource routes not loaded: {short_hunter_error}")
|
| 4167 |
|
| 4168 |
+
# ===== APEX strategy history + truthful forward archives =====
|
| 4169 |
+
try:
|
| 4170 |
+
from apex_strategy_routes import register_apex_routes
|
| 4171 |
+
register_apex_routes(app)
|
| 4172 |
+
logger.info("APEX historical data routes loaded (/api/apex/*)")
|
| 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
|
data_lab.html
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>APEX Historical Data Lab Β· Space 4</title><link rel="stylesheet" href="/static/css/apex-data-lab.css"></head><body><header><div class="brand"><div class="orb"></div><div><strong>APEX Data Lab</strong><div class="eyebrow">Datasource Space 4</div></div></div><nav><a href="/">Dashboard</a><a href="/help">Help & provenance</a><a href="/docs">OpenAPI</a></nav></header><main><section class="hero"><div class="eyebrow">Real data Β· explicit coverage Β· LIVE snapshot contract preserved</div><h1>Historical depth, without the marketing fog.</h1><p>Inspect real paginated candles and strategy inputs. Every panel surfaces provenance and freshness so recent-only data cannot masquerade as historical coverage.</p></section><section class="grid"><div class="panel controls"><h2>Historical request</h2><div class="form"><div><label>Symbol</label><input id="symbol" value="BTCUSDT"></div><div><label>Interval</label><select id="interval"><option>1h</option><option selected>4h</option><option>1d</option></select></div><div><label>Start</label><input id="start" type="date" value="2022-01-01"></div><div><label>End</label><input id="end" type="date" value="2022-03-01"></div><button id="load">Load real candles</button></div><div class="status"><span id="dot" class="dot"></span><span id="state">Ready</span></div><div id="error" class="error"></div></div><div class="panel chart"><div class="meta">OHLCV close Β· UTC</div><canvas id="canvas" width="820" height="330"></canvas><div class="stats"><div class="stat"><span class="meta">Rows</span><b id="rows">β</b></div><div class="stat"><span class="meta">Earliest</span><b id="earliest">β</b></div><div class="stat"><span class="meta">Latest</span><b id="latest">β</b></div></div></div><div class="panel coverage"><h2>Strategy coverage</h2><div id="cards" class="cards"></div></div></section></main><script src="/static/js/apex-data-lab.js"></script></body></html>
|
help.html
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Space 4 Β· Real Data Guide</title><style>
|
| 2 |
+
:root{color-scheme:dark;--bg:#070b14;--card:#10192abb;--line:#ffffff12;--text:#eff5ff;--muted:#90a2bd;--good:#42e5c6;--warn:#ffbd5b;--bad:#ff6b7d}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 15% 0,#16334b,transparent 28%),var(--bg);color:var(--text);font:15px/1.65 Inter,system-ui}header,main{width:min(1120px,calc(100% - 32px));margin:auto}header{display:flex;justify-content:space-between;align-items:center;padding:24px 0}a{color:#79ddff;text-decoration:none}nav a{margin-left:18px;color:var(--text)}.hero{padding:48px 0 30px}.kicker{color:var(--good);font-size:12px;letter-spacing:.12em;text-transform:uppercase}h1{font-size:clamp(34px,7vw,66px);line-height:1;margin:18px 0}h2{margin-top:50px}p{color:var(--muted)}.matrix{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.cell,.endpoint,.warning{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:18px}.cell b{display:block}.tag{font-size:11px;padding:5px 8px;border-radius:999px;background:#42e5c61b;color:var(--good)}.tag.warn{color:var(--warn);background:#ffbd5b1b}.tag.bad{color:var(--bad);background:#ff6b7d1b}.warning{border-color:#ffbd5b55}.endpoint{margin:14px 0}.method{font:700 12px ui-monospace;color:var(--good)}code,pre{font-family:ui-monospace,monospace}pre{overflow:auto;background:#050a12;border:1px solid var(--line);padding:15px;border-radius:13px;color:#cce6ff}summary{cursor:pointer}.route-list{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}.route{background:#0c1422;border:1px solid var(--line);border-radius:11px;padding:10px;font:12px ui-monospace}.muted{color:var(--muted)}@media(max-width:760px){.matrix,.route-list{grid-template-columns:1fr}nav{display:none}}
|
| 3 |
+
</style></head><body><header><strong>Datasource 4 Β· Truthful API Guide</strong><nav><a href="/">Dashboard</a><a href="/data-lab">Data Lab</a><a href="/docs">OpenAPI</a></nav></header><main><section class="hero"><div class="kicker">Canonical compat router Β· updated 2026-08-25</div><h1>Market history and LIVE snapshots, clearly separated.</h1><p>The compatibility router is the canonical historical path. The Enterprise Data Hub's duplicate aliases remain unregistered. Short Hunter stays a LIVE-only contract.</p></section><div class="warning"><b>Consumer trap</b><p>HTTP 200 is not proof of data. Check <code>success</code>, <code>dataState</code>, <code>coverage.mode</code>, <code>count</code> and array length.</p></div><h2>Coverage matrix</h2><div class="matrix"><div class="cell"><b>OHLCV</b><span class="tag">REAL HISTORICAL</span><p>Binance Vision β Binance β KuCoin β CryptoCompare, with temporal pagination.</p></div><div class="cell"><b>Funding</b><span class="tag">REAL HISTORICAL</span><p>Gate.io provides real funding with about 180 days of retention in the current HF runtime; Binance/Bybit are geo-blocked there.</p></div><div class="cell"><b>Open interest</b><span class="tag warn">180-DAY + FORWARD</span><p>Gate.io supplies the real ~180-day production window plus a daily SQLite cache; Binance/Bybit are geo-blocked from the current HF runtime.</p></div><div class="cell"><b>Fear & Greed</b><span class="tag">REAL HISTORICAL</span><p>Full Alternative.me series.</p></div><div class="cell"><b>News</b><span class="tag warn">FORWARD ONLY</span><p>Authenticated NewsData latest feed; no pre-deployment history.</p></div><div class="cell"><b>Whale flow</b><span class="tag warn">FORWARD ONLY</span><p>Real large native ETH/BNB transfers from public RPC blocks; no owner labels.</p></div></div><h2>Endpoints and verified behavior</h2>
|
| 4 |
+
<article class="endpoint"><div class="method">GET</div><h3>/api/ohlcv Β· /api/klines Β· /api/history</h3><p>Intentional aliases of one implementation. Supports <code>since/start/startTime</code> and <code>end/endTime</code> in milliseconds.</p><pre>curl "$BASE/api/history?symbol=BTCUSDT&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>Funding is multi-year. OI's live source is limited to 30 days, but startup + daily collection upserts the complete rolling window for <code>APEX_TRACKED_SYMBOLS</code>, deduped by symbol + timestamp. Production history begins at <code>archiveStartedAt</code>; earlier OI cannot be backfilled. <code>/api/apex/coverage</code> distinguishes a mounted <code>/data</code> persistent volume from ephemeral container storage.</p><pre>curl "$BASE/api/apex/funding/BTCUSDT?start=1640995200000&end=1735689599999&limit=20000"
|
| 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>Captured response summaries</summary><pre>funding: {"count":3288,"earliestTimestamp":1640995200006,"latestTimestamp":1735660800000}
|
| 10 |
+
openInterest: {"trackedSymbols":5,"rowsPerSymbol":500,"dedupeKey":["symbol","timestamp"],"secondRunDuplicateRows":0}</pre></details></article>
|
| 11 |
+
<article class="endpoint"><div class="method">GET</div><h3>/api/apex/sentiment/fear-greed?limit=0</h3><p>Full dated Alternative.me history.</p><pre>curl "$BASE/api/apex/sentiment/fear-greed?limit=0"</pre><pre>const sentiment = await fetch(`${base}/api/apex/sentiment/fear-greed?limit=0`).then(r=>r.json());</pre><details><summary>Captured response summary</summary><pre>{"count":3123,"source":"alternative.me","coverage":{"mode":"REAL_HISTORICAL","earliestTimestamp":"1517443200","latestTimestamp":"1787529600"}}</pre></details></article>
|
| 12 |
+
<article class="endpoint"><div class="method">GET</div><h3>/api/short-hunter/snapshot/{symbol}</h3><p>LIVE-only multi-capability snapshot. It is not a replay endpoint and accepts no historical timestamp.</p><pre>curl "$BASE/api/short-hunter/snapshot/BTCUSDT?interval=1h&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
|
| 15 |
+
CACHED/CACHED = cache response
|
| 16 |
+
UNAVAILABLE/UNAVAILABLE = no usable provider result</pre></details></article>
|
| 17 |
+
<article class="endpoint"><div class="method">GET / POST</div><h3>/api/apex/news Β· /api/apex/whale-flow Β· /api/apex/archive/collect</h3><p>News uses authenticated NewsData every 15 minutes. Whale flow runs every 2 minutes, scans 15 Ethereum and 200 BSC recent blocks by default, and archives native transfers above configurable thresholds (defaults: 50 ETH / 500 BNB), deduped by chain + transaction hash. It is real transfer flow, not address-owner labeling; both archives begin only after deployment.</p><pre>curl -X POST "$BASE/api/apex/archive/collect"
|
| 18 |
+
curl "$BASE/api/apex/news?limit=500"</pre><pre>const news = await fetch(`${base}/api/apex/news?limit=500`).then(r=>r.json());
|
| 19 |
+
console.assert(news.coverage.mode === 'FORWARD_COLLECTING_ONLY');</pre><details><summary>Captured provider verification</summary><pre>NewsData: HTTP 200, fetched=10, first saved=10, second saved=0
|
| 20 |
+
CryptoCompare candidate: HTTP 401 Β· NewsAPI.org candidate: HTTP 401
|
| 21 |
+
BitQuery old/new without token: HTTP 401 Β· ClankApp: HTTP 403
|
| 22 |
+
DIY live test: 12 ETH blocks, 2,556 transactions, 2 matches β₯50 ETH; 12 BSC blocks, 744 transactions, 0 matches β₯500 BNB
|
| 23 |
+
BitQuery signup: https://ide.bitquery.io (time-limited free trial; paid production plans thereafter)
|
| 24 |
+
Whale source: public_evm_rpc_large_native_transfers; forward-only</pre></details></article>
|
| 25 |
+
<h2>All live routes</h2><p>Generated from the deployed OpenAPI contract.</p><div id="routes" class="route-list"><div class="muted">Loading OpenAPIβ¦</div></div><script>fetch('/openapi.json').then(r=>r.json()).then(j=>{routes.innerHTML=Object.entries(j.paths).flatMap(([p,ops])=>Object.entries(ops).filter(([m])=>['get','post','put','delete','patch'].includes(m)).map(([m,o])=>`<div class="route"><b>${m.toUpperCase()}</b> ${p}<br><span class="muted">${o.summary||o.description?.split('\n')[0]||'Registered endpoint'}</span></div>`)).join('')}).catch(e=>routes.textContent='OpenAPI unavailable: '+e.message)</script></main></body></html>
|
index.html
CHANGED
|
@@ -914,5 +914,9 @@
|
|
| 914 |
</main>
|
| 915 |
</div>
|
| 916 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 917 |
</body>
|
| 918 |
</html>
|
|
|
|
| 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>
|
requirements.txt
CHANGED
|
@@ -3,7 +3,7 @@ fastapi>=0.110,<1.0
|
|
| 3 |
uvicorn[standard]>=0.27,<1.0
|
| 4 |
|
| 5 |
# ββ HTTP clients ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 6 |
-
httpx>=0.26,<1.0
|
| 7 |
requests>=2.31,<3.0
|
| 8 |
aiohttp>=3.9,<4.0
|
| 9 |
|
|
@@ -35,4 +35,4 @@ accelerate>=0.27.0
|
|
| 35 |
|
| 36 |
# ββ Optional but IMPORTANT for real inference stability ββββββββββββββββββββββ
|
| 37 |
sentencepiece>=0.1.99
|
| 38 |
-
protobuf>=4.25.0
|
|
|
|
| 3 |
uvicorn[standard]>=0.27,<1.0
|
| 4 |
|
| 5 |
# ββ HTTP clients ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 6 |
+
httpx[socks]>=0.26,<1.0
|
| 7 |
requests>=2.31,<3.0
|
| 8 |
aiohttp>=3.9,<4.0
|
| 9 |
|
|
|
|
| 35 |
|
| 36 |
# ββ Optional but IMPORTANT for real inference stability ββββββββββββββββββββββ
|
| 37 |
sentencepiece>=0.1.99
|
| 38 |
+
protobuf>=4.25.0
|
static/css/apex-data-lab.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
:root{color-scheme:dark;--bg:#070b14;--panel:rgba(18,25,43,.72);--line:rgba(255,255,255,.09);--text:#eef4ff;--muted:#8fa1bc;--cyan:#45e5d2;--violet:#9d7cff;--amber:#ffbd5b;--red:#ff6577}*{box-sizing:border-box}body{margin:0;min-height:100vh;font:15px/1.55 Inter,ui-sans-serif,system-ui;background:radial-gradient(circle at 15% 5%,#132b44 0,transparent 32%),radial-gradient(circle at 90% 10%,#251a4a 0,transparent 28%),var(--bg);color:var(--text)}header,main{width:min(1220px,calc(100% - 32px));margin:auto}header{display:flex;align-items:center;justify-content:space-between;padding:26px 0}.brand{display:flex;gap:12px;align-items:center}.orb{width:38px;height:38px;border-radius:13px;background:linear-gradient(135deg,var(--cyan),var(--violet));box-shadow:0 0 34px #45e5d244}.eyebrow,.meta{color:var(--muted);font-size:12px;letter-spacing:.1em;text-transform:uppercase}h1{font-size:clamp(28px,5vw,54px);line-height:1.02;margin:24px 0 12px;max-width:820px}h2{font-size:18px;margin:0 0 16px}p{color:var(--muted)}nav a{color:var(--text);text-decoration:none;margin-left:20px}.hero{padding:40px 0 28px}.hero p{max-width:720px;font-size:17px}.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:16px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:22px;padding:20px;box-shadow:0 18px 70px #0005;backdrop-filter:blur(18px)}.controls{grid-column:span 4}.chart{grid-column:span 8}.coverage{grid-column:span 12}.form{display:grid;gap:12px}label{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em}input,select,button{width:100%;border:1px solid var(--line);background:#0a1120;color:var(--text);padding:12px 13px;border-radius:12px;font:inherit}button{cursor:pointer;border:0;background:linear-gradient(135deg,var(--cyan),#5ba7ff);color:#04131a;font-weight:800;margin-top:6px}.status{display:flex;gap:8px;align-items:center;margin:14px 0}.dot{width:9px;height:9px;border-radius:50%;background:var(--amber);box-shadow:0 0 16px currentColor}.dot.real{background:var(--cyan)}.dot.bad{background:var(--red)}canvas{width:100%;height:330px;background:linear-gradient(180deg,#0b1425aa,#08101c33);border-radius:14px}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:12px}.stat{background:#09111f;border:1px solid var(--line);border-radius:13px;padding:12px}.stat b{display:block;font-size:18px}.cards{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}.card{border:1px solid var(--line);border-radius:15px;padding:14px;background:#09111d}.badge{display:inline-block;margin-top:8px;padding:5px 8px;border-radius:999px;background:#45e5d21a;color:var(--cyan);font-size:11px}.badge.forward{color:var(--amber);background:#ffbd5b1a}.badge.limited{color:#ff91a0;background:#ff65771a}.error{color:#ff91a0;white-space:pre-wrap}@media(max-width:900px){.controls,.chart{grid-column:span 12}.cards{grid-template-columns:1fr 1fr}.stats{grid-template-columns:1fr}nav{display:none}}@media(max-width:560px){.cards{grid-template-columns:1fr}}
|
static/js/apex-data-lab.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
const $=id=>document.getElementById(id),fmt=ts=>ts?new Date(Number(ts)).toISOString().slice(0,10):'β';function draw(rows){const c=$('canvas'),x=c.getContext('2d'),w=c.width,h=c.height;x.clearRect(0,0,w,h);if(!rows.length)return;const vals=rows.map(r=>+r.close),lo=Math.min(...vals),hi=Math.max(...vals),span=hi-lo||1;x.strokeStyle='#45e5d2';x.lineWidth=2;x.beginPath();rows.forEach((r,i)=>{const px=i/(rows.length-1||1)*(w-40)+20,py=h-20-(+r.close-lo)/span*(h-40);i?x.lineTo(px,py):x.moveTo(px,py)});x.stroke();x.fillStyle='#8fa1bc';x.font='12px system-ui';x.fillText(hi.toFixed(2),12,16);x.fillText(lo.toFixed(2),12,h-6)}async function load(){const start=Date.parse($('start').value+'T00:00:00Z'),end=Date.parse($('end').value+'T23:59:59Z'),url=`/api/ohlcv?symbol=${encodeURIComponent($('symbol').value)}&interval=${$('interval').value}&start=${start}&end=${end}&limit=100000`;$('state').textContent='Loading upstream pagesβ¦';$('dot').className='dot';$('error').textContent='';try{const r=await fetch(url),j=await r.json();if(!r.ok||!j.success)throw Error(j.detail||j.error||'Empty/unavailable response');const data=j.data||j.candles||[];draw(data);$('rows').textContent=data.length.toLocaleString();$('earliest').textContent=fmt(data[0]?.timestamp);$('latest').textContent=fmt(data.at(-1)?.timestamp);$('state').textContent=`${j.dataState||'REAL'} Β· ${j.source||'provider'} Β· ${j.coverage?.mode||'unknown'}`;$('dot').className='dot real'}catch(e){$('state').textContent='UNAVAILABLE';$('dot').className='dot bad';$('error').textContent=e.message}}$('load').onclick=load;async function coverage(){const j=await fetch('/api/apex/coverage').then(r=>r.json());$('cards').innerHTML=Object.entries(j.components||{}).map(([k,v])=>`<div class="card"><strong>${k}</strong><p>${v.endpoint||''}</p><span class="badge ${v.mode.includes('FORWARD')?'forward':v.mode.includes('LIMITED')?'limited':''}">${v.mode}</span></div>`).join('')}coverage();load();
|
tests/test_apex_history.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
import api_compat_routes as compat
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@pytest.mark.asyncio
|
| 7 |
+
async def test_compat_binance_range_pages_forward(monkeypatch):
|
| 8 |
+
calls = []
|
| 9 |
+
|
| 10 |
+
async def fake_json(url, params=None, timeout=0):
|
| 11 |
+
calls.append(dict(params or {}))
|
| 12 |
+
start = (params or {}).get("startTime", 1000)
|
| 13 |
+
return [[start + i * 1000, "1", "2", ".5", "1.5", "10"] for i in range((params or {})["limit"])], None, 1
|
| 14 |
+
|
| 15 |
+
monkeypatch.setattr(compat, "_get_json", fake_json)
|
| 16 |
+
rows, errors = await compat._fetch_binance_klines("BTCUSDT", "1h", 2000, 1000, 3_000_000)
|
| 17 |
+
assert not errors
|
| 18 |
+
assert len(rows) == 2000
|
| 19 |
+
assert rows[0]["timestamp"] == 1000
|
| 20 |
+
assert calls[1]["startTime"] == 1_000_001
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_coverage_labels_separate_historical_oi_from_forward_only_events():
|
| 24 |
+
from fastapi.testclient import TestClient
|
| 25 |
+
from fastapi import FastAPI
|
| 26 |
+
from apex_strategy_routes import router
|
| 27 |
+
|
| 28 |
+
app = FastAPI()
|
| 29 |
+
app.include_router(router)
|
| 30 |
+
body = TestClient(app).get("/api/apex/coverage").json()
|
| 31 |
+
assert body["components"]["openInterest"]["mode"] == "REAL_180_DAY_UPSTREAM_PLUS_FORWARD_ARCHIVE"
|
| 32 |
+
assert body["components"]["news"]["mode"] == "FORWARD_COLLECTING_ONLY"
|
| 33 |
+
assert body["components"]["whaleFlow"]["mode"] == "FORWARD_COLLECTING_ONLY"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_open_interest_archive_upserts_by_symbol_timestamp(monkeypatch, tmp_path):
|
| 37 |
+
import apex_strategy_routes as apex
|
| 38 |
+
monkeypatch.setattr(apex, "DB_PATH", tmp_path / "apex.db")
|
| 39 |
+
row = {"symbol": "BTCUSDT", "timestamp": 1234, "sumOpenInterest": 1.0, "sumOpenInterestValue": 2.0}
|
| 40 |
+
apex._save_oi([row])
|
| 41 |
+
apex._save_oi([{**row, "sumOpenInterest": 3.0}])
|
| 42 |
+
result = apex._read_oi("BTCUSDT", None, None, 100)
|
| 43 |
+
assert result["count"] == 1
|
| 44 |
+
assert result["data"][0]["sumOpenInterest"] == 3.0
|
| 45 |
+
assert result["coverage"]["preDeploymentHistoryAvailable"] is False
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_whale_archive_dedupes_by_chain_and_transaction_hash(monkeypatch, tmp_path):
|
| 49 |
+
import apex_strategy_routes as apex
|
| 50 |
+
monkeypatch.setattr(apex, "DB_PATH", tmp_path / "apex.db")
|
| 51 |
+
event = {"txHash": "0xabc", "chain": "ethereum", "symbol": "ETH", "amount": 51, "eventTime": 1234}
|
| 52 |
+
assert apex._save("whaleFlow", "public_evm_rpc", [event]) == 1
|
| 53 |
+
assert apex._save("whaleFlow", "public_evm_rpc", [event]) == 0
|
| 54 |
+
assert apex._archive_count("whaleFlow") == 1
|