Skip to content

Runtime

Runtime #4172

Workflow file for this run

name: Runtime
on:
workflow_dispatch:
inputs:
validate_only:
description: "Skip live trading and only validate GitHub OIDC + Firestore access"
required: false
default: false
type: boolean
permissions:
contents: read
env:
GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ vars.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: false
jobs:
deploy:
runs-on: self-hosted
timeout-minutes: 60
environment: binance-runtime
permissions:
actions: read
contents: write
id-token: write
steps:
- name: 0. Validate deployment identity configuration
shell: bash
run: |
set -euo pipefail
for name in GCP_PROJECT_ID GCP_WORKLOAD_IDENTITY_PROVIDER GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT; do
if [ -z "${!name:-}" ]; then
echo "::error::Required repository variable ${name} is not configured."
exit 1
fi
done
readonly EXPECTED_OIDC_IDENTITY_SHA256="68e87a5dc1bbe2e41af33d526514034246c6487fb7b716596cc75fe1c739a6b9"
actual_oidc_identity_sha256="$(
printf '%s\0%s\0%s' \
"$GCP_PROJECT_ID" \
"$GCP_WORKLOAD_IDENTITY_PROVIDER" \
"$GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT" |
sha256sum |
awk '{print $1}'
)"
if [ "$actual_oidc_identity_sha256" != "$EXPECTED_OIDC_IDENTITY_SHA256" ]; then
echo "::error::Repository OIDC identity variables do not match the reviewed identity contract."
exit 1
fi
- name: 1. Checkout latest code
uses: actions/checkout@v6
- name: 2. Authenticate to Google Cloud
uses: google-github-actions/auth@v3
with:
project_id: ${{ env.GCP_PROJECT_ID }}
workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}
create_credentials_file: true
export_environment_variables: true
cleanup_credentials: true
- name: 3. Prepare or update dependency environment
run: |
set -euo pipefail
LOCK_FILE="uv.lock"
PYTHON_BIN="python3"
if command -v python3.11 >/dev/null 2>&1; then PYTHON_BIN="python3.11"; fi
echo "Using interpreter: $PYTHON_BIN ($($PYTHON_BIN --version 2>&1))"
CACHE_ROOT="${RUNNER_WORKSPACE}/.runtime-cache/binance-platform"
VENV_PATH="${CACHE_ROOT}/venv"
HASH_FILE="${CACHE_ROOT}/uv.lock.sha256"
PYTHON_VERSION_FILE="${CACHE_ROOT}/python.version"
CURR_HASH=$(sha256sum "$LOCK_FILE" | cut -d' ' -f1)
CURR_PYTHON_VERSION="$("$PYTHON_BIN" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")')"
mkdir -p "$CACHE_ROOT"
echo "VENV_PATH=$VENV_PATH" >> "$GITHUB_ENV"
VENV_RECREATED=false
install_with_retry() {
local description="$1"
shift
local attempt
for attempt in 1 2 3; do
echo "${description} (attempt ${attempt}/3)"
if "$@"; then
return 0
fi
if [ "$attempt" -lt 3 ]; then
sleep $((attempt * 15))
fi
done
return 1
}
ensure_venv_pip() {
if "$VENV_PATH/bin/python" -m pip --version >/dev/null 2>&1; then
return 0
fi
echo "pip missing from dependency venv; bootstrapping with ensurepip."
"$VENV_PATH/bin/python" -m ensurepip --upgrade
}
if [ ! -x "$VENV_PATH/bin/python" ]; then
echo "Creating dependency venv at $VENV_PATH."
rm -rf "$VENV_PATH"
"$PYTHON_BIN" -m venv "$VENV_PATH"
VENV_RECREATED=true
elif [ ! -f "$PYTHON_VERSION_FILE" ] || [ "$CURR_PYTHON_VERSION" != "$(cat "$PYTHON_VERSION_FILE")" ]; then
echo "Python version changed; recreating dependency venv."
rm -rf "$VENV_PATH"
"$PYTHON_BIN" -m venv "$VENV_PATH"
VENV_RECREATED=true
fi
if [ "$VENV_RECREATED" = "true" ]; then
rm -f "$HASH_FILE" "$PYTHON_VERSION_FILE"
fi
ensure_venv_pip
install_with_retry "Install uv into dependency venv" \
"$VENV_PATH/bin/python" -m pip install --upgrade pip uv
export UV_PROJECT_ENVIRONMENT="$VENV_PATH"
UV_BIN="$VENV_PATH/bin/uv"
if [ "$VENV_RECREATED" = "false" ] && [ -f "$HASH_FILE" ] && [ "$CURR_HASH" = "$(cat "$HASH_FILE")" ]; then
echo "$LOCK_FILE unchanged; reusing cached venv."
else
echo "$LOCK_FILE changed or cache is cold; refreshing dependency venv."
fi
install_with_retry "Sync runtime dependencies" \
env UV_PROJECT_ENVIRONMENT="$VENV_PATH" "$UV_BIN" sync --frozen --no-dev
if [ "$VENV_RECREATED" = "true" ] || [ ! -f "$HASH_FILE" ] || [ "$CURR_HASH" != "$(cat "$HASH_FILE" 2>/dev/null || true)" ]; then
echo "$CURR_HASH" > "$HASH_FILE"
echo "$CURR_PYTHON_VERSION" > "$PYTHON_VERSION_FILE"
fi
- name: 4. Run trading strategy
run: |
set -euo pipefail
if [ "${VALIDATE_ONLY:-false}" = "true" ]; then
"$VENV_PATH/bin/python" -c "from google.cloud import firestore; client = firestore.Client(); collections = [collection.id for _, collection in zip(range(3), client.collections())]; print(f'Validated Google Cloud auth and Firestore access. project={client.project} sample_collections={collections}')"
exit 0
fi
"$VENV_PATH/bin/python" main.py
env:
BINANCE_API_KEY: ${{ secrets.BINANCE_API_KEY }}
BINANCE_API_SECRET: ${{ secrets.BINANCE_API_SECRET }}
STRATEGY_PROFILE: ${{ vars.STRATEGY_PROFILE || 'crypto_live_pool_rotation' }}
RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }}
TG_TOKEN: ${{ secrets.TG_TOKEN }}
GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }}
NOTIFY_LANG: ${{ vars.NOTIFY_LANG }}
EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }}
BINANCE_DRY_RUN: ${{ vars.BINANCE_DRY_RUN || 'true' }}
STRATEGY_ARTIFACT_FILE: ${{ vars.STRATEGY_ARTIFACT_FILE }}
STRATEGY_ARTIFACT_MANIFEST_FILE: ${{ vars.STRATEGY_ARTIFACT_MANIFEST_FILE }}
STRATEGY_ARTIFACT_FIRESTORE_COLLECTION: ${{ vars.STRATEGY_ARTIFACT_FIRESTORE_COLLECTION }}
STRATEGY_ARTIFACT_FIRESTORE_DOCUMENT: ${{ vars.STRATEGY_ARTIFACT_FIRESTORE_DOCUMENT }}
STRATEGY_ARTIFACT_MAX_AGE_DAYS: ${{ vars.STRATEGY_ARTIFACT_MAX_AGE_DAYS }}
STRATEGY_ARTIFACT_ACCEPTABLE_MODES: ${{ vars.STRATEGY_ARTIFACT_ACCEPTABLE_MODES }}
STRATEGY_ARTIFACT_EXPECTED_SIZE: ${{ vars.STRATEGY_ARTIFACT_EXPECTED_SIZE }}
STRATEGY_ARTIFACT_ALLOW_NEW_ENTRIES_ON_DEGRADED: ${{ vars.STRATEGY_ARTIFACT_ALLOW_NEW_ENTRIES_ON_DEGRADED }}
BTC_WEIGHT: ${{ vars.BTC_WEIGHT }}
TREND_WEIGHT: ${{ vars.TREND_WEIGHT }}
DYNAMIC_MODE: ${{ vars.DYNAMIC_MODE }}
DYNAMIC_REGIME_MODE: ${{ vars.DYNAMIC_REGIME_MODE }}
DYNAMIC_REGIME_OFF_CUT: ${{ vars.DYNAMIC_REGIME_OFF_CUT }}
DYNAMIC_HARD_SMA200_RATIO: ${{ vars.DYNAMIC_HARD_SMA200_RATIO }}
DYNAMIC_HARD_MA200_SLOPE: ${{ vars.DYNAMIC_HARD_MA200_SLOPE }}
DYNAMIC_SOFT_SMA200_RATIO: ${{ vars.DYNAMIC_SOFT_SMA200_RATIO }}
DYNAMIC_HARD_BTC_WEIGHT: ${{ vars.DYNAMIC_HARD_BTC_WEIGHT }}
DYNAMIC_HARD_TREND_WEIGHT: ${{ vars.DYNAMIC_HARD_TREND_WEIGHT }}
DYNAMIC_SOFT_BTC_WEIGHT: ${{ vars.DYNAMIC_SOFT_BTC_WEIGHT }}
DYNAMIC_SOFT_TREND_WEIGHT: ${{ vars.DYNAMIC_SOFT_TREND_WEIGHT }}
ROTATION_TOP_N: ${{ vars.ROTATION_TOP_N }}
TARGET_VOL: ${{ vars.TARGET_VOL }}
CIRCUIT_BREAKER_ENABLED: ${{ vars.CIRCUIT_BREAKER_ENABLED }}
ZSCORE_EXIT_RISK_REDUCED_EXPOSURE: ${{ vars.ZSCORE_EXIT_RISK_REDUCED_EXPOSURE }}
ZSCORE_EXIT_RISK_OFF_EXPOSURE: ${{ vars.ZSCORE_EXIT_RISK_OFF_EXPOSURE }}
ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW: ${{ vars.ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW }}
VALIDATE_ONLY: ${{ github.event.inputs.validate_only || 'false' }}
- name: 5. Capture execution timestamp
id: ts
run: echo "ts=$(date -u +%Y-%m-%dT%H%M)" >> "$GITHUB_OUTPUT"
- name: 6. Push execution log to logs branch
continue-on-error: true
if: ${{ github.event.inputs.validate_only != 'true' }}
run: |
LOGS_BRANCH="logs"
TS="${{ steps.ts.outputs.ts }}"
MONTH="${TS:0:7}"
REPORT_FILE="${GITHUB_WORKSPACE}/reports/execution_report.json"
if [ ! -f "$REPORT_FILE" ]; then
echo "No execution report found, skipping log push."
exit 0
fi
TMP_DIR=$(mktemp -d)
git clone --no-checkout "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${TMP_DIR}/logs-repo"
cd "${TMP_DIR}/logs-repo"
if git ls-remote --exit-code --heads origin "${LOGS_BRANCH}" >/dev/null 2>&1; then
git fetch origin "${LOGS_BRANCH}:${LOGS_BRANCH}"
git checkout "${LOGS_BRANCH}"
else
git checkout --orphan "${LOGS_BRANCH}"
git rm -rf . >/dev/null 2>&1 || true
find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
fi
mkdir -p "hourly/${MONTH}"
cp "$REPORT_FILE" "hourly/${MONTH}/${TS}.json"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add "hourly/${MONTH}/${TS}.json"
git commit -m "execution: ${TS}"
git push origin "HEAD:${LOGS_BRANCH}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: 7. Notify Telegram on runtime workflow failure
if: ${{ failure() && github.event.inputs.validate_only != 'true' }}
continue-on-error: true
run: |
set -euo pipefail
if [ -z "${TG_TOKEN:-}" ] || [ -z "${GLOBAL_TELEGRAM_CHAT_ID:-}" ]; then
echo "Telegram workflow failure notification skipped: target is not configured."
exit 0
fi
REPORT_FILE="${GITHUB_WORKSPACE}/reports/execution_report.json"
if [ -f "$REPORT_FILE" ]; then
echo "Execution report exists; strategy-level runtime handling should already have recorded this failure."
exit 0
fi
MESSAGE=$(printf '%s\n' \
"Binance Runtime workflow failed before execution report was written" \
"repo: ${GITHUB_REPOSITORY}" \
"workflow: ${GITHUB_WORKFLOW}" \
"run: #${GITHUB_RUN_NUMBER} attempt ${GITHUB_RUN_ATTEMPT}" \
"ref: ${GITHUB_REF_NAME}" \
"sha: ${GITHUB_SHA}" \
"actor: ${GITHUB_ACTOR}" \
"url: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}")
RESPONSE="$(curl -fsS -X POST "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${GLOBAL_TELEGRAM_CHAT_ID}" \
--data-urlencode "text=${MESSAGE}")"
python3 - "${RESPONSE}" <<'PY'
import json
import sys
try:
payload = json.loads(sys.argv[1])
except json.JSONDecodeError as exc:
raise SystemExit("Telegram returned invalid JSON") from exc
if not isinstance(payload, dict) or payload.get("ok") is not True:
raise SystemExit("Telegram did not acknowledge workflow failure notification")
PY
env:
TG_TOKEN: ${{ secrets.TG_TOKEN }}
GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }}