webull_strategy_id
for live execution, watch its first trades, measure whether live results converge on the paper-derived
edge, and kill it from anywhere. This is a lab experiment — AI as lab rat, real money as the labeled
training data — not a deployment. The safety binding is not an env flag: it is a per-strategy authorization row
in webull_live_authorizations.db. A strategy with no unrevoked row falls back to the paper bridge
(Phase 3.1). Hard limits are code-constants in webull_live_adapter.py: MAX_OPEN_LIVE_POSITIONS=3,
MAX_DOLLARS_PER_TRADE=100, MAX_DAILY_REALIZED_LOSS=250,
MAX_CONSECUTIVE_LIVE_LOSSES=5. UAT (us-openapi-alb.uat.webullbroker.com) is validated
before PROD.
EDGE_CONFIRMED gate
(n≥100, p_lower>break_even). Clearing them authorizes a small-stakes live experiment; it does not assert
an edge. Edge claims require paper and live to agree.
Every row must PASS before running the §2 authorize script. These are the operator's empirical lab-entry
thresholds, not the F7 EDGE_CONFIRMED gate.
| Check | Source | Threshold (PASS if) | Rationale |
|---|---|---|---|
Paper n_closed | /api/webull/f7/gate?mode=paper | ≥ 30 | Enough closes to compute a stable Wilson lower bound. |
Paper p_lower | same | > 0.30 | Lower bound exceeds 30% — a meaningful prior, not noise. |
Paper b (R-multiple) | same | > 0.5 | Asymmetric payoff favors winners enough to matter. |
| R-distribution sanity | SQL on webull_paper_trades | no |R| > 5 in any bin | No infinity-tail blow-ups distorting the average. |
| 5-arm provenance | SQL by webull_lab | ≥ 2 labs contributed closes | Single-arm could be a lab-specific artifact. |
| Service health | systemctl --user is-active stockpickerai | active | No restart pending mid-flip. |
| Reconciliation cron | crontab -l | installed | The §1.4 reconcile cron must be running before any live order exists. |
Gate metrics (rows 1–3):
curl -s 'http://127.0.0.1:8030/api/webull/f7/gate?mode=paper' \
| python3 -c "import sys,json; d=json.load(sys.stdin); \
b=[x for x in d['buckets'] if x['strategy_id']=='<strategy>'][0]; \
assert b['n_closed'] >= 30, f'FAIL n_closed={b[\"n_closed\"]}'; \
assert b['p_lower'] > 0.30, f'FAIL p_lower={b[\"p_lower\"]}'; \
assert b['b'] > 0.5, f'FAIL b={b[\"b\"]}'; \
print('PASS', b['strategy_id'], b['n_closed'], b['p_lower'], b['b'])"
R-distribution sanity (row 4) — fires FAIL if any close has |R| > 5:
SELECT COUNT(*) AS extreme_r FROM webull_paper_trades WHERE webull_strategy_id='<strategy>' AND is_live=0 AND status='closed' AND ABS(realized_r) > 5; -- PASS iff extreme_r = 0
5-arm provenance (row 5) — fires FAIL if fewer than 2 labs contributed:
SELECT COUNT(DISTINCT webull_lab) AS arms FROM webull_paper_trades WHERE webull_strategy_id='<strategy>' AND is_live=0 AND status='closed'; -- PASS iff arms >= 2
Service health (row 6) and cron installed (row 7):
systemctl --user is-active stockpickerai # PASS iff prints: active
crontab -l | grep -q phase4_reconcile.py && echo "PASS: reconcile cron installed" \
|| echo "FAIL: install §1.4 cron first"
Authorization is a deliberate, signed operator action — it writes one row to
webull_live_authorizations.db. Run it interactively:
python3 scripts/phase4_authorize_strategy.py \
--strategy long_call \
--by "operator-2026-06-18" \
--max-usd 50 \
--notes "Phase 4 first authorization: lab experiment 4a"
Expected JSON output (the script snapshots paper metrics at the moment of auth as a sanity record):
{
"status": "authorized",
"strategy_id": "long_call",
"authorized_at": 1781747820,
"paper_snapshot": {"n_closed": 32, "p_lower": 0.412, "b": 0.83},
"max_position_usd": 50
}
SQL invariant immediately after auth — exactly one active authorization for the strategy:
SELECT COUNT(*) FROM webull_live_authorizations WHERE strategy_id='long_call' AND revoked_at IS NULL; -- expected: 1
From this moment, the next open_trade() for long_call with live_mode_request=True
routes through webull_live_adapter.place_option_order() (which still runs broker
preview_option first, never skipping it). Every other strategy remains paper. The
max_position_usd on the row caps the ticket regardless of the global MAX_DOLLARS_PER_TRADE.
The first three live trades after authorization are watched by the operator in sequence. Each has a specific gate; failing any one is an operator-only revoke + investigation — there is no automation here.
| Trade | What the operator confirms | Flag / abort condition |
|---|---|---|
| Trade 1 | Watch in real time. Confirm the broker order ID matches the local client_order_id. Record the
actual fill price vs. the paper-expected fill price. |
fill_diff > 5% above mid → possible routing problem → revoke + investigate. |
| Trade 2 | Confirm trade 1 has reconciled — the broker says CLOSED before trade 2 opens (no
overlap). Compare trade 1's close R-multiple to the paper baseline distribution. |
R-multiple is an outlier (> 2σ from the paper median) → flag. |
| Trade 3 | Confirm rows are landing in webull_live_orders.db AND in webull_paper_trades
with is_live=1. Verify the kill switch is reachable: run
curl -X POST .../api/webull/f4/kill/long_call, confirm the revoke, then re-authorize and
let the bot run. |
Rows missing from either DB, or kill switch unreachable → flag + revoke until fixed. |
This is the science. Live execution is a second, independent dataset; the edge claim requires it to agree with paper. After every 10 live closes for an authorized strategy, run:
WITH live AS (
SELECT COUNT(*) AS n,
AVG(CASE WHEN realized_r > 0 THEN 1.0 ELSE 0.0 END) AS p_hat_live,
AVG(realized_r) AS mean_r_live
FROM webull_paper_trades
WHERE webull_strategy_id=? AND is_live=1 AND status='closed'
),
paper AS (
SELECT COUNT(*) AS n,
AVG(CASE WHEN realized_r > 0 THEN 1.0 ELSE 0.0 END) AS p_hat_paper,
AVG(realized_r) AS mean_r_paper
FROM webull_paper_trades
WHERE webull_strategy_id=? AND is_live=0 AND status='closed'
)
SELECT live.p_hat_live, paper.p_hat_paper,
ABS(live.p_hat_live - paper.p_hat_paper) AS delta,
live.mean_r_live, paper.mean_r_paper
FROM live, paper;
Convergence rule on delta = |p_hat_live − p_hat_paper|:
| delta | Verdict | Action |
|---|---|---|
| < 0.10 | CONVERGENT | Paper edge replicates on live — continue. |
| 0.10 – 0.15 | WATCH | Drift; record but continue. |
| ≥ 0.15 | DIVERGENT | Probable regime shift OR market microstructure (slippage / fills / IV smile) eats the edge. Auto-kill the strategy, alert operator. |
Auto-divergence detection is a candidate for future automation; in v1 the operator runs this query manually and decides. Divergence is not a failure of the rail — it is exactly the finding Phase 4 exists to surface: a paper edge that does not survive real execution is a real (and valuable) negative result.
Three reachable surfaces. The kill revokes the authorization row — the next open_trade() for that
strategy reverts to paper. No service restart needed.
curl -X POST https://gpu3.<host>/api/webull/f4/kill/long_call \
-H "Content-Type: application/json" \
-d '{"reason":"phone kill - lunch meeting"}'
Expected response:
{
"status": "revoked",
"strategy_id": "long_call",
"open_live_positions": 2,
"next_open_trade_routes_to": "paper"
}
/webull-live-dashboard — a kill button per strategy plus a red KILL ALL (double-click)
that hits POST /api/webull/f4/kill/all and cascades over every active authorization.
python3 scripts/phase4_authorize_strategy.py --strategy long_call --revoke --reason "kill"
?cancel_open_orders=true, or hit the broker directly via
webull_live_adapter.cancel_order(client_order_id).
When MAX_DAILY_REALIZED_LOSS = $250 is breached, the adapter enforces the floor without operator
intervention:
webull_live_adapter.check_position_limits() returns a blocked reason.{"status":"blocked","reason":"MAX_DAILY_REALIZED_LOSS breached: today_pnl=-$251.34"}.SQL the operator uses to inspect the day's realized live loss:
SELECT SUM(pnl_usd) AS today_realized
FROM webull_paper_trades
WHERE is_live=1 AND status='closed'
AND closed_at >= strftime('%s', 'now', 'start of day');
MAX_CONSECUTIVE_LIVE_LOSSES=5 auto-fallback (a strategy that loses 5 live trades in a row reverts to
paper) is the finer-grained sibling control, enforced in the adapter.