Skip to content

feat: stop preview in the arm modal (POST /entry/preview dry run) - #243

Merged
ldamasio merged 10 commits into
mainfrom
feat/arm-preview
Oct 15, 2026
Merged

ldamasio merged 10 commits into
mainfrom
feat/arm-preview

Conversation

@ldamasio

Copy link
Copy Markdown
Owner

Summary

  • Two consecutive arms on 2026-10-11 got ATR-fallback stops at 0.15% and 0.26% of price and were stopped by noise within seconds; the operator only learned the stop method after the fill (feat: show the technical stop method and confidence on the position card #234 then put METHOD on the card). The arm modal still showed nothing about the stop it was about to use.
  • This PR adds a dry run: POST /entry/preview reuses the production detector and engine end to end (same capital base, same TechnicalStopConfig, same invalidation guard, same sizing) without persisting anything or placing orders, and the arm modal gets a STOP PREVIEW panel: technical stop, distance, method and confidence, guard level, size, planned risk and executable trigger/span. Advisory warnings (ATR fallback, medium confidence, distance under 0.5%) turn ARM into ARM ANYWAY; only an explicit domain rejection blocks; an unavailable preview (no candles, metadata down) never blocks.
  • Reviewed by Codex (gpt-6.1, xhigh) over four rounds on the branch; final verdict APPROVE, no open findings. Rounds fixed: the preview held the manager read lock across exchange I/O; it populated the production trading-rules cache (and its mutex was held across HTTP); eviction could displace an actively traded symbol; a memo could serve a sizing computed before a capital change; sub-micro prices printed identically.

Changes

  • Key changes in this PR:
    • robsond/src/position_manager.rs: prepare_entry_preview(symbol, side) -> EntryPreviewJob (captured under a short manager read guard; the only I/O under it is the local monthly_state read) and EntryPreviewJob::run (candles, balance, metadata, engine decision, after the guard is released). EntryPreview { reference_price, capital_base, analysis, sizing, rejection, unavailable, warnings }. Availability failures (no candles, Insufficient candle data, trading rules unavailable) go to unavailable; engine DomainError goes to rejection.
    • robsond/src/detector.rs: preview_immediate_signal (100 × 15m candles, anchor on last close, create_signal), no events, no tasks.
    • robsond/src/api.rs: preview_entry_handler on the authenticated router only; sliding budget of 6 previews / 60 s across all inputs (429), one preview at a time (429 on overlap), 15 s timeout on the I/O (504). No memoisation.
    • robson-exec/src/ports.rs: ExchangePort::trading_rules_preview (default = normal lookup). robsond/src/binance_exchange.rs: override that reads a fresh cached entry under a momentary lock or fetches exchangeInfo without writing the cache; the production cache is now LRU (last_used_at, updated on fresh and stale hits) and evicts instead of failing closed at 64 symbols.
    • Frontend: robsonApi.previewEntry, EntryPreview types/normalizer, presentation/preview.ts (previewSeverity, previewLines, fmtNum with ≥5 significant digits and scientific notation below 1e-6), STOP PREVIEW panel in ArmModal.svelte keyed to symbol|side so a stale preview never applies to different inputs.
    • CHANGELOG.

Tests

  • How to verify:
    • cargo test -p robson-exec -p robsond --features robsond/postgres — 366 robsond + exec tests passed (container, rust 1.92); doc-tests pass; clippy -D correctness -D suspicious clean; cargo +nightly-2026-08-22 fmt --check clean
    • cd frontend && npx vitest run — 138 passed; svelte-check 0 errors
  • Added/updated tests:
    • position_manager.rs::preview_entry_sizes_without_persisting_anything, preview_availability_failures_are_not_rejections
    • binance_exchange.rs::trading_rules_cache_evicts_the_least_recently_used_entry_when_full (active symbol survives; stale fallback in use survives)
    • api.rs::preview_budget_admits_at_most_max_per_window
    • frontend/tests/unit/preview.test.ts (severity, lines, unavailable never blocks, small and sub-micro price formatting, normalizer)

Migrations

  • No schema changes

Backward Compatibility

  • New endpoint and new trait method with a default; existing routes unchanged. The trading-rules cache change (LRU eviction instead of a capacity error) only alters behaviour past 64 distinct symbols, where arms used to fail.
  • Merging restarts the daemon pod: merge with an empty book (it is empty now).

Security & Data

  • Endpoint is behind the Google ID token layer (ADR-0054), absent from the read-only router. Exchange traffic per preview: two candle fetches, one balance read, at most one exchangeInfo call, bounded by the budget above. No secrets.

Checklist

  • Scope is focused and documented
  • Code follows project conventions (see docs/DEVELOPER.md)
  • Migrations created (if needed) and rationale explained
  • Tests added/updated and passing locally
  • Docs updated (CHANGELOG)

🤖 Generated with Claude Code

ldamasio and others added 10 commits October 14, 2026 17:48
Three of October's stops were ATR fallbacks or single-level stops at
0.15% to 0.26% of price, stopped by noise within minutes; the operator
only learned the stop method after the fill.

PositionManager::preview_entry runs the immediate-arm path without side
effects: the month's capital base as arm_handler loads it, a transient
Armed position shaped like arm_position_with_policy builds, the
detector's immediate-signal analysis (new DetectorTask::
preview_immediate_signal, no info log, no event), the futures balance and
trading rules, and decide_entry_with_rules on a cloned engine. It returns
reference price, technical stop with distance, method and confidence,
size, notional, planned risk, executable trigger and span, the domain
rejection the arm would hit, and advisory warnings. Nothing is saved and
no order is placed; a unit test asserts that.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Call POST /entry/preview for the current symbol and side and show the
result in the card's monospace style: reference, stop with distance and
percentage, method and confidence, size and notional, planned risk
against capital, executable trigger and span. Warnings turn the button
into ARM ANYWAY; a domain rejection disables it with the reason. The
preview is keyed to the inputs, so changing symbol or side invalidates
it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ail-closed on the rules cache

