Spaces:
Paused
Paused
Update app.py
Browse files
app.py
CHANGED
|
@@ -21,7 +21,7 @@ for module_name, pip_name in REQUIRED_PACKAGES.items():
|
|
| 21 |
print(f"✅ {pip_name} установлен!")
|
| 22 |
|
| 23 |
# ============================================
|
| 24 |
-
# 👑 TOMIRIS SPACE 28 v2.
|
| 25 |
# ============================================
|
| 26 |
import os, time, json, logging, asyncio
|
| 27 |
from typing import Dict, Any, List, Optional
|
|
@@ -38,15 +38,16 @@ logger = logging.getLogger("Space28_RegimeBubble")
|
|
| 38 |
# ================= КОНФИГУРАЦИЯ =================
|
| 39 |
SYMBOLS = ["XAU/USD", "ETH/USD", "SOL/USD"]
|
| 40 |
HUB_URL = os.getenv("SPACE17_URL", "https://tomiris-ai-name5-5.hf.space")
|
| 41 |
-
SPACE9_URL = os.getenv("SPACE9_URL", "https://nuxotetotnicksvoboden-name3.hf.space")
|
| 42 |
-
SPACE22_URL = os.getenv("SPACE22_URL", "https://tomirisai80-tomirisanal4.hf.space")
|
| 43 |
-
SPACE26_URL = os.getenv("SPACE26_URL", "https://tomirisg25-tomirisgold2.hf.space")
|
| 44 |
TWELVE_DATA_KEY = os.getenv("TWELVE_DATA_KEY", "e3740c072fda4fe8b8539d40b07e445e")
|
| 45 |
|
|
|
|
|
|
|
|
|
|
| 46 |
BUBBLE_HISTORY_FILE = "bubble_history.json"
|
| 47 |
-
CACHE_TTL = {
|
| 48 |
-
"candles": 300, "onchain": 600, "sentiment": 300, "derivatives": 120
|
| 49 |
-
}
|
| 50 |
|
| 51 |
# ================= HTTP КЛИЕНТ =================
|
| 52 |
http_client = httpx.AsyncClient(timeout=20.0)
|
|
@@ -67,33 +68,24 @@ def breaker_open(name: str) -> bool:
|
|
| 67 |
|
| 68 |
def breaker_record(name: str, success: bool):
|
| 69 |
info = CIRCUIT_BREAKER.get(name, {"fails": 0, "last_fail": 0})
|
| 70 |
-
if success:
|
| 71 |
-
|
| 72 |
-
else:
|
| 73 |
-
info["fails"] += 1
|
| 74 |
-
info["last_fail"] = time.time()
|
| 75 |
CIRCUIT_BREAKER[name] = info
|
| 76 |
|
| 77 |
-
# История пузырей
|
| 78 |
if os.path.exists(BUBBLE_HISTORY_FILE):
|
| 79 |
try:
|
| 80 |
-
with open(BUBBLE_HISTORY_FILE) as f:
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
BUBBLE_HISTORY = deque(maxlen=500)
|
| 84 |
-
else:
|
| 85 |
-
BUBBLE_HISTORY = deque(maxlen=500)
|
| 86 |
|
| 87 |
def save_bubble_history():
|
| 88 |
-
with open(BUBBLE_HISTORY_FILE, 'w') as f:
|
| 89 |
-
json.dump(list(BUBBLE_HISTORY), f)
|
| 90 |
|
| 91 |
# ================= ЗАГРУЗКА ДАННЫХ =================
|
| 92 |
async def fetch_candles(symbol: str, tf: str = "1h", count: int = 200) -> Optional[pd.DataFrame]:
|
| 93 |
cache_key = f"candles_{symbol}_{tf}_{count}"
|
| 94 |
if cache_key in cache_store and time.time() - cache_times.get(cache_key, 0) < CACHE_TTL["candles"]:
|
| 95 |
return cache_store[cache_key]
|
| 96 |
-
|
| 97 |
if breaker_open("hub"): return None
|
| 98 |
try:
|
| 99 |
r = await http_client.get(f"{HUB_URL}/candles", params={"symbol": symbol, "interval": tf, "limit": count})
|
|
@@ -105,20 +97,16 @@ async def fetch_candles(symbol: str, tf: str = "1h", count: int = 200) -> Option
|
|
| 105 |
df["close"] = pd.to_numeric(df["close"], errors="coerce")
|
| 106 |
df["high"] = pd.to_numeric(df["high"], errors="coerce")
|
| 107 |
df["low"] = pd.to_numeric(df["low"], errors="coerce")
|
| 108 |
-
if "volume" in df.columns:
|
| 109 |
-
df["volume"] = pd.to_numeric(df["volume"], errors="coerce").fillna(0)
|
| 110 |
breaker_record("hub", True)
|
| 111 |
-
cache_store[cache_key] = df
|
| 112 |
-
cache_times[cache_key] = time.time()
|
| 113 |
return df
|
| 114 |
breaker_record("hub", False)
|
| 115 |
-
except:
|
| 116 |
-
breaker_record("hub", False)
|
| 117 |
return None
|
| 118 |
|
| 119 |
async def fetch_onchain_volume(symbol: str) -> Optional[float]:
|
| 120 |
-
if not SPACE9_URL or breaker_open("space9"):
|
| 121 |
-
return None
|
| 122 |
try:
|
| 123 |
r = await http_client.get(f"{SPACE9_URL}/consilium?symbol={symbol}")
|
| 124 |
if r.status_code == 200:
|
|
@@ -126,23 +114,16 @@ async def fetch_onchain_volume(symbol: str) -> Optional[float]:
|
|
| 126 |
metrics = data.get("onchain_analysis", {}).get("metrics", {})
|
| 127 |
network = metrics.get("network", {})
|
| 128 |
vol = network.get("tx_volume_24h") or network.get("total_volume_24h")
|
| 129 |
-
if vol:
|
| 130 |
-
|
| 131 |
-
return float(vol)
|
| 132 |
-
except:
|
| 133 |
-
breaker_record("space9", False)
|
| 134 |
return None
|
| 135 |
|
| 136 |
async def fetch_sentiment_signal(symbol: str) -> Optional[Dict]:
|
| 137 |
if not SPACE22_URL or breaker_open("space22"): return None
|
| 138 |
try:
|
| 139 |
r = await http_client.get(f"{SPACE22_URL}/sentiment/{symbol}")
|
| 140 |
-
if r.status_code == 200:
|
| 141 |
-
|
| 142 |
-
breaker_record("space22", True)
|
| 143 |
-
return data
|
| 144 |
-
except:
|
| 145 |
-
breaker_record("space22", False)
|
| 146 |
return None
|
| 147 |
|
| 148 |
async def fetch_derivatives(symbol: str) -> Optional[Dict]:
|
|
@@ -152,10 +133,8 @@ async def fetch_derivatives(symbol: str) -> Optional[Dict]:
|
|
| 152 |
if r.status_code == 200:
|
| 153 |
data = r.json()
|
| 154 |
deriv = data.get("derivative_analysis", {}).get("metrics", {})
|
| 155 |
-
breaker_record("space26", True)
|
| 156 |
-
|
| 157 |
-
except:
|
| 158 |
-
breaker_record("space26", False)
|
| 159 |
return None
|
| 160 |
|
| 161 |
# ================= ИНДИКАТОРЫ =================
|
|
@@ -166,263 +145,156 @@ def safe_rsi(close: pd.Series, period: int = 14) -> float:
|
|
| 166 |
loss = (-delta.clip(upper=0)).rolling(period, min_periods=period).mean()
|
| 167 |
rs = gain / (loss + 1e-10)
|
| 168 |
return float(100 - (100 / (1 + rs.iloc[-1])))
|
| 169 |
-
except:
|
| 170 |
-
return 50.0
|
| 171 |
|
| 172 |
def calculate_euphoria_index(df: pd.DataFrame, funding_signal: Optional[str] = None,
|
| 173 |
oi_change: Optional[float] = None, sentiment_fear: Optional[float] = None) -> float:
|
| 174 |
-
if df is None or len(df) < 20:
|
| 175 |
-
|
| 176 |
-
close = df['close']
|
| 177 |
-
volume = df['volume'] if 'volume' in df.columns else pd.Series([1.0]*len(df))
|
| 178 |
-
|
| 179 |
score = 0.0
|
| 180 |
-
|
| 181 |
rsi = safe_rsi(close, 14)
|
| 182 |
-
if rsi > 80:
|
| 183 |
-
|
| 184 |
-
elif rsi >
|
| 185 |
-
|
| 186 |
-
elif rsi
|
| 187 |
-
score += 8
|
| 188 |
-
elif rsi < 30:
|
| 189 |
-
score -= 15
|
| 190 |
-
elif rsi < 20:
|
| 191 |
-
score -= 20
|
| 192 |
-
|
| 193 |
if len(close) >= 50:
|
| 194 |
sma50 = close.rolling(50).mean().iloc[-1]
|
| 195 |
dev = (close.iloc[-1] - sma50) / sma50 * 100
|
| 196 |
-
if dev > 20:
|
| 197 |
-
|
| 198 |
-
elif dev >
|
| 199 |
-
|
| 200 |
-
elif dev > 5:
|
| 201 |
-
score += 5
|
| 202 |
-
elif dev < -20:
|
| 203 |
-
score -= 15
|
| 204 |
-
|
| 205 |
if len(volume) >= 20:
|
| 206 |
avg_vol = volume.rolling(20).mean().iloc[-1]
|
| 207 |
vol_ratio = volume.iloc[-1] / (avg_vol + 1e-10)
|
| 208 |
-
if vol_ratio > 3:
|
| 209 |
-
|
| 210 |
-
elif vol_ratio > 2:
|
| 211 |
-
score += 8
|
| 212 |
-
|
| 213 |
if funding_signal:
|
| 214 |
-
if funding_signal
|
| 215 |
-
|
| 216 |
-
elif funding_signal == 'BULLISH':
|
| 217 |
-
score -= 10
|
| 218 |
-
|
| 219 |
if oi_change:
|
| 220 |
-
if oi_change > 10:
|
| 221 |
-
|
| 222 |
-
elif oi_change < -10:
|
| 223 |
-
score -= 5
|
| 224 |
-
|
| 225 |
if sentiment_fear is not None:
|
| 226 |
greed = 100 - sentiment_fear
|
| 227 |
-
if greed > 70:
|
| 228 |
-
|
| 229 |
-
elif greed < 30:
|
| 230 |
-
score -= 10
|
| 231 |
-
|
| 232 |
return max(0.0, min(100.0, score))
|
| 233 |
|
| 234 |
def calculate_nvt_ratio(symbol: str, df: pd.DataFrame, onchain_volume: Optional[float] = None) -> float:
|
| 235 |
-
if df is None or len(df) < 24:
|
| 236 |
-
return 0.0
|
| 237 |
close = df['close'].iloc[-1]
|
| 238 |
supply = {"ETH/USD": 120_000_000, "SOL/USD": 440_000_000, "XAU/USD": 1}.get(symbol, 1)
|
| 239 |
market_cap = close * supply
|
| 240 |
-
|
| 241 |
-
if onchain_volume and onchain_volume > 0:
|
| 242 |
-
nvt = market_cap / onchain_volume
|
| 243 |
else:
|
| 244 |
volume = df['volume'] if 'volume' in df.columns else pd.Series([1.0]*len(df))
|
| 245 |
daily_volume = volume.iloc[-24:].sum() if len(volume) >= 24 else volume.sum()
|
| 246 |
-
if daily_volume <= 0:
|
| 247 |
-
return 0.0
|
| 248 |
nvt = market_cap / daily_volume
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
else:
|
| 255 |
-
return 0.0
|
| 256 |
-
|
| 257 |
-
if nvt > normal_high:
|
| 258 |
-
return min(30, (nvt - normal_high) / 10)
|
| 259 |
-
elif nvt < normal_low:
|
| 260 |
-
return max(-20, (nvt - normal_low) / 10)
|
| 261 |
return 0.0
|
| 262 |
|
| 263 |
# ================= РЕЖИМ РЫНКА =================
|
| 264 |
async def detect_market_regime(symbol: str) -> Dict[str, Any]:
|
| 265 |
df = await fetch_candles(symbol, "1h", 200)
|
| 266 |
-
if df is None or len(df) < 50:
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
onchain_vol = None
|
| 270 |
-
if symbol != "XAU/USD":
|
| 271 |
-
onchain_vol = await fetch_onchain_volume(symbol)
|
| 272 |
-
deriv = None
|
| 273 |
-
if symbol != "XAU/USD":
|
| 274 |
-
deriv = await fetch_derivatives(symbol)
|
| 275 |
sent = await fetch_sentiment_signal(symbol)
|
| 276 |
-
|
| 277 |
-
funding_signal = None
|
| 278 |
-
oi_change = None
|
| 279 |
if deriv:
|
| 280 |
-
funding = deriv.get("funding_rate", {})
|
| 281 |
-
|
| 282 |
-
oi = deriv.get("open_interest", {})
|
| 283 |
-
oi_change = oi.get("change_pct")
|
| 284 |
-
|
| 285 |
fear_ratio = None
|
| 286 |
if sent:
|
| 287 |
reddit = sent.get("metrics", {}).get("reddit", {})
|
| 288 |
fear_ratio = reddit.get("fear_ratio") or sent.get("fear_ratio")
|
| 289 |
-
|
| 290 |
close = df['close'].astype(float)
|
| 291 |
returns = np.diff(np.log(close.values))
|
| 292 |
volatility = float(np.std(returns[-24:])) if len(returns) >= 24 else 0.01
|
| 293 |
-
|
| 294 |
euphoria = calculate_euphoria_index(df, funding_signal, oi_change, fear_ratio)
|
| 295 |
-
nvt_score = 0.0
|
| 296 |
-
if symbol != "XAU/USD":
|
| 297 |
-
nvt_score = calculate_nvt_ratio(symbol, df, onchain_vol)
|
| 298 |
-
|
| 299 |
if euphoria > 70 or nvt_score > 20:
|
| 300 |
-
regime = "BUBBLE"
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
elif
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
veto = False
|
| 308 |
-
signal = "CAUTION"
|
| 309 |
-
elif volatility > 0.03:
|
| 310 |
-
regime = "VOLATILE"
|
| 311 |
-
bubble_probability = 40
|
| 312 |
-
veto = False
|
| 313 |
-
signal = "NEUTRAL"
|
| 314 |
-
elif close.iloc[-1] < close.iloc[-50] * 0.8 and euphoria < 30:
|
| 315 |
-
regime = "CAPITULATION"
|
| 316 |
-
bubble_probability = 10
|
| 317 |
-
veto = False
|
| 318 |
-
signal = "NORMAL"
|
| 319 |
-
elif abs(close.iloc[-1] - close.iloc[-20]) / close.iloc[-20] < 0.02:
|
| 320 |
-
regime = "RANGE"
|
| 321 |
-
bubble_probability = 20
|
| 322 |
-
veto = False
|
| 323 |
-
signal = "NEUTRAL"
|
| 324 |
-
else:
|
| 325 |
-
regime = "TREND"
|
| 326 |
-
bubble_probability = max(0, min(100, 50 + (close.iloc[-1] > close.iloc[-50] and 15 or -15)))
|
| 327 |
-
veto = False
|
| 328 |
-
signal = "NORMAL"
|
| 329 |
-
|
| 330 |
-
BUBBLE_HISTORY.append({
|
| 331 |
-
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 332 |
-
"symbol": symbol,
|
| 333 |
-
"regime": regime,
|
| 334 |
-
"bubble_probability": bubble_probability,
|
| 335 |
-
"euphoria": euphoria
|
| 336 |
-
})
|
| 337 |
save_bubble_history()
|
| 338 |
-
|
| 339 |
-
return {
|
| 340 |
-
"regime": regime,
|
| 341 |
-
"bubble_probability": round(bubble_probability, 1),
|
| 342 |
-
"euphoria_index": round(euphoria, 1),
|
| 343 |
-
"nvt_score": round(nvt_score, 1),
|
| 344 |
-
"rsi_14": round(safe_rsi(close, 14), 1),
|
| 345 |
-
"volatility_24h_pct": round(volatility * 100, 3),
|
| 346 |
-
"veto": veto,
|
| 347 |
-
"signal": signal
|
| 348 |
-
}
|
| 349 |
|
| 350 |
# ================= ОТПРАВКА В HUB =================
|
| 351 |
async def send_signal_to_hub(symbol: str, direction: str, confidence: float):
|
| 352 |
try:
|
| 353 |
-
await http_client.post(f"{HUB_URL}/signal", json={
|
| 354 |
-
"space": "space_28_regime",
|
| 355 |
-
"
|
| 356 |
-
"direction": direction,
|
| 357 |
-
"confidence": confidence,
|
| 358 |
"raw": json.dumps({"source": "space_28_regime"})
|
| 359 |
-
})
|
| 360 |
-
logger.info(f"📤 {symbol}: {direction} conf={confidence:.3f} отправлен в Hub")
|
| 361 |
-
|
| 362 |
-
|
| 363 |
|
| 364 |
# ================= ГЛАВНЫЙ СИГНАЛ =================
|
| 365 |
async def get_regime_signal(symbol: str = "XAU/USD") -> Dict[str, Any]:
|
| 366 |
-
start = time.time()
|
| 367 |
-
regime_data = await detect_market_regime(symbol)
|
| 368 |
latency = int((time.time() - start) * 1000)
|
| 369 |
-
|
| 370 |
direction = "WAIT" if regime_data["veto"] else "NEUTRAL"
|
| 371 |
confidence = regime_data["bubble_probability"] / 100 if regime_data["veto"] else 0.0
|
| 372 |
-
|
| 373 |
-
# Отправка в Hub
|
| 374 |
await send_signal_to_hub(symbol, direction, confidence)
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
"space": "space_28_regime",
|
| 378 |
-
"timestamp": int(time.time()),
|
| 379 |
-
"symbol": symbol,
|
| 380 |
-
"signal": {
|
| 381 |
-
"direction": direction,
|
| 382 |
-
"confidence": confidence,
|
| 383 |
-
"veto": regime_data["veto"],
|
| 384 |
-
"veto_reason": "BUBBLE_DETECTED" if regime_data["veto"] else None
|
| 385 |
-
},
|
| 386 |
-
"regime_analysis": regime_data,
|
| 387 |
-
"latency_ms": latency
|
| 388 |
-
}
|
| 389 |
-
|
| 390 |
-
logger.info(f"🫧 Regime {symbol}: {regime_data['regime']} bubble_prob={regime_data['bubble_probability']:.1f} veto={regime_data['veto']}")
|
| 391 |
return result
|
| 392 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
# ================= FASTAPI =================
|
| 394 |
-
app = FastAPI(title="Tomiris Space 28 v2.
|
| 395 |
|
| 396 |
@app.on_event("startup")
|
| 397 |
-
async def startup():
|
|
|
|
|
|
|
| 398 |
|
| 399 |
@app.on_event("shutdown")
|
| 400 |
async def shutdown(): await http_client.aclose()
|
| 401 |
|
| 402 |
@app.get("/health")
|
| 403 |
async def health():
|
| 404 |
-
return {"status": "operational", "version": "2.
|
| 405 |
-
"features": ["Real NVT", "Composite Bubble Index", "Funding/Sentiment Integration", "Persistent Bubble History"]}
|
| 406 |
|
| 407 |
@app.get("/consilium")
|
| 408 |
async def consilium(symbol: str = Query("XAU/USD")):
|
| 409 |
-
if symbol not in SYMBOLS:
|
| 410 |
-
return {"error": "Invalid symbol"}
|
| 411 |
return await get_regime_signal(symbol)
|
| 412 |
|
| 413 |
@app.get("/regime/{symbol}")
|
| 414 |
async def regime(symbol: str):
|
| 415 |
-
if symbol not in SYMBOLS:
|
| 416 |
-
return {"error": "Invalid symbol"}
|
| 417 |
return await detect_market_regime(symbol)
|
| 418 |
|
| 419 |
@app.get("/euphoria/{symbol}")
|
| 420 |
async def euphoria(symbol: str):
|
| 421 |
-
if symbol not in SYMBOLS:
|
| 422 |
-
return {"error": "Invalid symbol"}
|
| 423 |
df = await fetch_candles(symbol)
|
| 424 |
-
if df is None:
|
| 425 |
-
return {"error": "no data"}
|
| 426 |
deriv = await fetch_derivatives(symbol) if symbol != "XAU/USD" else None
|
| 427 |
sent = await fetch_sentiment_signal(symbol)
|
| 428 |
fear = None
|
|
@@ -436,17 +308,23 @@ async def euphoria(symbol: str):
|
|
| 436 |
|
| 437 |
@app.get("/nvt/{symbol}")
|
| 438 |
async def nvt(symbol: str):
|
| 439 |
-
if symbol not in ["ETH/USD", "SOL/USD"]:
|
| 440 |
-
return {"error": "NVT доступен только для крипты"}
|
| 441 |
df = await fetch_candles(symbol)
|
| 442 |
-
if df is None:
|
| 443 |
-
return {"error": "no data"}
|
| 444 |
onchain_vol = await fetch_onchain_volume(symbol)
|
| 445 |
nvt_score = calculate_nvt_ratio(symbol, df, onchain_vol)
|
| 446 |
-
return {"symbol": symbol, "nvt_score": round(nvt_score, 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
|
| 448 |
if __name__ == "__main__":
|
| 449 |
import uvicorn
|
| 450 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
| 451 |
|
| 452 |
-
print("🚀 SPACE 28 v2.
|
|
|
|
| 21 |
print(f"✅ {pip_name} установлен!")
|
| 22 |
|
| 23 |
# ============================================
|
| 24 |
+
# 👑 TOMIRIS SPACE 28 v2.2 — MARKET REGIME & BUBBLE SENTINEL (АВТО-ОТПРАВКА)
|
| 25 |
# ============================================
|
| 26 |
import os, time, json, logging, asyncio
|
| 27 |
from typing import Dict, Any, List, Optional
|
|
|
|
| 38 |
# ================= КОНФИГУРАЦИЯ =================
|
| 39 |
SYMBOLS = ["XAU/USD", "ETH/USD", "SOL/USD"]
|
| 40 |
HUB_URL = os.getenv("SPACE17_URL", "https://tomiris-ai-name5-5.hf.space")
|
| 41 |
+
SPACE9_URL = os.getenv("SPACE9_URL", "https://nuxotetotnicksvoboden-name3.hf.space")
|
| 42 |
+
SPACE22_URL = os.getenv("SPACE22_URL", "https://tomirisai80-tomirisanal4.hf.space")
|
| 43 |
+
SPACE26_URL = os.getenv("SPACE26_URL", "https://tomirisg25-tomirisgold2.hf.space")
|
| 44 |
TWELVE_DATA_KEY = os.getenv("TWELVE_DATA_KEY", "e3740c072fda4fe8b8539d40b07e445e")
|
| 45 |
|
| 46 |
+
# Интервал авто-отправки
|
| 47 |
+
AUTO_SEND_INTERVAL = int(os.getenv("AUTO_SEND_INTERVAL", "300"))
|
| 48 |
+
|
| 49 |
BUBBLE_HISTORY_FILE = "bubble_history.json"
|
| 50 |
+
CACHE_TTL = {"candles": 300, "onchain": 600, "sentiment": 300, "derivatives": 120}
|
|
|
|
|
|
|
| 51 |
|
| 52 |
# ================= HTTP КЛИЕНТ =================
|
| 53 |
http_client = httpx.AsyncClient(timeout=20.0)
|
|
|
|
| 68 |
|
| 69 |
def breaker_record(name: str, success: bool):
|
| 70 |
info = CIRCUIT_BREAKER.get(name, {"fails": 0, "last_fail": 0})
|
| 71 |
+
if success: info["fails"] = 0
|
| 72 |
+
else: info["fails"] += 1; info["last_fail"] = time.time()
|
|
|
|
|
|
|
|
|
|
| 73 |
CIRCUIT_BREAKER[name] = info
|
| 74 |
|
|
|
|
| 75 |
if os.path.exists(BUBBLE_HISTORY_FILE):
|
| 76 |
try:
|
| 77 |
+
with open(BUBBLE_HISTORY_FILE) as f: BUBBLE_HISTORY = deque(json.load(f), maxlen=500)
|
| 78 |
+
except: BUBBLE_HISTORY = deque(maxlen=500)
|
| 79 |
+
else: BUBBLE_HISTORY = deque(maxlen=500)
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
def save_bubble_history():
|
| 82 |
+
with open(BUBBLE_HISTORY_FILE, 'w') as f: json.dump(list(BUBBLE_HISTORY), f)
|
|
|
|
| 83 |
|
| 84 |
# ================= ЗАГРУЗКА ДАННЫХ =================
|
| 85 |
async def fetch_candles(symbol: str, tf: str = "1h", count: int = 200) -> Optional[pd.DataFrame]:
|
| 86 |
cache_key = f"candles_{symbol}_{tf}_{count}"
|
| 87 |
if cache_key in cache_store and time.time() - cache_times.get(cache_key, 0) < CACHE_TTL["candles"]:
|
| 88 |
return cache_store[cache_key]
|
|
|
|
| 89 |
if breaker_open("hub"): return None
|
| 90 |
try:
|
| 91 |
r = await http_client.get(f"{HUB_URL}/candles", params={"symbol": symbol, "interval": tf, "limit": count})
|
|
|
|
| 97 |
df["close"] = pd.to_numeric(df["close"], errors="coerce")
|
| 98 |
df["high"] = pd.to_numeric(df["high"], errors="coerce")
|
| 99 |
df["low"] = pd.to_numeric(df["low"], errors="coerce")
|
| 100 |
+
if "volume" in df.columns: df["volume"] = pd.to_numeric(df["volume"], errors="coerce").fillna(0)
|
|
|
|
| 101 |
breaker_record("hub", True)
|
| 102 |
+
cache_store[cache_key] = df; cache_times[cache_key] = time.time()
|
|
|
|
| 103 |
return df
|
| 104 |
breaker_record("hub", False)
|
| 105 |
+
except: breaker_record("hub", False)
|
|
|
|
| 106 |
return None
|
| 107 |
|
| 108 |
async def fetch_onchain_volume(symbol: str) -> Optional[float]:
|
| 109 |
+
if not SPACE9_URL or breaker_open("space9"): return None
|
|
|
|
| 110 |
try:
|
| 111 |
r = await http_client.get(f"{SPACE9_URL}/consilium?symbol={symbol}")
|
| 112 |
if r.status_code == 200:
|
|
|
|
| 114 |
metrics = data.get("onchain_analysis", {}).get("metrics", {})
|
| 115 |
network = metrics.get("network", {})
|
| 116 |
vol = network.get("tx_volume_24h") or network.get("total_volume_24h")
|
| 117 |
+
if vol: breaker_record("space9", True); return float(vol)
|
| 118 |
+
except: breaker_record("space9", False)
|
|
|
|
|
|
|
|
|
|
| 119 |
return None
|
| 120 |
|
| 121 |
async def fetch_sentiment_signal(symbol: str) -> Optional[Dict]:
|
| 122 |
if not SPACE22_URL or breaker_open("space22"): return None
|
| 123 |
try:
|
| 124 |
r = await http_client.get(f"{SPACE22_URL}/sentiment/{symbol}")
|
| 125 |
+
if r.status_code == 200: breaker_record("space22", True); return r.json()
|
| 126 |
+
except: breaker_record("space22", False)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
return None
|
| 128 |
|
| 129 |
async def fetch_derivatives(symbol: str) -> Optional[Dict]:
|
|
|
|
| 133 |
if r.status_code == 200:
|
| 134 |
data = r.json()
|
| 135 |
deriv = data.get("derivative_analysis", {}).get("metrics", {})
|
| 136 |
+
breaker_record("space26", True); return deriv
|
| 137 |
+
except: breaker_record("space26", False)
|
|
|
|
|
|
|
| 138 |
return None
|
| 139 |
|
| 140 |
# ================= ИНДИКАТОРЫ =================
|
|
|
|
| 145 |
loss = (-delta.clip(upper=0)).rolling(period, min_periods=period).mean()
|
| 146 |
rs = gain / (loss + 1e-10)
|
| 147 |
return float(100 - (100 / (1 + rs.iloc[-1])))
|
| 148 |
+
except: return 50.0
|
|
|
|
| 149 |
|
| 150 |
def calculate_euphoria_index(df: pd.DataFrame, funding_signal: Optional[str] = None,
|
| 151 |
oi_change: Optional[float] = None, sentiment_fear: Optional[float] = None) -> float:
|
| 152 |
+
if df is None or len(df) < 20: return 50.0
|
| 153 |
+
close = df['close']; volume = df['volume'] if 'volume' in df.columns else pd.Series([1.0]*len(df))
|
|
|
|
|
|
|
|
|
|
| 154 |
score = 0.0
|
|
|
|
| 155 |
rsi = safe_rsi(close, 14)
|
| 156 |
+
if rsi > 80: score += 25
|
| 157 |
+
elif rsi > 70: score += 18
|
| 158 |
+
elif rsi > 60: score += 8
|
| 159 |
+
elif rsi < 30: score -= 15
|
| 160 |
+
elif rsi < 20: score -= 20
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
if len(close) >= 50:
|
| 162 |
sma50 = close.rolling(50).mean().iloc[-1]
|
| 163 |
dev = (close.iloc[-1] - sma50) / sma50 * 100
|
| 164 |
+
if dev > 20: score += 20
|
| 165 |
+
elif dev > 10: score += 12
|
| 166 |
+
elif dev > 5: score += 5
|
| 167 |
+
elif dev < -20: score -= 15
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
if len(volume) >= 20:
|
| 169 |
avg_vol = volume.rolling(20).mean().iloc[-1]
|
| 170 |
vol_ratio = volume.iloc[-1] / (avg_vol + 1e-10)
|
| 171 |
+
if vol_ratio > 3: score += 15
|
| 172 |
+
elif vol_ratio > 2: score += 8
|
|
|
|
|
|
|
|
|
|
| 173 |
if funding_signal:
|
| 174 |
+
if funding_signal in ('EXTREME_LONG', 'BEARISH'): score += 15
|
| 175 |
+
elif funding_signal == 'BULLISH': score -= 10
|
|
|
|
|
|
|
|
|
|
| 176 |
if oi_change:
|
| 177 |
+
if oi_change > 10: score += 10
|
| 178 |
+
elif oi_change < -10: score -= 5
|
|
|
|
|
|
|
|
|
|
| 179 |
if sentiment_fear is not None:
|
| 180 |
greed = 100 - sentiment_fear
|
| 181 |
+
if greed > 70: score += 10
|
| 182 |
+
elif greed < 30: score -= 10
|
|
|
|
|
|
|
|
|
|
| 183 |
return max(0.0, min(100.0, score))
|
| 184 |
|
| 185 |
def calculate_nvt_ratio(symbol: str, df: pd.DataFrame, onchain_volume: Optional[float] = None) -> float:
|
| 186 |
+
if df is None or len(df) < 24: return 0.0
|
|
|
|
| 187 |
close = df['close'].iloc[-1]
|
| 188 |
supply = {"ETH/USD": 120_000_000, "SOL/USD": 440_000_000, "XAU/USD": 1}.get(symbol, 1)
|
| 189 |
market_cap = close * supply
|
| 190 |
+
if onchain_volume and onchain_volume > 0: nvt = market_cap / onchain_volume
|
|
|
|
|
|
|
| 191 |
else:
|
| 192 |
volume = df['volume'] if 'volume' in df.columns else pd.Series([1.0]*len(df))
|
| 193 |
daily_volume = volume.iloc[-24:].sum() if len(volume) >= 24 else volume.sum()
|
| 194 |
+
if daily_volume <= 0: return 0.0
|
|
|
|
| 195 |
nvt = market_cap / daily_volume
|
| 196 |
+
if "ETH" in symbol: normal_low, normal_high = 30, 100
|
| 197 |
+
elif "SOL" in symbol: normal_low, normal_high = 50, 150
|
| 198 |
+
else: return 0.0
|
| 199 |
+
if nvt > normal_high: return min(30, (nvt - normal_high) / 10)
|
| 200 |
+
elif nvt < normal_low: return max(-20, (nvt - normal_low) / 10)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
return 0.0
|
| 202 |
|
| 203 |
# ================= РЕЖИМ РЫНКА =================
|
| 204 |
async def detect_market_regime(symbol: str) -> Dict[str, Any]:
|
| 205 |
df = await fetch_candles(symbol, "1h", 200)
|
| 206 |
+
if df is None or len(df) < 50: return {"regime": "UNKNOWN", "bubble_probability": 0, "veto": False, "euphoria_index": 50}
|
| 207 |
+
onchain_vol = None if symbol == "XAU/USD" else await fetch_onchain_volume(symbol)
|
| 208 |
+
deriv = None if symbol == "XAU/USD" else await fetch_derivatives(symbol)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
sent = await fetch_sentiment_signal(symbol)
|
| 210 |
+
funding_signal = None; oi_change = None
|
|
|
|
|
|
|
| 211 |
if deriv:
|
| 212 |
+
funding = deriv.get("funding_rate", {}); funding_signal = funding.get("signal")
|
| 213 |
+
oi = deriv.get("open_interest", {}); oi_change = oi.get("change_pct")
|
|
|
|
|
|
|
|
|
|
| 214 |
fear_ratio = None
|
| 215 |
if sent:
|
| 216 |
reddit = sent.get("metrics", {}).get("reddit", {})
|
| 217 |
fear_ratio = reddit.get("fear_ratio") or sent.get("fear_ratio")
|
|
|
|
| 218 |
close = df['close'].astype(float)
|
| 219 |
returns = np.diff(np.log(close.values))
|
| 220 |
volatility = float(np.std(returns[-24:])) if len(returns) >= 24 else 0.01
|
|
|
|
| 221 |
euphoria = calculate_euphoria_index(df, funding_signal, oi_change, fear_ratio)
|
| 222 |
+
nvt_score = 0.0 if symbol == "XAU/USD" else calculate_nvt_ratio(symbol, df, onchain_vol)
|
|
|
|
|
|
|
|
|
|
| 223 |
if euphoria > 70 or nvt_score > 20:
|
| 224 |
+
regime = "BUBBLE"; bubble_probability = min(100, euphoria + nvt_score * 2); veto = True; signal = "FORCE_WAIT"
|
| 225 |
+
elif euphoria > 55: regime = "EUPHORIA"; bubble_probability = euphoria; veto = False; signal = "CAUTION"
|
| 226 |
+
elif volatility > 0.03: regime = "VOLATILE"; bubble_probability = 40; veto = False; signal = "NEUTRAL"
|
| 227 |
+
elif close.iloc[-1] < close.iloc[-50] * 0.8 and euphoria < 30: regime = "CAPITULATION"; bubble_probability = 10; veto = False; signal = "NORMAL"
|
| 228 |
+
elif abs(close.iloc[-1] - close.iloc[-20]) / close.iloc[-20] < 0.02: regime = "RANGE"; bubble_probability = 20; veto = False; signal = "NEUTRAL"
|
| 229 |
+
else: regime = "TREND"; bubble_probability = max(0, min(100, 50 + (close.iloc[-1] > close.iloc[-50] and 15 or -15))); veto = False; signal = "NORMAL"
|
| 230 |
+
BUBBLE_HISTORY.append({"timestamp": datetime.now(timezone.utc).isoformat(), "symbol": symbol, "regime": regime, "bubble_probability": bubble_probability, "euphoria": euphoria})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
save_bubble_history()
|
| 232 |
+
return {"regime": regime, "bubble_probability": round(bubble_probability, 1), "euphoria_index": round(euphoria, 1), "nvt_score": round(nvt_score, 1), "rsi_14": round(safe_rsi(close, 14), 1), "volatility_24h_pct": round(volatility * 100, 3), "veto": veto, "signal": signal}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
# ================= ОТПРАВКА В HUB =================
|
| 235 |
async def send_signal_to_hub(symbol: str, direction: str, confidence: float):
|
| 236 |
try:
|
| 237 |
+
resp = await http_client.post(f"{HUB_URL}/signal", json={
|
| 238 |
+
"space": "space_28_regime", "symbol": symbol,
|
| 239 |
+
"direction": direction, "confidence": confidence,
|
|
|
|
|
|
|
| 240 |
"raw": json.dumps({"source": "space_28_regime"})
|
| 241 |
+
}, timeout=10)
|
| 242 |
+
if resp.status_code == 200: logger.info(f"📤 {symbol}: {direction} conf={confidence:.3f} отправлен в Hub")
|
| 243 |
+
else: logger.warning(f"Hub вернул {resp.status_code}")
|
| 244 |
+
except Exception as e: logger.error(f"Ошибка отправки в Hub: {e}")
|
| 245 |
|
| 246 |
# ================= ГЛАВНЫЙ СИГНАЛ =================
|
| 247 |
async def get_regime_signal(symbol: str = "XAU/USD") -> Dict[str, Any]:
|
| 248 |
+
start = time.time(); regime_data = await detect_market_regime(symbol)
|
|
|
|
| 249 |
latency = int((time.time() - start) * 1000)
|
|
|
|
| 250 |
direction = "WAIT" if regime_data["veto"] else "NEUTRAL"
|
| 251 |
confidence = regime_data["bubble_probability"] / 100 if regime_data["veto"] else 0.0
|
|
|
|
|
|
|
| 252 |
await send_signal_to_hub(symbol, direction, confidence)
|
| 253 |
+
result = {"space": "space_28_regime", "timestamp": int(time.time()), "symbol": symbol, "signal": {"direction": direction, "confidence": confidence, "veto": regime_data["veto"], "veto_reason": "BUBBLE_DETECTED" if regime_data["veto"] else None}, "regime_analysis": regime_data, "latency_ms": latency}
|
| 254 |
+
logger.info(f"🫧 Regime {symbol}: {regime_data['regime']} veto={regime_data['veto']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
return result
|
| 256 |
|
| 257 |
+
# ================= АВТО-ОТПРАВКА =================
|
| 258 |
+
async def auto_send_loop():
|
| 259 |
+
logger.info(f"🔄 Авто-отправка Regime Sentinel запущена (интервал {AUTO_SEND_INTERVAL}с)")
|
| 260 |
+
await asyncio.sleep(30)
|
| 261 |
+
while True:
|
| 262 |
+
try:
|
| 263 |
+
for symbol in SYMBOLS: await get_regime_signal(symbol); await asyncio.sleep(2)
|
| 264 |
+
logger.info("✅ Regime Sentinel авто-отправка завершена")
|
| 265 |
+
except Exception as e: logger.error(f"Ошибка авто-отправки: {e}")
|
| 266 |
+
await asyncio.sleep(AUTO_SEND_INTERVAL)
|
| 267 |
+
|
| 268 |
# ================= FASTAPI =================
|
| 269 |
+
app = FastAPI(title="Tomiris Space 28 v2.2 — Market Regime & Bubble Sentinel (Auto-Hub)")
|
| 270 |
|
| 271 |
@app.on_event("startup")
|
| 272 |
+
async def startup():
|
| 273 |
+
asyncio.create_task(auto_send_loop())
|
| 274 |
+
logger.info("🚀 Space 28 v2.2 запущен с авто-отправкой в Hub")
|
| 275 |
|
| 276 |
@app.on_event("shutdown")
|
| 277 |
async def shutdown(): await http_client.aclose()
|
| 278 |
|
| 279 |
@app.get("/health")
|
| 280 |
async def health():
|
| 281 |
+
return {"status": "operational", "version": "2.2", "hub_url": HUB_URL, "auto_send_interval": AUTO_SEND_INTERVAL, "features": ["Real NVT", "Composite Bubble Index", "Funding/Sentiment Integration"]}
|
|
|
|
| 282 |
|
| 283 |
@app.get("/consilium")
|
| 284 |
async def consilium(symbol: str = Query("XAU/USD")):
|
| 285 |
+
if symbol not in SYMBOLS: return {"error": "Invalid symbol"}
|
|
|
|
| 286 |
return await get_regime_signal(symbol)
|
| 287 |
|
| 288 |
@app.get("/regime/{symbol}")
|
| 289 |
async def regime(symbol: str):
|
| 290 |
+
if symbol not in SYMBOLS: return {"error": "Invalid symbol"}
|
|
|
|
| 291 |
return await detect_market_regime(symbol)
|
| 292 |
|
| 293 |
@app.get("/euphoria/{symbol}")
|
| 294 |
async def euphoria(symbol: str):
|
| 295 |
+
if symbol not in SYMBOLS: return {"error": "Invalid symbol"}
|
|
|
|
| 296 |
df = await fetch_candles(symbol)
|
| 297 |
+
if df is None: return {"error": "no data"}
|
|
|
|
| 298 |
deriv = await fetch_derivatives(symbol) if symbol != "XAU/USD" else None
|
| 299 |
sent = await fetch_sentiment_signal(symbol)
|
| 300 |
fear = None
|
|
|
|
| 308 |
|
| 309 |
@app.get("/nvt/{symbol}")
|
| 310 |
async def nvt(symbol: str):
|
| 311 |
+
if symbol not in ["ETH/USD", "SOL/USD"]: return {"error": "NVT доступен только для крипты"}
|
|
|
|
| 312 |
df = await fetch_candles(symbol)
|
| 313 |
+
if df is None: return {"error": "no data"}
|
|
|
|
| 314 |
onchain_vol = await fetch_onchain_volume(symbol)
|
| 315 |
nvt_score = calculate_nvt_ratio(symbol, df, onchain_vol)
|
| 316 |
+
return {"symbol": symbol, "nvt_score": round(nvt_score, 1)}
|
| 317 |
+
|
| 318 |
+
@app.get("/send_now")
|
| 319 |
+
async def send_now():
|
| 320 |
+
results = {}
|
| 321 |
+
for symbol in SYMBOLS:
|
| 322 |
+
analysis = await get_regime_signal(symbol)
|
| 323 |
+
results[symbol] = analysis.get("signal", {}).get("direction", "WAIT")
|
| 324 |
+
return {"status": "sent", "results": results}
|
| 325 |
|
| 326 |
if __name__ == "__main__":
|
| 327 |
import uvicorn
|
| 328 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
| 329 |
|
| 330 |
+
print("🚀 SPACE 28 v2.2 — MARKET REGIME & BUBBLE SENTINEL (АВТО-ОТПРАВКА) ЗАПУЩЕН!")
|