Skip to content

Releases: calesthio/OptionsCanvas

v0.1.10 — Server-side SL/TP monitor (critical hotfix)

28 May 21:19

Choose a tag to compare

Critical: stops no longer depend on the browser being open

Before this release, check_stop_loss() and check_take_profit() ran only inside the /api/position HTTP handler — so stops only checked when the browser was actively polling. A closed tab, throttled background tab, sleeping laptop, or brief network blip silently disabled stop protection. The whole "your stops live locally, off the broker book" promise relied on the browser being awake.

The fix

A daemon thread spawned at server start that calls:

trading_engine.process_pending_orders()
trading_engine.check_stop_loss()
trading_engine.check_take_profit()

every 2 seconds, unconditionally, for as long as the server is up. Browser state no longer matters to stop enforcement.

Market-hours gating

The loop ticks at 2s cadence 24/7 (it's cheap) but skips broker calls outside RTH (9:30–16:00 ET) since a market sell can't fill on a closed market. Open/close transitions log a single line each — no log spam.

Overnight gap protection: at the 9:30 open, the very first tick sweeps every position immediately. If your underlying gapped through your SL overnight, it fires within 2 seconds of the bell — not whenever the browser happens to reconnect.

Upgrade

Pull main, restart the platform. If you carry positions overnight or trust stops to fire while you're away from the screen, you want this release.

v0.1.9 — Broker-driven strikes, fill visibility, drag-to-add SL/TP

26 May 19:14

Choose a tag to compare

Broker-driven strike snapping

v0.1.8 fixed the ATM-window increment math, but a deeper bug surfaced on MSTR: orders for valid strikes like $160 still failed with "Strike not available."

Root cause: broker_module.get_option_contracts() was passing underlying_symbol (singular) to alpaca-py. The real API field is underlying_symbols (plural list) — pydantic silently dropped the bad field, so every contracts query ran across all underlyings and relied on a post-filter. Narrow strike filters happened to work; wider ones returned junk.

Fixed to use underlying_symbols=[symbol] + limit=10000 — real server-side filtering.

No more guessing increments. New endpoint GET /api/symbol/strikes/<symbol>?dte=X&type=Y returns the broker's actual sorted strike list. The trading panel snaps to the nearest entry in that list (refreshed on symbol switch, DTE change, and CALL↔PUT toggle) instead of synthesizing strikes from an increment. Works correctly even when the strike grid varies per expiration.

Queued-order fill visibility

The queued-order card used to show "Limit @ Midpoint" with no numbers — you had no idea what your actual limit was or how close the market was to filling you.

Now each pending_fill row shows:

  • Strike
  • Limit Price (what we sent to the broker — the midpoint at submit time)
  • Bid / Ask (live quote, polled every 3s)
  • Mark with a distance hint: "$0.05 below ask" or "at or above ask — should fill"

Server-side enrichment so the UI does one round trip, not N.

Drag-to-add SL/TP on existing positions

If you opened a position without setting a stop or target, you'd have no way to add them from the chart later. Now the position pill on the chart shows compact outlined + SL / + TP chips beside CLOSE — only when the corresponding level isn't set.

Click a chip → a dashed pending line spawns at 2% off the current underlying price (always on the validatePrice-acceptable side) and immediately enters drag mode. As soon as your cursor enters the chart area, the line follows it. Click anywhere on the chart to drop it, confirm the price, and it's persisted via the existing POST /api/position/update_sl_tp.

No hunting for a tiny line, no precision grab required — same drag-out interaction you'd expect dragging an icon onto a chart. Once saved, the dashed line goes solid and the chip disappears.

Upgrade

Pull main, restart the platform, hard-refresh the browser.

v0.1.8 — Resilient symbol switching + mixed-grid strike fix

26 May 19:14

Choose a tag to compare

What's fixed

Stale state on rapid symbol switching. Slow API responses from the previous symbol could land after the new symbol's responses and overwrite the chart, contract list, or option preview with the wrong ticker's data — the kind of bug that makes traders open a trade against an instrument they're not looking at.

Now every symbol-scoped fetch (bars, contracts, config, quotes) is cancelled with AbortController the instant you switch, with a correlation-ID guard as a belt-and-suspenders check for anything that slips past the abort. Same pattern TanStack Query and other modern trading frontends use.

Strike-increment detection for mixed-grid chains. MSTR (and similar names) have $1 strikes deep ITM/OTM and $2.50 strikes near ATM. The old algorithm picked the smallest common increment in a ±$20 window, returning $1 — so orders snapped to $161 (which doesn't exist) instead of $160 or $162.50.

New algorithm: take the 9-strike window centered on ATM, compute adjacent diffs, pick the mode. Verified increments after the fix: SPY=$1, PLTR=$1, MRVL=$2.5, AMZN=$2.5, MSTR=$2.5, MU=$5.

Strike-increment cache split. Verified (chain-derived) values cache for 1 hour; heuristic fallbacks cache for only 60 seconds — so a transient API blip on the first lookup can't poison the long-TTL cache with a wrong value for the rest of the hour.

Why this matters

Resilient state handling and correct strike math aren't "nice to have" on a real-money trading platform — they're the difference between executing the trade you intended and executing one you didn't.

v0.1.7 - Smart errors + DTE pre-flight + clean stream subscriptions

26 May 17:25

Choose a tag to compare

Four related fixes for trader-facing weirdness uncovered during live use today.

What's in this release

Smart error when a contract can't be found

/api/open_position used to return a single unhelpful string ('Could not find suitable option contract') when something was wrong. Now it distinguishes the failure modes:

  • DTE not in chain"DTE=6 not available for MU. Valid DTEs: 3, 10, 17, 23."
  • Strike not at this DTE"Strike $885 not available for MU CALL DTE=10. Nearest valid: $880."
  • No options for this symbol"No options found for SYMBOL."

Backend also returns structured valid_dtes + valid_strikes fields so future UI can auto-correct.

Frontend DTE pre-flight

When you click Buy, the panel now validates the selected DTE against the current symbol's actual chain before sending the order. If your selection is stale (carried over from a previous symbol that had it but the current one doesn't), you get an immediate toast naming valid DTEs and the nearest one — no 10-second timeout, no broker-rejected order.

Stale-response guard on symbol switching

TradingPanel.loadValidContracts now captures the requested symbol at request time and discards the response if you switched symbols mid-fetch. Stops the previous symbol's options leaking into the new symbol's dropdown during fast switching.

Ref-counted stream subscriptions

The big perf fix:

  • Backend now tracks subscriptions per-client (sidset of (symbol, timeframe)). handle_disconnect releases all of the disconnecting client's claims; if no other client wants the same (symbol, timeframe), the streaming loop stops polling that symbol.
  • Frontend unsubscribeChart now updates its local subscription set even when the WebSocket is disconnected, so a reconnect-then-resubscribe doesn't replay stale subscriptions.

Previously: every closed browser tab leaked entries into streaming_symbols permanently. The background loop kept fetching bars for symbols nobody was watching. Verified by inspecting logs: on a clean boot you should now only see Fetching bars for <current_symbol>, no orphaned symbols accumulating.

How to upgrade

git pull
# restart the launcher, refresh the browser tab

The streaming-symbol cleanup needs a backend restart to clear the in-memory leaked entries from the previous session.

What's next

A proper refactor is queued for v0.2.0: applying AbortController + correlation-ID guards uniformly across every symbol-scoped async path (frontend ApiClient, TradingPanel, ChartManager, OrderPanelOnChart), plus splitting the backend's strike-increment cache so heuristic fallbacks don't poison the long-TTL verified cache. That's the architectural fix for the entire class of "I switched symbols and the panel showed stale data" bugs — these v0.1.x patches reduce the surface area, v0.2.0 eliminates the class.

v0.1.6 - Trail SL/TP on open positions

26 May 15:51

Choose a tag to compare

Fixes a regression where the SL/TP price lines on the chart for open positions were visually present but read-only — you couldn't trail your stops without closing and re-entering.

What's fixed

DragHandles (the chart layer that listens for clicks on horizontal price lines and drives the trailing-stop drag UX) was instantiated in main.js but never had .enable() called on it. A comment in the code explained the intent: the chart's order-panel pills had taken over SL/TP setup for new orders, so DragHandles was meant to be "kept available for existing-position management but disabled by default." The disabled-by-default part shipped; the re-enable for open positions did not.

Now DragHandles.enable() is called immediately after construction. The two systems coexist cleanly because DragHandles already scopes its pointerdown handler to ignore clicks inside .order-panel-on-chart (the pill container).

How trailing works now

  1. Open a position via the pill or hotkey
  2. Solid red SL line + solid green TP line appear on the chart at your levels
  3. Grab the solid line itself (not the pill) and drag to a new price
  4. A "Set Stop Loss: $X?" popup appears with Confirm / Cancel buttons
  5. Click Confirm within 10 seconds → backend updates, positions.stop_loss_price (or take_profit_price) in SQLite reflects the new level
  6. The check_stop_loss() / check_take_profit() loops use the new level on the very next poll

Server-side stop guarantee still holds — the trailed level lives in our local DB, the broker order book never sees it until breach.

How to upgrade

git pull
# refresh the browser tab — pure frontend change, no platform restart needed

Verified

  • window.DragHandles.enabled flips to true after refresh
  • Dragging the SL line on an open TSLA PUT shows the confirm popup → click Confirm → DB stop_loss_price reflects the new value
  • TP works identically (drag the green line)
  • New-order pill drag for SL/TP setup unchanged

v0.1.5 - Day P&L works, chart fits new symbol price range

26 May 15:36

Choose a tag to compare

Two visible bugs fixed after using the platform end-to-end through a real close + symbol-switch flow.

What's fixed

Day P&L now updates on close

/api/day_pnl read from journal_dir/<date>/trades.json — but no code path in v0.1.x writes to that file. Realized P&L is recorded to the SQLite journal_entries table (via the per-close auto-recompute we shipped earlier today). The endpoint now queries the right place, so Day P&L updates immediately after a close instead of staying at $0.

The response also now exposes realized_pnl and unrealized_pnl as separate fields if you want to surface the breakdown in the UI later.

Chart no longer blank after switching symbols

ChartManager.setSymbol() called timeScale().fitContent() to fit the new symbol's bars — but that only fits the time axis. The price axis stayed at the previous symbol's range. Switching from SPY ($750) to AMZN ($268) left the chart's price scale at 744-755 and every AMZN bar rendered off-screen. Added priceScale('right').applyOptions({ autoScale: true }) after fitContent() so the price axis re-fits.

How to upgrade

git pull
# restart the launcher, refresh the browser tab

Verified

  • Closed a SPY 751P position → Day P&L immediately shows the realized P&L (no manual journal-file step needed)
  • AAPL → AMZN → SPY symbol switches all render bars at the correct price range
  • All v0.1.1 — v0.1.4 fixes intact (CSRF, gevent, position auto-import, 0 DTE)

v0.1.4 - Position tracking self-heal + 0 DTE display

26 May 15:28

Choose a tag to compare

Recommended upgrade for everyone. Fixes a real bug where positions opened through the platform sometimes ended up tagged as 'External broker position' and could not be managed (no SL/TP, no close button).

What's fixed

Positions opened via the platform no longer get tagged 'External'

_fetch_broker_positions (the 5-second TTL cache for broker positions added in v0.1.3) returned fresh=True on cache hits — not just on actual broker calls. The destructive position-cleanup loop in get_position_details used that flag to decide whether it was safe to delete tracked positions that weren't in the broker's positions list.

Race:

  • T=0: Order placed; broker hasn't filled yet
  • T=1: Broker fills, process_pending_orders writes the position to our DB
  • T=2: /api/position polls — but the cached broker snapshot is from T=0 and doesn't include the new position. Cleanup logic sees the position in our DB, not in the cached snapshot, and deletes it
  • T=6: Cache expires, broker is re-polled, now includes the position — but it's no longer in our DB, so it renders as 'External broker position'

Fix: cache hits no longer claim to be fresh. Only actual broker fetches set just_fetched=True, and only those trigger destructive cleanup.

Auto-import untracked broker option positions

Even with the cache fix, any future bug that broke position-creation would leave the user with unmanageable positions. v0.1.4 self-heals: when the trading engine sees an option position at the broker that isn't in our DB, it auto-imports it as a tracked position. SL/TP start empty — drag them on the chart to attach. The position immediately gets the × close button + standard management UI.

Stock positions in the broker account are still ignored (the platform manages options, not stocks).

0 DTE positions display correctly

PositionTracker rendered the DTE field as position.dte || 'N/A'. JavaScript's || falls back when the left operand is falsy, and 0 is falsy — so 0 DTE positions (a primary use case the README pitches) displayed as 'N/A'. Swapped to ?? (nullish coalescing) so only null / undefined falls back. Real 0 now renders as 0.

How to upgrade

git pull
# restart the launcher, refresh the browser tab

Verified

  • Auto-import test: untracked broker option appears as tracked, no 'External' tag, close button visible
  • Cache semantics: 4/4 unit cases pass — first call just_fetched=True, cache hit just_fetched=False (the fix), TTL expiry re-fetches, broker failure serves stale with just_fetched=False
  • 0 DTE position renders DTE: 0 (not N/A)
  • v0.1.1 + v0.1.2 + v0.1.3 guarantees all preserved

v0.1.3 - WebSocket stability, 60x faster /api/position, 0 DTE support

26 May 15:12

Choose a tag to compare

Recommended upgrade for everyone on v0.1.2. Required if you trade 0 DTE.

What's fixed

WebSocket no longer dies with 'ping timeout'

The platform uses gevent for async, but gevent.monkey.patch_all() was never called. Result: every blocking alpaca-py REST call (which uses requests internally) froze the entire gevent event loop, including SocketIO's ping/pong heartbeats. Browsers saw WebSocket disconnected: ping timeout and entered a reconnect loop.

Fix: monkey-patch sockets at process start, before any module imports requests / urllib3. WebSocket now stays connected through normal trading activity.

/api/position is 60x faster

The trading engine was firing one broker.get_current_price() call per untracked broker position on every /api/position poll. For a user with 12 stock positions in their Alpaca account, that was 12 serial broker round-trips and ~6 seconds of wall time per call. Worse, it starved every other REST endpoint (including the ones that gate the Buy button's readiness check), so the trading UI would never finish loading.

Fix: use the current_price field already populated on the broker position object. No extra round-trips needed.

Endpoint Before After
/api/position ~7,000ms ~100ms
/api/symbol/config/<sym> ~27,000ms (queued behind /position) ~900ms
/api/symbol/contracts/<sym> ~16,000ms (queued) ~150ms
/api/option/quote/<sym> ~5,000ms (queued) ~1,700ms

Buy button now enables for 0 DTE trades

The readiness check required dte > 0, which silently disabled trading on 0-DTE contracts — one of the platform's primary use cases. The check is now dte >= 0; the panel-ready flag handles uninitialized state separately.

Symptom on v0.1.2: Buy / SL / TP pills greyed out forever, tooltip 'Trading controls are still loading', even though the chart and side panel had fully loaded.

How to upgrade

git pull
# restart the launcher and refresh your browser tab

If the running platform's been up since v0.1.2, restart it so gevent monkey-patches at boot (this can't be hot-applied to a running process).

Verified

  • WebSocket stays connected across normal trading activity (no ping timeout)
  • 10 parallel /api/position calls all complete in under 1.3 seconds
  • Buy / SL / TP pills become active within ~3 seconds of page load
  • 0 DTE trades can be placed (Buy enables with dte = 0)
  • v0.1.1 + v0.1.2 security guarantees still hold (CSRF + CORS + origin checks all intact)

v0.1.2 - Fix WebSocket regression from v0.1.1

26 May 14:49

Choose a tag to compare

Required upgrade if you're on v0.1.1. v0.1.0 users can also upgrade straight to v0.1.2 — it includes everything from v0.1.1 (the CSRF + CORS hardening) plus this fix.

What this fixes

v0.1.1 set SocketIO's cors_allowed_origins=[] at module load time, planning to tighten it to localhost-only via register_security() once the bind port was known. The post-init mutation was wrapped in a silent except Exception: pass, so it failed quietly and the empty list rejected every WebSocket handshake — including the legitimate one from your own browser.

User-visible symptoms on v0.1.1:

  • Browser console spam: WebSocket connection ... failed: WebSocket is closed before the connection is established
  • Order placement times out at 10 seconds (Error: Order placement timed out after 10 seconds) because the frontend was waiting for a fill event that never arrived over the broken WebSocket
  • Live price updates and position-card refreshes stop

The fix

  • Initialize SocketIO with cors_allowed_origins="*" at module load so legitimate connections from http://127.0.0.1:<port> can hand-shake successfully
  • In register_security(), narrow the allowlist to the actual localhost origin by setting both socketio.server.eio.cors_allowed_origins and socketio.server.cors_allowed_origins (different python-socketio versions expose the attribute on different objects) — no silent except clauses
  • Boot log now prints Security: SocketIO cors_allowed_origins locked to ['http://127.0.0.1:<port>', 'http://localhost:<port>'] so you can verify which paths took effect
  • If the underlying library version doesn't expose either attribute, a loud WARNING is logged instead of silent failure

Verified

  • WebSocket polling handshake from http://127.0.0.1:<port> → 200 with engine.io session ID
  • WebSocket polling handshake from https://evil.example400 "Not an accepted origin."
  • POST without CSRF token → 403 (v0.1.1 behavior preserved)
  • POST with valid token + correct Origin → 200 (legitimate flow works)

How to upgrade

git pull
# launcher will pick up changes on next start

Restart the platform, refresh the browser tab so the new CSRF token loads, and orders should place + fill normally again.

v0.1.1 - Security hotfix (CSRF + CORS)

26 May 03:38

Choose a tag to compare

Recommended upgrade for everyone running v0.1.0.

What changed

Three layers of defense added against browser-based CSRF attacks on the locally-hosted server:

  1. CORS allowlist restricted to localhost origins. Previously CORS(app) and cors_allowed_origins='*' accepted requests from any website. Now only http://127.0.0.1:<port> and http://localhost:<port> are allowed. SocketIO restricted the same way.

  2. Per-process CSRF token. A random token is generated at server startup, injected into served HTML via a <meta name='csrf-token'> tag. The frontend reads it and sends X-CSRF-Token on every state-changing request. The backend rejects any POST / PUT / PATCH / DELETE without a matching token (constant-time compare). Only /api/health is exempt so Docker healthchecks keep working.

  3. Origin header check. Belt-and-suspenders: state-changing requests with a non-allowlisted Origin header return 403 even if a token was somehow obtained.

Why this matters

The previous server (v0.1.0) was bound to 127.0.0.1 but accepted unauthenticated cross-origin POSTs. Any webpage you visited while OptionsCanvas was running — a compromised ad on a news site, a Discord-embedded link, a malicious blog post — could fire JavaScript like:

fetch('http://127.0.0.1:5001/api/open_position', {
  method: 'POST',
  body: JSON.stringify({...})
})

…and place real trades in your account without any visible signal. Localhost binding does not protect against this — the browser is already on localhost.

How to upgrade

git pull
# launcher will pick up changes on next start

Or, for Docker:

docker compose pull   # if you tag :latest
docker compose down && docker compose up -d --build

Refresh any open browser tabs after restart so the new CSRF token is loaded into the page.

Verified

End-to-end against a real Alpaca paper account:

  • ✅ GET endpoints work unchanged (no token required, CORS still blocks cross-origin reads)
  • ✅ POST without token → 403
  • ✅ POST with invalid token → 403
  • ✅ POST with valid token + foreign Origin → 403
  • ✅ POST with valid token + correct Origin → 200 (legitimate flow)
  • ✅ index.html + setup.html both serve the live token
  • ✅ Token is per-process — same across both pages, rotates each restart

Compatibility

  • No breaking changes for users running the platform normally — the frontend was updated in the same commit, so a git pull + restart + browser refresh restores normal operation.
  • External scripts that POST to the API must now read the token from <meta name='csrf-token'> and send it as the X-CSRF-Token header.