Review findings (Codex, P1/P2) on POST /entry/preview:

- The handler held `position_manager.read()` across two candle fetches,
  a balance call and an exchangeInfo lookup, so a slow exchange delayed
  every `write()` holder (arm, close, panic) behind it. The preview is
  now two steps: `prepare_entry_preview` captures capital, risk config,
  the transient position, the detector and the exchange/engine handles
  under a short guard; `EntryPreviewJob::run` does the I/O after the
  guard is dropped, under a 15 s timeout (504 on expiry). A process-wide
  semaphore serializes previews (429 when one is running), so previews
  cannot fan out exchange calls faster than a real arm.
- The adapter's trading-rules cache refused new symbols once its 64
  slots were full, which previews could fill and then a real arm on an
  uncached symbol would fail. It now evicts the least recently fetched
  entry instead.
- Availability failures (no or too few candles, trading rules
  unavailable) are reported as `unavailable`, distinct from `rejection`,
  so the UI never blocks an arm on missing data; only an explicit domain
  rejection blocks. A missing futures balance is surfaced as a warning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…p five significant digits

`unavailable` is rendered as a NO DATA line and an explanatory note while
ARM stays enabled (ARM ANYWAY); only `rejection` blocks. Prices and spans
print with at least five significant digits and two decimals (83,454.50;
0.12340 vs 0.12240), monetary totals stay at two decimals.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…es cache or its mutex

Second review round (Codex):

- The preview still fetched trading rules through the adapter's cached
  path, which holds the adapter mutex across the exchangeInfo HTTP call
  (the same mutex the market-exit path takes) and populates/evicts the
  cache real arms depend on. New `ExchangePort::trading_rules_preview`
  (default: the normal lookup) is overridden by the Binance adapter with
  a peek-or-fetch: a fresh cached entry is read under a momentary lock,
  otherwise exchangeInfo is fetched without writing the cache. The
  preview job uses it.
- Cache eviction was by oldest fetch, so a burst of distinct symbols
  could evict an actively traded symbol whose timestamp never refreshed
  on hits. Entries now track last use and eviction is LRU; test asserts
  the active symbol survives.
- Identical previews (same symbol and side) within 5 s are answered from
  the last result, so a double click or a second tab costs no exchange
  traffic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… preview

Below one millionth fixed decimals stop being readable, so values switch
to scientific notation with four significant digits; between a millionth
and a thousand the decimals grow up to ten. Fixture: reference
0.00000123, stop 0.000001228, span 2e-9.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…instead of memo

Third review round (Codex):

- A symbol living on its stale trading-rules snapshot after failed
  refreshes was never marked as used, so it could become the LRU victim
  while actively traded. `touch_stale_trading_rules` updates
  `last_used_at` (keeping `fetched_at`, so the next lookup still retries)
  and the eviction test covers the stale path.
- The 5 s per-key memo could serve a preview computed before a capital
  recalibration or the UTC month rollover, including an obsolete
  rejection that disables ARM, and it throttled only identical inputs.
  Replaced by a sliding budget of 6 previews per 60 s across all inputs
  (429 beyond), with no memoisation: every served preview reloads capital
  and candles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ldamasio
ldamasio merged commit 145e1c1 into main Oct 15, 2026
7 checks passed
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