Crypto Monitor — crypto #129
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Crypto Monitor | |
| # Crypto cross-pair conversion signals (see `watchingCrypto` in config.json). | |
| # | |
| # Deliberately a SEPARATE workflow from portfolio-monitor.yml. Its `state/` | |
| # cache is shared across its own runs, so folding crypto in would let a crypto | |
| # run clobber the equity morning baseline; this workflow caches only its own | |
| # baseline file. | |
| # | |
| # It used to be separate for two further reasons, both since designed out: that | |
| # workflow's `send-weekly` job fired on every schedule tick (it no longer | |
| # exists — the Worker dispatches `weekly` explicitly), and its "Determine mode" | |
| # step mapped any non-daily schedule to `intraday` (mode is now named by the | |
| # dispatch event type, so an unknown tick cannot silently become an equity run). | |
| # The cache is reason enough on its own to keep them apart. | |
| # | |
| # Unlike equities, these instruments trade 24/7, so a price move between runs is | |
| # always real — which is what makes a high cadence worth having. The daily | |
| # candles still only close once a day, so the indicators are identical between | |
| # runs; `cryptoAlerts.minPriceMovePctToAlert` is the guard that keeps that from | |
| # turning into whipsaw noise. | |
| # Scheduling moved to the Cloudflare Worker in scheduler/ — see the header of | |
| # portfolio-monitor.yml for why. This workflow was hit harder than the equity | |
| # one: over 2026-08-27..29 GitHub delivered only 2-3 of its 8 daily runs, which | |
| # breaks more than punctuality here. The first run of the UTC day is the day's | |
| # comparison anchor (CRYPTO_ANCHOR_HOUR_UTC in src/index.ts), so dropping runs | |
| # and shifting the survivors by hours means the anchor is repeatedly | |
| # re-established mid-day and the 3-hourly comparisons stop being 3-hourly. | |
| run-name: >- | |
| Crypto Monitor — | |
| ${{ github.event_name == 'repository_dispatch' && github.event.action | |
| || github.event_name == 'workflow_dispatch' && format('{0} (manual)', github.event.inputs.mode) | |
| || github.event_name }} | |
| on: | |
| repository_dispatch: | |
| # Fired by scheduler/ every 3 hours: 00,03,06,09,12,15,18,21 UTC. Still no | |
| # collision with portfolio-monitor.yml's 22:00 daily, 22:30 weekly, or its | |
| # 03:15/07:15/11:15/14:15 intraday slots. | |
| types: [crypto] | |
| workflow_dispatch: | |
| inputs: | |
| mode: | |
| description: "Run mode" | |
| required: false | |
| default: "crypto" | |
| type: choice | |
| options: | |
| - crypto | |
| # Contract check against crypto.com's public API. Run this from a | |
| # runner before trusting the schedule: crypto.com geo-restricts | |
| # trading for US residents, and while market data has never been | |
| # observed blocked, GitHub's runners egress from Azure US. | |
| - smoke | |
| # See portfolio-monitor.yml: the user-facing Actions Variable is TIME_ZONE, | |
| # mapped to POSIX TZ which Node respects natively. | |
| env: | |
| TZ: ${{ vars.TIME_ZONE || 'UTC' }} | |
| # A run can outlast its 3-hour slot if the Claude subscription transport is slow | |
| # (it spawns a Claude Code subprocess per stage). Let the in-flight run finish | |
| # rather than starting a second one against the same baseline. | |
| concurrency: | |
| group: crypto-monitor | |
| cancel-in-progress: false | |
| jobs: | |
| check-crypto: | |
| runs-on: ubuntu-latest | |
| # Sized for the Claude subscription transport, which is far slower than the | |
| # API-key path. Only two tickers, so well short of the equity job's 90. | |
| timeout-minutes: 60 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - run: npm ci | |
| # Write config.json from variable (gitignored, contains portfolio data) | |
| - run: printenv CONFIG_JSON > config.json | |
| env: | |
| CONFIG_JSON: ${{ vars.CONFIG_JSON }} | |
| # Only this workflow's own baseline is cached. Scoping the path (rather | |
| # than all of state/) is what keeps a crypto run from overwriting the | |
| # equity morning baseline that portfolio-monitor.yml depends on. | |
| - uses: actions/cache@v5 | |
| with: | |
| path: state/crypto-baseline.json | |
| key: crypto-state-${{ github.run_id }} | |
| restore-keys: | | |
| crypto-state- | |
| - name: Contract smoke test | |
| if: github.event.inputs.mode == 'smoke' | |
| run: npm run smoke:crypto | |
| # `github.event.inputs` is null for repository_dispatch, so this is the | |
| # branch every scheduled run takes; only an explicit manual `smoke` | |
| # selection goes the other way. | |
| - name: Crypto cross-pair check | |
| if: github.event.inputs.mode != 'smoke' | |
| run: npm run crypto | |
| env: | |
| # Separate Gemini key so the 8x/day cadence has its own ~20 req/day | |
| # quota instead of competing with the equity schedule's. | |
| GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY_CRYPTO }} | |
| # Required, not cosmetic: Google retires models for NEW keys first, and | |
| # a freshly created key gets `404 gemini-2.5-flash is no longer | |
| # available to new users` while older keys on the same model keep | |
| # working. `gemini-flash-latest` is an alias tracking the current Flash. | |
| GEMINI_MODEL: ${{ vars.GEMINI_MODEL_CRYPTO || 'gemini-flash-latest' }} | |
| # Same subscription identity as the equity runs — one token, no | |
| # per-token cost. ANTHROPIC_API_KEY is deliberately NOT passed: it | |
| # outranks OAuth inside Claude Code and would silently bill credits. | |
| CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} | |
| CLAUDE_MODEL: ${{ vars.CLAUDE_MODEL }} | |
| MISTRAL_MODEL: ${{ vars.MISTRAL_MODEL }} | |
| # Pinned to Mistral so the detailed-analysis page (one call per | |
| # STRONG BUY ticker) never eats Gemini's small daily quota, which the | |
| # two Observe/Decide calls x 8 runs already nearly exhausts. | |
| AI_DETAILED_PROVIDER: mistral | |
| RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} | |
| RECIPIENT_EMAIL: ${{ vars.RECIPIENT_EMAIL }} | |
| TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} | |
| TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} | |
| # No social credentials: conversion signals on thin books are personal, | |
| # and src/index.ts does not post from this mode regardless. |