Skip to content

Delta-neutral funding carry: multi-asset support, hardening & tests - #1

Open
jzbtc101 wants to merge 95 commits into
khromalabs:dev011from
jzbtc101:ainara-arbitrage
Open

Delta-neutral funding carry: multi-asset support, hardening & tests#1
jzbtc101 wants to merge 95 commits into
khromalabs:dev011from
jzbtc101:ainara-arbitrage

Conversation

@jzbtc101

Copy link
Copy Markdown

Draft — for review & discussion. Large feature branch on top of dev011.

What this is

A cross-venue delta-neutral funding-carry trading capability (Hyperliquid + dYdX v4), now running multiple majors concurrently. It holds equal-and-opposite perps on the two venues and collects the funding differential, price-neutral. Architecture write-up in docs/funding_arb.md; end-user operator guide in docs/delta_neutral_guide.md.

Please review with an eye on these (shared-infra + housekeeping)

  • Conductor vars mechanism (ainara/bureau/plan.py, conductor.py, server.py): a general-purpose, backward-compatible addition — a plan-level vars: block seeded into the scratchpad so step params resolve {{vars.coin}}. Used here to parameterize the trading plans by coin, but it's not trading-specific. This is the one piece that touches your core orchestration — flagging it for a shared-infra review.
  • litellm pin: dev011 bumped litellm to 1.92.0, which is uninstallable on Windows/py3.12 (no wheel; sdist needs Rust). I stayed on 1.81.10. Suggest pinning ~1.91.3 or relaxing the constraint.

Highlights

  • Multi-asset: BTC/ETH/SOL run as independent concurrent hedges. The position watchdog groups both venues' positions by coin and assesses each hedge on its own; the plans carry the coin as an input variable rather than duplicating files.
  • Safety where a shared guard can't stay valid across assets: dYdX is cross-margined per subaccount, so with more than one position the single-position liquidation formula reads optimistically — it degrades to "unknown" and alerts rather than reporting a falsely-safe number.
  • Deterministic order path, no LLM: every order decision is code; the LLM only writes the human-readable report after money has moved.
  • Read-only conversational layer (trading_portfolio): live status / closed-trade review / predicted-vs-realized analytics, per coin or whole-book.
  • Correctness fix: Hyperliquid funding history is now forward-paginated past the 500-row API cap (the signal window had been ending days in the past).

Tests

36 committed unit tests under scripts/evaluation/tests/test_trading_*.py (+ README_trading.md) covering the safety-critical pure functions: the watchdog's per-coin risk assessment, the opener's crossing-limit planner, the engine's EMA/backtest/funding-pagination, and the coin-parameterization. They span both venvs (the executor SDKs can't co-exist with the Orakle deps) — the README documents which venv runs which. No test places an order or needs a live daemon.

Deliberately left for later

  • Real per-coin dYdX liquidation monitoring via subaccount isolation (vs. today's degrade-and-alert).
  • A ceiling on total concurrent exposure across coins.
  • A conversational control skill (start/stop from chat) — wired but disabled during testing so the LLM never sits on the order path.

Verification posture

Validated end-to-end on mainnet at small size (proof-of-machine, not income): full round trips, both legs, entry and exit, delta neutrality holding through real price moves, and BTC/ETH/SOL running concurrently.

🤖 Generated with Claude Code

jzbtc101 and others added 30 commits July 15, 2026 12:49
…ion.

Introduces skill_scaffolder, skillbuilder meta-skill, and example skills with SKILL.md docs so users can create capabilities via natural language (including voice). Also fixes multi-word skill class discovery, strips internal ORAKLE signals from chat history, and restores config-driven TTS selection.

Co-authored-by: Cursor <cursoragent@cursor.com>
…yer)

Introduces a `trading/` skill category for a delta-neutral funding-rate
differential strategy across decentralized perpetual venues:

- hyperliquid, dydx: read-only market-data skills (funding, prices, open
  interest, order-book slippage). No keys, no orders. dydx needs no new deps.
- carry_engine: deterministic decision core. `evaluate` gives a point-in-time
  open/sit-out call on the EMA-smoothed cross-venue funding differential;
  `backtest` walks a full history for realized net after fees. Backtest
  reproduces the offline study's net-on-capital exactly at matching params.
- _compliance: jurisdiction acknowledgement gate for future order-placing
  skills. A notice (off by default), not a compliance control; read-only
  skills deliberately do not gate.

All skills ship SKILL.md docs. No order placement in this commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Standalone process (own venv) that owns the heavy venue signing SDKs, which
cannot coexist with Orakle's deps (dydx-v4-client forces httpx<0.28, breaking
solana used by framework/auth.py). Orakle-side skills talk to it over HTTP.

- config/compliance: shared-yaml loader; layered submit gate (dry_run default,
  testnet allowed, mainnet requires jurisdiction ack).
- venues/hyperliquid, venues/dydx: credential validation + account-state reads,
  verified against both live testnets. Order placement is stubbed (no live
  submit in this commit).
- venues/dydx_permissioned + setup_dydx_permission: scoped trade-only
  permissioned key (place/cancel + subaccount + majors only, no withdraw),
  so the main wallet seed need not live in the running bot's config.

No order placement and no on-chain writes in this commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HyperliquidExecutor now builds a signing Exchange from the agent wallet and
implements place_order (behind the submission gate) and cancel_order, plus
open_orders. Verified end-to-end on testnet: placed a resting BTC limit order
15% below mid, confirmed it rested, cancelled it, confirmed the book cleared.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DydxExecutor now auto-detects signing mode from config: "permissioned"
(account_address + agent_private_key + authenticator_id — a scoped trade-only
API key) or "mnemonic". validate() confirms on-chain that the API key is the
one authorized in the registered authenticator (mirrors the HL agent-approval
check). Verified against testnet: authenticator 2336 (place/cancel/batch-cancel,
subaccount 0, no withdraw) authorizes the configured key.

selftest: exercise the dry_run gate instead of a live submit. The prior version
passed dry_run=False, which on testnet the gate permits — so it was placing real
orders. It now never submits.

dYdX order-proto construction still pending the first funded testnet subaccount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
server.py exposes the daemon over localhost: /health, /venues/<v>/validate,
/state, /orders, POST /order, POST /cancel. _resolve() normalizes the adapters'
sync (HL) and async (dYdX) calls. Order placement defaults to dry_run=True — only
an explicit dry_run:false reaches the venue, and the adapter compliance gate
still applies; every order/cancel is logged.

Verified end-to-end via the test client against HL testnet: reads return live
state, a no-dry_run body is safely refused, and a real place->list->cancel round
trip over HTTP rested and cleared an order. dYdX order/cancel remain pending the
funded-subaccount test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TradingExecutorClient proxies to the standalone executor daemon over localhost
HTTP (validate / state / orders / place / cancel / health), keeping Orakle
dependency-light. Order placement defaults to dry_run; the daemon remains the
single, network-aware enforcement point for the compliance gate. Unreachable
daemon returns a clear error instead of failing.

Verified end-to-end across the venv boundary: Orakle venv skill -> HTTP ->
executor venv daemon -> HL testnet. Reads returned live state, dry_run was
refused, and a live place->list->cancel round trip driven through the skill
rested and cleared an order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… live

watchdog.py guards the two failure modes that can wipe the delta-neutral strategy
between Conductor runs: a BROKEN HEDGE (one leg gone, the other naked-directional)
and LIQUIDATION PROXIMITY. assess() is a pure, unit-tested function; the Watchdog
loop reads live state, assesses, and — only in opt-in 'active' mode (default
'monitor') — flattens the exposed leg.

HL adapter state() now reports per-position liquidation_px and liq_distance_pct.

Verified live on testnet: opened a naked HL long, the watchdog detected the broken
hedge (critical) and auto-closed it via a reduce-only order; position returned to
flat. dYdX close/reduce actions are recorded pending dYdX order construction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DydxExecutor now places stateful (LONG_TERM) limit orders and cancels them,
signing as the scoped permissioned key: Wallet(api_key) + TxOptions(
authenticators=[id], sequence, account_number) on behalf of the main account.
Adds open_orders (indexer) and returns client_id + good_til_block_time so cancel
can reconstruct the order id. server.py and the Orakle thin-client skill handle
dYdX's cancel-by-client_id (vs HL's oid).

