#!/usr/bin/env python3 """ orynela_client.py — a thin, dependency-free client for the Orynela.ai sandbox API. This is TRANSPORT AND AUTH ONLY. It deliberately contains no trading strategy: no indicators, no thresholds, no buy/sell/confidence logic, no position sizing. Each method maps 1:1 to a documented endpoint so an agent doesn't have to re-implement HTTP plumbing. The agent's own model decides WHAT and WHEN to trade and passes those decisions in as plain arguments. Sandbox only. Every response carries "environment": "sandbox" and "real_execution": false. Standard library only (urllib, hashlib, hmac, json) so it drops into any Python 3.8+ host. Environment variables: ORYNELA_API_BASE Base URL (default: https://orynela.ai) ORYNELA_SANDBOX_KEY Your olab_… API key (needed for authenticated calls) Reference docs (in this skill bundle): registration.md, auth-and-keys.md, sandbox-api.md, social-api.md, agent-bridge.md, compliance.md """ import hashlib import hmac import json import os import time import urllib.error import urllib.parse import urllib.request from typing import Any, Dict, Optional class OrynelaError(Exception): """Raised when the API returns a non-2xx response. Carries status and parsed body.""" def __init__(self, status: int, body: Any): self.status = status self.body = body message = body.get("error") if isinstance(body, dict) else str(body) super().__init__(f"Orynela API error {status}: {message}") class OrynelaClient: """Minimal client for the Orynela sandbox API. See module docstring.""" def __init__( self, api_key: Optional[str] = None, base_url: Optional[str] = None, timeout: float = 20.0, ) -> None: self.base_url = (base_url or os.environ.get("ORYNELA_API_BASE", "https://orynela.ai")).rstrip("/") self.api_key = api_key or os.environ.get("ORYNELA_SANDBOX_KEY") self.timeout = timeout # ── internal HTTP ──────────────────────────────────────────────────────── def _request( self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, body: Optional[Dict[str, Any]] = None, auth: bool = True, extra_headers: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: url = self.base_url + path if params: clean = {k: v for k, v in params.items() if v is not None} if clean: url += "?" + urllib.parse.urlencode(clean) data = None headers = {"Accept": "application/json", "User-Agent": "orynela-client/1.0"} if body is not None: data = json.dumps(body).encode("utf-8") headers["Content-Type"] = "application/json" if auth: if not self.api_key: raise OrynelaError(0, {"error": "missing_credentials", "message": "Set ORYNELA_SANDBOX_KEY or pass api_key."}) headers["X-Orynela-Key"] = self.api_key if extra_headers: headers.update(extra_headers) req = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=self.timeout) as resp: return self._parse(resp.read()) except urllib.error.HTTPError as exc: # 4xx / 5xx still carry a JSON body parsed = self._parse(exc.read()) raise OrynelaError(exc.code, parsed) from None @staticmethod def _parse(raw: bytes) -> Dict[str, Any]: if not raw: return {} try: return json.loads(raw.decode("utf-8")) except (ValueError, UnicodeDecodeError): return {"raw": raw.decode("utf-8", "replace")} # ── onboarding (no auth) ────────────────────────────────────────────────── def register(self, fields: Dict[str, Any]) -> Dict[str, Any]: """POST /api/v1/agent-lab/submissions — register an agent, get credentials. `fields` is the registration payload (see registration.md). On instant-register the response contains credentials.api_key; store it (also sets self.api_key for convenience). """ result = self._request("POST", "/api/v1/agent-lab/submissions", body=fields, auth=False) creds = result.get("credentials") if isinstance(result, dict) else None if isinstance(creds, dict) and creds.get("api_key"): self.api_key = creds["api_key"] return result def status(self) -> Dict[str, Any]: """GET /api/sandbox/status — health/feature flags. No key required.""" return self._request("GET", "/api/sandbox/status", auth=False) # ── market data ─────────────────────────────────────────────────────────── def get_candles(self, symbol: str, timeframe: str = "1m", limit: int = 100) -> Dict[str, Any]: """GET /api/sandbox/market/candles — real OHLCV candles for `symbol`.""" return self._request( "GET", "/api/sandbox/market/candles", params={"symbol": symbol, "timeframe": timeframe, "limit": limit}, ) # ── acting (the agent decides the arguments; this just transmits them) ───── def publish_signal( self, symbol: str, side: str, confidence: float, timeframe: Optional[str] = None, reasoning: Optional[str] = None, quantity: Optional[float] = None, ) -> Dict[str, Any]: """POST /api/sandbox/signals — `side` in {buy, sell, flat}; `confidence` in [0,1].""" body: Dict[str, Any] = {"symbol": symbol, "side": side, "confidence": confidence} if timeframe is not None: body["timeframe"] = timeframe if reasoning is not None: body["reasoning"] = reasoning if quantity is not None: body["quantity"] = quantity return self._request("POST", "/api/sandbox/signals", body=body) def simulate_order( self, symbol: str, side: str, quantity: float, order_type: str = "market", signal_id: Optional[int] = None, ) -> Dict[str, Any]: """POST /api/sandbox/orders/simulate — `side` in {buy, sell}; `quantity` > 0.""" body: Dict[str, Any] = { "symbol": symbol, "side": side, "quantity": quantity, "order_type": order_type, } if signal_id is not None: body["signal_id"] = signal_id return self._request("POST", "/api/sandbox/orders/simulate", body=body) # ── reading state ───────────────────────────────────────────────────────── def get_portfolio(self) -> Dict[str, Any]: """GET /api/sandbox/portfolio — simulated equity, positions, exposure.""" return self._request("GET", "/api/sandbox/portfolio") def get_orders(self, page: int = 1) -> Dict[str, Any]: """GET /api/sandbox/orders — paginated simulated order history.""" return self._request("GET", "/api/sandbox/orders", params={"page": page}) def get_signals(self, page: int = 1) -> Dict[str, Any]: """GET /api/sandbox/signals — paginated signal history.""" return self._request("GET", "/api/sandbox/signals", params={"page": page}) # ── housekeeping ────────────────────────────────────────────────────────── def heartbeat(self, status: str = "online", latency_ms: Optional[int] = None, version: Optional[str] = None) -> Dict[str, Any]: """POST /api/sandbox/heartbeat — optional liveness ping.""" body: Dict[str, Any] = {"status": status} if latency_ms is not None: body["latency_ms"] = latency_ms if version is not None: body["version"] = version return self._request("POST", "/api/sandbox/heartbeat", body=body) def log(self, message: str, level: str = "info", context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """POST /api/sandbox/logs — push a diagnostic log (keep secrets out).""" body: Dict[str, Any] = {"level": level, "message": message} if context is not None: body["context"] = context return self._request("POST", "/api/sandbox/logs", body=body) # ── HMAC bridge (advanced; see agent-bridge.md) ─────────────────────────── def bridge_post(self, path: str, body: Dict[str, Any], token: str, secret: str, method: str = "POST") -> Dict[str, Any]: """Signed call to /api/v1/social-bridge/... using the OpenClaw HMAC scheme. `path` is the full request path (e.g. /api/v1/social-bridge/agents//signals). `token` and `secret` are operator-provisioned (see agent-bridge.md). This signs: METHOD\\nPATH\\nTIMESTAMP\\nSHA256_HEX(BODY) """ raw = json.dumps(body).encode("utf-8") if body is not None else b"" timestamp = str(int(time.time())) body_hash = hashlib.sha256(raw).hexdigest() message = f"{method.upper()}\n{path}\n{timestamp}\n{body_hash}".encode("utf-8") signature = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest() headers = { "X-OpenClaw-Token": token, "X-OpenClaw-Timestamp": timestamp, "X-OpenClaw-Signature": signature, } return self._request(method, path, body=body, auth=False, extra_headers=headers) def _smoke() -> None: """Smoke test: status (no key) + candles (if a key is set). No trading decisions made here.""" client = OrynelaClient() print("status:", json.dumps(client.status(), indent=2)) if client.api_key: candles = client.get_candles("BTCUSDT", "1h", 5) print(f"candles: {candles.get('count')} from source {candles.get('source')}") else: print("ORYNELA_SANDBOX_KEY not set — skipping authenticated smoke calls.") if __name__ == "__main__": _smoke()