fix: marketable entries price from live last + time-of-day rvol arms fast path

Day-22 forensic (BIYA session): 6 entry submissions, 0 fills — IREX missed
+1.7R, IRE +1.65R by one cent. Two root causes fixed:

1. Slow-path limit rested AT the trigger while price ran 2% past it, and
   retries reused the same stale price. Marketable model now prices from
   max(trigger, live last) via price_provider; the anti-chase extension
   gate gets the live ref too (was an entry_ref==trigger no-op on this
   path). TRADING_ENTRY_FILL_MODEL flipped to marketable.

2. Fast trigger path armed nothing all day: legacy rvol (cum-so-far /
   full-day MEAN) is ~0.1-0.3 minutes after the open for a normal heavy
   name and its mean is poisoned by reverse-split/prior-squeeze days
   (BIYA legacy 1.27 vs real activity 481x median). New rvol_tod =
   cum / (MEDIAN(20d) * elapsed-session fraction) drives arm eligibility
   and the scanner keep-filter; legacy rvol untouched for validated
   boundaries (gap-squeeze rvol>=5).

Also: stagnation time-stop (TRADING_EXIT_STAGNATION_MIN/_R, default OFF)
in simulate_exit + manage_live with sweep_exits.py variants for
validation before any live enable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-20 15:40:15 -05:00
1 parent f10a8033a1
commit 5597250ff1
11 files changed
+894 -8

No files matched your search

