Skip to content

Unify Firstrade Telegram notifications (#10) #3

Unify Firstrade Telegram notifications (#10)

Unify Firstrade Telegram notifications (#10) #3

name: Deploy Cloud Run
on:
push:
branches: [ main ]
env:
GCP_PROJECT_ID: firstradequant
GCP_PROJECT_NUMBER: "1088907247379"
GCP_WORKLOAD_IDENTITY_PROVIDER: projects/1088907247379/locations/global/workloadIdentityPools/github-actions/providers/github-main
GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: firstrade-platform-deploy@firstradequant.iam.gserviceaccount.com
GCP_RUNTIME_SERVICE_ACCOUNT: firstrade-platform-runtime@firstradequant.iam.gserviceaccount.com
GCP_ARTIFACT_REGISTRY_HOSTNAME: us-central1-docker.pkg.dev
GCP_ARTIFACT_REGISTRY_REPOSITORY: cloud-run-source-deploy
jobs:
deploy-cloud-run:
name: Deploy Cloud Run
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
env:
ENABLE_GITHUB_CLOUD_RUN_DEPLOY: ${{ vars.ENABLE_GITHUB_CLOUD_RUN_DEPLOY }}
ENABLE_GITHUB_ENV_SYNC: ${{ vars.ENABLE_GITHUB_ENV_SYNC }}
CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }}
CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }}
TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }}
FIRSTRADE_USERNAME_SECRET_NAME: ${{ vars.FIRSTRADE_USERNAME_SECRET_NAME }}
FIRSTRADE_PASSWORD_SECRET_NAME: ${{ vars.FIRSTRADE_PASSWORD_SECRET_NAME }}
FIRSTRADE_MFA_SECRET_SECRET_NAME: ${{ vars.FIRSTRADE_MFA_SECRET_SECRET_NAME }}
FIRSTRADE_PIN_SECRET_NAME: ${{ vars.FIRSTRADE_PIN_SECRET_NAME }}
FIRSTRADE_MFA_EMAIL_SECRET_NAME: ${{ vars.FIRSTRADE_MFA_EMAIL_SECRET_NAME }}
FIRSTRADE_MFA_PHONE_SECRET_NAME: ${{ vars.FIRSTRADE_MFA_PHONE_SECRET_NAME }}
FIRSTRADE_MFA_CODE_SECRET_NAME: ${{ vars.FIRSTRADE_MFA_CODE_SECRET_NAME }}
RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }}
ACCOUNT_PREFIX: ${{ vars.ACCOUNT_PREFIX }}
ACCOUNT_REGION: ${{ vars.ACCOUNT_REGION }}
FIRSTRADE_ACCOUNT: ${{ vars.FIRSTRADE_ACCOUNT }}
FIRSTRADE_COOKIE_DIR: ${{ vars.FIRSTRADE_COOKIE_DIR }}
FIRSTRADE_DRY_RUN_ONLY: ${{ vars.FIRSTRADE_DRY_RUN_ONLY }}
FIRSTRADE_ENABLE_LIVE_TRADING: ${{ vars.FIRSTRADE_ENABLE_LIVE_TRADING }}
FIRSTRADE_RUN_SMOKE_ON_HTTP: ${{ vars.FIRSTRADE_RUN_SMOKE_ON_HTTP }}
FIRSTRADE_RUN_STRATEGY_ON_HTTP: ${{ vars.FIRSTRADE_RUN_STRATEGY_ON_HTTP }}
FIRSTRADE_LIVE_ORDER_ACK: ${{ vars.FIRSTRADE_LIVE_ORDER_ACK }}
FIRSTRADE_MAX_ORDER_NOTIONAL_USD: ${{ vars.FIRSTRADE_MAX_ORDER_NOTIONAL_USD }}
FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }}
FIRSTRADE_SMOKE_SYMBOL: ${{ vars.FIRSTRADE_SMOKE_SYMBOL }}
FIRSTRADE_FEATURE_SNAPSHOT_PATH: ${{ vars.FIRSTRADE_FEATURE_SNAPSHOT_PATH }}
FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH }}
FIRSTRADE_STRATEGY_CONFIG_PATH: ${{ vars.FIRSTRADE_STRATEGY_CONFIG_PATH }}
FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON }}
FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }}
INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }}
QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }}
EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }}
GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }}
NOTIFY_LANG: ${{ vars.NOTIFY_LANG }}
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
FIRSTRADE_USERNAME: ${{ secrets.FIRSTRADE_USERNAME }}
FIRSTRADE_PASSWORD: ${{ secrets.FIRSTRADE_PASSWORD }}
FIRSTRADE_MFA_SECRET: ${{ secrets.FIRSTRADE_MFA_SECRET }}
FIRSTRADE_PIN: ${{ secrets.FIRSTRADE_PIN }}
FIRSTRADE_MFA_EMAIL: ${{ secrets.FIRSTRADE_MFA_EMAIL }}
FIRSTRADE_MFA_PHONE: ${{ secrets.FIRSTRADE_MFA_PHONE }}
FIRSTRADE_MFA_CODE: ${{ secrets.FIRSTRADE_MFA_CODE }}
steps:
- name: Check whether deploy is enabled
id: deploy_config
run: |
set -euo pipefail
if [ "${ENABLE_GITHUB_CLOUD_RUN_DEPLOY:-true}" != "true" ]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping Cloud Run deploy because ENABLE_GITHUB_CLOUD_RUN_DEPLOY is not true." >&2
exit 0
fi
echo "enabled=true" >> "$GITHUB_OUTPUT"
- name: Checkout repository
if: steps.deploy_config.outputs.enabled == 'true'
uses: actions/checkout@v6
- name: Validate deploy inputs
if: steps.deploy_config.outputs.enabled == 'true'
run: |
set -euo pipefail
missing_vars=()
for var_name in CLOUD_RUN_REGION CLOUD_RUN_SERVICE; do
if [ -z "${!var_name:-}" ]; then
missing_vars+=("${var_name}")
fi
done
if [ "${#missing_vars[@]}" -gt 0 ]; then
echo "Cloud Run deploy is enabled, but these values are missing:" >&2
printf ' - %s\n' "${missing_vars[@]}" >&2
exit 1
fi
- name: Authenticate to Google Cloud
id: auth
if: steps.deploy_config.outputs.enabled == 'true'
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}
- name: Set up gcloud
if: steps.deploy_config.outputs.enabled == 'true'
uses: google-github-actions/setup-gcloud@v3
with:
project_id: ${{ env.GCP_PROJECT_ID }}
version: ">= 416.0.0"
- name: Build, push, and deploy Cloud Run image
if: steps.deploy_config.outputs.enabled == 'true'
run: |
set -euo pipefail
image_repo="${GCP_ARTIFACT_REGISTRY_HOSTNAME}/${GCP_PROJECT_ID}/${GCP_ARTIFACT_REGISTRY_REPOSITORY}/firstradeplatform/${CLOUD_RUN_SERVICE}"
image="${image_repo}:${GITHUB_SHA}"
gcloud auth configure-docker "${GCP_ARTIFACT_REGISTRY_HOSTNAME}" --quiet
docker build --pull -t "${image}" .
docker push "${image}"
gcloud run deploy "${CLOUD_RUN_SERVICE}" \
--project="${GCP_PROJECT_ID}" \
--region="${CLOUD_RUN_REGION}" \
--platform=managed \
--image="${image}" \
--service-account="${GCP_RUNTIME_SERVICE_ACCOUNT}" \
--ingress=internal \
--max-instances=1 \
--concurrency=80 \
--memory=512Mi \
--cpu=1 \
--timeout=300s \
--labels="managed-by=github-actions,commit-sha=${GITHUB_SHA},github-run-id=${GITHUB_RUN_ID}" \
--quiet
- name: Check whether env sync is enabled
id: env_sync_config
if: steps.deploy_config.outputs.enabled == 'true'
run: |
set -euo pipefail
if [ "${ENABLE_GITHUB_ENV_SYNC:-}" != "true" ]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping Cloud Run env sync because ENABLE_GITHUB_ENV_SYNC is not true." >&2
exit 0
fi
echo "enabled=true" >> "$GITHUB_OUTPUT"
- name: Set up Python for strategy requirement resolution
if: steps.env_sync_config.outputs.enabled == 'true'
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install strategy status dependencies
if: steps.env_sync_config.outputs.enabled == 'true'
run: |
set -euo pipefail
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
- name: Resolve selected strategy runtime requirements
id: strategy_requirements
if: steps.env_sync_config.outputs.enabled == 'true'
run: |
set -euo pipefail
python - <<'PY'
import json
import os
import subprocess
import sys
from us_equity_strategies import resolve_canonical_profile
raw_runtime_target = os.environ.get("RUNTIME_TARGET_JSON", "").strip()
if not raw_runtime_target:
raise SystemExit("RUNTIME_TARGET_JSON is required")
runtime_target = json.loads(raw_runtime_target)
profile = str(runtime_target.get("strategy_profile") or "").strip().lower()
if not profile:
raise SystemExit("RUNTIME_TARGET_JSON.strategy_profile is required")
canonical_profile = resolve_canonical_profile(profile)
runtime_target["strategy_profile"] = canonical_profile
expected_service = os.environ.get("CLOUD_RUN_SERVICE", "").strip()
configured_service = str(runtime_target.get("service_name") or "").strip()
if configured_service and expected_service and configured_service != expected_service:
raise SystemExit(
"RUNTIME_TARGET_JSON.service_name does not match CLOUD_RUN_SERVICE: "
f"{configured_service!r} != {expected_service!r}"
)
raw_status = subprocess.check_output(
[sys.executable, "scripts/print_strategy_profile_status.py", "--json"],
text=True,
)
rows = json.loads(raw_status)
selected = next((row for row in rows if row["canonical_profile"] == canonical_profile), None)
if selected is None:
supported = ", ".join(sorted(row["canonical_profile"] for row in rows))
raise SystemExit(f"Unsupported STRATEGY_PROFILE={profile!r}; supported: {supported}")
if not selected.get("eligible") or not selected.get("enabled"):
raise SystemExit(f"STRATEGY_PROFILE={profile!r} is not eligible/enabled: {selected}")
output_path = os.environ["GITHUB_OUTPUT"]
with open(output_path, "a", encoding="utf-8") as output:
output.write(f"strategy_profile={canonical_profile}\n")
output.write(
f"requires_snapshot_artifacts={str(bool(selected.get('requires_snapshot_artifacts'))).lower()}\n"
)
output.write(
f"requires_snapshot_manifest_path={str(bool(selected.get('requires_snapshot_manifest_path'))).lower()}\n"
)
output.write(
f"requires_strategy_config_path={str(bool(selected.get('requires_strategy_config_path'))).lower()}\n"
)
output.write(
f"config_source_policy={str(selected.get('config_source_policy') or 'none')}\n"
)
output.write(f"runtime_target_json={json.dumps(runtime_target, sort_keys=True)}\n")
PY
- name: Validate env sync inputs
if: steps.env_sync_config.outputs.enabled == 'true'
env:
REQUIRES_SNAPSHOT_ARTIFACTS: ${{ steps.strategy_requirements.outputs.requires_snapshot_artifacts }}
REQUIRES_SNAPSHOT_MANIFEST_PATH: ${{ steps.strategy_requirements.outputs.requires_snapshot_manifest_path }}
REQUIRES_STRATEGY_CONFIG_PATH: ${{ steps.strategy_requirements.outputs.requires_strategy_config_path }}
CONFIG_SOURCE_POLICY: ${{ steps.strategy_requirements.outputs.config_source_policy }}
RUNTIME_TARGET_JSON: ${{ steps.strategy_requirements.outputs.runtime_target_json }}
run: |
set -euo pipefail
required_vars=(
CLOUD_RUN_REGION
CLOUD_RUN_SERVICE
RUNTIME_TARGET_JSON
)
if [ -z "${NOTIFY_LANG:-}" ]; then
required_vars+=(NOTIFY_LANG)
fi
if [ "${FIRSTRADE_RUN_STRATEGY_ON_HTTP:-}" = "true" ] || [ "${FIRSTRADE_RUN_SMOKE_ON_HTTP:-}" = "true" ]; then
if [ -z "${FIRSTRADE_USERNAME_SECRET_NAME:-}" ] && [ -z "${FIRSTRADE_USERNAME:-}" ]; then
required_vars+=("FIRSTRADE_USERNAME_SECRET_NAME or FIRSTRADE_USERNAME")
fi
if [ -z "${FIRSTRADE_PASSWORD_SECRET_NAME:-}" ] && [ -z "${FIRSTRADE_PASSWORD:-}" ]; then
required_vars+=("FIRSTRADE_PASSWORD_SECRET_NAME or FIRSTRADE_PASSWORD")
fi
fi
if [ -n "${GLOBAL_TELEGRAM_CHAT_ID:-}" ] \
&& [ -z "${TELEGRAM_TOKEN_SECRET_NAME:-}" ] \
&& [ -z "${TELEGRAM_TOKEN:-}" ]; then
required_vars+=("TELEGRAM_TOKEN_SECRET_NAME or TELEGRAM_TOKEN")
fi
if [ -n "${TELEGRAM_TOKEN_SECRET_NAME:-}${TELEGRAM_TOKEN:-}" ] \
&& [ -z "${GLOBAL_TELEGRAM_CHAT_ID:-}" ]; then
required_vars+=(GLOBAL_TELEGRAM_CHAT_ID)
fi
if [ "${REQUIRES_SNAPSHOT_ARTIFACTS:-}" = "true" ] && [ -z "${FIRSTRADE_FEATURE_SNAPSHOT_PATH:-}" ]; then
required_vars+=(FIRSTRADE_FEATURE_SNAPSHOT_PATH)
fi
if [ "${REQUIRES_SNAPSHOT_MANIFEST_PATH:-}" = "true" ] && [ -z "${FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH:-}" ]; then
required_vars+=(FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH)
fi
if [ "${REQUIRES_STRATEGY_CONFIG_PATH:-}" = "true" ] \
&& [ "${CONFIG_SOURCE_POLICY:-}" = "env_only" ] \
&& [ -z "${FIRSTRADE_STRATEGY_CONFIG_PATH:-}" ]; then
required_vars+=(FIRSTRADE_STRATEGY_CONFIG_PATH)
fi
missing_vars=()
for var_name in "${required_vars[@]}"; do
if [[ "${var_name}" == *" or "* ]]; then
missing_vars+=("${var_name}")
elif [ -z "${!var_name:-}" ]; then
missing_vars+=("${var_name}")
fi
done
if [ "${#missing_vars[@]}" -gt 0 ]; then
echo "Cloud Run env sync is enabled, but these values are missing:" >&2
printf ' - %s\n' "${missing_vars[@]}" >&2
exit 1
fi
- name: Wait for Cloud Run deployment of current commit
if: steps.env_sync_config.outputs.enabled == 'true'
run: |
set -euo pipefail
target_sha="${GITHUB_SHA}"
deadline=$((SECONDS + 1800))
while true; do
deployed_sha="$(gcloud run services describe "${CLOUD_RUN_SERVICE}" --region "${CLOUD_RUN_REGION}" --format='value(spec.template.metadata.labels.commit-sha)' 2>/dev/null || true)"
if [ -n "${deployed_sha}" ] && [ "${deployed_sha}" = "${target_sha}" ]; then
echo "Cloud Run service ${CLOUD_RUN_SERVICE} is on commit ${deployed_sha}."
break
fi
if [ "${SECONDS}" -ge "${deadline}" ]; then
echo "Timed out waiting for Cloud Run service ${CLOUD_RUN_SERVICE} to deploy commit ${target_sha}. Last seen commit: ${deployed_sha:-<none>}" >&2
exit 1
fi
echo "Waiting for Cloud Run service ${CLOUD_RUN_SERVICE} to deploy commit ${target_sha}. Last seen commit: ${deployed_sha:-<none>}" >&2
sleep 10
done
- name: Sync Cloud Run environment
if: steps.env_sync_config.outputs.enabled == 'true'
env:
STRATEGY_PROFILE: ${{ steps.strategy_requirements.outputs.strategy_profile }}
RUNTIME_TARGET_JSON: ${{ steps.strategy_requirements.outputs.runtime_target_json }}
run: |
set -euo pipefail
join_by_delimiter() {
local delimiter="$1"
shift
local output=""
local item
for item in "$@"; do
if [ -z "${output}" ]; then
output="${item}"
else
output="${output}${delimiter}${item}"
fi
done
printf '%s' "${output}"
}
env_pairs=(
"GOOGLE_CLOUD_PROJECT=${GCP_PROJECT_ID}"
"RUNTIME_TARGET_JSON=${RUNTIME_TARGET_JSON}"
"STRATEGY_PROFILE=${STRATEGY_PROFILE}"
)
secret_pairs=()
remove_env_vars=(
"TELEGRAM_CHAT_ID"
)
remove_secret_vars=()
add_optional_env() {
local name="$1"
local value="${!name:-}"
if [ -n "${value}" ]; then
env_pairs+=("${name}=${value}")
else
remove_env_vars+=("${name}")
fi
}
add_optional_secret() {
local env_name="$1"
local secret_var_name="$2"
local secret_value_var_name="$3"
local secret_name="${!secret_var_name:-}"
local plain_value="${!secret_value_var_name:-}"
if [ -n "${secret_name}" ]; then
secret_pairs+=("${env_name}=${secret_name}:latest")
remove_env_vars+=("${env_name}")
elif [ -n "${plain_value}" ]; then
env_pairs+=("${env_name}=${plain_value}")
remove_secret_vars+=("${env_name}")
else
remove_env_vars+=("${env_name}")
remove_secret_vars+=("${env_name}")
fi
}
add_optional_env ACCOUNT_PREFIX
add_optional_env ACCOUNT_REGION
add_optional_env FIRSTRADE_ACCOUNT
add_optional_env FIRSTRADE_COOKIE_DIR
add_optional_env FIRSTRADE_DRY_RUN_ONLY
add_optional_env FIRSTRADE_ENABLE_LIVE_TRADING
add_optional_env FIRSTRADE_RUN_SMOKE_ON_HTTP
add_optional_env FIRSTRADE_RUN_STRATEGY_ON_HTTP
add_optional_env FIRSTRADE_LIVE_ORDER_ACK
add_optional_env FIRSTRADE_MAX_ORDER_NOTIONAL_USD
add_optional_env FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD
add_optional_env FIRSTRADE_SMOKE_SYMBOL
add_optional_env FIRSTRADE_FEATURE_SNAPSHOT_PATH
add_optional_env FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH
add_optional_env FIRSTRADE_STRATEGY_CONFIG_PATH
add_optional_env FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON
add_optional_env FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS
add_optional_env INCOME_THRESHOLD_USD
add_optional_env QQQI_INCOME_RATIO
add_optional_env EXECUTION_REPORT_GCS_URI
add_optional_env GLOBAL_TELEGRAM_CHAT_ID
add_optional_env NOTIFY_LANG
add_optional_secret TELEGRAM_TOKEN TELEGRAM_TOKEN_SECRET_NAME TELEGRAM_TOKEN
add_optional_secret FIRSTRADE_USERNAME FIRSTRADE_USERNAME_SECRET_NAME FIRSTRADE_USERNAME
add_optional_secret FIRSTRADE_PASSWORD FIRSTRADE_PASSWORD_SECRET_NAME FIRSTRADE_PASSWORD
add_optional_secret FIRSTRADE_MFA_SECRET FIRSTRADE_MFA_SECRET_SECRET_NAME FIRSTRADE_MFA_SECRET
add_optional_secret FIRSTRADE_PIN FIRSTRADE_PIN_SECRET_NAME FIRSTRADE_PIN
add_optional_secret FIRSTRADE_MFA_EMAIL FIRSTRADE_MFA_EMAIL_SECRET_NAME FIRSTRADE_MFA_EMAIL
add_optional_secret FIRSTRADE_MFA_PHONE FIRSTRADE_MFA_PHONE_SECRET_NAME FIRSTRADE_MFA_PHONE
add_optional_secret FIRSTRADE_MFA_CODE FIRSTRADE_MFA_CODE_SECRET_NAME FIRSTRADE_MFA_CODE
gcloud_args=(
run services update "${CLOUD_RUN_SERVICE}"
--region "${CLOUD_RUN_REGION}"
--remove-env-vars "$(IFS=,; echo "${remove_env_vars[*]}")"
--update-env-vars "^|^$(join_by_delimiter "|" "${env_pairs[@]}")"
)
if [ "${#remove_secret_vars[@]}" -gt 0 ]; then
gcloud_args+=(--remove-secrets "$(IFS=,; echo "${remove_secret_vars[*]}")")
fi
if [ "${#secret_pairs[@]}" -gt 0 ]; then
gcloud_args+=(--update-secrets "$(IFS=,; echo "${secret_pairs[*]}")")
fi
gcloud "${gcloud_args[@]}"