borro-live 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.
- borro_live/__init__.py +16 -0
- borro_live/__main__.py +56 -0
- borro_live/ai_hedge_fund.py +164 -0
- borro_live/broker.py +403 -0
- borro_live/chain.py +127 -0
- borro_live/mcp.py +84 -0
- borro_live/tradingagents.py +84 -0
- borro_live-0.1.0.dist-info/METADATA +212 -0
- borro_live-0.1.0.dist-info/RECORD +13 -0
- borro_live-0.1.0.dist-info/WHEEL +5 -0
- borro_live-0.1.0.dist-info/entry_points.txt +2 -0
- borro_live-0.1.0.dist-info/licenses/LICENSE +21 -0
- borro_live-0.1.0.dist-info/top_level.txt +1 -0
borro_live/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""borro-live: send a trading agent's decisions to Borro on Robinhood Chain.
|
|
2
|
+
|
|
3
|
+
from borro_live import Broker
|
|
4
|
+
broker = Broker.from_env() # dry run unless BORRO_LIVE=1
|
|
5
|
+
broker.buy("NVDA", 5) # 5 USDG
|
|
6
|
+
|
|
7
|
+
Adapters: ``borro_live.tradingagents`` and ``borro_live.ai_hedge_fund``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .broker import BorroLiveError, Broker, CapExceeded, Result, SimulationFailed, UnlistedTicker, Unsafe
|
|
11
|
+
from .chain import Chain, ChainError
|
|
12
|
+
from .mcp import McpClient, McpError, ToolRefused
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
__all__ = ["Broker", "Result", "McpClient", "Chain", "BorroLiveError", "UnlistedTicker", "CapExceeded", "SimulationFailed", "Unsafe",
|
|
16
|
+
"ChainError", "McpError", "ToolRefused"]
|
borro_live/__main__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""borro-live on the command line: one order, or the wallet's positions, printed as JSON.
|
|
2
|
+
|
|
3
|
+
borro-live tickers
|
|
4
|
+
borro-live positions
|
|
5
|
+
borro-live buy NVDA 5 a dry run: Borro's simulation, nothing sent
|
|
6
|
+
borro-live buy NVDA 5 --live sends, and only with BORRO_LIVE=1 in the environment as well
|
|
7
|
+
borro-live sell NVDA [percent] | short NVDA 5 | cover NVDA | lend USDG 5
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from .broker import BorroLiveError, Broker
|
|
17
|
+
from .chain import ChainError
|
|
18
|
+
from .mcp import McpClient, McpError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv: list[str] | None = None) -> int:
|
|
22
|
+
ap = argparse.ArgumentParser(prog="borro-live", description="One order on Borro, dry run unless --live and BORRO_LIVE=1.")
|
|
23
|
+
ap.add_argument("action", choices=["tickers", "positions", "buy", "sell", "short", "cover", "lend"])
|
|
24
|
+
ap.add_argument("symbol", nargs="?")
|
|
25
|
+
ap.add_argument("amount", nargs="?", type=float, help="USDG for buy and short; percent for sell; units for lend")
|
|
26
|
+
ap.add_argument("--live", action="store_true", help="send for real (also needs BORRO_LIVE=1)")
|
|
27
|
+
ap.add_argument("--wallet", help="an address, for dry runs without a key")
|
|
28
|
+
ap.add_argument("--builder-code")
|
|
29
|
+
ap.add_argument("--max-order", type=float)
|
|
30
|
+
ap.add_argument("--max-day", type=float)
|
|
31
|
+
a = ap.parse_args(argv)
|
|
32
|
+
try:
|
|
33
|
+
if a.action == "tickers": # a public read: no key and no wallet needed
|
|
34
|
+
print(json.dumps([s["symbol"] for s in McpClient().call("markets")["stocks"]], indent=1))
|
|
35
|
+
return 0
|
|
36
|
+
b = Broker(live=a.live, wallet=a.wallet, builder_code=a.builder_code, max_order_usdg=a.max_order, max_day_usdg=a.max_day)
|
|
37
|
+
if a.action == "positions":
|
|
38
|
+
out = {k: v for k, v in b.positions().items() if k != "raw"}
|
|
39
|
+
else:
|
|
40
|
+
if not a.symbol or (a.amount is None and a.action in ("buy", "short", "lend")):
|
|
41
|
+
ap.error(f"{a.action} needs a symbol" + (" and an amount" if a.action in ("buy", "short", "lend") else ""))
|
|
42
|
+
if a.action == "sell":
|
|
43
|
+
out = b.sell(a.symbol, a.amount if a.amount is not None else 100).to_dict()
|
|
44
|
+
elif a.action == "cover":
|
|
45
|
+
out = b.cover(a.symbol).to_dict()
|
|
46
|
+
else:
|
|
47
|
+
out = getattr(b, a.action)(a.symbol, a.amount).to_dict()
|
|
48
|
+
except (BorroLiveError, McpError, ChainError, ValueError) as e:
|
|
49
|
+
print(json.dumps({"ok": False, "error": str(e)}))
|
|
50
|
+
return 1
|
|
51
|
+
print(json.dumps(out, indent=1))
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
sys.exit(main())
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""ai-hedge-fund on Borro.
|
|
2
|
+
|
|
3
|
+
Two shapes, because the project has two:
|
|
4
|
+
|
|
5
|
+
* The portfolio manager's decisions, ``{ticker: {"action": "buy" | "sell" | "short" | "cover" | "hold",
|
|
6
|
+
"quantity": shares, ...}}`` (what ``run_hedge_fund`` returns under ``"decisions"``): ``execute_decisions``.
|
|
7
|
+
* The v2 engine's ``Broker`` protocol (``positions()``, ``cash()``, ``place_order(order)`` with buy and sell orders
|
|
8
|
+
on signed share counts): ``BorroBroker``.
|
|
9
|
+
|
|
10
|
+
ai-hedge-fund itself is never imported unless it is installed: plain dicts and objects work.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from .broker import BorroLiveError, Broker, Result
|
|
18
|
+
from .chain import ChainError
|
|
19
|
+
from .mcp import McpError
|
|
20
|
+
|
|
21
|
+
ACTIONS = ("buy", "sell", "short", "cover", "hold")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get(obj, name, default=None):
|
|
25
|
+
return obj.get(name, default) if isinstance(obj, dict) else getattr(obj, name, default)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _long_shares(pos: dict, symbol: str) -> float:
|
|
29
|
+
if not any(p.get("symbol") == symbol for p in pos["longs"]):
|
|
30
|
+
return 0.0
|
|
31
|
+
row = next((s for s in pos["stocks"] if s.get("symbol") == symbol), {})
|
|
32
|
+
return float((row.get("usdg_loan") or {}).get("posted_stock") or 0)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _short_shares(pos: dict, symbol: str) -> float:
|
|
36
|
+
return float(next((s.get("owed_stock") or 0 for s in pos["shorts"] if s.get("symbol") == symbol), 0))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def execute_decisions(broker: Broker, decisions, scale: float = 1.0) -> dict:
|
|
40
|
+
"""Execute the portfolio manager's decisions, ticker by ticker.
|
|
41
|
+
|
|
42
|
+
``decisions`` is the dict of decisions, the whole ``run_hedge_fund`` result, or the manager's output object.
|
|
43
|
+
``scale`` multiplies every quantity (0.01 turns 10 shares into 0.1), since the model sizes for a paper
|
|
44
|
+
portfolio and the caps here are in USDG. Returns ``{ticker: Result}``; a ticker this kit or Borro refused maps
|
|
45
|
+
to the reason as a string, and a hold to None, so one refusal never stops the others.
|
|
46
|
+
|
|
47
|
+
buy buy ``quantity`` shares' worth sell sell that share of the long
|
|
48
|
+
short short ``quantity`` shares' worth cover close the short (Borro closes a short whole)
|
|
49
|
+
"""
|
|
50
|
+
inner = _get(decisions, "decisions")
|
|
51
|
+
decisions = inner if inner is not None else decisions
|
|
52
|
+
if not isinstance(decisions, dict):
|
|
53
|
+
raise BorroLiveError("decisions must be a dict of ticker to {action, quantity}")
|
|
54
|
+
out: dict = {}
|
|
55
|
+
pos = None
|
|
56
|
+
for ticker, d in decisions.items():
|
|
57
|
+
symbol = str(ticker).strip().upper()
|
|
58
|
+
action = str(_get(d, "action", "hold")).strip().lower()
|
|
59
|
+
qty = float(_get(d, "quantity", 0) or 0) * scale
|
|
60
|
+
try:
|
|
61
|
+
if action not in ACTIONS:
|
|
62
|
+
raise BorroLiveError(f"unknown action {action}")
|
|
63
|
+
if action == "hold" or (qty <= 0 and action != "cover"):
|
|
64
|
+
out[symbol] = None
|
|
65
|
+
elif action == "buy":
|
|
66
|
+
out[symbol] = broker.buy(symbol, qty * broker.price(symbol))
|
|
67
|
+
elif action == "short":
|
|
68
|
+
out[symbol] = broker.short(symbol, qty * broker.price(symbol))
|
|
69
|
+
elif action == "cover":
|
|
70
|
+
out[symbol] = broker.cover(symbol)
|
|
71
|
+
else:
|
|
72
|
+
broker.price(symbol)
|
|
73
|
+
pos = pos or broker.positions()
|
|
74
|
+
held = _long_shares(pos, symbol)
|
|
75
|
+
if held <= 0:
|
|
76
|
+
raise BorroLiveError(f"no long on {symbol} to sell")
|
|
77
|
+
out[symbol] = broker.sell(symbol, max(1.0, min(100.0, 100.0 * qty / held)))
|
|
78
|
+
except (BorroLiveError, McpError, ChainError) as e:
|
|
79
|
+
out[symbol] = f"refused: {e}"
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class _Position:
|
|
85
|
+
ticker: str
|
|
86
|
+
shares: int
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class _Fill:
|
|
91
|
+
ticker: str
|
|
92
|
+
side: str
|
|
93
|
+
quantity: int
|
|
94
|
+
price: float
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _models():
|
|
98
|
+
try:
|
|
99
|
+
from hedge_fund.brokers.models import Fill, Position # the real ones when ai-hedge-fund is installed
|
|
100
|
+
return Position, Fill
|
|
101
|
+
except Exception:
|
|
102
|
+
return _Position, _Fill
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class DryRun(BorroLiveError):
|
|
106
|
+
"""place_order on a broker that is not live: nothing was sent. ``result`` holds Borro's simulation."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, result: Result):
|
|
109
|
+
super().__init__(f"dry run: {result.action} {result.symbol} was simulated ({'ok' if result.ok else 'it would fail'}) and not sent")
|
|
110
|
+
self.result = result
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class BorroBroker:
|
|
114
|
+
"""ai-hedge-fund's v2 ``Broker`` protocol over a live ``borro_live.Broker``.
|
|
115
|
+
|
|
116
|
+
Orders there are whole shares on a signed book: a sell past zero is a short, a buy toward zero a cover. On Borro
|
|
117
|
+
a buy is a 1.01x long and its share count is what the swap gave, so ``positions()`` rounds to whole shares. An
|
|
118
|
+
order fills completely or raises: one that would cross zero, or cover part of a short, is refused (Borro closes
|
|
119
|
+
a short whole). A dry-run broker raises ``DryRun`` with the simulation, since nothing was filled.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, broker: Broker):
|
|
123
|
+
self.broker = broker
|
|
124
|
+
self.results: list[Result] = []
|
|
125
|
+
|
|
126
|
+
def positions(self) -> dict:
|
|
127
|
+
Position, _ = _models()
|
|
128
|
+
pos = self.broker.positions()
|
|
129
|
+
book = {}
|
|
130
|
+
for symbol in {p["symbol"] for p in pos["longs"]} | {p["symbol"] for p in pos["shorts"]}:
|
|
131
|
+
shares = round(_long_shares(pos, symbol) - _short_shares(pos, symbol))
|
|
132
|
+
if shares:
|
|
133
|
+
book[symbol] = Position(ticker=symbol, shares=shares)
|
|
134
|
+
return book
|
|
135
|
+
|
|
136
|
+
def cash(self) -> float:
|
|
137
|
+
return float(self.broker.positions()["usdg"] or 0)
|
|
138
|
+
|
|
139
|
+
def place_order(self, order):
|
|
140
|
+
_, Fill = _models()
|
|
141
|
+
symbol, side, qty, ref = str(_get(order, "ticker")).upper(), _get(order, "side"), int(_get(order, "quantity")), float(_get(order, "price"))
|
|
142
|
+
if side not in ("buy", "sell") or qty <= 0:
|
|
143
|
+
raise BorroLiveError("an order is buy or sell of at least one share")
|
|
144
|
+
pos = self.broker.positions()
|
|
145
|
+
long_, short_ = _long_shares(pos, symbol), _short_shares(pos, symbol)
|
|
146
|
+
price = self.broker.price(symbol)
|
|
147
|
+
if side == "buy" and short_ > 0:
|
|
148
|
+
if qty != round(short_):
|
|
149
|
+
raise BorroLiveError(f"Borro closes a short whole: buy {round(short_)} {symbol} to cover it, then buy more")
|
|
150
|
+
res = self.broker.cover(symbol)
|
|
151
|
+
elif side == "buy":
|
|
152
|
+
res = self.broker.buy(symbol, qty * price)
|
|
153
|
+
elif long_ > 0:
|
|
154
|
+
if qty > round(long_):
|
|
155
|
+
raise BorroLiveError(f"sell the {round(long_)} {symbol} held first, then short")
|
|
156
|
+
res = self.broker.sell(symbol, min(100.0, 100.0 * qty / long_))
|
|
157
|
+
else:
|
|
158
|
+
res = self.broker.short(symbol, qty * price)
|
|
159
|
+
self.results.append(res)
|
|
160
|
+
if not res.sent:
|
|
161
|
+
raise DryRun(res)
|
|
162
|
+
q = res.quote
|
|
163
|
+
filled = float(q["size_usdg"]) / float(q["stock"]) if q.get("size_usdg") and q.get("stock") else float(q.get("price_usd") or price or ref)
|
|
164
|
+
return Fill(ticker=symbol, side=side, quantity=qty, price=filled)
|
borro_live/broker.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""The broker: buy, sell, short, cover, lend and positions on Borro, with the safety rules in one place.
|
|
2
|
+
|
|
3
|
+
Nothing is sent unless the broker was made with ``live=True`` AND the environment has ``BORRO_LIVE=1``. Without
|
|
4
|
+
both, every order is a dry run: it returns Borro's own simulation of each step and sends nothing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from decimal import ROUND_DOWN, Decimal
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .chain import CHAIN_ID, Chain, ChainError, account_from_key, calldata
|
|
17
|
+
from .mcp import BorroLiveError, McpClient, ToolRefused
|
|
18
|
+
|
|
19
|
+
USDG = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168"
|
|
20
|
+
# BorroBuilders: deposits made through it with a registered code are credited to that code (Bookrunners).
|
|
21
|
+
BORRO_BUILDERS = "0x082Ae609bdC7ccEec0C58B2361a21c49dc2aE291"
|
|
22
|
+
APPROVE = "0x095ea7b3"
|
|
23
|
+
|
|
24
|
+
DEFAULT_MAX_ORDER_USDG = 10.0
|
|
25
|
+
DEFAULT_MAX_DAY_USDG = 25.0
|
|
26
|
+
# buy() is a long at the lowest leverage that makes sense on Borro: the order's USDG plus 1% borrowed.
|
|
27
|
+
DEFAULT_LEVERAGE = 1.01
|
|
28
|
+
# short() deposits this much USDG for each USDG of stock sold, on top of the sale money Borro holds.
|
|
29
|
+
DEFAULT_SHORT_DEPOSIT = 1.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UnlistedTicker(BorroLiveError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CapExceeded(BorroLiveError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SimulationFailed(BorroLiveError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Unsafe(BorroLiveError):
|
|
45
|
+
"""The prepared transactions are not what was asked for (wrong chain, wrong wallet, value, or an approval too big)."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Result:
|
|
50
|
+
"""What one order did. ``sent`` is False for a dry run; ``ok`` is the simulation's verdict."""
|
|
51
|
+
|
|
52
|
+
action: str
|
|
53
|
+
symbol: str
|
|
54
|
+
usdg: float | None
|
|
55
|
+
live: bool
|
|
56
|
+
sent: bool
|
|
57
|
+
ok: bool
|
|
58
|
+
tool: str
|
|
59
|
+
quote: dict = field(default_factory=dict)
|
|
60
|
+
simulation: dict = field(default_factory=dict)
|
|
61
|
+
transactions: list = field(default_factory=list)
|
|
62
|
+
note: str | None = None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def hashes(self) -> list[str]:
|
|
66
|
+
return [t["hash"] for t in self.transactions if t.get("hash")]
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict:
|
|
69
|
+
return asdict(self)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _dec(x, places: int) -> str:
|
|
73
|
+
"""A decimal string in whole units, cut (never rounded up) to the token's places."""
|
|
74
|
+
d = Decimal(str(x)).quantize(Decimal(1).scaleb(-places), rounding=ROUND_DOWN)
|
|
75
|
+
return format(d.normalize(), "f") if d else "0"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _env_float(name: str, default: float) -> float:
|
|
79
|
+
raw = os.environ.get(name)
|
|
80
|
+
if raw is None or raw.strip() == "":
|
|
81
|
+
return default
|
|
82
|
+
try:
|
|
83
|
+
v = float(raw)
|
|
84
|
+
except ValueError:
|
|
85
|
+
raise BorroLiveError(f"{name} must be a number of USDG") from None
|
|
86
|
+
if not v > 0:
|
|
87
|
+
raise BorroLiveError(f"{name} must be more than 0")
|
|
88
|
+
return v
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Broker:
|
|
92
|
+
"""One wallet's desk on Borro.
|
|
93
|
+
|
|
94
|
+
key the agent's private key (default: the BORRO_AGENT_KEY environment variable). Never printed,
|
|
95
|
+
logged or stored. Without a key, give ``wallet`` (an address) for dry runs only.
|
|
96
|
+
rpc_url your own Robinhood Chain RPC (default: BORRO_RPC_URL). There is no built-in one.
|
|
97
|
+
live True to send. It also needs BORRO_LIVE=1 in the environment, or the broker refuses to start.
|
|
98
|
+
max_order_usdg the most one order may be worth (default 10, or BORRO_MAX_ORDER_USDG).
|
|
99
|
+
max_day_usdg the most all orders of one UTC day may be worth (default 25, or BORRO_MAX_DAY_USDG).
|
|
100
|
+
builder_code a Bookrunner code. When set, the wallet is bound to it once and lends go through BorroBuilders,
|
|
101
|
+
so the code's owner earns a share of Borro's fees. No default.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, key: str | None = None, rpc_url: str | None = None, *, live: bool = False, wallet: str | None = None,
|
|
105
|
+
max_order_usdg: float | None = None, max_day_usdg: float | None = None, builder_code: str | None = None,
|
|
106
|
+
leverage: float = DEFAULT_LEVERAGE, short_deposit: float = DEFAULT_SHORT_DEPOSIT,
|
|
107
|
+
mcp: McpClient | None = None, chain: Chain | None = None, state_path: str | os.PathLike | None = None):
|
|
108
|
+
key = key or os.environ.get("BORRO_AGENT_KEY")
|
|
109
|
+
self._account = account_from_key(key) if key else None
|
|
110
|
+
wallet = wallet or os.environ.get("BORRO_WALLET") or None
|
|
111
|
+
if self._account is None and not wallet:
|
|
112
|
+
raise BorroLiveError("set BORRO_AGENT_KEY (the agent's own key), or for a dry run without a key set BORRO_WALLET (or give wallet=) to an address")
|
|
113
|
+
self.wallet = self._account.address if self._account else wallet
|
|
114
|
+
rpc_url = rpc_url or os.environ.get("BORRO_RPC_URL")
|
|
115
|
+
self.chain = chain or (Chain(rpc_url) if rpc_url else None)
|
|
116
|
+
if live:
|
|
117
|
+
if os.environ.get("BORRO_LIVE") != "1":
|
|
118
|
+
raise BorroLiveError("live=True also needs BORRO_LIVE=1 in the environment; without both, orders are dry runs")
|
|
119
|
+
if self._account is None:
|
|
120
|
+
raise BorroLiveError("live orders need the agent's key (BORRO_AGENT_KEY)")
|
|
121
|
+
if self.chain is None:
|
|
122
|
+
raise BorroLiveError("live orders need your own Robinhood Chain RPC (BORRO_RPC_URL)")
|
|
123
|
+
self.live = bool(live)
|
|
124
|
+
self.max_order_usdg = float(max_order_usdg) if max_order_usdg is not None else _env_float("BORRO_MAX_ORDER_USDG", DEFAULT_MAX_ORDER_USDG)
|
|
125
|
+
self.max_day_usdg = float(max_day_usdg) if max_day_usdg is not None else _env_float("BORRO_MAX_DAY_USDG", DEFAULT_MAX_DAY_USDG)
|
|
126
|
+
if not (self.max_order_usdg > 0 and self.max_day_usdg > 0):
|
|
127
|
+
raise BorroLiveError("the caps must be more than 0")
|
|
128
|
+
self.builder_code = (builder_code or "").strip().lower() or None
|
|
129
|
+
if self.builder_code and not (self.builder_code.isascii() and len(self.builder_code) <= 31 and self.builder_code.replace("-", "").replace("_", "").isalnum()):
|
|
130
|
+
raise BorroLiveError("a builder code is up to 31 letters, digits, - or _")
|
|
131
|
+
if not (1 < leverage <= 1.35):
|
|
132
|
+
raise BorroLiveError("leverage for buy() goes from just over 1 to 1.35")
|
|
133
|
+
if not short_deposit >= 0.5:
|
|
134
|
+
raise BorroLiveError("short_deposit must be at least 0.5 (USDG deposited for each USDG shorted)")
|
|
135
|
+
self.leverage = leverage
|
|
136
|
+
self.short_deposit = short_deposit
|
|
137
|
+
self.mcp = mcp or McpClient()
|
|
138
|
+
self._state_path = Path(state_path or os.environ.get("BORRO_LIVE_STATE") or Path.home() / ".borro-live" / "state.json")
|
|
139
|
+
self._markets: tuple[float, dict] | None = None
|
|
140
|
+
|
|
141
|
+
def __repr__(self) -> str:
|
|
142
|
+
return f"Broker(wallet={self.wallet}, live={self.live}, max_order_usdg={self.max_order_usdg}, max_day_usdg={self.max_day_usdg})"
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def from_env(cls, **kw) -> "Broker":
|
|
146
|
+
"""BORRO_AGENT_KEY (or BORRO_WALLET, an address, for a dry run without a key), BORRO_RPC_URL and BORRO_LIVE; live when BORRO_LIVE=1 unless live= says otherwise."""
|
|
147
|
+
kw.setdefault("live", os.environ.get("BORRO_LIVE") == "1")
|
|
148
|
+
kw.setdefault("builder_code", os.environ.get("BORRO_BUILDER_CODE") or None)
|
|
149
|
+
return cls(**kw)
|
|
150
|
+
|
|
151
|
+
# ---- reads -------------------------------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def markets(self) -> dict:
|
|
154
|
+
"""Borro's shelf, kept for a minute."""
|
|
155
|
+
if self._markets is None or time.time() - self._markets[0] > 60:
|
|
156
|
+
self._markets = (time.time(), self.mcp.call("markets"))
|
|
157
|
+
return self._markets[1]
|
|
158
|
+
|
|
159
|
+
def tickers(self) -> list[str]:
|
|
160
|
+
return [s["symbol"] for s in self.markets()["stocks"]]
|
|
161
|
+
|
|
162
|
+
def _stock(self, symbol: str) -> dict:
|
|
163
|
+
sym = str(symbol or "").strip().upper()
|
|
164
|
+
for s in self.markets()["stocks"]:
|
|
165
|
+
if s["symbol"] == sym:
|
|
166
|
+
return s
|
|
167
|
+
raise UnlistedTicker(f"{sym or 'that ticker'} is not on Borro; listed: {', '.join(self.tickers())}")
|
|
168
|
+
|
|
169
|
+
def price(self, symbol: str) -> float:
|
|
170
|
+
return float(self._stock(symbol)["price_usd"])
|
|
171
|
+
|
|
172
|
+
def positions(self) -> dict:
|
|
173
|
+
"""What the wallet has on Borro, from the ``position`` tool: longs, shorts, what is lent, and its balances."""
|
|
174
|
+
p = self.mcp.call("position", {"wallet": self.wallet})
|
|
175
|
+
stocks = p.get("stocks") or []
|
|
176
|
+
return {
|
|
177
|
+
"wallet": p.get("wallet", self.wallet),
|
|
178
|
+
"eth": (p.get("wallet_balances") or {}).get("eth"),
|
|
179
|
+
"usdg": (p.get("wallet_balances") or {}).get("usdg"),
|
|
180
|
+
"longs": p.get("leveraged_longs") or [],
|
|
181
|
+
"shorts": [{"symbol": s["symbol"], **s["short"]} for s in stocks if s.get("short")],
|
|
182
|
+
"lent_usdg": (p.get("usdg_lent") or {}).get("usdg", 0),
|
|
183
|
+
"stocks": stocks,
|
|
184
|
+
"raw": p,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# ---- orders ------------------------------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def buy(self, symbol: str, usdg: float) -> Result:
|
|
190
|
+
"""Buy ``usdg`` worth of a stock token.
|
|
191
|
+
|
|
192
|
+
Borro's tools have no plain spot buy for USDG, so this is the nearest thing: a long at 1.01x (``open_long``).
|
|
193
|
+
The USDG buys the stock token, the token is lent (it earns lending fees) and posted, and 1% of the order is
|
|
194
|
+
borrowed against it. ``sell`` takes it back to USDG.
|
|
195
|
+
"""
|
|
196
|
+
s = self._stock(symbol)
|
|
197
|
+
return self._order("buy", s["symbol"], usdg, "open_long", {"symbol": s["symbol"], "margin": _dec(usdg, 6), "leverage": self.leverage})
|
|
198
|
+
|
|
199
|
+
def sell(self, symbol: str, percent: float = 100) -> Result:
|
|
200
|
+
"""Sell all or part of the long ``buy`` opened: the stock is sold, the small debt repaid, USDG comes back."""
|
|
201
|
+
s = self._stock(symbol)
|
|
202
|
+
if not (1 <= percent <= 100):
|
|
203
|
+
raise BorroLiveError("percent must be from 1 to 100")
|
|
204
|
+
return self._order("sell", s["symbol"], None, "close_long", {"symbol": s["symbol"], "percent": round(float(percent), 2)})
|
|
205
|
+
|
|
206
|
+
def short(self, symbol: str, usdg: float) -> Result:
|
|
207
|
+
"""Short ``usdg`` worth of a stock: borrow it, sell it, hold the money plus a USDG deposit (``open_short``).
|
|
208
|
+
|
|
209
|
+
A short can be liquidated: if the stock rises far enough it is closed for you at a loss. The deposit is
|
|
210
|
+
``short_deposit`` USDG per USDG shorted (default 1), never under the least Borro asks.
|
|
211
|
+
"""
|
|
212
|
+
s = self._stock(symbol)
|
|
213
|
+
self._check_caps(usdg)
|
|
214
|
+
amount = _dec(Decimal(str(usdg)) / Decimal(str(s["price_usd"])), 18)
|
|
215
|
+
deposit = float(usdg) * self.short_deposit
|
|
216
|
+
args = {"symbol": s["symbol"], "amount": amount, "deposit": _dec(deposit, 2)}
|
|
217
|
+
try:
|
|
218
|
+
least = float(self.mcp.call("open_short", {"wallet": self.wallet, **args})["quote"]["deposit_at_least_usdg"])
|
|
219
|
+
except ToolRefused:
|
|
220
|
+
least = 0.0 # the order below gets the refusal and reports it
|
|
221
|
+
if least * 1.1 > deposit:
|
|
222
|
+
args["deposit"] = _dec(least * 1.1, 2)
|
|
223
|
+
return self._order("short", s["symbol"], usdg, "open_short", args, usdg_out=float(args["deposit"]))
|
|
224
|
+
|
|
225
|
+
def cover(self, symbol: str) -> Result:
|
|
226
|
+
"""Close the wallet's short on a stock, all of it (``close_short``): Borro has one short per stock."""
|
|
227
|
+
s = self._stock(symbol)
|
|
228
|
+
return self._order("cover", s["symbol"], None, "close_short", {"symbol": s["symbol"]})
|
|
229
|
+
|
|
230
|
+
def lend(self, asset: str, amount: float) -> Result:
|
|
231
|
+
"""Lend USDG (it earns interest) or a stock token the wallet holds (it earns lending fees): ``supply``.
|
|
232
|
+
|
|
233
|
+
``amount`` is in the asset's own units: USDG, or shares of the stock. With a builder code, the deposit goes
|
|
234
|
+
through BorroBuilders instead, so the code is credited.
|
|
235
|
+
"""
|
|
236
|
+
a = str(asset or "").strip().upper()
|
|
237
|
+
if a == "USDG":
|
|
238
|
+
value, stock = float(amount), None
|
|
239
|
+
else:
|
|
240
|
+
stock = self._stock(a)
|
|
241
|
+
value = float(amount) * float(stock["price_usd"])
|
|
242
|
+
if self.builder_code:
|
|
243
|
+
return self._lend_with_code(a, stock, amount, value)
|
|
244
|
+
asked = _dec(amount, 6 if stock is None else 18)
|
|
245
|
+
out = self._order("lend", a, value, "supply", {"asset": a, "amount": asked}, usdg_out=value if stock is None else 0)
|
|
246
|
+
if Decimal(str(out.quote.get("amount", asked))) < Decimal(asked):
|
|
247
|
+
out.note = f"Borro cut this lend to the {out.quote['amount']} {a} the wallet holds"
|
|
248
|
+
return out
|
|
249
|
+
|
|
250
|
+
def buy_spot_with_eth(self, symbol: str, shares: float) -> Result:
|
|
251
|
+
"""A plain spot buy, paid in the wallet's ETH (``buy_with_eth``): the stock token lands in the wallet.
|
|
252
|
+
|
|
253
|
+
No Borro tool sells a token held in the wallet back, so what this buys can be lent with ``lend`` or sold
|
|
254
|
+
elsewhere; ``sell`` only closes longs opened by ``buy``.
|
|
255
|
+
"""
|
|
256
|
+
s = self._stock(symbol)
|
|
257
|
+
return self._order("buy_spot_with_eth", s["symbol"], float(shares) * float(s["price_usd"]), "buy_with_eth",
|
|
258
|
+
{"want": s["symbol"], "amount": _dec(shares, 18)}, pays_eth=True)
|
|
259
|
+
|
|
260
|
+
# ---- the one path every order takes ------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
def _order(self, action: str, symbol: str, usdg: float | None, tool: str, args: dict, *, usdg_out: float | None = None, pays_eth: bool = False) -> Result:
|
|
263
|
+
if usdg is not None:
|
|
264
|
+
self._check_caps(usdg)
|
|
265
|
+
note = self._bind() if usdg is not None and self.builder_code else None
|
|
266
|
+
prep = self.mcp.call(tool, {"wallet": self.wallet, **args})
|
|
267
|
+
if prep.get("live") is False:
|
|
268
|
+
raise BorroLiveError(prep.get("note") or f"{tool} is not live on Borro")
|
|
269
|
+
max_wei = int(Decimal(str((prep.get("quote") or {}).get("eth_at_most", "0"))).scaleb(18)) if pays_eth else 0
|
|
270
|
+
self._check_prepared(prep, usdg if usdg_out is None else usdg_out, max_wei)
|
|
271
|
+
return self._finish(action, symbol, usdg, tool, prep.get("quote") or {k: prep[k] for k in ("asset", "amount", "mode") if k in prep},
|
|
272
|
+
prep.get("simulation") or {}, prep["transactions"], note)
|
|
273
|
+
|
|
274
|
+
def _finish(self, action, symbol, usdg, tool, quote, simulation, txs, note) -> Result:
|
|
275
|
+
steps = simulation.get("steps") or []
|
|
276
|
+
ok = simulation.get("ok") is True and len(steps) == len(txs) and all(s.get("ok") is True for s in steps)
|
|
277
|
+
out = Result(action=action, symbol=symbol, usdg=usdg, live=self.live, sent=False, ok=ok, tool=tool, quote=quote, simulation=simulation, note=note,
|
|
278
|
+
transactions=[{"step": t.get("step", i + 1), "kind": t.get("kind"), "description": t.get("description"), "to": t["to"]} for i, t in enumerate(txs)])
|
|
279
|
+
if not self.live:
|
|
280
|
+
return out
|
|
281
|
+
if not ok:
|
|
282
|
+
why = "; ".join(f"step {s.get('step')}: {s.get('error', 'failed')}" for s in steps if s.get("ok") is not True) or simulation.get("error") or "no passing simulation"
|
|
283
|
+
raise SimulationFailed(f"{action} {symbol}: the simulation failed, nothing was sent ({why})")
|
|
284
|
+
if usdg is not None:
|
|
285
|
+
self._spend(usdg)
|
|
286
|
+
for row, t in zip(out.transactions, txs):
|
|
287
|
+
row.update(self.chain.send(self._account, t))
|
|
288
|
+
out.sent = True
|
|
289
|
+
return out
|
|
290
|
+
|
|
291
|
+
def _check_prepared(self, prep: dict, usdg_out: float | None, max_wei: int) -> None:
|
|
292
|
+
txs = prep.get("transactions")
|
|
293
|
+
if not isinstance(txs, list) or not txs:
|
|
294
|
+
raise Unsafe("the tool returned no transactions")
|
|
295
|
+
if prep.get("chainId") != CHAIN_ID or str(prep.get("from", "")).lower() != self.wallet.lower():
|
|
296
|
+
raise Unsafe("the transactions are not for this wallet on Robinhood Chain")
|
|
297
|
+
for t in txs:
|
|
298
|
+
if t.get("chainId") != CHAIN_ID or not str(t.get("to", "")).startswith("0x") or not str(t.get("data", "")).startswith("0x"):
|
|
299
|
+
raise Unsafe("a transaction is malformed or for another chain")
|
|
300
|
+
if int(t.get("value", 0)) > max_wei:
|
|
301
|
+
raise Unsafe("a transaction sends more ETH than this order's quote")
|
|
302
|
+
# A USDG approval may never be for more than the order itself (a little over, for rounding).
|
|
303
|
+
if usdg_out is not None and t["to"].lower() == USDG.lower() and t["data"].startswith(APPROVE):
|
|
304
|
+
if int(t["data"][74:138], 16) > int(usdg_out * 1.02 * 1e6) + 10_000:
|
|
305
|
+
raise Unsafe("a USDG approval is for more than this order")
|
|
306
|
+
|
|
307
|
+
# ---- caps --------------------------------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def _state(self) -> dict:
|
|
310
|
+
try:
|
|
311
|
+
return json.loads(self._state_path.read_text())
|
|
312
|
+
except (OSError, ValueError):
|
|
313
|
+
return {}
|
|
314
|
+
|
|
315
|
+
def _save(self, state: dict) -> None:
|
|
316
|
+
self._state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
tmp = self._state_path.with_suffix(".tmp")
|
|
318
|
+
tmp.write_text(json.dumps(state, indent=1))
|
|
319
|
+
tmp.replace(self._state_path)
|
|
320
|
+
|
|
321
|
+
def spent_today(self) -> float:
|
|
322
|
+
day = (self._state().get("days") or {}).get(self.wallet.lower()) or {}
|
|
323
|
+
return float(day.get("usdg", 0)) if day.get("day") == time.strftime("%Y-%m-%d", time.gmtime()) else 0.0
|
|
324
|
+
|
|
325
|
+
def _check_caps(self, usdg: float) -> None:
|
|
326
|
+
usdg = float(usdg)
|
|
327
|
+
if not usdg > 0:
|
|
328
|
+
raise BorroLiveError("the order size must be more than 0 USDG")
|
|
329
|
+
if usdg > self.max_order_usdg + 1e-9:
|
|
330
|
+
raise CapExceeded(f"{usdg:g} USDG is over the per-order cap of {self.max_order_usdg:g} (max_order_usdg)")
|
|
331
|
+
if self.spent_today() + usdg > self.max_day_usdg + 1e-9:
|
|
332
|
+
raise CapExceeded(f"{usdg:g} USDG would pass today's cap of {self.max_day_usdg:g} ({self.spent_today():g} already sent; max_day_usdg, UTC day)")
|
|
333
|
+
|
|
334
|
+
def _spend(self, usdg: float) -> None:
|
|
335
|
+
"""Counted before the first transaction goes out, so a crash mid-order never frees room under the cap."""
|
|
336
|
+
state = self._state()
|
|
337
|
+
state.setdefault("days", {})[self.wallet.lower()] = {"day": time.strftime("%Y-%m-%d", time.gmtime()), "usdg": round(self.spent_today() + float(usdg), 6)}
|
|
338
|
+
self._save(state)
|
|
339
|
+
|
|
340
|
+
# ---- builder code ------------------------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
def _bind(self) -> str:
|
|
343
|
+
"""Bind the wallet to the builder code, once: one EIP-712 signature through ``bind_builder``, no transaction."""
|
|
344
|
+
code, me = self.builder_code, self.wallet
|
|
345
|
+
state = self._state()
|
|
346
|
+
if (state.get("bound") or {}).get(me.lower()):
|
|
347
|
+
return f"credited to builder code {state['bound'][me.lower()]}"
|
|
348
|
+
first = self.mcp.call("bind_builder", {"wallet": me, "code": code})
|
|
349
|
+
if not first.get("bound"):
|
|
350
|
+
if not self.live:
|
|
351
|
+
return f"a live order would first bind this wallet to builder code {code} (one signature, no gas)"
|
|
352
|
+
td = first.get("typed_data") or {}
|
|
353
|
+
msg, dom = td.get("message") or {}, td.get("domain") or {}
|
|
354
|
+
# Sign only the one statement this is for, whatever the server sent.
|
|
355
|
+
if not (td.get("primaryType") == "BuilderBinding" and dom == {"name": "Borro Builders", "version": "1", "chainId": CHAIN_ID}
|
|
356
|
+
and str(msg.get("account", "")).lower() == me.lower() and msg.get("code") == code and msg.get("chainId") == CHAIN_ID
|
|
357
|
+
and msg.get("statement") == f"Credit my Borro borrowing to builder code {code}" and set(msg) == {"statement", "account", "code", "chainId", "timestamp"}):
|
|
358
|
+
raise Unsafe("the binding to sign is not the builder binding for this wallet and code")
|
|
359
|
+
sig = self._account.sign_typed_data(full_message=td).signature.hex()
|
|
360
|
+
first = self.mcp.call("bind_builder", {"wallet": me, "code": code, "timestamp": msg["timestamp"], "signature": sig if sig.startswith("0x") else "0x" + sig})
|
|
361
|
+
bound = ((first.get("binding") or {}).get("code") or code) if first.get("bound") else None
|
|
362
|
+
if not bound:
|
|
363
|
+
raise BorroLiveError(f"the wallet could not be bound to builder code {code}")
|
|
364
|
+
state = self._state()
|
|
365
|
+
state.setdefault("bound", {})[me.lower()] = bound
|
|
366
|
+
self._save(state)
|
|
367
|
+
return f"credited to builder code {bound}" + ("" if bound == code else f" (the wallet was bound to it first; {code} is not credited)")
|
|
368
|
+
|
|
369
|
+
def _lend_with_code(self, asset: str, stock: dict | None, amount: float, value: float) -> Result:
|
|
370
|
+
"""A lend through BorroBuilders.deposit(vault, assets, code, minShares): the wallet keeps the vault shares."""
|
|
371
|
+
if self.chain is None:
|
|
372
|
+
raise BorroLiveError("lending with a builder code reads the chain: set BORRO_RPC_URL")
|
|
373
|
+
self._check_caps(value)
|
|
374
|
+
token = USDG if stock is None else stock["contracts"]["token"]
|
|
375
|
+
vault = self.markets()["usdg"]["vault"] if stock is None else stock["contracts"]["vault"]
|
|
376
|
+
units = int(Decimal(_dec(amount, 6 if stock is None else 18)).scaleb(6 if stock is None else 18))
|
|
377
|
+
code32 = self.builder_code.encode().ljust(32, b"\0")
|
|
378
|
+
c, me = self.chain, self.wallet
|
|
379
|
+
if int.from_bytes(c.call(BORRO_BUILDERS, calldata("builders(bytes32)", ["bytes32"], [code32]))[-20:], "big") == 0:
|
|
380
|
+
raise BorroLiveError(f"no one has registered the builder code {self.builder_code}")
|
|
381
|
+
if not c.read(BORRO_BUILDERS, "vaults(address)", ["address"], [vault], ["bool"])[0]:
|
|
382
|
+
out = self._order("lend", asset, value, "supply", {"asset": asset, "amount": _dec(amount, 6 if stock is None else 18)}, usdg_out=value if stock is None else 0)
|
|
383
|
+
out.note = f"BorroBuilders does not list the {asset} vault yet, so this lend went straight to the vault and is not credited to {self.builder_code}"
|
|
384
|
+
return out
|
|
385
|
+
if c.read(token, "balanceOf(address)", ["address"], [me], ["uint256"])[0] < units:
|
|
386
|
+
raise BorroLiveError(f"the wallet holds less than {amount:g} {asset}")
|
|
387
|
+
note = self._bind()
|
|
388
|
+
txs = []
|
|
389
|
+
if c.read(token, "allowance(address,address)", ["address", "address"], [me, BORRO_BUILDERS], ["uint256"])[0] < units:
|
|
390
|
+
txs.append({"kind": "approve", "description": "Approve BorroBuilders to take exactly what this lends", "to": token,
|
|
391
|
+
"data": calldata("approve(address,uint256)", ["address", "uint256"], [BORRO_BUILDERS, units]), "value": "0", "chainId": CHAIN_ID})
|
|
392
|
+
min_shares = c.read(vault, "previewDeposit(uint256)", ["uint256"], [units], ["uint256"])[0] * 995 // 1000
|
|
393
|
+
txs.append({"kind": "call", "description": f"Lend {amount:g} {asset} through BorroBuilders with the code {self.builder_code}", "to": BORRO_BUILDERS,
|
|
394
|
+
"data": calldata("deposit(address,uint256,bytes32,uint256)", ["address", "uint256", "bytes32", "uint256"], [vault, units, code32, min_shares]), "value": "0", "chainId": CHAIN_ID})
|
|
395
|
+
for i, t in enumerate(txs):
|
|
396
|
+
t["step"] = i + 1
|
|
397
|
+
steps = c.simulate(me, txs)
|
|
398
|
+
sim = {"method": "eth_simulateV1", "ok": bool(steps) and all(s["ok"] for s in steps), "steps": steps or [],
|
|
399
|
+
**({} if steps is not None else {"error": "this RPC does not offer eth_simulateV1, so the steps could not be simulated together"})}
|
|
400
|
+
return self._finish("lend", asset, value, "BorroBuilders.deposit", {"asset": asset, "amount": _dec(amount, 6 if stock is None else 18), "builder_code": self.builder_code}, sim, txs, note)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
__all__ = ["Broker", "Result", "BorroLiveError", "UnlistedTicker", "CapExceeded", "SimulationFailed", "Unsafe", "ChainError", "BORRO_BUILDERS", "USDG"]
|
borro_live/chain.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""The chain side: reads through the user's own RPC, and signing and sending with the agent's own key."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from eth_abi import decode, encode
|
|
8
|
+
from eth_account import Account
|
|
9
|
+
from eth_utils import keccak, to_checksum_address
|
|
10
|
+
|
|
11
|
+
from .mcp import McpError, http_post
|
|
12
|
+
|
|
13
|
+
CHAIN_ID = 4663 # Robinhood Chain
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ChainError(Exception):
|
|
17
|
+
"""The RPC refused a call, or a transaction did not go through."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Reverted(ChainError):
|
|
21
|
+
"""A call or a gas estimate reverted: the transaction would fail, so it is not sent."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def selector(signature: str) -> bytes:
|
|
25
|
+
return keccak(text=signature)[:4]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def calldata(signature: str, types: list[str], values: list) -> str:
|
|
29
|
+
return "0x" + (selector(signature) + encode(types, values)).hex()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Chain:
|
|
33
|
+
"""JSON-RPC to the user's own Robinhood Chain endpoint. The address is kept private and never printed."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, rpc_url: str, timeout: float = 60, transport=None):
|
|
36
|
+
self.__url = rpc_url
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
self._transport = transport or (lambda payload: http_post(self.__url, payload, self.timeout, "the RPC"))
|
|
39
|
+
self._id = 0
|
|
40
|
+
self._checked = False
|
|
41
|
+
|
|
42
|
+
def __repr__(self) -> str:
|
|
43
|
+
return "Chain(<your RPC>)"
|
|
44
|
+
|
|
45
|
+
def rpc(self, method: str, params: list | None = None):
|
|
46
|
+
self._id += 1
|
|
47
|
+
try:
|
|
48
|
+
res = self._transport({"jsonrpc": "2.0", "id": self._id, "method": method, "params": params or []})
|
|
49
|
+
except McpError as e:
|
|
50
|
+
raise ChainError(str(e)) from None
|
|
51
|
+
if "error" in res:
|
|
52
|
+
msg = str((res["error"] or {}).get("message", "error"))[:300]
|
|
53
|
+
raise (Reverted if "revert" in msg.lower() else ChainError)(f"{method}: {msg}")
|
|
54
|
+
return res.get("result")
|
|
55
|
+
|
|
56
|
+
def check_chain(self) -> None:
|
|
57
|
+
if not self._checked:
|
|
58
|
+
got = int(self.rpc("eth_chainId"), 16)
|
|
59
|
+
if got != CHAIN_ID:
|
|
60
|
+
raise ChainError(f"the RPC is on chain {got}, not Robinhood Chain ({CHAIN_ID})")
|
|
61
|
+
self._checked = True
|
|
62
|
+
|
|
63
|
+
def call(self, to: str, data: str, sender: str | None = None, value: int = 0) -> bytes:
|
|
64
|
+
tx = {"to": to, "data": data}
|
|
65
|
+
if sender:
|
|
66
|
+
tx["from"] = sender
|
|
67
|
+
if value:
|
|
68
|
+
tx["value"] = hex(value)
|
|
69
|
+
return bytes.fromhex(self.rpc("eth_call", [tx, "latest"])[2:])
|
|
70
|
+
|
|
71
|
+
def read(self, to: str, signature: str, types: list[str], values: list, out: list[str]):
|
|
72
|
+
return decode(out, self.call(to, calldata(signature, types, values)))
|
|
73
|
+
|
|
74
|
+
def simulate(self, sender: str, txs: list[dict]) -> list[dict] | None:
|
|
75
|
+
"""Every step in order from the sender (eth_simulateV1). None when the RPC does not offer it."""
|
|
76
|
+
calls = [{"from": sender, "to": t["to"], "data": t["data"], **({"value": hex(int(t["value"]))} if int(t.get("value", 0)) else {})} for t in txs]
|
|
77
|
+
try:
|
|
78
|
+
out = self.rpc("eth_simulateV1", [{"blockStateCalls": [{"calls": calls}], "validation": False}, "latest"])
|
|
79
|
+
except ChainError:
|
|
80
|
+
return None
|
|
81
|
+
steps = []
|
|
82
|
+
for i, c in enumerate(out[0]["calls"]):
|
|
83
|
+
ok = c.get("status") == "0x1"
|
|
84
|
+
steps.append({"step": i + 1, "ok": ok, **({} if ok else {"error": str((c.get("error") or {}).get("message", "reverted"))[:200]})})
|
|
85
|
+
return steps
|
|
86
|
+
|
|
87
|
+
def send(self, account, tx: dict, wait_seconds: float = 180) -> dict:
|
|
88
|
+
"""Sign one transaction with the agent's key, send it, wait for its receipt and check it went through.
|
|
89
|
+
|
|
90
|
+
The call is run from the wallet first (eth_call, then the gas estimate): a transaction that would revert is
|
|
91
|
+
never sent. Returns {hash, status, gas_used, block}.
|
|
92
|
+
"""
|
|
93
|
+
self.check_chain()
|
|
94
|
+
me = account.address
|
|
95
|
+
to, data, value = to_checksum_address(tx["to"]), tx["data"], int(tx.get("value", 0))
|
|
96
|
+
self.call(to, data, sender=me, value=value)
|
|
97
|
+
req = {"from": me, "to": to, "data": data, **({"value": hex(value)} if value else {})}
|
|
98
|
+
gas = int(self.rpc("eth_estimateGas", [req]), 16) * 13 // 10
|
|
99
|
+
base = int((self.rpc("eth_getBlockByNumber", ["latest", False]) or {}).get("baseFeePerGas", "0x0"), 16)
|
|
100
|
+
try:
|
|
101
|
+
tip = int(self.rpc("eth_maxPriorityFeePerGas"), 16)
|
|
102
|
+
except ChainError:
|
|
103
|
+
tip = 0
|
|
104
|
+
signed = account.sign_transaction({
|
|
105
|
+
"type": 2, "chainId": CHAIN_ID, "to": to, "data": data, "value": value, "gas": gas,
|
|
106
|
+
"nonce": int(self.rpc("eth_getTransactionCount", [me, "pending"]), 16),
|
|
107
|
+
"maxFeePerGas": base * 2 + tip + 1, "maxPriorityFeePerGas": tip,
|
|
108
|
+
})
|
|
109
|
+
tx_hash = self.rpc("eth_sendRawTransaction", ["0x" + signed.raw_transaction.hex()])
|
|
110
|
+
deadline = time.time() + wait_seconds
|
|
111
|
+
while time.time() < deadline:
|
|
112
|
+
rc = self.rpc("eth_getTransactionReceipt", [tx_hash])
|
|
113
|
+
if rc:
|
|
114
|
+
out = {"hash": tx_hash, "status": int(rc["status"], 16), "gas_used": int(rc["gasUsed"], 16), "block": int(rc["blockNumber"], 16)}
|
|
115
|
+
if out["status"] != 1:
|
|
116
|
+
raise ChainError(f"transaction {tx_hash} was mined and failed")
|
|
117
|
+
return out
|
|
118
|
+
time.sleep(0.5)
|
|
119
|
+
raise ChainError(f"transaction {tx_hash} was sent and has no receipt after {int(wait_seconds)} seconds; check it before sending anything else")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def account_from_key(key: str):
|
|
123
|
+
"""The agent's signer. The key lives only inside this object: nothing here prints, logs or stores it."""
|
|
124
|
+
try:
|
|
125
|
+
return Account.from_key(key.strip())
|
|
126
|
+
except Exception:
|
|
127
|
+
raise ValueError("the agent key is not a private key (0x and 64 hex characters)") from None
|
borro_live/mcp.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""A thin client for Borro's MCP server: JSON-RPC 2.0 over HTTP, no session, no key."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
MCP_URL = "https://borro.markets/mcp"
|
|
11
|
+
PROTOCOL = "2025-03-26"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BorroLiveError(Exception):
|
|
15
|
+
"""Every error this kit raises: an order it refuses before anything is signed, or an answer Borro gave in words."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class McpError(BorroLiveError):
|
|
19
|
+
"""The server could not be reached or answered with a JSON-RPC error."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ToolRefused(McpError):
|
|
23
|
+
"""A tool answered in words instead of an answer: an unlisted ticker, too little money, no position."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def http_post(url: str, payload: dict, timeout: float, what: str) -> dict:
|
|
27
|
+
"""POST JSON, return JSON. Errors never carry the address: an RPC address can hold a key."""
|
|
28
|
+
req = urllib.request.Request(
|
|
29
|
+
url, data=json.dumps(payload).encode(),
|
|
30
|
+
headers={"Content-Type": "application/json", "Accept": "application/json", "User-Agent": "borro-live"},
|
|
31
|
+
)
|
|
32
|
+
try:
|
|
33
|
+
with urllib.request.urlopen(req, timeout=timeout) as res:
|
|
34
|
+
return json.loads(res.read())
|
|
35
|
+
except urllib.error.HTTPError as e:
|
|
36
|
+
raise McpError(f"{what} answered HTTP {e.code}") from None
|
|
37
|
+
except (urllib.error.URLError, TimeoutError, OSError):
|
|
38
|
+
raise McpError(f"{what} could not be reached") from None
|
|
39
|
+
except ValueError:
|
|
40
|
+
raise McpError(f"{what} did not answer in JSON") from None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class McpClient:
|
|
44
|
+
"""initialize, tools/list and tools/call against Borro's MCP endpoint.
|
|
45
|
+
|
|
46
|
+
``transport`` replaces the HTTP call (a function taking the JSON-RPC payload and returning the response),
|
|
47
|
+
which is how the tests replay recorded answers.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, url: str | None = None, timeout: float = 60, transport=None):
|
|
51
|
+
self.url = url or os.environ.get("BORRO_MCP_URL") or MCP_URL
|
|
52
|
+
self.timeout = timeout
|
|
53
|
+
self._transport = transport or (lambda payload: http_post(self.url, payload, self.timeout, "Borro's MCP server"))
|
|
54
|
+
self._id = 0
|
|
55
|
+
|
|
56
|
+
def _rpc(self, method: str, params: dict | None = None) -> dict:
|
|
57
|
+
self._id += 1
|
|
58
|
+
payload = {"jsonrpc": "2.0", "id": self._id, "method": method}
|
|
59
|
+
if params is not None:
|
|
60
|
+
payload["params"] = params
|
|
61
|
+
res = self._transport(payload)
|
|
62
|
+
if not isinstance(res, dict) or "result" not in res:
|
|
63
|
+
err = (res or {}).get("error") if isinstance(res, dict) else None
|
|
64
|
+
raise McpError(f"{method}: {(err or {}).get('message', 'no result')}")
|
|
65
|
+
return res["result"]
|
|
66
|
+
|
|
67
|
+
def initialize(self) -> dict:
|
|
68
|
+
return self._rpc("initialize", {"protocolVersion": PROTOCOL, "capabilities": {}, "clientInfo": {"name": "borro-live", "version": "0.1.0"}})
|
|
69
|
+
|
|
70
|
+
def tools_list(self) -> list[dict]:
|
|
71
|
+
return self._rpc("tools/list")["tools"]
|
|
72
|
+
|
|
73
|
+
def call(self, name: str, arguments: dict | None = None) -> dict:
|
|
74
|
+
"""One tool call. Returns the tool's structured answer; a refusal in words raises ToolRefused."""
|
|
75
|
+
res = self._rpc("tools/call", {"name": name, "arguments": arguments or {}})
|
|
76
|
+
text = next((c.get("text", "") for c in res.get("content", []) if c.get("type") == "text"), "")
|
|
77
|
+
if res.get("isError"):
|
|
78
|
+
raise ToolRefused(text or f"{name} was refused")
|
|
79
|
+
if isinstance(res.get("structuredContent"), dict):
|
|
80
|
+
return res["structuredContent"]
|
|
81
|
+
try:
|
|
82
|
+
return json.loads(text)
|
|
83
|
+
except ValueError:
|
|
84
|
+
raise McpError(f"{name} did not answer in JSON") from None
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""TradingAgents on Borro: turn a run's rating into an order.
|
|
2
|
+
|
|
3
|
+
from borro_live import Broker
|
|
4
|
+
from borro_live.tradingagents import go_live
|
|
5
|
+
|
|
6
|
+
ta = go_live(TradingAgentsGraph(config=config), Broker.from_env(), usdg=5)
|
|
7
|
+
_, decision = ta.propagate("NVDA", "2026-09-01") # the same answer as before; ta.borro_orders has the orders
|
|
8
|
+
|
|
9
|
+
``TradingAgentsGraph.propagate(ticker, date)`` returns ``(final_state, signal)``; the signal is one of Buy,
|
|
10
|
+
Overweight, Hold, Underweight, Sell, or REVIEW when the run reached no rating. Older versions answer BUY, SELL or
|
|
11
|
+
HOLD. TradingAgents itself is never imported here: a plain string, the tuple or the final state all work.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import warnings
|
|
18
|
+
|
|
19
|
+
from .broker import BorroLiveError, Broker, Result
|
|
20
|
+
from .chain import ChainError
|
|
21
|
+
from .mcp import McpError
|
|
22
|
+
|
|
23
|
+
RATINGS = ("buy", "overweight", "hold", "underweight", "sell")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def rating_of(decision) -> str | None:
|
|
27
|
+
"""The rating in a decision, lower case, or None when there is none to act on (REVIEW included)."""
|
|
28
|
+
if isinstance(decision, (tuple, list)) and decision:
|
|
29
|
+
decision = decision[-1]
|
|
30
|
+
if isinstance(decision, dict):
|
|
31
|
+
decision = decision.get("final_rating") or decision.get("signal") or decision.get("decision") or ""
|
|
32
|
+
word = re.sub(r"[^a-z]", "", str(decision or "").lower())
|
|
33
|
+
return word if word in RATINGS else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def execute(broker: Broker, ticker: str, decision, usdg: float = 5.0, allow_short: bool = False) -> list[Result]:
|
|
37
|
+
"""Act on one decision for one ticker. Returns the orders made (none for Hold, REVIEW or nothing to sell).
|
|
38
|
+
|
|
39
|
+
Buy cover a short if there is one, then buy ``usdg`` worth
|
|
40
|
+
Overweight the same, half the size
|
|
41
|
+
Hold nothing
|
|
42
|
+
Underweight sell half of the long, if there is one
|
|
43
|
+
Sell sell all of the long; with ``allow_short`` and no long, short ``usdg`` worth instead
|
|
44
|
+
"""
|
|
45
|
+
rating = rating_of(decision)
|
|
46
|
+
if rating in (None, "hold"):
|
|
47
|
+
return []
|
|
48
|
+
symbol = str(ticker).strip().upper()
|
|
49
|
+
broker.price(symbol) # refuses a ticker Borro does not list, before anything else
|
|
50
|
+
pos = broker.positions()
|
|
51
|
+
has_long = any(p.get("symbol") == symbol for p in pos["longs"])
|
|
52
|
+
has_short = any(p.get("symbol") == symbol for p in pos["shorts"])
|
|
53
|
+
out: list[Result] = []
|
|
54
|
+
if rating in ("buy", "overweight"):
|
|
55
|
+
if has_short:
|
|
56
|
+
out.append(broker.cover(symbol))
|
|
57
|
+
out.append(broker.buy(symbol, usdg if rating == "buy" else usdg / 2))
|
|
58
|
+
elif has_long:
|
|
59
|
+
out.append(broker.sell(symbol, 100 if rating == "sell" else 50))
|
|
60
|
+
elif rating == "sell" and allow_short and not has_short:
|
|
61
|
+
out.append(broker.short(symbol, usdg))
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def go_live(graph, broker: Broker, usdg: float = 5.0, allow_short: bool = False):
|
|
66
|
+
"""Wrap ``graph.propagate`` so every run's decision is executed on Borro. Returns the same graph.
|
|
67
|
+
|
|
68
|
+
``propagate`` answers exactly as before. The orders are in ``graph.borro_orders``; if Borro or this kit refused
|
|
69
|
+
(a cap, an unlisted ticker, a failed simulation), the reason is in ``graph.borro_error`` and a warning is raised.
|
|
70
|
+
"""
|
|
71
|
+
inner = graph.propagate
|
|
72
|
+
|
|
73
|
+
def propagate(company_name, trade_date, *args, **kwargs):
|
|
74
|
+
result = inner(company_name, trade_date, *args, **kwargs)
|
|
75
|
+
graph.borro_orders, graph.borro_error = [], None
|
|
76
|
+
try:
|
|
77
|
+
graph.borro_orders = execute(broker, company_name, result, usdg=usdg, allow_short=allow_short)
|
|
78
|
+
except (BorroLiveError, McpError, ChainError) as e:
|
|
79
|
+
graph.borro_error = e
|
|
80
|
+
warnings.warn(f"borro-live did not complete the order for {company_name}: {e}", stacklevel=2)
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
graph.propagate = propagate
|
|
84
|
+
return graph
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: borro-live
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Send a trading agent's decisions to Borro on Robinhood Chain: buy, sell, short, cover and lend tokenized stocks against USDG. Adapters for TradingAgents and ai-hedge-fund.
|
|
5
|
+
Author: Borro
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://borro.markets/go-live
|
|
8
|
+
Project-URL: Documentation, https://borro.markets/developers
|
|
9
|
+
Project-URL: Repository, https://github.com/BorroMarkets/borro-live
|
|
10
|
+
Keywords: borro,robinhood-chain,tradingagents,ai-hedge-fund,mcp,agents
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: eth-account>=0.13
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# Go Live on Borro
|
|
18
|
+
|
|
19
|
+
`borro-live` sends a trading agent's decisions to [Borro](https://borro.markets) for real: buy, sell, short, cover and
|
|
20
|
+
lend tokenized stocks against USDG on Robinhood Chain. It comes with adapters for
|
|
21
|
+
[TradingAgents](https://github.com/TauricResearch/TradingAgents) and
|
|
22
|
+
[ai-hedge-fund](https://github.com/virattt/ai-hedge-fund), which decide and simulate but place no orders.
|
|
23
|
+
|
|
24
|
+
It is a small Python package: one dependency (`eth-account`), no framework imported unless you have it. It talks to
|
|
25
|
+
Borro's public MCP server (`https://borro.markets/mcp`, no key), which builds each transaction and simulates it; the
|
|
26
|
+
package checks the simulation, signs with your agent's own key and sends through your own RPC.
|
|
27
|
+
|
|
28
|
+
**Read this first.**
|
|
29
|
+
|
|
30
|
+
- **Not advice.** This is plumbing. Nothing here tells you what to trade, and nothing here promises a return. You can
|
|
31
|
+
lose everything you send.
|
|
32
|
+
- **What a stock token is.** A token on Robinhood Chain, issued by Robinhood Assets (Jersey) Limited, whose price
|
|
33
|
+
follows a stock or fund. It is not the share: no vote, no brokerage account, no investor protection scheme. Its price
|
|
34
|
+
on chain can sit away from the stock's, most of all while the stock market is shut.
|
|
35
|
+
- **A short can be liquidated.** If the stock rises far enough, Borro closes the short for you at a loss, and the
|
|
36
|
+
deposit can be lost. A buy here is a long with a small loan against it, and it can be liquidated too if the token
|
|
37
|
+
falls nearly to zero.
|
|
38
|
+
- **It starts as a dry run.** Nothing is sent until you ask for live mode twice: a flag in code and a variable in the
|
|
39
|
+
environment.
|
|
40
|
+
|
|
41
|
+
## Five minutes
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pip install borro-live # or, from a clone: pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Three environment variables:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
export BORRO_AGENT_KEY=0x... # the agent's own wallet key. Use a fresh wallet with little in it.
|
|
51
|
+
export BORRO_RPC_URL=https://... # your own Robinhood Chain RPC (chain id 4663). There is no built-in one.
|
|
52
|
+
export BORRO_LIVE=1 # leave this out until you mean it
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
To try it with no key at all, set `BORRO_WALLET=0x...` to any address in place of `BORRO_AGENT_KEY`: every order is
|
|
56
|
+
then a dry run simulated from that address, and nothing can be sent. A simulation passes only if that address holds
|
|
57
|
+
the USDG the order needs.
|
|
58
|
+
|
|
59
|
+
Dry run (leave `BORRO_LIVE` out). It prints Borro's simulation of every step and sends nothing:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
borro-live tickers
|
|
63
|
+
borro-live buy NVDA 5 # 5 USDG of NVDA
|
|
64
|
+
borro-live positions
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Live. The wallet needs USDG for the order and a little ETH for gas:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
BORRO_LIVE=1 borro-live buy NVDA 5 --live
|
|
71
|
+
BORRO_LIVE=1 borro-live sell NVDA --live
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Each live order prints every transaction hash. A dry run needs no RPC and no key at all:
|
|
75
|
+
`borro-live buy NVDA 5 --wallet 0xYourAddress`.
|
|
76
|
+
|
|
77
|
+
## TradingAgents
|
|
78
|
+
|
|
79
|
+
`TradingAgentsGraph.propagate(ticker, date)` returns `(final_state, signal)`, where the signal is Buy, Overweight, Hold,
|
|
80
|
+
Underweight or Sell (or REVIEW when the run reached no rating). One line wraps it:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
|
84
|
+
from tradingagents.default_config import DEFAULT_CONFIG
|
|
85
|
+
from borro_live import Broker
|
|
86
|
+
from borro_live.tradingagents import go_live
|
|
87
|
+
|
|
88
|
+
ta = go_live(TradingAgentsGraph(config=DEFAULT_CONFIG.copy()), Broker.from_env(), usdg=5)
|
|
89
|
+
_, decision = ta.propagate("NVDA", "2026-09-01")
|
|
90
|
+
print(decision, ta.borro_orders, ta.borro_error)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`propagate` answers exactly as before. What the adapter does with each rating:
|
|
94
|
+
|
|
95
|
+
| Rating | Order |
|
|
96
|
+
|---|---|
|
|
97
|
+
| Buy | cover a short if there is one, then buy `usdg` worth |
|
|
98
|
+
| Overweight | the same, half the size |
|
|
99
|
+
| Hold, REVIEW | nothing |
|
|
100
|
+
| Underweight | sell half of the long, if there is one |
|
|
101
|
+
| Sell | sell all of the long. With `allow_short=True` and no long, short `usdg` worth instead |
|
|
102
|
+
|
|
103
|
+
Without the graph: `borro_live.tradingagents.execute(broker, "NVDA", "Buy", usdg=5)`. Runnable:
|
|
104
|
+
`python examples/tradingagents_example.py NVDA --decision Buy`.
|
|
105
|
+
|
|
106
|
+
## ai-hedge-fund
|
|
107
|
+
|
|
108
|
+
The portfolio manager's decisions (`{ticker: {"action": "buy" | "sell" | "short" | "cover" | "hold", "quantity":
|
|
109
|
+
shares}}`, what `run_hedge_fund` returns under `"decisions"`) go straight in:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from borro_live import Broker
|
|
113
|
+
from borro_live.ai_hedge_fund import execute_decisions
|
|
114
|
+
|
|
115
|
+
results = execute_decisions(Broker.from_env(), result["decisions"], scale=0.01)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`scale` multiplies every quantity, since the model sizes in whole shares for a paper portfolio and the caps here are
|
|
119
|
+
in USDG. Pick it so the largest order lands under your per-order cap (10 USDG unless you raise it): an order over the
|
|
120
|
+
cap is refused, not shrunk. Each ticker gets its own answer; one refusal never stops the others. Runnable:
|
|
121
|
+
`python examples/ai_hedge_fund_example.py --buy NVDA 3`.
|
|
122
|
+
|
|
123
|
+
The v2 engine (`hedge_fund/`, the `aihf` package) has a `Broker` protocol instead: `positions()`, `cash()` and
|
|
124
|
+
`place_order(order)`. `borro_live.ai_hedge_fund.BorroBroker(Broker.from_env())` satisfies it. Whole shares on a signed
|
|
125
|
+
book meet two limits on Borro: a short is closed whole, and an order may not cross zero in one step. Both are refused
|
|
126
|
+
in words, never half done. Treat it as experimental, for three reasons. The engine orders whole shares, so with the
|
|
127
|
+
default 10 USDG cap every order is refused: raise the caps to more than a share of what you trade. The engine expects
|
|
128
|
+
`place_order` to fill or raise, so in a dry run it raises with Borro's simulation and the engine's session stops there.
|
|
129
|
+
And the engine reconciles its ledger against whole shares, while a buy on Borro fills at the share count the swap
|
|
130
|
+
gave. Its own data client also needs its own key.
|
|
131
|
+
|
|
132
|
+
## What each order really is
|
|
133
|
+
|
|
134
|
+
Worked out from the server's own `tools/list`:
|
|
135
|
+
|
|
136
|
+
| `Broker` | Borro tool | What happens |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `buy(symbol, usdg)` | `open_long` at 1.01x | Your USDG buys the stock token; it is lent (it earns lending fees) and posted; 1% of the order is borrowed against it. |
|
|
139
|
+
| `sell(symbol, percent=100)` | `close_long` | The token is sold, the small loan repaid, USDG comes back. |
|
|
140
|
+
| `short(symbol, usdg)` | `open_short` | The stock is borrowed and sold; the sale money and a USDG deposit are held. You pay a borrow fee while it is open. |
|
|
141
|
+
| `cover(symbol)` | `close_short` | Buys back what is owed, returns it, pays out what is left. Borro has one short per stock: this closes all of it. |
|
|
142
|
+
| `lend(asset, amount)` | `supply` | USDG earns interest; a stock token you hold earns lending fees. |
|
|
143
|
+
| `positions()` | `position` | Longs, shorts, what is lent, wallet balances. |
|
|
144
|
+
| `buy_spot_with_eth(symbol, shares)` | `buy_with_eth` | A plain spot buy paid in the wallet's ETH. The token lands in the wallet. |
|
|
145
|
+
|
|
146
|
+
**Borro's MCP server has no tool for a plain spot buy or sell of a stock token for USDG.** `buy` is therefore the
|
|
147
|
+
nearest thing that can also be sold back through Borro: a long at 1.01x. It behaves like holding the token, with two
|
|
148
|
+
differences you should know: the token is lent out while you hold it, and there is a loan of 1% of the order against
|
|
149
|
+
it, so the position can be liquidated if the token loses nearly all its value. `buy_spot_with_eth` is a true spot
|
|
150
|
+
buy, but it pays in ETH and no Borro tool sells the token back; lend it, or sell it elsewhere.
|
|
151
|
+
|
|
152
|
+
`short` deposits 1 USDG for each USDG shorted by default (`short_deposit=1.0`), and never less than a tenth over the
|
|
153
|
+
least Borro asks. At that default a short is closed for you after a rise of roughly half; the quote in every answer
|
|
154
|
+
gives the exact price (`closed_for_you_at_price`). A thinner deposit is closed sooner.
|
|
155
|
+
|
|
156
|
+
## Safety
|
|
157
|
+
|
|
158
|
+
- **Dry run by default.** Without `live=True` and `BORRO_LIVE=1` together, an order returns the tool's own simulation
|
|
159
|
+
and sends nothing. `live=True` without the variable refuses to start; the variable without the flag stays a dry run.
|
|
160
|
+
- **Caps you set.** `max_order_usdg` (default 10) is the most one buy, short or lend may be worth; `max_day_usdg`
|
|
161
|
+
(default 25) is the most they may add up to in one UTC day, per wallet. Over either, the order is refused before
|
|
162
|
+
anything is signed. The day's total is kept in `~/.borro-live/state.json` (or `BORRO_LIVE_STATE`), counted before the
|
|
163
|
+
first transaction goes out. Selling and covering are never stopped by a cap. Set them in code or with
|
|
164
|
+
`BORRO_MAX_ORDER_USDG` and `BORRO_MAX_DAY_USDG`.
|
|
165
|
+
- **Listed tickers only.** A ticker Borro does not list is refused before any trade tool is called.
|
|
166
|
+
- **A failed simulation is never sent.** Every step must pass Borro's simulation, and each step is run again from the
|
|
167
|
+
wallet (`eth_call`, then a gas estimate) just before it is signed. Steps go in order; each receipt is checked before
|
|
168
|
+
the next.
|
|
169
|
+
- **What is signed.** Only transactions for your wallet on chain 4663. No order sends ETH except
|
|
170
|
+
`buy_spot_with_eth`, and never more than its quote. A USDG approval is refused if it is for more than the order.
|
|
171
|
+
- **The key.** Read from `BORRO_AGENT_KEY`, kept in memory, never printed, logged or written. Errors never carry your
|
|
172
|
+
RPC address.
|
|
173
|
+
- **Trust.** Borro's server builds the transactions. Read a dry run's `transactions` before going live: each has the
|
|
174
|
+
contract it goes to and what it does. The `rules` tool lists Borro's contracts.
|
|
175
|
+
|
|
176
|
+
## Builder codes: a fee share for whoever ships the integration
|
|
177
|
+
|
|
178
|
+
Borro shares its own fees with whoever brings the business (the Bookrunners programme). Give the broker a code and
|
|
179
|
+
installs that carry it pay the code's owner:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
Broker.from_env(builder_code="yourcode") # or BORRO_BUILDER_CODE=yourcode
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- The wallet is bound to the code once, with one free signature (`bind_builder`, no transaction). From then on the
|
|
186
|
+
code earns Borro's fee on the interest that wallet pays when it borrows (shorts and the loan under a buy).
|
|
187
|
+
- Lends go through the `BorroBuilders` contract with the code, so the code earns Borro's fee on what those deposits
|
|
188
|
+
earn. The wallet keeps its vault shares.
|
|
189
|
+
- The share is all of Borro's fee for 12 months, half after, paid monthly in USDG to the wallet that registered the
|
|
190
|
+
code. The user pays nothing extra. Fees float with demand: with no borrowing there is no fee to share.
|
|
191
|
+
- Register a code with your own wallet at [borro.markets/bookrunners](https://borro.markets/bookrunners). First come,
|
|
192
|
+
first served; only that wallet can move it. One code per wallet: the first binding stands.
|
|
193
|
+
|
|
194
|
+
There is no default code. If you ship this in a framework, set yours.
|
|
195
|
+
|
|
196
|
+
## Tests
|
|
197
|
+
|
|
198
|
+
```sh
|
|
199
|
+
python -m unittest discover -s tests -p 'test_unit.py' # 50 tests against recorded MCP answers, no network
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Borro also runs the kit end to end on a local fork of Robinhood Chain in its own repository: a dry run, a live buy, lend,
|
|
203
|
+
short, cover and sell, both adapters, the caps and a builder code. `BORRO_MCP_URL` points the client at another MCP
|
|
204
|
+
address, which is how that test reaches its local server.
|
|
205
|
+
|
|
206
|
+
`walk.sh` is the one real walk: one small live order per framework from a funded wallet. It asks before it sends.
|
|
207
|
+
|
|
208
|
+
## Not here
|
|
209
|
+
|
|
210
|
+
Options, perps and Borro's dark pool are not part of this kit.
|
|
211
|
+
|
|
212
|
+
MIT licence.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
borro_live/__init__.py,sha256=w7EGI8U3GUfsyU1CH1dyhSprb37LgRKqh10bZ1ZuqOo,712
|
|
2
|
+
borro_live/__main__.py,sha256=4nIa5fZIuDsdbROWu3l222tzsOBKahxCUN36x6FHbEY,2594
|
|
3
|
+
borro_live/ai_hedge_fund.py,sha256=A3mJSiYYcIW7yCtfUs51dS9tG8rKMwAFZ8fe1o_ZX4g,6950
|
|
4
|
+
borro_live/broker.py,sha256=E5KKwa_4ZtRQPrNphgXdHI6gK2O-idC9hW4TciZTV5w,23364
|
|
5
|
+
borro_live/chain.py,sha256=FB--XbmPM084RZv2K15ywA07g4RiQC6ivPlzzmSap7M,5688
|
|
6
|
+
borro_live/mcp.py,sha256=ifxst_QIEWoNKk3I-UPd83G-E0vqnHwG1a7Yc3uQtnA,3565
|
|
7
|
+
borro_live/tradingagents.py,sha256=ug-L91vtuaQAWHinNeaP-XrpbTPng1HWgT0RSL0_J8o,3769
|
|
8
|
+
borro_live-0.1.0.dist-info/licenses/LICENSE,sha256=GJmFD0cz1TzbvIJBfoIZQqz9XLEGpcBKSpJZpOjmNKQ,1062
|
|
9
|
+
borro_live-0.1.0.dist-info/METADATA,sha256=HQL3QHqXgSYZfywkEgZDIf20t2p4Rf4-cOiDJsSx5LY,11697
|
|
10
|
+
borro_live-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
11
|
+
borro_live-0.1.0.dist-info/entry_points.txt,sha256=jbZUsSDcH5aUzJ-jwXG5EwJ9DFKn4o8k-V-aZfOhwa0,56
|
|
12
|
+
borro_live-0.1.0.dist-info/top_level.txt,sha256=kcTrbIBcLvB6i5_NgIBmaRHbEM_AWk7dB8wUNf66i4o,11
|
|
13
|
+
borro_live-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Borro
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
borro_live
|