Skip to content

Portfolio Monitor — intraday #711

Portfolio Monitor — intraday

Portfolio Monitor — intraday #711

name: Portfolio Monitor
# SCHEDULING LIVES IN scheduler/ (a Cloudflare Worker), NOT HERE.
#
# GitHub's `schedule:` event was measured firing this workflow's 22:00 UTC daily
# brief 5–8 hours late, and dropping it entirely on 2026-08-26. That is
# documented GitHub behaviour ("may be delayed during periods of high load…"),
# not an outage, and GitHub staff acknowledged the worsening drift in community
# discussion #196910 with no committed fix. The Worker fires these runs via
# `repository_dispatch` instead; see scheduler/README.md.
#
# The `schedule:` triggers were REMOVED rather than kept as a fallback. GitHub
# does eventually deliver a late cron, which would then run a second, duplicate
# brief — and `daily`/`intraday` post publicly to X / Facebook / LinkedIn /
# Threads, so a duplicate means duplicate public posts, not just a second email.
# `workflow_dispatch` below is the manual recovery path if the Worker ever fails.
#
# NOTE: repository_dispatch always runs the workflow from the DEFAULT BRANCH,
# regardless of what the payload says. That suits this repo (all work lands on
# main) but means changes here only take effect once pushed to main.
# Every run used to be titled "Portfolio Monitor" in the Actions UI, which hid
# exactly the information needed to diagnose the drift above — which cron fired,
# and what mode it resolved to. Surface it in the run title instead.
run-name: >-
Portfolio 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:
# The event type IS the run mode — see "Determine mode" below. Anything not
# listed here cannot trigger this workflow at all, which is what keeps
# `github.event.action` a closed set of known-safe values.
types: [daily, intraday, weekly]
workflow_dispatch:
inputs:
mode:
description: "Run mode"
required: false
default: "daily"
type: choice
options:
- daily
- weekly
- intraday
- refresh
ticker:
description: "Ticker for refresh mode (e.g., SMH)"
required: false
type: string
# Workflow-level env applies to every step in every job. The user-facing
# Actions Variable is `TIME_ZONE` (matches the rest of richfolio's env
# naming style); we map it to POSIX `TZ` here, which Node.js natively
# respects so all date formatting in emails/Telegram renders in the
# configured zone with zero code changes. Defaults to UTC if unset.
env:
TZ: ${{ vars.TIME_ZONE || 'UTC' }}
jobs:
send-brief:
runs-on: ubuntu-latest
# The subscription transport spawns a Claude Code subprocess per AI stage,
# which runs far longer than the 1-2 minutes the api-key path took. A full
# daily run has been observed taking 40+ minutes even while failing
# partway, so 45 (sized from a ~13-minute refresh run) was too tight.
timeout-minutes: 90
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 }}
# Restore intraday state cache (morning baseline for comparison)
- uses: actions/cache@v5
with:
path: state/
key: intraday-state-${{ github.run_id }}
restore-keys: |
intraday-state-
# Determine run mode from the dispatch event type or manual input.
#
# Values reach the shell through env rather than `${{ }}` interpolation so
# they can never be spliced into the script itself.
- name: Determine mode
id: mode
env:
EVENT_NAME: ${{ github.event_name }}
DISPATCH_TYPE: ${{ github.event.action }}
INPUT_MODE: ${{ github.event.inputs.mode }}
run: |
case "$EVENT_NAME" in
repository_dispatch) MODE="$DISPATCH_TYPE" ;;
workflow_dispatch) MODE="$INPUT_MODE" ;;
*) MODE="daily" ;;
esac
echo "mode=$MODE" >> $GITHUB_OUTPUT
echo "Resolved mode: $MODE"
# Daily morning brief
- run: npm run start
if: steps.mode.outputs.mode == 'daily'
env:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
NEWS_API_KEY: ${{ secrets.NEWS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MISTRAL_MODEL: ${{ vars.MISTRAL_MODEL }}
CLAUDE_MODEL: ${{ vars.CLAUDE_MODEL }}
AI_DETAILED_PROVIDER: ${{ vars.AI_DETAILED_PROVIDER }}
RECIPIENT_EMAIL: ${{ vars.RECIPIENT_EMAIL }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
# Social posting (all optional — each platform skips if its keys are unset)
X_API_KEY: ${{ secrets.X_API_KEY }}
X_API_SECRET: ${{ secrets.X_API_SECRET }}
X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }}
X_ACCESS_TOKEN_SECRET: ${{ secrets.X_ACCESS_TOKEN_SECRET }}
FACEBOOK_PAGE_ID: ${{ secrets.FACEBOOK_PAGE_ID }}
FACEBOOK_PAGE_TOKEN: ${{ secrets.FACEBOOK_PAGE_TOKEN }}
LINKEDIN_ACCESS_TOKEN: ${{ secrets.LINKEDIN_ACCESS_TOKEN }}
LINKEDIN_ORG_URN: ${{ secrets.LINKEDIN_ORG_URN }}
THREADS_USER_ID: ${{ secrets.THREADS_USER_ID }}
THREADS_ACCESS_TOKEN: ${{ secrets.THREADS_ACCESS_TOKEN }}
# Weekly rebalancing report
- run: npm run weekly
if: steps.mode.outputs.mode == 'weekly'
env:
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 }}
# Intraday check — no NEWS_API_KEY (saves quota)
- run: npm run intraday
if: steps.mode.outputs.mode == 'intraday'
env:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MISTRAL_MODEL: ${{ vars.MISTRAL_MODEL }}
CLAUDE_MODEL: ${{ vars.CLAUDE_MODEL }}
AI_DETAILED_PROVIDER: ${{ vars.AI_DETAILED_PROVIDER }}
RECIPIENT_EMAIL: ${{ vars.RECIPIENT_EMAIL }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
# Social posting (all optional — each platform skips if its keys are unset)
X_API_KEY: ${{ secrets.X_API_KEY }}
X_API_SECRET: ${{ secrets.X_API_SECRET }}
X_ACCESS_TOKEN: ${{ secrets.X_ACCESS_TOKEN }}
X_ACCESS_TOKEN_SECRET: ${{ secrets.X_ACCESS_TOKEN_SECRET }}
FACEBOOK_PAGE_ID: ${{ secrets.FACEBOOK_PAGE_ID }}
FACEBOOK_PAGE_TOKEN: ${{ secrets.FACEBOOK_PAGE_TOKEN }}
LINKEDIN_ACCESS_TOKEN: ${{ secrets.LINKEDIN_ACCESS_TOKEN }}
LINKEDIN_ORG_URN: ${{ secrets.LINKEDIN_ORG_URN }}
THREADS_USER_ID: ${{ secrets.THREADS_USER_ID }}
THREADS_ACCESS_TOKEN: ${{ secrets.THREADS_ACCESS_TOKEN }}
# Refresh analysis for a single ticker
- run: npm run refresh -- ${{ github.event.inputs.ticker }}
if: steps.mode.outputs.mode == 'refresh'
env:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MISTRAL_MODEL: ${{ vars.MISTRAL_MODEL }}
CLAUDE_MODEL: ${{ vars.CLAUDE_MODEL }}
AI_DETAILED_PROVIDER: ${{ vars.AI_DETAILED_PROVIDER }}
RECIPIENT_EMAIL: ${{ vars.RECIPIENT_EMAIL }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
# The `send-weekly` job that used to live here is gone. It ran on EVERY
# schedule tick and asked the runner for the weekday (`date -u +%u`), sending
# the weekly report only when that returned 7 — which made it silently
# dependent on the cron arriving on the day it was scheduled for. Under the
# drift documented at the top of this file it stopped being reliable: a Sunday
# 22:00 UTC cron delivered at Monday 03:00 UTC computes day=1 and skips the
# weekly report altogether, with nothing in the logs suggesting anything was
# missed.
#
# The Cloudflare Worker now dispatches a `weekly` event at a known time
# (Sunday 22:30 UTC), and the `npm run weekly` step in send-brief above — which
# already existed for `workflow_dispatch` — handles it. The weekday is named by
# the caller rather than inferred by the callee, so a late dispatch still sends
# the right report.