
Parabolic Short Trade Planner
- 502 installs
- 2.6k repo stars
- Updated August 4, 2026
- tradermonty/claude-trading-skills
parabolic-short-trade-planner is an agent skill that screens US equities for parabolic exhaustion and generates conditional pre-market short plans with intraday 5-minute trigger monitoring for developers building systema
About
parabolic-short-trade-planner is a tradermonty claude-trading-skills workflow with three Python phases and schema_version 1.0 JSON outputs. Phase 1 screen_parabolic.py pulls EOD bars from FMP, applies mode-aware invalidation rules, and scores survivors on five weighted factors (30/25/20/15/10) with A–D grades. Phase 2 generate_pre_market_plan.py filters tradable B+ names, checks Alpaca short inventory and SEC Rule 201 SSR, and renders three trigger plans per candidate (ORL break, first red 5-min, VWAP fail). Phase 3 monitor_intraday_trigger.py walks a one-shot FSM on 5-min Alpaca bars, emitting shares_actual when triggered. The skill never routes orders—developers use it to produce JSON and Markdown plans for manual broker review.
- parabolic-short-trade-planner
Parabolic Short Trade Planner by the numbers
- 502 all-time installs (skills.sh)
- +33 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #815 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tradermonty/claude-trading-skills --skill parabolic-short-trade-plannerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 502 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | August 4, 2026 |
| Repository | tradermonty/claude-trading-skills ↗ |
How do you plan parabolic short trades systematically?
Use parabolic-short-trade-planner for development tasks
Who is it for?
Quantitative traders running Python who want Qullamaggie-style parabolic short watchlists with borrow, SSR, and 5-min trigger gating.
Skip if: Long-side momentum screening, sub-minute scalping, or fully automated order routing without human broker confirmation.
When should I use this skill?
User wants a parabolic short watchlist, pre-market short plan, SSR/borrow audit, or intraday 5-min trigger monitoring for US equities.
What you get
parabolic_short JSON watchlists, parabolic_short_plan JSON with three triggers per ticker, and intraday_monitor JSON with entry/stop/share counts.
- parabolic_short JSON watchlist
- parabolic_short_plan JSON
- parabolic_short_intraday JSON
By the numbers
- Three phases: screen_parabolic.py, generate_pre_market_plan.py, monitor_intraday_trigger.py
- Five-factor scorer with weights 30/25/20/15/10 and A–D grades
- Three entry triggers per candidate; JSON schema_version 1.0 outputs
Files
Overview
Generate Qullamaggie-style Parabolic Short watchlists and conditional pre-market plans for US equities. The skill never sends orders. It emits JSON + Markdown that a human reviews against their broker before entry.
Three phases:
- Phase 1 (`screen_parabolic.py`): pulls EOD bars + company profile
from FMP, applies hard invalidation rules (mode-aware), scores survivors on 5 factors (weights 30/25/20/15/10), and assigns A/B/C/D grades.
- Phase 2 (`generate_pre_market_plan.py`): takes the Phase 1 JSON,
filters by --tradable-min-grade (default B), checks Alpaca short inventory (or ManualBrokerAdapter), evaluates SEC Rule 201 SSR state from the inherited prior-day close, and renders three trigger plans per candidate.
- Phase 3 (`monitor_intraday_trigger.py`): reads the Phase 2 plan,
fetches 5-min bars (Alpaca live or fixture), walks each plan's FSM forward by one step, persists per-plan state, and writes an intraday_monitor JSON with state, entry_actual, stop_actual, and shares_actual (when triggered). One-shot — trader runs it every 1–5 min via watch or cron; replay-deterministic so re-runs are byte-identical.
When to Use
Invoke this skill when the user wants to:
- Build a daily Parabolic Short watchlist from S&P 500 (or a custom CSV).
- Translate a watchlist into pre-market trade plans with explicit
borrow / SSR / state-cap gating.
- Audit a candidate's blocking vs advisory manual-confirmation reasons
before placing an order at Alpaca.
Do NOT invoke for:
- Long-side momentum screening — use vcp-screener or canslim-screener.
- 1-minute / sub-minute intraday signals — Phase 3 evaluates 5-min
bars only.
- Live order routing — this skill is detection-only by design;
Phase 3 emits a triggered state with concrete entry/stop/share count, but the trader fires the order manually.
Workflow
Phase 1 — daily screener
1. Confirm FMP_API_KEY is set (env var or --api-key). 2. Run with the safer-by-default mode:
python3 skills/parabolic-short-trade-planner/scripts/screen_parabolic.py \
--mode safe_largecap --as-of 2026-04-30 --output-dir reports/3. Inspect reports/parabolic_short_<date>.md — the watchlist is grouped by grade (A→D). 4. Promote interesting names to Phase 2.
For small-cap blow-offs, switch to --mode classic_qm (looser market cap and ADV floors, higher 5-day ROC threshold).
For testing without the API, run --dry-run --fixture <path> against a JSON fixture (one is shipped at scripts/tests/fixtures/dry_run_minimal.json).
Phase 2 — pre-market plan generator
1. Optional: set ALPACA_API_KEY / ALPACA_SECRET_KEY for live borrow checks. Without them the planner falls back to ManualBrokerAdapter, which marks every candidate as borrow_inventory_unavailable / plan_status: watch_only. 2. Run:
python3 skills/parabolic-short-trade-planner/scripts/generate_pre_market_plan.py \
--candidates-json reports/parabolic_short_2026-04-30.json \
--account-size 100000 --risk-bps 50 --output-dir reports/3. Output: reports/parabolic_short_plan_<date>.json. Each plan contains three entry plans (5min ORL break, first red 5-min, VWAP fail) with entry_hint / stop_hint formula strings (no baked-in shares — the trader computes shares at trigger time from the shares_formula).
Phase 3 — intraday trigger monitor
1. Confirm ALPACA_API_KEY / ALPACA_SECRET_KEY are set (Phase 3 uses Alpaca market data; data.alpaca.markets works for both paper and live accounts). 2. During US regular session, run one-shot per cadence — typical is every 60 s during the first 30 min, then every 5 min:
python3 skills/parabolic-short-trade-planner/scripts/monitor_intraday_trigger.py \
--plans-json reports/parabolic_short_plan_2026-05-05.json \
--bars-source alpaca \
--state-dir state/parabolic_short/ \
--output-dir reports/Or wrap in watch -n 60 'python3 ...' / cron. 3. Output: reports/parabolic_short_intraday_<date>.json lists every monitored plan with state (armed / triggered / invalidated / FSM-specific), bar-derived transition timestamps, and size_recipe_resolved (concrete shares_actual) when triggered. 4. For testing without the API, use --bars-source fixture --bars-fixture <path> against a JSON fixture (scripts/tests/fixtures/intraday_bars/).
Phase 3 is idempotent: each run replays the full session bars from open up to now_et (or --now-et override), so re-running during the same minute produces the same state. prior_state is used only for diff/notification display; it never advances the FSM.
Reviewing a plan before entry
Read three top-level fields per ticker:
plan_status:actionable(manual gates can be cleared) or
watch_only (hard blockers — borrow unavailable or SSR active).
blocking_manual_reasons: must all be resolved before pulling the
trigger.
advisory_manual_reasons: heads-up only, e.g.
manual_locate_required (always set), warning:too_early_to_short, warning:recent_earnings_catalyst (last earnings within --earnings-catalyst-window-days, default 10 trading days — flag the move as event-driven rather than pure technical blow-off).
Earnings-aware screening
Phase 1 fetches the FMP earnings calendar once per run (single call, not per-symbol) and emits two earnings-aware checks:
--exclude-earnings-within-days(default 2 calendar days, forward) —
hard invalidation when next earnings is within the window. Matches the legacy earnings_blackout_days semantic.
--earnings-catalyst-window-days(default 10 trading days, backward)
— soft warning recent_earnings_catalyst when last earnings is within the window. Routes to Phase 2 as an advisory manual reason without forcing trade_allowed_without_manual: false.
Per-candidate output exposes last_earnings_date, next_earnings_date, trading_days_since_earnings (TRADING days), earnings_within_days (CALENDAR days, forward), earnings_blackout_days (configured threshold), and earnings_in_blackout_window. The legacy earnings_within_2d is kept for backward compatibility.
Top-level dates: as_of is the planning date (Phase 2 contract — never mutate); run_date mirrors it; market_data_as_of is the latest bar date used for technical metrics (differs from as_of on weekend runs).
Output Format
Phase 1 JSON: parabolic_short_<as_of>.json (schema_version 1.0). Phase 2 JSON: parabolic_short_plan_<as_of>.json (schema_version 1.0). Phase 3 JSON: parabolic_short_intraday_<as_of>.json (schema_version 1.0, phase = intraday_monitor). The contract is pinned by tests/test_schema_contract.py plus tests/test_monitor_intraday_smoke.py for Phase 3.
Resources
references/parabolic_short_methodology.md— Qullamaggie's 3-trigger
framework and exhaustion signals.
references/short_invalidation_rules.md— mode-aware exclusion rules.references/short_risk_management.md— Rule 201, ETB vs HTB, locate.references/intraday_trigger_playbook.md— detail on each trigger
type, the FSM transitions Phase 3 implements, and same-bar tie-break semantics.
references/broker_capability_matrix.md— what each broker exposes
through its API for short inventory.
Broker Capability Matrix
What each broker exposes through its API for short inventory, and how it maps onto this skill's BrokerShortInventoryAdapter contract.
| Field | Alpaca (paper + live) | Interactive Brokers | Manual |
|---|---|---|---|
shortable | ✅ (/v2/assets/{symbol}.shortable) | ✅ via TWS API | n/a |
easy_to_borrow | ✅ (easy_to_borrow) | ⚠️ inferred from rate sheet | n/a |
can_open_new_short | shortable AND ETB | locate-dependent | always False (default-deny) |
borrow_fee_apr | 0.0 for ETB; None for HTB | quoted per-symbol | None |
borrow_fee_manual_check_required | True only for HTB | False (rate is quoted) | always True |
manual_locate_required | always True (broker confirms) | False after locate succeeds | always True |
| New short on HTB? | ❌ rejected at submit | ✅ after locate | n/a |
Why the contract sets manual_locate_required to True even for ETB
ETB names can lose ETB status mid-day if borrow demand spikes. Marking the field always True keeps the human in the loop: the trader sees advisory_manual_reasons: [manual_locate_required] on every plan and re-checks at the broker before submitting. The flag is advisory, so it does not gate trade_allowed_without_manual on its own.
How to add another broker
1. Subclass BrokerShortInventoryAdapter in scripts/adapters/<broker>_inventory_adapter.py. 2. Implement get_inventory_status(symbol) -> dict returning the contract dict. 3. Raise BrokerNotConfiguredError if the broker's credentials are missing — generate_pre_market_plan.py falls back to ManualBrokerAdapter when this fires. 4. Add the broker name to the --broker CLI choices in generate_pre_market_plan.py::build_arg_parser. 5. Add an adapter test that mocks the broker's HTTP layer with unittest.mock.patch.
Why no SDK dependency
Alpaca publishes alpaca-py but each broker SDK pulls in additional transitive dependencies and increases the skill bundle size. This skill talks HTTP via requests directly, the same pattern the existing portfolio-manager skill uses for its account checks. Keeps the .skill bundle deployable as-is.
Intraday Trigger Playbook
Status (v0.5 — implemented): this reference describes the
trigger semantics that monitor_intraday_trigger.py evaluates as aone-shot FSM. Phase 2 plans (generate_pre_market_plan.py) emitentry_hint/stop_hintformula strings that map to these
triggers; Phase 3 reads bars (Alpaca live or fixture), walks the
FSM, and writes intraday_monitor JSON with concreteentry_actual/stop_actual/shares_actual.
>
FSM contracts implemented in Phase 3:
- triggered is not terminal — post-trigger bars are stillevaluated for invalidation predicates so a reclaim flips the plan
to invalidated.- invalidated is terminal/absorbing — no further transitions for that plan_id on that session date.- First Red same-bar tie-break: when a single bar both takes out
red_high(would invalidate) AND prints belowred_low(would
trigger), invalidation wins. Short-side conservatism.
- ORL post-trigger invalidation requires close > orl_low AND close > current_vwap (BOTH reclaimed, not just one).- Idempotency: every Phase 3 run replays the full session bars; the
FSM is a pure function of(plan, bars, atr_14).prior_state
is consulted only for display continuity / notification diffs.
- Bar timing (v0.5d): ts_et on each bar is the bar-open instant (matches Alpaca wire). A bar with ts_et = T covers[T, T+5min)and is confirmed atT+5min. The Phase 3
adapters only return confirmed bars (`bar_open + 5min <=
until_et`); the FSM never sees an in-progress current bar.
- ORL anchoring: the Opening Range bar must be the 09:30 ET bar.
When Alpaca skips that interval (no trades / halt at the open),
the ORL evaluator emits evaluation_status="skipped" + skip_reason="opening_range_bar_unavailable" rather thananchoring on a later bar.
5-min Opening Range Low (ORL) break
plan_id template: <TICKER>-<YYYYMMDD>-ORL5
trigger_type: orl_5min_break
condition (ja): 5min ORL を出来高 1.2x 以上で下抜け
entry_hint: 5min_orl_low - 0.05
stop_hint: session_HOD + 0.25 * ATR
structural_targets: dma_10, dma_20Phase 3 evaluator:
1. Wait for the first 5-minute bar to close. Mark its high (ORH) and low (ORL). 2. After 9:35 ET, watch every 5-minute close. If a bar closes below ORL AND its volume ≥ 1.2× the ORL bar's volume, fire the trigger. 3. Stop is session_HOD + stop_buffer_atr * ATR(14). 4. Invalidates if a subsequent 5-minute close prints back above ORL AND VWAP (sign of failed breakdown).
First Red 5-minute candle
plan_id template: <TICKER>-<YYYYMMDD>-FR5
trigger_type: first_red_5min
condition (ja): 寄付後最初の赤 5min の安値割れ
entry_hint: first_red_5min_low - 0.05
stop_hint: first_red_5min_highPhase 3 evaluator:
1. Track every 5-minute bar from 9:30 ET. Mark the first bar where close < open (a red candle). 2. After that bar closes, fire the trigger when a later 5-minute bar prints below the red candle's low. 3. Stop is the red candle's high. 4. Invalidates if any subsequent 5-minute bar takes out the red candle's high before the trigger fires.
VWAP fail
plan_id template: <TICKER>-<YYYYMMDD>-VWF
trigger_type: vwap_fail
condition (ja): First crack 後 VWAP retest で 5min 終値拒否 + lower-high 下抜け
entry_hint: lower_high_low - 0.05
stop_hint: vwap_reclaim_5min_closePhase 3 evaluator (FSM):
State machine has six states: armed → first_crack_seen → vwap_retest_seen → rejection_confirmed → triggered → invalidated
1. armed: market open, watching for first VWAP loss. 2. first_crack_seen: 5-minute close prints below VWAP for the first time, AND price comes from session HOD (filters open-print VWAP noise). 3. vwap_retest_seen: subsequent bar closes back at or above VWAP (the "retest"). 4. rejection_confirmed: next 5-minute bar prints a lower high than the retest bar AND closes back below VWAP. 5. triggered: fire on the break of the rejection bar's low. 6. invalidated: any 5-minute close back above VWAP after triggered OR a clean uptrend line break before triggered.
Common evaluator concerns
- Time zone: all timestamps
America/New_York. - Halt handling: bars during halt are skipped; the FSM resumes
from its last state when trading resumes.
- Bar close vs touch: triggers fire on bar close, not intra-bar.
Reduces wick noise.
- No re-entry: once a plan is
triggeredorinvalidated, the
FSM does not re-arm for the day.
Parabolic Short Methodology
Adapted from Qullamaggie's three "timeless" short setups. The thesis: parabolic moves end in mean-reverting blow-offs, but shorting too early — while the move is still climbing — is the single fastest way to lose money. The setup framework below exists to delay entry until exhaustion is visibly confirmed.
When a stock qualifies as parabolic
Daily-chart preconditions, all of which must be true:
- 5-day return ≥ +30% (
safe_largecap) or ≥ +100% (classic_qm). - Latest close ≥ +25% above the 20-day SMA, AND ≥ 4 ATR-units above it
(volatility-normalized — small-caps with high ATR aren't penalized).
- Three or more consecutive green daily candles AND an acceleration
ratio (3-day mean return / 10-day mean return) > 1.0.
- Latest-bar volume ratio (vs 20-day average) ≥ 1.5×.
- Liquidity floor: 20-day average dollar volume ≥ $20M
(safe_largecap) or $5M (classic_qm).
These aren't a trade trigger — they're a watchlist filter. Entries are intraday on one of the three trigger types below.
The three trigger types
1. 5-min Opening Range Low (ORL) break
The cleanest setup when the open prints a wide first 5-minute bar. Mark its low. If a subsequent 5-minute bar prints below ORL on ≥1.2× the ORL bar's volume, short the break. Stop above session HOD plus 0.25 ATR.
2. First Red 5-minute candle
When the open is straight up — a series of green 5-minute bars extending the parabola — wait for the first red 5-minute candle. Short the break of its low. Stop above its high. This is the safest variant when the open gaps into resistance.
3. VWAP fail
After a "first crack" — price collapsing off session HOD toward VWAP — let it retest VWAP. A 5-minute close back below VWAP plus a lower-high break is the entry. Invalidates instantly on a 5-minute close back above VWAP (the "VWAP reclaim").
Invalidation rules
Hard skips, applied before scoring. See short_invalidation_rules.md for the full list.
Why state caps matter
Parabolic candidates are by definition hitting fresh highs with strong closes and expanding volume — exactly the metrics that, in a trend template, would qualify them as bullish. The skill flags candidates that closed near their session high at a fresh 52-week high as state_cap: still_in_markup so Phase 2 can mark them plan_status: watch_only until intraday weakness shows up.
Short Invalidation Rules
Hard-rejects in invalidation_rules.py. These run before any scoring, so they're cheap and binary. Soft signals (state caps / warnings) live in state_caps.py instead.
Rules (mode-aware)
| Rule | safe_largecap | classic_qm | Source |
|---|---|---|---|
| Earnings within N trading days | ≤ 2 days → reject | ≤ 2 days → reject | FMP earnings calendar |
| Market cap floor | < $2B → reject | < $300M → reject | FMP profile mktCap |
| 20-day average dollar volume | < $20M → reject | < $5M → reject | Computed from EOD bars |
| Latest close | < $5.00 → reject | < $5.00 → reject | EOD close |
| Days since IPO | < 60 trading days → reject | < 60 trading days → reject | FMP profile days_listed_actual |
| User CSV catalyst flag | flagged → reject | flagged → reject | Optional input |
Why earnings within 2 days is hard-rejected
Earnings risk is binary and asymmetric. Even a deeply parabolic chart can gap +30% on a beat-and-raise overnight, blowing through any stop. The screener does not enable a "trade through earnings" override — post-earnings setups belong to a different skill (earnings-trade-analyzer or pead-screener).
Why classic_qm tolerates smaller caps
Qullamaggie's archetypal Parabolic Short targets — small-cap meme runners up 300-1000% in a few weeks — wouldn't pass safe_largecap. Switching to classic_qm lowers the cap floor to $300M and the ADV floor to $5M so those names enter the universe. The trade-off: classic_qm names are far more likely to be borrow_inventory_unavailable on Alpaca and end up plan_status: watch_only.
What's NOT a hard reject
- Recent 52-week high — that's a state cap, not a kill.
- Strong closing print near session high — also a state cap.
- Premarket gap — turns into the "wait for first crack" advisory warning.
The reasoning: this is a Parabolic SHORT planner. Bullish-looking daily patterns are the target. Filtering them out leaves no candidates.
Short Risk Management
SEC Rule 201 (Short Sale Restriction)
Triggered when a security's regular-session intraday price drops 10% or more from the prior day's regular-session close. While active:
- New short sales are restricted to prices ABOVE the national best bid
(the "uptick rule").
- The restriction holds for the rest of the trading day and the
full next trading day.
Implementation note: this skill inherits prior_regular_close from Phase 1's key_levels.prior_close (sourced from FMP's historical-price-eod/full, which is the regular-session 4:00 PM ET close). It does NOT use FMP's quote endpoint previousClose, which can drift to the aftermarket print.
ssr_state_tracker.py persists per-symbol state to state/parabolic_short/ssr_state_<ticker>_<date>.json so today's ssr_triggered_today rolls forward to tomorrow's ssr_carryover_from_prior_day.
Borrow inventory: Alpaca specifics
Alpaca only allows new short opens on Easy-To-Borrow (ETB) names. The adapter encodes this exactly:
can_open_new_short = shortable AND easy_to_borrow
borrow_fee_apr = 0.0 if easy_to_borrow else None
manual_locate_required = True # alwaysA name that is shortable=True but easy_to_borrow=False (HTB) cannot be opened on Alpaca regardless of locate. Phase 2 marks these as borrow_inventory_unavailable (a hard blocker) and renders the plan as plan_status: watch_only.
manual_locate_required is True even on ETB names. The trader still confirms locate at the broker before entry — it's an advisory reason, not blocking, so plans for ETB names stay actionable.
Position sizing
The size_recipe_builder.py outputs:
risk_usd— per-trade risk in USD (account_size × risk_bps/10000).max_position_value_usd— per-symbol position cap (account_size ×
max_position_pct/100), tightened if current_short_exposure is high.
shares_formula— string form of the formula. Phase 3 evaluates it
at trigger fire when actual entry/stop are known.
exposure_cap_applied— True if the per-symbol cap was tightened
because the aggregate short-book budget was already mostly used.
remaining_short_exposure_capacity_usd— how much short-book
headroom is left.
This deliberately excludes a fixed share count. ORL / first-red / VWAP-fail entries only have known prices intraday, so committing to a share count pre-market would be inaccurate.
Daily loss limits
Not enforced in this MVP. The trader is responsible for honoring account-level circuit breakers. A future revision can add a state/ file recording realized P&L and reject new plans when the daily loss limit is hit.
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.pyand
generate_pre_market_plan.py resolve --ssr-state-dir and --output-dir relative 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;candidates
may 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 on screen_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 tradingdays) attaches a soft recent_earnings_catalyst warning to anycandidate 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/ butwith different `--output-prefix` values. The default prefix
(parabolic_short_plan) would have the manual run silentlyoverwrite 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 out watch_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 numeric shares field.
- 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_reasonscontains
borrow_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 with
state: "triggered", entry_actual: 148.45, stop_actual: 150.35, and a size_recipe_resolved block with a positive integer shares_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" with state carried forward from any prior state file (defaults to armed). The monitored_plans list 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 expected entry_actual / stop_actual and a positive shares_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 as plan_status: watch_only with borrow_inventory_unavailable in blocking_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 against tests/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 to `plan_status:
watch_only`.
- Day-2 carryover step (Section 6) flips
ssr_carryover_from_prior_day
to true.
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
1. FMP `quote.previousClose` aftermarket drift — the screener uses historical-price-eod/full for prior_close. Never read quote.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 in key_levels.prior_close. 2. 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_found and Phase 2 continues with that symbol marked borrow_inventory_unavailable. --verbose shows the per-ticker map. 3. SSR state file path drift — --ssr-state-dir defaults to state/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 .gitignore excludes state/ so production state cannot accidentally be committed; note that already-tracked files would need a separate git rm --cached, and git add -f can still force-add. Treat the ignore line as a default, not a hard guarantee. 4. Universe selection bias is split across two CSVs — smoke_universe_diverse.csv is intentionally rejection-biased to exercise the invalidation path; smoke_universe_relaxed.csv is intentionally pass-biased to exercise Phase 2 wiring. Invalid tickers are not in either CSV — that path is covered by check_live_apis.py step 5 + the tests/test_broker_inventory.py 404 test. Cherry-picking only parabolic-today names is forbidden because it masks rejection-path bugs. 5. 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. 6. Lookback < 21 bars silent skip — screen_one_candidate returns None when fewer than 21 bars are available, logged only at DEBUG. Recently-IPO'd or post-split tickers can produce empty candidates[] and look like an API failure. Always use --verbose for 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.
JNJ
PG
KO
PEP
WMT
MCD
VZ
KMB
CL
MO
MMM
GIS
DUK
SO
ED
AAPL
MSFT
NVDA
AMZN
GOOG
META
JPM
XOM
V
UNH
"""FMP ``/api/v3`` → ``/stable`` URL compatibility shim.
FMP retired the legacy ``/api/v3/`` surface on 2025-08-31; API keys issued
after that date receive ``403 "Legacy Endpoint"`` on every ``/api/v3/`` request.
This helper rewrites a legacy v3-style URL (and its params) to the ``/stable``
equivalent. It is applied ONLY at construction points that build hardcoded v3
URLs and are *not* part of an explicit stable→v3 fallback list. Methods that
already iterate a ``_FMP_ENDPOINTS`` stable→v3 table must NOT route through this
shim, or the v3 fallback entry would be rewritten back to stable and the
fallback contract would break.
Note on endpoint naming: ``/stable`` endpoint names are inconsistent. Most
legacy underscore names resolve, so unmapped endpoints fall through to a 1:1
underscore-preserving swap. But a few endpoints (``sp500_constituent`` and
``earning_calendar``) return **404 on the underscore form for all tiers** —
their live ``/stable`` name is hyphenated (verified 2026-06). Those are pinned
to the hyphenated form in ``_PATH_RENAME_NO_SYMBOL`` below. Do not "modernize"
the underscore-preserving fallthrough wholesale, and do not revert the pinned
endpoints back to underscore.
"""
from __future__ import annotations
from datetime import date, timedelta
_STABLE = "https://financialmodelingprep.com/stable"
# v3 path segment (symbol carried in the path) → /stable path (symbol via ?symbol=)
_PATH_WITH_SYMBOL = {
"quote": "/quote",
"profile": "/profile",
"income-statement": "/income-statement",
"balance-sheet-statement": "/balance-sheet-statement",
"cash-flow-statement": "/cash-flow-statement",
"key-metrics": "/key-metrics",
"ratios": "/ratios",
"enterprise-values": "/enterprise-values",
"market-capitalization": "/market-capitalization",
"institutional-holder": "/institutional-ownership/symbol-ownership",
"etf-holder": "/etf-holdings",
"rating": "/rating",
"discounted-cash-flow": "/discounted-cash-flow",
}
# v3 path → /stable path for endpoints that carry NO path symbol and whose
# /stable name differs from the v3 name. Explicit because the underscore
# (v3-style) /stable name 404s for these; the hyphenated name is the live one
# (verified 2026-06: /stable/sp500_constituent and /stable/earning_calendar
# both 404; the hyphenated variants are the live endpoints — 200 with a Premium
# key, lower tiers may 402). These override the underscore-preserving fallthrough.
_PATH_RENAME_NO_SYMBOL = {
"sp500_constituent": "/sp500-constituent",
"earning_calendar": "/earnings-calendar",
}
def v3_to_stable(url: str, params: dict | None = None) -> tuple[str, dict]:
"""Rewrite a legacy FMP v3 URL to its ``/stable`` equivalent.
No-op for URLs that do not contain ``/api/v3/``. Unmapped endpoints fall
back to a 1:1 underscore-preserving path swap; endpoints whose underscore
``/stable`` form 404s are pinned to hyphen via ``_PATH_RENAME_NO_SYMBOL``.
"""
params = {} if params is None else dict(params)
if "/api/v3/" not in url:
return url, params
after = url.split("/api/v3/", 1)[1].rstrip("/")
# historical-price-full has a dividend sub-path and a price variant
if after.startswith("historical-price-full/stock_dividend/"):
params["symbol"] = after[len("historical-price-full/stock_dividend/") :]
return _STABLE + "/dividends", params
if after.startswith("historical-price-full/"):
params["symbol"] = after[len("historical-price-full/") :]
# The stable EOD endpoint ignores ``timeseries``; convert to a from/to
# range (2x calendar days covers N trading days with weekend headroom).
timeseries = params.pop("timeseries", None)
if timeseries:
today = date.today()
params.setdefault("from", (today - timedelta(days=int(timeseries) * 2)).isoformat())
params.setdefault("to", today.isoformat())
return _STABLE + "/historical-price-eod/full", params
# historical/earning_calendar/{symbol} → earnings?symbol=
if after.startswith("historical/earning_calendar/"):
params["symbol"] = after[len("historical/earning_calendar/") :]
return _STABLE + "/earnings", params
# symbol-in-path endpoints → ?symbol=
for v3_path, stable_path in _PATH_WITH_SYMBOL.items():
if after.startswith(v3_path + "/"):
params["symbol"] = after[len(v3_path) + 1 :]
return _STABLE + stable_path, params
if after == v3_path:
return _STABLE + stable_path, params
# Explicit hyphenated renames for symbol-less endpoints whose underscore
# /stable form 404s (must come before the underscore-preserving fallthrough).
if after in _PATH_RENAME_NO_SYMBOL:
return _STABLE + _PATH_RENAME_NO_SYMBOL[after], params
# Best-effort 1:1 swap, preserving the underscore (v3-style) name. Endpoints
# whose underscore /stable form is known to 404 are pinned to hyphen above.
return _STABLE + "/" + after, params
"""Alpaca implementation of the broker short-inventory adapter.
Uses ``requests`` against the public Alpaca Asset endpoint
(``GET /v2/assets/{symbol}``) so this skill stays compatible with the
existing portfolio-manager skill (which already takes the same approach)
and avoids adding the ``alpaca-py`` SDK as a dependency.
Important Alpaca-specific facts that the contract reflects:
- New shorts are only permitted on **ETB (Easy-To-Borrow)** names.
Hard-to-borrow names cannot be opened on Alpaca regardless of locate.
- Alpaca does NOT publish a borrow-fee schedule via API. ETB names are
effectively 0% but anything not ETB is "manual check required".
- Alpaca does not support self-service locate. A non-ETB name simply
cannot be opened, so ``manual_locate_required`` is always True (the
trader must confirm at the broker even for ETB symbols).
"""
from __future__ import annotations
import os
from datetime import datetime, timezone
try:
import requests
except ImportError as e: # pragma: no cover - environment check
raise RuntimeError("alpaca_inventory_adapter requires the `requests` package") from e
from broker_short_inventory_adapter import (
BrokerNotConfiguredError,
BrokerShortInventoryAdapter,
)
PAPER_BASE_URL = "https://paper-api.alpaca.markets"
LIVE_BASE_URL = "https://api.alpaca.markets"
class AlpacaInventoryAdapter(BrokerShortInventoryAdapter):
"""Read-only adapter that calls ``/v2/assets/{symbol}`` on Alpaca."""
def __init__(
self,
api_key: str | None = None,
secret_key: str | None = None,
paper: bool = True,
timeout: float = 10.0,
) -> None:
self.api_key = api_key or os.getenv("ALPACA_API_KEY")
self.secret_key = secret_key or os.getenv("ALPACA_SECRET_KEY")
if not self.api_key or not self.secret_key:
raise BrokerNotConfiguredError(
"ALPACA_API_KEY and ALPACA_SECRET_KEY must be set (env vars or constructor args)."
)
self.paper = paper
self.base_url = PAPER_BASE_URL if paper else LIVE_BASE_URL
self.timeout = timeout
def _headers(self) -> dict[str, str]:
return {
"APCA-API-KEY-ID": self.api_key,
"APCA-API-SECRET-KEY": self.secret_key,
}
def get_inventory_status(self, symbol: str) -> dict:
url = f"{self.base_url}/v2/assets/{symbol}"
response = requests.get(url, headers=self._headers(), timeout=self.timeout)
# Paper accounts have a smaller asset universe than live; missing
# tickers must degrade to a blocking-but-non-fatal status so a
# single unknown symbol cannot abort an entire Phase 2 batch.
if response.status_code == 404:
return {
"shortable": False,
"easy_to_borrow": False,
"can_open_new_short": False,
"borrow_fee_apr": None,
"borrow_fee_manual_check_required": True,
"manual_locate_required": True,
"source": "alpaca_v2_assets",
"checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"error": "asset_not_found",
}
response.raise_for_status()
data = response.json()
shortable = bool(data.get("shortable"))
etb = bool(data.get("easy_to_borrow"))
# Alpaca: new shorts only on ETB names. Treat HTB-but-shortable as
# ``not openable`` so Phase 2 surfaces a blocking reason.
can_open = shortable and etb
return {
"shortable": shortable,
"easy_to_borrow": etb,
"can_open_new_short": can_open,
"borrow_fee_apr": 0.0 if etb else None,
"borrow_fee_manual_check_required": not etb,
"manual_locate_required": True,
"source": "alpaca_v2_assets",
"checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
"""Alpaca implementation of MarketDataAdapter.
Reuses the same auth pattern and 404-graceful contract as
``alpaca_inventory_adapter.py``. The market data host is
``data.alpaca.markets`` for **both** paper and live accounts —
Alpaca's account class only affects the trading API host, not the
market data API.
Free paper accounts get the IEX feed (~15 min delay). The adapter
defaults to ``feed=iex``; pass ``feed='sip'`` if you have a paid
subscription.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime, time, timedelta
try:
import requests
except ImportError as e: # pragma: no cover - environment check
raise RuntimeError("alpaca_market_data_adapter requires the `requests` package") from e
from broker_short_inventory_adapter import BrokerNotConfiguredError
from market_clock import (
ET,
REGULAR_CLOSE_HOUR,
REGULAR_CLOSE_MINUTE,
REGULAR_OPEN_HOUR,
REGULAR_OPEN_MINUTE,
to_utc,
)
from market_data_adapter import MarketDataAdapter
DATA_BASE_URL = "https://data.alpaca.markets"
TIMEFRAME = "5Min"
BAR_DURATION = timedelta(minutes=5)
logger = logging.getLogger("parabolic_short.alpaca_market_data")
class AlpacaMarketDataAdapter(MarketDataAdapter):
def __init__(
self,
api_key: str | None = None,
secret_key: str | None = None,
paper: bool = True,
feed: str = "iex",
timeout: float = 15.0,
) -> None:
self.api_key = api_key or os.getenv("ALPACA_API_KEY")
self.secret_key = secret_key or os.getenv("ALPACA_SECRET_KEY")
if not self.api_key or not self.secret_key:
raise BrokerNotConfiguredError(
"ALPACA_API_KEY and ALPACA_SECRET_KEY must be set (env vars or constructor args)."
)
# Stored for symmetry with the trading adapter; market data
# endpoint is the same for both account classes.
self.paper = paper
self.feed = feed
self.timeout = timeout
self.base_url = DATA_BASE_URL
def _headers(self) -> dict[str, str]:
return {
"APCA-API-KEY-ID": self.api_key,
"APCA-API-SECRET-KEY": self.secret_key,
}
def get_bars_5min(
self,
symbol: str,
*,
session_date: str,
until_et: datetime,
) -> list[dict]:
if until_et.tzinfo is None:
raise ValueError("until_et must be timezone-aware")
# Build the regular-session window in ET, convert to RFC3339 UTC.
date_obj = datetime.strptime(session_date, "%Y-%m-%d").date()
open_et = datetime.combine(
date_obj, time(REGULAR_OPEN_HOUR, REGULAR_OPEN_MINUTE), tzinfo=ET
)
close_et = datetime.combine(
date_obj, time(REGULAR_CLOSE_HOUR, REGULAR_CLOSE_MINUTE), tzinfo=ET
)
# End at the earlier of close_et and until_et — there's no point
# asking Alpaca for bars we'd then discard.
end_et = min(close_et, until_et)
if end_et <= open_et:
return []
params_base = {
"timeframe": TIMEFRAME,
"start": _rfc3339_utc(open_et),
"end": _rfc3339_utc(end_et),
"adjustment": "raw",
"feed": self.feed,
}
url = f"{self.base_url}/v2/stocks/{symbol}/bars"
all_wire_bars: list[dict] = []
page_token: str | None = None
while True:
params = dict(params_base)
if page_token is not None:
params["page_token"] = page_token
response = requests.get(
url, headers=self._headers(), params=params, timeout=self.timeout
)
if response.status_code == 404:
logger.info(
"alpaca.assets_404: %s not in Alpaca asset universe; returning [] bars",
symbol,
)
return []
response.raise_for_status()
payload = response.json()
page_bars = payload.get("bars") or []
all_wire_bars.extend(page_bars)
page_token = payload.get("next_page_token")
if not page_token:
break
return _convert_and_filter(all_wire_bars, session_date=session_date, until_et=until_et)
def _rfc3339_utc(ts_et: datetime) -> str:
"""Convert an ET datetime to ``YYYY-MM-DDTHH:MM:SSZ`` (UTC)."""
utc = to_utc(ts_et)
return utc.strftime("%Y-%m-%dT%H:%M:%SZ")
def _convert_and_filter(
wire_bars: list[dict],
*,
session_date: str,
until_et: datetime,
) -> list[dict]:
"""Normalise Alpaca's wire shape and apply the contract filters.
Alpaca's bar timestamp ``t`` is the **bar-open** instant (start of
the 5-minute interval). A bar with ``t = 09:35:00`` covers
09:35–09:40 and is **not yet confirmed at 09:35** — it confirms at
09:40 when the next bar starts. The contract Phase 3 needs is
"only evaluate confirmed bars", so we filter on ``bar_close =
bar_start + 5 min`` instead of ``bar_start <= until_et``. ``ts_et``
in the output dict is kept as the bar-open time (the convention the
rest of the FSM uses for transition timestamps), with the explicit
documented meaning of "the start of the bar that triggered the
transition" (i.e. the 5-min interval whose close fired the move).
"""
open_et = datetime.combine(
datetime.strptime(session_date, "%Y-%m-%d").date(),
time(REGULAR_OPEN_HOUR, REGULAR_OPEN_MINUTE),
tzinfo=ET,
)
close_et = datetime.combine(
datetime.strptime(session_date, "%Y-%m-%d").date(),
time(REGULAR_CLOSE_HOUR, REGULAR_CLOSE_MINUTE),
tzinfo=ET,
)
out: list[dict] = []
for wire in wire_bars:
ts_utc_str = wire["t"]
# Alpaca returns "...Z" — fromisoformat in 3.11+ handles "Z",
# but be defensive across Python versions.
if ts_utc_str.endswith("Z"):
ts_utc_str = ts_utc_str[:-1] + "+00:00"
ts_utc = datetime.fromisoformat(ts_utc_str)
ts_et = ts_utc.astimezone(ET)
bar_close_et = ts_et + BAR_DURATION
# Regular-session filter: 09:30 ≤ bar_start < 16:00 ET on the
# requested session_date.
if ts_et.date().isoformat() != session_date:
continue
if not (open_et <= ts_et < close_et):
continue
# Confirmation filter: only include bars whose CLOSE
# (bar_start + 5 min) is at or before until_et.
if bar_close_et > until_et:
continue
out.append(
{
"ts_et": ts_et.isoformat(),
"o": float(wire["o"]),
"h": float(wire["h"]),
"l": float(wire["l"]),
"c": float(wire["c"]),
"v": int(wire["v"]),
}
)
return out
"""Fixture-driven MarketDataAdapter for offline testing.
A fixture file is a JSON object mapping ticker symbol → list of bars:
{
"AAPL": [
{"ts_et": "2026-05-05T09:30:00-04:00",
"o": 150.0, "h": 150.4, "l": 149.8, "c": 150.2, "v": 1200000},
...
],
"NVDA": [...]
}
Bars in the fixture should be pre-sorted chronological and use
**bar-open** semantics for ``ts_et`` (matching Alpaca wire). The
adapter only returns *confirmed* bars: a bar with ``ts_et = T``
covers ``[T, T+5min)`` and is confirmed at ``T+5min``, so it is
included only when ``T + 5min <= until_et``.
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta
from pathlib import Path
from market_data_adapter import MarketDataAdapter
BAR_DURATION = timedelta(minutes=5)
class FixtureBarsAdapter(MarketDataAdapter):
def __init__(self, fixture_path: str | Path) -> None:
self._path = Path(fixture_path)
self._cache: dict[str, list[dict]] | None = None
def _load(self) -> dict[str, list[dict]]:
if self._cache is None:
with self._path.open(encoding="utf-8") as fh:
self._cache = json.load(fh)
if not isinstance(self._cache, dict):
raise ValueError(
f"Fixture {self._path} must be a JSON object "
f"{{ticker: [bars]}}, got {type(self._cache).__name__}"
)
return self._cache
def get_bars_5min(
self,
symbol: str,
*,
session_date: str,
until_et: datetime,
) -> list[dict]:
if until_et.tzinfo is None:
raise ValueError("until_et must be timezone-aware")
all_bars = self._load().get(symbol, [])
if not all_bars:
return []
out: list[dict] = []
for bar in all_bars:
ts = datetime.fromisoformat(bar["ts_et"])
if ts.tzinfo is None:
# Defensive: a fixture writer might forget the offset.
raise ValueError(f"Fixture bar for {symbol} is missing tz: {bar['ts_et']}")
# Filter to the requested session date (ET wall-clock) and to
# bars that have CLOSED at or before until_et (bar_open + 5
# min — Alpaca-compatible bar-open semantics, see module
# docstring).
if ts.date().isoformat() != session_date:
continue
bar_close = ts + BAR_DURATION
if bar_close > until_et:
continue
out.append(bar)
return out
"""Abstract base for intraday bar fetchers used by Phase 3.
Two concrete implementations live alongside this file:
- ``FixtureBarsAdapter`` reads a JSON file mapping ``ticker -> [bars]``
and is used in every unit test.
- ``AlpacaMarketDataAdapter`` calls
``data.alpaca.markets/v2/stocks/{symbol}/bars`` and is the live data
source.
The Phase 3 FSM evaluators are pure functions — they never touch an
adapter directly. The CLI (`monitor_intraday_trigger.py`) instantiates
the right adapter based on ``--bars-source`` and passes the bar list
into ``intraday_state_machine.step_one_plan``.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
class MarketDataAdapter(ABC):
"""Contract every Phase 3 bar fetcher must satisfy."""
@abstractmethod
def get_bars_5min(
self,
symbol: str,
*,
session_date: str,
until_et: datetime,
) -> list[dict]:
"""Return 5-min bars for the regular session of ``session_date``.
Returned bars MUST be:
- chronological (oldest first),
- all from the regular cash session (09:30–16:00 ET) of
``session_date``,
- filtered to **confirmed** bars only: a bar with ``ts_et =
T`` covers ``[T, T+5min)`` and is confirmed at ``T+5min``,
so the bar is included iff ``T + 5min <= until_et``.
Implementations MUST NOT include unconfirmed (still-open)
bars even if Alpaca returns them with the current minute's
timestamp,
- in this dict shape exactly:
{"ts_et": <ISO 8601 with America/New_York tz; bar-open>,
"o": float, "h": float, "l": float, "c": float,
"v": int}
Implementations MUST return ``[]`` (not raise) when:
- the symbol is not in the universe (e.g. delisted, paper
account doesn't have it),
- the session hasn't opened yet,
- it's a weekend / market holiday.
``session_date`` is the ET wall-clock date (``YYYY-MM-DD``),
NOT a UTC date. Use ``market_clock.session_date_for(now_et)``
to compute it.
"""
raise NotImplementedError
"""Bar order normalization — the single boundary between raw FMP output
and calculator input.
FMP's ``historical-price-eod/full`` returns rows in most-recent-first order.
Calculators in this skill expect chronological (oldest-first) input. Call
:func:`normalize_bars` once at the screen entry point so each calculator can
assume a consistent contract.
Other responsibilities handled here (kept on the boundary, not inside the
HTTP client):
- De-duplicate rows that share the same ``date``. The latest occurrence wins
(a re-fetch arriving after a partial bar is preferred over the partial).
- Optionally warn when consecutive calendar days are missing. This is not an
error (weekends/holidays are normal) — it's just a hook for callers that
want to surface unusual gaps.
"""
from __future__ import annotations
import warnings
from datetime import date
from typing import Literal
OutputOrder = Literal["chronological", "recent_first"]
def normalize_bars(
bars: list[dict],
output_order: OutputOrder = "chronological",
*,
warn_on_gaps: bool = False,
) -> list[dict]:
"""Return ``bars`` sorted in ``output_order`` with duplicates removed.
Args:
bars: Iterable of dicts that each carry a ``date`` field formatted
as ``YYYY-MM-DD`` (the shape FMP returns).
output_order: ``"chronological"`` (oldest first) or ``"recent_first"``
(newest first). The argument names the *output*, not the input,
to remove ambiguity at call sites.
warn_on_gaps: When ``True``, emit a :class:`UserWarning` for every
calendar-day gap larger than three days (ignores typical
weekends).
Returns:
A new list — the input is not mutated.
Raises:
ValueError: If ``output_order`` is not one of the supported values.
"""
if output_order not in ("chronological", "recent_first"):
raise ValueError(
f"output_order must be 'chronological' or 'recent_first', got {output_order!r}"
)
by_date: dict[str, dict] = {}
for row in bars:
d = row.get("date")
if not d:
continue
# Last occurrence wins so a fresh re-fetch beats a stale partial bar.
by_date[d] = row
chronological = sorted(by_date.values(), key=lambda r: r["date"])
if warn_on_gaps and len(chronological) > 1:
for prev, curr in zip(chronological, chronological[1:]):
try:
d_prev = date.fromisoformat(prev["date"])
d_curr = date.fromisoformat(curr["date"])
except (TypeError, ValueError):
continue
gap = (d_curr - d_prev).days
if gap > 3:
warnings.warn(
f"bar_normalizer: gap of {gap} calendar days between "
f"{prev['date']} and {curr['date']}",
stacklevel=2,
)
if output_order == "chronological":
return chronological
return list(reversed(chronological))
"""Broker-agnostic short-inventory interface.
Phase 2 needs four answers from the broker before it can render a trade
plan: can we open a new short, what is the borrow fee, do we know the
fee, and is a manual locate needed. Different brokers expose this with
very different APIs (Alpaca: shortable / easy_to_borrow flags;
Interactive Brokers: locate API + borrow rate sheet), so the screener
talks to a thin adapter layer instead of a specific broker SDK.
Contract:
{
"shortable": bool,
"easy_to_borrow": bool,
"can_open_new_short": bool, # alias for shortable AND ETB
"borrow_fee_apr": float | None, # 0.0 for ETB on Alpaca; None
# if the broker can't quote
"borrow_fee_manual_check_required": bool,
"manual_locate_required": bool, # always True for Alpaca short
"source": str, # e.g. "alpaca_v2_assets"
"checked_at": str, # ISO 8601 UTC
}
Adapters MUST raise ``BrokerNotConfiguredError`` if their credentials are
missing — the CLI uses that to flip to ``--broker none`` (manual checklist)
instead of crashing.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class BrokerNotConfiguredError(RuntimeError):
"""Raised when a broker adapter is invoked without required credentials."""
class BrokerShortInventoryAdapter(ABC):
"""Interface every broker adapter must implement."""
@abstractmethod
def get_inventory_status(self, symbol: str) -> dict:
"""Return the contract dict described in the module docstring."""
raise NotImplementedError
def can_open_new_short(self, symbol: str) -> bool:
"""Convenience: True iff ``get_inventory_status`` says yes."""
return bool(self.get_inventory_status(symbol).get("can_open_new_short"))
class ManualBrokerAdapter(BrokerShortInventoryAdapter):
"""Sentinel adapter used when ``--broker none`` is passed.
Returns a dict that flags every gate as ``manual_check_required`` so
the Phase 2 plan output explicitly tells the trader to verify locate
and shortability at the broker before entering.
"""
def get_inventory_status(self, symbol: str) -> dict:
from datetime import datetime, timezone
return {
"shortable": None,
"easy_to_borrow": None,
"can_open_new_short": False, # default-deny: tell Phase 2 to block
"borrow_fee_apr": None,
"borrow_fee_manual_check_required": True,
"manual_locate_required": True,
"source": "manual",
"checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
"""Short-term acceleration metrics for parabolic detection.
Captures three ideas Qullamaggie flags as "exhaustion":
- ``return_n_days_pct``: total return over a recent N-day window. Inputs
use chronological order so the latest close is at the end.
- ``consecutive_green_days``: how many consecutive bars closed up over
the most recent run. A streak of 3-5+ is the canonical Parabolic Short
setup.
- ``acceleration_ratio``: average daily return over the last 3 sessions
divided by the average daily return over the last 10 sessions. Values
> 1.5 mean the move is *accelerating* — the curve is bending up, not
just trending.
"""
from __future__ import annotations
def return_pct(closes: list[float], days: int) -> float | None:
"""Total return over the trailing ``days`` bars, in percent.
Returns ``None`` if there are not enough closes (we need at least
``days + 1`` to compute a return). Returns ``None`` if the reference
close is non-positive.
"""
if days <= 0:
raise ValueError(f"days must be positive, got {days}")
if len(closes) < days + 1:
return None
base = closes[-days - 1]
if base <= 0:
return None
latest = closes[-1]
return (latest - base) / base * 100.0
def consecutive_green_days(opens: list[float], closes: list[float]) -> int:
"""Count consecutive bars with ``close > open`` from the latest backwards."""
if len(opens) != len(closes) or not closes:
return 0
streak = 0
for o, c in zip(reversed(opens), reversed(closes)):
if c > o:
streak += 1
else:
break
return streak
def _avg_daily_return(closes: list[float], window: int) -> float | None:
if len(closes) < window + 1:
return None
base = closes[-window - 1]
if base <= 0:
return None
# Geometric-ish: simple average of daily simple returns (sufficient for ranking)
rets = []
for i in range(-window, 0):
prev = closes[i - 1]
curr = closes[i]
if prev <= 0:
continue
rets.append((curr - prev) / prev)
if not rets:
return None
return sum(rets) / len(rets)
def acceleration_ratio(
closes: list[float], short_window: int = 3, long_window: int = 10
) -> float | None:
"""Ratio of recent average daily return over a longer-window average.
Returns ``None`` if either window cannot be computed or the long-window
average is non-positive (no acceleration if the move was flat / down).
"""
short_avg = _avg_daily_return(closes, short_window)
long_avg = _avg_daily_return(closes, long_window)
if short_avg is None or long_avg is None or long_avg <= 0:
return None
return short_avg / long_avg
def calculate_acceleration(
opens: list[float],
closes: list[float],
) -> dict:
"""Aggregate acceleration metrics into one dict for downstream scoring."""
return {
"return_3d_pct": return_pct(closes, 3),
"return_5d_pct": return_pct(closes, 5),
"return_10d_pct": return_pct(closes, 10),
"return_15d_pct": return_pct(closes, 15),
"consecutive_green_days": consecutive_green_days(opens, closes),
"acceleration_ratio_3_over_10": acceleration_ratio(closes, 3, 10),
}
"""ATR (Average True Range) and True Range helpers.
Adapted from skills/vcp-screener/scripts/calculators/vcp_pattern_calculator.py
(``_calculate_atr`` / ``_true_range``). Inputs are always in chronological
order (oldest first) — the bar_normalizer enforces this contract upstream.
"""
from __future__ import annotations
def true_range(high: float, low: float, prev_close: float) -> float:
"""Single-bar True Range.
TR = max(high - low, |high - prev_close|, |low - prev_close|).
"""
return max(high - low, abs(high - prev_close), abs(low - prev_close))
def calculate_atr(
highs: list[float],
lows: list[float],
closes: list[float],
period: int = 14,
) -> float:
"""Average True Range over the most recent ``period`` bars.
Args:
highs: High prices in chronological order.
lows: Low prices in chronological order.
closes: Close prices in chronological order.
period: ATR window in bars (default 14).
Returns:
ATR value, or ``0.0`` if there are fewer than ``period + 1`` bars.
"""
n = len(highs)
if n < period + 1 or len(lows) != n or len(closes) != n:
return 0.0
true_ranges = [true_range(highs[i], lows[i], closes[i - 1]) for i in range(1, n)]
if len(true_ranges) < period:
return 0.0
return sum(true_ranges[-period:]) / period
"""Average daily dollar volume (ADV) and a log-scale liquidity score.
ADV is the universe-side hard filter — anything below the floor is rejected
in ``invalidation_rules`` before scoring runs. The score returned here is
only used for the 10-point Liquidity factor inside the composite score.
"""
from __future__ import annotations
from math_helpers import log10_scale
def adv_dollars(closes: list[float], volumes: list[float], period: int = 20) -> float | None:
"""Average ``close * volume`` over the trailing ``period`` bars."""
if period <= 0:
raise ValueError(f"period must be positive, got {period}")
if len(closes) < period or len(volumes) < period or len(closes) != len(volumes):
return None
pairs = list(zip(closes[-period:], volumes[-period:]))
total = sum(c * v for c, v in pairs)
return total / period
def latest_volume_ratio(volumes: list[float], period: int = 20) -> float | None:
"""Latest bar volume divided by the trailing-period average volume."""
if len(volumes) < period + 1:
return None
avg = sum(volumes[-period - 1 : -1]) / period
if avg <= 0:
return None
return volumes[-1] / avg
def calculate_liquidity(
closes: list[float],
volumes: list[float],
period: int = 20,
score_lo_log10: float = 7.0,
score_hi_log10: float = 8.5,
) -> dict:
"""Aggregate liquidity metrics + log-scale score.
Score endpoints default to ``$10M ADV → 0`` and ``$316M ADV → 10``.
"""
adv = adv_dollars(closes, volumes, period=period)
score = log10_scale(adv, score_lo_log10, score_hi_log10) if adv is not None else 0.0
return {
"adv_20d_usd": adv,
"volume_ratio_20d": latest_volume_ratio(volumes, period=period),
"liquidity_score_0_to_10": score,
}
"""Distance of the latest close from key SMAs.
Returns three measurements that together describe how stretched a parabolic
candidate has become from its trend:
- ``ext_*_pct``: percentage distance from the SMA, signed (positive when
price is above the MA, negative below).
- ``ext_20dma_atr``: same distance for the 20-DMA expressed in ATR(14)
units. Volatility-adjusted comparison across symbols.
All inputs must be in chronological order (oldest first).
"""
from __future__ import annotations
from atr_calculator import calculate_atr
from math_helpers import sma
def calculate_ma_extension(
closes: list[float],
highs: list[float | None] = None,
lows: list[float | None] = None,
atr_period: int = 14,
) -> dict:
"""Compute MA-extension metrics for the most recent close.
Args:
closes: Closing prices (chronological).
highs: Optional high prices for ATR-unit calculation. If not
provided, ``ext_20dma_atr`` is returned as ``None``.
lows: Optional low prices, see ``highs``.
atr_period: ATR window for the volatility-adjusted distance.
Returns:
Dict with keys ``ext_10dma_pct``, ``ext_20dma_pct``,
``ext_50dma_pct``, ``ext_20dma_atr``, ``close``, ``dma_10``,
``dma_20``, ``dma_50``, ``atr_14``. Any value that cannot be
computed (insufficient history) is ``None``.
"""
if not closes:
return {
"close": None,
"dma_10": None,
"dma_20": None,
"dma_50": None,
"ext_10dma_pct": None,
"ext_20dma_pct": None,
"ext_50dma_pct": None,
"atr_14": None,
"ext_20dma_atr": None,
}
close = closes[-1]
dma_10 = sma(closes, 10)
dma_20 = sma(closes, 20)
dma_50 = sma(closes, 50)
def _pct(ma: float | None) -> float | None:
if ma is None or ma == 0:
return None
return (close - ma) / ma * 100.0
atr_14: float | None = None
ext_20dma_atr: float | None = None
if highs is not None and lows is not None and dma_20 is not None:
atr_value = calculate_atr(highs, lows, closes, period=atr_period)
if atr_value > 0:
atr_14 = atr_value
ext_20dma_atr = (close - dma_20) / atr_value
return {
"close": close,
"dma_10": dma_10,
"dma_20": dma_20,
"dma_50": dma_50,
"ext_10dma_pct": _pct(dma_10),
"ext_20dma_pct": _pct(dma_20),
"ext_50dma_pct": _pct(dma_50),
"atr_14": atr_14,
"ext_20dma_atr": ext_20dma_atr,
}
"""Aggregate the 5 component scores (each 0-100) used by the composite scorer.
Each component takes the raw metrics computed in Day 1 and maps them to a
single 0-100 sub-score. The composite scorer in ``scorer.py`` then applies
weights (30/25/20/15/10) and converts the result to a letter grade.
These mappings are intentionally simple piecewise-linear functions so they
are predictable and easy to test. Tuning is left to ``screen_parabolic.py``
CLI knobs (e.g. ``--min-roc-5d``).
"""
from __future__ import annotations
from acceleration_calculator import calculate_acceleration
from liquidity_metrics_calculator import calculate_liquidity
from ma_extension_calculator import calculate_ma_extension
from range_expansion_calculator import calculate_range_expansion
def _clamp(value: float, lo: float = 0.0, hi: float = 100.0) -> float:
return max(lo, min(hi, value))
def _linear(x: float | None, x_lo: float, x_hi: float) -> float:
"""Map x linearly from [x_lo, x_hi] -> [0, 100], clamped at the edges."""
if x is None:
return 0.0
if x <= x_lo:
return 0.0
if x >= x_hi:
return 100.0
return (x - x_lo) / (x_hi - x_lo) * 100.0
def score_ma_extension(metrics: dict) -> float:
"""Use the larger of (20DMA % distance, 20DMA ATR distance)."""
pct = metrics.get("ext_20dma_pct") or 0.0
atr_units = metrics.get("ext_20dma_atr") or 0.0
pct_score = _linear(pct, 20.0, 120.0)
atr_score = _linear(atr_units, 3.0, 10.0)
# Combine: 50/50 — both signals matter. ATR-units matters for vol-adjustment;
# raw % matters for absolute climax.
return _clamp(0.5 * pct_score + 0.5 * atr_score)
def score_acceleration(metrics: dict) -> float:
r5 = metrics.get("return_5d_pct") or 0.0
r3 = metrics.get("return_3d_pct") or 0.0
streak = metrics.get("consecutive_green_days") or 0
accel = metrics.get("acceleration_ratio_3_over_10") or 0.0
return_score = _linear(r5, 20.0, 100.0)
streak_score = _linear(streak, 2, 5)
accel_score = _linear(accel, 1.0, 2.0)
short_burst_score = _linear(r3, 10.0, 50.0)
return _clamp(
0.4 * return_score + 0.2 * streak_score + 0.2 * accel_score + 0.2 * short_burst_score
)
def score_volume_climax(volume_ratio: float | None) -> float:
return _linear(volume_ratio, 1.5, 5.0)
def score_range_expansion(expansion_ratio: float | None) -> float:
return _linear(expansion_ratio, 1.2, 3.0)
def score_liquidity(liquidity_score_0_to_10: float) -> float:
"""Map the 0-10 ADV log-scale score onto 0-100."""
return _clamp(liquidity_score_0_to_10 * 10.0)
def calculate_component_scores(
closes: list[float],
opens: list[float],
highs: list[float],
lows: list[float],
volumes: list[float],
) -> dict:
"""Compute the 5 component sub-scores (each 0-100) plus raw metrics.
Used by ``scorer.py`` and exposed in the screener output for transparency.
"""
ma_metrics = calculate_ma_extension(closes=closes, highs=highs, lows=lows)
accel_metrics = calculate_acceleration(opens=opens, closes=closes)
range_metrics = calculate_range_expansion(highs=highs, lows=lows, closes=closes)
liq_metrics = calculate_liquidity(closes=closes, volumes=volumes)
return {
"components": {
"ma_extension": score_ma_extension(ma_metrics),
"acceleration": score_acceleration(accel_metrics),
"volume_climax": score_volume_climax(liq_metrics.get("volume_ratio_20d")),
"range_expansion": score_range_expansion(range_metrics.get("expansion_ratio")),
"liquidity": score_liquidity(liq_metrics.get("liquidity_score_0_to_10", 0.0)),
},
"raw_metrics": {
**{k: v for k, v in ma_metrics.items()},
**{k: v for k, v in accel_metrics.items()},
**{k: v for k, v in range_metrics.items()},
**{k: v for k, v in liq_metrics.items()},
},
}
"""Range-expansion metric: latest 5-day average True Range divided by the
prior 20-day ATR.
A value > 2.0 means the current move's daily range has roughly doubled
versus the prior month's volatility — the canonical "blow-off" signature
for parabolic exhaustion.
"""
from __future__ import annotations
from atr_calculator import true_range
def calculate_range_expansion(
highs: list[float],
lows: list[float],
closes: list[float],
short_window: int = 5,
long_window: int = 20,
) -> dict:
"""Latest short-window ATR / prior long-window ATR.
The two windows are non-overlapping by construction:
- ``recent_avg_tr`` averages the last ``short_window`` true ranges
- ``baseline_avg_tr`` averages the ``long_window`` true ranges that
precede that recent block.
Returns dict with ``recent_avg_tr``, ``baseline_avg_tr``, and
``expansion_ratio``. Any value that cannot be computed is ``None``.
"""
n = len(highs)
if n < short_window + long_window + 1 or len(lows) != n or len(closes) != n:
return {
"recent_avg_tr": None,
"baseline_avg_tr": None,
"expansion_ratio": None,
}
trs = [true_range(highs[i], lows[i], closes[i - 1]) for i in range(1, n)]
recent = trs[-short_window:]
baseline = trs[-(short_window + long_window) : -short_window]
recent_avg = sum(recent) / short_window
baseline_avg = sum(baseline) / long_window
expansion_ratio: float | None = None
if baseline_avg > 0:
expansion_ratio = recent_avg / baseline_avg
return {
"recent_avg_tr": recent_avg,
"baseline_avg_tr": baseline_avg,
"expansion_ratio": expansion_ratio,
}
#!/usr/bin/env python3
"""Live API smoke check for parabolic-short-trade-planner.
Five logical checks (4 required gates + 1 optional warning) that prove
both the happy paths and the 404 graceful-handling path that's
otherwise unreachable from Phase 1 output:
1. FMP historical-price-eod/full?symbol=AAPL&from=&to= (Issue #64
flat-list shape; the contract Phase 1 exercises)
2. FMP profile/AAPL (mktCap)
3. FMP sp500_constituent (optional —
entitlement varies by tier; print a warning, do not fail)
4. Alpaca /v2/assets/AAPL (shortable +
easy_to_borrow keys present)
5. Alpaca /v2/assets/XXXXXFAKE (negative
path: 404 → AlpacaInventoryAdapter must return asset_not_found
dict without raising)
Note: check #5 issues TWO HTTP requests against the same Alpaca
endpoint (one raw probe to confirm the 404 itself, then a second one
through `AlpacaInventoryAdapter.get_inventory_status` to confirm the
adapter handles that response gracefully). So while there are 5
logical checks, the script makes 6 HTTP calls per run (3 FMP +
3 Alpaca). The cost remains trivial under both API rate limits.
Returns 0 on all four required gates passing. Logs status code +
first 200 chars of the error body on failures.
"""
from __future__ import annotations
import argparse
import os
import sys
from datetime import date, timedelta
from pathlib import Path
import requests
# Ensure the skill's adapters/ is importable for the Alpaca 404 check.
SCRIPTS_DIR = Path(__file__).resolve().parent
ADAPTERS_DIR = SCRIPTS_DIR / "adapters"
for _p in (str(ADAPTERS_DIR), str(SCRIPTS_DIR)):
if _p not in sys.path:
sys.path.insert(0, _p)
FMP_STABLE = "https://financialmodelingprep.com/stable"
FMP_STABLE_HIST = "https://financialmodelingprep.com/stable/historical-price-eod/full"
FMP_V3 = "https://financialmodelingprep.com/api/v3"
ALPACA_PAPER = "https://paper-api.alpaca.markets"
ALPACA_LIVE = "https://api.alpaca.markets"
REQUIRED_HISTORICAL_KEYS = {"date", "open", "high", "low", "close", "volume"}
def _truncate(body: str, n: int = 200) -> str:
body = body.strip()
return body if len(body) <= n else body[:n] + "..."
def _print_pass(name: str, detail: str) -> None:
print(f"PASS {name} — {detail}")
def _print_warn(name: str, detail: str) -> None:
print(f"WARN {name} — {detail}")
def _print_fail(name: str, detail: str) -> None:
print(f"FAIL {name} — {detail}")
def _print_skip(name: str, detail: str) -> None:
print(f"SKIP {name} — {detail}")
def check_fmp_historical(api_key: str) -> bool:
"""Gate 1 — verify Issue #64 flat-list shape."""
name = "fmp.historical_price_eod_full"
today = date.today()
params = {
"symbol": "AAPL",
"from": (today - timedelta(days=10)).isoformat(),
"to": today.isoformat(),
"apikey": api_key,
}
try:
r = requests.get(FMP_STABLE_HIST, params=params, timeout=15)
except requests.RequestException as e:
_print_fail(name, f"network error: {e}")
return False
if r.status_code != 200:
_print_fail(name, f"HTTP {r.status_code} — {_truncate(r.text)}")
return False
try:
data = r.json()
except ValueError:
_print_fail(name, f"non-JSON body: {_truncate(r.text)}")
return False
if not isinstance(data, list) or not data:
_print_fail(name, f"expected non-empty list[dict], got {type(data).__name__}")
return False
first = data[0]
if not isinstance(first, dict):
_print_fail(name, f"first row is {type(first).__name__}, expected dict")
return False
missing = REQUIRED_HISTORICAL_KEYS - set(first.keys())
if missing:
_print_fail(name, f"row missing keys: {sorted(missing)} (got {sorted(first.keys())})")
return False
_print_pass(name, f"{len(data)} bars; Issue #64 shape verified (date/o/h/l/c/v)")
return True
def check_fmp_profile(api_key: str) -> bool:
"""Gate 2 — verify profile returns a non-empty list with a market-cap field.
Tries /stable/profile?symbol=AAPL first, then the v3 /profile/AAPL fallback
(matching the client). /stable renamed mktCap -> marketCap, so either is OK.
"""
name = "fmp.profile"
attempts = [
(f"{FMP_STABLE}/profile", {"symbol": "AAPL", "apikey": api_key}),
(f"{FMP_V3}/profile/AAPL", {"apikey": api_key}),
]
last_detail = "no response"
for url, params in attempts:
try:
r = requests.get(url, params=params, timeout=15)
except requests.RequestException as e:
last_detail = f"network error: {e}"
continue
if r.status_code != 200:
last_detail = f"HTTP {r.status_code} — {_truncate(r.text)}"
continue
try:
data = r.json()
except ValueError:
last_detail = f"non-JSON body: {_truncate(r.text)}"
continue
if isinstance(data, list) and data:
cap = data[0].get("mktCap", data[0].get("marketCap"))
if cap is not None:
_print_pass(name, f"marketCap={cap}")
return True
last_detail = f"expected list with market cap on first item, got {data!r:.200}"
_print_fail(name, last_detail)
return False
def check_fmp_sp500(api_key: str) -> bool:
"""Optional warning — sp500 constituent entitlement varies by FMP tier.
Tries /stable/sp500-constituent first, then the v3 /sp500_constituent
fallback.
"""
name = "fmp.sp500_constituent"
attempts = [
(f"{FMP_STABLE}/sp500-constituent", {"apikey": api_key}),
(f"{FMP_V3}/sp500_constituent", {"apikey": api_key}),
]
last_detail = "no response"
for url, params in attempts:
try:
r = requests.get(url, params=params, timeout=15)
except requests.RequestException as e:
last_detail = f"network error (optional gate): {e}"
continue
if r.status_code != 200:
last_detail = (
f"HTTP {r.status_code} — likely entitlement (skip on Free); "
f"body: {_truncate(r.text)}"
)
continue
try:
data = r.json()
except ValueError:
last_detail = f"non-JSON body: {_truncate(r.text)}"
continue
if isinstance(data, list) and data:
_print_pass(name, f"{len(data)} constituents")
return True
last_detail = "empty list (skip on Free)"
_print_warn(name, last_detail)
return True
def check_alpaca_assets_aapl(api_key: str, secret: str, paper: bool) -> bool:
"""Gate 3 — verify shortable + easy_to_borrow keys are present."""
name = "alpaca.assets_aapl"
base = ALPACA_PAPER if paper else ALPACA_LIVE
url = f"{base}/v2/assets/AAPL"
headers = {"APCA-API-KEY-ID": api_key, "APCA-API-SECRET-KEY": secret}
try:
r = requests.get(url, headers=headers, timeout=15)
except requests.RequestException as e:
_print_fail(name, f"network error: {e}")
return False
if r.status_code != 200:
_print_fail(name, f"HTTP {r.status_code} — {_truncate(r.text)}")
return False
try:
data = r.json()
except ValueError:
_print_fail(name, f"non-JSON body: {_truncate(r.text)}")
return False
missing = {"shortable", "easy_to_borrow"} - set(data.keys())
if missing:
_print_fail(name, f"missing keys {sorted(missing)} (got {sorted(data.keys())})")
return False
_print_pass(
name,
f"shortable={data['shortable']} easy_to_borrow={data['easy_to_borrow']} (paper={paper})",
)
return True
def check_alpaca_404_graceful(api_key: str, secret: str, paper: bool) -> bool:
"""Gate 4 — confirm AlpacaInventoryAdapter returns asset_not_found
on 404 instead of raising. Phase 1 normally rejects unknown tickers
before they reach the Adapter, so this is the only end-to-end
coverage of the 404 fix."""
name = "alpaca.assets_404_graceful"
base = ALPACA_PAPER if paper else ALPACA_LIVE
url = f"{base}/v2/assets/XXXXXFAKE"
headers = {"APCA-API-KEY-ID": api_key, "APCA-API-SECRET-KEY": secret}
try:
r = requests.get(url, headers=headers, timeout=15)
except requests.RequestException as e:
_print_fail(name, f"network error on raw 404 probe: {e}")
return False
if r.status_code != 404:
_print_fail(
name,
f"expected HTTP 404 from raw probe, got {r.status_code} — {_truncate(r.text)}",
)
return False
# Now confirm the Adapter handles the same response gracefully.
try:
from alpaca_inventory_adapter import AlpacaInventoryAdapter # noqa: WPS433
adapter = AlpacaInventoryAdapter(api_key=api_key, secret_key=secret, paper=paper)
status = adapter.get_inventory_status("XXXXXFAKE")
except Exception as e: # noqa: BLE001 — we explicitly want any raise to fail this gate
_print_fail(name, f"AlpacaInventoryAdapter raised on 404: {type(e).__name__}: {e}")
return False
if status.get("error") != "asset_not_found":
_print_fail(name, f"expected error=asset_not_found, got {status!r:.200}")
return False
if status.get("can_open_new_short") is not False:
_print_fail(
name, f"can_open_new_short must be False, got {status.get('can_open_new_short')}"
)
return False
_print_pass(name, "raw HTTP 404 mapped to asset_not_found dict (no exception)")
return True
def _build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="Live API smoke check for parabolic-short-trade-planner",
)
p.add_argument(
"--fmp-only",
action="store_true",
help=(
"Skip Alpaca gates entirely. Useful for contributors who only have "
"an FMP key configured. Exit 0 when the FMP required gates pass; "
"the Alpaca gates are reported as SKIP and never gate the exit code."
),
)
return p
def main(argv: list[str] | None = None) -> int:
args = _build_arg_parser().parse_args(argv)
print("=" * 70)
print("Parabolic Short — live API smoke check")
if args.fmp_only:
print("Mode: --fmp-only (Alpaca gates will be skipped)")
print("=" * 70)
fmp_key = os.environ.get("FMP_API_KEY")
alpaca_key = os.environ.get("ALPACA_API_KEY")
alpaca_secret = os.environ.get("ALPACA_SECRET_KEY")
alpaca_paper = os.environ.get("ALPACA_PAPER", "true").lower() == "true"
if not fmp_key:
print("FAIL setup — FMP_API_KEY env var is missing")
return 1
# Run the FMP gates first; they don't depend on Alpaca config.
results: dict[str, bool] = {
"fmp_historical": check_fmp_historical(fmp_key),
"fmp_profile": check_fmp_profile(fmp_key),
"fmp_sp500": check_fmp_sp500(fmp_key), # optional, never gates exit code
}
# Decide whether to run the Alpaca gates.
alpaca_skipped = args.fmp_only or not alpaca_key or not alpaca_secret
if alpaca_skipped:
if args.fmp_only:
reason = "explicitly skipped via --fmp-only"
else:
reason = "ALPACA_API_KEY / ALPACA_SECRET_KEY env vars not configured"
_print_skip("alpaca.assets_aapl", reason)
_print_skip("alpaca.assets_404_graceful", reason)
else:
results["alpaca_assets_aapl"] = check_alpaca_assets_aapl(
alpaca_key, alpaca_secret, alpaca_paper
)
results["alpaca_404_graceful"] = check_alpaca_404_graceful(
alpaca_key, alpaca_secret, alpaca_paper
)
# Required-gate set depends on whether Alpaca was skipped. In either case
# the FMP gates are required; the Alpaca gates are only required when
# they were actually attempted.
required = ["fmp_historical", "fmp_profile"]
if not alpaca_skipped:
required.extend(["alpaca_assets_aapl", "alpaca_404_graceful"])
passed = sum(1 for k in required if results.get(k))
print("-" * 70)
if alpaca_skipped:
print(
f"Required gates: {passed}/{len(required)} passed "
f"(FMP only; Alpaca gates skipped, sp500 is optional warning)"
)
if not args.fmp_only:
print(
"Note: pass --fmp-only to acknowledge intent, or set ALPACA_API_KEY / "
"ALPACA_SECRET_KEY to run the full check."
)
else:
print(f"Required gates: {passed}/{len(required)} passed (sp500 is optional warning)")
return 0 if passed == len(required) else 1
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
# GENERATED by scripts/generate_fmp_client.py — do not edit.
# Source of truth: scripts/fmp_client/ (core_template.py.tmpl, registry.py, extensions/).
# Regenerate: python3 scripts/generate_fmp_client.py
"""
FMP API Client for Parabolic Short Trade Planner
Provides rate-limited access to Financial Modeling Prep API endpoints.
Features:
- Rate limiting (0.3s between requests)
- Automatic retry on 429 errors
- Session caching for duplicate requests
- Batch quote support
- S&P 500 constituents fetching
"""
import os
import sys
import time
from datetime import date, timedelta
from typing import Optional
try:
import requests
except ImportError:
print("ERROR: requests library not found. Install with: pip install requests", file=sys.stderr)
sys.exit(1)
try:
from _fmp_compat import v3_to_stable
except ModuleNotFoundError: # loaded by file path (e.g. repo-level contract tests)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _fmp_compat import v3_to_stable
# --- FMP endpoint fallback: stable (new users) -> v3 (legacy users) ---
def _stable_quote_url(base, symbols_str, params):
"""stable/quote?symbol=^GSPC"""
params["symbol"] = symbols_str
return base, params
def _v3_quote_url(base, symbols_str, params):
"""api/v3/quote/^GSPC"""
return f"{base}/{symbols_str}", params
def _stable_hist_url(base, symbols_str, params):
"""stable/historical-price-eod/full?symbol=^GSPC&from=...&to=..."""
params["symbol"] = symbols_str
# New stable EOD endpoint ignores `timeseries`; convert to from/to range
# to bound the payload. Use 2x calendar days to cover N trading days
# (trading-day/calendar-day ratio ~252/365 ~0.69, so *2 leaves headroom).
days = params.pop("timeseries", None)
if days is not None:
today = date.today()
params["from"] = (today - timedelta(days=int(days) * 2)).isoformat()
params["to"] = today.isoformat()
return base, params
def _v3_hist_url(base, symbols_str, params):
"""api/v3/historical-price-full/^GSPC?timeseries=80"""
return f"{base}/{symbols_str}", params
_FMP_ENDPOINTS = {
"quote": [
("https://financialmodelingprep.com/stable/quote", _stable_quote_url),
("https://financialmodelingprep.com/api/v3/quote", _v3_quote_url),
],
"historical": [
("https://financialmodelingprep.com/stable/historical-price-eod/full", _stable_hist_url),
("https://financialmodelingprep.com/api/v3/historical-price-full", _v3_hist_url),
],
}
def _normalize_eod_flat_list(data, symbols_str: str, limit: Optional[int] = None):
"""Convert stable/historical-price-eod/full flat list to v3-compatible dict.
Input : [{"symbol": "SPY", "date": "...", "open": ..., ...}, ...]
Output : {"symbol": "SPY", "historical": [{"date": ..., "open": ..., ...}, ...]}
Returns the input unchanged if not a list (passthrough for v3 dict /
historicalStockList responses). Returns None when no row matches the
requested symbol; the caller will record the failure and try the next
endpoint.
If `limit` is provided (the original `timeseries=N` request), the
`historical` list is truncated to the first `limit` entries. The new
EOD endpoint ignores `timeseries` and returns the full available history,
so the caller's date-range bounding plus this truncation together preserve
the legacy "most-recent N rows" contract. Truncation assumes descending
date order, which the FMP EOD endpoint provides (verified live).
Note: empty list ``[]`` does not reach this normalizer because the caller's
``if not data: continue`` falsy check handles it earlier in
``_request_with_fallback``.
"""
if not isinstance(data, list):
return data
if not data:
return None
norm_target = symbols_str.replace("-", ".")
matched_symbol = None
historical = []
for row in data:
if not isinstance(row, dict):
continue
# Be permissive: single-symbol endpoint may omit per-row "symbol".
# Treat missing symbol as belonging to the requested symbols_str.
row_sym = row.get("symbol") or symbols_str
if row_sym.replace("-", ".") != norm_target:
continue
matched_symbol = matched_symbol or row_sym
historical.append({k: v for k, v in row.items() if k != "symbol"})
if not historical:
return None
if limit is not None and limit > 0:
historical = historical[:limit]
return {"symbol": matched_symbol or symbols_str, "historical": historical}
class FMPClient:
"""Client for Financial Modeling Prep API with rate limiting and caching"""
BASE_URL = "https://financialmodelingprep.com/api/v3"
RATE_LIMIT_DELAY = 0.3 # 300ms between requests
_ENDPOINT_FAILURE_THRESHOLD = 3 # disable endpoint after N consecutive failures
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.getenv("FMP_API_KEY")
if not self.api_key:
raise ValueError(
"FMP API key required. Set FMP_API_KEY environment variable "
"or pass api_key parameter."
)
self.session = requests.Session()
self.session.headers.update({"apikey": self.api_key})
self.cache = {}
self.last_call_time = 0
self.rate_limit_reached = False
self.retry_count = 0
self.max_retries = 1
self.api_calls_made = 0
# Circuit breaker: track consecutive failures per endpoint URL prefix
self._endpoint_failures: dict[str, int] = {}
self._disabled_endpoints: set[str] = set()
# Most recent transport-level failure reason; set by _rate_limited_get
# so _request_with_fallback can surface suppressed errors even when
# an endpoint was called with quiet=True.
self._last_error: Optional[str] = None
def _rate_limited_get(
self, url: str, params: Optional[dict] = None, quiet: bool = False
) -> Optional[dict]:
self._last_error = None
if self.rate_limit_reached:
self._last_error = "daily rate limit already reached"
return None
if params is None:
params = {}
elapsed = time.time() - self.last_call_time
if elapsed < self.RATE_LIMIT_DELAY:
time.sleep(self.RATE_LIMIT_DELAY - elapsed)
try:
response = self.session.get(url, params=params, timeout=30)
self.last_call_time = time.time()
self.api_calls_made += 1
if response.status_code == 200:
self.retry_count = 0
return response.json()
elif response.status_code == 429:
self.retry_count += 1
if self.retry_count <= self.max_retries:
print("WARNING: Rate limit exceeded. Waiting 60 seconds...", file=sys.stderr)
time.sleep(60)
return self._rate_limited_get(url, params, quiet=quiet)
else:
self._last_error = "HTTP 429 (daily rate limit)"
print("ERROR: Daily API rate limit reached.", file=sys.stderr)
self.rate_limit_reached = True
return None
else:
msg = f"HTTP {response.status_code} - {response.text[:200]}"
self._last_error = msg
if not quiet:
print(
f"ERROR: API request failed: {msg}",
file=sys.stderr,
)
return None
except requests.exceptions.RequestException as e:
self._last_error = f"request exception: {e}"
print(f"ERROR: Request exception: {e}", file=sys.stderr)
return None
def _request_with_fallback(self, endpoint_key, symbols_str, extra_params=None):
"""Try stable endpoint first, fall back to v3 for legacy users.
Returns parsed JSON in v3-compatible shape, or None if all fail.
Non-last endpoints are called with quiet=True so the user isn't
alarmed by an expected stable failure when v3 will catch it — but
when a non-last endpoint DOES fail, a WARN line is emitted explaining
why we're falling back. Otherwise users only see the (often misleading)
last-endpoint error and have no clue what really went wrong.
"""
params = dict(extra_params) if extra_params else {}
endpoints = _FMP_ENDPOINTS[endpoint_key]
is_single = "," not in symbols_str
for i, (base_url, url_builder) in enumerate(endpoints):
# Circuit breaker: skip endpoints with too many consecutive failures
if base_url in self._disabled_endpoints:
continue
url, final_params = url_builder(base_url, symbols_str, dict(params))
is_last = i == len(endpoints) - 1
data = self._rate_limited_get(url, final_params, quiet=not is_last)
if not data: # falsy (None, [], {}) — try next endpoint
self._record_endpoint_failure(base_url)
self._warn_fallback(base_url, is_last, self._last_error)
continue
# Normalize new stable EOD flat-list shape to v3-compatible dict.
# No-op for v3 dict / historicalStockList responses.
# `timeseries` (original request) is passed as `limit` so the
# EOD endpoint's full-history response is truncated to the
# legacy "most-recent N rows" contract.
if endpoint_key == "historical":
limit = params.get("timeseries") if isinstance(params, dict) else None
data = _normalize_eod_flat_list(data, symbols_str, limit=limit)
if not data:
self._record_endpoint_failure(base_url)
self._warn_fallback(
base_url,
is_last,
f"response had no rows matching '{symbols_str}'",
)
continue
# Shape validation: reject truthy-but-wrong-shape responses
valid = True
shape_issue: Optional[str] = None
if endpoint_key == "quote":
if not isinstance(data, list) or len(data) == 0:
valid = False
shape_issue = "expected non-empty list"
elif is_single and not any(
q.get("symbol", "").replace("-", ".") == symbols_str.replace("-", ".")
for q in data
):
valid = False
shape_issue = f"requested symbol '{symbols_str}' not in response"
if endpoint_key == "historical":
if not isinstance(data, dict):
valid = False
shape_issue = "expected dict"
elif "historicalStockList" in data:
# stable batch format -> v3 single format (exact match only)
norm = symbols_str.replace("-", ".")
found = None
for entry in data["historicalStockList"]:
if entry.get("symbol", "").replace("-", ".") == norm:
found = {
"symbol": entry.get("symbol"),
"historical": entry.get("historical", []),
}
break
if found:
self._endpoint_failures[base_url] = 0
return found
valid = False
shape_issue = f"'{symbols_str}' not in historicalStockList"
elif "historical" not in data:
valid = False
shape_issue = "missing 'historical' key"
elif is_single and data.get("symbol"):
if data["symbol"].replace("-", ".") != symbols_str.replace("-", "."):
valid = False
shape_issue = (
f"response symbol '{data['symbol']}' != requested '{symbols_str}'"
)
if valid:
self._endpoint_failures[base_url] = 0
return data
self._record_endpoint_failure(base_url)
self._warn_fallback(base_url, is_last, shape_issue or "unexpected response shape")
return None
def _warn_fallback(self, base_url: str, is_last: bool, reason: Optional[str]) -> None:
"""Emit a WARN line so users see why a non-last endpoint failed and the
client is falling back. No-op when the failing endpoint is the last one
(its error was already printed by _rate_limited_get with quiet=False)."""
if is_last or not reason:
return
print(
f"WARN: {base_url} failed ({reason}); falling back to next endpoint",
file=sys.stderr,
)
def _record_endpoint_failure(self, base_url: str) -> None:
"""Track consecutive failures and disable endpoint after threshold."""
failures = self._endpoint_failures.get(base_url, 0) + 1
self._endpoint_failures[base_url] = failures
if failures >= self._ENDPOINT_FAILURE_THRESHOLD:
self._disabled_endpoints.add(base_url)
def get_sp500_constituents(self) -> Optional[list[dict]]:
"""Fetch S&P 500 constituent list.
Returns:
List of dicts with keys: symbol, name, sector, subSector
or None on failure.
"""
cache_key = "sp500_constituents"
if cache_key in self.cache:
return self.cache[cache_key]
# Migrate hardcoded v3 URL to /stable (this method bypasses the
# _FMP_ENDPOINTS stable→v3 fallback list, so rewrite at the call site).
url, params = v3_to_stable(f"{self.BASE_URL}/sp500_constituent")
data = self._rate_limited_get(url, params)
if data:
self.cache[cache_key] = data
return data
def get_quote(self, symbols: str) -> Optional[list[dict]]:
"""Fetch real-time quote data for one or more symbols (comma-separated)"""
cache_key = f"quote_{symbols}"
if cache_key in self.cache:
return self.cache[cache_key]
data = self._request_with_fallback("quote", symbols)
if data:
self.cache[cache_key] = data
return data
def get_batch_quotes(self, symbols: list[str]) -> dict[str, dict]:
"""Fetch quotes for a list of symbols, batching up to 5 per request"""
results = {}
batch_size = 5
for i in range(0, len(symbols), batch_size):
batch = symbols[i : i + batch_size]
batch_str = ",".join(batch)
quotes = self.get_quote(batch_str)
if quotes:
for q in quotes:
results[q["symbol"]] = q
return results
def get_batch_historical(self, symbols: list[str], days: int = 260) -> dict[str, list[dict]]:
"""Fetch historical prices for multiple symbols"""
results = {}
for symbol in symbols:
data = self.get_historical_prices(symbol, days=days)
if data and "historical" in data:
results[symbol] = data["historical"]
return results
def calculate_sma(self, prices: list[float], period: int) -> float:
"""Calculate Simple Moving Average from a list of prices (most recent first)"""
if len(prices) < period:
return sum(prices) / len(prices)
return sum(prices[:period]) / period
def get_company_profile(self, symbol: str) -> Optional[dict]:
"""Fetch company profile (market cap, IPO date, sector, etc.).
Uses ``/stable/profile?symbol={symbol}`` (single symbol). Returns
``None`` on failure.
"""
cache_key = f"profile_{symbol}"
if cache_key in self.cache:
return self.cache[cache_key]
# Hardcoded v3 URL bypasses the stable→v3 fallback list; rewrite here.
url, params = v3_to_stable(f"{self.BASE_URL}/profile/{symbol}")
data = self._rate_limited_get(url, params)
if isinstance(data, list) and data:
profile = data[0]
# /stable/profile returns ``marketCap``; v3 returned ``mktCap``.
# Re-alias so consumers (e.g. screen_parabolic.py market-cap bounds)
# keep working against the legacy key.
if "mktCap" not in profile and "marketCap" in profile:
profile["mktCap"] = profile["marketCap"]
self.cache[cache_key] = profile
return profile
return None
def get_profile_bulk(self, part: int = 0) -> Optional[list[dict]]:
"""Bulk profile download (Premium endpoint).
Used to avoid per-symbol calls during the initial universe pass.
"""
cache_key = f"profile_bulk_{part}"
if cache_key in self.cache:
return self.cache[cache_key]
url = "https://financialmodelingprep.com/stable/profile-bulk"
data = self._rate_limited_get(url, params={"part": part}, quiet=True)
if isinstance(data, list) and data:
self.cache[cache_key] = data
return data
return None
def get_earnings_calendar(self, from_date: str, to_date: str) -> Optional[list[dict]]:
"""Fetch upcoming earnings between two dates (YYYY-MM-DD)."""
cache_key = f"earnings_{from_date}_{to_date}"
if cache_key in self.cache:
return self.cache[cache_key]
# Hardcoded v3 URL bypasses the stable→v3 fallback list; rewrite here.
url, params = v3_to_stable(
f"{self.BASE_URL}/earning_calendar", {"from": from_date, "to": to_date}
)
data = self._rate_limited_get(url, params=params)
if isinstance(data, list):
self.cache[cache_key] = data
return data
def get_aftermarket_quote(self, symbol: str) -> Optional[dict]:
"""Pre/after-market quote.
FMP exposes this as ``stable/aftermarket-quote?symbol=AAPL``. The
response shape is ``price`` / ``bid`` / ``ask`` / ``volume`` /
``timestamp`` — ``high`` / ``low`` are NOT guaranteed and the caller
must handle ``None`` for those fields explicitly. The returned dict
always carries a ``source`` key for the schema.
Returns ``None`` on failure (no aftermarket data published).
"""
cache_key = f"aftermarket_{symbol}"
if cache_key in self.cache:
return self.cache[cache_key]
url = "https://financialmodelingprep.com/stable/aftermarket-quote"
data = self._rate_limited_get(url, params={"symbol": symbol}, quiet=True)
if not data:
return None
# FMP may return either a list or a dict depending on tier
row = data[0] if isinstance(data, list) and data else data
if not isinstance(row, dict):
return None
out = {
"price": row.get("price"),
"bid": row.get("bid"),
"ask": row.get("ask"),
"volume": row.get("volume") or row.get("size"),
"high": row.get("high"), # may be None
"low": row.get("low"), # may be None
"timestamp": row.get("timestamp") or row.get("date"),
"source": "fmp_aftermarket_quote",
}
self.cache[cache_key] = out
return out
def get_intraday_ohlcv(
self,
symbol: str,
interval: str = "5min",
from_ts: Optional[str] = None,
to_ts: Optional[str] = None,
) -> Optional[list[dict]]:
"""Intraday bars — Phase 3 (v0.5) feature.
Stub for the MVP. Phase 1 + Phase 2 do not need this; the
function is declared so the broader skill API surface is stable.
"""
raise NotImplementedError(
"Intraday OHLCV is part of Phase 3 (v0.5). MVP only consumes "
"daily bars + aftermarket quote."
)
def get_historical_prices(self, symbol: str, days: int = 365) -> Optional[dict]:
"""Fetch historical daily OHLCV data.
Args:
symbol: Stock symbol
days: Number of trading days to fetch
Returns:
Dict with 'symbol' and 'historical' keys, where 'historical' is a
list of price dicts (most-recent-first) with: date, open, high, low,
close, adjClose, volume
"""
cache_key = f"prices_{symbol}_{days}"
if cache_key in self.cache:
return self.cache[cache_key]
data = self._request_with_fallback("historical", symbol, {"timeseries": days})
if data:
self.cache[cache_key] = data
return data
def get_api_stats(self) -> dict:
"""Return API usage statistics."""
return {
"cache_entries": len(self.cache),
"api_calls_made": self.api_calls_made,
"rate_limit_reached": self.rate_limit_reached,
}
"""Phase 3 trigger evaluators (pure functions).
Each module exports an ``evaluate(plan, bars, *, atr_14,
vwap_series=None) -> dict`` function. Importing as
``from intraday_evaluators import orl_evaluator`` keeps the dispatch
table in ``intraday_state_machine`` readable.
"""
"""First Red 5-min evaluator (Phase 3).
Pure function. State machine:
armed → red_marked on the first bar where close < open
red_marked → triggered on a later bar whose low < red_low
(intra-bar trigger; playbook says
"prints below")
red_marked → invalidated on a later bar whose high > red_high
triggered → invalidated on a post-trigger bar whose high
> red_high (rare but the contract
says triggered is NOT terminal)
invalidated → (terminal) no further transitions
Same-bar tie-break (v0.5b contract): when a single bar prints both
``high > red_high`` (would invalidate) AND ``low < red_low`` (would
trigger), invalidation wins. Rationale: a bar that swept both sides
of the prior structure is a failed setup, not a clean breakdown.
"""
from __future__ import annotations
TRIGGER_TYPE = "first_red_5min"
ENTRY_OFFSET_BELOW_RED_LOW = 0.05 # entry hint: "first_red_5min_low - 0.05"
def _empty_state(plan: dict) -> dict:
return {
"plan_id": plan["plan_id"],
"ticker": plan["ticker"],
"trigger_type": TRIGGER_TYPE,
"state": "armed",
"evaluation_status": "evaluated",
"skip_reason": None,
"armed_at": None,
"red_marked_at": None,
"triggered_at": None,
"invalidated_at": None,
"invalidation_reason": None,
"entry_actual": None,
"stop_actual": None,
"session_high": None,
"session_low": None,
"red_low": None,
"red_high": None,
"last_bar_ts": None,
}
def evaluate(
plan: dict,
bars: list[dict],
*,
atr_14: float | None = None,
vwap_series: list[float] | None = None,
) -> dict:
"""Run the First Red FSM. ``atr_14`` is unused but accepted for
signature parity with the other evaluators."""
out = _empty_state(plan)
if not bars:
return out
# Initialise with the open bar.
out["armed_at"] = bars[0]["ts_et"]
out["session_high"] = bars[0]["h"]
out["session_low"] = bars[0]["l"]
out["last_bar_ts"] = bars[0]["ts_et"]
for i, bar in enumerate(bars):
out["last_bar_ts"] = bar["ts_et"]
out["session_high"] = max(out["session_high"], bar["h"])
out["session_low"] = min(out["session_low"], bar["l"])
if out["state"] == "invalidated":
continue
if out["state"] == "armed":
# Mark first red bar.
if bar["c"] < bar["o"]:
out["state"] = "red_marked"
out["red_marked_at"] = bar["ts_et"]
out["red_low"] = bar["l"]
out["red_high"] = bar["h"]
continue
# red_marked or triggered: evaluate invalidation FIRST so the
# same-bar tie-break (invalidation wins) is honoured.
invalidates = bar["h"] > out["red_high"]
triggers = bar["l"] < out["red_low"]
if out["state"] == "red_marked":
if invalidates:
# Tie-break: even if triggers also true on the same bar,
# invalidation wins.
out["state"] = "invalidated"
out["invalidated_at"] = bar["ts_et"]
out["invalidation_reason"] = "red_high_taken_out"
elif triggers:
out["state"] = "triggered"
out["triggered_at"] = bar["ts_et"]
out["entry_actual"] = round(out["red_low"] - ENTRY_OFFSET_BELOW_RED_LOW, 4)
out["stop_actual"] = round(out["red_high"], 4)
continue
if out["state"] == "triggered":
# Post-trigger invalidation: red_high taken out.
if invalidates:
out["state"] = "invalidated"
out["invalidated_at"] = bar["ts_et"]
out["invalidation_reason"] = "post_trigger_red_high_taken_out"
continue
return out
"""5-min Opening Range Low evaluator (Phase 3).
Pure function: ``evaluate(plan, bars, *, atr_14, vwap_series=None)``
returns the new state dict computed by left-folding over ``bars``.
No ``prior_state`` parameter — replay determinism (per the v0.5
idempotency contract) requires the FSM be a function of the bar list
alone.
State machine:
armed → triggered on bar that closes < ORL low AND
vol ≥ 1.2 × ORL bar's vol
armed → (no transition) on a pre-trigger reclaim (does not
invalidate; the plan stays armed)
triggered → invalidated on a post-trigger bar with close >
ORL low AND close > current VWAP
(BOTH must be reclaimed)
invalidated → (terminal) no further transitions
Inputs:
- ``plan["plan_id"]``, ``plan["ticker"]``, ``plan["trigger_type"]``
- ``atr_14`` (float | None) — daily 14-bar ATR from
``plans[i]["key_levels"]["atr_14"]``. When None or missing, the
evaluator returns ``evaluation_status="skipped"`` +
``skip_reason="atr_14_unavailable"`` and leaves ``state="armed"``.
- ``vwap_series`` (list[float] | None) — pre-computed cumulative
session VWAP per bar. When None, evaluator computes it.
- ``stop_buffer_atr`` (kwarg, default 0.25) — ORL stop cushion.
"""
from __future__ import annotations
from datetime import datetime
from vwap import vwap_for_each_bar
TRIGGER_TYPE = "orl_5min_break"
ORL_VOLUME_MULTIPLIER = 1.2
ENTRY_OFFSET_BELOW_ORL = 0.05 # entry hint string: "5min_orl_low - 0.05"
OPENING_BAR_HOUR = 9
OPENING_BAR_MINUTE = 30
def _empty_state(plan: dict) -> dict:
"""The starting / no-bars / skipped shell."""
return {
"plan_id": plan["plan_id"],
"ticker": plan["ticker"],
"trigger_type": TRIGGER_TYPE,
"state": "armed",
"evaluation_status": "evaluated",
"skip_reason": None,
"armed_at": None,
"triggered_at": None,
"invalidated_at": None,
"invalidation_reason": None,
"entry_actual": None,
"stop_actual": None,
"session_high": None,
"session_low": None,
"orl_low": None,
"orl_high": None,
"orl_volume": None,
"vwap_series_last": None,
"last_bar_ts": None,
}
def evaluate(
plan: dict,
bars: list[dict],
*,
atr_14: float | None,
vwap_series: list[float] | None = None,
stop_buffer_atr: float = 0.25,
) -> dict:
"""Run the ORL FSM over the full bar list and return the new state."""
out = _empty_state(plan)
if not bars:
return out # state stays armed; CLI sets evaluation_status=no_bars
if atr_14 is None:
out["evaluation_status"] = "skipped"
out["skip_reason"] = "atr_14_unavailable"
return out
# The first bar of the regular session MUST be the 09:30 ET bar
# (which covers 09:30-09:35 with bar-open semantics). Alpaca skips
# bars during halts or empty intervals, so bars[0] could be 09:35
# or later if there were no trades in the opening 5 minutes — in
# that rare case we cannot establish ORL low/high/volume and must
# skip rather than mis-anchor on a later bar.
first_bar_ts = datetime.fromisoformat(bars[0]["ts_et"])
if (first_bar_ts.hour, first_bar_ts.minute) != (OPENING_BAR_HOUR, OPENING_BAR_MINUTE):
out["evaluation_status"] = "skipped"
out["skip_reason"] = "opening_range_bar_unavailable"
return out
if vwap_series is None:
vwap_series = vwap_for_each_bar(bars)
# First bar (09:30 → 09:35) is the Opening Range bar.
orl = bars[0]
out["armed_at"] = orl["ts_et"]
out["orl_low"] = orl["l"]
out["orl_high"] = orl["h"]
out["orl_volume"] = orl["v"]
out["session_high"] = orl["h"]
out["session_low"] = orl["l"]
out["last_bar_ts"] = orl["ts_et"]
out["vwap_series_last"] = vwap_series[0]
# Walk subsequent bars, updating session H/L and FSM state.
for i in range(1, len(bars)):
bar = bars[i]
out["last_bar_ts"] = bar["ts_et"]
out["session_high"] = max(out["session_high"], bar["h"])
out["session_low"] = min(out["session_low"], bar["l"])
out["vwap_series_last"] = vwap_series[i]
if out["state"] == "invalidated":
# Terminal: no further transitions.
continue
current_vwap = vwap_series[i]
if out["state"] == "armed":
# Trigger predicate: close < ORL low AND vol >= 1.2× ORL vol.
if bar["c"] < out["orl_low"] and bar["v"] >= ORL_VOLUME_MULTIPLIER * out["orl_volume"]:
out["state"] = "triggered"
out["triggered_at"] = bar["ts_et"]
out["entry_actual"] = round(bar["c"] - ENTRY_OFFSET_BELOW_ORL, 4)
out["stop_actual"] = round(out["session_high"] + stop_buffer_atr * atr_14, 4)
# Pre-trigger reclaims do NOT invalidate — plan stays armed.
continue
if out["state"] == "triggered":
# Post-trigger invalidation: close > orl_low AND close > current_vwap.
if bar["c"] > out["orl_low"] and bar["c"] > current_vwap:
out["state"] = "invalidated"
out["invalidated_at"] = bar["ts_et"]
out["invalidation_reason"] = "post_trigger_close_reclaimed_orl_and_vwap"
continue
return out
"""Phase 3 dispatcher: choose the right evaluator by trigger_type.
The Phase 3 idempotency contract requires the FSM be a pure function
of ``(plan, bars, atr_14)``. ``prior_state`` is **not** an input —
it's read by the CLI for diff/notification purposes only. Each
evaluator left-folds over the full bar list from session open.
"""
from __future__ import annotations
from intraday_evaluators import (
first_red_evaluator,
orl_evaluator,
vwap_fail_evaluator,
)
from vwap import vwap_for_each_bar
_EVALUATORS = {
"orl_5min_break": orl_evaluator.evaluate,
"first_red_5min": first_red_evaluator.evaluate,
"vwap_fail": vwap_fail_evaluator.evaluate,
}
def step_one_plan(
plan: dict,
bars: list[dict],
*,
atr_14: float | None,
stop_buffer_atr: float = 0.25,
) -> dict:
"""Dispatch to the right FSM evaluator based on plan["trigger_type"].
Computes session VWAP once for the bar list and shares it with any
evaluator that needs it (ORL + VWAP fail), so a Phase 3 run that
has all three trigger plans for the same ticker only computes
VWAP once.
"""
trigger_type = plan["trigger_type"]
if trigger_type not in _EVALUATORS:
raise ValueError(f"Unknown trigger_type: {trigger_type!r}")
vwap_series = vwap_for_each_bar(bars) if bars else None
if trigger_type == "orl_5min_break":
return orl_evaluator.evaluate(
plan,
bars,
atr_14=atr_14,
vwap_series=vwap_series,
stop_buffer_atr=stop_buffer_atr,
)
if trigger_type == "first_red_5min":
return first_red_evaluator.evaluate(plan, bars, atr_14=atr_14, vwap_series=vwap_series)
# vwap_fail
return vwap_fail_evaluator.evaluate(plan, bars, atr_14=atr_14, vwap_series=vwap_series)
Related skills
How it compares
Pick parabolic-short-trade-planner over generic market skills when you need a three-phase parabolic short pipeline with Alpaca borrow/SSR gating and 5-min FSM triggers.
FAQ
Does parabolic-short-trade-planner place trades automatically?
parabolic-short-trade-planner is detection-only by design. Phase 3 emits triggered state with entry_actual, stop_actual, and shares_actual JSON fields, but the trader must manually fire orders at the broker after clearing blocking_manual_reasons.
What APIs does parabolic-short-trade-planner require?
parabolic-short-trade-planner Phase 1 needs FMP_API_KEY for EOD bars and earnings calendar. Phase 2 and 3 use ALPACA_API_KEY and ALPACA_SECRET_KEY for borrow checks and 5-min market data, falling back to ManualBrokerAdapter when absent.
How many scoring factors does the daily screener use?
parabolic-short-trade-planner Phase 1 scores survivors on five factors with weights 30/25/20/15/10 covering MA extension, acceleration, volume climax, range expansion, and liquidity, then assigns A/B/C/D grades.