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
17 changes: 17 additions & 0 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ jobs:
CRISIS_ALERT_EMAIL_SMTP_HOST: ${{ vars.CRISIS_ALERT_EMAIL_SMTP_HOST }}
CRISIS_ALERT_EMAIL_SMTP_PORT: ${{ vars.CRISIS_ALERT_EMAIL_SMTP_PORT }}
CRISIS_ALERT_EMAIL_SMTP_SECURITY: ${{ vars.CRISIS_ALERT_EMAIL_SMTP_SECURITY }}
CRISIS_ALERT_SMS_RECIPIENTS: ${{ vars.CRISIS_ALERT_SMS_RECIPIENTS }}
CRISIS_ALERT_SMS_PROVIDER: ${{ vars.CRISIS_ALERT_SMS_PROVIDER }}
CRISIS_ALERT_SMS_ACCOUNT_ID: ${{ vars.CRISIS_ALERT_SMS_ACCOUNT_ID }}
CRISIS_ALERT_SMS_AUTH_TOKEN_SECRET_NAME: ${{ vars.CRISIS_ALERT_SMS_AUTH_TOKEN_SECRET_NAME }}
CRISIS_ALERT_SMS_SENDER: ${{ vars.CRISIS_ALERT_SMS_SENDER }}
CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID: ${{ vars.CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID }}
CRISIS_ALERT_SMS_API_BASE_URL: ${{ vars.CRISIS_ALERT_SMS_API_BASE_URL }}
CRISIS_ALERT_SMS_BODY_MAX_CHARS: ${{ vars.CRISIS_ALERT_SMS_BODY_MAX_CHARS }}
FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }}
FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }}
INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }}
Expand All @@ -76,6 +84,7 @@ jobs:
NOTIFY_LANG: ${{ vars.NOTIFY_LANG }}
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
CRISIS_ALERT_EMAIL_SENDER_PASSWORD: ${{ secrets.CRISIS_ALERT_EMAIL_SENDER_PASSWORD }}
CRISIS_ALERT_SMS_AUTH_TOKEN: ${{ secrets.CRISIS_ALERT_SMS_AUTH_TOKEN }}
FIRSTRADE_USERNAME: ${{ secrets.FIRSTRADE_USERNAME }}
FIRSTRADE_PASSWORD: ${{ secrets.FIRSTRADE_PASSWORD }}
FIRSTRADE_MFA_SECRET: ${{ secrets.FIRSTRADE_MFA_SECRET }}
Expand Down Expand Up @@ -457,6 +466,13 @@ jobs:
add_optional_env CRISIS_ALERT_EMAIL_SMTP_HOST
add_optional_env CRISIS_ALERT_EMAIL_SMTP_PORT
add_optional_env CRISIS_ALERT_EMAIL_SMTP_SECURITY
add_optional_env CRISIS_ALERT_SMS_RECIPIENTS
add_optional_env CRISIS_ALERT_SMS_PROVIDER
add_optional_env CRISIS_ALERT_SMS_ACCOUNT_ID
add_optional_env CRISIS_ALERT_SMS_SENDER
add_optional_env CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID
add_optional_env CRISIS_ALERT_SMS_API_BASE_URL
add_optional_env CRISIS_ALERT_SMS_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
Expand All @@ -467,6 +483,7 @@ jobs:

add_optional_secret TELEGRAM_TOKEN TELEGRAM_TOKEN_SECRET_NAME TELEGRAM_TOKEN
add_optional_secret CRISIS_ALERT_EMAIL_SENDER_PASSWORD CRISIS_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME CRISIS_ALERT_EMAIL_SENDER_PASSWORD
add_optional_secret CRISIS_ALERT_SMS_AUTH_TOKEN CRISIS_ALERT_SMS_AUTH_TOKEN_SECRET_NAME CRISIS_ALERT_SMS_AUTH_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
Expand Down
55 changes: 54 additions & 1 deletion application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@
build_strategy_plugin_alert_context_label as build_email_alert_context_label,
publish_strategy_plugin_email_alerts,
)
from quant_platform_kit.notifications.strategy_plugin_sms import (
StrategyPluginSmsAlertMarkerStore,
publish_strategy_plugin_sms_alerts,
)
from quant_platform_kit.strategy_contracts import build_strategy_evaluation_inputs
from runtime_config_support import PlatformRuntimeSettings, load_platform_runtime_settings
from strategy_runtime import load_strategy_runtime
Expand Down Expand Up @@ -238,6 +242,35 @@ def build_strategy_plugin_alert_store(
)


