Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 68 additions & 202 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -269,81 +269,28 @@ jobs:
python -m pip install --upgrade pip
python -m pip install -r requirements.txt

- name: Resolve selected strategy runtime requirements
- name: Resolve Cloud Run sync targets
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
sync_plan_json="$(python scripts/build_cloud_run_env_sync_plan.py --json)"
{
echo "sync_plan_json<<__SYNC_PLAN_JSON__"
printf '%s\n' "${sync_plan_json}"
echo "__SYNC_PLAN_JSON__"
} >> "$GITHUB_OUTPUT"

- 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 }}
SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }}
run: |
set -euo pipefail

required_vars=(
CLOUD_RUN_REGION
CLOUD_RUN_SERVICE
RUNTIME_TARGET_JSON
)

if [ -z "${NOTIFY_LANG:-}" ]; then
Expand All @@ -370,20 +317,6 @@ jobs:
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
Expand All @@ -393,6 +326,22 @@ jobs:
fi
done

python - <<'PY'
import json
import os

plan = json.loads(os.environ["SYNC_PLAN_JSON"])
targets = plan.get("targets") or []
if not targets:
raise SystemExit("Cloud Run env sync did not resolve any targets")
for target in targets:
service_name = str(target.get("service_name") or "").strip()
if not service_name:
raise SystemExit("Cloud Run sync target is missing service_name")
if not isinstance(target.get("env"), dict):
raise SystemExit(f"Cloud Run sync target {service_name} is missing env")
PY

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
Expand Down Expand Up @@ -426,11 +375,31 @@ jobs:
- 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 }}
SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }}
run: |
set -euo pipefail

mapfile -t target_env_pairs < <(python - <<'PY'
import json
import os

plan = json.loads(os.environ["SYNC_PLAN_JSON"])
target = plan["targets"][0]
for key, value in sorted(target["env"].items()):
print(f"{key}={value}")
PY
)
mapfile -t target_remove_env_vars < <(python - <<'PY'
import json
import os

plan = json.loads(os.environ["SYNC_PLAN_JSON"])
target = plan["targets"][0]
for key in sorted(target.get("remove_env_vars") or []):
print(key)
PY
)

join_by_delimiter() {
local delimiter="$1"
shift
Expand All @@ -446,13 +415,11 @@ jobs:
printf '%s' "${output}"
}

