Delta-neutral funding carry: multi-asset support, hardening & tests - #1
Open
jzbtc101 wants to merge 95 commits into
Open
Delta-neutral funding carry: multi-asset support, hardening & tests#1jzbtc101 wants to merge 95 commits into
jzbtc101 wants to merge 95 commits into
Conversation
…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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 indocs/delta_neutral_guide.md.Please review with an eye on these (shared-infra + housekeeping)
varsmechanism (ainara/bureau/plan.py,conductor.py,server.py): a general-purpose, backward-compatible addition — a plan-levelvars: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.dev011bumped 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
trading_portfolio): live status / closed-trade review / predicted-vs-realized analytics, per coin or whole-book.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
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