Verified end-to-end against the funded testnet subaccount, through the full stack
(Orakle skill -> HTTP -> daemon -> chain): dry_run refused, a live stateful limit
order rested on-chain, then cancelled cleanly. Both legs now execute live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dYdX state() now returns signed per-position sizes, so the watchdog sees both
legs' positions (not just open/closed). Adds DydxExecutor.place_market_reduce
(SHORT_TERM IOC reduce-only) — dYdX rejects reduce_only on resting orders
(code 9003), so a close must cross immediately. The watchdog now flattens either
venue on a broken hedge, and _run_coro lets its sync loop drive the async dYdX
path from any context.

Verified live on testnet: opened a naked dYdX long, the watchdog detected the
broken hedge and auto-closed it via market-reduce; both legs returned flat. The
HL auto-close path (tested earlier) is unchanged. Delta-neutral protection is now
symmetric across both venues.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
carry_engine gains a 'decide' action: given a coin + capital it fetches its own
funding history for both venues (mainnet signal by default), runs the smoothed-
spread decision, and returns a FLAT actionable verdict incl. a `sit_out` boolean.
This makes it callable as one skill step — the plan can't thread history arrays
through string templating. evaluate/backtest still take supplied arrays.

plans/delta_neutral_farm.yaml: evaluate (skill: carry_engine decide) -> execute
(agent, skipped via avoid_step_if when sit_out) -> report. The execute agent
places both legs through the executor and is instructed to never leave a naked
leg. Copy into <config>/bureau/ to load; schedule via scripts/scheduler.py.

Verified: decide returns live verdicts for BTC/ETH/SOL; the plan loads and its
DAG validates via bureau.plan.Plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`python -m executor.watchdog` wires the two venue adapters and runs the guard
loop, so it can run as its own always-on process alongside the daemon. Warns on
startup when mode is 'monitor' (report-only) with how to enable 'active'
(auto-flatten). Verified it boots cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs/funding_arb.md: what the delta-neutral funding-carry system is, the edge, how
it maps onto Ainara, what's tested, key design decisions.
docs/funding_arb_runbook.md: step-by-step testnet run guide (4-terminal topology,
prerequisites, startup order, what to watch, kill switch, caveats).

Force-added: the repo's .gitignore excludes docs/ (paper artifacts), but these are
tracked capability docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… wheels)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
check_order_cap refuses any OPENING order whose USD notional exceeds
trading.executor.max_order_notional_usd — a deterministic ceiling enforced in the
daemon, so neither the carry engine's sizing nor the LLM execute agent can exceed
it. Reduce-only / closing orders are never capped (a size limit must never be able
to trap a naked leg). Wired into both venue adapters' place_order, ahead of the
submission gate. Unset = no cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n backstop

Position sizing is now risk-driven rather than a static capital figure:

- carry_engine.decide sizes each leg to trading.max_account_margin_pct% of the
  SMALLER account's live free collateral (× leverage), clamped by the hard
  notional cap. Both legs matched off the binding account. Falls back to
  capital_usd if balances can't be read. Returns a full sizing breakdown.
- Dilution guard: decide subtracts estimated order-book slippage at the sized
  notional from the net edge; if net goes non-positive — or the book can't absorb
  the size — it sits out. At small size slippage ~0 (never triggers); it protects
  the edge automatically as size scales.
- Daemon backstop (server.py): refuses any OPENING order above the margin-rule cap
  computed from both live equities (equity, not free collateral, so it stays
  stable across the two-leg open and can't strand a naked leg). Closes never
  capped. HL state() now exposes free_collateral.

Verified live: sized to $200 (hard-cap-bound) off real testnet balances; slippage
~0.008% at that size; backstop refuses a $715 open, passes a $715 close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The existing `venv/` rule does not match `executor/.venv/` — the isolated
executor virtualenv was one `git add -A` away from being committed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… error

Two independent fixes in the Bureau, both found by running plans for real.

1. Conductor plans were located via the raw platform default
   (`get_default_config_paths()`), which ignores AINARA_CONFIG — while
   ConfigManager, the executor and scripts/scheduler.py all honour it. On any
   machine with the override set, the Bureau read its config from one directory
   and looked for plans in another, loaded zero plans, and returned
   `404 Plan not found` for every trigger. Derive plans_dir from the config file
   that was actually loaded instead.

2. The provider loop computed the real failure reason and then dropped it, so an
   agent step that failed reported "All configured LLM providers failed. Last
   error: None" — pointing at the LLM when the LLM was fine and the agent had
   failed. Record it.

Note: the agent worker runs in a separate process whose logs never reach
bureau.log, so its failure reason otherwise exists only on the console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
call_skill only signals transport-level problems, as an "Error: ..." string. A
skill that completes the round trip and returns {"error": ...} counted as a
completed step — so the plan reported SUCCESS and `on_failure: notify` never
fired. A step that left an unhedged trading position open reported a green tick.

Inspect the {"result": {...}} envelope and fail the step if the payload carries
an error. Deliberately narrow: domain-level "did nothing" outcomes (a skill that
declined to act) are still successes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ConfigManager sets and honours `data.directory` (chat memory, vector db, green
memories, pybridge, the orakle scheduler, backup all read it). These six called
platform_utils.get_default_data_dir() directly instead, so they ignored the
configured location and always wrote to the platform default:

  - orakle/skills/tools/notes.py (module-level constant)
  - orakle/skills/tools/habit_tracker.py
  - framework/tts/kokoro.py, framework/tts/piper.py
  - framework/wakeword/openwakeword.py
  - orakle/skills/tools/skillbuilder.py — which also *documented* that pattern to
    generated skills, propagating it to every scaffolded skill

Add config.get_data_dir() (the `data.directory` key, falling back to the platform
default) and point all six at it. The fallback is byte-identical to the previous
behaviour when the key is unset, so this is a no-op for anyone not overriding it.

Matters most on Windows, where the platform default resolves to the
OneDrive-redirected Documents path — i.e. user data in cloud sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The two-leg sequence previously existed only as prose in an LLM agent's goal.
Move it into the process that owns the venue SDKs and the gates, so the unwind
cannot be lost with a dead caller.

POST /hedge/open   refuse unless flat -> short leg -> confirm by POSITION ->
                   long leg -> confirm -> unwind the short if the long fails.
                   The only outcomes are both legs on, or nothing on.
POST /hedge/close  close every leg, confirm flat, shout PARTIAL_CLOSE_FAILED
                   (HTTP 500) if anything survives.

Details that turned out to matter:

- Verify by reading positions, never by the place response: HL's place_order
  returns submitted=True once the request is sent, which says nothing about
  acceptance or fill. dYdX's tx_code is trustworthy.
- Legs must CROSS the book (sell below ref, buy above). The carry engine prices
  its edge with taker fees; the old prose said "buy just below / sell just
  above", i.e. maker pricing, which rests and half-builds the hedge.
  cross_pct is a worst-case cap, not a cost — a crossing limit fills at the
  resting order's price.
- Size is floored to fit the binding cap AT THE CROSSING PRICE, both legs
  equally (so shaving cannot break delta-neutrality). Sizing to a cap exactly
  meant the buy leg breached it the moment it crossed up, and the venue refused
  the long AFTER the short had filled.
- The opener applies min(hard cap, margin rule) itself: it calls the adapters
  directly and so does not get the margin backstop that lives in the
  /venues/<v>/order route.
- A refused leg short-circuits instead of waiting out the fill timeout — the
  gate refuses instantly, and every second spent waiting is held naked.

HyperliquidExecutor.flatten() now owns HL close pricing (mark -> live mid ->
refuse). Both the daemon and the watchdog built that price themselves off
`mark_px or 0`, so a missing positionValue produced a limit of ZERO: closing a
short became a buy at 0 (never fills, leg stays naked), closing a long a sell at
0 (crosses the book at any price). Both look like well-formed orders.

/health also surfaces the position watchdog's alarm file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dydx.py hardcoded "liq_distance_pct": None ("not yet wired"), and
watchdog.assess skips any leg whose distance is None — so liquidation proximity
was only ever checked on Hyperliquid. The dYdX leg was unguarded.

Hyperliquid hands us a liquidationPx; dYdX does not, so derive it. dYdX v4 is
cross-margined per subaccount: liquidation triggers once equity falls to the
maintenance margin requirement, giving

    P = (equity - size*mark) / (|size|*mmf - size)

Validated against HL's own number: at mmf=0.0125 this reproduces Hyperliquid's
reported liquidation price to the dollar (274,665 vs 274,664.9).

Single-position only — with several positions the MMR is a sum, which the rest
of the stack (positions[0]) does not model yet.

_market_risk() reads oraclePrice + maintenanceMarginFraction from the indexer.
BTC-USD ships imf=0.02 (that is where the "50x" comes from — a market parameter,
not an account setting) and mmf=0.012.

None now means two different things, hence liq_note: "not liquidatable by price
alone" (a long whose equity exceeds its notional literally cannot be liquidated
— correct, and silent) versus "risk params unavailable" (blind). The watchdog
warns on the latter, because silence used to look identical to safety.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_act fired a close, stashed the venue's answer in a dict, and run() threw it
away. No verification, no logging, no escalation. Observed live: it retried a
close every 5s for ~30 minutes against a dead order book while cheerfully
logging "BROKEN HEDGE", protecting nothing. A safety net that cannot act and
does not say so is worse than none, because you stop watching.