env_pairs=(
"GOOGLE_CLOUD_PROJECT=${GCP_PROJECT_ID}"
"RUNTIME_TARGET_JSON=${RUNTIME_TARGET_JSON}"
"STRATEGY_PROFILE=${STRATEGY_PROFILE}"
)
env_pairs=("${target_env_pairs[@]}")
env_pairs+=("GOOGLE_CLOUD_PROJECT=${GCP_PROJECT_ID}")
secret_pairs=()
remove_env_vars=(
remove_env_vars=("${target_remove_env_vars[@]}")
remove_env_vars+=(
"TELEGRAM_CHAT_ID"
"CRISIS_ALERT_GOOGLE_VOICE_TO"
"CRISIS_ALERT_GOOGLE_VOICE_GATEWAY"
Expand Down Expand Up @@ -513,16 +480,6 @@ jobs:
"CRISIS_ALERT_TELEGRAM_BOT_TOKEN"
)

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"
Expand All @@ -541,89 +498,6 @@ jobs:
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_REUSE_SESSION
add_optional_env FIRSTRADE_SESSION_CACHE_TTL_SECONDS
add_optional_env FIRSTRADE_PERSIST_SESSION_CACHE
add_optional_env FIRSTRADE_GCS_STATE_BUCKET
add_optional_env FIRSTRADE_STATE_PREFIX
add_optional_env FIRSTRADE_PERSIST_ACCOUNT_SNAPSHOT
add_optional_env FIRSTRADE_PERSIST_STRATEGY_RUNS
add_optional_env FIRSTRADE_ENABLE_LIVE_TRADING
add_optional_env FIRSTRADE_RUN_SMOKE_ON_HTTP
add_optional_env FIRSTRADE_RUN_SESSION_CHECK_ON_HTTP
add_optional_env FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS
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_MIN_RESERVED_CASH_USD
add_optional_env FIRSTRADE_RESERVED_CASH_RATIO
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_FEATURE_SNAPSHOT_FALLBACK_MODE
add_optional_env FIRSTRADE_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR
add_optional_env FIRSTRADE_FEATURE_SNAPSHOT_MAX_STALE_DAYS
add_optional_env FIRSTRADE_STRATEGY_CONFIG_PATH
add_optional_env FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON
add_optional_env FIRSTRADE_MARKET_SIGNAL_HANDOFF_INDEX_URI
add_optional_env FIRSTRADE_MARKET_SIGNAL_HANDOFF_MANIFEST_URI
add_optional_env FIRSTRADE_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI
add_optional_env FIRSTRADE_MARKET_SIGNAL_CACHE_DIR
add_optional_env FIRSTRADE_MARKET_SIGNAL_REQUIRED
add_optional_env FIRSTRADE_MARKET_SIGNAL_FALLBACK_MODE
add_optional_env FIRSTRADE_MARKET_SIGNAL_MAX_STALE_DAYS
add_optional_env STRATEGY_PLUGIN_ALERT_CHANNELS
add_optional_env STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS
add_optional_env STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_EMAIL
add_optional_env STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_HOST
add_optional_env STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_PORT
add_optional_env STRATEGY_PLUGIN_ALERT_EMAIL_SMTP_SECURITY
add_optional_env STRATEGY_PLUGIN_ALERT_SMS_RECIPIENTS
add_optional_env STRATEGY_PLUGIN_ALERT_SMS_PROVIDER
add_optional_env STRATEGY_PLUGIN_ALERT_SMS_ACCOUNT_ID
add_optional_env STRATEGY_PLUGIN_ALERT_SMS_SENDER
add_optional_env STRATEGY_PLUGIN_ALERT_SMS_MESSAGING_SERVICE_ID
add_optional_env STRATEGY_PLUGIN_ALERT_SMS_API_BASE_URL
add_optional_env STRATEGY_PLUGIN_ALERT_SMS_BODY_MAX_CHARS
add_optional_env STRATEGY_PLUGIN_ALERT_PUSH_RECIPIENTS
add_optional_env STRATEGY_PLUGIN_ALERT_PUSH_PROVIDER
add_optional_env STRATEGY_PLUGIN_ALERT_PUSH_API_BASE_URL
add_optional_env STRATEGY_PLUGIN_ALERT_PUSH_DEVICE
add_optional_env STRATEGY_PLUGIN_ALERT_PUSH_PRIORITY
add_optional_env STRATEGY_PLUGIN_ALERT_PUSH_TAGS
add_optional_env STRATEGY_PLUGIN_ALERT_PUSH_BODY_MAX_CHARS
add_optional_env STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS
add_optional_env STRATEGY_PLUGIN_ALERT_TELEGRAM_API_BASE_URL
add_optional_env STRATEGY_PLUGIN_ALERT_TELEGRAM_PARSE_MODE
add_optional_env STRATEGY_PLUGIN_ALERT_TELEGRAM_DISABLE_WEB_PAGE_PREVIEW
add_optional_env STRATEGY_PLUGIN_ALERT_TELEGRAM_BODY_MAX_CHARS
add_optional_env FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS
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 INCOME_LAYER_ENABLED
add_optional_env INCOME_LAYER_START_USD
add_optional_env INCOME_LAYER_MAX_RATIO
add_optional_env CASH_ONLY_EXECUTION
add_optional_env DCA_MODE
add_optional_env DCA_BASE_INVESTMENT_USD
add_optional_env IBIT_ZSCORE_EXIT_ENABLED
add_optional_env IBIT_ZSCORE_EXIT_MODE
add_optional_env IBIT_ZSCORE_EXIT_PARKING_SYMBOL
add_optional_env IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE
add_optional_env IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE
add_optional_env IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW
add_optional_env RUNTIME_TARGET_ENABLED
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 STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME STRATEGY_PLUGIN_ALERT_EMAIL_SENDER_PASSWORD
add_optional_secret STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN_SECRET_NAME STRATEGY_PLUGIN_ALERT_SMS_AUTH_TOKEN
Expand Down Expand Up @@ -651,16 +525,19 @@ jobs:
import json
import os

runtime_target = json.loads(os.environ.get("RUNTIME_TARGET_JSON") or "{}")
target = {
"service_name": os.environ.get("CLOUD_RUN_SERVICE"),
plan = json.loads(os.environ["SYNC_PLAN_JSON"])
target = plan["targets"][0]
env = target.get("env") or {}
runtime_target = json.loads(env.get("RUNTIME_TARGET_JSON") or "{}")
payload = {
"service_name": target.get("service_name") or os.environ.get("CLOUD_RUN_SERVICE"),
"service_url": os.environ.get("SERVICE_URL"),
"strategy_profile": runtime_target.get("strategy_profile"),
"strategy_profile": target.get("strategy_profile") or env.get("STRATEGY_PROFILE"),
"account_scope": runtime_target.get("account_scope"),
"runtime_target_enabled": os.environ.get("RUNTIME_TARGET_ENABLED", "true"),
"scheduler": runtime_target.get("scheduler") if isinstance(runtime_target.get("scheduler"), dict) else {},
"runtime_target_enabled": env.get("RUNTIME_TARGET_ENABLED", "true"),
"scheduler": target.get("scheduler") or {},
}
print(json.dumps({"targets": [target]}, separators=(",", ":")))
print(json.dumps({"targets": [payload]}, separators=(",", ":")))
PY
)"
env_pairs+=("MONITOR_DISPATCH_TARGETS_JSON=${monitor_targets_json}")
Expand All @@ -685,7 +562,7 @@ jobs:
- name: Sync Cloud Scheduler schedule
if: steps.env_sync_config.outputs.enabled == 'true'
env:
RUNTIME_TARGET_JSON: ${{ steps.strategy_requirements.outputs.runtime_target_json }}
SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }}
run: |
set -euo pipefail

