Runtime #4223
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: 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 }} |