day-trading

← Home · ~/day-trading · updated 5 days ago

day-trading

Personal day-trading dashboard at /trade, backed by Alpaca Markets (paper) for the equities track and Hyperliquid testnet for the crypto track. Runs alongside the newsfeed: two launchd-managed worker daemons (one per market) ingest bars + quotes + funding rates, the existing webpage-server.py:8899 serves the UI and JSON API, and bb-send.sh relays alerts to iMessage.

Paper-default. Live trading is stubbed — even if all three gates (config flag, env var, per-order confirm token) pass, the ledger raises LiveTradingDisabled. MVP will not route real money. The crypto track has no live path at all in v1; Hyperliquid mainnet UI geoblocks US persons and US-resident perp routing remains a regulatory minefield this project does not attempt to thread.

Markets

The trade system runs three parallel tracks: equities polls Alpaca during NYSE hours (com.mark.trade-worker); crypto polls Hyperliquid testnet 24/7 (com.mark.trade-worker-crypto); Kalshi polls the Kalshi demo sandbox 24/7 (com.mark.trade-worker-kalshi). All three write into the same trade.db, which has a market column on every relevant table. The dashboard at /trade/results shows all three with a 4-tab toggle (All / Equities / Crypto / Kalshi); each track has its own starting-cash anchor so the % return number stays intelligible at each track's scale.

Track Venue Auth Default strategy Worker plist
equities Alpaca paper bearer (key + secret) bollinger_breakout com.mark.trade-worker
crypto Hyperliquid testnet API wallet (private key + addr) crypto_funding_arb com.mark.trade-worker-crypto
kalshi Kalshi demo sandbox RSA-PSS signed requests kalshi_calibration_arbitrage com.mark.trade-worker-kalshi

Configuration is segmented: equities under [watchlist] / [strategy], crypto under [crypto], Kalshi under [kalshi]. Live trading remains stubbed in all three tracks; the crypto + Kalshi tracks ship paper-only by construction in v1.

Scope

  • Watchlist of ~5 tickers. Real-time quotes (2 s polling) + intraday candle charts (1 m / 5 m) with SMA / EMA / RSI / MACD / Bollinger overlays.
  • Ticker-scoped news pane: read-only FTS5 query against newsfeed.db.
  • Paper-trade ledger: weighted-avg cost, realized/unrealized PnL, notional cap per order.
  • iMessage alerts via bb-send.sh: price_cross, volume_spike, news_event rules with per-rule cooldowns.
  • Worker daemon with Alpaca REST + (Phase 2) WebSocket; KeepAlive under launchd; exponential backoff on the Alpaca connection; writes a runs heartbeat so the dashboard can show staleness.
  • CLI (python -m trade.cli …) for watchlist / alert management, backfill, position readout, health check.

Interface

GET /trade

Renders trade/templates/trade.html.j2. Embeds the current trade token in a <meta name="trade-token"> tag; the client echoes it back in X-Trade-Token on every subsequent call. No caching (Cache-Control: no-store).

Layout: header (market pill + tick age + equity est.), watchlist chips, candlestick+volume chart with SMA20 / Bollinger overlays, indicator panel, sidebar (positions + fills + recent alerts), bottom news pane, BUY/SELL order form.

GET /trade/results

Renders trade/templates/results.html.j2. Performance dashboard for the paper account: total-return hero, equity curve vs SPY benchmark, drawdown shading, KPI grid (return, equity, today PnL, realized, unrealized, win rate, max drawdown, annualized Sharpe), per-ticker breakdown, fills timeline. Embeds the trade token in <meta name="trade-token"> so the client can call /api/trade/results directly; the page itself is unprotected, matching /trade. Mobile-first single-column below 600 px; prefers-reduced-motion honored. Cache-Control: no-store.

JSON API

