Parabolic Short — Live API Smoke Test Runbook
This runbook is the manual verification procedure for the
parabolic-short-trade-planner skill against live FMP and Alpaca
APIs. The Phase 1 + Phase 2 unit-test suite (137 tests on dry-run
fixtures) cannot prove that the wire shapes upstream still match the
contract Phase 1 was built against, nor that the Alpaca paper account
is reachable with the configured credentials. This runbook closes that
gap.
Run it on any non-trivial change to:
fmp_client.py(especially the EOD / profile normalizers)adapters/alpaca_inventory_adapter.py- the Phase 1 → Phase 2 schema (any field rename in
parabolic_short_*output JSON) - new Alpaca account / API key rotation
It is also the one-time validation expected after merging the Phase 1+2 implementation, since the original PR shipped without any live execution.
1. Prerequisites
Set the following environment variables before running anything:
export FMP_API_KEY=... # Free tier (250 calls/day) is enough
export ALPACA_API_KEY=...
export ALPACA_SECRET_KEY=...
export ALPACA_PAPER=true # Paper trading account; recommendedOther requirements:
- Python 3.10+
requestsavailable (pip install requestsor use the repo's venv)- Run all commands from the repository root;
screen_parabolic.pyandgenerate_pre_market_plan.pyresolve--ssr-state-dirand--output-dirrelative to the current working directory.
Cwd matters for SSR carryover. Mixing relative and absolute paths across runs can leave orphan state files. The runbook below always passes
"$(pwd)/state/parabolic_short"as the SSR state dir.
2. Connectivity check (~90 s, 5 checks / 6 HTTP calls)
python3 skills/parabolic-short-trade-planner/scripts/check_live_apis.pyThe script runs 5 logical checks (4 required gates + 1 optional
warning) against FMP + Alpaca. The fifth check (alpaca_404_graceful)
issues two HTTP requests against the same Alpaca endpoint — one raw
probe to confirm the 404 status, one through
AlpacaInventoryAdapter.get_inventory_status() to confirm the adapter
handles that response without raising — so the script makes
6 HTTP calls per run total (3 FMP + 3 Alpaca). Cost remains
trivial under both API rate limits.
Expected output (order may vary):
PASS fmp.historical_price_eod_full — N bars; Issue #64 shape verified
PASS fmp.profile — mktCap=...
WARN fmp.sp500_constituent — HTTP 403 — likely entitlement (skip on Free)
PASS alpaca.assets_aapl — shortable=True easy_to_borrow=True (paper=True)
PASS alpaca.assets_404_graceful — raw HTTP 404 mapped to asset_not_found dict
Required gates: 4/4 passed (sp500 is optional warning)Exit code 0 means all four required gates passed. The sp500 line is a warning, not a gate — it can be PASS or WARN depending on FMP tier.
If any gate fails, the script prints FAIL <name> — HTTP <code> — <body truncated>. Common failure causes are documented in the
Troubleshooting matrix at the end.
2a. FMP-only mode (no Alpaca credentials)
Contributors who only have an FMP key configured can validate the FMP wire shape without needing Alpaca paper credentials:
python3 skills/parabolic-short-trade-planner/scripts/check_live_apis.py --fmp-onlyExpected output:
Mode: --fmp-only (Alpaca gates will be skipped)
PASS fmp.historical_price_eod_full — N bars; Issue #64 shape verified
PASS fmp.profile — mktCap=...
PASS fmp.sp500_constituent — N constituents
SKIP alpaca.assets_aapl — explicitly skipped via --fmp-only
SKIP alpaca.assets_404_graceful — explicitly skipped via --fmp-only
Required gates: 2/2 passed (FMP only; Alpaca gates skipped, sp500 is optional warning)Exit code is 0 when the FMP required gates pass. The Alpaca gates
print as SKIP and never count toward the exit code in this mode.
If you forget the flag and Alpaca creds are unset, the script still
runs the FMP gates, prints SKIP for the Alpaca gates, and tells you
to either pass --fmp-only or set the Alpaca env vars. This avoids
the previous behaviour of aborting at setup.
The Alpaca gates (alpaca.assets_aapl, alpaca.assets_404_graceful)
are maintainer-only verification when FMP is the only API the
contributor has access to. Production deployment of Phase 2 / Phase 3
still requires Alpaca credentials — --fmp-only is for runbook
validation, not production use.
3. Phase 1 — Tier 1 rejection smoke (smoke_universe_diverse.csv)
mkdir -p reports/smoke
python3 skills/parabolic-short-trade-planner/scripts/screen_parabolic.py \
--universe finviz-csv \
--universe-csv skills/parabolic-short-trade-planner/references/smoke_universe_diverse.csv \
--max-api-calls 50 --top 25 \
--output-dir reports/smoke/ \
--verboseThe diverse CSV is rejection-biased (mega-cap defensives + thin
mid-caps), so most or all tickers will reject at the soft thresholds
(min_roc_5d, min_ma20_extension_pct). Zero candidates is a PASS
for this tier provided --verbose shows at least one rejection
reason, which proves the invalidation path is live.
Expected --verbose rejection log (one line per rejected ticker,
under INFO parabolic_short.screen Universe size: ...):
DEBUG parabolic_short.screen Rejected JNJ: min_roc_5d threshold not met (got -0.13%, need >=30.00%)
DEBUG parabolic_short.screen Rejected PG: min_roc_5d threshold not met (got -0.62%, need >=30.00%)
DEBUG parabolic_short.screen Rejected KO: min_roc_5d threshold not met (got 2.54%, need >=30.00%)
...Other rejection reasons that may appear depending on the CSV / market state:
Rejected <T>: insufficient_history (<N> bars; need >=21)— recent IPO or post-split; the screener cannot compute its 20-bar metrics.Rejected <T>: invalidation (<reasons>)— hard-gate rejection (market cap below mode floor, ADV below floor, earnings within window, IPO too recent, catalyst blackout).Rejected <T>: min_ma20_extension_pct threshold not met (got <X>%, need >=<Y>%)Rejected <T>: min_atr_extension threshold not met (got <X>, need >=<Y>)
Tier 1 PASS requires at least one such rejection line — that proves the rejection path is live, not silently swallowed.
Output:
reports/smoke/parabolic_short_<as_of>.json— v1.0 schema;candidatesmay be[].reports/smoke/parabolic_short_<as_of>.md— human-readable report.
If candidates is non-empty, you can additionally run Phase 2 with
--broker none against this report and confirm every plan comes out
as plan_status: watch_only with borrow_inventory_unavailable in
blocking_manual_reasons — same checks as Tier 2 below.
4. Phase 1 — Tier 2 end-to-end smoke (smoke_universe_relaxed.csv)
python3 skills/parabolic-short-trade-planner/scripts/screen_parabolic.py \
--universe finviz-csv \
--universe-csv skills/parabolic-short-trade-planner/references/smoke_universe_relaxed.csv \
--min-roc-5d 0 --min-ma20-extension-pct 0 --min-atr-extension 0 \
--watch-min-grade D \
--exclude-earnings-within-days 0 --min-adv-usd 0 \
--min-price 0 --min-market-cap 0 \
--max-api-calls 50 --top 25 \
--output-dir reports/smoke/ \
--output-prefix parabolic_short_relaxed \
--verboseTwo flag groups:
- Soft (
--min-roc-5d,--min-ma20-extension-pct,--min-atr-extension): set to 0 so score components don't gate. - Hard (
--exclude-earnings-within-days,--min-adv-usd,--min-price,--min-market-cap): set to 0 so a single earnings-tomorrow ticker (or recent IPO) doesn't drop the whole CSV. All four flags already exist onscreen_parabolic.py— the runbook does not need a source patch.
Earnings-aware behavior: in addition to the forward-looking hard blackout above,
--earnings-catalyst-window-days(default 10 trading days) attaches a softrecent_earnings_catalystwarning to any candidate that reported earnings within the window. Smoke runs leave this at the default — the warning is informational and does not gate survival. To suppress it for a regression diff against pre-earnings-fix output, set a negative value (e.g.-1): the screener comparestrading_days_since_earnings <= window, so0still fires for same-day earnings while a negative window can never match.
Expected: candidates length ≥ 1 (near-certain on 8–10 mega-caps with
all gates relaxed). If 0, the rejection logic itself is buggy or the
relaxed CSV is stale (re-curate the CSV — see Pitfall #5).
Output:
reports/smoke/parabolic_short_relaxed_<as_of>.json— feeds Phase 2.
5. Phase 2 — Alpaca + manual paths
Important: Steps 5a and 5b both write to
reports/smoke/but with different--output-prefixvalues. The default prefix (parabolic_short_plan) would have the manual run silently overwrite the Alpaca run.
5a. Alpaca path
mkdir -p state/parabolic_short
PHASE1_RELAXED=reports/smoke/parabolic_short_relaxed_<as_of>.json
python3 skills/parabolic-short-trade-planner/scripts/generate_pre_market_plan.py \
--candidates-json "$PHASE1_RELAXED" \
--broker alpaca \
--tradable-min-grade D \
--account-size 100000 --risk-bps 50 \
--ssr-state-dir "$(pwd)/state/parabolic_short" \
--output-dir reports/smoke/ \
--output-prefix parabolic_short_plan_alpacaExpected:
- ≥1 plan emitted in
parabolic_short_plan_alpaca_<as_of>.json. - ≥1 plan ideally has
plan_status: actionable(ETB happy path). If all plans come outwatch_only, document in the smoke report ("all relaxed-CSV candidates HTB today — not a code bug, log only"). entry_plans[*].size_recipe.shares_formulais a string formula, not a numericsharesfield.- Per-ticker SSR state files written under
state/parabolic_short/ssr_state_<ticker>_<as_of>.json.
5b. Manual fallback path
python3 skills/parabolic-short-trade-planner/scripts/generate_pre_market_plan.py \
--candidates-json "$PHASE1_RELAXED" \
--broker none \
--tradable-min-grade D \
--account-size 100000 --risk-bps 50 \
--ssr-state-dir "$(pwd)/state/parabolic_short" \
--output-dir reports/smoke/ \
--output-prefix parabolic_short_plan_manualExpected (regression check on the manual fallback):
- Every plan has
plan_status: watch_only. - Every plan's
blocking_manual_reasonscontainsborrow_inventory_unavailable.
6. Day-2 SSR carryover determinism
PHASE2_PLAN=reports/smoke/parabolic_short_plan_alpaca_<as_of>.json
# Guard: Tier 2 must have produced at least one plan.
PLAN_COUNT=$(python3 -c "import json; print(len(json.load(open('$PHASE2_PLAN'))['plans']))")
if [ "$PLAN_COUNT" -lt 1 ]; then
echo "Step 6 skipped: Tier 2 produced 0 plans; carryover test cannot run."
exit 1
fi
TICKER=$(python3 -c "import json; print(json.load(open('$PHASE2_PLAN'))['plans'][0]['ticker'])")
TODAY=$(python3 -c "import json; print(json.load(open('$PHASE1_RELAXED'))['as_of'])")
TOMORROW=$(python3 -c "from datetime import date,timedelta; print((date.fromisoformat('$TODAY')+timedelta(days=1)).isoformat())")
STATE_FILE="state/parabolic_short/ssr_state_${TICKER}_${TODAY}.json"
# Force the trigger flag in yesterday's state file (the MVP can't detect
# Rule 201 fires on its own because aftermarket data isn't wired in).
python3 -c "import json,pathlib; p=pathlib.Path('$STATE_FILE'); \
d=json.loads(p.read_text()); d['ssr_triggered_today']=True; \
p.write_text(json.dumps(d))"
python3 skills/parabolic-short-trade-planner/scripts/generate_pre_market_plan.py \
--candidates-json "$PHASE1_RELAXED" \
--broker none \
--tradable-min-grade D \
--as-of "$TOMORROW" \
--ssr-state-dir "$(pwd)/state/parabolic_short" \
--output-dir reports/smoke/ \
--output-prefix parabolic_short_plan_day2Expected: in reports/smoke/parabolic_short_plan_day2_<TOMORROW>.json,
the plan for $TICKER has:
"ssr_state": {
"ssr_carryover_from_prior_day": true,
"uptick_rule_active": true,
...
}The new test_as_of_override_advances_carryover test in
tests/test_generate_pre_market_plan.py covers the same behaviour at
the CLI/main() level, so this manual step is a regression check, not
the only verification.
7. Phase 3 — intraday trigger monitor smoke (added in Phase 3 v0.5)
Phase 3 evaluates 5-min bars during the US regular session and walks each plan's FSM forward. v0.5 ships two data sources — a fixture (offline, used in tests) and live Alpaca. Both are smoke-tested here.
7a. Fixture-driven dry-run (no network)
python3 skills/parabolic-short-trade-planner/scripts/monitor_intraday_trigger.py \
--plans-json skills/parabolic-short-trade-planner/scripts/tests/fixtures/phase2_plan_smoke.json \
--bars-source fixture \
--bars-fixture \
skills/parabolic-short-trade-planner/scripts/tests/fixtures/intraday_bars/orl_clean_break.json \
--state-dir /tmp/parabolic_intraday_smoke \
--output-dir /tmp/parabolic_intraday_smoke \
--as-of 2026-05-05 \
--now-et 2026-05-05T10:00:00-04:00 \
--verboseExpected (from parabolic_short_intraday_2026-05-05.json):
phase: "intraday_monitor",data_source: "fixture",market_status: "regular_session".monitored_planscontains the AAPL ORL plan withstate: "triggered",entry_actual: 148.45,stop_actual: 150.35, and asize_recipe_resolvedblock with a positive integershares_actual.
7b. Alpaca live integration check (paper account)
PHASE2_PLAN=reports/smoke/parabolic_short_plan_alpaca_<as_of>.json
mkdir -p state/parabolic_short
python3 skills/parabolic-short-trade-planner/scripts/monitor_intraday_trigger.py \
--plans-json "$PHASE2_PLAN" \
--bars-source alpaca \
--state-dir "$(pwd)/state/parabolic_short" \
--output-dir reports/smoke/ \
--verboseExpected during regular session:
monitored_plansis non-empty (one entry per actionable plan in the Phase 2 report).- Each plan with bars has
last_bar_tswithin ~20 min of now (15-min IEX feed delay + 5-min bar close). - Outside session / on holidays: every plan emits
evaluation_status: "no_bars"withstatecarried forward from any prior state file (defaults toarmed). Themonitored_planslist is never empty when input plans exist — this distinguishes "filtered out" from "no bars".
7c. Idempotency spot-check
Run Phase 3 twice in a row with --bars-source fixture and the
same --now-et; the output JSON files MUST be byte-identical
after stripping wall-clock fields (evaluated_at,
last_evaluated_at, written_at). If they diverge, the FSM is
reading prior_state — a regression against the v0.5
idempotency contract. The
tests/test_one_shot_idempotency.py test enforces the same
property in CI.
7d. Phase 3 PASS criteria (one-tier)
PASS if all of:
- 7a returns exit 0; the AAPL ORL plan reaches
state="triggered"with the expectedentry_actual/stop_actualand a positiveshares_actual. - 7b returns exit 0; either monitored_plans contain bars (regular
session) OR every plan has
evaluation_status: "no_bars"(closed / pre-9:30). - 7c shows byte-identical output across two consecutive runs.
8. Success criteria — three-tier (with Phase 3)
A FULL PASS requires both tiers green; report a PARTIAL PASS when one tier passes and the other is incomplete (e.g. "rejection tier passed; end-to-end incomplete due to live Alpaca outage").
Tier 1: rejection smoke (Section 3)
PASS if all of:
check_live_apis.pyexits 0 on the four required gates.- Phase 1 produces a v1.0 schema JSON. Candidates may be empty.
--verbosedocuments at least one rejection reason for at least one ticker.- (If candidates non-empty) Phase 2 with
--broker nonereturns every plan asplan_status: watch_onlywithborrow_inventory_unavailableinblocking_manual_reasons.
Zero Phase 1 candidates is NOT a fail for Tier 1 — the diverse CSV is rejection-biased by design.
Tier 2: minimum-one-plan smoke (Sections 4–6)
PASS if all of:
- Phase 1 produces ≥1 candidate against the relaxed CSV.
- Phase 2 with
--broker alpacaproduces ≥1 plan whose schema validates againsttests/test_schema_contract.py. - ≥1 plan has
plan_status: actionable— if none, document in the smoke report ("all relaxed-CSV candidates HTB today; not a code bug"). - Phase 2 with
--broker noneflips every plan toplan_status: watch_only. - Day-2 carryover step (Section 6) flips
ssr_carryover_from_prior_daytotrue.
This tier is incomplete, not pass if Phase 1 returns zero candidates against the relaxed CSV — investigate (rejection logic buggy, CSV stale, or FMP transient error).
9. Pitfalls
- FMP
quote.previousCloseaftermarket drift — the screener useshistorical-price-eod/fullforprior_close. Never readquote.previousClose; it returns aftermarket-adjusted values and breaks SSR Rule 201 math. Verify by picking a ticker with a > 5% post-4 PM move and confirming Phase 1 stored the regular-session number inkey_levels.prior_close. - Alpaca paper symbol absence — paper accounts have a smaller
asset universe than live. After the 404 fix (in this PR),
missing tickers map to
error: asset_not_foundand Phase 2 continues with that symbol markedborrow_inventory_unavailable.--verboseshows the per-ticker map. - SSR state file path drift —
--ssr-state-dirdefaults tostate/parabolic_short/relative to cwd. Different cwds produce orphan state files and silently break carryover. Always pass"$(pwd)/state/parabolic_short"for absolute clarity. The repo's.gitignoreexcludesstate/so production state cannot accidentally be committed; note that already-tracked files would need a separategit rm --cached, andgit add -fcan still force-add. Treat the ignore line as a default, not a hard guarantee. - Universe selection bias is split across two CSVs —
smoke_universe_diverse.csvis intentionally rejection-biased to exercise the invalidation path;smoke_universe_relaxed.csvis intentionally pass-biased to exercise Phase 2 wiring. Invalid tickers are not in either CSV — that path is covered bycheck_live_apis.pystep 5 + thetests/test_broker_inventory.py404 test. Cherry-picking only parabolic-today names is forbidden because it masks rejection-path bugs. - Both smoke CSVs age by construction — current high-fliers, ETB liquidity, and mega-cap composition all rotate. Re-curate both CSVs quarterly. For the diverse CSV, Tier 1 still passes on staleness (zero candidates is OK). For the relaxed CSV, staleness drops Tier 2 to "incomplete" (zero candidates) — the known failure mode for stale CSV maintenance, distinct from a real code bug.
- Lookback < 21 bars silent skip —
screen_one_candidatereturnsNonewhen fewer than 21 bars are available, logged only at DEBUG. Recently-IPO'd or post-split tickers can produce emptycandidates[]and look like an API failure. Always use--verbosefor the first smoke run; empty output is not the same as a broken pipeline.
10. Troubleshooting matrix
| Symptom | Likely cause | Action |
|---|---|---|
check_live_apis.py FAIL on fmp.historical_price_eod_full HTTP 401 |
FMP_API_KEY invalid / expired |
Re-issue key on https://site.financialmodelingprep.com/developer/docs |
check_live_apis.py FAIL on fmp.historical_price_eod_full shape mismatch |
FMP changed the EOD response shape (Issue #64 regression) | Read the body excerpt, then re-check fmp_client._normalize_eod_flat_list |
check_live_apis.py WARN on fmp.sp500_constituent |
FMP tier doesn't include the constituent endpoint | Ignore — not a gate. Phase 1 only uses sp500 when --universe sp500; finviz-csv path doesn't need it. |
check_live_apis.py FAIL on alpaca.assets_aapl HTTP 401/403 |
ALPACA_API_KEY / ALPACA_SECRET_KEY mismatch with ALPACA_PAPER |
Confirm the key was issued for the same account class (paper vs live) as ALPACA_PAPER |
check_live_apis.py FAIL on alpaca.assets_404_graceful ("raised on 404") |
Adapter regression — raise_for_status() triggered |
Re-apply the 404 → asset_not_found mapping in adapters/alpaca_inventory_adapter.py |
Phase 1 emits empty candidates[] against the diverse CSV |
Expected — diverse CSV is rejection-biased | Confirm --verbose shows rejection reasons; PASS for Tier 1 |
Phase 1 emits empty candidates[] against the relaxed CSV |
(a) Rejection logic buggy, (b) CSV stale, (c) FMP transient error | Re-run with another ticker; check --verbose; re-curate the CSV if needed |
Phase 2 errors on KeyError: 'as_of' from a custom Phase 1 JSON |
Hand-edited Phase 1 JSON missing as_of |
Pass --as-of YYYY-MM-DD explicitly |
| Phase 2 alpaca-step output disappears between steps 5a and 5b | --output-prefix defaulted to the same value in both runs |
Always pass distinct --output-prefix per step |
Day-2 carryover does NOT flip ssr_carryover_from_prior_day |
(a) Wrong cwd between runs (state file in a different state/ dir), (b) --as-of not advanced by exactly +1 calendar day |
Run ls state/parabolic_short/ and confirm the Day-1 state file uses the expected date and ticker |
state-dir permission denied |
The state/ directory is owned by another user (e.g. root from a Docker run) |
sudo chown -R "$USER" state/ |
This runbook is the executable definition of "smoke passed". When in doubt, prefer running the runbook over reasoning about whether the upstream APIs still match the contract.