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_eventrules with per-rule cooldowns. - Worker daemon with Alpaca REST + (Phase 2) WebSocket; KeepAlive under
launchd; exponential backoff on the Alpaca connection; writes a
runsheartbeat 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/python3is fine;install.shdoes 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 CLIhealthsubcommand surfaces whether it's installed.pandas— indicator math + backtest engine + autotrader history slicing.sma/emahave plain-Python fallbacks;rsi/macd/bollinger/ the backtester /_autotrader_tickrequire 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 ofconfig.toml. - BlueBubbles /
~/bin/bb-send.sh— iMessage relay (shared with daily newsfeed digest). - Tailscale Funnel — already exposing
:8899publicly; the/tradesubpath 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/tradeHTML route and the whole/api/trade/*surface. The server adds~/bin/trade/(the symlink this repo'sinstall.shcreates, falling back to~/day-trading/trade/) tosys.pathand imports the package lazily per request. This is the one deliberate cross-repo coupling —verify.shlists it under INFO rather than flagging it.~/agents/newsfeed/newsfeed.db— opened read-only viasqlite3.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'sinstall.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 amarketcolumn ('equities' | 'crypto' | 'kalshi'); fills also gainpair_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), andrealized_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 bywebpage-serveron 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.csvand<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(FTS5items_ftstable).
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
- Edit
~/.config/trade/config.toml:toml [strategy] enabled = true name = "bollinger_breakout" # or any name in trade.strategies.list_names() - Restart the worker:
bash launchctl kickstart -k gui/$UID/com.mark.trade-worker - 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;" - Watch the log:
bash tail -f ~/Library/Logs/trade-worker.log | grep autotrader - 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:
- 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. - 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. - Add to
~/.env:KALSHI_KEY_ID=...uuid-style id... KALSHI_PRIVATE_KEY_PATH=/Users/mark/.ssh/kalshi_demo.pem - 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:
- Refresh
kalshi_marketscache everycfg.kalshi.poll_seconds(filtered bycfg.kalshi.market_filterscategory prefixes). - 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. - Sweep stale positions older than
max_position_age_hours(default 168 = 1 week) — frees capital from markets that stopped moving. Routes throughplace_kalshi_order(real demo SELL). - Evaluate
kalshi_calibration_arbitrage; each emitted intent (a paired YES + NO BUY on a single market) routes throughplace_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. - Equity snapshot every 60s. The dashboard's % return baseline is
Kalshi's actual demo starting balance (
/portfolio/balanceon first sync; persisted inkalshi_reconcile_state). Thecfg.kalshi.starting_cashtoml 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 — onlyfunding_rate_min_annualized_pctandfunding_rate_close_pct.[kalshi.kalshi_calibration_arbitrage]sub-table — onlymin_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 current → candidate, ALL four inequalities must hold:
new.sharpe > current.sharpe + 0.10new.trade_count >= 60new.max_drawdown_pct <= current.max_drawdown_pctnew.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/tradeand/api/trade/*.~/agents/newsfeed/— source of ticker-scoped news (read-only).~/agents/scripts/bb-send.sh— iMessage relay.