apexrunner-tools 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apexrunner_tools/__init__.py +31 -0
- apexrunner_tools/client.py +297 -0
- apexrunner_tools/constants.py +34 -0
- apexrunner_tools/crewai.py +75 -0
- apexrunner_tools/errors.py +31 -0
- apexrunner_tools/langchain.py +71 -0
- apexrunner_tools/payment.py +181 -0
- apexrunner_tools/schemas.py +26 -0
- apexrunner_tools/signals.py +82 -0
- apexrunner_tools-0.1.0.dist-info/METADATA +127 -0
- apexrunner_tools-0.1.0.dist-info/RECORD +13 -0
- apexrunner_tools-0.1.0.dist-info/WHEEL +4 -0
- apexrunner_tools-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""APEX Runner pre-trade crypto signals for LangChain and CrewAI agents, paid per call over x402.
|
|
2
|
+
|
|
3
|
+
Framework adapters live in ``apexrunner_tools.langchain`` and ``apexrunner_tools.crewai``
|
|
4
|
+
so that importing the core never requires either framework.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .client import ApexClient, ApexResult
|
|
8
|
+
from .constants import __version__
|
|
9
|
+
from .errors import (
|
|
10
|
+
ApexError,
|
|
11
|
+
ApexResponseError,
|
|
12
|
+
ApexUnavailable,
|
|
13
|
+
BudgetExceeded,
|
|
14
|
+
PaymentRefused,
|
|
15
|
+
PaymentRejected,
|
|
16
|
+
)
|
|
17
|
+
from .signals import SIGNALS, SignalSpec
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ApexClient",
|
|
21
|
+
"ApexResult",
|
|
22
|
+
"ApexError",
|
|
23
|
+
"ApexResponseError",
|
|
24
|
+
"ApexUnavailable",
|
|
25
|
+
"BudgetExceeded",
|
|
26
|
+
"PaymentRefused",
|
|
27
|
+
"PaymentRejected",
|
|
28
|
+
"SIGNALS",
|
|
29
|
+
"SignalSpec",
|
|
30
|
+
"__version__",
|
|
31
|
+
]
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""ApexClient: one paid call per signal, with caching, budget and error mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field, replace
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
from x402.http import decode_payment_required_header, decode_payment_response_header
|
|
13
|
+
from x402.http.clients import wrapRequestsWithPayment, x402HttpxClient
|
|
14
|
+
|
|
15
|
+
from .constants import (
|
|
16
|
+
APEX_BASE_URL,
|
|
17
|
+
DEFAULT_BUDGET_USDC,
|
|
18
|
+
DEFAULT_MAX_PAYMENT_USDC,
|
|
19
|
+
MAX_ALLOWED_PAYMENT_USDC,
|
|
20
|
+
MAX_CACHE_SECONDS,
|
|
21
|
+
MAX_CONTENT_CHARS,
|
|
22
|
+
REQUEST_TIMEOUT_S,
|
|
23
|
+
USER_AGENT,
|
|
24
|
+
)
|
|
25
|
+
from .errors import ApexResponseError, ApexUnavailable, PaymentRejected
|
|
26
|
+
from .payment import (
|
|
27
|
+
SpendBudget,
|
|
28
|
+
build_async_client,
|
|
29
|
+
build_sync_client,
|
|
30
|
+
call_ledger,
|
|
31
|
+
from_atomic,
|
|
32
|
+
map_payment_exception,
|
|
33
|
+
resolve_signer,
|
|
34
|
+
to_atomic,
|
|
35
|
+
)
|
|
36
|
+
from .signals import SignalSpec, get_spec
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ApexResult:
|
|
41
|
+
signal: str
|
|
42
|
+
data: dict[str, Any]
|
|
43
|
+
cached: bool = False
|
|
44
|
+
#: USDC signed for this call (0.0 when served from cache).
|
|
45
|
+
amount_usdc: float = 0.0
|
|
46
|
+
transaction: str | None = None
|
|
47
|
+
payer: str | None = None
|
|
48
|
+
network: str | None = None
|
|
49
|
+
query: dict[str, str] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
def payment(self) -> dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"amount_usdc": self.amount_usdc,
|
|
54
|
+
"transaction": self.transaction,
|
|
55
|
+
"network": self.network,
|
|
56
|
+
"cached": self.cached,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
def to_content(self, max_chars: int = MAX_CONTENT_CHARS) -> str:
|
|
60
|
+
"""Compact JSON for the model."""
|
|
61
|
+
out = json.dumps(
|
|
62
|
+
{"signal": self.signal, "data": self.data, "payment": self.payment()},
|
|
63
|
+
separators=(",", ":"),
|
|
64
|
+
default=str,
|
|
65
|
+
)
|
|
66
|
+
return out if len(out) <= max_chars else out[:max_chars] + "...[truncated]"
|
|
67
|
+
|
|
68
|
+
def to_artifact(self) -> dict[str, Any]:
|
|
69
|
+
"""Full result for the application (not sent to the model)."""
|
|
70
|
+
return {
|
|
71
|
+
"signal": self.signal,
|
|
72
|
+
"query": self.query,
|
|
73
|
+
"data": self.data,
|
|
74
|
+
"payment": {**self.payment(), "payer": self.payer},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _TTLCache:
|
|
79
|
+
def __init__(self, clock: Callable[[], float]):
|
|
80
|
+
self._clock = clock
|
|
81
|
+
self._items: dict[tuple, tuple[float, ApexResult]] = {}
|
|
82
|
+
self._lock = threading.Lock()
|
|
83
|
+
|
|
84
|
+
def get(self, key: tuple) -> ApexResult | None:
|
|
85
|
+
with self._lock:
|
|
86
|
+
hit = self._items.get(key)
|
|
87
|
+
if hit is None:
|
|
88
|
+
return None
|
|
89
|
+
if hit[0] <= self._clock():
|
|
90
|
+
del self._items[key]
|
|
91
|
+
return None
|
|
92
|
+
return hit[1]
|
|
93
|
+
|
|
94
|
+
def put(self, key: tuple, result: ApexResult, ttl: float) -> None:
|
|
95
|
+
if ttl <= 0:
|
|
96
|
+
return
|
|
97
|
+
with self._lock:
|
|
98
|
+
self._items[key] = (self._clock() + ttl, result)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ttl_for(data: dict[str, Any]) -> float:
|
|
102
|
+
"""Cache for as long as the response says it is valid, minus its age, capped."""
|
|
103
|
+
valid = data.get("valid_for_seconds")
|
|
104
|
+
if not isinstance(valid, (int, float)) or isinstance(valid, bool) or valid <= 0:
|
|
105
|
+
return 0.0
|
|
106
|
+
age = data.get("data_age_seconds", 0)
|
|
107
|
+
if not isinstance(age, (int, float)) or isinstance(age, bool) or age < 0:
|
|
108
|
+
age = 0
|
|
109
|
+
if data.get("stale") is True:
|
|
110
|
+
return 0.0
|
|
111
|
+
return float(min(valid - age, MAX_CACHE_SECONDS))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ApexClient:
|
|
115
|
+
"""Pays for and fetches APEX Runner signals.
|
|
116
|
+
|
|
117
|
+
Supply the wallet as exactly one of ``private_key``, ``account`` (an
|
|
118
|
+
eth_account LocalAccount) or ``signer`` (any object with ``address`` and
|
|
119
|
+
``sign_typed_data``). The wallet needs USDC on Base mainnet; no ETH is
|
|
120
|
+
needed, because payments are signed authorizations settled by the facilitator.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
private_key: str | None = None,
|
|
127
|
+
account: Any = None,
|
|
128
|
+
signer: Any = None,
|
|
129
|
+
max_payment_usdc: float = DEFAULT_MAX_PAYMENT_USDC,
|
|
130
|
+
budget_usdc: float = DEFAULT_BUDGET_USDC,
|
|
131
|
+
cache: bool = True,
|
|
132
|
+
timeout: float = REQUEST_TIMEOUT_S,
|
|
133
|
+
base_url: str = APEX_BASE_URL,
|
|
134
|
+
_clock: Callable[[], float] = time.monotonic,
|
|
135
|
+
):
|
|
136
|
+
if not (0 < max_payment_usdc <= MAX_ALLOWED_PAYMENT_USDC):
|
|
137
|
+
raise ValueError(f"max_payment_usdc must be in (0, {MAX_ALLOWED_PAYMENT_USDC}]")
|
|
138
|
+
self._signer = resolve_signer(private_key, account, signer)
|
|
139
|
+
self.address: str = self._signer.address
|
|
140
|
+
self.budget = SpendBudget(budget_usdc)
|
|
141
|
+
self._max_atomic = to_atomic(max_payment_usdc)
|
|
142
|
+
self._timeout = timeout
|
|
143
|
+
self._base_url = base_url.rstrip("/")
|
|
144
|
+
self._cache = _TTLCache(_clock) if cache else None
|
|
145
|
+
self._session = wrapRequestsWithPayment(
|
|
146
|
+
requests.Session(), build_sync_client(self._signer, self.budget, self._max_atomic)
|
|
147
|
+
)
|
|
148
|
+
self._session.headers["User-Agent"] = USER_AGENT
|
|
149
|
+
self._session.headers["Accept"] = "application/json"
|
|
150
|
+
self._async_x402 = build_async_client(self._signer, self.budget, self._max_atomic)
|
|
151
|
+
|
|
152
|
+
# ── public API ─────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
def call(self, signal: str, **params: Any) -> ApexResult:
|
|
155
|
+
spec, query, key = self._prepare(signal, params)
|
|
156
|
+
hit = self._cached(key)
|
|
157
|
+
if hit is not None:
|
|
158
|
+
return hit
|
|
159
|
+
ledger: list[int] = []
|
|
160
|
+
token = call_ledger.set(ledger)
|
|
161
|
+
try:
|
|
162
|
+
resp = self._session.get(self._url(spec), params=query, timeout=self._timeout)
|
|
163
|
+
except Exception as exc: # noqa: BLE001 - mapped to ApexError below
|
|
164
|
+
raise map_payment_exception(exc, self.budget) from exc
|
|
165
|
+
finally:
|
|
166
|
+
call_ledger.reset(token)
|
|
167
|
+
return self._finish(spec, query, key, resp.status_code, resp.headers, resp.content, ledger)
|
|
168
|
+
|
|
169
|
+
async def acall(self, signal: str, **params: Any) -> ApexResult:
|
|
170
|
+
spec, query, key = self._prepare(signal, params)
|
|
171
|
+
hit = self._cached(key)
|
|
172
|
+
if hit is not None:
|
|
173
|
+
return hit
|
|
174
|
+
ledger: list[int] = []
|
|
175
|
+
token = call_ledger.set(ledger)
|
|
176
|
+
try:
|
|
177
|
+
# A fresh AsyncClient per call: httpx clients are bound to an event loop,
|
|
178
|
+
# and agent frameworks do not guarantee the same loop between calls.
|
|
179
|
+
async with x402HttpxClient(
|
|
180
|
+
self._async_x402,
|
|
181
|
+
timeout=self._timeout,
|
|
182
|
+
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
|
|
183
|
+
) as http:
|
|
184
|
+
resp = await http.get(self._url(spec), params=query)
|
|
185
|
+
content = resp.content
|
|
186
|
+
except Exception as exc: # noqa: BLE001
|
|
187
|
+
raise map_payment_exception(exc, self.budget) from exc
|
|
188
|
+
finally:
|
|
189
|
+
call_ledger.reset(token)
|
|
190
|
+
return self._finish(spec, query, key, resp.status_code, resp.headers, content, ledger)
|
|
191
|
+
|
|
192
|
+
def close(self) -> None:
|
|
193
|
+
self._session.close()
|
|
194
|
+
|
|
195
|
+
def __enter__(self) -> "ApexClient":
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
def __exit__(self, *exc: Any) -> None:
|
|
199
|
+
self.close()
|
|
200
|
+
|
|
201
|
+
# ── internals ──────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
def _url(self, spec: SignalSpec) -> str:
|
|
204
|
+
return f"{self._base_url}/{spec.slug}"
|
|
205
|
+
|
|
206
|
+
def _prepare(self, signal: str, params: dict[str, Any]):
|
|
207
|
+
spec = get_spec(signal)
|
|
208
|
+
args = spec.input_model(**params)
|
|
209
|
+
query = spec.to_query(args)
|
|
210
|
+
return spec, query, (spec.slug, tuple(sorted(query.items())))
|
|
211
|
+
|
|
212
|
+
def _cached(self, key: tuple) -> ApexResult | None:
|
|
213
|
+
if self._cache is None:
|
|
214
|
+
return None
|
|
215
|
+
hit = self._cache.get(key)
|
|
216
|
+
return None if hit is None else replace(hit, cached=True, amount_usdc=0.0)
|
|
217
|
+
|
|
218
|
+
def _finish(
|
|
219
|
+
self,
|
|
220
|
+
spec: SignalSpec,
|
|
221
|
+
query: dict[str, str],
|
|
222
|
+
key: tuple,
|
|
223
|
+
status: int,
|
|
224
|
+
headers: Any,
|
|
225
|
+
content: bytes,
|
|
226
|
+
ledger: list[int],
|
|
227
|
+
) -> ApexResult:
|
|
228
|
+
signed = from_atomic(sum(ledger))
|
|
229
|
+
if status == 402:
|
|
230
|
+
reason = _payment_error_reason(headers)
|
|
231
|
+
raise PaymentRejected(
|
|
232
|
+
f"Payment was rejected{f' ({reason})' if reason else ''}. Check that wallet "
|
|
233
|
+
f"{self.address} holds USDC on Base mainnet (about $0.01 per call)."
|
|
234
|
+
)
|
|
235
|
+
if not 200 <= status < 300:
|
|
236
|
+
body = content[:300].decode("utf-8", "replace")
|
|
237
|
+
raise ApexUnavailable(
|
|
238
|
+
f"APEX Runner returned HTTP {status} for {spec.name}; x402 settles only on "
|
|
239
|
+
f"success, so this call should not be charged. Body: {body}",
|
|
240
|
+
status_code=status,
|
|
241
|
+
)
|
|
242
|
+
try:
|
|
243
|
+
data = json.loads(content)
|
|
244
|
+
except ValueError:
|
|
245
|
+
raise ApexResponseError(f"APEX Runner returned a non-JSON body for {spec.name}.") from None
|
|
246
|
+
if not isinstance(data, dict):
|
|
247
|
+
raise ApexResponseError(f"APEX Runner returned a non-object JSON body for {spec.name}.")
|
|
248
|
+
|
|
249
|
+
receipt = _settle_receipt(headers)
|
|
250
|
+
result = ApexResult(
|
|
251
|
+
signal=spec.name,
|
|
252
|
+
data=data,
|
|
253
|
+
amount_usdc=signed,
|
|
254
|
+
transaction=receipt.get("transaction"),
|
|
255
|
+
payer=receipt.get("payer"),
|
|
256
|
+
network=receipt.get("network"),
|
|
257
|
+
query=query,
|
|
258
|
+
)
|
|
259
|
+
if self._cache is not None:
|
|
260
|
+
self._cache.put(key, result, _ttl_for(data))
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _header(headers: Any, *names: str) -> str | None:
|
|
265
|
+
for name in names:
|
|
266
|
+
value = headers.get(name)
|
|
267
|
+
if value:
|
|
268
|
+
return value
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _settle_receipt(headers: Any) -> dict[str, Any]:
|
|
273
|
+
raw = _header(headers, "PAYMENT-RESPONSE", "X-PAYMENT-RESPONSE")
|
|
274
|
+
if not raw:
|
|
275
|
+
return {}
|
|
276
|
+
try:
|
|
277
|
+
settle = decode_payment_response_header(raw)
|
|
278
|
+
return {
|
|
279
|
+
"transaction": getattr(settle, "transaction", None),
|
|
280
|
+
"payer": getattr(settle, "payer", None),
|
|
281
|
+
"network": getattr(settle, "network", None),
|
|
282
|
+
}
|
|
283
|
+
except Exception: # noqa: BLE001 - a receipt we cannot read is not a failed call
|
|
284
|
+
return {}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _payment_error_reason(headers: Any) -> str | None:
|
|
288
|
+
raw = _header(headers, "PAYMENT-REQUIRED")
|
|
289
|
+
if not raw:
|
|
290
|
+
return None
|
|
291
|
+
try:
|
|
292
|
+
return getattr(decode_payment_required_header(raw), "error", None)
|
|
293
|
+
except Exception: # noqa: BLE001
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
__all__ = ["ApexClient", "ApexResult"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Pinned values. The payment policy refuses any payment request that differs."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
APEX_BASE_URL = "https://apexrunner.ai/signals"
|
|
6
|
+
|
|
7
|
+
# Base mainnet, CAIP-2. APEX is paid here only.
|
|
8
|
+
BASE_MAINNET = "eip155:8453"
|
|
9
|
+
|
|
10
|
+
# Native USDC on Base (6 decimals).
|
|
11
|
+
BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
12
|
+
USDC_DECIMALS = 6
|
|
13
|
+
|
|
14
|
+
# APEX Runner's receiving address, as served in every 402 challenge.
|
|
15
|
+
APEX_PAY_TO = "0x8D0663BBb967ebF1c047405A888DDf865f3E8854"
|
|
16
|
+
|
|
17
|
+
# Per-call ceiling. List price today is $0.01 (charged $0.007 until 2027-01-01).
|
|
18
|
+
DEFAULT_MAX_PAYMENT_USDC = 0.02
|
|
19
|
+
# Hard ceiling for any configured per-call cap: a typo must not authorise $10 calls.
|
|
20
|
+
MAX_ALLOWED_PAYMENT_USDC = 1.00
|
|
21
|
+
|
|
22
|
+
# Total the client may sign for over its lifetime.
|
|
23
|
+
DEFAULT_BUDGET_USDC = 1.00
|
|
24
|
+
|
|
25
|
+
REQUEST_TIMEOUT_S = 20.0
|
|
26
|
+
|
|
27
|
+
# Never serve a cached result older than this, whatever the response claims.
|
|
28
|
+
MAX_CACHE_SECONDS = 300
|
|
29
|
+
|
|
30
|
+
# Keeps one signal payload from flooding the model's context window.
|
|
31
|
+
MAX_CONTENT_CHARS = 16_000
|
|
32
|
+
|
|
33
|
+
# Distinct UA so APEX can attribute traffic from this package.
|
|
34
|
+
USER_AGENT = f"apexrunner-tools/{__version__} (python; +https://apexrunner.ai)"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""CrewAI tools. Requires ``pip install apexrunner-tools[crewai]``.
|
|
2
|
+
|
|
3
|
+
from apexrunner_tools.crewai import get_apex_tools
|
|
4
|
+
analyst = Agent(role="...", goal="...", backstory="...",
|
|
5
|
+
tools=get_apex_tools(private_key=os.environ["AGENT_WALLET_KEY"]))
|
|
6
|
+
|
|
7
|
+
CrewAI's own tool cache is switched off for these tools: it has no expiry, so
|
|
8
|
+
it would keep serving market data long after it went stale. ApexClient caches
|
|
9
|
+
each result only for as long as the response says it is valid.
|
|
10
|
+
|
|
11
|
+
Failures are returned as JSON with ``success: false`` and the reason, so the
|
|
12
|
+
agent can read them instead of the crew crashing.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from crewai.tools import BaseTool
|
|
22
|
+
except ImportError as exc: # pragma: no cover
|
|
23
|
+
raise ImportError("Install the CrewAI extra: pip install 'apexrunner-tools[crewai]'") from exc
|
|
24
|
+
|
|
25
|
+
from pydantic import ConfigDict, Field
|
|
26
|
+
|
|
27
|
+
from .client import ApexClient
|
|
28
|
+
from .errors import ApexError
|
|
29
|
+
from .signals import SIGNALS
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _never_cache(_args: Any = None, _result: Any = None) -> bool:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ApexCrewTool(BaseTool):
|
|
37
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
38
|
+
|
|
39
|
+
client: ApexClient = Field(exclude=True)
|
|
40
|
+
signal: str
|
|
41
|
+
cache_function: Any = _never_cache
|
|
42
|
+
|
|
43
|
+
def _run(self, **kwargs: Any) -> str:
|
|
44
|
+
try:
|
|
45
|
+
return self.client.call(self.signal, **kwargs).to_content()
|
|
46
|
+
except ApexError as exc:
|
|
47
|
+
return _failure(self.signal, exc)
|
|
48
|
+
|
|
49
|
+
async def _arun(self, **kwargs: Any) -> str:
|
|
50
|
+
try:
|
|
51
|
+
return (await self.client.acall(self.signal, **kwargs)).to_content()
|
|
52
|
+
except ApexError as exc:
|
|
53
|
+
return _failure(self.signal, exc)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _failure(signal: str, exc: ApexError) -> str:
|
|
57
|
+
return json.dumps({"success": False, "signal": signal, "error": str(exc)})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_apex_tools(client: ApexClient | None = None, **client_kwargs: Any) -> list[ApexCrewTool]:
|
|
61
|
+
"""The three APEX tools sharing one client (one wallet, one budget, one cache)."""
|
|
62
|
+
if client is None:
|
|
63
|
+
client = ApexClient(**client_kwargs)
|
|
64
|
+
elif client_kwargs:
|
|
65
|
+
raise ValueError("Pass either client or client arguments, not both.")
|
|
66
|
+
return [
|
|
67
|
+
ApexCrewTool(
|
|
68
|
+
name=spec.name,
|
|
69
|
+
description=spec.description,
|
|
70
|
+
args_schema=spec.input_model,
|
|
71
|
+
client=client,
|
|
72
|
+
signal=spec.name,
|
|
73
|
+
)
|
|
74
|
+
for spec in SIGNALS
|
|
75
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Every failure the client raises. All subclass ApexError."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ApexError(Exception):
|
|
5
|
+
"""Base class for apexrunner-tools errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PaymentRefused(ApexError):
|
|
9
|
+
"""No payment option passed the local policy. Nothing was signed."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BudgetExceeded(PaymentRefused):
|
|
13
|
+
"""Signing this payment would exceed the client's spend budget. Nothing was signed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PaymentRejected(ApexError):
|
|
17
|
+
"""A payment was signed but the server or facilitator refused it.
|
|
18
|
+
|
|
19
|
+
Usually the wallet holds too little USDC on Base."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ApexUnavailable(ApexError):
|
|
23
|
+
"""APEX answered with a non-success status. x402 settles only on success."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, status_code: int):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status_code = status_code
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ApexResponseError(ApexError):
|
|
31
|
+
"""APEX returned a success status with a body that is not a JSON object."""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""LangChain tools. Requires ``pip install apexrunner-tools[langchain]``.
|
|
2
|
+
|
|
3
|
+
from apexrunner_tools.langchain import get_apex_tools
|
|
4
|
+
tools = get_apex_tools(private_key=os.environ["AGENT_WALLET_KEY"])
|
|
5
|
+
agent = create_agent(model, tools=tools)
|
|
6
|
+
|
|
7
|
+
Each tool returns ``content_and_artifact``: the model sees compact JSON, and the
|
|
8
|
+
ToolMessage artifact carries the full data plus the payment receipt.
|
|
9
|
+
Failures raise ToolException, which ``handle_tool_error=True`` turns into a
|
|
10
|
+
message the model can read and act on.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from langchain_core.tools import BaseTool, ToolException
|
|
19
|
+
except ImportError as exc: # pragma: no cover
|
|
20
|
+
raise ImportError("Install the LangChain extra: pip install 'apexrunner-tools[langchain]'") from exc
|
|
21
|
+
|
|
22
|
+
from pydantic import ConfigDict, Field
|
|
23
|
+
|
|
24
|
+
from .client import ApexClient
|
|
25
|
+
from .errors import ApexError
|
|
26
|
+
from .signals import SIGNALS
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApexTool(BaseTool):
|
|
30
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
31
|
+
|
|
32
|
+
client: ApexClient = Field(exclude=True)
|
|
33
|
+
signal: str
|
|
34
|
+
response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
|
|
35
|
+
handle_tool_error: bool = True
|
|
36
|
+
|
|
37
|
+
def _run(self, *args: Any, run_manager: Any = None, **kwargs: Any) -> tuple[str, dict]:
|
|
38
|
+
try:
|
|
39
|
+
result = self.client.call(self.signal, **kwargs)
|
|
40
|
+
except ApexError as exc:
|
|
41
|
+
raise ToolException(str(exc)) from exc
|
|
42
|
+
return result.to_content(), result.to_artifact()
|
|
43
|
+
|
|
44
|
+
async def _arun(self, *args: Any, run_manager: Any = None, **kwargs: Any) -> tuple[str, dict]:
|
|
45
|
+
try:
|
|
46
|
+
result = await self.client.acall(self.signal, **kwargs)
|
|
47
|
+
except ApexError as exc:
|
|
48
|
+
raise ToolException(str(exc)) from exc
|
|
49
|
+
return result.to_content(), result.to_artifact()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_apex_tools(client: ApexClient | None = None, **client_kwargs: Any) -> list[ApexTool]:
|
|
53
|
+
"""The three APEX tools sharing one client (one wallet, one budget, one cache).
|
|
54
|
+
|
|
55
|
+
Pass an existing ``client``, or the ApexClient arguments (``private_key=...``,
|
|
56
|
+
``budget_usdc=...``, ...) to create one.
|
|
57
|
+
"""
|
|
58
|
+
if client is None:
|
|
59
|
+
client = ApexClient(**client_kwargs)
|
|
60
|
+
elif client_kwargs:
|
|
61
|
+
raise ValueError("Pass either client or client arguments, not both.")
|
|
62
|
+
return [
|
|
63
|
+
ApexTool(
|
|
64
|
+
name=spec.name,
|
|
65
|
+
description=spec.description,
|
|
66
|
+
args_schema=spec.input_model,
|
|
67
|
+
client=client,
|
|
68
|
+
signal=spec.name,
|
|
69
|
+
)
|
|
70
|
+
for spec in SIGNALS
|
|
71
|
+
]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Wallet setup, the APEX-only payment policy, and the spend budget.
|
|
2
|
+
|
|
3
|
+
Two independent gates run before anything is signed:
|
|
4
|
+
|
|
5
|
+
1. A payment *policy* removes every payment option that is not exact-scheme
|
|
6
|
+
USDC on Base, to APEX's pinned address, at or under the per-call cap. If no
|
|
7
|
+
option is left, x402 raises and nothing is signed.
|
|
8
|
+
2. A *before-payment hook* re-checks the selected option and charges the spend
|
|
9
|
+
budget. If the budget would be exceeded it aborts, and nothing is signed.
|
|
10
|
+
|
|
11
|
+
The hook runs once per signature, not once per call, so the SDK's "recovery"
|
|
12
|
+
path (which can sign a second payment within one HTTP call) is budgeted too.
|
|
13
|
+
A signed authorization counts as spent the moment it is signed: the budget
|
|
14
|
+
never assumes a payment was *not* settled.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import threading
|
|
20
|
+
from contextvars import ContextVar
|
|
21
|
+
from decimal import Decimal
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from x402 import x402Client, x402ClientSync
|
|
25
|
+
from x402.mechanisms.evm.exact import register_exact_evm_client
|
|
26
|
+
from x402.mechanisms.evm.signers import EthAccountSigner
|
|
27
|
+
from x402.schemas import AbortResult, NoMatchingRequirementsError, PaymentAbortedError
|
|
28
|
+
|
|
29
|
+
from .constants import APEX_PAY_TO, BASE_MAINNET, BASE_USDC, USDC_DECIMALS
|
|
30
|
+
from .errors import ApexError, BudgetExceeded, PaymentRefused
|
|
31
|
+
|
|
32
|
+
ABORT_BUDGET = "apexrunner_budget_exceeded"
|
|
33
|
+
ABORT_NOT_APEX = "apexrunner_not_apex_payment"
|
|
34
|
+
|
|
35
|
+
_UNIT = Decimal(10) ** USDC_DECIMALS
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def to_atomic(usdc: float | int | str | Decimal) -> int:
|
|
39
|
+
return int((Decimal(str(usdc)) * _UNIT).to_integral_value())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def from_atomic(atomic: int) -> float:
|
|
43
|
+
return float(Decimal(atomic) / _UNIT)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Amounts signed during the current call. Set by ApexClient around each request.
|
|
47
|
+
# Works for threads (sync) and asyncio tasks, including hooks the async client
|
|
48
|
+
# runs through asyncio.to_thread (which copies the context).
|
|
49
|
+
call_ledger: ContextVar[list[int] | None] = ContextVar("apexrunner_call_ledger", default=None)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_signer(private_key: str | None = None, account: Any = None, signer: Any = None) -> Any:
|
|
53
|
+
"""Return an x402 EVM signer from exactly one of the three ways to supply a wallet.
|
|
54
|
+
|
|
55
|
+
A private key becomes an eth_account LocalAccount, which holds it only to sign;
|
|
56
|
+
the key string itself is not stored anywhere else.
|
|
57
|
+
"""
|
|
58
|
+
supplied = sum(x is not None for x in (private_key, account, signer))
|
|
59
|
+
if supplied != 1:
|
|
60
|
+
raise ValueError("Provide exactly one of private_key, account or signer.")
|
|
61
|
+
if private_key is not None:
|
|
62
|
+
from eth_account import Account
|
|
63
|
+
|
|
64
|
+
account = Account.from_key(private_key)
|
|
65
|
+
if account is not None:
|
|
66
|
+
return EthAccountSigner(account)
|
|
67
|
+
if not isinstance(getattr(signer, "address", None), str) or not callable(
|
|
68
|
+
getattr(signer, "sign_typed_data", None)
|
|
69
|
+
):
|
|
70
|
+
raise TypeError("signer must have an 'address' string and a 'sign_typed_data' method.")
|
|
71
|
+
return signer
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_apex_requirement(req: Any, max_atomic: int) -> bool:
|
|
75
|
+
amount = str(getattr(req, "amount", "") or "")
|
|
76
|
+
if not amount.isdigit():
|
|
77
|
+
return False
|
|
78
|
+
return (
|
|
79
|
+
getattr(req, "scheme", None) == "exact"
|
|
80
|
+
and getattr(req, "network", None) == BASE_MAINNET
|
|
81
|
+
and str(getattr(req, "asset", "")).lower() == BASE_USDC.lower()
|
|
82
|
+
and str(getattr(req, "pay_to", "")).lower() == APEX_PAY_TO.lower()
|
|
83
|
+
and 0 < int(amount) <= max_atomic
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def apex_only_policy(max_atomic: int):
|
|
88
|
+
def policy(version: int, reqs: list) -> list:
|
|
89
|
+
if version != 2: # APEX speaks x402 v2; never guess at v1 fields
|
|
90
|
+
return []
|
|
91
|
+
return [r for r in reqs if is_apex_requirement(r, max_atomic)]
|
|
92
|
+
|
|
93
|
+
return policy
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SpendBudget:
|
|
97
|
+
"""Thread-safe running total of USDC signed by one client."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, limit_usdc: float):
|
|
100
|
+
if limit_usdc < 0:
|
|
101
|
+
raise ValueError("budget_usdc must be >= 0")
|
|
102
|
+
self._limit = to_atomic(limit_usdc)
|
|
103
|
+
self._spent = 0
|
|
104
|
+
self._lock = threading.Lock()
|
|
105
|
+
|
|
106
|
+
def try_spend(self, atomic: int) -> bool:
|
|
107
|
+
with self._lock:
|
|
108
|
+
if self._spent + atomic > self._limit:
|
|
109
|
+
return False
|
|
110
|
+
self._spent += atomic
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def limit_usdc(self) -> float:
|
|
115
|
+
return from_atomic(self._limit)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def spent_usdc(self) -> float:
|
|
119
|
+
with self._lock:
|
|
120
|
+
return from_atomic(self._spent)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def remaining_usdc(self) -> float:
|
|
124
|
+
with self._lock:
|
|
125
|
+
return from_atomic(self._limit - self._spent)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _before_payment_hook(budget: SpendBudget, max_atomic: int):
|
|
129
|
+
def hook(ctx: Any) -> AbortResult | None:
|
|
130
|
+
req = ctx.selected_requirements
|
|
131
|
+
if ctx.payment_required.x402_version != 2 or not is_apex_requirement(req, max_atomic):
|
|
132
|
+
return AbortResult(reason=ABORT_NOT_APEX)
|
|
133
|
+
amount = int(req.amount)
|
|
134
|
+
if not budget.try_spend(amount):
|
|
135
|
+
return AbortResult(reason=ABORT_BUDGET)
|
|
136
|
+
ledger = call_ledger.get()
|
|
137
|
+
if ledger is not None:
|
|
138
|
+
ledger.append(amount)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
return hook
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_sync_client(signer: Any, budget: SpendBudget, max_atomic: int) -> x402ClientSync:
|
|
145
|
+
client = x402ClientSync()
|
|
146
|
+
register_exact_evm_client(client, signer, networks=BASE_MAINNET)
|
|
147
|
+
client.register_policy(apex_only_policy(max_atomic))
|
|
148
|
+
client.on_before_payment_creation(_before_payment_hook(budget, max_atomic))
|
|
149
|
+
return client
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_async_client(signer: Any, budget: SpendBudget, max_atomic: int) -> x402Client:
|
|
153
|
+
client = x402Client()
|
|
154
|
+
register_exact_evm_client(client, signer, networks=BASE_MAINNET)
|
|
155
|
+
client.register_policy(apex_only_policy(max_atomic))
|
|
156
|
+
client.on_before_payment_creation(_before_payment_hook(budget, max_atomic))
|
|
157
|
+
return client
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def map_payment_exception(exc: BaseException, budget: SpendBudget) -> ApexError:
|
|
161
|
+
"""Translate x402's exception (often wrapped by the HTTP adapter) into ours."""
|
|
162
|
+
seen: set[int] = set()
|
|
163
|
+
cur: BaseException | None = exc
|
|
164
|
+
while cur is not None and id(cur) not in seen:
|
|
165
|
+
seen.add(id(cur))
|
|
166
|
+
if isinstance(cur, PaymentAbortedError):
|
|
167
|
+
if cur.reason == ABORT_BUDGET:
|
|
168
|
+
return BudgetExceeded(
|
|
169
|
+
f"Spend budget reached (${budget.spent_usdc:.4f} of ${budget.limit_usdc:.2f} "
|
|
170
|
+
"signed). Nothing was signed for this call."
|
|
171
|
+
)
|
|
172
|
+
return PaymentRefused(
|
|
173
|
+
"The payment request did not match APEX Runner's pinned terms. Nothing was signed."
|
|
174
|
+
)
|
|
175
|
+
if isinstance(cur, NoMatchingRequirementsError):
|
|
176
|
+
return PaymentRefused(
|
|
177
|
+
"No acceptable payment option: APEX payments must be exact USDC on Base to the "
|
|
178
|
+
"pinned address at or under the per-call cap. Nothing was signed."
|
|
179
|
+
)
|
|
180
|
+
cur = cur.__cause__ or cur.__context__
|
|
181
|
+
return ApexError(f"Request to APEX Runner failed: {type(exc).__name__}: {exc}")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Tool inputs, shared by the LangChain and CrewAI adapters.
|
|
2
|
+
|
|
3
|
+
Bounds mirror the server's own validation (apex-signals PARAM_SPECS).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
Coin = Literal["BTC", "ETH", "SOL", "AVAX", "LINK", "XRP"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RiskAssessmentBundleInput(BaseModel):
|
|
14
|
+
"""No input: a market-wide risk snapshot."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ExecutionWindowOptimizerInput(BaseModel):
|
|
18
|
+
coin: Coin = Field(default="BTC", description="Asset symbol to score the execution window for.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SlippageForecastInput(BaseModel):
|
|
22
|
+
coin: Coin = Field(default="BTC", description="Asset symbol of the order.")
|
|
23
|
+
size_usd: float = Field(
|
|
24
|
+
ge=10, le=1_000_000, description="Order notional in USD, between 10 and 1,000,000. Example: 5000."
|
|
25
|
+
)
|
|
26
|
+
side: Literal["buy", "sell"] = Field(default="buy", description="Order side.")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""The three tools, defined once. Both framework adapters read from here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from .schemas import ExecutionWindowOptimizerInput, RiskAssessmentBundleInput, SlippageForecastInput
|
|
10
|
+
|
|
11
|
+
_COST = (
|
|
12
|
+
"Costs about $0.01 in USDC on Base, paid automatically from the agent's wallet. "
|
|
13
|
+
"Repeating the same call while the data is still fresh returns the cached result at no cost."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class SignalSpec:
|
|
19
|
+
name: str
|
|
20
|
+
slug: str
|
|
21
|
+
input_model: type[BaseModel]
|
|
22
|
+
description: str
|
|
23
|
+
|
|
24
|
+
def to_query(self, args: BaseModel) -> dict[str, str]:
|
|
25
|
+
out: dict[str, str] = {}
|
|
26
|
+
for key, value in args.model_dump().items():
|
|
27
|
+
if isinstance(value, float) and value.is_integer():
|
|
28
|
+
value = int(value)
|
|
29
|
+
out[key] = str(value)
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
RISK_ASSESSMENT_BUNDLE = SignalSpec(
|
|
34
|
+
name="risk_assessment_bundle",
|
|
35
|
+
slug="risk-assessment-bundle",
|
|
36
|
+
input_model=RiskAssessmentBundleInput,
|
|
37
|
+
description=(
|
|
38
|
+
"Check crypto market risk before sizing or opening a position. One call returns 4 signals: "
|
|
39
|
+
"liquidation cascade risk per coin (liquidation_pressure), the nearest liquidation clusters "
|
|
40
|
+
"and which way price is being pulled (liquidation_magnet), overall market heat with a "
|
|
41
|
+
"suggested action (portfolio_heat), and top-trader long/short positioning (whale_sentiment). "
|
|
42
|
+
"Use it to avoid entering just as leverage is about to unwind. Takes no input. " + _COST
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
EXECUTION_WINDOW_OPTIMIZER = SignalSpec(
|
|
47
|
+
name="execution_window_optimizer",
|
|
48
|
+
slug="execution-window-optimizer",
|
|
49
|
+
input_model=ExecutionWindowOptimizerInput,
|
|
50
|
+
description=(
|
|
51
|
+
"Decide whether to execute a crypto order now or wait. Scores the current window 0-100 "
|
|
52
|
+
"from trading session, market regime, Fear & Greed and volatility, and returns a verdict "
|
|
53
|
+
"(EXECUTE_NOW, GOOD_WINDOW, WAIT_15MIN or WAIT_SESSION), a recommended order type and the "
|
|
54
|
+
"next favourable window. Input: coin (BTC, ETH, SOL, AVAX, LINK or XRP; default BTC). " + _COST
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
SLIPPAGE_FORECAST = SignalSpec(
|
|
59
|
+
name="slippage_forecast",
|
|
60
|
+
slug="slippage-forecast",
|
|
61
|
+
input_model=SlippageForecastInput,
|
|
62
|
+
description=(
|
|
63
|
+
"Forecast the hidden market-impact cost of a crypto order before placing it. Returns "
|
|
64
|
+
"expected slippage as a percentage and in USD for the given coin, size and side, plus a "
|
|
65
|
+
"recommendation: EXECUTE_NOW, EXECUTE, SPLIT_ORDER (with a suggested tranche count) or "
|
|
66
|
+
"USE_LIMIT. Use it for any order above a few hundred dollars. Inputs: coin (BTC, ETH, SOL, "
|
|
67
|
+
"AVAX, LINK or XRP), size_usd (10 to 1,000,000), side (buy or sell). "
|
|
68
|
+
"Example: coin=ETH, size_usd=5000, side=sell. " + _COST
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
SIGNALS: tuple[SignalSpec, ...] = (RISK_ASSESSMENT_BUNDLE, EXECUTION_WINDOW_OPTIMIZER, SLIPPAGE_FORECAST)
|
|
73
|
+
|
|
74
|
+
_BY_KEY = {key: spec for spec in SIGNALS for key in (spec.name, spec.slug)}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_spec(signal: str) -> SignalSpec:
|
|
78
|
+
try:
|
|
79
|
+
return _BY_KEY[signal]
|
|
80
|
+
except KeyError:
|
|
81
|
+
names = ", ".join(s.name for s in SIGNALS)
|
|
82
|
+
raise ValueError(f"Unknown signal {signal!r}. Available: {names}") from None
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: apexrunner-tools
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain and CrewAI tools for APEX Runner pre-trade crypto signals (risk, execution timing, slippage), paid per call over x402 in USDC on Base.
|
|
5
|
+
Project-URL: Homepage, https://apexrunner.ai
|
|
6
|
+
Project-URL: Documentation, https://apexrunner.ai/signals/docs/
|
|
7
|
+
Project-URL: Repository, https://github.com/kynto2001-ctrl/apexrunner-tools
|
|
8
|
+
Project-URL: Issues, https://github.com/kynto2001-ctrl/apexrunner-tools/issues
|
|
9
|
+
Author-email: APEX Runner <267811382+kynto2001-ctrl@users.noreply.github.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,base,crewai,crypto,langchain,trading,usdc,x402
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: pydantic<3,>=2.6
|
|
21
|
+
Requires-Dist: x402[evm,httpx,requests]<3,>=2.8
|
|
22
|
+
Provides-Extra: crewai
|
|
23
|
+
Requires-Dist: crewai<2,>=1.0; extra == 'crewai'
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: crewai<2,>=1.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: langchain-core<2,>=1.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
29
|
+
Provides-Extra: langchain
|
|
30
|
+
Requires-Dist: langchain-core<2,>=1.0; extra == 'langchain'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# apexrunner-tools
|
|
34
|
+
|
|
35
|
+
Pre-trade crypto signals for **LangChain** and **CrewAI** agents. Each call is paid per use over [x402](https://x402.org) from the agent's own wallet: about $0.01 in USDC on Base mainnet. There is no API key, no account and no subscription.
|
|
36
|
+
|
|
37
|
+
| Tool | Input | Returns |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `risk_assessment_bundle` | none | 4 signals in one call: liquidation cascade risk, nearest liquidation clusters, market heat, top-trader positioning |
|
|
40
|
+
| `execution_window_optimizer` | `coin` | 0–100 window score and a verdict: EXECUTE_NOW / GOOD_WINDOW / WAIT_15MIN / WAIT_SESSION, plus an order type |
|
|
41
|
+
| `slippage_forecast` | `coin`, `size_usd`, `side` | Expected slippage % and USD, and a recommendation: EXECUTE_NOW / EXECUTE / SPLIT_ORDER / USE_LIMIT |
|
|
42
|
+
|
|
43
|
+
Coins: BTC, ETH, SOL, AVAX, LINK, XRP.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install "apexrunner-tools[langchain]" # or [crewai]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## LangChain
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import os
|
|
55
|
+
from langchain.agents import create_agent
|
|
56
|
+
from apexrunner_tools.langchain import get_apex_tools
|
|
57
|
+
|
|
58
|
+
tools = get_apex_tools(private_key=os.environ["AGENT_WALLET_KEY"]) # $1.00 budget by default
|
|
59
|
+
agent = create_agent("openai:gpt-4.1-mini", tools=tools)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Each tool returns `content_and_artifact`. The model sees compact JSON. The `ToolMessage.artifact` holds the full data and the payment receipt (amount and transaction hash).
|
|
63
|
+
|
|
64
|
+
## CrewAI
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import os
|
|
68
|
+
from crewai import Agent
|
|
69
|
+
from apexrunner_tools.crewai import get_apex_tools
|
|
70
|
+
|
|
71
|
+
trader = Agent(
|
|
72
|
+
role="Execution trader",
|
|
73
|
+
goal="Place orders at the lowest total cost",
|
|
74
|
+
backstory="Checks risk, timing and slippage before every order.",
|
|
75
|
+
tools=get_apex_tools(private_key=os.environ["AGENT_WALLET_KEY"]),
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Without a framework
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from apexrunner_tools import ApexClient
|
|
83
|
+
|
|
84
|
+
with ApexClient(private_key=os.environ["AGENT_WALLET_KEY"]) as apex:
|
|
85
|
+
r = apex.call("slippage_forecast", coin="ETH", size_usd=5000, side="sell")
|
|
86
|
+
print(r.data["recommendation"], r.amount_usdc, r.transaction)
|
|
87
|
+
print(apex.budget.spent_usdc, apex.budget.remaining_usdc)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`acall()` is the async version.
|
|
91
|
+
|
|
92
|
+
## Wallet
|
|
93
|
+
|
|
94
|
+
Supply exactly one of these:
|
|
95
|
+
|
|
96
|
+
- `private_key="0x…"`. It is turned into an `eth_account` signer, which holds the key only so it can sign. The key never appears in any attribute, `repr()` or a tool's serialised form.
|
|
97
|
+
- `account=LocalAccount`, from `eth_account`.
|
|
98
|
+
- `signer=...`, any object with an `address` and `sign_typed_data(...)`. Use this for KMS, HSM or hosted wallets.
|
|
99
|
+
|
|
100
|
+
The wallet needs **USDC on Base mainnet**, and no ETH. Payments are signed USDC authorizations (EIP-3009), and the facilitator pays the gas. One dollar covers about 100 calls.
|
|
101
|
+
|
|
102
|
+
## Safeguards
|
|
103
|
+
|
|
104
|
+
| Safeguard | What it does |
|
|
105
|
+
|---|---|
|
|
106
|
+
| **Pinned payment terms** | Before anything is signed, every payment option is checked: it must be x402 v2 `exact` USDC on Base, payable to APEX Runner's pinned address. Anything else is refused and **nothing is signed** (`PaymentRefused`). This holds even if the server, DNS or a proxy is compromised. |
|
|
107
|
+
| **Per-call cap** | `max_payment_usdc`, default **$0.02** and never above $1.00. A higher price is refused before signing. |
|
|
108
|
+
| **Spend budget** | `budget_usdc`, default **$1.00** per client. It is checked at the moment of signing, every signature counts (including the SDK's automatic re-sign on recovery), and it is safe across threads. When the budget is spent, calls raise `BudgetExceeded` and nothing is signed. A signed payment counts as spent even if the call then fails: the budget never assumes a payment was not settled. |
|
|
109
|
+
| **Expiring cache** | Repeating an identical call while its data is still valid (the response's `valid_for_seconds` minus its age, at most 300 s) returns the cached result at no cost. Stale data is never cached. CrewAI's own cache has no expiry, so it is switched off for these tools. |
|
|
110
|
+
|
|
111
|
+
## Errors
|
|
112
|
+
|
|
113
|
+
All errors subclass `ApexError`:
|
|
114
|
+
|
|
115
|
+
| Error | Meaning | Charged? |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `PaymentRefused` | The payment request didn't match the pinned terms | No, nothing was signed |
|
|
118
|
+
| `BudgetExceeded` | The budget is spent | No, nothing was signed |
|
|
119
|
+
| `PaymentRejected` | A payment was signed but refused, usually too little USDC | No settlement |
|
|
120
|
+
| `ApexUnavailable` | The server returned a non-2xx status | x402 settles only on success |
|
|
121
|
+
| `ApexResponseError` | The server returned a 2xx with an unreadable body | Possibly; report it |
|
|
122
|
+
|
|
123
|
+
LangChain turns these into a tool message the model can read. CrewAI tools return `{"success": false, "error": ...}`.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
apexrunner_tools/__init__.py,sha256=plFzZpLHdOaqs_WfhbzpKbXeaBklB-ykgjls2k3fx8Q,752
|
|
2
|
+
apexrunner_tools/client.py,sha256=1RubhPH3QFo7lrRvEkjnbEkEX-z0zbaZIZqKIg_YeL8,10725
|
|
3
|
+
apexrunner_tools/constants.py,sha256=2FWL2thpd912-izTFeXZS5ARQM_Eo_6RgCPostEJ9dU,1152
|
|
4
|
+
apexrunner_tools/crewai.py,sha256=BgVdXUscRaUX10G9uYVocSg001QOsugLw109tFIeil4,2460
|
|
5
|
+
apexrunner_tools/errors.py,sha256=DG9MjdKaga9K0dE5WQ3383piEaamPB3TsnQlwLtAM7A,911
|
|
6
|
+
apexrunner_tools/langchain.py,sha256=wGpWnO6e9uywdCIhijd8c09Jmw_CcKaR9IK4ztAPHg4,2573
|
|
7
|
+
apexrunner_tools/payment.py,sha256=-wbNGVt3nlo8Qfwg_jk2CFVv2qgcQ_MhNGGqCdbAcjw,6977
|
|
8
|
+
apexrunner_tools/schemas.py,sha256=DOKMcEJjcKDomirj_YgOsbvLlRTyF-TY5axNZi-cqCw,856
|
|
9
|
+
apexrunner_tools/signals.py,sha256=QMTemtZMWLiVAXg1dQO6aStq8U5cQRaY1fm8r4IpWM4,3292
|
|
10
|
+
apexrunner_tools-0.1.0.dist-info/METADATA,sha256=NNC4l6Bt8Byhydm6wQKiIeX3zafgooUsreCzr3D-_xA,6007
|
|
11
|
+
apexrunner_tools-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
12
|
+
apexrunner_tools-0.1.0.dist-info/licenses/LICENSE,sha256=H2oaFd6oS8ncxyFUXpNsLXHRQwb_S7ewMH4dRxCuGCI,1068
|
|
13
|
+
apexrunner_tools-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 APEX Runner
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|