- Fix TOCTOU race conditions by wrapping read-modify-write cycles under single-file locks in execution_state, portfolio_service, precheck_state, state_manager, and precheck_service. - Add missing test coverage (96 tests total): - test_review_service.py (15 tests) - test_check_api.py (6 tests) - test_external_gate.py main branches (+10 tests) - test_trade_execution.py new commands (+8 tests) - Unify all agent-consumed JSON messages to English. - Config-ize hardcoded values (volume filter, schema_version) via get_user_config with sensible defaults. - Add 1-hour TTL to exchange cache with force_new override. - Add ruff and mypy to dev dependencies; fix all type errors. - Add __all__ declarations to 11 service modules. - Sync README with new commands, config tuning docs, and PyPI badge. - Publish package as coinhunter==1.0.0 on PyPI with MIT license. - Deprecate coinhunter-cli==1.0.1 with runtime warning. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
134 lines
5.1 KiB
Python
134 lines
5.1 KiB
Python
"""Market data fetching and metric computation for precheck."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import ccxt
|
|
|
|
from .data_utils import norm_symbol, to_float
|
|
|
|
|
|
def get_exchange():
|
|
from ..runtime import load_env_file
|
|
|
|
load_env_file()
|
|
api_key = os.getenv("BINANCE_API_KEY")
|
|
secret = os.getenv("BINANCE_API_SECRET")
|
|
if not api_key or not secret:
|
|
raise RuntimeError("Missing BINANCE_API_KEY or BINANCE_API_SECRET in ~/.hermes/.env")
|
|
ex = ccxt.binance({
|
|
"apiKey": api_key,
|
|
"secret": secret,
|
|
"options": {"defaultType": "spot"},
|
|
"enableRateLimit": True,
|
|
})
|
|
ex.load_markets()
|
|
return ex
|
|
|
|
|
|
def fetch_ohlcv_batch(ex, symbols: set, timeframe: str, limit: int):
|
|
results = {}
|
|
for sym in sorted(symbols):
|
|
try:
|
|
ohlcv = ex.fetch_ohlcv(sym, timeframe=timeframe, limit=limit)
|
|
if ohlcv and len(ohlcv) >= 2:
|
|
results[sym] = ohlcv
|
|
except Exception:
|
|
pass
|
|
return results
|
|
|
|
|
|
def compute_ohlcv_metrics(ohlcv_1h, ohlcv_4h, current_price, volume_24h=None):
|
|
metrics = {}
|
|
if ohlcv_1h and len(ohlcv_1h) >= 2:
|
|
closes = [c[4] for c in ohlcv_1h]
|
|
volumes = [c[5] for c in ohlcv_1h]
|
|
metrics["change_1h_pct"] = round((closes[-1] - closes[-2]) / closes[-2] * 100, 2) if closes[-2] != 0 else None
|
|
if len(closes) >= 5:
|
|
metrics["change_4h_pct"] = round((closes[-1] - closes[-5]) / closes[-5] * 100, 2) if closes[-5] != 0 else None
|
|
recent_vol = sum(volumes[-4:]) / 4 if len(volumes) >= 4 else None
|
|
metrics["volume_1h_avg"] = round(recent_vol, 2) if recent_vol else None
|
|
highs = [c[2] for c in ohlcv_1h[-4:]]
|
|
lows = [c[3] for c in ohlcv_1h[-4:]]
|
|
metrics["high_4h"] = round(max(highs), 8) if highs else None
|
|
metrics["low_4h"] = round(min(lows), 8) if lows else None
|
|
|
|
if ohlcv_4h and len(ohlcv_4h) >= 2:
|
|
closes_4h = [c[4] for c in ohlcv_4h]
|
|
volumes_4h = [c[5] for c in ohlcv_4h]
|
|
metrics["change_4h_pct_from_4h"] = round((closes_4h[-1] - closes_4h[-2]) / closes_4h[-2] * 100, 2) if closes_4h[-2] != 0 else None
|
|
recent_vol_4h = sum(volumes_4h[-2:]) / 2 if len(volumes_4h) >= 2 else None
|
|
metrics["volume_4h_avg"] = round(recent_vol_4h, 2) if recent_vol_4h else None
|
|
highs_4h = [c[2] for c in ohlcv_4h]
|
|
lows_4h = [c[3] for c in ohlcv_4h]
|
|
metrics["high_24h_calc"] = round(max(highs_4h), 8) if highs_4h else None
|
|
metrics["low_24h_calc"] = round(min(lows_4h), 8) if lows_4h else None
|
|
if highs_4h and lows_4h:
|
|
avg_price = sum(closes_4h) / len(closes_4h)
|
|
metrics["volatility_4h_pct"] = round((max(highs_4h) - min(lows_4h)) / avg_price * 100, 2)
|
|
|
|
if current_price:
|
|
if metrics.get("high_4h"):
|
|
metrics["distance_from_4h_high_pct"] = round((metrics["high_4h"] - current_price) / metrics["high_4h"] * 100, 2)
|
|
if metrics.get("low_4h"):
|
|
metrics["distance_from_4h_low_pct"] = round((current_price - metrics["low_4h"]) / metrics["low_4h"] * 100, 2)
|
|
if metrics.get("high_24h_calc"):
|
|
metrics["distance_from_24h_high_pct"] = round((metrics["high_24h_calc"] - current_price) / metrics["high_24h_calc"] * 100, 2)
|
|
if metrics.get("low_24h_calc"):
|
|
metrics["distance_from_24h_low_pct"] = round((current_price - metrics["low_24h_calc"]) / metrics["low_24h_calc"] * 100, 2)
|
|
|
|
if volume_24h and volume_24h > 0 and metrics.get("volume_1h_avg"):
|
|
daily_avg_1h = volume_24h / 24
|
|
metrics["volume_1h_multiple"] = round(metrics["volume_1h_avg"] / daily_avg_1h, 2)
|
|
if volume_24h and volume_24h > 0 and metrics.get("volume_4h_avg"):
|
|
daily_avg_4h = volume_24h / 6
|
|
metrics["volume_4h_multiple"] = round(metrics["volume_4h_avg"] / daily_avg_4h, 2)
|
|
|
|
return metrics
|
|
|
|
|
|
def enrich_candidates_and_positions(global_candidates, candidate_layers, positions_view, tickers, ex):
|
|
symbols = set()
|
|
for c in global_candidates:
|
|
symbols.add(c["symbol"])
|
|
for p in positions_view:
|
|
sym = p.get("symbol")
|
|
if sym:
|
|
sym_ccxt = norm_symbol(sym)
|
|
symbols.add(sym_ccxt)
|
|
|
|
ohlcv_1h = fetch_ohlcv_batch(ex, symbols, "1h", 24)
|
|
ohlcv_4h = fetch_ohlcv_batch(ex, symbols, "4h", 12)
|
|
|
|
def _apply(target_list):
|
|
for item in target_list:
|
|
sym = item.get("symbol")
|
|
if not sym:
|
|
continue
|
|
sym_ccxt = norm_symbol(sym)
|
|
v24h = to_float(tickers.get(sym_ccxt, {}).get("quoteVolume"))
|
|
metrics = compute_ohlcv_metrics(
|
|
ohlcv_1h.get(sym_ccxt),
|
|
ohlcv_4h.get(sym_ccxt),
|
|
item.get("price") or item.get("last_price"),
|
|
volume_24h=v24h,
|
|
)
|
|
item["metrics"] = metrics
|
|
|
|
_apply(global_candidates)
|
|
for band_list in candidate_layers.values():
|
|
_apply(band_list)
|
|
_apply(positions_view)
|
|
return global_candidates, candidate_layers, positions_view
|
|
|
|
|
|
def regime_from_pct(pct: float | None) -> str:
|
|
if pct is None:
|
|
return "unknown"
|
|
if pct >= 2.0:
|
|
return "bullish"
|
|
if pct <= -2.0:
|
|
return "bearish"
|
|
return "neutral"
|