def build_strategy_plugin_sms_alert_store(
settings: PlatformRuntimeSettings,
*,
env_reader: Callable[[str, str | None], str | None] = os.getenv,
):
explicit_gcs_uri = env_reader("STRATEGY_PLUGIN_ALERT_STATE_GCS_URI", None)
report_gcs_uri = env_reader("EXECUTION_REPORT_GCS_URI", None)
state_bucket = env_reader("FIRSTRADE_GCS_STATE_BUCKET", None)
state_prefix = env_reader("FIRSTRADE_STATE_PREFIX", "firstrade-platform") or "firstrade-platform"
state_gcs_uri = f"gs://{state_bucket}/{state_prefix}" if state_bucket else None
return StrategyPluginSmsAlertMarkerStore(
local_dir=env_reader("STRATEGY_PLUGIN_ALERT_STATE_DIR", None) or "/tmp/quant_strategy_plugin_alerts",
gcs_prefix_uri=explicit_gcs_uri or report_gcs_uri or state_gcs_uri,
gcp_project_id=settings.project_id,
)


class StrategyPluginAlertPublishResults:
def __init__(self, *, email_result, sms_result):
self.email_result = email_result
self.sms_result = sms_result

def to_report_fields(self) -> dict[str, Any]:
fields: dict[str, Any] = {}
fields.update(self.email_result.to_report_fields())
fields.update(self.sms_result.to_report_fields())
return fields