Expand All @@ -699,24 +576,13 @@ jobs:
import json
import os

raw_runtime_target = os.environ.get("RUNTIME_TARGET_JSON", "").strip()
runtime_scheduler = {}
if raw_runtime_target:
try:
runtime_target = json.loads(raw_runtime_target)
except json.JSONDecodeError:
runtime_target = {}
scheduler = runtime_target.get("scheduler") if isinstance(runtime_target, dict) else {}
if isinstance(scheduler, dict):
runtime_scheduler = scheduler

def configured_time(key: str, name: str, default: str) -> str:
return str(runtime_scheduler.get(key) or os.environ.get(name, "").strip() or default)

print(str(runtime_scheduler.get("timezone") or "America/New_York").strip())
print(configured_time("main_time", "CLOUD_SCHEDULER_MAIN_TIME", "45 15"))
print(configured_time("probe_time", "CLOUD_SCHEDULER_PROBE_TIME", "35 9,15"))
print(configured_time("precheck_time", "CLOUD_SCHEDULER_PRECHECK_TIME", "45 9"))
plan = json.loads(os.environ["SYNC_PLAN_JSON"])
target = plan["targets"][0]
scheduler = target.get("scheduler") or {}
print(str(scheduler.get("timezone") or "America/New_York").strip())
print(str(scheduler.get("main_time") or os.environ.get("CLOUD_SCHEDULER_MAIN_TIME", "").strip() or "45 15"))
print(str(scheduler.get("probe_time") or os.environ.get("CLOUD_SCHEDULER_PROBE_TIME", "").strip() or "35 9,15"))
print(str(scheduler.get("precheck_time") or os.environ.get("CLOUD_SCHEDULER_PRECHECK_TIME", "").strip() or "45 9"))
PY
)
market_timezone="${scheduler_config[0]}"
Expand Down
Loading