- Log what the venue ACTUALLY said, and count consecutive attempts. Escalation
  is driven by the leg still being open on the next poll, NOT by the venue's
  response — the killer case is {"submitted": True, "tx_code": 0} with no fill
  (an IOC against an empty book), which looks like success at every layer.
- Escalate after `escalate_after` attempts: ERROR + a JSON alarm file the daemon
  surfaces on /health (separate processes, so a file is the channel; alarms over
  5min old are marked stale — a dead watchdog's alarm should not read as live).
  ~50s to escalate, versus never.
- _try_close(): an exception is a FAILED ATTEMPT, not an escape. Previously a
  raising adapter skipped the counting entirely and bubbled to run()'s
  catch-all, which logs and sleeps — the same silent failure in a different hat.
- Debounce broken-hedge closes by `confirm_polls` (default 3). A two-leg open is
  TRANSIENTLY a broken hedge, and dYdX state comes from a lagging indexer, so
  acting on the first sighting flattens healthy hedges mid-open — observed: it
  closed a leg 13s in while the opener was still working. Liquidation proximity
  is never debounced.
- Back off after escalation (30s -> 60 -> ... -> 300s cap): past escalation the
  failure is structural and a human is required by definition, while on mainnet
  every retry is an on-chain tx burning gas on an action that cannot succeed.
  It never stops retrying, and monitoring never backs off.
- Close pricing delegated to the adapter so it cannot drift from /hedge/close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
decide() was stateless — it could only ever say open/sit_out — so the live
system entered positions and never left them, while the backtest has always
modelled a ~14 day hold with fees charged for entry AND exit.

decide_exit(): reads the CURRENT position via _current_side() (public APIs, no
keys, like the rest of the engine) and compares it to the smoothed spread,
returning close/hold/none plus skip_close for the plan's gate. Implements the
rule the backtest already models (want = sign(sig) if |sig| > thresh else 0;
close when want != pos; a flipped sign closes and the entry plan re-opens the
other way). Every failure path returns hold — an unreadable signal is not a
reason to abandon a paying position, and a broken hedge is the watchdog's job.

Three guard bugs, all the same shape — something unmeasurable defaulting to
permissive:

1. Sizing rounded UP into a hard ceiling: round(300/64106.5, 6) = 0.00468 is
   $300.02 against a $300 cap, so the daemon refused the long leg after the
   short had already filled. Floor, never round, toward a cap.
2. funding_network was doing three jobs: the SIGNAL (mainnet — correct, testnet
   funding is artificial), the reference PRICE, and the ORDER-BOOK depth. So the
   dilution guard priced mainnet's deep book while the order hit a testnet book
   with no bids: it measured one exchange and traded another. _venue_network()
   now resolves where each venue actually trades; _round_trip_slippage no longer
   takes a network at all.
3. `slip = slip_frac if slip_frac is not None else 0.0` — an unreadable book
   became ZERO slippage, the most optimistic value available, and the trade
   proceeded. Now sits out. A guard that cannot measure must refuse.

Also: walk all FOUR sides rather than doubling the entry. Entry sells into the
short venue's bids and buys the long venue's asks; the EXIT needs the opposite
books, which were never checked. Doubling the entry cost cannot tell you whether
you can get OUT — dYdX had asks (we bought in) and no bids (we could never sell
out). With symmetric books the result is identical to the old estimate, so this
only bites on asymmetry, which is exactly when it must.

Verified: decide() on the config that opened the untradeable position now sits
out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The farm plan's `execute` step was an agent: it handed the engine's verdict to an
LLM with prose instructions to place both legs and "never leave a naked leg". It
failed every run and never placed a single order.

It was also the wrong shape. decide() already returns a complete instruction, so
there was no judgement left to exercise — the agent could only retype fields,
slowly and unreliably. Worse, the naked-leg unwind (the most safety-critical
action in the system) existed ONLY as prose improvised by a fast non-reasoning
model, and ~85s of LLM deliberation sat INSIDE the window where one leg is
naked, widening the exact exposure it was told to prevent. Replaced with a
deterministic skill step calling the daemon's /hedge/open; it opened a real
hedge on the first attempt.

Rule: deterministic code for anything touching orders; the LLM only for the
`report` step — prose for a human, after the money has moved, where a bad
summary costs a paragraph rather than capital.

- executor_client: open_hedge / close_hedge actions, each taking the whole
  verdict (scratchpad templates only resolve one level, so passing individual
  nested fields would silently not resolve). Both RE-CHECK the verdict
  themselves — the Conductor's avoid_step_if fails OPEN on an unresolvable path,
  so this second gate is the one that actually holds.
- delta_neutral_exit.yaml: evaluate_exit -> close -> report, mirroring the farm
  plan. Without it, decide() enters and nothing ever leaves.
- Both plans' avoid_step_if paths corrected to the nested form
  (`evaluate.response.result.sit_out`). The old shallow path never resolved, and
  because the gate fails open, the sit-out gate had never once fired — it would
  have placed orders on a "do not trade" verdict.
- report now depends on the execute/close step it reads, not just evaluate;
  without the edge the DAG was free to run it first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary of both capabilities and the framework fixes, written for Rubén. Leads
with the framework fixes (Part 1) since those matter to Ainara regardless of the
trading work, and several are one-liners that could go upstream on their own.

Force-added: `docs` is in .gitignore, which is also how the existing two
funding-arb docs got in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ture

Both predated the LLM removal and described a system that no longer exists.

funding_arb.md
- The plan is `evaluate -> execute -> report` with execute a DETERMINISTIC skill
  step, not an agent; document the exit plan alongside it.
- Document /hedge/open and /hedge/close, decide_exit, and the watchdog's
  debounce / verify / escalate / back-off behaviour and its dYdX liquidation math.
- "What's built": the full stack has now opened a real hedge on its own, and the
  exit's hold path is validated. Replace "not yet done: full orchestration run"
  (done) with the honest gap: the exit's `close` has never succeeded.
- New design-decisions section leading with "no LLM on the order path" and the
  reasoning, plus "guards fail closed" and "verify by state, not acknowledgement"
  — the three lessons that cost the most to learn.

funding_arb_runbook.md
- Fix the header warning (no execute agent) and add the dYdX-testnet-is-one-way
  warning up front.
- Install BOTH plans; note plans load at Bureau startup only.
- Config: enter/exit thresholds, cross_pct, fill_timeout_s, and the watchdog's
  confirm_polls / escalate_after / backoff keys, with the reasoning inline.
- Risk controls: the dilution guard now walks all four legs (entry AND exit) on
  the network each venue actually trades, and sits out when it cannot measure.
  Drop "the LLM execute agent can't exceed them" — there is no agent.
- Rewrite "what happens when you fire it" for both plans, incl. the two
  independent gates and why the second is the one that holds.
- New "Scheduling the exit" section: decide() is stateless, so without a cron a
  position is entered and held forever. Ships disabled; avoid_if prevents races.
- Watchdog terminal output, and /health's watchdog_alarm (incl. staleness).
- Caveats rewritten: the newest link is the exit's close, not the agent; dYdX
  testnet is one-way; a plan's green tick does not mean the hedge is on.

Force-added: `docs` is in .gitignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three fixes needed before any mainnet run.

1. The dYdX adapter connected to TESTNET.node unconditionally ("mainnet node
   added when we go live there") while resolving the INDEXER by network. A
   mainnet config would therefore READ mainnet state and SUBMIT orders to
   testnet. Orders fail on chain-id mismatch, so nothing lands in the wrong
   place — but the watchdog's close fails the same way, which means a live
   mainnet position with its safety net pointed at the wrong chain.

   dydx_network(network, node_url) now resolves both, and the adapter uses it.
   Mainnet validator gRPC is overridable via apis.dydx.mainnet.node_url; the
   default and two alternatives were verified reachable (all agreed on block
   height, chain-id dydx-mainnet-1).

2. setup_dydx_permission.py passed None as the mainnet node (make_mainnet was
   imported and never used), so a mainnet authenticator could never be
   registered — which is why the mainnet authenticator_id is still null. It now
   shares dydx_network(), and prints the correct config section to save into
   rather than hardcoding "testnet".

3. The venues quantize size differently — dYdX BTC-USD steps 0.0001, HL BTC
   0.00001 — and each silently rounds whatever we send, so ONE size produced TWO
   different fills and the difference was unhedged directional exposure. The
   error is bounded by one step regardless of position size, so it hurts
   proportionally more the smaller you trade: at $60/leg (a $100 account) it was
   0.00003 BTC = ~$1.94 naked, 3.2% of the position, against an expected edge of
   ~$0.20 over a two-week hold — noise bigger than the signal.

   plan_hedge_legs now floors the size to the coarsest step across both venues
   (queried per venue, cached nowhere yet), last, after any cap shave. Verified
   on live mainnet params: mismatch $1.94 -> $0.00. Refuses if the size is
   smaller than one step rather than sending something a venue will round to
   zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This script needs the MAIN wallet mnemonic — the key that can withdraw, i.e.
exactly the authority the permissioned-key design exists to keep out of the
running bot. It needs it once, to sign the registration.

Putting it in ainara.yaml for that one run is worse than it looks:
ConfigManager.save() copies the config to ainara.yaml.bak before writing, and
Orakle exposes PUT /config — so any save while the mnemonic is present leaves a
copy in .bak that survives deleting it from the original. A stale .bak from a
previous session is already sitting in the config dir.

Read DYDX_MAIN_MNEMONIC first, falling back to config. Set in the calling shell
it never touches disk and dies with the terminal. The script prints which source
it used, and the no-key error explains both paths (including deleting .bak if
the config route is taken anyway).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jzbtc101 and others added 30 commits July 28, 2026 23:29
Replaces the render-path probe with the real thing. The skill calls
trading_portfolio status coin=ALL and returns it as the JSON string the
nexus middleware expects; the component renders the book.

Per coin: both legs (side, subaccount, size, notional), entry vs mark,
per-leg uPnL, and a liquidation-buffer bar - the number position isolation
bought, and the most glanceable risk signal here. Then combined uPnL and
net funding/day with paying-vs-earning made explicit, broken out per leg
as APR and $/hour. Above them a book strip: notional per side, total uPnL,
net funding/day with its APR on notional, and the worst liq buffer across
every leg.

Refresh: postMessage paints instantly, then the component polls Orakle
same-origin every 60s (a full-book status touches both venues per coin and
takes ~7s - do not tighten without measuring). Polling pauses while the
panel is not visible. A failed refresh keeps the last good picture behind
a warning banner, because a blank risk view is worse than a visibly stale
one; an age readout and status dot keep staleness from ever being silent.

Money renders to 4dp below $1: this book earns cents per day, and 2dp
turned a real -$0.0249/day into a meaningless -$0.02.

hiddenCapability stays True. The dashboard is reachable via /testnexus and
a direct /run call, but the LLM cannot select it, so trading_portfolio
keeps answering position questions in prose - no behaviour change on a
live-money system. Read-only throughout; it renders data and never trades.

Force-added past .gitignore, as with the other nexus and docs files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…er_info

The dashboard was unroutable: "show me the position dashboard" failed and
Ainara improvised a nonsense fallback (claiming no skill could save HTML and
open a browser). Two causes:

1. hiddenCapability was still True from its probe days. A hidden capability is
   never registered in the semantic matcher, so the LLM could not see the
   skill at all. Set it False - the dashboard only renders read-only data, so
   exposing it moves no money. This reverses the earlier "keep hidden" call, at
   the user's explicit request after hitting exactly this failure.

2. get_capabilities() dropped matcher_info for every type=="nexus" capability -
   the skill branch copied it, the nexus branch did not. Nexus skills are
   registered into the SAME matcher as native skills (orakle_middleware.py:227),
   so without matcher_info a nexus skill could only be matched on its id and
   docstring; its matcher_info was silently inert. Carry matcher_info (and
   embeddings_boost_factor) through the nexus branch too. Benefits any nexus
   skill, not just this one.

With matcher_info flowing and tuned toward the visual medium, ranking measured
against the live matcher: visual phrasings hit the dashboard strongly ("show me
the dashboard ... visually" 0.63, "pull up the dashboard" 0.52, "open my
positions on screen" 0.47), while a pure status question ("am I still hedged
and what funding") pushes the dashboard down to 0.18 and yields to the text
portfolio skill. The matcher only supplies candidates; matcher_info tells the
LLM to prefer text when the user wants to be told numbers rather than shown a
panel.

Read-only throughout; the dashboard renders data and never trades.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d config

A relaunch through the setup wizard wiped ainara.yaml down to defaults,
losing the trading keys (apis.hyperliquid / apis.dydx / trading) and the
LLM providers. Root cause was two failures lining up:

1. config.update_config() was not a merge. update_dict() mirrored the stored
   config onto the PUT payload, DELETING every key the payload omitted. So a
   partial PUT /config (which the setup wizard sends) silently erased whole
   sections. Since PyBridge's PUT handler saves with save=True, that wrote the
   stripped config to disk. Make update_dict a true deep-merge: add/replace
   what the payload contains, never delete keys absent from it. Replacing a
   value or a whole list (e.g. llm.providers) still works, since non-dict
   values are overwritten wholesale.

2. The wizard's loadBackendConfig() swallowed a failed GET /config and returned
   undefined, so a save step would build its payload from /config/defaults and
   PUT that. The wizard is offered precisely when the backend is unhealthy, so
   this is exactly when it fires. Make loadBackendConfig() re-throw on failure
   (and reject a non-object body) so save steps abort instead of saving a
   default-derived payload, and add a backstop in saveBackendConfig() that
   refuses to PUT an empty/invalid config.

Either fix alone stops the wipe; both give defense in depth. Merge semantics
verified: a partial payload keeps apis.hyperliquid, trading and untouched
nested keys, while values and lists still replace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two dashboard enhancements requested by the operator.

Light/dark toggle: the dashboard was dark-only. Route the remaining
hardcoded colors (side badges, buffer-bar track, funding footer) through
CSS variables, add a light palette under [data-theme="light"], and add a
top-bar toggle. Default follows the viewer's prefers-color-scheme; the
choice persists via localStorage where available (guarded, since a file://
open cannot use it). Themes the dashboard only, not the wider Polaris app.

Margin + leverage per leg: trading_portfolio now surfaces committed margin
and leverage for each leg (it already fetched the raw venue data; it just
was not exposing it). HL uses the exchange-reported marginUsed; dYdX uses
the initial-margin requirement (subaccount equity minus freeCollateral,
since dYdX reports no per-position marginUsed). Leverage = notional /
committed margin, the same formula for both legs (operator's choice of
basis). Live: HL 10x across the book; dYdX 50x on BTC/ETH and 20x on SOL —
the SOL difference is real (higher initial-margin tier). dYdX reads higher
than HL because its isolated subaccounts hold spare collateral beyond the
requirement; this does not change the liquidation buffer, which is shown
separately. portfolio.py is read-only and not on the executor path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The config-wipe incident had no recovery path: the backup routine only zips
data/*.db, so ainara.yaml (with the live keys) was never captured, and the
single sibling .bak is overwritten every save. Two changes close that.

config.py: every save() now snapshots the OUTGOING ainara.yaml (before the
overwrite) to a guaranteed-local, non-synced, versioned folder
(%LOCALAPPDATA%/Ainara/config-backups on Windows; ~/Library/Application
Support and ~/.local/state equivalents elsewhere), keeping the last 15 and
chmod 600 on POSIX. The location is deliberately NOT derived from
data/cache config (a bad wizard can point those at OneDrive) nor from the
Documents-based platform default; snapshotting the outgoing file means a bad
save cannot destroy the last good state. Best-effort: a backup failure never
propagates out of save().

docs/troubleshooting.md: new "Config, startup, and the one rule that matters
most" section — the safe-restart rule (never restart executor/watchdog during
a config problem; they hold the keys in memory) plus a table for the
PyBridge-crash / wizard-wipe / Kokoro-missing / OneDrive-path failure modes.
Adds a "2026-07-29 incident" narrative in the same style as the 07-27 one,
with the generalizable lessons (merge-not-mirror, don't seed a destructive
write from defaults, back up what has no other copy, the least-recently-
restarted process is often your only intact copy of state).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Polaris and the headless scheduler each supervise services with their own
notion of "already running" (scheduler matches by command string, Polaris
only ran a port check in the packaged build), so running both — a 24/7
trading scheduler plus a source-mode GUI — spawned a second Orakle and
Bureau on top of the scheduler's. Two servers on one port, flapping
nondeterministically on Windows.

Make Polaris's launch idempotent instead of picking a single owner (PyBridge
is GUI-tied and should not be supervised headless, so a single-owner model
would fight the design):

- startServices() health-checks each service first. If it is already up
  (e.g. the scheduler's Orakle/Bureau), mark it `attached` and adopt it
  rather than spawning a duplicate; only services that are actually missing
  (typically just PyBridge) are started. A desktop-only user with no
  scheduler finds every port free and starts all three, exactly as before.
- An attached service is never stopped or restarted by Polaris: Service.stop()
  refuses it (closing the GUI must not kill the scheduler's Orakle and take
  down the trading loop), and the health monitor's restart path skips it
  (its owner brings it back; we just re-attach). Polaris also holds no child
  handle for an attached service, so this is belt-and-suspenders.
- checkPortsAvailability() (the packaged pre-flight) now treats a port held
  by a healthy instance of our own service as attach-able, not a fatal
  conflict; only a foreign occupant still aborts with the port dialog.

Residual: if Polaris and the scheduler cold-start in the same instant,
neither sees the other yet and both may spawn. In practice the scheduler
starts at logon well before the GUI opens, so Polaris attaches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An independent audit of the position watchdog found its coverage correct —
it reads every coin and every dYdX subaccount (state() is book-wide), fails
closed on an unreadable venue, and closes the naked leg (debounced) in active
mode. Two follow-ups from that audit:

1. _market_risk fetched /v4/perpetualMarkets?ticker=X once PER open coin every
   poll — at a 5s watchdog cadence across several coins that eats into the dYdX
   indexer's per-request rate budget (shared with cron exits, the portfolio
   skill and the carry engine), and a 429 makes the guard read the venue as
   unreadable and go blind (fail-safe, but unguarded while blind). Cache a
   single all-markets snapshot (short TTL, default 3s) and index tickers
   locally: N coins now cost ONE request per poll, flat as coins are added.
   Fail-soft is unchanged — a bad read returns (None, None) -> liq_unknown
   alert, never stale data, never a wrong action. Verified live against the
   indexer: 3 coins -> 1 request, cache hit within TTL, refetch after, unknown
   ticker -> (None, None).

2. docs/troubleshooting.md: note that trading.watchdog.mode defaults to
   'monitor', so a config wipe silently downgrades the watchdog from auto-close
   to report-only; confirm mode: active after any config restore.

Takes effect on the next watchdog restart; the running guard was left running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ainara kept answering "show me my delta neutral positions" in text: the
words "delta neutral" scored trading_portfolio (0.461) above the dashboard
(0.325), so the visual "show me" lost. Rewrite the dashboard matcher_info to
own the SEE/SHOW/VIEW/PULL-UP/DISPLAY + positions/delta-neutral/hedges space
explicitly, stating the visual verb is the trigger even when the user names
the positions or the strategy, and listing the exact phrasings. Verified
against the live matcher: every visual phrasing (incl. "show me my delta
neutral positions", now 0.500 vs 0.461) routes to the dashboard; plain prose
("am I hedged", "what funding am I earning") stays with the text skill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…enticator

Adding HYPE to the delta-neutral book: the on-chain authenticator filters by
market as well as subaccount, and the market list was hardcoded to BTC/ETH/SOL
[0,1,5], so HYPE orders (clobPairId 319) would be rejected by the chain. Add
319. Still needs a --broadcast to register the new authenticator and a config
update to authenticator_id. Flagged inline: the "add any token" work should
derive these clobPairIds from the configured coin map so no code edit is
needed per market.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…set)

plan_hedge_legs hardcoded a $1 price tick, so a sub-$1 asset floored its
crossing sell to zero and was refused — only BTC/ETH/SOL (all >= ~$74, where
whole dollars are a valid tick) could open. Thread the real per-venue price
tick through instead.

- plan_hedge_legs gains price_tick (DEFAULT 1.0 = the old whole-dollar
  behaviour, so majors and every existing test are unchanged). It rounds each
  leg DIRECTIONALLY to the tick (floor sell, ceil buy), guaranteeing
  sell <= ref < buy, and refuses only when the tick genuinely cannot express a
  crossing sell above zero.
- New price_tick() on each adapter: dYdX returns tickSize (flat market param,
  from the shared all-markets snapshot); HL computes its sig-fig + decimal-cap
  grid from szDecimals at the ref price.
- _hedge_price_tick picks the COARSER of the two venues' ticks so one rounded
  price is valid on both legs (venue ticks are decimal-nested). /hedge/open
  passes it; None falls back to the $1 default.

Validated live end-to-end (read-only): BTC/ETH/SOL still cross, and HYPE, XRP
and sub-$1 DOGE now build valid on-grid crossing pairs. 14/14 unit tests pass
(8 existing behaviours preserved + 6 new: HYPE/XRP/DOGE crossing, default-tick
parity, coarse-tick sub-$1 still refused).

Note: majors now cross at the true cross_pct offset (~0.05%) rather than the
accidental ~$1 buffer a coarse tick gave them. Takes effect on the next
executor restart; a watched test open is the right first live exercise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rs open

The guard blanket-refused sourcing from any subaccount holding a position, so
you could not fund a new coin's subaccount without first closing an existing
hedge — the exact wall hit when adding HYPE (subaccount 0 holds BTC). But
moving FREE collateral (equity minus the position's margin requirement) can
never drop a position below its initial margin; it only reduces the buffer
above it. Add an opt-in --allow-funded-source that permits this, keeping the
--min-source floor and printing the resulting buffer. Default behaviour is
unchanged (still refuses without the flag). Verified live: without the flag
subaccount 0 (BTC) is refused; with it, a 32-of-128-free transfer to the new
HYPE subaccount is planned, leaving 96 free and BTC's margin untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ot OneDrive

The conductor built its reports dir from get_default_log_dir(), which is
Documents-based on Windows and thus syncs into OneDrive — so plan reports
landed in OneDrive\Documents\Ainara\Logs while every other log correctly
honoured the local logging.directory (AppData\Roaming\ainara\Logs). Read
logging.directory from config_manager first, falling back to the platform
default only when it is unset. Keeps all logs local-first. Takes effect on the
next Bureau restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds three checkboxes to the finish step, all previously stubbed out or
missing entirely:

- "Launch Ainara automatically on system startup" — finishes an existing
  TODO-delayed-for-v0.10 stub (Electron's own setLoginItemSettings, applied
  on boot and on toggle). Generic app convenience, unrelated to trading.
- "Keep the trading executor and position watchdog running" and
  "Automatically close a broken hedge" — new, only shown once real
  Hyperliquid + dYdX credentials are detected. Write
  trading.executor.autostart / trading.watchdog.mode into ainara.yaml via
  the existing pybridge /config path, matching every other backend-config
  checkbox on this step (e.g. backup.directory).

scripts/scheduler.py's _load_executor_config now also honours
trading.executor.autostart (OR'd with the existing scheduler.yaml
services.executor.enabled), so the toggle has a real effect path without
Polaris ever needing to locate or parse scheduler.yaml itself — avoiding a
repeat of the plans_dir split-brain bug (progress_report.md 1.1).

Both settings take effect on the next restart of the trading services, not
live; the wizard copy says so.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… Documents

get_default_config_paths/log_dir/cache_dir/data_dir all resolved through
SHGetFolderPathW's Documents folder on Windows, which OneDrive's "Known
Folder Move" silently redirects into cloud sync on any default Windows 11
setup — syncing config (incl. API keys), SQLite WAL files, and logs without
the app ever choosing to.

Now defaults to %APPDATA% (config/logs/data) / %LOCALAPPDATA% (cache),
matching what Polaris's own config.js already does correctly and what this
project's real config already uses. The old Documents-based config path is
kept as a second search candidate for backward compatibility — never used to
create a new file, only to find one that's already there.

executor/config.py's hand-copied mirror (it can't import platform_utils —
separate venv) gets the same fix.

Also cleared ~762MB of stale app-generated data that had accumulated at
OneDrive\Documents\Ainara\ under the old defaults (Cache/Config/Data/Logs
subtrees, old backup zips) — verified nothing there was still live, left the
two unrelated personal files in that folder untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…posure cap

Two additions to the delta-neutral engine's risk layer:

1. backtest() now models the two knobs decide_exit already exposes live but
   the walk-forward simulation never tested: a separate (typically lower)
   exit_threshold_annual_pct for hysteresis, and min_hold_hours, a floor on
   how long a position must be held before an exit signal is honored. Both
   default to reproducing the old single-threshold, no-floor behavior
   exactly, so every existing caller is unaffected unless it opts in. Real
   round trips have run fees ($0.38) close to funding collected ($0.14),
   with none held past the ~5.6-day fee-breakeven point — this is what
   answers whether either knob would have helped.

2. A book-wide exposure cap (trading.max_concurrent_positions, default 5;
   trading.max_book_notional_usd, unset = uncapped), checked in two layers
   matching every other guard here: carry_engine.decide() sits out cleanly
   before sizing if opening a coin would breach the cap, and the executor
   daemon independently re-checks the same thing right after the "only open
   from flat" preflight in /hedge/open — so a stale or bypassed engine
   decision can't push the book over the limit either. Closes the gap
   flagged and deferred back on 2026-07-25: nothing bounded the WHOLE book,
   only ever one order at a time, as more coins (BTC/ETH/SOL/HYPE) went live
   on the same account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every open coin shares ONE Hyperliquid cross-margin account, and nothing
watched the account's aggregate margin health as a leading indicator: several
coins can each look individually safe on their own liquidation distance while
the shared account's total margin usage climbs toward no cushion left for a
move that hits more than one at once. HL's own liquidationPx is already
correctly cross-margin-aware (unlike dYdX's hand-derived single-position
formula, which needed subaccount isolation), so this isn't fixing a
correctness bug — it's a signal assess() had no other way to surface.

_assess_book_margin is a pure function using only fields state() already
returns (perp_account_value, free_collateral) — no adapter change needed.
Merged into guard_once() rather than folded into assess() itself, so
assess()'s existing tests stay exactly as they were: this only adds a
finding/action, never changes anything assess() already produced.

Deliberately alert-only: a book-wide auto-de-risk would touch every open
hedge simultaneously, a materially bigger action than anything else this
watchdog does on its own, and stays a separate decision for later.

Defaults: trading.watchdog.hl_book_margin_warn_pct=70,
hl_book_margin_critical_pct=85. Both config-driven, easy to retune once real
utilization has been observed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ORAKLE_LOG/BUREAU_LOG and, when enabled, executor.log/executor_watchdog.log
are raw subprocess stdout/stderr redirects held open by start_service() for
each process's whole lifetime — they append forever with no rotation,
unlike orakle.log/bureau.log/pybridge.log, which already rotate via the
framework's own RotatingFileHandler.

Can't use RotatingFileHandler here: that intercepts individual
logger.emit() calls, but this content bypasses Python's logging entirely —
it's the OS writing straight into an inherited file descriptor. Renaming the
live file to rotate it would leave the subprocess writing into the now-
invisible, renamed-away inode forever, since nothing here can tell a plain
subprocess to reopen stdout the way SIGHUP tells nginx/syslog to.

rotate_log_if_large instead copies the content to a numbered backup and
truncates the ORIGINAL file in place — logrotate's own "copytruncate"
strategy, built for exactly this situation — so the subprocess's existing
file descriptor stays valid. Checked from watchdog_loop's existing cycle
every 60s (LOG_ROTATE_CHECK_INTERVAL), mirroring the heartbeat-log pattern
already there.

New logging.rotation.max_size_mb (10) / logging.rotation.backup_count (5)
keys, deliberately separate from the framework's existing
logging.max_size_mb/logging.backup_count: that key's default is a raw byte
count despite the "_mb" name, and this doesn't inherit the ambiguity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rol work

funding_arb_runbook.md: document the book-wide exposure cap (now six risk
guards, not four) and the HL book-margin monitor's config keys; fix a stale
comment claiming exit_threshold_annual_pct isn't backtestable — it is, as of
this session.

progress_report.md: close out 6.1's "remaining gap" note now that managed-
service logs actually rotate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Brings in Rubén's v0.11 work: the setup-wizard refactor (monolithic
setup.js split into setup/core/* + setup/steps/*), the skill
configurable-properties system (skill_properties.py,
skill_config_scanner.py), Nexus Apps property configuration, the Windows
move to Saved Games, and Wizard Hyperliquid API support.

Conflict resolutions of note:

- capabilities/skills.py: adopted upstream's flat/nested skill-discovery
  split but kept our _to_pascal naming in both branches. Upstream's
  str.capitalize() would resolve carry_engine.py to TradingCarry_engine
  and silently fail to load the trading skills.

- setup.js: took upstream's rewrite wholesale, then re-applied our
  config-wipe guards into the module that now owns them
  (setup/core/api.js): loadBackendConfig re-throws instead of returning
  undefined, and saveBackendConfig refuses an empty/non-object payload.
  Upstream's extraction had reintroduced the swallow-and-return-undefined
  behaviour that let the wizard PUT a defaults-derived config over the
  user's trading and LLM keys.

- setup/steps/finish.js: ported the Trading Automation section
  (executor autostart / watchdog mode) and the re-enabled launch-at-login
  checkbox into the new step-module shape. The markup survived the
  auto-merge in setup.html.

- platform_utils.py: took upstream's slimmed version; the path helpers
  now live on ConfigManager. Our get_data_dir() wrapper (which honours
  the data.directory config key) is kept, rebased onto
  ConfigManager.get_default_data_dir().

- config.py: kept both upstream's Nexus-scope guard on update_config and
  our deep-merge semantics with the comment explaining why partial PUTs
  must not delete absent keys.

- conductor.py: kept our _skill_reported_error helper over upstream's
  inline equivalent (same feature, developed in parallel); forensic
  reports still honour logging.directory, now falling back to
  ConfigManager.get_default_log_dir().

- executor/config.py: the standalone executor venv can't import the
  framework, so its hand-copied path mirror was still hardcoding
  %APPDATA%. Updated to search Saved Games\Ainara\Config first with the
  legacy AppData locations as fallbacks, so it resolves the config both
  before and after the Windows migration runs.

Verified: full byte-compile, imports of every touched module, all 11
committed trading/scheduler tests pass (4 of them under executor/.venv),
and every skill file still resolves to the class name discovery expects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_default_config_paths, get_default_log_dir, get_default_cache_dir and
get_default_data_dir all resolve through _get_windows_saved_games_path,
which shelled out to PowerShell for:

    [Environment]::GetFolderPath('SavedGames')

.NET's Environment.SpecialFolder enum has no SavedGames member, so that
call raises "Unable to match the identifier name SavedGames to a valid
enumerator name" and exits non-zero every single time. Every lookup paid
the cost of spawning a PowerShell process purely to fail, then fell back
to expanduser('~/Saved Games').

The fallback happens to be right on a default profile, which is why this
went unnoticed — but FOLDERID_SavedGames is never consulted, so the path
is simply wrong for anyone who has relocated the folder, and the failure
is silent either way. Measured cost on Windows 11: ~393ms per call across
four call sites.

Python now calls SHGetKnownFolderPath directly via ctypes (~0.002ms, no
subprocess) and caches the result, since a known folder cannot move while
the process is running. The ~/Saved Games fallback is kept for the case
where the API itself fails, but it is now genuinely a last resort rather
than the only code path.

The two JS copies of the same helper (polaris/framework/config.js and
polaris/framework/WindowsMigration.js) had the identical bug. Node has no
known-folder API, so they resolve it through the shell namespace
(shell:SavedGames) instead, which does work, and cache it as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…migration path

Two hardening changes for Windows installs that have not been through the
v0.11 Saved Games migration.

get_default_config_paths listed exactly one real candidate on Windows
(Saved Games\Ainara\Config\ainara.yaml). Index 0 of that list is also
where load_config CREATES a config when it finds nothing, so on a machine
whose config still lives in AppData the result is not "config not found"
— it is a silently generated default written on top of a working install,
with the user's API keys and trading credentials apparently gone. The
Electron layer migrates AppData at startup, but the Python services are
routinely started without it (the scheduler, the executor, a bare
`python -m ainara.orakle.server`), so they can hit this on their own.

The two pre-v0.11 AppData locations are now search candidates after the
current default. They are never used to create a config, only to find one
already sitting there, and the creation target at index 0 is unchanged.
Ordered live-location-first: <appdata>\ainara\ainara.yaml is the file a
working install actually uses, and <appdata>\ainara\Config\ainara.yaml is
an older layout that may still be present but stale.

Separately, _local_config_backup_dir pointed at %LOCALAPPDATA%\Ainara,
which Windows resolves to the same directory as the %LOCALAPPDATA%\ainara
that the Saved Games migration copies out and then renames to
*.old.migrated_to_savedgames. Paths are case-insensitive there, so the
one directory holding the recovery snapshots for a config wipe was itself
inside the blast radius of a migration. Moved to a sibling directory the
migration does not match; the nine existing snapshots were copied across
and hash-verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oreka_desk, oreka_signal and oreka_preflight are maintained in the Oreka
repository and copied here, so they version with the engine they wrap. This
is a first install, not a refresh: Ainara had no copies of them at all.

All three are read-only. None can place, cancel or modify an order, and
oreka_signal does not expose dry_run as a parameter, so there is no value a
model can pass to reach an order path. Live trading stays on Oreka's CLI.

Ainara's own carry_engine.py, portfolio.py and executor_client.py are a
separate pre-extraction implementation and are left untouched, which is why
these files carry the oreka_ prefix.

The skills return a clear "Oreka is not installed" result until Oreka is
importable by Orakle. It is not installable into this venv today:
dydx-v4-client 1.1.6 needs httpx>=0.27,<0.28 and solana 0.36.10 needs
httpx>=0.28. See OREKA_SKILLS.md.

Records the Oreka version and commit each copy was taken from, because
version: "1.0" on every copy could not distinguish a fresh one from a stale
one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports the reporting guards Oreka grew after the extraction back into Ainara's
own carry stack. Every one of these was a live defect here, not a stale copy
of someone else's: none of the guards existed in this tree. They are pure
computation over data already fetched, so none of it needs the venue SDKs and
none of it is affected by the httpx conflict that blocks installing Oreka.

incomplete_window. A review window that caught a trade's CLOSE but not its
OPEN reconstructed it as a live position, with entry_px set to the price the
CLOSING order filled at. The venue's current size settles what fills alone
cannot: a trailing episode the venue says is not there is a window that does
not reach back far enough, and it is labelled as one instead of invented as a
position. A failed size read leaves the reconstruction exactly as it was.

The lookback default. It was 7 days against a strategy that holds for 14,
which is what put the reconstructor in that state to begin with. Now derived
from expected_hold_days with a 90-day floor.

Data quality. Analytics now refuses numbers a delta-neutral hedge cannot
produce, and reports the fault instead of the figure. Faulted trades are
excluded from total_realized_net_usd and from every headline rate. The
completeness check is first and matters most: a window clipped at both ends
counts no fills, sums to $0.00 and reports exactly what a healthy hedge
reports, so no magnitude test can catch it.

Two denominators. Funding rates and net returns are earned against different
bases; both are now named rather than implied, with notional_basis stating
what this build cannot account for rather than letting them be assumed equal.

Liquidation nulls. A missing positionValue divides out to 0.0, not None, so an
unreadable mark was rendering with the benign "not liquidatable by price
alone" note. The Hyperliquid leg now carries a note in both cases and says
which one it is. The dashboard already refuses to paint a null as safe.

Inherited watchdog alarms. _alarm_published starts False in each process and
only its own branch removes the file, so a watchdog replacing a crashed one
never cleared that one's alarm - and inside the freshness window it read as a
live emergency raised by a process that no longer exists. It is adopted rather
than deleted, keeping its original timestamp so it ages honestly, and retired
on the first clean poll.

50 new tests (165 trading tests total). The 4 pre-existing bech32 import
errors are unchanged - those modules need the executor venv.

NOT ported: the predicted-vs-holding `benchmark`. Ainara has no equivalent, so
it cannot commit the error the guard exists to prevent, and building one needs
a mark series and execution-cost modelling this tree does not have. A
benchmark that cannot stand behind its verdict is worse than none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y are not

Four trading test modules could not be imported under either interpreter. They
were not broken; they were unrunnable, and unittest reported that as four
errors, which reads like something to fix rather than something to run
elsewhere.

The root cause of the jsonschema half was scripts/evaluation/__init__.py. It
eagerly imported the runner and evaluator, and it is also the parent package of
scripts.evaluation.tests, so importing ONE unit test module required the entire
framework dependency set. The executor's venv deliberately does not carry those
- it carries the venue signing SDKs, which conflict with them - so the
executor's own tests failed on `No module named 'jsonschema'` before reaching
an assertion. Those four names are now resolved lazily via PEP 562, so
`from scripts.evaluation import run_evaluation` still works and still raises if
the framework really is missing; it just no longer happens on the way past.

The bech32 half is not fixable and should not be: executor/server.py imports
the dYdX v4 SDK at module scope, dydx-v4-client pins httpx<0.28, the framework's
solana needs httpx>=0.28, and that conflict is why the executor is a separate
process at all. So each half now declares what it needs and SKIPS with a reason
naming the interpreter to use, instead of failing to import.

Both directions are guarded, because the split cuts both ways: the framework
tests were equally unimportable under the executor's venv.

That leaves a quieter problem. Run under one interpreter the suite now reports
`OK (skipped=4)`, and those four skips are ~51 real tests that did not run - a
green result covering an unrun half. run_trading_tests.py runs both and adds
them up, so OK means the whole suite passed rather than whichever half was
reachable, and it exits non-zero if either half fails or an interpreter is
missing.

    main      161 tests
    executor  128 tests
    total     289 tests actually executed

Unrelated and pre-existing: test_orakle_middleware's
test_attribute_rejection_query_attribute fails - a guardrail now returns
PROCESSED_COMMAND_SUCCESSFULLY where the test expects __AINARA_GUARDRAIL__.
Verified present before these changes and left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_attribute_rejection_query_attribute asserted that
<orakle query="..."></orakle> is rejected with an attribute guardrail. It is
not, and it must not be: `query` is the ONE permitted attribute.

Four places already say so, and only the test disagreed:

  - framework.chat_manager.system_prompt.mu and framework.agent.system_prompt.mu
    document it to the model as the "data form",
    <orakle query="action intent">data payload here</orakle>, which is the
    syntax we are actively asking for;
  - _get_attribute_rejection_message says "Orakle tags only accept the 'query'
    attribute";
  - _check_for_invalid_attributes strips a valid query= before deciding, so it
    only fires on anything else;
  - test_self_closing_rejection_with_attribute passes `query=` and expects the
    rejection to be about self-closing.

The feature, its prompt documentation and the test asserting its opposite all
arrived in the same upstream squashed import (e874bf6, "Import of changes for
v0.11" - the test did not exist before it), so it has never passed. This is an
upstream inconsistency, not a local regression, and it is worth reporting.

Verified against the parser rather than assumed:

  <orakle query="save this">payload</orakle>   -> intent "save this", data "payload"
  <orakle query="get weather"></orakle>        -> intent "get weather", data None
  <orakle type="weather">get weather</orakle>  -> guardrail "attribute"
  <orakle query="a" type="b">x</orakle>        -> guardrail "attribute"
  <orakle></orakle>                            -> intent "", not processed

So the empty data form is not the same thing as an empty command: the intent
lives in the attribute and is unambiguous, while <orakle></orakle> carries no
intent anywhere and is still refused by test_empty_command.

Replaces the one wrong assertion with four: the data form is accepted, it is
accepted without a payload, the intent/data split is what it claims to be, and
an invalid attribute travelling alongside `query` is still rejected - so
permitting `query` cannot quietly permit anything else.

18 tests with 1 failure -> 21 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
analytics now carries a benchmark. The decision rule is a timing layer: it
chooses when to be positioned. The only question that establishes whether it
earns its place is whether it beat opening the hedge once and holding it, and
nothing here ever made that comparison - so a rule that lost to always-on
looked identical to one that won.

I said last session this was not portable because it needed a mark series this
tree does not fetch. That was wrong. The mark series is the optional branch of
Oreka's implementation, with a documented fallback, and everything else was
already here: carry_engine has _hl_funding_history, _dydx_funding_history and
_round_trip_cost_fraction with the same signatures and return shapes, and the
ledger records short_venue and size. The fallback basis is reported rather than
assumed away - it is the one term here that is knowably approximate.

Three things it gets right, because getting them wrong is how a timing layer
flatters itself:

  - Direction is CAUSAL: the side the FIRST trade took, which is information
    that existed at the start. Picking the better side after the fact would
    benchmark against hindsight and flatter almost any rule.
  - The denominator is the WHOLE window, not the hours spent positioned. Carry
    forgone while flat is a real cost of timing, and charging the rule only for
    the hours it showed up is what hides it.
  - BOTH sides pay the rule's own MEASURED cost per round trip. Charging the
    rule real fees and real slippage against a benchmark charged one modelled
    fee and no slippage is not a comparison, and the bias scales with the number
    of round trips - which is the thing a timing layer does more of, by
    definition. When nothing was measured it falls back to the modelled fee and
    SAYS the two sides are no longer charged alike.

And what it refuses. A verdict is the one output nobody re-derives, so
beat_holding is deliberately ABSENT rather than computed when:

  - any recorded trade carries numbers a hedged round trip cannot produce
    (re-derived here, so a caller that skipped the guard cannot buy a verdict by
    omission);
  - the rule was positioned >=99% of the window, because it never chose to sit
    out and there is no timing decision to judge. One trade spans its own window
    exactly, which is why this is occupancy and not a trade count.

In every refusal the comparison is still reported. Refusing the conclusion is
not refusing the evidence.

gap_decomposition sums exactly to gap_usd, with any residual named as
rounding_usd rather than left to read as meaning.

The book-wide view gets a COUNT of per-coin verdicts, not a combined one: each
coin has its own direction, window and funding series, and averaging four of
those into one verdict is precisely the false summary the per-coin guard exists
to refuse.

36 new tests, fully offline (231 evaluation tests in 0.24s). The existing
analytics tests stub _benchmark, which is what caught it turning a 0.01s
offline suite into a 21s networked one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The benchmark reads two public funding-history series PER COIN. Charging that to
every analytics call made a book-wide read noticeably slower for a question
nobody asked - and it turned the previously offline analytics tests into a 21s
networked suite, which is how the cost became visible at all.

Now `benchmark=false` by default. The realized-vs-predicted figures never needed
it; it answers a different question, so it is asked for separately.

The unrequested slot is PRESENT and explicit rather than omitted. An absent key
reads as "this build has no benchmark" and a bare note reads as "it could not be
computed" - the second being a finding this skill reports for real elsewhere, so
the two must not look alike. `requested: false` is the discriminator and the note
says how to get one. It is built fresh per call: a shared module-level dict handed
to every caller is one mutation away from corrupting every later response.

The book-wide tally is likewise withheld rather than faked. Tallying an
unrequested benchmark would file every coin under `not_measured`, which reads as
a measurement failure rather than a question nobody put.

The parameter's description is written for the model that has to choose it: pass
it when the user asks whether the strategy is WORTH running, not when they ask
what it did.

portfolio.SKILL.md was stale well beyond this change and is now current: `coin`
defaulted to ALL not BTC, `lookback_days` no longer defaults to 7, and it
documented none of incomplete_window, the data-quality guard, the two named rate
denominators, the liquidation notes or the benchmark. Version 1.0 -> 1.1.

7 new tests, including that the default path never calls _benchmark at all. The
stub the analytics tests needed last commit is deleted - with the benchmark
opt-in it is dead scaffolding, and its comment had become untrue. 238 evaluation
tests still run in 0.25s, which is the proof the default really does stay off
the network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he stamps miss

Running the benchmark against the real BTC ledger refused a verdict: 2 of 4
closed trades failed the data-quality guard. Both refusals were correct and both
causes were real, but they were different problems and one of them was being
described wrongly.

THE LEDGER'S STAMPS ARE NOT THE TRADE'S BOUNDS. `opened_at` is written after the
two-leg open finishes, so the opening fills precede it; `closed_at` when the
closing order is sent, so the closing fills follow it. On the 2026-07-29 trade
the Hyperliquid opening fill sits THREE SECONDS before its own `opened_at`, so
the window counted that leg's close and not its open, and every realized figure
for a 15-day trade measured something else. Since price PnL is the sum of signed
fill cash, clipping an edge drops a whole leg's notional - and clipping both does
not look wrong, because a window catching no fills sums to $0.00, exactly what a
healthy hedge reports. Both edges are now extended to the fills that actually
opened and closed the position, and `window_open_basis`/`window_close_basis` say
which stamp each edge ended up on. Done as a walk, not a ledger column: a walk
fixes every row already recorded.

A BROKEN HEDGE IS A RESULT, NOT A FAULT. The 2026-07-23 trade's -$1.28 price PnL
was flagged as "2.5% of notional, impossible for a hedge". It is entirely
possible: the legs closed 8.01 HOURS apart while BTC fell 2.54%, so the position
was outright long on dYdX for that interval and really lost that money. The
magnitude guards rest on the two legs cancelling; legs that were not both in the
market did not cancel, so their premise is void and they are suppressed. The
breach is reported as `hedge_integrity` instead and the trade COUNTS toward every
total. Excluding a genuine loss as unmeasurable would flatter the strategy, which
is the exact direction of error this guard layer exists to prevent. A coverage
fault still fires either way - that is a real measurement failure regardless.

A WINDOW TOO SHORT ANSWERS NOTHING. Occupancy guarded against "the rule never sat
out" but nothing guarded against a window too brief to mean anything: the first
run rendered a verdict off 1.02 days at $59 notional, reporting -212% annualized
where the funding earned was 1.8 cents against 18 cents of execution. The verdict
is now withheld below one intended hold (>= 14 days, derived from
expected_hold_days), with the comparison still reported.

Against the real ledger the three together clear both refusals - 0 faulted, 2
broken hedges counted, total realized net -$1.6363 rather than a total with two
real losses missing from it - and the benchmark now renders over the full
21.44-day window.

32 new tests (259 evaluation tests, still 0.28s and still offline).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant