From 5597250ff1cadbb1393e7d66c15c812435787aec Mon Sep 17 00:00:00 2001 From: momentum dev Date: Mon, 20 Jul 2026 15:40:15 -0500 Subject: [PATCH] fix: marketable entries price from live last + time-of-day rvol arms fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- deploy/docker/truenas-compose.flat.yaml | 687 ++++++++++++++++++++++++ research/ingestion/signals.py | 41 +- run_live_paper.py | 1 + runtime/triggers.py | 12 +- strategy/exits.py | 26 +- sweep_exits.py | 5 + tests/unit/research/test_ingestion.py | 16 + tests/unit/test_exits.py | 32 ++ tests/unit/test_trading_execution.py | 41 ++ tests/unit/test_triggers.py | 23 + trading_execution.py | 18 +- 11 files changed, 894 insertions(+), 8 deletions(-) create mode 100644 deploy/docker/truenas-compose.flat.yaml diff --git a/deploy/docker/truenas-compose.flat.yaml b/deploy/docker/truenas-compose.flat.yaml new file mode 100644 index 0000000..d02f506 --- /dev/null +++ b/deploy/docker/truenas-compose.flat.yaml @@ -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 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: + 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: + 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: + DATABASE_URL: postgresql://momentum:@postgres:5432/momentum + DEADMAN_DAILY_PING: '1' + DEADMAN_STALE_MIN: '5' + DISCOVER_INTERVAL_SECONDS: '120' + FMP_API_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: + 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: + 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: + 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: + DATABASE_URL: postgresql://momentum:@postgres:5432/momentum + DEADMAN_DAILY_PING: '1' + DEADMAN_STALE_MIN: '5' + DISCOVER_INTERVAL_SECONDS: '120' + FMP_API_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: + 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: + DASHBOARD_HOST: 0.0.0.0 + DASHBOARD_PORT: '8765' + 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: + 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: + DATABASE_URL: postgresql://momentum:@postgres:5432/momentum + DEADMAN_DAILY_PING: '1' + DEADMAN_STALE_MIN: '5' + DISCOVER_INTERVAL_SECONDS: '120' + FMP_API_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: + 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: + 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: + 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: + DATABASE_URL: postgresql://momentum:@postgres:5432/momentum + DEADMAN_DAILY_PING: '1' + DEADMAN_STALE_MIN: '5' + DISCOVER_INTERVAL_SECONDS: '120' + FMP_API_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: + 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: + 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: + 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: + DATABASE_URL: postgresql://momentum:@postgres:5432/momentum + DEADMAN_DAILY_PING: '1' + DEADMAN_STALE_MIN: '5' + DISCOVER_INTERVAL_SECONDS: '120' + FMP_API_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: + 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: + ADMIN_PORT: '8767' + 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: + 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: + DATABASE_URL: postgresql://momentum:@postgres:5432/momentum + DEADMAN_DAILY_PING: '1' + DEADMAN_STALE_MIN: '5' + DISCOVER_INTERVAL_SECONDS: '120' + FMP_API_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: + 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: + 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:@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" + diff --git a/research/ingestion/signals.py b/research/ingestion/signals.py index 0220748..3215c1a 100644 --- a/research/ingestion/signals.py +++ b/research/ingestion/signals.py @@ -24,6 +24,28 @@ class GapperCandidate: vwap: float | None prev_close: float | None = None # for the STATIC overnight gap (open vs prev close) 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( @@ -34,6 +56,7 @@ def scan_gappers( price_min: float = 1.0, price_max: float = 20.0, limit: int = 25, + now: datetime | None = None, ) -> list[GapperCandidate]: """Find symbols gapping up on elevated volume for a session.""" rows = con.execute( @@ -56,7 +79,8 @@ def scan_gappers( WHERE trade_date < ? ), 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 ( SELECT symbol, volume, ROW_NUMBER() OVER ( @@ -69,7 +93,7 @@ def scan_gappers( GROUP BY symbol ) 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 JOIN prev p ON p.symbol = l.symbol AND p.rn = 1 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], ).fetchall() + frac = _session_elapsed_fraction(now) 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: continue 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 - 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( GapperCandidate( symbol=symbol, @@ -94,6 +126,7 @@ def scan_gappers( cumulative_volume=int(cum_volume), relative_volume=rvol, 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) diff --git a/run_live_paper.py b/run_live_paper.py index b7f83de..3f90102 100644 --- a/run_live_paper.py +++ b/run_live_paper.py @@ -960,6 +960,7 @@ def main(argv: list[str] | None = None) -> int: "gap_pct": g.gap_pct, "gap_open_pct": gap_open_pct, "rvol": g.relative_volume, + "rvol_tod": g.relative_volume_tod, "trigger": hi, "stop": stop, "range_pct": ((hi - lo) / hi) if (hi and lo and hi > 0) else 0.0, diff --git a/runtime/triggers.py b/runtime/triggers.py index 022c4eb..063d350 100644 --- a/runtime/triggers.py +++ b/runtime/triggers.py @@ -56,6 +56,12 @@ class ArmedTrigger: 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) 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) stop: float | None = None # opening-range low, cushioned (protective stop) range_pct: float = 0.0 # (high-low)/high — how wide the opening range is @@ -86,6 +92,7 @@ class ArmedTrigger: "rank": self.rank, "gap": round(self.gap_pct, 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, "stop": round(self.stop, 4) if self.stop 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 < t.trigger 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.dollar_vol >= self.min_dollar_vol ) @@ -172,6 +180,8 @@ class ArmedTriggerBook: _go = c.get("gap_open_pct") t.gap_open_pct = float(_go) if _go is not None else None 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.stop = c.get("stop") t.range_pct = float(c.get("range_pct") or 0.0) diff --git a/strategy/exits.py b/strategy/exits.py index 750641e..3aac51c 100644 --- a/strategy/exits.py +++ b/strategy/exits.py @@ -89,6 +89,14 @@ class ExitConfig: # filled bracket's legs can attach first). 0 = off. The preventive complement # to the catastrophe stop (which is the -X% backstop). 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 # 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 @@ -126,6 +134,8 @@ class ExitConfig: catastrophe_pct=f("TRADING_CATASTROPHE_STOP_PCT", "0.10"), catastrophe_risk_mult=f("TRADING_CATASTROPHE_RISK_MULT", "1.5"), 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 # 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), @@ -287,7 +297,15 @@ def simulate_exit( fills.append(ExitFill(remaining, close, "first_red")) 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) prev_low = low @@ -340,6 +358,12 @@ def manage_live( if last_close < prior_low and reached_r >= cfg.trail_after_r: 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_frac = 0.0 if (cfg.scale_out_r and not already_scaled diff --git a/sweep_exits.py b/sweep_exits.py index 79c4638..fbbb5a4 100644 --- a/sweep_exits.py +++ b/sweep_exits.py @@ -80,6 +80,11 @@ def variants() -> dict[str, ExitConfig]: profit_lock_tiers=[(.12, .05), (.25, .16), (.40, .30)]) v["lock_only_mid"] = ExitConfig(target_r=10.0, 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 diff --git a/tests/unit/research/test_ingestion.py b/tests/unit/research/test_ingestion.py index cca7898..7c9ac28 100644 --- a/tests/unit/research/test_ingestion.py +++ b/tests/unit/research/test_ingestion.py @@ -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) assert candidates and candidates[0].symbol == "GAPR" assert candidates[0].gap_pct > 20 + assert candidates[0].relative_volume_tod is not None assert store_scanner_snapshot(con, candidates) == len(candidates) rows = con.execute("SELECT symbol, rank FROM scanner_snapshots").fetchall() 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) bars = provider.get_bars("GAPR", SESSION.date()) 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) diff --git a/tests/unit/test_exits.py b/tests/unit/test_exits.py index ac6acf7..5bc19d4 100644 --- a/tests/unit/test_exits.py +++ b/tests/unit/test_exits.py @@ -173,3 +173,35 @@ def test_manage_live_pct_trail(): d = manage_live(10.0, 9.0, bars, 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) + + +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 diff --git a/tests/unit/test_trading_execution.py b/tests/unit/test_trading_execution.py index 085f82a..e91de83 100644 --- a/tests/unit/test_trading_execution.py +++ b/tests/unit/test_trading_execution.py @@ -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 +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): emit_signal(store) service = make_service(store) diff --git a/tests/unit/test_triggers.py b/tests/unit/test_triggers.py index d6e5316..3b13ed4 100644 --- a/tests/unit/test_triggers.py +++ b/tests/unit/test_triggers.py @@ -401,3 +401,26 @@ def test_trigger_eligibility_uses_static_open_gap_not_dynamic(): c2["gap_open_pct"] = 19.0 # true gapper stays armed b.arm([c2]) 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 diff --git a/trading_execution.py b/trading_execution.py index a564c33..f471d91 100644 --- a/trading_execution.py +++ b/trading_execution.py @@ -1205,12 +1205,26 @@ class TradingExecutionService: if not entry or not stop or stop >= entry: continue # price-sanity (silent; path-specific float coercion) 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 # level, so entry_ref == trigger == the entry level. rank arrives from the # trigger book via rank_map (None -> factor 1.0, no-op). decision = self._evaluate_entry_gates(EntryContext( 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, cum_volume=signal.get("cum_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 # (stop/target) is measured from the level (entry_f), not the limit. 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 ) request = ExecutionRequest(