All /api/trade/* endpoints require header X-Trade-Token: $(cat ~/Library/Application\ Support/trade/trade_token.txt). The worker mints + rotates this token daily. Missing or wrong token → 401.

Method Path Purpose
GET /api/trade/watchlist {tickers: [...]}
GET /api/trade/quotes {quotes: [{ticker,bid,ask,last,ts}, …]}
GET /api/trade/bars?t=NVDA&tf=1m&n=390 OHLCV, oldest-first
GET /api/trade/indicators?t=NVDA&tf=1m Flat snapshot: {sma20, ema9, rsi14, macd, macd_signal, macd_hist, bb_mid, bb_upper, bb_lower, series: {...}}
GET /api/trade/positions {positions: [...], realized_pnl, unrealized_pnl, cash_est, equity_est}
GET /api/trade/ledger?limit=50 Recent fills
GET /api/trade/alert-log?limit=20 Recent alert firings
GET /api/trade/news?t=NVDA&hours=24 Ticker-scoped headlines from newsfeed.db (FTS5)
GET /api/trade/health {last_tick_at, alpaca_status, market_open, db_exists, token_exists}
GET /api/trade/results Aggregated PnL payload — see Performance dashboard.
POST /api/trade/order Body: {ticker, side, qty, limit_price?, note?}. Returns {status:"filled", id, ticker, side, qty, price, ts, realized_pnl, live:false}. 400 on bad params, 403 if live=true (triple gate), 501 even when gates pass (adapter not wired).

CLI

python -m trade.cli health                             # config + DB + bb-send + deps + crypto reachability
python -m trade.cli watchlist {add,remove,list} NVDA   # mutates ~/.config/trade/config.toml
python -m trade.cli alert add --ticker NVDA --above 920 [--cooldown-min 30]
python -m trade.cli alert add --ticker NVDA --volume-spike-z 3.0
python -m trade.cli alert add --ticker NVDA --news-event
python -m trade.cli alert {list,disable ID}
python -m trade.cli positions                          # pretty table
python -m trade.cli fills [--limit 50]
python -m trade.cli backfill [--days 1]                # force historical bars refetch
python -m trade.cli news --ticker NVDA [--hours 24]
python -m trade.cli order {buy,sell} NVDA 10 [--limit-price 920.50]
python -m trade.cli backtest [--market equities|crypto|kalshi] [--strategy NAME|all] [--years 2]
                              [--tickers AAPL,NVDA] [--from YYYY-MM-DD] [--to YYYY-MM-DD]
                              [--cache | --no-cache] [--export-csv DIR] [--timeframe 1Day]

# Crypto-specific:
python -m trade.cli funding-rates [--symbol BTC] [--persist]   # current rates from Hyperliquid testnet
python -m trade.cli funding-history --symbol BTC --hours 168   # bulk-ingest historical rates
python -m trade.cli crypto-pair-clear <pair_id> [--confirm]    # manual unbalanced-pair recovery

# Kalshi-specific:
python -m trade.cli kalshi auth-test                            # signed GET /exchange/status
python -m trade.cli kalshi list-markets [--filter PREFIX] [--status open|all] [--refresh]
python -m trade.cli kalshi positions                            # open kalshi paper positions

Workers

Three long-running daemons, one per market:

python -m trade.worker        [--backfill-days 1]    # equities (Alpaca)
python -m trade.crypto_worker                         # crypto (Hyperliquid testnet)
python -m trade.kalshi_worker                         # kalshi (demo sandbox)

All three managed by launchd: - ~/Library/LaunchAgents/com.mark.trade-worker.plist (RunAtLoad + KeepAlive). Logs to ~/Library/Logs/trade-worker.log. - ~/Library/LaunchAgents/com.mark.trade-worker-crypto.plist (same keys). Logs to ~/Library/Logs/trade-worker-crypto.log. - ~/Library/LaunchAgents/com.mark.trade-worker-kalshi.plist (same keys). Logs to ~/Library/Logs/trade-worker-kalshi.log. Disabled- state behaviour: cfg.kalshi.enabled = false makes the worker exit 0 cleanly on each spawn (KeepAlive respawns; effective CPU is zero).

Main loop: 1. Backfill last trading day's 1-minute bars on startup. 2. Poll Alpaca REST for latest quote + trade for each watchlist ticker (15 s cadence when market open; 300 s when closed). 3. Evaluate every active alert after each poll; fire + log + iMessage any rule whose cooldown has elapsed. 4. Record a runs tick so /api/trade/health exposes staleness.

The Alpaca SDK is imported lazily — trade.cli health still works without alpaca-py installed (it reports "not installed"). WebSocket bar aggregation is scaffolded (MinuteAggregator class) but MVP stays on REST.

Package layout

day-trading/
├── PLAN.md              # implementation plan (reference)
├── README.md            # this file
├── install.sh           # symlinks trade/ → ~/bin/trade/, seeds config, loads plist
├── examples/
│   └── config.toml      # template, copied on first install
└── trade/
    ├── __init__.py
    ├── config.py        # TOML loader, frozen dataclasses, path helpers (equities + crypto)
    ├── db.py            # SQLite schema + helpers (WAL, 10s busy_timeout, multi-market)
    ├── indicators.py    # pandas-backed sma/ema/rsi/macd/bollinger + snapshot()
    ├── aliases.py       # ticker → company-name aliases for FTS5 news queries
    ├── news.py          # read-only newsfeed.db FTS5 lookup
    ├── ledger.py        # paper order execution + position bookkeeping (market+pair_id)
    ├── results.py       # /api/trade/results aggregator: per-market scoping + funding panel data
    ├── alerts.py        # rule engine + bb-send.sh delivery
    ├── worker.py        # equities ingest loop + alert evaluator + autotrader + token minter
    ├── crypto_worker.py # crypto ingest loop + funding-arb strategy + reconcile_pairs invariant
    ├── cli.py           # argparse subcommands (incl. backtest, funding-rates, crypto-pair-clear)
    ├── backtest.py      # bar-by-bar equities simulator + crypto funding-arb simulator
    ├── historical.py    # Alpaca historical fetch with on-disk CSV cache
    ├── crypto/
    │   ├── __init__.py
    │   ├── hyperliquid_testnet_client.py  # /info REST client (paper-only public endpoints)
    │   └── historical.py                  # funding history + perp bars cache for backtest
    ├── strategies/
    │   ├── __init__.py            # registry: list_names() / get(name)
    │   ├── base.py                # @register decorator + STRATEGIES dict
    │   ├── buy_and_hold.py        # equal-weight, day-one BUYs
    │   ├── sma_crossover.py       # EMA(9,21) cross
    │   ├── rsi_meanrev.py         # RSI14 oversold/overbought
    │   ├── bollinger_breakout.py  # close > BB upper, exit on mid touch
    │   ├── momentum_rank.py       # weekly top-1 by 12-day return
    │   └── crypto_funding_arb.py  # delta-neutral spot+perp pair (crypto-only)
    └── templates/
        ├── trade.html.j2     # Jinja2 dashboard page (equities-only view)
        └── results.html.j2   # /trade/results — multi-market toggle + funding panel

Dependencies

Python runtime

  • Python 3.11+ (uses tomllib).
  • System /usr/bin/python3 is fine; install.sh does not create a venv.

Python libraries

Mark installs these on the host before first run (they are not vendored):

pip3 install --user alpaca-py pandas jinja2
  • alpaca-py — REST + WebSocket for market data. Lazy-imported; the CLI health subcommand surfaces whether it's installed.
  • pandas — indicator math + backtest engine + autotrader history slicing. sma / ema have plain-Python fallbacks; rsi / macd / bollinger / the backtester / _autotrader_tick require pandas.
  • jinja2 — template rendering for /trade. Imported by the webpage server.
  • Stdlib only otherwise: sqlite3, http.server, subprocess, tomllib, zoneinfo.

The 2026-05 autotrader / backtest work added no new dependencies — the on-disk bar cache is CSV (avoids pyarrow) and all indicator math reuses the existing pandas import.

External services

  • Alpaca Markets — free paper account (IEX real-time feed, REST historical). Keys go in [alpaca] block of config.toml.
  • BlueBubbles / ~/bin/bb-send.sh — iMessage relay (shared with daily newsfeed digest).
  • Tailscale Funnel — already exposing :8899 publicly; the /trade subpath piggybacks.

Shared infrastructure (lives in ~/agents)

This repo is standalone. The pieces below are shared host infrastructure it talks to by path, not code it owns:

  • ~/agents/webpage-server/webpage-server.py — hosts the /trade HTML route and the whole /api/trade/* surface. The server adds ~/bin/trade/ (the symlink this repo's install.sh creates, falling back to ~/day-trading/trade/) to sys.path and imports the package lazily per request. This is the one deliberate cross-repo coupling — verify.sh lists it under INFO rather than flagging it.
  • ~/agents/newsfeed/newsfeed.db — opened read-only via sqlite3.connect("file:...?mode=ro", uri=True) for the news pane. FTS5 query, phrase-quoted alias expressions.
  • launchd plists are vendored in this repo (LaunchAgents/) and deployed by this repo's install.sh. The monorepo installer no longer touches them.

Configuration

File: ~/.config/trade/config.toml (mode 600, not committed; template in examples/config.toml).

Credentials (ALPACA_KEY, ALPACA_SECRET) live in ~/.env, not in this file. The config loader reads os.environ first, then falls back to parsing ~/.env (since launchd does not source it).

[alpaca]
paper = true

[watchlist]
tickers = ["AAPL", "NVDA", "TSLA", "SPY", "QQQ"]

[alerts]
phone                     = "+17076556006"
price_cross_cooldown_min  = 30
volume_spike_zscore       = 3.0

[trading]
live                   = false    # triple-gate 1/3 — MVP must leave false
max_order_notional_usd = 500

[paper]
starting_cash = 100000

[hours]
regular_open  = "09:30"
regular_close = "16:00"
timezone      = "America/New_York"

[strategy]
enabled                  = false             # opt-in autotrader; default OFF
name                     = "bollinger_breakout"
max_concurrent_positions = 3
stop_loss_pct            = 2.0
daily_loss_cap_pct       = 5.0

[crypto]
enabled                          = false     # opt-in crypto track; default OFF
exchange                         = "hyperliquid_testnet"
symbols                          = ["BTC", "ETH", "SOL"]
paper                            = true
funding_rate_min_annualized_pct  = 8.0
funding_rate_close_pct           = 2.0
notional_per_position_usd        = 1000.0
max_concurrent_positions         = 3
poll_seconds                     = 30

trade.cli watchlist add/remove round-trips this file (uses tomlkit if present, falls back to a naive [watchlist] block rewriter otherwise).

Environment variables

Var Consumed by Effect
TRADE_CONFIG_DIR trade.config Override ~/.config/trade/.
TRADE_CONFIG trade.config Full path to config TOML (overrides the dir).
TRADE_LIVE webpage-server + trade.config.live_enabled_env Gate 2/3 for live orders. MVP keeps this unset.
TRADE_LOG_LEVEL trade.worker Override log level (default INFO).
TRADE_TOKEN_ROTATE_S trade.worker Token rotation interval (default 86400).
TRADE_DRY_RUN trade.config.dry_run Skip side effects in CLI debug.
TRADE_AUTOTRADER_FORCE_FIRE trade.worker._autotrader_tick Set to 1 to emit a single 1-share BUY of cfg.tickers[0] for autotrader wiring smoke tests. Auto-clears after firing once per worker run.
TRADE_FORCE_EQUITY_SNAPSHOT trade.worker.run_forever When 1, write an equity snapshot every tick regardless of market hours. Smoke-test only.
TRADE_CRYPTO_FORCE_FIRE trade.crypto_worker._evaluate_crypto_strategy Set to 1 to emit one paired BUY-spot/SELL-perp on cfg.crypto.symbols[0] for crypto wiring smoke tests. Auto-clears after firing once per worker run.
KALSHI_KEY_ID trade.config Kalshi API key id. Required when cfg.kalshi.enabled = true.
KALSHI_PRIVATE_KEY_PATH trade.config Absolute path to RSA private-key PEM. Required when cfg.kalshi.enabled = true. PEM must be mode 0o600.
KALSHI_FORCE_FIRE trade.kalshi_worker._evaluate_strategy Set to 1 to emit one paired YES + NO BUY on the first open market in cache. Auto-clears after firing once per worker run.

Data layout

  • ~/Library/Application Support/trade/trade.db — SQLite, WAL, 10 s busy timeout. Tables: bars, quotes_latest, positions, fills, alerts, alert_log, runs, equity_snapshots, funding_rates, imessage_quota, kalshi_markets. The first six gain a market column ('equities' | 'crypto' | 'kalshi'); fills also gain pair_id (binds spot+perp legs of a crypto pair), cashflow (funding accrual rows have qty=0, cashflow=$dollars), direction ('YES' | 'NO' | NULL — populated only on Kalshi fills), and realized_pnl (per-row realized gain/loss in dollars booked at close/flip; NULL on opens, including short-opens; NULL on funding-accrual rows so the realized-PnL reader's COALESCE(realized_pnl,0) + COALESCE(cashflow,0) cannot double-count).
  • ~/Library/Application Support/trade/trade_token.txt — per-install token minted by the equities worker, rotated daily. Consumed by webpage-server on every /api/trade/* request. Crypto worker reads-only.
  • ~/Library/Application Support/trade/bars_cache/ — CSV cache for the equities backtest engine (<TICKER>_<YYYYMMDD>_<YYYYMMDD>_<TF>.csv).
  • ~/Library/Application Support/trade/funding_cache/ — CSV cache for the crypto backtest engine (<SYMBOL>_<YYYYMMDD>_<YYYYMMDD>_funding.csv and <SYMBOL>_<YYYYMMDD>_<YYYYMMDD>_1h_bars.csv).
  • ~/Library/Logs/trade-worker.log — equities launchd stdout/stderr.
  • ~/Library/Logs/trade-worker-crypto.log — crypto launchd stdout/stderr.
  • Cross-read only: ~/Library/Application Support/newsfeed/newsfeed.db (FTS5 items_fts table).

Auto-trading (paper, experimental)

This is a science-fair experiment, not a portfolio manager. An LLM has no information edge on equity prices, and a deterministic rules-based strategy on IEX-only data has, at best, marginal expected return after slippage and microstructure noise. The autotrader is opt-in, runs only against paper capital, and exists to falsify "do these strategies beat buy-and-hold." Interpret results accordingly.

How it works

When [strategy].enabled = true, the worker calls _autotrader_tick after every poll cycle. It evaluates the configured strategy against the last 500 1-minute bars per watchlist ticker and routes any BUY/SELL through the existing paper ledger (trade.ledger.place_paper_order). Live trading remains triple-gated and unwired — even with the autotrader on, no real money moves.

Five strategies are implemented in trade/strategies/:

name scope description
buy_and_hold portfolio Equal-weight all watchlist tickers on day one; hold to end.
sma_crossover per-ticker Long when EMA9 crosses above EMA21; exit on the inverse cross.
rsi_meanrev per-ticker Long on RSI14 < 30; exit on RSI > 55 or 2% stop.
bollinger_breakout per-ticker Long on close > Bollinger upper band; exit on touch of mid-band.
momentum_rank portfolio Weekly: hold the top-1 ticker by 12-day return.

Backtest results

Window: 2024-05-04 to 2026-05-04 (2 years), 1-day bars from Alpaca IEX, 5 bps slippage, $0 commission, $100k starting cash, 3 max concurrent positions, per-order cap = starting_cash / max_concurrent (~$33k). Tickers: AAPL, NVDA, TSLA, SPY, QQQ.

strategy total return % annualized Sharpe max drawdown % win rate trades exposure %
bollinger_breakout +18.45 % 0.71 15.01 % 53 % 38 62.6 %
rsi_meanrev +15.77 % 0.63 10.35 % 21 % 38 30.0 %
buy_and_hold +35.16 % 0.63 36.03 % 80 % 5 99.8 %
sma_crossover +10.38 % 0.47 14.11 % 33 % 39 78.8 %
momentum_rank −10.56 % −0.00 35.16 % 48 % 61 97.8 %

Selection rule: pick the highest Sharpe provided Sharpe > 0.5. Result: bollinger_breakout (Sharpe 0.71). It clears the bar by a hair — 0.71 vs 0.63 for the runners-up — and posts roughly half the absolute return of buy-and-hold (+18.45 % vs +35.16 %) but with a 15 % max drawdown vs buy-and-hold's 36 %. Buy-and-hold "won" the index but with substantially more pain along the way; bollinger gave up upside in exchange for tighter drawdown discipline. On a 2-year window dominated by a tech-heavy bull market, both are within margin of error.

The honest read: no strategy in the table demonstrates a robust edge. The Sharpe gap between bollinger_breakout and buy_and_hold is small enough that microstructure noise on real fills could close it in either direction. The rules-based experiment is not falsified by these numbers, but it is also not vindicated — you cannot conclude from a 2-year backtest with 38 trades that bollinger_breakout has an edge over the index. Treat the result as "directional" per the CLI banner.

The seeded [strategy].name in examples/config.toml is bollinger_breakout so a flip of enabled = true runs the backtest-selected winner. Mark may prefer to flip to buy_and_hold for absolute return; both are reasonable.

Re-run any time:

python3 -m trade.cli backtest --years 2
python3 -m trade.cli backtest --strategy bollinger_breakout --tickers SPY,QQQ
python3 -m trade.cli backtest --no-cache --export-csv ~/Library/Application\ Support/trade/bt

Risk controls

control value enforced where
Max concurrent positions [strategy].max_concurrent_positions = 3 worker._autotrader_route
Per-order notional cap [trading].max_order_notional_usd = $500 worker._autotrader_route (clamps qty) + ledger.place_paper_order (rejects oversize)
Per-position stop-loss [strategy].stop_loss_pct = 2.0 % worker._autotrader_tick (sweep before strategy eval)
Daily loss cap (halt for the day) [strategy].daily_loss_cap_pct = 5.0 % worker._autotrader_tick (preflight; resets at next UTC day)
Live trading triple-gated and stubbed ledger.place_order(live=True) raises LiveTradingDisabled
Market hours strategy off when NYSE closed worker._autotrader_tick (preflight)
iMessage spam ≤5 fill notifications per UTC hour, 6th+ logs only worker._autotrader_imessage

Enabling

  1. Edit ~/.config/trade/config.toml: toml [strategy] enabled = true name = "bollinger_breakout" # or any name in trade.strategies.list_names()
  2. Restart the worker: bash launchctl kickstart -k gui/$UID/com.mark.trade-worker
  3. Verify the next tick: bash sqlite3 ~/Library/Application\ Support/trade/trade.db \ "SELECT * FROM fills WHERE note LIKE 'auto:%' ORDER BY id DESC LIMIT 5;"
  4. Watch the log: bash tail -f ~/Library/Logs/trade-worker.log | grep autotrader
  5. iMessages on fills land at [alerts].phone.

Smoke-test escape hatch

TRADE_AUTOTRADER_FORCE_FIRE=1 in the worker process bypasses the configured strategy and emits a single 1-share BUY of cfg.tickers[0] on the next tick. Used to confirm wiring without waiting for a real signal:

launchctl setenv TRADE_AUTOTRADER_FORCE_FIRE 1
launchctl kickstart -k gui/$UID/com.mark.trade-worker
# wait one poll cycle (~15s when market open)
sqlite3 ~/Library/Application\ Support/trade/trade.db \
  "SELECT id,ticker,side,qty,price,note FROM fills WHERE note='auto:force_fire' ORDER BY id DESC LIMIT 1;"
launchctl unsetenv TRADE_AUTOTRADER_FORCE_FIRE
launchctl kickstart -k gui/$UID/com.mark.trade-worker

Swapping strategies

# edit ~/.config/trade/config.toml — change [strategy].name
launchctl kickstart -k gui/$UID/com.mark.trade-worker

The strategy registry is module-resolution-free; any name in trade.strategies.list_names() works.

Disabling

[strategy]
enabled = false

Or simply delete the [strategy] block — the loader defaults all fields, with enabled = false.

Crypto funding-rate arbitrage

This strategy has a real, mechanical edge that bollinger_breakout doesn't. The edge is small — net APR after fees in mature markets is typically reported at 8-40% per vendor blogs (take with salt; these are not first-party benchmarks) — and a single counterparty event can erase a year of accruals. Treat the live-trading toggle as the same triple- gated stub as equities. The strategy is paper-only by construction in v1.

Mechanism

Crypto perpetual futures pay a periodic funding rate (Hyperliquid settles hourly) so the perp price tracks spot. When longs over-pay, you can BUY the spot asset and SELL the perpetual in equal qty — a delta-neutral pair. Funding payments accrue to the short leg; price moves cancel between the two legs because they're opposite signs of the same underlying. The position closes when annualized funding decays below funding_rate_close_pct or flips negative.

Why it has a real edge (and equities doesn't)

The funding rate is a measurable, declared cashflow, not a probabilistic price prediction. The risk lives in the implementation (basis between spot and perp, taker fees, exchange counterparty), not in the signal itself. By contrast bollinger_breakout is trying to predict price moves on IEX-only data — an inherently lower-edge exercise.

That said: edge ≠ free money. The risk vectors below can wipe a quarter's gains in a day, and the testnet's synthetic funding history is not predictive of mainnet realised cashflow.

Exchange choice (Hyperliquid testnet)

US-resident accessible REST API, hourly funding so feedback is fast in development, no KYC for paper. The mainnet UI geoblocks US persons but the protocol is decentralized — irrelevant to v1 because v1 is paper-only. Any future live work would need:

  • A US-accessible spot venue (Coinbase / Kraken) for the spot leg — Hyperliquid testnet does not run a spot market for most pairs, so v1 simulates the spot leg at the perp's mark price (basis = 0 by construction; documented as optimistic in the risks table below).
  • A non-trivial regulatory analysis for the perp leg.

Switching venues is cheap because the crypto/<venue>_client.py boundary keeps the rest of the system venue-agnostic.

Configuration

[crypto]
enabled                          = false              # default OFF
exchange                         = "hyperliquid_testnet"
symbols                          = ["BTC", "ETH", "SOL"]
paper                            = true               # v1 is paper-only by construction
funding_rate_min_annualized_pct  = 8.0                # open when annualized > 8%
funding_rate_close_pct           = 2.0                # close when < 2% (or flip)
notional_per_position_usd        = 1000.0
max_concurrent_positions         = 3
poll_seconds                     = 30                 # REST polling cadence

Hyperliquid funding settles hourly, so a 30s REST poll captures every state change with massive headroom. WebSocket is deliberately deferred — the connection-lifecycle complexity buys nothing for an hourly signal.

Smoke-test escape hatch

TRADE_CRYPTO_FORCE_FIRE=1 in the crypto worker process emits a single paired BUY-spot + SELL-perp on cfg.crypto.symbols[0] at the next tick, bypassing the funding-rate threshold. Used to confirm wiring without waiting for a real signal. Auto-clears after firing once per worker run.

# launchd's plist EnvironmentVariables block strips setenv-injected
# vars, so the cleanest way is to run the worker manually:
TRADE_CRYPTO_FORCE_FIRE=1 python3 -m trade.crypto_worker
# Wait one poll cycle (~30s).
sqlite3 ~/Library/Application\ Support/trade/trade.db \
  "SELECT id,ticker,side,qty,price,pair_id,note FROM fills \
   WHERE market='crypto' ORDER BY id DESC LIMIT 4;"
# Expect 2 rows with the same pair_id (BTC-SPOT BUY + BTC-PERP SELL).

Pair_id reconciliation invariant

Every paired open/close is wrapped in a single SQLite transaction so a crash mid-pair leaves either zero or two fills, never one. On startup the crypto worker runs reconcile_pairs(conn) — if any distinct pair_id has an odd number of legs, it logs WARN, sends one iMessage, sets state["halt_unbalanced"] = True, and refuses to evaluate strategy until the operator runs:

python3 -m trade.cli crypto-pair-clear <pair_id>           # dry-run
python3 -m trade.cli crypto-pair-clear <pair_id> --confirm # actually delete

The CLI refuses to clear pairs with an even number of legs — those should be closed via the strategy, not deleted manually. The recovery flow is: review the rows the dry-run prints, decide whether to clear, re-run with --confirm, restart the worker.

Funding accrual accounting

Held pairs accrue funding once per UTC hour as a funding_accrual row in fills with qty=0, cashflow=$amount, realized_pnl=NULL. The post-2026-05-05 db.realized_pnl(conn) reader returns COALESCE(SUM(realized_pnl), 0) + COALESCE(SUM(cashflow), 0) so the funding receipts feed the equity curve and the dashboard's headline realized-PnL number without double-counting against the per-fill realized PnL on close legs (realized_pnl IS NULL on funding rows is what guarantees the disjoint sum). Funding rows are written inside the worker's transaction so the equity snapshot's "realized" line stays in sync with the fills timeline.

Risks & caveats (in addition to the equities-track table below)

Risk Mitigation
Counterparty risk: testnet venue down or hot-wallet drained Paper-only by construction. Future live work requires a separate analysis.
Basis-risk between spot and perp v1 paper uses perp mark_px for both legs (basis = 0 by construction — optimistic). Live work moves spot to Coinbase/Kraken with documented basis tracking.
Funding-rate flip during a held position funding_rate_close_pct threshold + worker tick cadence. Worst-case latency = cfg.crypto.poll_seconds (default 30s).
US-resident regulatory uncertainty for live perps Paper-only deployment. Live perp trading by US persons remains restricted; this plan does not attempt to thread that needle.
Cross-leg execution skew (one leg fills, other doesn't) All pair operations are wrapped in a single SQLite transaction. Startup reconcile_pairs halts trading if any unbalanced pair exists. iMessage on detection.
Funding rate, fee rate, and basis are vendor-blog numbers (not first-party benchmarks) Quoted with "per vendor" caveat. The README does not assert specific net APR numbers — it documents only the mechanism + risk vectors.
Hyperliquid testnet has very few real funding settlements (~43/yr observed for BTC vs ~8760 expected on mainnet) Backtest results on testnet data are dominated by fees, not funding — the simulator is wired correctly but the numbers extrapolate badly to mainnet. Treat as plumbing-validation only.

Backtest

python3 -m trade.cli backtest --market crypto --strategy crypto_funding_arb --years 1

Output is the same metrics table as the equities backtest plus a crypto-specific footer line with funding=$X (+Y% of starting), fee_burden=$Z, net=$N. Round-trip fee model: 2 × Hyperliquid taker (35 bps each) on open + 2 × on close = 4 × 35 = 140 bps total ("0.14% round-trip"). Funding accrual is notional × interval_rate per held hour. Basis P&L is zero by construction in v1 (perp price on both legs).

Disabling

[crypto]
enabled = false

Or delete the [crypto] block — the loader defaults enabled = false.

Kalshi (prediction markets)

This is a calibration-arbitrage experiment on illiquid Kalshi markets. The edge is small, intermittent, and shrinks as the market grows; on liquid markets it doesn't exist. Kalshi pros from DRW / SIG / Jump capture most of the systematic alpha within seconds. The autotrader is paper-only by construction. Interpret results accordingly.

What Kalshi is

CFTC-regulated US prediction-market exchange, legal for US residents since 2021. As of 2026 Kalshi charges 0% trading fees, which is the mechanic that makes the calibration-arb idea even worth running: without fees, any market where YES_ask + NO_ask < 100c is a positive-EV paper trade if both legs fill before the spread tightens. See docs.kalshi.com.

Strategy: intra-market YES + NO sum arbitrage

When YES_ask + NO_ask < 100c on the same market, buy both legs IOC at the cached ask. At settlement exactly one side pays $1.00 and the other pays $0, so the contract pair always nets $1.00 per pair. Realized PnL = (100 - (yes_ask + no_ask)) cents per pair, minus slippage between signal and fill. Unbalanced fills (YES filled, NO didn't because the spread tightened mid-flight) are flattened by the next-tick stale-leg sweep — we accept this rather than try to cancel or roll back the YES leg. Edge is small (typically 1–3 cents per pair when it exists) and capacity-bounded by orderbook depth on the thin side.

Honest edge analysis

I evaluated five candidate Kalshi strategies. v1 ships only #3.

candidate edge story hard truth
1. News-event arbitrage (newsfeed → Kalshi) If we ingest news a few seconds before pros react, we trade through the spread. DRW / SIG / Jump have dedicated prediction-market desks since 2024. The newsfeed pipeline polls RSS at minute cadence — that is not a latency edge against firms with WebSocket news feeds. Marginal at best, narrow slice of markets.
2. Per-event calibration (NWS forecast vs Kalshi weather, BLS vs Kalshi econ) Real but rare and category-specific. Each calibration rule is a one-off; not generalizable into a registry strategy. Defer.
3. Intra-market YES+NO sum arb Mechanical: when ask-sum < $1.00, buy both, hold to settlement, collect $1.00. Edge is small (1-3c per pair), capacity bounded by orderbook depth, pros sweep most of it within seconds — but truly illiquid markets sit. The v1 pick.
4. Liquidity provision / market-making Quote bid+ask, capture spread. Capital-intensive, inventory-risk-heavy, requires real-time orderbook imbalance modeling. Out of scope.
5. Buy-and-hold benchmark Buy YES at fair value, hold to settlement. Works as a baseline, not a strategy. Not wired in v1.

Auth

Kalshi authenticates each API request with three headers:

KALSHI-ACCESS-KEY        — UUID-style key id from Kalshi UI
KALSHI-ACCESS-TIMESTAMP  — Unix epoch in MILLISECONDS, decimal
KALSHI-ACCESS-SIGNATURE  — base64(RSA-PSS-SHA256(timestamp + method + path))

The signed message is timestamp + method + path concatenated. The path is signed without any query string — sign /markets, then append ?event_ticker=... to the URL. Timestamps drifted >5 s are rejected (clock-sync the host with sntp -sS time.apple.com if auth-test fails with 401).

Setup steps:

  1. Generate keypair in the Kalshi web UI (Account → API Keys). Use the demo site (demo.kalshi.co) for sandbox keys, the prod site for prod keys.
  2. Save the private key PEM at ~/.ssh/kalshi_demo.pem (sandbox) or ~/.ssh/kalshi_prod.pem (prod). chmod 600. The auth helper refuses world/group-readable PEMs.
  3. Add to ~/.env: KALSHI_KEY_ID=...uuid-style id... KALSHI_PRIVATE_KEY_PATH=/Users/mark/.ssh/kalshi_demo.pem
  4. Confirm: python3 -m trade.cli kalshi auth-test → expect HTTP 200 from /exchange/status.

Demo creds are now required (not optional) to use the Kalshi track at all. There is no synthetic-paper fallback — every order the strategy / CLI / force-fire emits is a real POST to Kalshi's demo-API, fills come back from Kalshi's matching engine, and the local SQLite is a synced mirror of Kalshi state.

Sandbox vs production

cfg.kalshi.sandbox = true (default) routes to the demo URL https://demo-api.kalshi.co/trade-api/v2. Demo IS paper — production-grade matching, fills, and settlement on simulated cash. Zero divergence in code paths between demo and prod.

Production routing is gated by cfg.trading.live=true AND TRADE_LIVE=1 env. If cfg.kalshi.sandbox=false is set without both gates, KalshiClient.from_config(cfg) raises KalshiLiveDisabled and the worker / CLI bails. Mark has not enabled prod and intends to keep it that way; the gates exist so a config typo can't accidentally route real money. Flipping to prod requires the prod-side keypair (separate PEM at ~/.ssh/kalshi_prod.pem) AND both gates; there's no automatic fallback.

Worker

python -m trade.kalshi_worker (or /Users/mark/bin/trade/kalshi_worker.py). Long-running, 24/7. Plist: ~/Library/LaunchAgents/com.mark.trade-worker-kalshi.plist. Log: ~/Library/Logs/trade-worker-kalshi.log.

Lifecycle:

  1. Refresh kalshi_markets cache every cfg.kalshi.poll_seconds (filtered by cfg.kalshi.market_filters category prefixes).
  2. Reconcile from Kalshi: pull new fills (/portfolio/fills), settlements (/portfolio/settlements), order status updates, position drift, and balance. The local DB mirrors Kalshi state; Kalshi is the source of truth. Per-tick cadence; pagination capped at 5 pages per resource per cycle.
  3. Sweep stale positions older than max_position_age_hours (default 168 = 1 week) — frees capital from markets that stopped moving. Routes through place_kalshi_order (real demo SELL).
  4. Evaluate kalshi_calibration_arbitrage; each emitted intent (a paired YES + NO BUY on a single market) routes through place_kalshi_order (real demo POST). Legs are placed sequentially; an unbalanced fill (YES filled, NO didn't) is accepted and flattened by the next-tick stale-leg sweep.
  5. Equity snapshot every 60s. The dashboard's % return baseline is Kalshi's actual demo starting balance (/portfolio/balance on first sync; persisted in kalshi_reconcile_state). The cfg.kalshi.starting_cash toml knob is informational only.

Disabled-state behaviour: - cfg.kalshi.enabled = false (default) → log + exit 0; KeepAlive re-spawns each poll cycle effectively giving zero CPU. - enabled = true with missing creds → exit 1 + KeepAlive backoff retry.

CLI

python3 -m trade.cli kalshi auth-test
python3 -m trade.cli kalshi list-markets [--filter WEATHER] [--status open] [--refresh]
python3 -m trade.cli kalshi positions
python3 -m trade.cli kalshi orders [--status executed|resting|canceled|...] [--limit 50]
python3 -m trade.cli kalshi reconcile          # one-shot pull from Kalshi
python3 -m trade.cli kalshi order <ticker> <YES|NO> <count> --price-cents <NN>
                                  [--side BUY|SELL] [--time-in-force IOC|GTC|FOK]
                                  [--note "..."] [--dry-run]
python3 -m trade.cli backtest --market kalshi --strategy kalshi_calibration_arbitrage [--from YYYY-MM-DD]

kalshi order POSTs to the configured environment (demo by default). Default --time-in-force is immediate_or_cancel (IOC): the order either fills immediately or vanishes — no resting orders to clean up. Use --time-in-force good_till_canceled to place a resting order; the reconciliation loop will pick up the eventual fill. --dry-run skips the POST and prints the request body — use it to debug a malformed order without burning demo balance.

Smoke-test escape hatch

KALSHI_FORCE_FIRE=1 env on the worker process: bypass the strategy and emit one paired YES+NO BUY (10 contracts each at the cached ask prices) on the first cached open market. Confirms wiring without waiting for a real signal. Auto-clears after firing once per worker run. Cost on demo is ~$2 (10×$0.10 typical leg) which is fine for one-shot wiring tests.

launchctl setenv KALSHI_FORCE_FIRE 1
launchctl kickstart -k gui/$UID/com.mark.trade-worker-kalshi
sleep 60
sqlite3 ~/Library/Application\ Support/trade/trade.db \
  "SELECT id, status, ticker, direction, requested_count, filled_count
   FROM orders WHERE note='auto:force_fire'
   ORDER BY id DESC LIMIT 4;"
launchctl unsetenv KALSHI_FORCE_FIRE
launchctl kickstart -k gui/$UID/com.mark.trade-worker-kalshi

The force-fire path uses the same place_kalshi_order entry point as the strategy and CLI — there is exactly one code path that POSTs to Kalshi, so a wired force-fire proves the entire production path works.

Backtest

python3 -m trade.cli backtest --market kalshi --strategy kalshi_calibration_arbitrage.

Limitation: the backtest uses Kalshi's daily candlestick midpoints, not orderbook ask depth. The real strategy fires on orderbook gaps that aren't visible in this reconstruction. Treat results as a sanity check on strategy logic, not as a return estimate. Real Kalshi historical orderbook data isn't published via the public API so a true backtest of calibration arbitrage isn't possible from outside the exchange.

Risks & caveats

risk mitigation
Pros (DRW / SIG / Jump) capture systematic alpha within seconds. Edge for retail is tiny, intermittent, and shrinks as the market grows. Strategy runs against demo by default and prod is gated behind cfg.trading.live + TRADE_LIVE=1. README documents the edge story. No claims of edge.
Settlement risk: market unilaterally cancelled by Kalshi (regulatory action, fraud detection, etc.). Position sizing capped at notional_per_position_usd so a single cancellation cannot blow up the demo account.
Market thinness: orderbook depth on illiquid Kalshi markets evaporates intraday; fills slip far from quoted ask. min_orderbook_depth_* filters; IOC limit orders so non-marketable orders don't sit at stale prices.
Regulatory risk on specific market categories (election + sports markets have been litigated). market_filters config skips politics/elections by default; README documents the toggle.
RSA private key compromise allows the holder to trade on Mark's account. PEM stored at ~/.ssh/kalshi_demo.pem mode 600, never committed; separate keypair per environment; the auth helper refuses to load a world-readable PEM.
Time drift: Kalshi rejects requests with timestamps drifted >5 s. now_ms() reads system clock; macOS NTP keeps drift <1 s. Symptom: signed-but-rejected requests with HTTP 401 referencing "timestamp". Fix: sntp -sS time.apple.com.
Network timeout after POST: we don't know if the order landed. Every order carries a client_order_id (uuid4 hex) stored UNIQUE in the local orders table. A 409 on POST → GET /portfolio/orders?client_order_id=... recovers the existing order. Network timeout → same lookup before raising.
Unbalanced YES+NO pair (one leg fills, the other rejects). Accepted as a real outcome. The next-tick stale-position sweep flattens the orphan leg at bid. state['orphan_legs'] counter for visibility. No cancel-on-failure (would race a fill).
Reconciliation drift between local mirror and Kalshi state. Logged per-tick (kalshi: position drift on TICKER ...). Not auto-corrected — drift is a debug surface; investigate manually. Note: calibration-arb intentionally tracks YES + NO as separate positions while Kalshi nets them, so a "drift" of local_yes - local_no = remote_yes is the expected steady-state shape.
Demo balance low / drained. The reconciliation loop stores last_balance_cents in kalshi_reconcile_state; the dashboard surfaces the value. If you're getting "insufficient funds" 400s from Kalshi, top up via the Kalshi web UI on the demo site.

Disabling

[kalshi]
enabled = false

Or delete the [kalshi] block — the loader defaults enabled = false.

Deployment

Install

This repo is self-contained. From the repo root ~/day-trading/:

./install.sh

This: 1. Symlinks trade/~/bin/trade/, plus trade-nightly-runner.sh and nightly-audit.prompt.md into ~/bin/. 2. Copies examples/config.toml~/.config/trade/config.toml (mode 600) iff the destination does not exist. Warns otherwise. 3. Creates ~/Library/Application Support/trade/ + ~/Library/Logs/. 4. Copies the four vendored plists (LaunchAgents/com.mark.trade-*.plist) → ~/Library/LaunchAgents/ and (re)loads each via launchctl.

Re-run any time; it's idempotent. verify.sh is the ship-gate — it proves the wiring resolves into this repo, the four launchd jobs are loaded, and the suite is green.

First run

1. echo 'ALPACA_KEY=...\nALPACA_SECRET=...' >> ~/.env   # if not already set
2. pip3 install --user alpaca-py pandas jinja2
3. python3 -m trade.cli health           # config + DB + bb-send check
4. python3 -m trade.cli backfill --days 1
5. launchctl start com.mark.trade-worker # if not auto-started
6. open https://marks-mac-mini.tail20af9f.ts.net/trade

Reload after code edits

The trade/ package is symlinked, so edits are live for any fresh process import. The worker holds its import cache — restart with:

launchctl kickstart -k gui/$UID/com.mark.trade-worker

The webpage-server is separate (com.mark.webpage-server) — kick it too if you edit /api/trade/* handlers.

Operations

Check worker heartbeat

sqlite3 ~/Library/Application\ Support/trade/trade.db \
  "SELECT started_at, last_tick_at, status, error FROM runs ORDER BY id DESC LIMIT 1;"

or curl -s -H "X-Trade-Token: $(cat …/trade_token.txt)" http://localhost:8899/api/trade/health | jq.

Read current token

TOKEN="$(cat "$HOME/Library/Application Support/trade/trade_token.txt")"

Hit an endpoint

curl -s -H "X-Trade-Token: $TOKEN" \
  "http://localhost:8899/api/trade/bars?t=NVDA&tf=1m&n=60" | jq '.bars | length'

Place a paper order

curl -s -X POST \
  -H "X-Trade-Token: $TOKEN" -H "Content-Type: application/json" \
  -d '{"ticker":"NVDA","side":"BUY","qty":1}' \
  http://localhost:8899/api/trade/order | jq .

Tail the log

tail -f ~/Library/Logs/trade-worker.log         # equities
tail -f ~/Library/Logs/trade-worker-crypto.log  # crypto

Restart workers

launchctl kickstart -k gui/$UID/com.mark.trade-worker
launchctl kickstart -k gui/$UID/com.mark.trade-worker-crypto

Performance dashboard

https://marks-mac-mini.tail20af9f.ts.net/trade/results — wins and losses. The autotrader has no proven edge; see Auto-trading § Backtest results before drawing conclusions.

Data: a fresh equity_snapshots row is written by the worker every 60 s during market hours and immediately after every fill. Schema: (id, ts, cash, positions_mv, equity, realized_pnl, unrealized_pnl), indexed on ts. The first snapshot is the dashboard's "starting cash" anchor for total return %.

The dashboard polls /api/trade/results once on load + every 30 s while market open (5 min when closed). The response covers headline metrics, equity curve (decimated to ≤720 points), SPY benchmark overlay, drawdown curve, per-trade analytics (win rate, avg winner / loser, max drawdown, annualized Sharpe), per-ticker rollup, and the most-recent 100 fills with FIFO-matched realized PnL.

Smoke-test escape hatch: set TRADE_FORCE_EQUITY_SNAPSHOT=1 and launchctl kickstart to write snapshots regardless of market hours.

Unload (stop the worker)

launchctl unload ~/Library/LaunchAgents/com.mark.trade-worker.plist

Nightly audit & tune

A launchd job (com.mark.trade-nightly) fires at 00:05 local Mac-mini time every night and drops the prompt at ~/day-trading/nightly-audit.prompt.md as an event for the day-trading poll session, which audits the prior UTC day's fills, runs a bounded backtest sweep against each enabled strategy, optionally edits ~/.config/trade/config.toml, and sends Mark a one-line iMessage digest. Full per-run detail lands in ~/Library/Logs/trade-nightly.log.

Bounded scope

The autotuner only ever touches a narrow surface — everything else is forbidden, and a verification step asserts byte-equality on the forbidden blocks after every write.

Allowed config surfaces:

  • [strategy.bollinger_breakout] sub-table (window + std knobs).
  • [crypto] block — only funding_rate_min_annualized_pct and funding_rate_close_pct.
  • [kalshi.kalshi_calibration_arbitrage] sub-table — only min_gap_cents, min_orderbook_depth_yes, min_orderbook_depth_no (depth knobs swept tied).

Forbidden surfaces — never read for-write, never written:

  • [trading].live (live-trading gate 1/3).
  • [kalshi].sandbox.
  • [alpaca].paper.
  • [alpaca], [trading], [paper], [hours], [watchlist], [alerts] top-level blocks.
  • RSA PEM files under ~/.ssh/kalshi_*.pem.
  • ~/.env.
  • All source under ~/bin/trade/ and ~/day-trading/trade/.

Verification: every config write is preceded by ~/.config/trade/config.toml.bak.<UTC YYYYMMDD-HHMMSS> and followed by a diff sanity check that asserts the forbidden blocks are byte-identical. On mismatch the audit restores the backup and iMessages a failure note (Trade audit FAILED: forbidden field write detected; reverted). The last 14 backups are kept; older ones are pruned each run.

Tuning gate

To swap from currentcandidate, ALL four inequalities must hold:

  1. new.sharpe > current.sharpe + 0.10
  2. new.trade_count >= 60
  3. new.max_drawdown_pct <= current.max_drawdown_pct
  4. new.total_return_pct >= current.total_return_pct * 0.9

If multiple candidates pass, the highest-Sharpe variant wins. If none pass, Tuned: none is reported. The trade-count floor of 60 is intentionally strict at --years 1 — most nights it rejects lucky-streak variants by construction and Tuned: none is the expected outcome.

Per-strategy search space

Strategy Knob Cells Samples
bollinger_breakout bollinger_window × bollinger_std {18,20,22} × {1.8,2.0,2.2} = 9 3 random + current
crypto_funding_arb funding_rate_min_annualized_pct × funding_rate_close_pct (constraint: min > close + 4) {6,8,10,12} × {1,2,3} = 12 raw 3 valid + current
kalshi_calibration_arbitrage min_gap_cents × tied depth {1,2,3,4} × {10,25,50} = 12 3 + current
buy_and_hold (none — no tunable knobs) sweep skipped

Random samples use a date-seeded RNG so the same UTC date always samples the same cells (reproducible across re-runs of the same night).

Paths

Artifact Path
Prompt ~/day-trading/nightly-audit.prompt.md (symlinked to ~/bin/trade-nightly-audit.prompt.md)
Runner ~/day-trading/trade-nightly-runner.sh (symlinked to ~/bin/trade-nightly-runner.sh)
Plist ~/day-trading/LaunchAgents/com.mark.trade-nightly.plist (vendored; copied to ~/Library/LaunchAgents/)
Log ~/Library/Logs/trade-nightly.log
Config backups ~/.config/trade/config.toml.bak.<UTC> (last 14 retained)

Disable / force-run / dry-run

# disable
launchctl unload ~/Library/LaunchAgents/com.mark.trade-nightly.plist

# force-run immediately (live — config write + iMessage happen)
launchctl kickstart -k gui/$UID/com.mark.trade-nightly

# dry-run (audit + sweep run; no config write, no iMessage)
NIGHTLY_DRY_RUN=1 ~/bin/trade-nightly-runner.sh

iMessage format

Trade audit 2026-05-14: eq +$12.34 (bollinger_breakout), crypto -$0.50 (crypto_funding_arb). Anomalies: none. Tuned: none.

Per-track segments are omitted when that track is disabled. Anomaly lists longer than 80 chars collapse to Anomalies: N (see log). Target ≤160 chars so the message stays a single SMS segment in fallback delivery.

Bounded but not bulletproof

The autotuner runs against backtests with no proven live edge — a "winning" candidate may just be overfit to the 2-year window. The 4-inequality gate is a sanity rail, not a guarantee. The risk row in the table below documents this trade-off; paper-only by construction keeps the blast radius zero.

Risks & caveats

Personal-use software, not financial advice. No warranty. Paper-default for a reason. See PLAN.md §Risks for the full table.

Risk Mitigation
Alpaca IEX feed ~2% of volume — thin names look flat vs reality. MVP sticks to high-volume names. Polygon upgrade documented.
/trade is reachable via the public Funnel URL. Mandatory X-Trade-Token; URL must stay private; consider Tailscale auth for this subpath if leakage matters.
Paper ↔ live confusion could send real money. Triple gate + Phase-4 adapter absent → every live request returns 501.
BlueBubbles tunnel URL rotates → alerts silently fail. Daily heartbeat bb-send.sh --dry-run; surface failures in /api/trade/health.
Autotrader runs deterministically against IEX-only quotes — fillable liquidity is overstated, expected return is negative after slippage and microstructure noise. Paper-only by construction; daily-loss halt; default-off; backtest reported and only enabled if Sharpe > 0.5; iMessage rate-limit prevents alert spam.
Backtest cadence (1-day bars) does not match autotrader cadence (1-min). Documented gap: backtest is a sanity check on strategy logic, not a tick simulator. Pass --timeframe 1Min for fine-grained tests but expect IEX-thin minute bars to overstate fillable liquidity.
Bar cache can serve stale data after Alpaca tape adjustments. Cache keyed by exact (ticker,start,end,tf) strings; pass --no-cache to force refetch when in doubt.
PDT rule (≥4 day-trades / 5 business days < $25k). Not a concern until Phase 4; rolling counter + UI warning planned then.
Market-data redistribution ToS. Personal use only; dashboard behind auth; no public caching of bars.
/trade/results shows wins AND losses but is still a small-sample paper history; visible "outperformance" or "underperformance" against SPY is statistically meaningless until the equity curve has months of trading days. Honesty banner pinned to the page footer; README spec links to backtest section; bollinger_breakout selection criteria documented under Auto-trading.
Nightly autotuner runs against backtests with no proven edge — a "winning" parameter set may just be overfit to the 2-year window. 4-inequality gate (Sharpe + trade-count + drawdown + return floor). Forbidden-fields safety rail with backup-restore on violation. Bounded grid (≤12 cells per strategy, 3 random samples). Iteration is paper-only by construction.

See also

  • PLAN.md — full implementation plan + verification checklist.
  • LaunchAgents/com.mark.trade-worker.plist — vendored launchd unit (this repo).
  • ~/agents/webpage-server/ — shared server hosting /trade and /api/trade/*.
  • ~/agents/newsfeed/ — source of ticker-scoped news (read-only).
  • ~/agents/scripts/bb-send.sh — iMessage relay.