def publish_strategy_plugin_alerts(
signals,
*,
Expand All @@ -246,7 +279,7 @@ def publish_strategy_plugin_alerts(
log_message: Callable[..., Any] = print,
env_reader: Callable[[str, str | None], str | None] = os.getenv,
):
return publish_strategy_plugin_email_alerts(
email_result = publish_strategy_plugin_email_alerts(
signals,
email_settings=settings,
translator=translator,
Expand All @@ -255,6 +288,16 @@ def publish_strategy_plugin_alerts(
alert_store=build_strategy_plugin_alert_store(settings, env_reader=env_reader),
log_message=log_message,
)
sms_result = publish_strategy_plugin_sms_alerts(
signals,
sms_settings=settings,
translator=translator,
strategy_label=settings.strategy_profile,
Comment on lines +291 to +295

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Isolate SMS failures from email alert reporting

Publishing email and SMS alerts in a single call path means an exception from publish_strategy_plugin_sms_alerts aborts publish_strategy_plugin_alerts after email may already have been sent, and run_strategy_cycle then falls back to zero counts with strategy_plugin_alert_email_error. In environments where SMS credentials are missing or the SMS provider errors, this produces incorrect email telemetry and mislabels the failure channel, which can hide real deliveries and mislead incident response.

Useful? React with 👍 / 👎.

context_label=build_strategy_plugin_alert_context_label(settings),
alert_store=build_strategy_plugin_sms_alert_store(settings, env_reader=env_reader),
log_message=log_message,
)
Comment on lines +291 to +299

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Isolate SMS publish failures from email reporting

Call failures from publish_strategy_plugin_sms_alerts currently abort publish_strategy_plugin_alerts after email may already have been sent, so run_strategy_cycle falls back to the zeroed alert fields and loses successful email delivery counts. This creates incorrect telemetry whenever SMS credentials/provider calls fail (for example, missing CRISIS_ALERT_SMS_AUTH_TOKEN), making alert-channel incidents harder to diagnose and masking real email sends.

Useful? React with 👍 / 👎.

return StrategyPluginAlertPublishResults(email_result=email_result, sms_result=sms_result)


def _runtime_metadata_with_execution_policy(
Expand Down Expand Up @@ -385,6 +428,11 @@ def run_strategy_cycle(
"strategy_plugin_alert_email_skipped_count": 0,
"strategy_plugin_alert_email_failed_count": 0,
"strategy_plugin_alert_email_deliveries": [],
"strategy_plugin_alert_sms_attempted_count": 0,
"strategy_plugin_alert_sms_sent_count": 0,
"strategy_plugin_alert_sms_skipped_count": 0,
"strategy_plugin_alert_sms_failed_count": 0,
"strategy_plugin_alert_sms_deliveries": [],
}
return attach_strategy_plugin_result(
result,
Expand Down Expand Up @@ -489,6 +537,11 @@ def run_strategy_cycle(
"strategy_plugin_alert_email_skipped_count": 0,
"strategy_plugin_alert_email_failed_count": 0,
"strategy_plugin_alert_email_deliveries": [],
"strategy_plugin_alert_sms_attempted_count": 0,
"strategy_plugin_alert_sms_sent_count": 0,
"strategy_plugin_alert_sms_skipped_count": 0,
"strategy_plugin_alert_sms_failed_count": 0,
"strategy_plugin_alert_sms_deliveries": [],
}
)
if strategy_plugin_alert_email_error:
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ authors = [
]
dependencies = [
"firstrade==0.0.38",
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@3c8f52b16a8ebf98b9fdfa4af5e96be43e6144fe",
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@b84bebb784248bd2bb3d2c589db9ea0ab35ee95d",
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@d43800180aae1c7fe7051496a6af5d76f2c65879",
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@9661d8bb74e33466fa0ec1efef168b1d1bae8875",
"google-cloud-storage",
"requests",
]
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
flask
gunicorn
firstrade==0.0.38
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@3c8f52b16a8ebf98b9fdfa4af5e96be43e6144fe
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@b84bebb784248bd2bb3d2c589db9ea0ab35ee95d
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@d43800180aae1c7fe7051496a6af5d76f2c65879
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@9661d8bb74e33466fa0ec1efef168b1d1bae8875
google-cloud-storage
requests
pytest
20 changes: 20 additions & 0 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ class PlatformRuntimeSettings:
crisis_alert_email_smtp_host: str | None = None
crisis_alert_email_smtp_port: str | None = None
crisis_alert_email_smtp_security: str | None = None
crisis_alert_sms_recipients: tuple[str, ...] = ()
crisis_alert_sms_provider: str | None = None
crisis_alert_sms_account_id: str | None = None
crisis_alert_sms_auth_token: str | None = None
crisis_alert_sms_sender: str | None = None
crisis_alert_sms_messaging_service_id: str | None = None
crisis_alert_sms_api_base_url: str | None = None
crisis_alert_sms_body_max_chars: str | None = None
runtime_target: RuntimeTarget | None = None


Expand Down Expand Up @@ -161,6 +169,18 @@ def load_platform_runtime_settings(
crisis_alert_email_smtp_security=_first_non_empty(
os.getenv("CRISIS_ALERT_EMAIL_SMTP_SECURITY")
),
crisis_alert_sms_recipients=_split_env_list(os.getenv("CRISIS_ALERT_SMS_RECIPIENTS")),
crisis_alert_sms_provider=_first_non_empty(os.getenv("CRISIS_ALERT_SMS_PROVIDER")),
crisis_alert_sms_account_id=_first_non_empty(os.getenv("CRISIS_ALERT_SMS_ACCOUNT_ID")),
crisis_alert_sms_auth_token=_first_non_empty(os.getenv("CRISIS_ALERT_SMS_AUTH_TOKEN")),
crisis_alert_sms_sender=_first_non_empty(os.getenv("CRISIS_ALERT_SMS_SENDER")),
crisis_alert_sms_messaging_service_id=_first_non_empty(
os.getenv("CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID")
),
crisis_alert_sms_api_base_url=_first_non_empty(os.getenv("CRISIS_ALERT_SMS_API_BASE_URL")),
crisis_alert_sms_body_max_chars=_first_non_empty(
os.getenv("CRISIS_ALERT_SMS_BODY_MAX_CHARS")
),
runtime_target=runtime_target,
)

Expand Down
37 changes: 30 additions & 7 deletions tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,15 +233,16 @@ def test_run_strategy_cycle_loads_strategy_plugin_report_and_sends_email(
crisis_alert_email_sender_password="app-password",
)
messages = []
observed_alerts = []
observed_email_alerts = []
observed_sms_alerts = []

monkeypatch.setattr(
"application.rebalance_service.load_strategy_runtime",
lambda *_args, **_kwargs: FakeStrategyRuntime(),
)

def fake_publish(signals, **kwargs):
observed_alerts.append((tuple(signals), kwargs))
def fake_email_publish(signals, **kwargs):
observed_email_alerts.append((tuple(signals), kwargs))
return SimpleNamespace(
sent_count=1,
to_report_fields=lambda: {
Expand All @@ -255,7 +256,23 @@ def fake_publish(signals, **kwargs):
},
)

monkeypatch.setattr("application.rebalance_service.publish_strategy_plugin_email_alerts", fake_publish)
def fake_sms_publish(signals, **kwargs):
observed_sms_alerts.append((tuple(signals), kwargs))
return SimpleNamespace(
sent_count=1,
to_report_fields=lambda: {
"strategy_plugin_alert_sms_attempted_count": 1,
"strategy_plugin_alert_sms_sent_count": 1,
"strategy_plugin_alert_sms_skipped_count": 0,
"strategy_plugin_alert_sms_failed_count": 0,
"strategy_plugin_alert_sms_deliveries": [
{"subject": "Crisis plugin alert", "status": "sent"}
],
},
)

monkeypatch.setattr("application.rebalance_service.publish_strategy_plugin_email_alerts", fake_email_publish)
monkeypatch.setattr("application.rebalance_service.publish_strategy_plugin_sms_alerts", fake_sms_publish)

result = run_strategy_cycle(
runtime_settings=settings,
Expand All @@ -267,13 +284,18 @@ def fake_publish(signals, **kwargs):

assert result["strategy_plugins"][0]["canonical_route"] == "true_crisis"
assert result["strategy_plugin_alert_email_sent_count"] == 1
assert result["strategy_plugin_alert_sms_sent_count"] == 1
assert result["strategy_plugin_lines"] == (
"🧩 Plugin: Crisis Watch Notice | status: true crisis | notice: defend",
)
assert len(observed_alerts) == 1
assert observed_alerts[0][0][0].canonical_route == "true_crisis"
assert "firstrade" in observed_alerts[0][1]["context_label"]
assert len(observed_email_alerts) == 1
assert len(observed_sms_alerts) == 1
assert observed_email_alerts[0][0][0].canonical_route == "true_crisis"
assert observed_sms_alerts[0][0][0].canonical_route == "true_crisis"
assert "firstrade" in observed_email_alerts[0][1]["context_label"]
assert "firstrade" in observed_sms_alerts[0][1]["context_label"]
assert result["strategy_plugin_alert_email_deliveries"][0]["status"] == "sent"
assert result["strategy_plugin_alert_sms_deliveries"][0]["status"] == "sent"
assert "🧩 Plugin: Crisis Watch Notice | status: true crisis | notice: defend" in messages[0]


Expand All @@ -296,6 +318,7 @@ def test_run_strategy_cycle_strategy_plugin_load_error_is_non_blocking(monkeypat
assert result["action_done"] is True
assert result["strategy_plugin_error"].startswith("JSONDecodeError:")
assert result["strategy_plugin_alert_email_sent_count"] == 0
assert result["strategy_plugin_alert_sms_sent_count"] == 0


def test_run_strategy_cycle_persists_strategy_run_state(monkeypatch):
Expand Down
31 changes: 31 additions & 0 deletions tests/test_runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ def test_reserved_cash_policy_defaults_to_zero(monkeypatch):
assert settings.crisis_alert_email_smtp_host is None
assert settings.crisis_alert_email_smtp_port is None
assert settings.crisis_alert_email_smtp_security is None
assert settings.crisis_alert_sms_recipients == ()
assert settings.crisis_alert_sms_provider is None
assert settings.crisis_alert_sms_account_id is None
assert settings.crisis_alert_sms_auth_token is None
assert settings.crisis_alert_sms_sender is None
assert settings.crisis_alert_sms_messaging_service_id is None
assert settings.crisis_alert_sms_api_base_url is None
assert settings.crisis_alert_sms_body_max_chars is None


def test_reserved_cash_policy_loads_from_env(monkeypatch):
Expand Down Expand Up @@ -91,6 +99,29 @@ def test_crisis_alert_email_settings_load_from_env(monkeypatch):
assert settings.crisis_alert_email_smtp_security == "starttls"


def test_crisis_alert_sms_settings_load_from_env(monkeypatch):
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json())
monkeypatch.setenv("CRISIS_ALERT_SMS_RECIPIENTS", "+15165480265;(516) 548-0265")
monkeypatch.setenv("CRISIS_ALERT_SMS_PROVIDER", "twilio")
monkeypatch.setenv("CRISIS_ALERT_SMS_ACCOUNT_ID", "AC123")
monkeypatch.setenv("CRISIS_ALERT_SMS_AUTH_TOKEN", "secret")
monkeypatch.setenv("CRISIS_ALERT_SMS_SENDER", "+15551234567")
monkeypatch.setenv("CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID", "MG123")
monkeypatch.setenv("CRISIS_ALERT_SMS_API_BASE_URL", "https://twilio.example.test")
monkeypatch.setenv("CRISIS_ALERT_SMS_BODY_MAX_CHARS", "160")

settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")

assert settings.crisis_alert_sms_recipients == ("+15165480265", "(516) 548-0265")
assert settings.crisis_alert_sms_provider == "twilio"
assert settings.crisis_alert_sms_account_id == "AC123"
assert settings.crisis_alert_sms_auth_token == "secret"
assert settings.crisis_alert_sms_sender == "+15551234567"
assert settings.crisis_alert_sms_messaging_service_id == "MG123"
assert settings.crisis_alert_sms_api_base_url == "https://twilio.example.test"
assert settings.crisis_alert_sms_body_max_chars == "160"


def test_reserved_cash_ratio_rejects_invalid_env(monkeypatch):
monkeypatch.setenv("FIRSTRADE_RESERVED_CASH_RATIO", "1.25")

Expand Down
16 changes: 16 additions & 0 deletions tests/test_sync_cloud_run_env_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ def test_sync_cloud_run_env_workflow_syncs_crisis_alert_settings():
"CRISIS_ALERT_EMAIL_SMTP_HOST",
"CRISIS_ALERT_EMAIL_SMTP_PORT",
"CRISIS_ALERT_EMAIL_SMTP_SECURITY",
"CRISIS_ALERT_SMS_RECIPIENTS",
"CRISIS_ALERT_SMS_PROVIDER",
"CRISIS_ALERT_SMS_ACCOUNT_ID",
"CRISIS_ALERT_SMS_SENDER",
"CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID",
"CRISIS_ALERT_SMS_API_BASE_URL",
"CRISIS_ALERT_SMS_BODY_MAX_CHARS",
):
assert f"{name}: ${{{{ vars.{name} }}}}" in workflow
assert f"add_optional_env {name}" in workflow
Expand All @@ -26,6 +33,15 @@ def test_sync_cloud_run_env_workflow_syncs_crisis_alert_settings():
"add_optional_secret CRISIS_ALERT_EMAIL_SENDER_PASSWORD "
"CRISIS_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME CRISIS_ALERT_EMAIL_SENDER_PASSWORD"
) in workflow
assert (
"CRISIS_ALERT_SMS_AUTH_TOKEN_SECRET_NAME: "
"${{ vars.CRISIS_ALERT_SMS_AUTH_TOKEN_SECRET_NAME }}"
) in workflow
assert "CRISIS_ALERT_SMS_AUTH_TOKEN: ${{ secrets.CRISIS_ALERT_SMS_AUTH_TOKEN }}" in workflow
assert (
"add_optional_secret CRISIS_ALERT_SMS_AUTH_TOKEN "
"CRISIS_ALERT_SMS_AUTH_TOKEN_SECRET_NAME CRISIS_ALERT_SMS_AUTH_TOKEN"
) in workflow
assert '"CRISIS_ALERT_GOOGLE_VOICE_TO"' in workflow
assert '"CRISIS_ALERT_GOOGLE_VOICE_GATEWAY"' in workflow
assert '"CRISIS_ALERT_GOOGLE_VOICE_GMAIL_USER"' in workflow
Expand Down