+687
View File
@@ -0,0 +1,687 @@
# TrueNAS SCALE custom-app compose - FLATTENED (no YAML anchors).
# TrueNAS's app validator REJECTS anchors (x-app / <<: *app) with
# 'Invalid YAML provided', so every service carries its own full env.
# Anchored/DRY source of truth: deploy/docker/truenas-compose.yml.
# Fill every <FILL-ME-*> before deploying (PG password repeats in all
# DATABASE_URL blocks + postgres + db-backup). Regenerated? keep both in sync.
#
services:
postgres:
image: postgres:18
environment:
POSTGRES_USER: momentum
POSTGRES_PASSWORD: <FILL-ME-PG-PASSWORD>
POSTGRES_DB: momentum
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- /mnt/volume/Apps/momentum/pgdata:/var/lib/postgresql/data
ports:
- 5433:5432
healthcheck:
test:
- CMD-SHELL
- pg_isready -U momentum -d momentum
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
loop:
image: docker.io/fez5587/momentum-app:latest
command:
- python
- -u
- run_live_paper.py
- --no-dashboard
environment:
ALPACA_API_KEY: <FILL-ME-ALPACA-KEY>
ALPACA_BACKFILL_FEED: sip
ALPACA_BASE_URL: https://paper-api.alpaca.markets/v2
ALPACA_DATA_FEED: sip
ALPACA_PAPER_SYNC_ENABLED: '1'
ALPACA_SECRET_KEY: <FILL-ME-ALPACA-SECRET>
DATABASE_URL: postgresql://momentum:<FILL-ME-PG-PASSWORD>@postgres:5432/momentum
DEADMAN_DAILY_PING: '1'
DEADMAN_STALE_MIN: '5'
DISCOVER_INTERVAL_SECONDS: '120'
FMP_API_KEY: <FILL-ME-FMP-KEY>
INGEST_STALE_BLOCK: '0'
INGEST_STALE_SECONDS: '180'
LIVE_BARS_INTERVAL_SECONDS: '20'
LIVE_BARS_REFRESH_MINUTES: '20'
NEWS_CATALYST_SCORE_ENABLED: '1'
NEWS_DILUTION_VETO_ENABLED: '1'
NEWS_ENRICH_BATCH_LIMIT: '50'
NEWS_ENRICH_ENABLED: '1'
NEWS_ENRICH_INTERVAL_SECONDS: '120'
NEWS_ENRICH_LOOKBACK_HOURS: '12'
NEWS_FEEDS: prnewswire=https://www.prnewswire.com/rss/news-releases-list.rss,businesswire=https://feed.businesswire.com/rss/home/?rss=G1QFDERJXkJeGVtRWQ==,globenewswire=https://www.globenewswire.com/RssFeed/orgclass/1/feedTitle/GlobeNewswire%20-%20News%20about%20Public%20Companies
NEWS_LOOKBACK_HOURS: '8'
NEWS_RANK_BOOST: '2.0'
OLLAMA_ENABLED: '1'
OLLAMA_HOST: http://192.168.1.5:30068
OLLAMA_MODEL: qwen2.5:7b-instruct
OLLAMA_TEMPERATURE: '0.1'
OLLAMA_TIMEOUT_SECONDS: '60'
ORB_BARS: '5'
PG_SKIP_SCHEMA: '1'
STRATEGY_SETUPS: opening_range_break
TELEGRAM_BOT_TOKEN: <FILL-ME-TELEGRAM-TOKEN>
TELEGRAM_CHAT_ID: <FILL-ME-TELEGRAM-CHAT-ID>
TRADING_AUTO_APPROVE: '1'
TRADING_BACKOUT_COOLDOWN_SECONDS: '180'
TRADING_BLOCK_REENTRY_AFTER_EXIT: '1'
TRADING_ENTRY_CONFIRM_BY_MINUTE: '0'
TRADING_ENTRY_FILL_MODEL: marketable
TRADING_ENTRY_INVALIDATE_PCT: '0.015'
TRADING_ENTRY_ORDER_TYPE: limit
TRADING_ENTRY_TIMEOUT_BARS: '2'
TRADING_EOD_FLATTEN_ENABLED: '1'
TRADING_EOD_FLATTEN_TIME: '15:55'
TRADING_EXECUTION_ENABLED: '1'
TRADING_EXIT_BREAKEVEN_R: '1.0'
TRADING_EXIT_PROFIT_TIERS: 8:3,15:9,25:18,40:30
TRADING_EXIT_TRAIL_AFTER_R: '1.0'
TRADING_EXIT_TRAIL_MODE: prior_low
TRADING_EXIT_TRAIL_R_STEP: '0.25'
TRADING_LIQUIDITY_MAX_VOLUME_PCT: '0.025'
TRADING_MAX_BACKOUTS_PER_SYMBOL: '3'
TRADING_MAX_CONCURRENT_POSITIONS: '10'
TRADING_MAX_DAILY_LOSS_PCT: '0.05'
TRADING_MAX_FRESH_ENTRIES_PER_DAY: '10'
TRADING_MAX_GROSS_NOTIONAL_PCT: '0.60'
TRADING_MAX_ORDERS_PER_TICK: '1'
TRADING_MAX_POSITION_PCT: '0.16'
TRADING_MAX_RISK_DOLLARS: '500'
TRADING_MIN_DAY_VOLUME: '300000'
TRADING_MIN_ENTRY_GAP_PCT: '0.10'
TRADING_MIN_QUALITY_SCORE: '0.60'
TRADING_PRICE_MAX: '20.0'
TRADING_PRICE_MIN: '2.0'
TRADING_REENTRY_MIN_LOSS_PCT: '0.01'
TRADING_REGIME_GATE_ENABLED: '1'
TRADING_REGIME_GATE_P_MIN: '0.5'
TRADING_REQUIRE_ABOVE_VWAP: '1'
TRADING_REWARD_MULTIPLE: '10.0'
TRADING_RISK_PER_TRADE_PCT: '0.01'
TRADING_SESSION_ONLY: '1'
TRADING_TRIGGER_SLIP_PCT: '0.004'
TRADING_UNIFIED_ENTRY: '1'
TRIGGER_ARM_INTERVAL_SECONDS: '20'
TRIGGER_ENABLED: '1'
TRIGGER_GAP_MAX: '35'
TRIGGER_GAP_MIN: '10.0'
TRIGGER_GAP_SQUEEZE_MIN: '150'
TRIGGER_GAP_SQUEEZE_RVOL: '5.0'
TRIGGER_INTERVAL_SECONDS: '4'
TRIGGER_MAX_ARMED: '10'
TRIGGER_MIN_DOLLAR_VOL: '8000'
TRIGGER_PRICE_MAX_AGE: '12'
TRIGGER_RVOL_MIN: '2.0'
TZ: America/New_York
WATCHER_ENABLED: '1'
WATCHER_INTERVAL_SECONDS: '15'
WATCHER_PRICE_MAX: '20'
WATCHER_PRICE_MIN: '2'
YOUTUBE_API_KEY: <FILL-ME-YOUTUBE-KEY>
volumes:
- /mnt/volume/Apps/momentum/data:/app/data
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
healthcheck:
test:
- CMD
- python
- -c
- import os; os.environ['PG_SKIP_SCHEMA']='1'; import datetime,sys; from research.multi_schema import open_research_db; r=open_research_db('market').execute('SELECT max(timestamp) FROM events').fetchall()[0][0]; sys.exit(0 if r and (datetime.datetime.now()-r).total_seconds()<300 else 1)
interval: 60s
timeout: 20s
retries: 5
start_period: 180s
dashboard:
image: docker.io/fez5587/momentum-app:latest
command:
- python
- -u
- dashboard_api.py
environment:
ALPACA_API_KEY: <FILL-ME-ALPACA-KEY>
ALPACA_BACKFILL_FEED: sip
ALPACA_BASE_URL: https://paper-api.alpaca.markets/v2
ALPACA_DATA_FEED: sip
ALPACA_PAPER_SYNC_ENABLED: '1'
ALPACA_SECRET_KEY: <FILL-ME-ALPACA-SECRET>
DATABASE_URL: postgresql://momentum:<FILL-ME-PG-PASSWORD>@postgres:5432/momentum
DEADMAN_DAILY_PING: '1'
DEADMAN_STALE_MIN: '5'
DISCOVER_INTERVAL_SECONDS: '120'
FMP_API_KEY: <FILL-ME-FMP-KEY>
INGEST_STALE_BLOCK: '0'
INGEST_STALE_SECONDS: '180'
LIVE_BARS_INTERVAL_SECONDS: '20'
LIVE_BARS_REFRESH_MINUTES: '20'
NEWS_CATALYST_SCORE_ENABLED: '1'
NEWS_DILUTION_VETO_ENABLED: '1'
NEWS_ENRICH_BATCH_LIMIT: '50'
NEWS_ENRICH_ENABLED: '1'
NEWS_ENRICH_INTERVAL_SECONDS: '120'
NEWS_ENRICH_LOOKBACK_HOURS: '12'
NEWS_FEEDS: prnewswire=https://www.prnewswire.com/rss/news-releases-list.rss,businesswire=https://feed.businesswire.com/rss/home/?rss=G1QFDERJXkJeGVtRWQ==,globenewswire=https://www.globenewswire.com/RssFeed/orgclass/1/feedTitle/GlobeNewswire%20-%20News%20about%20Public%20Companies
NEWS_LOOKBACK_HOURS: '8'
NEWS_RANK_BOOST: '2.0'
OLLAMA_ENABLED: '1'
OLLAMA_HOST: http://192.168.1.5:30068
OLLAMA_MODEL: qwen2.5:7b-instruct
OLLAMA_TEMPERATURE: '0.1'
OLLAMA_TIMEOUT_SECONDS: '60'
ORB_BARS: '5'
PG_SKIP_SCHEMA: '1'
STRATEGY_SETUPS: opening_range_break
TELEGRAM_BOT_TOKEN: <FILL-ME-TELEGRAM-TOKEN>
TELEGRAM_CHAT_ID: <FILL-ME-TELEGRAM-CHAT-ID>
TRADING_AUTO_APPROVE: '1'
TRADING_BACKOUT_COOLDOWN_SECONDS: '180'
TRADING_BLOCK_REENTRY_AFTER_EXIT: '1'
TRADING_ENTRY_CONFIRM_BY_MINUTE: '0'
TRADING_ENTRY_FILL_MODEL: marketable
TRADING_ENTRY_INVALIDATE_PCT: '0.015'
TRADING_ENTRY_ORDER_TYPE: limit
TRADING_ENTRY_TIMEOUT_BARS: '2'
TRADING_EOD_FLATTEN_ENABLED: '1'
TRADING_EOD_FLATTEN_TIME: '15:55'
TRADING_EXECUTION_ENABLED: '1'
TRADING_EXIT_BREAKEVEN_R: '1.0'
TRADING_EXIT_PROFIT_TIERS: 8:3,15:9,25:18,40:30
TRADING_EXIT_TRAIL_AFTER_R: '1.0'
TRADING_EXIT_TRAIL_MODE: prior_low
TRADING_EXIT_TRAIL_R_STEP: '0.25'
TRADING_LIQUIDITY_MAX_VOLUME_PCT: '0.025'
TRADING_MAX_BACKOUTS_PER_SYMBOL: '3'
TRADING_MAX_CONCURRENT_POSITIONS: '10'
TRADING_MAX_DAILY_LOSS_PCT: '0.05'
TRADING_MAX_FRESH_ENTRIES_PER_DAY: '10'
TRADING_MAX_GROSS_NOTIONAL_PCT: '0.60'
TRADING_MAX_ORDERS_PER_TICK: '1'
TRADING_MAX_POSITION_PCT: '0.16'
TRADING_MAX_RISK_DOLLARS: '500'
TRADING_MIN_DAY_VOLUME: '300000'
TRADING_MIN_ENTRY_GAP_PCT: '0.10'
TRADING_MIN_QUALITY_SCORE: '0.60'
TRADING_PRICE_MAX: '20.0'
TRADING_PRICE_MIN: '2.0'
TRADING_REENTRY_MIN_LOSS_PCT: '0.01'
TRADING_REGIME_GATE_ENABLED: '1'
TRADING_REGIME_GATE_P_MIN: '0.5'
TRADING_REQUIRE_ABOVE_VWAP: '1'
TRADING_REWARD_MULTIPLE: '10.0'
TRADING_RISK_PER_TRADE_PCT: '0.01'
TRADING_SESSION_ONLY: '1'
TRADING_TRIGGER_SLIP_PCT: '0.004'
TRADING_UNIFIED_ENTRY: '1'
TRIGGER_ARM_INTERVAL_SECONDS: '20'
TRIGGER_ENABLED: '1'
TRIGGER_GAP_MAX: '35'
TRIGGER_GAP_MIN: '10.0'
TRIGGER_GAP_SQUEEZE_MIN: '150'
TRIGGER_GAP_SQUEEZE_RVOL: '5.0'
TRIGGER_INTERVAL_SECONDS: '4'
TRIGGER_MAX_ARMED: '10'
TRIGGER_MIN_DOLLAR_VOL: '8000'
TRIGGER_PRICE_MAX_AGE: '12'
TRIGGER_RVOL_MIN: '2.0'
TZ: America/New_York
WATCHER_ENABLED: '1'
WATCHER_INTERVAL_SECONDS: '15'
WATCHER_PRICE_MAX: '20'
WATCHER_PRICE_MIN: '2'
YOUTUBE_API_KEY: <FILL-ME-YOUTUBE-KEY>
DASHBOARD_HOST: 0.0.0.0
DASHBOARD_PORT: '8765'
DASHBOARD_TOKEN: <FILL-ME-DASHBOARD-TOKEN>
COMMAND_BUS_ENABLED: '1'
volumes:
- /mnt/volume/Apps/momentum/data:/app/data
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
ports:
- 8765:8765
qabot:
image: docker.io/fez5587/momentum-app:latest
command:
- python
- -u
- telegram_bot.py
environment:
ALPACA_API_KEY: <FILL-ME-ALPACA-KEY>
ALPACA_BACKFILL_FEED: sip
ALPACA_BASE_URL: https://paper-api.alpaca.markets/v2
ALPACA_DATA_FEED: sip
ALPACA_PAPER_SYNC_ENABLED: '1'
ALPACA_SECRET_KEY: <FILL-ME-ALPACA-SECRET>
DATABASE_URL: postgresql://momentum:<FILL-ME-PG-PASSWORD>@postgres:5432/momentum
DEADMAN_DAILY_PING: '1'
DEADMAN_STALE_MIN: '5'
DISCOVER_INTERVAL_SECONDS: '120'
FMP_API_KEY: <FILL-ME-FMP-KEY>
INGEST_STALE_BLOCK: '0'
INGEST_STALE_SECONDS: '180'
LIVE_BARS_INTERVAL_SECONDS: '20'
LIVE_BARS_REFRESH_MINUTES: '20'
NEWS_CATALYST_SCORE_ENABLED: '1'
NEWS_DILUTION_VETO_ENABLED: '1'
NEWS_ENRICH_BATCH_LIMIT: '50'
NEWS_ENRICH_ENABLED: '1'
NEWS_ENRICH_INTERVAL_SECONDS: '120'
NEWS_ENRICH_LOOKBACK_HOURS: '12'
NEWS_FEEDS: prnewswire=https://www.prnewswire.com/rss/news-releases-list.rss,businesswire=https://feed.businesswire.com/rss/home/?rss=G1QFDERJXkJeGVtRWQ==,globenewswire=https://www.globenewswire.com/RssFeed/orgclass/1/feedTitle/GlobeNewswire%20-%20News%20about%20Public%20Companies
NEWS_LOOKBACK_HOURS: '8'
NEWS_RANK_BOOST: '2.0'
OLLAMA_ENABLED: '1'
OLLAMA_HOST: http://192.168.1.5:30068
OLLAMA_MODEL: qwen2.5:7b-instruct
OLLAMA_TEMPERATURE: '0.1'
OLLAMA_TIMEOUT_SECONDS: '60'
ORB_BARS: '5'
PG_SKIP_SCHEMA: '1'
STRATEGY_SETUPS: opening_range_break
TELEGRAM_BOT_TOKEN: <FILL-ME-TELEGRAM-TOKEN>
TELEGRAM_CHAT_ID: <FILL-ME-TELEGRAM-CHAT-ID>
TRADING_AUTO_APPROVE: '1'
TRADING_BACKOUT_COOLDOWN_SECONDS: '180'
TRADING_BLOCK_REENTRY_AFTER_EXIT: '1'
TRADING_ENTRY_CONFIRM_BY_MINUTE: '0'
TRADING_ENTRY_FILL_MODEL: marketable
TRADING_ENTRY_INVALIDATE_PCT: '0.015'
TRADING_ENTRY_ORDER_TYPE: limit
TRADING_ENTRY_TIMEOUT_BARS: '2'
TRADING_EOD_FLATTEN_ENABLED: '1'
TRADING_EOD_FLATTEN_TIME: '15:55'
TRADING_EXECUTION_ENABLED: '1'
TRADING_EXIT_BREAKEVEN_R: '1.0'
TRADING_EXIT_PROFIT_TIERS: 8:3,15:9,25:18,40:30
TRADING_EXIT_TRAIL_AFTER_R: '1.0'
TRADING_EXIT_TRAIL_MODE: prior_low
TRADING_EXIT_TRAIL_R_STEP: '0.25'
TRADING_LIQUIDITY_MAX_VOLUME_PCT: '0.025'
TRADING_MAX_BACKOUTS_PER_SYMBOL: '3'
TRADING_MAX_CONCURRENT_POSITIONS: '10'
TRADING_MAX_DAILY_LOSS_PCT: '0.05'
TRADING_MAX_FRESH_ENTRIES_PER_DAY: '10'
TRADING_MAX_GROSS_NOTIONAL_PCT: '0.60'
TRADING_MAX_ORDERS_PER_TICK: '1'
TRADING_MAX_POSITION_PCT: '0.16'
TRADING_MAX_RISK_DOLLARS: '500'
TRADING_MIN_DAY_VOLUME: '300000'
TRADING_MIN_ENTRY_GAP_PCT: '0.10'
TRADING_MIN_QUALITY_SCORE: '0.60'
TRADING_PRICE_MAX: '20.0'
TRADING_PRICE_MIN: '2.0'
TRADING_REENTRY_MIN_LOSS_PCT: '0.01'
TRADING_REGIME_GATE_ENABLED: '1'
TRADING_REGIME_GATE_P_MIN: '0.5'
TRADING_REQUIRE_ABOVE_VWAP: '1'
TRADING_REWARD_MULTIPLE: '10.0'
TRADING_RISK_PER_TRADE_PCT: '0.01'
TRADING_SESSION_ONLY: '1'
TRADING_TRIGGER_SLIP_PCT: '0.004'
TRADING_UNIFIED_ENTRY: '1'
TRIGGER_ARM_INTERVAL_SECONDS: '20'
TRIGGER_ENABLED: '1'
TRIGGER_GAP_MAX: '35'
TRIGGER_GAP_MIN: '10.0'
TRIGGER_GAP_SQUEEZE_MIN: '150'
TRIGGER_GAP_SQUEEZE_RVOL: '5.0'
TRIGGER_INTERVAL_SECONDS: '4'
TRIGGER_MAX_ARMED: '10'
TRIGGER_MIN_DOLLAR_VOL: '8000'
TRIGGER_PRICE_MAX_AGE: '12'
TRIGGER_RVOL_MIN: '2.0'
TZ: America/New_York
WATCHER_ENABLED: '1'
WATCHER_INTERVAL_SECONDS: '15'
WATCHER_PRICE_MAX: '20'
WATCHER_PRICE_MIN: '2'
YOUTUBE_API_KEY: <FILL-ME-YOUTUBE-KEY>
volumes:
- /mnt/volume/Apps/momentum/data:/app/data
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
transcripts:
image: docker.io/fez5587/momentum-app:latest
command:
- python
- -u
- -m
- youtube_claims.run
- run
environment:
ALPACA_API_KEY: <FILL-ME-ALPACA-KEY>
ALPACA_BACKFILL_FEED: sip
ALPACA_BASE_URL: https://paper-api.alpaca.markets/v2
ALPACA_DATA_FEED: sip
ALPACA_PAPER_SYNC_ENABLED: '1'
ALPACA_SECRET_KEY: <FILL-ME-ALPACA-SECRET>
DATABASE_URL: postgresql://momentum:<FILL-ME-PG-PASSWORD>@postgres:5432/momentum
DEADMAN_DAILY_PING: '1'
DEADMAN_STALE_MIN: '5'
DISCOVER_INTERVAL_SECONDS: '120'
FMP_API_KEY: <FILL-ME-FMP-KEY>
INGEST_STALE_BLOCK: '0'
INGEST_STALE_SECONDS: '180'
LIVE_BARS_INTERVAL_SECONDS: '20'
LIVE_BARS_REFRESH_MINUTES: '20'
NEWS_CATALYST_SCORE_ENABLED: '1'
NEWS_DILUTION_VETO_ENABLED: '1'
NEWS_ENRICH_BATCH_LIMIT: '50'
NEWS_ENRICH_ENABLED: '1'
NEWS_ENRICH_INTERVAL_SECONDS: '120'
NEWS_ENRICH_LOOKBACK_HOURS: '12'
NEWS_FEEDS: prnewswire=https://www.prnewswire.com/rss/news-releases-list.rss,businesswire=https://feed.businesswire.com/rss/home/?rss=G1QFDERJXkJeGVtRWQ==,globenewswire=https://www.globenewswire.com/RssFeed/orgclass/1/feedTitle/GlobeNewswire%20-%20News%20about%20Public%20Companies
NEWS_LOOKBACK_HOURS: '8'
NEWS_RANK_BOOST: '2.0'
OLLAMA_ENABLED: '1'
OLLAMA_HOST: http://192.168.1.5:30068
OLLAMA_MODEL: qwen2.5:7b-instruct
OLLAMA_TEMPERATURE: '0.1'
OLLAMA_TIMEOUT_SECONDS: '60'
ORB_BARS: '5'
PG_SKIP_SCHEMA: '1'
STRATEGY_SETUPS: opening_range_break
TELEGRAM_BOT_TOKEN: <FILL-ME-TELEGRAM-TOKEN>
TELEGRAM_CHAT_ID: <FILL-ME-TELEGRAM-CHAT-ID>
TRADING_AUTO_APPROVE: '1'
TRADING_BACKOUT_COOLDOWN_SECONDS: '180'
TRADING_BLOCK_REENTRY_AFTER_EXIT: '1'
TRADING_ENTRY_CONFIRM_BY_MINUTE: '0'
TRADING_ENTRY_FILL_MODEL: marketable
TRADING_ENTRY_INVALIDATE_PCT: '0.015'
TRADING_ENTRY_ORDER_TYPE: limit
TRADING_ENTRY_TIMEOUT_BARS: '2'
TRADING_EOD_FLATTEN_ENABLED: '1'
TRADING_EOD_FLATTEN_TIME: '15:55'
TRADING_EXECUTION_ENABLED: '1'
TRADING_EXIT_BREAKEVEN_R: '1.0'
TRADING_EXIT_PROFIT_TIERS: 8:3,15:9,25:18,40:30
TRADING_EXIT_TRAIL_AFTER_R: '1.0'
TRADING_EXIT_TRAIL_MODE: prior_low
TRADING_EXIT_TRAIL_R_STEP: '0.25'
TRADING_LIQUIDITY_MAX_VOLUME_PCT: '0.025'
TRADING_MAX_BACKOUTS_PER_SYMBOL: '3'
TRADING_MAX_CONCURRENT_POSITIONS: '10'
TRADING_MAX_DAILY_LOSS_PCT: '0.05'
TRADING_MAX_FRESH_ENTRIES_PER_DAY: '10'
TRADING_MAX_GROSS_NOTIONAL_PCT: '0.60'
TRADING_MAX_ORDERS_PER_TICK: '1'
TRADING_MAX_POSITION_PCT: '0.16'
TRADING_MAX_RISK_DOLLARS: '500'
TRADING_MIN_DAY_VOLUME: '300000'
TRADING_MIN_ENTRY_GAP_PCT: '0.10'
TRADING_MIN_QUALITY_SCORE: '0.60'
TRADING_PRICE_MAX: '20.0'
TRADING_PRICE_MIN: '2.0'
TRADING_REENTRY_MIN_LOSS_PCT: '0.01'
TRADING_REGIME_GATE_ENABLED: '1'
TRADING_REGIME_GATE_P_MIN: '0.5'
TRADING_REQUIRE_ABOVE_VWAP: '1'
TRADING_REWARD_MULTIPLE: '10.0'
TRADING_RISK_PER_TRADE_PCT: '0.01'
TRADING_SESSION_ONLY: '1'
TRADING_TRIGGER_SLIP_PCT: '0.004'
TRADING_UNIFIED_ENTRY: '1'
TRIGGER_ARM_INTERVAL_SECONDS: '20'
TRIGGER_ENABLED: '1'
TRIGGER_GAP_MAX: '35'
TRIGGER_GAP_MIN: '10.0'
TRIGGER_GAP_SQUEEZE_MIN: '150'
TRIGGER_GAP_SQUEEZE_RVOL: '5.0'
TRIGGER_INTERVAL_SECONDS: '4'
TRIGGER_MAX_ARMED: '10'
TRIGGER_MIN_DOLLAR_VOL: '8000'
TRIGGER_PRICE_MAX_AGE: '12'
TRIGGER_RVOL_MIN: '2.0'
TZ: America/New_York
WATCHER_ENABLED: '1'
WATCHER_INTERVAL_SECONDS: '15'
WATCHER_PRICE_MAX: '20'
WATCHER_PRICE_MIN: '2'
YOUTUBE_API_KEY: <FILL-ME-YOUTUBE-KEY>
volumes:
- /mnt/volume/Apps/momentum/data:/app/data
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
admin:
image: docker.io/fez5587/momentum-app:latest
command:
- python
- -u
- admin_api.py
environment:
ALPACA_API_KEY: <FILL-ME-ALPACA-KEY>
ALPACA_BACKFILL_FEED: sip
ALPACA_BASE_URL: https://paper-api.alpaca.markets/v2
ALPACA_DATA_FEED: sip
ALPACA_PAPER_SYNC_ENABLED: '1'
ALPACA_SECRET_KEY: <FILL-ME-ALPACA-SECRET>
DATABASE_URL: postgresql://momentum:<FILL-ME-PG-PASSWORD>@postgres:5432/momentum
DEADMAN_DAILY_PING: '1'
DEADMAN_STALE_MIN: '5'
DISCOVER_INTERVAL_SECONDS: '120'
FMP_API_KEY: <FILL-ME-FMP-KEY>
INGEST_STALE_BLOCK: '0'
INGEST_STALE_SECONDS: '180'
LIVE_BARS_INTERVAL_SECONDS: '20'
LIVE_BARS_REFRESH_MINUTES: '20'
NEWS_CATALYST_SCORE_ENABLED: '1'
NEWS_DILUTION_VETO_ENABLED: '1'
NEWS_ENRICH_BATCH_LIMIT: '50'
NEWS_ENRICH_ENABLED: '1'
NEWS_ENRICH_INTERVAL_SECONDS: '120'
NEWS_ENRICH_LOOKBACK_HOURS: '12'
NEWS_FEEDS: prnewswire=https://www.prnewswire.com/rss/news-releases-list.rss,businesswire=https://feed.businesswire.com/rss/home/?rss=G1QFDERJXkJeGVtRWQ==,globenewswire=https://www.globenewswire.com/RssFeed/orgclass/1/feedTitle/GlobeNewswire%20-%20News%20about%20Public%20Companies
NEWS_LOOKBACK_HOURS: '8'
NEWS_RANK_BOOST: '2.0'
OLLAMA_ENABLED: '1'
OLLAMA_HOST: http://192.168.1.5:30068
OLLAMA_MODEL: qwen2.5:7b-instruct
OLLAMA_TEMPERATURE: '0.1'
OLLAMA_TIMEOUT_SECONDS: '60'
ORB_BARS: '5'
PG_SKIP_SCHEMA: '1'
STRATEGY_SETUPS: opening_range_break
TELEGRAM_BOT_TOKEN: <FILL-ME-TELEGRAM-TOKEN>
TELEGRAM_CHAT_ID: <FILL-ME-TELEGRAM-CHAT-ID>
TRADING_AUTO_APPROVE: '1'
TRADING_BACKOUT_COOLDOWN_SECONDS: '180'
TRADING_BLOCK_REENTRY_AFTER_EXIT: '1'
TRADING_ENTRY_CONFIRM_BY_MINUTE: '0'
TRADING_ENTRY_FILL_MODEL: marketable
TRADING_ENTRY_INVALIDATE_PCT: '0.015'
TRADING_ENTRY_ORDER_TYPE: limit
TRADING_ENTRY_TIMEOUT_BARS: '2'
TRADING_EOD_FLATTEN_ENABLED: '1'
TRADING_EOD_FLATTEN_TIME: '15:55'
TRADING_EXECUTION_ENABLED: '1'
TRADING_EXIT_BREAKEVEN_R: '1.0'
TRADING_EXIT_PROFIT_TIERS: 8:3,15:9,25:18,40:30
TRADING_EXIT_TRAIL_AFTER_R: '1.0'
TRADING_EXIT_TRAIL_MODE: prior_low
TRADING_EXIT_TRAIL_R_STEP: '0.25'
TRADING_LIQUIDITY_MAX_VOLUME_PCT: '0.025'
TRADING_MAX_BACKOUTS_PER_SYMBOL: '3'
TRADING_MAX_CONCURRENT_POSITIONS: '10'
TRADING_MAX_DAILY_LOSS_PCT: '0.05'
TRADING_MAX_FRESH_ENTRIES_PER_DAY: '10'
TRADING_MAX_GROSS_NOTIONAL_PCT: '0.60'
TRADING_MAX_ORDERS_PER_TICK: '1'
TRADING_MAX_POSITION_PCT: '0.16'
TRADING_MAX_RISK_DOLLARS: '500'
TRADING_MIN_DAY_VOLUME: '300000'
TRADING_MIN_ENTRY_GAP_PCT: '0.10'
TRADING_MIN_QUALITY_SCORE: '0.60'
TRADING_PRICE_MAX: '20.0'
TRADING_PRICE_MIN: '2.0'
TRADING_REENTRY_MIN_LOSS_PCT: '0.01'
TRADING_REGIME_GATE_ENABLED: '1'
TRADING_REGIME_GATE_P_MIN: '0.5'
TRADING_REQUIRE_ABOVE_VWAP: '1'
TRADING_REWARD_MULTIPLE: '10.0'
TRADING_RISK_PER_TRADE_PCT: '0.01'
TRADING_SESSION_ONLY: '1'
TRADING_TRIGGER_SLIP_PCT: '0.004'
TRADING_UNIFIED_ENTRY: '1'
TRIGGER_ARM_INTERVAL_SECONDS: '20'
TRIGGER_ENABLED: '1'
TRIGGER_GAP_MAX: '35'
TRIGGER_GAP_MIN: '10.0'
TRIGGER_GAP_SQUEEZE_MIN: '150'
TRIGGER_GAP_SQUEEZE_RVOL: '5.0'
TRIGGER_INTERVAL_SECONDS: '4'
TRIGGER_MAX_ARMED: '10'
TRIGGER_MIN_DOLLAR_VOL: '8000'
TRIGGER_PRICE_MAX_AGE: '12'
TRIGGER_RVOL_MIN: '2.0'
TZ: America/New_York
WATCHER_ENABLED: '1'
WATCHER_INTERVAL_SECONDS: '15'
WATCHER_PRICE_MAX: '20'
WATCHER_PRICE_MIN: '2'
YOUTUBE_API_KEY: <FILL-ME-YOUTUBE-KEY>
ADMIN_PORT: '8767'
ADMIN_TOKEN: <FILL-ME-ADMIN-TOKEN>
volumes:
- /mnt/volume/Apps/momentum/data:/app/data
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
ports:
- 8767:8767
scheduler:
image: docker.io/fez5587/momentum-app:latest
command:
- /bin/sh
- -c
- "echo \"[scheduler] up; fires 20:00 ET Mon-Fri\"\nwhile true; do\n if [ \"$$(date +%H:%M)\" = \"20:00\" ] && [ \"$$(date +%u)\" -le 5 ]; then\n python -m research.premarket_eval || echo \"[scheduler] eval FAILED\"\n python -m research.ingestion.market_context || echo \"[scheduler] market_context FAILED\"\n python -m research.eod_reconcile || echo \"[scheduler] reconcile FAILED\"\n python eod_replay.py || echo \"[scheduler] eod_replay FAILED\"\n python nightly_tune.py || echo \"[scheduler] nightly_tune FAILED\"\n python -m research.regime build-features || echo \"[scheduler] regime build FAILED\"\n python -m research.regime score --model threshold_v1 || echo \"[scheduler] regime threshold FAILED\"\n python -m research.regime score --model meta_v1 || echo \"[scheduler] regime meta FAILED\"\n python -m research.experiments run || echo \"[scheduler] experiments run FAILED\"\n python -m research.experiments adjudicate || echo \"[scheduler] experiments adjudicate FAILED\"\n sleep 120\n fi\n sleep 20\ndone\n"
environment:
ALPACA_API_KEY: <FILL-ME-ALPACA-KEY>
ALPACA_BACKFILL_FEED: sip
ALPACA_BASE_URL: https://paper-api.alpaca.markets/v2
ALPACA_DATA_FEED: sip
ALPACA_PAPER_SYNC_ENABLED: '1'
ALPACA_SECRET_KEY: <FILL-ME-ALPACA-SECRET>
DATABASE_URL: postgresql://momentum:<FILL-ME-PG-PASSWORD>@postgres:5432/momentum
DEADMAN_DAILY_PING: '1'
DEADMAN_STALE_MIN: '5'
DISCOVER_INTERVAL_SECONDS: '120'
FMP_API_KEY: <FILL-ME-FMP-KEY>
INGEST_STALE_BLOCK: '0'
INGEST_STALE_SECONDS: '180'
LIVE_BARS_INTERVAL_SECONDS: '20'
LIVE_BARS_REFRESH_MINUTES: '20'
NEWS_CATALYST_SCORE_ENABLED: '1'
NEWS_DILUTION_VETO_ENABLED: '1'
NEWS_ENRICH_BATCH_LIMIT: '50'
NEWS_ENRICH_ENABLED: '1'
NEWS_ENRICH_INTERVAL_SECONDS: '120'
NEWS_ENRICH_LOOKBACK_HOURS: '12'
NEWS_FEEDS: prnewswire=https://www.prnewswire.com/rss/news-releases-list.rss,businesswire=https://feed.businesswire.com/rss/home/?rss=G1QFDERJXkJeGVtRWQ==,globenewswire=https://www.globenewswire.com/RssFeed/orgclass/1/feedTitle/GlobeNewswire%20-%20News%20about%20Public%20Companies
NEWS_LOOKBACK_HOURS: '8'
NEWS_RANK_BOOST: '2.0'
OLLAMA_ENABLED: '1'
OLLAMA_HOST: http://192.168.1.5:30068
OLLAMA_MODEL: qwen2.5:7b-instruct
OLLAMA_TEMPERATURE: '0.1'
OLLAMA_TIMEOUT_SECONDS: '60'
ORB_BARS: '5'
PG_SKIP_SCHEMA: '1'
STRATEGY_SETUPS: opening_range_break
TELEGRAM_BOT_TOKEN: <FILL-ME-TELEGRAM-TOKEN>
TELEGRAM_CHAT_ID: <FILL-ME-TELEGRAM-CHAT-ID>
TRADING_AUTO_APPROVE: '1'
TRADING_BACKOUT_COOLDOWN_SECONDS: '180'
TRADING_BLOCK_REENTRY_AFTER_EXIT: '1'
TRADING_ENTRY_CONFIRM_BY_MINUTE: '0'
TRADING_ENTRY_FILL_MODEL: marketable
TRADING_ENTRY_INVALIDATE_PCT: '0.015'
TRADING_ENTRY_ORDER_TYPE: limit
TRADING_ENTRY_TIMEOUT_BARS: '2'
TRADING_EOD_FLATTEN_ENABLED: '1'
TRADING_EOD_FLATTEN_TIME: '15:55'
TRADING_EXECUTION_ENABLED: '1'
TRADING_EXIT_BREAKEVEN_R: '1.0'
TRADING_EXIT_PROFIT_TIERS: 8:3,15:9,25:18,40:30
TRADING_EXIT_TRAIL_AFTER_R: '1.0'
TRADING_EXIT_TRAIL_MODE: prior_low
TRADING_EXIT_TRAIL_R_STEP: '0.25'
TRADING_LIQUIDITY_MAX_VOLUME_PCT: '0.025'
TRADING_MAX_BACKOUTS_PER_SYMBOL: '3'
TRADING_MAX_CONCURRENT_POSITIONS: '10'
TRADING_MAX_DAILY_LOSS_PCT: '0.05'
TRADING_MAX_FRESH_ENTRIES_PER_DAY: '10'
TRADING_MAX_GROSS_NOTIONAL_PCT: '0.60'
TRADING_MAX_ORDERS_PER_TICK: '1'
TRADING_MAX_POSITION_PCT: '0.16'
TRADING_MAX_RISK_DOLLARS: '500'
TRADING_MIN_DAY_VOLUME: '300000'
TRADING_MIN_ENTRY_GAP_PCT: '0.10'
TRADING_MIN_QUALITY_SCORE: '0.60'
TRADING_PRICE_MAX: '20.0'
TRADING_PRICE_MIN: '2.0'
TRADING_REENTRY_MIN_LOSS_PCT: '0.01'
TRADING_REGIME_GATE_ENABLED: '1'
TRADING_REGIME_GATE_P_MIN: '0.5'
TRADING_REQUIRE_ABOVE_VWAP: '1'
TRADING_REWARD_MULTIPLE: '10.0'
TRADING_RISK_PER_TRADE_PCT: '0.01'
TRADING_SESSION_ONLY: '1'
TRADING_TRIGGER_SLIP_PCT: '0.004'
TRADING_UNIFIED_ENTRY: '1'
TRIGGER_ARM_INTERVAL_SECONDS: '20'
TRIGGER_ENABLED: '1'
TRIGGER_GAP_MAX: '35'
TRIGGER_GAP_MIN: '10.0'
TRIGGER_GAP_SQUEEZE_MIN: '150'
TRIGGER_GAP_SQUEEZE_RVOL: '5.0'
TRIGGER_INTERVAL_SECONDS: '4'
TRIGGER_MAX_ARMED: '10'
TRIGGER_MIN_DOLLAR_VOL: '8000'
TRIGGER_PRICE_MAX_AGE: '12'
TRIGGER_RVOL_MIN: '2.0'
TZ: America/New_York
WATCHER_ENABLED: '1'
WATCHER_INTERVAL_SECONDS: '15'
WATCHER_PRICE_MAX: '20'
WATCHER_PRICE_MIN: '2'
YOUTUBE_API_KEY: <FILL-ME-YOUTUBE-KEY>
volumes:
- /mnt/volume/Apps/momentum/data:/app/data
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
db-backup:
image: postgres:18
restart: unless-stopped
environment:
DATABASE_URL: postgresql://momentum:<FILL-ME-PG-PASSWORD>@postgres:5432/momentum
BACKUP_DIR: /backups
BACKUP_KEEP_DAYS: '14'
TZ: America/New_York
volumes:
- /mnt/volume/Apps/momentum/data/backups:/backups
depends_on:
postgres:
condition: service_healthy
command:
- /bin/bash
- -c
- "set -o pipefail\necho \"[backup] up; fires 20:10 ET Mon-Fri\"\nwhile true; do\n if [ \"$$(date +%H:%M)\" = \"20:10\" ] && [ \"$$(date +%u)\" -le 5 ]; then\n out=\"$$BACKUP_DIR/momentum-$$(date +%F).sql.gz\"\n mkdir -p \"$$BACKUP_DIR\" && pg_dump \"$$DATABASE_URL\" | gzip > \"$$out.tmp\" && gunzip -t \"$$out.tmp\" && mv \"$$out.tmp\" \"$$out\" && echo \"[backup] OK\" || echo \"[backup] FAILED\"\n sleep 120\n fi\n sleep 20\ndone\n"
+37 -4
View File
@@ -24,6 +24,28 @@ class GapperCandidate:
vwap: float | None vwap: float | None
prev_close: float | None = None # for the STATIC overnight gap (open vs prev close) prev_close: float | None = None # for the STATIC overnight gap (open vs prev close)
rank: int = 0 rank: int = 0
# Time-of-day rvol: cum volume vs MEDIAN(20d) prorated by session elapsed time.
# The legacy relative_volume divides cum-so-far by the full-day MEAN, which is
# (a) ~0.1-0.3 for a NORMAL heavy name minutes after the open (cum can't reach
# a full-day average yet) and (b) poisoned by reverse-split / prior-squeeze
# days in the mean (BIYA 07-20: legacy 1.27 vs tod 481 — median 1.5M, mean
# 29.4M). None when no baseline. Legacy relative_volume is kept untouched —
# validated boundaries (gap-squeeze rvol>=5) are calibrated in its units.
relative_volume_tod: float | None = None
def _session_elapsed_fraction(now: datetime | None = None) -> float:
"""Fraction of the 390-min regular session elapsed, floored at 0.05 so a
premarket/at-open scan doesn't divide by ~zero expected volume."""
try:
from zoneinfo import ZoneInfo
et = (now or datetime.now(ZoneInfo("America/New_York")))
if et.tzinfo is not None:
et = et.astimezone(ZoneInfo("America/New_York"))
elapsed = (et.hour - 9) * 60 + (et.minute - 30)
except Exception: # noqa: BLE001 — fall back to full-day (legacy behaviour)
return 1.0
return min(1.0, max(0.05, elapsed / 390.0))
def scan_gappers( def scan_gappers(
@@ -34,6 +56,7 @@ def scan_gappers(
price_min: float = 1.0, price_min: float = 1.0,
price_max: float = 20.0, price_max: float = 20.0,
limit: int = 25, limit: int = 25,
now: datetime | None = None,
) -> list[GapperCandidate]: ) -> list[GapperCandidate]:
"""Find symbols gapping up on elevated volume for a session.""" """Find symbols gapping up on elevated volume for a session."""
rows = con.execute( rows = con.execute(
@@ -56,7 +79,8 @@ def scan_gappers(
WHERE trade_date < ? WHERE trade_date < ?
), ),
avg_vol AS ( avg_vol AS (
SELECT symbol, AVG(volume) AS adv SELECT symbol, AVG(volume) AS adv,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY volume) AS med_vol
FROM ( FROM (
SELECT symbol, volume, SELECT symbol, volume,
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
@@ -69,7 +93,7 @@ def scan_gappers(
GROUP BY symbol GROUP BY symbol
) )
SELECT l.symbol, l.close, l.vwap, l.cum_volume, SELECT l.symbol, l.close, l.vwap, l.cum_volume,
p.prev_close, a.adv p.prev_close, a.adv, a.med_vol
FROM latest l FROM latest l
JOIN prev p ON p.symbol = l.symbol AND p.rn = 1 JOIN prev p ON p.symbol = l.symbol AND p.rn = 1
LEFT JOIN avg_vol a ON a.symbol = l.symbol LEFT JOIN avg_vol a ON a.symbol = l.symbol
@@ -78,13 +102,21 @@ def scan_gappers(
[session_date, session_date, session_date, price_min, price_max], [session_date, session_date, session_date, price_min, price_max],
).fetchall() ).fetchall()
frac = _session_elapsed_fraction(now)
candidates: list[GapperCandidate] = [] candidates: list[GapperCandidate] = []
for symbol, close, vwap, cum_volume, prev_close, adv in rows: for symbol, close, vwap, cum_volume, prev_close, adv, med_vol in rows:
if not prev_close or prev_close <= 0: if not prev_close or prev_close <= 0:
continue continue
gap_pct = (float(close) - float(prev_close)) / float(prev_close) * 100.0 gap_pct = (float(close) - float(prev_close)) / float(prev_close) * 100.0
rvol = (float(cum_volume) / float(adv)) if adv and adv > 0 else 0.0 rvol = (float(cum_volume) / float(adv)) if adv and adv > 0 else 0.0
if gap_pct >= min_gap_pct and rvol >= min_relative_volume: rvol_tod = (float(cum_volume) / (float(med_vol) * frac)
if med_vol and float(med_vol) > 0 else None)
# either rvol flavour clears the bar: legacy is ~0.1-0.3 early in the
# session even for a heavy name (cum-so-far vs full-day mean), so the
# tod variant keeps real early movers from being silently dropped
if gap_pct >= min_gap_pct and (
rvol >= min_relative_volume
or (rvol_tod or 0.0) >= min_relative_volume):
candidates.append( candidates.append(
GapperCandidate( GapperCandidate(
symbol=symbol, symbol=symbol,
@@ -94,6 +126,7 @@ def scan_gappers(
cumulative_volume=int(cum_volume), cumulative_volume=int(cum_volume),
relative_volume=rvol, relative_volume=rvol,
vwap=float(vwap) if vwap is not None else None, vwap=float(vwap) if vwap is not None else None,
relative_volume_tod=rvol_tod,
) )
) )
candidates.sort(key=lambda c: (c.gap_pct * max(c.relative_volume, 0.1)), reverse=True) candidates.sort(key=lambda c: (c.gap_pct * max(c.relative_volume, 0.1)), reverse=True)
+1
View File
@@ -960,6 +960,7 @@ def main(argv: list[str] | None = None) -> int:
"gap_pct": g.gap_pct, "gap_pct": g.gap_pct,
"gap_open_pct": gap_open_pct, "gap_open_pct": gap_open_pct,
"rvol": g.relative_volume, "rvol": g.relative_volume,
"rvol_tod": g.relative_volume_tod,
"trigger": hi, "trigger": hi,
"stop": stop, "stop": stop,
"range_pct": ((hi - lo) / hi) if (hi and lo and hi > 0) else 0.0, "range_pct": ((hi - lo) / hi) if (hi and lo and hi > 0) else 0.0,
+11 -1
View File
@@ -56,6 +56,12 @@ class ArmedTrigger:
gap_pct: float = 0.0 # DYNAMIC gap (latest vs prev close) — display/rank only gap_pct: float = 0.0 # DYNAMIC gap (latest vs prev close) — display/rank only
gap_open_pct: float | None = None # STATIC overnight gap (session open vs prev close) gap_open_pct: float | None = None # STATIC overnight gap (session open vs prev close)
rvol: float = 0.0 rvol: float = 0.0
# time-of-day rvol (cum vs median(20d) prorated by elapsed session) — the arm
# eligibility signal. Legacy rvol is full-day-mean based: ~0.1-0.3 minutes
# after the open for a NORMAL heavy name, so rvol_min=2 vs legacy armed almost
# nothing early (07-20: IREX 0.76/IRE 0.58/BIYA 1.27 all stuck "weak" while
# breaking out; tod = 16.8/11.9/481). None -> fall back to legacy rvol.
rvol_tod: float | None = None
trigger: float | None = None # opening-range high (entry breakout level) trigger: float | None = None # opening-range high (entry breakout level)
stop: float | None = None # opening-range low, cushioned (protective stop) stop: float | None = None # opening-range low, cushioned (protective stop)
range_pct: float = 0.0 # (high-low)/high — how wide the opening range is range_pct: float = 0.0 # (high-low)/high — how wide the opening range is
@@ -86,6 +92,7 @@ class ArmedTrigger:
"rank": self.rank, "rank": self.rank,
"gap": round(self.gap_pct, 2), "gap": round(self.gap_pct, 2),
"rvol": round(self.rvol, 2), "rvol": round(self.rvol, 2),
"rvol_tod": round(self.rvol_tod, 2) if self.rvol_tod is not None else None,
"trigger": round(self.trigger, 4) if self.trigger else None, "trigger": round(self.trigger, 4) if self.trigger else None,
"stop": round(self.stop, 4) if self.stop else None, "stop": round(self.stop, 4) if self.stop else None,
"price": round(self.price, 4) if self.price else None, "price": round(self.price, 4) if self.price else None,
@@ -140,7 +147,8 @@ class ArmedTriggerBook:
and t.stop is not None and t.stop is not None
and t.stop < t.trigger and t.stop < t.trigger
and self._gap_ok(t) and self._gap_ok(t)
and t.rvol >= self.rvol_min # tod rvol when the scanner provides it; legacy full-day-mean otherwise
and (t.rvol_tod if t.rvol_tod is not None else t.rvol) >= self.rvol_min
and t.range_pct >= self.min_range_pct and t.range_pct >= self.min_range_pct
and t.dollar_vol >= self.min_dollar_vol and t.dollar_vol >= self.min_dollar_vol
) )
@@ -172,6 +180,8 @@ class ArmedTriggerBook:
_go = c.get("gap_open_pct") _go = c.get("gap_open_pct")
t.gap_open_pct = float(_go) if _go is not None else None t.gap_open_pct = float(_go) if _go is not None else None
t.rvol = float(c.get("rvol") or 0.0) t.rvol = float(c.get("rvol") or 0.0)
_rt = c.get("rvol_tod")
t.rvol_tod = float(_rt) if _rt is not None else None
t.trigger = c.get("trigger") t.trigger = c.get("trigger")
t.stop = c.get("stop") t.stop = c.get("stop")
t.range_pct = float(c.get("range_pct") or 0.0) t.range_pct = float(c.get("range_pct") or 0.0)
+25 -1
View File
@@ -89,6 +89,14 @@ class ExitConfig:
# filled bracket's legs can attach first). 0 = off. The preventive complement # filled bracket's legs can attach first). 0 = off. The preventive complement
# to the catastrophe stop (which is the -X% backstop). # to the catastrophe stop (which is the -X% backstop).
enforce_stop_grace_passes: int = 2 enforce_stop_grace_passes: int = 2
# STAGNATION TIME-STOP: if after stagnation_minutes bars the trade's high-
# water never reached entry + stagnation_min_r * risk, exit at market. The
# dead-money case: bought the break, it neither ran nor stopped — capital and
# attention sit parked in a name that has told us the breakout didn't follow
# through. 0 = off (default; enable only after a sweep_exits.py validation —
# exit levers here have a long refuted-lever history).
stagnation_minutes: int = 0
stagnation_min_r: float = 0.25
# A held position at BREAKEVEN+ with no real R to trail from (a carried position, or # A held position at BREAKEVEN+ with no real R to trail from (a carried position, or
# the bracket stop was stripped/invalid) has its +0.25R trail RE-ANCHORED to today's # the bracket stop was stripped/invalid) has its +0.25R trail RE-ANCHORED to today's
# opening range by the exit manager. This is the PRE-OPENING-RANGE FALLBACK R (a # opening range by the exit manager. This is the PRE-OPENING-RANGE FALLBACK R (a
@@ -126,6 +134,8 @@ class ExitConfig:
catastrophe_pct=f("TRADING_CATASTROPHE_STOP_PCT", "0.10"), catastrophe_pct=f("TRADING_CATASTROPHE_STOP_PCT", "0.10"),
catastrophe_risk_mult=f("TRADING_CATASTROPHE_RISK_MULT", "1.5"), catastrophe_risk_mult=f("TRADING_CATASTROPHE_RISK_MULT", "1.5"),
enforce_stop_grace_passes=int(f("TRADING_ENFORCE_STOP_GRACE_PASSES", "2")), enforce_stop_grace_passes=int(f("TRADING_ENFORCE_STOP_GRACE_PASSES", "2")),
stagnation_minutes=int(f("TRADING_EXIT_STAGNATION_MIN", "0")),
stagnation_min_r=f("TRADING_EXIT_STAGNATION_R", "0.25"),
# clamp to [0, 1): a synthetic risk distance must be a positive fraction of # clamp to [0, 1): a synthetic risk distance must be a positive fraction of
# entry below it; >=1 or negative would put the synthetic stop at/above entry. # entry below it; >=1 or negative would put the synthetic stop at/above entry.
default_trail_r_pct=min(max(f("TRADING_EXIT_DEFAULT_TRAIL_R_PCT", "0.10"), 0.0), 0.95), default_trail_r_pct=min(max(f("TRADING_EXIT_DEFAULT_TRAIL_R_PCT", "0.10"), 0.0), 0.95),
@@ -287,7 +297,15 @@ def simulate_exit(
fills.append(ExitFill(remaining, close, "first_red")) fills.append(ExitFill(remaining, close, "first_red"))
return ExitResult(realized_r, "first_red", idx, fills) return ExitResult(realized_r, "first_red", idx, fills)
# (5) ratchet the stop for the NEXT bar # (5) stagnation time-stop: N minutes in and the move never came
if (cfg.stagnation_minutes and idx + 1 >= cfg.stagnation_minutes
and reached_r < cfg.stagnation_min_r):
r = (close - entry_price) / risk
realized_r += remaining * r
fills.append(ExitFill(remaining, close, "stagnation"))
return ExitResult(realized_r, "stagnation", idx, fills)
# (6) ratchet the stop for the NEXT bar
stop = _trail_stop(stop, entry_price, high_water, low, reached_r, cfg) stop = _trail_stop(stop, entry_price, high_water, low, reached_r, cfg)
prev_low = low prev_low = low
@@ -340,6 +358,12 @@ def manage_live(
if last_close < prior_low and reached_r >= cfg.trail_after_r: if last_close < prior_low and reached_r >= cfg.trail_after_r:
return ExitDecision(stop, 0.0, True, "first_red") return ExitDecision(stop, 0.0, True, "first_red")
# stagnation time-stop: N closed bars in and high-water never reached the
# minimum R — dead money, exit outright (same rule as simulate_exit)
if (cfg.stagnation_minutes and len(bars_since_entry) >= cfg.stagnation_minutes
and reached_r < cfg.stagnation_min_r):
return ExitDecision(stop, 0.0, True, "stagnation")
# scale-out: high reached the scale level and we haven't yet # scale-out: high reached the scale level and we haven't yet
scale_frac = 0.0 scale_frac = 0.0
if (cfg.scale_out_r and not already_scaled if (cfg.scale_out_r and not already_scaled
+5
View File
@@ -80,6 +80,11 @@ def variants() -> dict[str, ExitConfig]:
profit_lock_tiers=[(.12, .05), (.25, .16), (.40, .30)]) profit_lock_tiers=[(.12, .05), (.25, .16), (.40, .30)])
v["lock_only_mid"] = ExitConfig(target_r=10.0, v["lock_only_mid"] = ExitConfig(target_r=10.0,
profit_lock_tiers=[(.08, .03), (.15, .09), (.25, .18), (.40, .30)]) profit_lock_tiers=[(.08, .03), (.15, .09), (.25, .18), (.40, .30)])
# --- STAGNATION time-stop layered on the live trail: exit if high-water
# never reached +min_r within N minutes (dead-money reclaim) ---
for mins, min_r in ((15, 0.25), (30, 0.25), (45, 0.25), (60, 0.25), (30, 0.5)):
v[f"stag{mins}_r{int(min_r*100)}"] = ExitConfig(
**_trail, stagnation_minutes=mins, stagnation_min_r=min_r)
return v return v
+16
View File
@@ -140,6 +140,7 @@ def test_gapper_scan_and_snapshot(con):
candidates = scan_gappers(con, SESSION.date(), min_gap_pct=5, min_relative_volume=2) candidates = scan_gappers(con, SESSION.date(), min_gap_pct=5, min_relative_volume=2)
assert candidates and candidates[0].symbol == "GAPR" assert candidates and candidates[0].symbol == "GAPR"
assert candidates[0].gap_pct > 20 assert candidates[0].gap_pct > 20
assert candidates[0].relative_volume_tod is not None
assert store_scanner_snapshot(con, candidates) == len(candidates) assert store_scanner_snapshot(con, candidates) == len(candidates)
rows = con.execute("SELECT symbol, rank FROM scanner_snapshots").fetchall() rows = con.execute("SELECT symbol, rank FROM scanner_snapshots").fetchall()
assert rows[0] == ("GAPR", 1) assert rows[0] == ("GAPR", 1)
@@ -156,3 +157,18 @@ def test_research_watchlist_provider_feeds_watcher(con):
assert candidates[0].previous_close == pytest.approx(4.1) assert candidates[0].previous_close == pytest.approx(4.1)
bars = provider.get_bars("GAPR", SESSION.date()) bars = provider.get_bars("GAPR", SESSION.date())
assert len(bars) == 15 assert len(bars) == 15
def test_gapper_scan_tod_rvol_keeps_early_mover(con):
"""Early-session mover: cum volume is a fraction of the full-day mean (legacy
rvol < 2 -> previously dropped) but far above the time-prorated median."""
from datetime import datetime as _dt
upsert_daily_bars(con, "EARL", [
{"t": iso(SESSION - timedelta(days=2)), "o": 4, "h": 4.5, "l": 3.9, "c": 4.2, "v": 1_000_000},
{"t": iso(SESSION - timedelta(days=1)), "o": 4.2, "h": 4.4, "l": 4.0, "c": 4.1, "v": 900_000},
])
# 10 minutes, 60k shares -> cum 600k = 0.63x mean (legacy fails min 2)
upsert_minute_bars(con, "EARL", minute_payload(SESSION.replace(hour=13, minute=31), 10, base=5.0, volume=60_000))
naive = scan_gappers(con, SESSION.date(), min_gap_pct=5, min_relative_volume=2,
now=_dt(2026, 6, 10, 9, 40)) # 10 min into the session (ET, naive)
assert any(c.symbol == "EARL" for c in naive)
+32
View File
@@ -173,3 +173,35 @@ def test_manage_live_pct_trail():
d = manage_live(10.0, 9.0, bars, d = manage_live(10.0, 9.0, bars,
ExitConfig(trail_mode=TRAIL_PCT, trail_pct=0.05, trail_after_r=1.0)) ExitConfig(trail_mode=TRAIL_PCT, trail_pct=0.05, trail_after_r=1.0))
assert round(d.desired_stop, 2) == round(12.0 * 0.95, 2) assert round(d.desired_stop, 2) == round(12.0 * 0.95, 2)
def test_stagnation_exit_fires_when_flat():
# entry 10, stop 9 (risk 1). 5 flat bars: never reaches +0.25R by minute 5
cfg = ExitConfig(target_r=0, stagnation_minutes=5, stagnation_min_r=0.25)
bars = _bars([(10.1, 9.9, 10.05)] * 8)
res = simulate_exit(10.0, 9.0, bars, cfg)
assert res.reason == "stagnation"
assert res.exit_index == 4 # fired ON minute 5
assert abs(res.r_multiple - 0.05) < 1e-9 # left with the tiny drift
def test_stagnation_not_triggered_when_move_came():
cfg = ExitConfig(target_r=0, stagnation_minutes=5, stagnation_min_r=0.25)
bars = _bars([(10.30, 9.95, 10.25)] + [(10.1, 9.9, 10.05)] * 10)
res = simulate_exit(10.0, 9.0, bars, cfg) # +0.3R on bar 1 -> armed off
assert res.reason != "stagnation"
def test_stagnation_off_by_default():
bars = _bars([(10.1, 9.9, 10.05)] * 30)
res = simulate_exit(10.0, 9.0, bars, ExitConfig(target_r=0))
assert res.reason == "session_end"
def test_manage_live_stagnation_exits_flat_position():
cfg = ExitConfig(target_r=0, stagnation_minutes=5, stagnation_min_r=0.25)
bars = _bars([(10.1, 9.9, 10.05)] * 5)
d = manage_live(10.0, 9.0, bars, cfg)
assert d.exit_now and d.reason == "stagnation"
d2 = manage_live(10.0, 9.0, _bars([(10.1, 9.9, 10.05)] * 3), cfg)
assert not d2.exit_now # not enough bars yet
+41
View File
@@ -375,6 +375,47 @@ def test_fill_model_marketable_lifts_above_trigger(store):
assert req["stop_loss_price"] == 13.45 # R still measured from the level assert req["stop_loss_price"] == 13.45 # R still measured from the level
def test_fill_model_marketable_prices_from_live_last(store):
# price already ran to 15.10 by the time the slow path submits: the limit
# must chase from the LIVE price, not rest 2% behind the market at the level
emit_signal(store, symbol="RUN", entry=14.0, stop=13.45, above_vwap=True)
svc = make_service(store, entry_fill_model="marketable",
price_provider=lambda s: 15.10)
svc.request_approvals_for_ready_signals()
req = query_approval_queue(store)[0]["execution_request"]
assert req["entry_price"] == round(15.10 * 1.004, 2)
assert req["stop_loss_price"] == 13.45 # R still from the level
def test_fill_model_marketable_price_provider_error_falls_back(store):
def boom(sym):
raise RuntimeError("feed down")
emit_signal(store, symbol="ERR", entry=14.0, stop=13.45, above_vwap=True)
make_service(store, entry_fill_model="marketable",
price_provider=boom).request_approvals_for_ready_signals()
req = query_approval_queue(store)[0]["execution_request"]
assert req["entry_price"] == round(14.0 * 1.004, 2) # trigger-based fallback
def test_fill_model_marketable_live_below_trigger_uses_trigger(store):
# live last dipped under the level -> never price the limit BELOW the trigger
emit_signal(store, symbol="DIP", entry=14.0, stop=13.45, above_vwap=True)
make_service(store, entry_fill_model="marketable",
price_provider=lambda s: 13.80).request_approvals_for_ready_signals()
req = query_approval_queue(store)[0]["execution_request"]
assert req["entry_price"] == round(14.0 * 1.004, 2)
def test_fill_model_marketable_over_extended_live_price_skipped(store):
# price ran >15% past the trigger before we could submit -> anti-chase gate
# (previously inert on the slow path because entry_ref == trigger)
emit_signal(store, symbol="PARA", entry=14.0, stop=13.45, above_vwap=True)
svc = make_service(store, entry_fill_model="marketable", unified_entry=True,
price_provider=lambda s: 16.80)
assert svc.request_approvals_for_ready_signals() == []
assert query_approval_queue(store) == []
def test_no_duplicate_requests_for_same_symbol(store): def test_no_duplicate_requests_for_same_symbol(store):
emit_signal(store) emit_signal(store)
service = make_service(store) service = make_service(store)
+23
View File
@@ -401,3 +401,26 @@ def test_trigger_eligibility_uses_static_open_gap_not_dynamic():
c2["gap_open_pct"] = 19.0 # true gapper stays armed c2["gap_open_pct"] = 19.0 # true gapper stays armed
b.arm([c2]) b.arm([c2])
assert b.triggers["BABU"].state == ARMED assert b.triggers["BABU"].state == ARMED
def test_book_arms_on_rvol_tod_when_legacy_rvol_low():
"""The 07-20 miss: IREX legacy rvol 0.76 minutes after the open (cum-so-far
vs full-day mean can't reach 2x yet) while the time-of-day rvol showed 16.8.
tod must drive eligibility when present."""
b = ArmedTriggerBook(gap_min=3, rvol_min=2)
c = _cand("IREX", rvol=0.76); c["rvol_tod"] = 16.8
b.arm([c])
assert b.triggers["IREX"].state == ARMED
def test_book_weak_when_rvol_tod_low_even_if_present():
b = ArmedTriggerBook(gap_min=3, rvol_min=2)
c = _cand("DEAD", rvol=3.0); c["rvol_tod"] = 0.2 # tod wins when provided
b.arm([c])
assert b.triggers["DEAD"].state == WEAK
def test_book_falls_back_to_legacy_rvol_without_tod():
b = ArmedTriggerBook(gap_min=3, rvol_min=2)
b.arm([_cand("LEGACY", rvol=3.0)]) # no rvol_tod key at all
assert b.triggers["LEGACY"].state == ARMED
+16 -2
View File
@@ -1205,12 +1205,26 @@ class TradingExecutionService:
if not entry or not stop or stop >= entry: if not entry or not stop or stop >= entry:
continue # price-sanity (silent; path-specific float coercion) continue # price-sanity (silent; path-specific float coercion)
entry_f0 = float(entry) entry_f0 = float(entry)
# Marketable fill model prices from the LIVE last trade (the slow path
# runs minutes behind the break — a limit at the level rests behind the
# market and never fills on a runner; observed 6-for-6 unfilled 07-20).
# live_ref also feeds entry_ref so the anti-chase extension gate bites
# on the slow path instead of being an entry_ref==trigger no-op.
live_ref = entry_f0
if (self.settings.entry_fill_model == "marketable"
and self.price_provider is not None):
try:
last = self.price_provider(symbol)
except Exception: # noqa: BLE001 — feed hiccup: fall back to level
last = None
if last:
live_ref = max(entry_f0, float(last))
# ONE shared gate sequence (plan 005). The slow path rests a limit AT the # ONE shared gate sequence (plan 005). The slow path rests a limit AT the
# level, so entry_ref == trigger == the entry level. rank arrives from the # level, so entry_ref == trigger == the entry level. rank arrives from the
# trigger book via rank_map (None -> factor 1.0, no-op). # trigger book via rank_map (None -> factor 1.0, no-op).
decision = self._evaluate_entry_gates(EntryContext( decision = self._evaluate_entry_gates(EntryContext(
symbol=symbol, source="auto", entry=entry_f0, stop=float(stop), symbol=symbol, source="auto", entry=entry_f0, stop=float(stop),
trigger=entry_f0, entry_ref=entry_f0, trigger=entry_f0, entry_ref=live_ref,
gap_pct=signal.get("gap_pct"), price=entry_f0, gap_pct=signal.get("gap_pct"), price=entry_f0,
cum_volume=signal.get("cum_volume"), cum_volume=signal.get("cum_volume"),
rvol=signal.get("relative_volume"), rvol=signal.get("relative_volume"),
@@ -1265,7 +1279,7 @@ class TradingExecutionService:
# caps the fill price either way, so a gap-through can't be chased. R # caps the fill price either way, so a gap-through can't be chased. R
# (stop/target) is measured from the level (entry_f), not the limit. # (stop/target) is measured from the level (entry_f), not the limit.
limit_price = ( limit_price = (
round(entry_f * (1.0 + self.settings.trigger_slippage_pct), 2) round(live_ref * (1.0 + self.settings.trigger_slippage_pct), 2)
if self.settings.entry_fill_model == "marketable" else entry_f if self.settings.entry_fill_model == "marketable" else entry_f
) )
request = ExecutionRequest( request = ExecutionRequest(