Skip to content

Crypto Monitor — crypto #129

Crypto Monitor — crypto

Crypto Monitor — crypto #129

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.