Skip to content

takinanton/carry_bot

Repository files navigation

carry_bot — cross-DEX funding-carry coordinator

Delta-neutral funding carry across HL + Extended + Pacifica + Nado. Per coin: LONG the lowest-funding venue, SHORT the highest → collect the funding SPREAD, price-neutral, all perps. Validated strategy: project_combo_deep_test_2026_06_25 (cross-sectional + multi-venue-routed, edge ~3.5% conserv / ~8% observed ON NOTIONAL, cluster-t 5.16–7.77, 1 regime — not multi-cycle OOS).

Architecture (distributed — each venue client lives in its own repo/venv/host)

  • carry.py — COORDINATOR (runs locally). scan → rank cross-section → route legs around caps → size by basis-tail-budget L = risk_budget / |basis_shock| (default 33%/3.3% = 10×). LOGS the target book. Places NO orders.
  • rpc_read.py — READ-ONLY RPC piped over SSH into each venue's venv; builds that repo's tested client via main._build_client(Settings.from_env()), forces DRY_RUN=1, returns funding/positions/equity/mark as JSON. No order/cancel path is importable here. funding_all is tradeability-gated: a (coin,venue) cell is emitted only if that venue has a live mark > 0 for it (kills the SKIP_MARK-at-hedge-time class); mark resolves the venue-correct symbol (Nado '-PERP' key).
  • hedger.py / rpc_order.py — GATED delta-neutral executor + the only write path (double gate ARMED + DRY_RUN/intent; default OFF → returns plan dicts, places nothing).
  • risk.py — basis-tail monitor, ACTIVE every loop (wired into carry.run_once after reconcile, via run_risk_monitor): assess() deleverages a held pair on liq-distance < 8% or basis-MtM ≥ 75% of budget, flattens-ALL on total adverse MtM ≥ 100% (GLOBAL_KILL), and tops up HL isolated margin when a leg's liq-distance is thin; execute_actions() dispatches every write through the SAME gated OrderRPC (egress-assert + ARMED/DRY gate + RateGovernor) → under DRY/unarmed it PLANS only and places nothing.
  • state.py — persistent BOOK-STATE store (the bot's OWN carry book). Crash-safe atomic JSON at state/book.json (write tmp + os.replace, so a mid-write crash never corrupts it). Carry-managed coins ONLY. This is what lets hysteresis (HOLD / REBALANCE / CLOSE) survive restarts — without it the coordinator runs held={} and re-OPENs the whole cross-section every loop.

🚨 Egress isolation (money-safety) + rate-budget governor

The flaw this guards against. Carry's RPC runs each venue's client on that venue's PROD-bot host (SSH-in to reuse the tested SDK client). So carry's exchange API calls egress from the SAME public IP as the production bot → they share the venue's per-IP rate-limit bucket. A burst of carry traffic can therefore throttle the prod bot's money-critical writes (stop-loss placement, naked-heal). Two forcing mechanisms make that impossible:

1. Egress-isolation assert (structural — blocks the unsafe config)

The ORDER/WRITE path (hedger.OrderRPCrpc_order.py) REFUSES to run when its SSH egress host ∈ PROD_HOSTS, unless an explicit dedicated carry host is configured.

  • carry.PROD_HOSTS = the set of prod-bot hosts, derived from VENUES (never hand-listed, so it can't drift): {hl-bot, extended-bot, pacifica-bot, nado-bot}.
  • CARRY_DEDICATED_HOST (env / config) = a box that is NOT in PROD_HOSTS — a dedicated carry machine with the subaccount keys + all 4 venue SDKs installed. When set, every WRITE egresses from that host's IP (its own rate-limit bucket), never a prod IP.
  • Refusal = raise/return a clear error, place nothing. Default (no dedicated host) → WRITES are refused; the open_pair/close_pair result is status=REFUSED_EGRESS, placed=False and no SSH is even attempted. A CARRY_DEDICATED_HOST that is a prod host is also refused (it would still share that prod IP's bucket).
  • READS still work: rpc_read.py may SSH-in to a prod host for low-freq reads (funding/positions/ equity) — tolerable, but subject to the governor below.

This makes "carry orders egress from a prod IP" structurally impossible — it is enforced in carry.assert_write_egress_isolated() / OrderRPC._egress_host() before any SSH or SDK construction, so an unsafe deploy can't place a single order.

2. Rate-budget governor (self-throttle — never starve prod, even on a shared read IP)

carry.RateGovernor is a per-(egress-host) token-bucket + min-interval limiter wired into the SSH- dispatch layer (carry._ssh_read and OrderRPC._call):

  • Carry caps its own request rate to a small ceiling CARRY_MAX_REQ_PER_MIN (default 20/min), well under any venue's per-IP budget, and enforces a min gap between calls (CARRY_MIN_GAP_SEC, default 60/CARRY_MAX_REQ_PER_MIN = 3 s). Even a tight loop physically cannot hammer a venue.
  • On any HTTP 429 / rate-limit signal from a venue (carry.is_rate_limit_signal), carry backs off exponentially and yields (CARRY_BACKOFF_BASE_SEC 2 s → ×2 each hit, capped at CARRY_BACKOFF_MAX_SEC 120 s) — it defers to prod, never retry-storms. A clean call resets the streak.
  • The funding scan is hourly-cadence anyway: CARRY_POLL_INTERVAL_SEC (default 3600) is the sane loop default, and --loop is floored to it so a misconfigured --loop 1 can't hammer.
knob (env) default meaning
CARRY_DEDICATED_HOST (unset → writes refused) dedicated non-prod carry box (subaccount keys + venue SDKs) the WRITE path egresses from
CARRY_MAX_REQ_PER_MIN 20 per-egress-host request ceiling (≪ venue budgets)
CARRY_MIN_GAP_SEC 60 / max_req_per_min (=3) min seconds between calls to the same host
CARRY_BACKOFF_BASE_SEC 2.0 first 429 backoff (doubles per consecutive hit)
CARRY_BACKOFF_MAX_SEC 120.0 backoff ceiling (never unbounded)
CARRY_POLL_INTERVAL_SEC 3600 sane funding-loop interval (--loop floored to it)

Dedicated-carry-host provisioning (deploy prerequisite the assert enforces)

Because WRITES refuse to egress from a prod IP, arming live requires a dedicated carry host — a separate box/IP that is NOT one of the prod-bot hosts. Provision it with:

  1. All 4 venue SDKs + each repo's bot.* package installed (HL=hyperliquid-sdk, Extended=x10 Stark async, Pacifica=solders, Nado=nado-protocol) so rpc_order._build() can build each tested client there — the same cd <repo dir> && <venv>/bin/python layout the prod hosts use, mirrored on the carry box.
  2. The carry SUBACCOUNT .env per venue (see env_templates/*.env.example and the "Subaccount isolation" section) — the carry leg trades the isolated subaccount, never the prod account.
  3. SSH reachable as the hostname you put in CARRY_DEDICATED_HOST (matching ~/.ssh/config).

Until that host exists and CARRY_DEDICATED_HOST points at it, the assert keeps every WRITE refused — reads keep working (governed) so the DRY pipeline is fully usable. Tests: pytest -q test_egress_governor.py (20/20: egress refusal sends no SSH/places nothing; a dedicated non-prod host is allowed-to-proceed but ARMED-off plans only; governor throttles bursts + backs off on 429; a DRY funding scan still reads under the governor).

state.py API

  • load() — read book.json (missing → empty cold start; corrupt → FAILS LOUD, never silently empties).
  • add_pair(rec) — record a confirmed-open pair (atomic save). Record shape: {coin, long_venue, short_venue, leg_notional, entry_ts, entry_spread_pct, long_fill_px, short_fill_px, long_oid, short_oid, sl_oids, status}.
  • remove_pair(coin) — drop a confirmed-closed (or FULLY_GONE) pair (atomic save).
  • update_pair(coin, **fields) — patch fields (status, sl_oids, fill px) on an existing record.
  • held(){coin: rec} · held_coins()set · held_view(){coin:{long,short,net}} (what the diff hysteresis reads) · save() after every mutation (atomic).
  • reconcile(live_positions_by_venue, universe)READ-ONLY discrepancy report (logs only, NO auto-trade). Per booked pair, confirms BOTH legs still live with ~matching signed size and classifies:
    • OK — both legs live, sizes match.
    • LEG_MISSING — one leg gone → naked directional risk, surfaced LOUD in report["naked"]. Now ACTIVELY HEALED every loop by carry.run_risk_monitor: the survivor leg is flattened through the gated hedger (flatten_with_readback → readback + retry + ALARM_NAKED_LEG), so a naked leg is no longer log-only — it is killed (DRY/unarmed → planned, places nothing). The reconcile function itself remains read-only and NEVER touches a position; the heal is driven by the monitor, not reconcile.
    • SIZE_DRIFT — both legs live but signed size diverged > tol (investigate, no auto-trade).
    • FULLY_GONE — both legs closed externally → listed in report["drop"] (removed only on a real close).
    • UNTRACKED — a live position on a carry coin/venue that is not in the book → flagged, not adopted.
    • Foreign / non-carry coins are NEVER touched or flagged (manual / other-bot rule; book.json is carry-coins only). UNTRACKED is scoped to carry.UNIVERSE ∩ carry venues.

Wiring (carry.run_once): load book → pull live positions per venue → reconcile + log report (first loop iteration) → pass book.held_view() into diff_book_vs_live (replaces held={}) so OPEN fires only for un-held target coins, CLOSE only for held carry-pairs that left the target set (with the RESELECT_BAND_ANN hysteresis), REBALANCE on drift. When --arm (OFF now), a confirmed open → add_pair, a confirmed flat close → remove_pair. Smoke test: python3 smoke_state.py (25/25, zero orders).

Why distributed: the 4 clients use incompatible SDKs/hosts (HL=hyperliquid-sdk, Extended=x10 Stark async, Pacifica=solders, Nado=nado-protocol) — cannot co-import. Live creds verified present on each host.

Status (2026-06-25): pipeline wired E2E + persistent book-state store DONE; book NOT yet valid

DRY-RUN reads + scan + route + size + log + persistent self-managed book (state.py) + read-only reconcile all work. The book now survives restarts, so hysteresis/CLOSE/REBALANCE persist (the coordinator no longer runs held={}). Signal-parity bugs are FIXED (6h-trailing-mean · Extended sentinel-parse · Nado venue-decode) → all 4 venues report valid funding and the DRY-RUN book is sane. Self-impact OI-cap (5% of venue OI/leg) is applied in the sizer. Order layer (hedger/rpc_order/risk) is written and GATED OFF (triple gate). Remaining before live = capital + ARM sign-off + paper-trade.

Per-venue DRY-FORCE + resolved-dry DETECTION (paper-trade safety)

The 4 bots resolve DRY_RUN through different mechanisms, so a single shell DRY_RUN=1 is not uniformly safe and a single cfg.dry_run read is wrong on 2 of 4 venues. carry.py carries a per-venue dry_force descriptor; carry._dry_force_prefix() builds the venue-correct remote force (used by BOTH the read path and the order path via hedger.OrderRPC); rpc_order._resolved_dry() reads the value the order guard actually obeys, keyed by the CARRY_VENUE env the coordinator passes. The gate in rpc_order._guarded() REFUSES to fire unless the venue-correct resolved-dry is provably True under a dry intent.

🚨 The CARRY GATE is the SINGLE SOURCE OF TRUTH (Bug C → single-source-of-truth, money-critical, upgraded 2026-06-30). resolved-dry==True proves the venue is configured dry — it does not prove the adapter method mocks the write. The Extended and Nado adapters submit a REAL order regardless of dry_run (prod gates dry at a HIGHER layer — Extended trader.py _dry_block, Nado main.py — that carry's direct client.market_open/market_close call bypasses; proof: an armed+DRY carry leg once placed REAL ENA-USD MARKET orders on the Extended carry vault, ~$2.88 real taker round-trip). HL and Pacifica adapters do self-guard every signed write — but the carry gate no longer relies on that. Under a dry intent rpc_order._guarded self-mocks the signed write ITSELF for EVERY venue (HL/Extended/Pacifica/Nado alike) and the live adapter method is NEVER called, on any venue (carry_self_mocked=True). The adapter's own dry-guard remains as a redundant second layer, never the only one; the per-venue _ADAPTER_SELF_MOCKS_DRY flag is retained for telemetry only (surfaced as adapter_would_self_mock), no longer load-bearing — so a future venue whose adapter we have NOT audited is safe by construction (the carry gate intercepts first). A LIVE intent (resolved-dry provably False) is the ONLY path that fires a real order. Live-verified 2026-06-30 on the carry box via the real rpc_order.py: armed+DRY place_market + market_close + cancel on all four venues (HL 0x100B03 · Extended 5290 · Pacifica carry sub · Nado default_1) → every op returned carry_self_mocked=True / ack.status=carry-dry-mock, ZERO live SDK submits, and a before/after position+equity+fills read showed no new orders/fills/positions.

venue force mechanism (dry_force) resolved-dry detector why
HL env_file: cp .env.combo /tmp/carry_dry.$$.env; sed DRY_RUN=0→1; ENV_FILE=<copy> (cleaned up) Settings.from_env().dry_run config does load_dotenv(ENV_FILE or .env.combo, override=TRUE) → a shell DRY_RUN=1 is clobbered by the file. The ENV_FILE temp is the only safe force.
Extended shell: prepend DRY_RUN=1 import bot.trader; bot.trader.DRY_RUN no Settings.dry_run. Order guard obeys module const bot.trader.DRY_RUN = os.getenv(...) (override=False) → shell wins.
Pacifica shell: prepend DRY_RUN=1 bool(int(os.getenv("DRY_RUN","0") or "0")) (env RULE, no client built) no Settings.dry_run. Guard obeys instance PacificaClient.dry_run; reading the env rule is read-only-equivalent (client __init__ does a network _load_meta).
Nado shell: prepend DRY_RUN=1 Settings.from_env().dry_run load_dotenv WITHOUT override → shell DRY_RUN=1 propagates into Settings.dry_run.

⚠️ Pacifica deploy note: dry_force is wired against /home/ubuntu/pacifica_bot_v2_a, but that dir is RETIRED — the live pacifica-bot.service runs /home/ubuntu/pacifica_xnn_bot. The dry-force code path is identical, but the account/dir for any live carry leg is a deploy decision to re-point before arming.

Build-verified READ-ONLY 2026-06-25 (zero orders; no live file/source touched; HL /tmp temp cleaned). OP=resolved_dry probe per venue:

venue resolved-dry baseline (live) resolved-dry under force detector adapter ALSO self-guards? (adapter_would_self_mock, telemetry only)
HL False (live) True Settings.from_env().dry_run (forced via ENV_FILE) YES — _dry_guard (redundant; carry gate intercepts first)
Extended False (live) True import bot.trader; bot.trader.DRY_RUN NO — trader.create_and_place_order, no guard
Pacifica False (live) True bool(int(os.getenv("DRY_RUN","0") or "0")) YES — _request POST-mock (redundant; carry gate intercepts first)
Nado False (live) True Settings.from_env().dry_run NO — SDK place_market_order/close_position, no guard

Under a dry intent the carry gate self-mocks the write for every venue regardless of the last column (carry_self_mocked=True, the live adapter method is never called); the column only records whether the adapter would also have mocked (verified 2026-06-30 against /root/carry_sdks/<venue>/bot/). It is a backstop, not the authority.

HL act-FOR-master (UNIFIED account — NO spot→perp transfer)

The carry HL leg trades the standalone master account 0x100B03683F7A62f32fB6ab276eAe06529505f506 with the agent wallet 0x52C54373… as the signer and no vault_address (no subaccount). HL is a UNIFIED account — the master's spot USDC is the perp cross-margin collateral, so there is NO usdClassTransfer (re-adding one encodes a false split model AND is a DRY-bypass risk; see the NOTE in rpc_order.py). The installed SDK Exchange resolves the acting address as addr = wallet.address; if account_address: addr = account_address; if vault_address: addr = vault_address — so an empty account_address silently acts as the empty AGENT wallet (the proven "Must deposit before performing actions. User: 0x52c5…" rejection). rpc_order._build / rpc_read._build therefore call _assert_hl_acts_for_master(cfg, client): it forces exchange.account_address = <master> whenever it is empty or equals the agent, and raises if no master is configured (fail-closed, never silently act as the agent). Live-verified READ-ONLY on the carry box: a signed HL order ACTS_AS 0x100B03… (master), signer 0x52C54373… (agent), vault=None; an HL equity read resolves the master's $1000 unified balance.

What's LEFT before live (the only remaining gates)

  1. Dedicated carry host — a separate box/IP (NOT a prod-bot host) with the 4 venue SDKs + subaccount .env, pointed to by CARRY_DEDICATED_HOST. Enforced: until it exists every WRITE is REFUSED_EGRESS (carry must never share a prod IP's rate-limit bucket). See "Egress isolation".
  2. Capital — deposit per venue (HL / Extended / Pacifica / Nado); nothing can size without it.
  3. ARM sign-off--arm is OFF and rpc_order's host gate (ARMED + DRY_RUN/intent, HL also HL_LIVE_SIGNOFF) defaults DRY. Live = explicit operator sign-off on each host, not a code change.
  4. Paper-trade — run armed-but-DRY end-to-end to exercise the live order code path with zero money: confirm fills read back, add_pair/remove_pair record correctly, reconcile stays clean across a real open→close cycle. The dry-force is per-venue (see the table above): HL needs the ENV_FILE→dry-copy force (a bare shell DRY_RUN=1 is clobbered by its load_dotenv(override=True)); Extended/Pacifica/Nado take a shell DRY_RUN=1. hedger.OrderRPC (dry) splices these automatically; rpc_order._guarded REFUSES to place unless the venue-correct resolved-dry is provably True, AND (Bug C) self-mocks the write itself on any venue whose adapter does not provably self-guard (Extended/Nado), so a "dry" leg can never reach a real market_open/market_close. Then, and only then, flip to live (host-side DRY_RUN=0/sign-off, NOT a coordinator change).

(Signal-parity item 1 is a correctness blocker for the NUMBERS; capital + ARM + paper-trade are the operational gates for going live once the numbers are trusted.)

Subaccount isolation (run carry on a SEPARATE book per venue)

The carry bot should NOT trade on the same account as the existing per-venue production bots — it needs its own isolated margin book on each venue so its positions/risk never commingle with the live strategy bots. Each venue exposes a native isolation primitive; the carry bot is subaccount-ready WITHOUT any edit to the prod bot repos — config templates live in env_templates/*.env.example and the only code change is a carry-side HL re-point (below).

Per-venue isolation map (verified live + docs 2026-06-25):

venue isolation primitive what's NEW (user supplies) what's REUSED margin isolated?
HL native subaccount = separate on-chain address, no own key (master/agent controls it; SDK targets it via vault_address) HYPERLIQUID_SUBACCOUNT_ADDRESS (create + fund in UI) same agent key + same master HYPERLIQUID_ACCOUNT_ADDRESS yes (sub vs master)
Extended native subaccount (≤10 per ETH wallet) = own API key + own Stark keypair + own margin pool EXTENDED_VAULT_ID, EXTENDED_ACCOUNT_ID, EXTENDED_STARK_PUBLIC/PRIVATE, EXTENDED_API_KEY same EXTENDED_ETH_ADDRESS (parent wallet) yes (independent pool)
Pacifica native subaccount = separate Solana wallet, dual-signed create, isolated book new subaccount wallet + PACIFICA_ACCOUNT_ADDRESS + an agent bound to the sub + the sub's PACIFICA_PRIVATE_KEY network/exchange only yes ("Sub-accounts are isolated")
Nado native subaccount = same wallet + a name tag (1CT); own cross-margin book NADO_SUBACCOUNT=carry (new tag, create in 1CT UI) same signer key + same NADO_ACCOUNT_ADDRESS yes (per-subaccount book)

Pacifica note (Part A finding): Pacifica's subaccount is BOTH "native" AND "a new wallet" — the route POST /api/v1/account/subaccount/create exists live (unauth POST → HTTP 400 "missing field main_account", not 404) and the official docs state "Sub-accounts are margined independently / isolated". It is a fresh Solana keypair cross-signed (main_signature + sub_signature) into the master, NOT an address-only swap like HL. So the Pacifica carry leg needs a new keypair + a bound agent, whereas HL/Nado reuse the existing key and only change an address/tag.

Setup steps per venue

All steps that touch money (create / bind / fund) are done by the operator, never by the carry bot. Keep DRY_RUN=1 in every template until the paper-trade passes.

  1. HL — In the HL UI create a subaccount, transfer USDC to it from the master. Copy its address into HYPERLIQUID_SUBACCOUNT_ADDRESS (template env_templates/hl.env.example). The carry HL client (rpc_read/rpc_order _build) then auto-rebuilds Exchange(... vault_address=<sub>) and points reads at the sub. If the var is unset, carry runs on the master unchanged (no-op).
  2. Extended — In the Extended app create a new subaccount under the same ETH wallet, fund it, then issue its API key and derive its Stark keypair (SNIP-12, from the parent seed for that subaccount index). Fill EXTENDED_VAULT_ID / EXTENDED_ACCOUNT_ID / EXTENDED_STARK_PUBLIC / EXTENDED_STARK_PRIVATE / EXTENDED_API_KEY; keep EXTENDED_ETH_ADDRESS as the parent.
  3. Pacifica — Generate a fresh Solana keypair; POST /account/subaccount/create dual-signed (main + sub); POST /agent/bind (signed by the sub key) to authorize an agent; fund via a subaccount transfer from the master. Set PACIFICA_ACCOUNT_ADDRESS = sub pubkey, PACIFICA_AGENT_PRIVATE_KEY = the bound agent, PACIFICA_PRIVATE_KEY = the sub's own key.
  4. Nado — In app.nado.xyz (1CT) create a new named subaccount (e.g. tag carry) under the same wallet and fund it. Set NADO_SUBACCOUNT=carry; reuse NADO_LINKED_SIGNER_PRIVATE_KEY and NADO_ACCOUNT_ADDRESS. (Use a tag DIFFERENT from the live bot's default.)

HL carry-side wire (the only code change — prod repos untouched)

rpc_read.py and rpc_order.py _build() call _apply_hl_subaccount(cfg, client) right after building the HL client. When HYPERLIQUID_SUBACCOUNT_ADDRESS is set it (a) rebuilds client.exchange to a hyperliquid Exchange(wallet, url, account_address=<master>, vault_address=<sub>, perp_dexs=…) so every signed action stamps vaultAddress=<sub> (HL SDK uses vault_address for both vaults AND subaccounts), and (b) re-points reads by swapping in dataclasses.replace(settings, account_address=<sub>) (Settings is a frozen dataclass). When the var is absent it returns immediately — identical to the prod bot today. It is HL-only (guarded on the client being an HLClient with a hyperliquid Exchange), so the other 3 venues are unaffected. The prod exchange_hl.py is NOT edited.

Build-verified READ-ONLY on hl-bot (DRY, zero orders, only Exchange objects constructed):

case exchange.vault_address reads account_address
HYPERLIQUID_SUBACCOUNT_ADDRESS = 0xDEAD…0001 0xDEAD…0001 0xDEAD…0001 (re-pointed to sub) ✓
var absent None (no-op) ✓ master 0x21d5… (unchanged) ✓

(exchange.account_address stays the master in the sub case — the agent that SIGNS is the master's agent; vault_address is only the TARGET account.)

Open items

DONE this session:

  1. SIGNAL-PARITYrpc_read now returns 6h-TRAILING-MEAN funding (HL fundingHistory · Extended get_funding_rates_history · Pacifica funding_rate/history · Nado SDK daily×365) + ±cap guard. Spike×8760 killed (AVAX −32→−15, ETH −19→−8.7). Live signal == backtested signal.
  2. Extended parse — sentinel @@CARRYJSON@@, coordinator greps that line (SDK stdout chatter ignored).
  3. Nado funding — root was cfg.exchange=None on host → _venue() class-name fallback → SDK decode works.
  4. Order executionrpc_order.py (per-host, double-gated) + hedger.py (atomic both-legs-or-unwind, readback, no-naked; the LEG-SKEW unwind + any timed-out/erred leg now do a POSITIVE position readback + retry, and ALARM_NAKED_LEG on a residual that won't flatten — never a silent success) + risk.py (basis-tail deleverage / global flatten / HL isolated top-up), now WIRED ACTIVE every loop via carry.run_risk_monitor (after reconcile), which also HEALS LEG_MISSING (flattens a naked survivor) + state.py (persistent book + reconcile). Entry = marketable market_open (taker; no native post-only entry). All writes go through the SAME gate (egress-assert + ARMED/DRY + RateGovernor): Orders OFF (ARMED default 0) → the monitor/heal/deleverage PLAN only and place nothing.
  5. Self-impact OI-cap — each leg ≤ 5% of min(venue OI); thin-alt legs auto-shrunk/skipped.
  6. SKIP_MARK gap FIXED at sourcerank_and_route paired coins on funding PRESENCE, but a venue can publish funding-panel data for a coin it has no live tradeable MARK for (delisted/paused/symbol-map mismatch) → those legs detonated SKIP_MARK at hedge time.
    • Tradeability gate in rpc_read._funding_all: each funding cell is now gated on a venue-correct live mark > 0, so a (coin,venue) cell in the funding matrix ⇒ the coin is tradeable there. Cheap/reuse: HL one all_mids() snapshot · Pacifica one _prices() snapshot (mark+funding same pass) · Extended get_market_statistics(...).mark_price · Nado mark_price(<canonical key>). DEGRADED (mark source down) keeps the cell (never a silent whole-venue drop, logged to stderr); a fetchable mark==0/absent drops that one coin for that one venue.
    • Nado bare-coin mark bug (the actual SKIP_MARK trigger): Nado mark_price()/_pid() key strictly on the canonical symbol ('SOL-PERP') in _symbol_to_pid, so mark_price('SOL') KeyErrored → 0.0. The hedger's mark op + post-fill readback used the bare coin → 0.0SKIP_MARK L=0.0 on every Nado leg. Added a shared venue-correct _mark_for(cfg, client, coin) (resolves bare → '<COIN>-PERP'asset()) in both rpc_read.py (the mark op) and rpc_order.py (the mark op and _read_one readback). Read-only — places nothing, no ARMED/dry-gate change.
    • VERIFY (DRY, zero orders): --arm DRY SKIP_MARK count 2 → 0; the Nado-LONG legs (AAVE/SOL/ETH/ENA) now reach FAIL_LONG_UNCONFIRMED (expected no-fill-in-dry), not SKIP_MARK. Plain DRY book sane, all 4 venues report, every |funding| ≤ venue cap, gate logged 0 un-tradeable drops on the live snapshot (no coin currently has funding without a mark — gate is protective, not yet load-bearing today). smoke_state.py 25/25.

REMAINING correctness items (not blockers for paper-trade, but for trusting the LIVE %):

  • cap-collection 2.2↔8% — does a cap-pinned venue actually pay posted funding to our short? live-only (paper-trade).
  • self-impact magnitude — OI-cap bounds it; realized compression measured by paper-trade.
  • single regime ≤342d — not multi-cycle OOS; revisit as funding history grows.
  • cap_ann map vs live structural funding ceilings — reconcile exact clamp values before live.
  • cleanup: delete superseded book_state.py (replaced by state.py).

GATES TO LIVE (operational): dedicated carry host (CARRY_DEDICATED_HOST, non-prod IP — else WRITES refused) → capital per venue → ARM sign-off per host → paper-trade (ARMED=1 DRY_RUN=1) → flip DRY_RUN=0.

Sizing (futures — leverage free/max, knob = risk-budget, DD = output)

L = RISK_BUDGET_PCT / BASIS_SHOCK_PCT. Default 33% / 3.3% (Oct-10-2025 EW perp-spot decouple) = 10×. ROE = edge × L; basis-cascade loss = shock × L. Alt-heavy book → assume −6.6% → L≈5×. NOT Kelly/vol-target (they read flow-vol 0.78%, blind to basis-tail → ruinous leverage).

Backtest→live gaps (the 12 — see project_combo_deep_test_2026_06_25)

funding source per-venue · spike×8760 vs trailing+clamp (item 1 above) · funding_cum vs level · stale baseline · 1×/book≤eq (futures) · spot==mark basis-tail · fill instant-vs-limit · cap-collection 2.2↔8% (live-only) · Nado snapshot-history · single regime · depth/borrow/venue-pos-cap · per-venue liq.

Run (DRY-RUN, read-only)

python3 carry.py --equity 50000          # one pass, fixed equity
python3 carry.py                          # sum live equity across venues
python3 carry.py --loop 3600              # repeat hourly

About

Funding-carry / basis trading bot

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors