diff --git a/.github/workflows/longbridge-api-probe.yml b/.github/workflows/longbridge-api-probe.yml deleted file mode 100644 index 1620578..0000000 --- a/.github/workflows/longbridge-api-probe.yml +++ /dev/null @@ -1,164 +0,0 @@ -name: LongBridge API Probe - -on: - workflow_dispatch: - inputs: - qpk_ref: - description: "QuantPlatformKit ref containing the probe test" - required: false - default: "main" - type: string - symbol: - description: "US symbol to use for the probe" - required: false - default: "SOXX.US" - type: string - limit_price: - description: "Low buy limit price used by the cancellable probe order" - required: false - default: "0.01" - type: string - -env: - GCP_PROJECT_ID: longbridgequant - GCP_WORKLOAD_IDENTITY_PROVIDER: projects/252919773759/locations/global/workloadIdentityPools/github-actions/providers/github-main - GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: longbridge-platform-deploy@longbridgequant.iam.gserviceaccount.com - -jobs: - hk-fractional-order: - name: Probe HK Simulated Fractional Orders - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - environment: longbridge-hk - env: - CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} - CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }} - LONGPORT_APP_KEY_SECRET_NAME: ${{ vars.LONGPORT_APP_KEY_SECRET_NAME }} - LONGPORT_APP_SECRET_SECRET_NAME: ${{ vars.LONGPORT_APP_SECRET_SECRET_NAME }} - LONGPORT_SECRET_NAME: ${{ vars.LONGPORT_SECRET_NAME }} - LONGPORT_APP_KEY_VALUE: ${{ secrets.LONGPORT_APP_KEY }} - LONGPORT_APP_SECRET_VALUE: ${{ secrets.LONGPORT_APP_SECRET }} - LONGPORT_ACCESS_TOKEN_VALUE: ${{ secrets.LONGPORT_ACCESS_TOKEN }} - steps: - - name: Validate inputs - run: | - set -euo pipefail - - missing_vars=() - if [ -z "${LONGPORT_APP_KEY_VALUE:-}" ] && [ -z "${LONGPORT_APP_KEY_SECRET_NAME:-}" ]; then - missing_vars+=("LONGPORT_APP_KEY secret or LONGPORT_APP_KEY_SECRET_NAME variable") - fi - if [ -z "${LONGPORT_APP_SECRET_VALUE:-}" ] && [ -z "${LONGPORT_APP_SECRET_SECRET_NAME:-}" ]; then - missing_vars+=("LONGPORT_APP_SECRET secret or LONGPORT_APP_SECRET_SECRET_NAME variable") - fi - if [ -z "${LONGPORT_ACCESS_TOKEN_VALUE:-}" ] && [ -z "${LONGPORT_SECRET_NAME:-}" ]; then - missing_vars+=("LONGPORT_ACCESS_TOKEN secret or LONGPORT_SECRET_NAME variable") - fi - - if [ "${#missing_vars[@]}" -gt 0 ]; then - printf 'Missing longbridge-hk variables:\n' >&2 - printf ' - %s\n' "${missing_vars[@]}" >&2 - exit 1 - fi - - - name: Checkout QuantPlatformKit - uses: actions/checkout@v6 - with: - repository: QuantStrategyLab/QuantPlatformKit - ref: ${{ inputs.qpk_ref }} - path: quant-platform-kit - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Authenticate to Google Cloud - 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 - uses: google-github-actions/setup-gcloud@v3 - with: - project_id: ${{ env.GCP_PROJECT_ID }} - version: ">= 416.0.0" - - - name: Export LongPort credentials - run: | - set -euo pipefail - - read_secret_manager_value() { - local secret_name="$1" - local error_file - error_file="$(mktemp)" - if value="$(gcloud secrets versions access latest --secret="${secret_name}" 2>"${error_file}")"; then - rm -f "${error_file}" - printf '%s' "${value}" - return 0 - fi - - if [ -n "${CLOUD_RUN_REGION:-}" ] && [ -n "${CLOUD_RUN_SERVICE:-}" ]; then - runtime_service_account="$( - gcloud run services describe "${CLOUD_RUN_SERVICE}" \ - --region "${CLOUD_RUN_REGION}" \ - --format='value(spec.template.spec.serviceAccountName)' 2>/dev/null || true - )" - if [ -n "${runtime_service_account}" ] \ - && value="$(gcloud secrets versions access latest \ - --secret="${secret_name}" \ - --impersonate-service-account="${runtime_service_account}" 2>"${error_file}")"; then - rm -f "${error_file}" - printf '%s' "${value}" - return 0 - fi - fi - - cat "${error_file}" >&2 - rm -f "${error_file}" - return 1 - } - - longport_app_key="${LONGPORT_APP_KEY_VALUE:-}" - longport_app_secret="${LONGPORT_APP_SECRET_VALUE:-}" - longport_access_token="${LONGPORT_ACCESS_TOKEN_VALUE:-}" - - if [ -z "${longport_app_key}" ]; then - longport_app_key="$(read_secret_manager_value "${LONGPORT_APP_KEY_SECRET_NAME}")" - fi - if [ -z "${longport_app_secret}" ]; then - longport_app_secret="$(read_secret_manager_value "${LONGPORT_APP_SECRET_SECRET_NAME}")" - fi - if [ -z "${longport_access_token}" ]; then - longport_access_token="$(read_secret_manager_value "${LONGPORT_SECRET_NAME}")" - fi - - echo "::add-mask::${longport_app_key}" - echo "::add-mask::${longport_app_secret}" - echo "::add-mask::${longport_access_token}" - - { - echo "LONGPORT_APP_KEY=${longport_app_key}" - echo "LONGPORT_APP_SECRET=${longport_app_secret}" - echo "LONGPORT_ACCESS_TOKEN=${longport_access_token}" - } >> "$GITHUB_ENV" - - - name: Install probe dependencies - run: | - set -euo pipefail - - python -m pip install --upgrade pip - python -m pip install -e quant-platform-kit pytest longport - - - name: Run HK simulated fractional order probe - env: - LONGBRIDGE_API_PROBE: "1" - LONGBRIDGE_API_PROBE_SYMBOL: ${{ inputs.symbol }} - LONGBRIDGE_API_PROBE_LIMIT_PRICE: ${{ inputs.limit_price }} - run: | - set -euo pipefail - - python -m pytest quant-platform-kit/tests/test_longbridge_fractional_order_api_probe.py -q -s diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 625dfc2..aa633ed 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -37,9 +37,6 @@ jobs: NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }} LONGBRIDGE_DRY_RUN_ONLY: ${{ vars.LONGBRIDGE_DRY_RUN_ONLY }} - LONGBRIDGE_FRACTIONAL_SHARES_ENABLED: ${{ vars.LONGBRIDGE_FRACTIONAL_SHARES_ENABLED }} - LONGBRIDGE_ORDER_QUANTITY_STEP: ${{ vars.LONGBRIDGE_ORDER_QUANTITY_STEP }} - LONGBRIDGE_MIN_ORDER_NOTIONAL_USD: ${{ vars.LONGBRIDGE_MIN_ORDER_NOTIONAL_USD }} GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }} TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} steps: @@ -234,6 +231,9 @@ jobs: remove_env_vars=( "TELEGRAM_CHAT_ID" "SERVICE_NAME" + "LONGBRIDGE_FRACTIONAL_SHARES_ENABLED" + "LONGBRIDGE_ORDER_QUANTITY_STEP" + "LONGBRIDGE_MIN_ORDER_NOTIONAL_USD" ) remove_secret_vars=() @@ -281,24 +281,6 @@ jobs: remove_env_vars+=("LONGBRIDGE_DRY_RUN_ONLY") fi - if [ -n "${LONGBRIDGE_FRACTIONAL_SHARES_ENABLED:-}" ]; then - env_pairs+=("LONGBRIDGE_FRACTIONAL_SHARES_ENABLED=${LONGBRIDGE_FRACTIONAL_SHARES_ENABLED}") - else - remove_env_vars+=("LONGBRIDGE_FRACTIONAL_SHARES_ENABLED") - fi - - if [ -n "${LONGBRIDGE_ORDER_QUANTITY_STEP:-}" ]; then - env_pairs+=("LONGBRIDGE_ORDER_QUANTITY_STEP=${LONGBRIDGE_ORDER_QUANTITY_STEP}") - else - remove_env_vars+=("LONGBRIDGE_ORDER_QUANTITY_STEP") - fi - - if [ -n "${LONGBRIDGE_MIN_ORDER_NOTIONAL_USD:-}" ]; then - env_pairs+=("LONGBRIDGE_MIN_ORDER_NOTIONAL_USD=${LONGBRIDGE_MIN_ORDER_NOTIONAL_USD}") - else - remove_env_vars+=("LONGBRIDGE_MIN_ORDER_NOTIONAL_USD") - fi - if [ -n "${INCOME_THRESHOLD_USD:-}" ]; then env_pairs+=("INCOME_THRESHOLD_USD=${INCOME_THRESHOLD_USD}") else @@ -355,9 +337,6 @@ jobs: NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }} LONGBRIDGE_DRY_RUN_ONLY: ${{ vars.LONGBRIDGE_DRY_RUN_ONLY }} - LONGBRIDGE_FRACTIONAL_SHARES_ENABLED: ${{ vars.LONGBRIDGE_FRACTIONAL_SHARES_ENABLED }} - LONGBRIDGE_ORDER_QUANTITY_STEP: ${{ vars.LONGBRIDGE_ORDER_QUANTITY_STEP }} - LONGBRIDGE_MIN_ORDER_NOTIONAL_USD: ${{ vars.LONGBRIDGE_MIN_ORDER_NOTIONAL_USD }} GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }} TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} steps: @@ -552,6 +531,9 @@ jobs: remove_env_vars=( "TELEGRAM_CHAT_ID" "SERVICE_NAME" + "LONGBRIDGE_FRACTIONAL_SHARES_ENABLED" + "LONGBRIDGE_ORDER_QUANTITY_STEP" + "LONGBRIDGE_MIN_ORDER_NOTIONAL_USD" ) remove_secret_vars=() @@ -599,24 +581,6 @@ jobs: remove_env_vars+=("LONGBRIDGE_DRY_RUN_ONLY") fi - if [ -n "${LONGBRIDGE_FRACTIONAL_SHARES_ENABLED:-}" ]; then - env_pairs+=("LONGBRIDGE_FRACTIONAL_SHARES_ENABLED=${LONGBRIDGE_FRACTIONAL_SHARES_ENABLED}") - else - remove_env_vars+=("LONGBRIDGE_FRACTIONAL_SHARES_ENABLED") - fi - - if [ -n "${LONGBRIDGE_ORDER_QUANTITY_STEP:-}" ]; then - env_pairs+=("LONGBRIDGE_ORDER_QUANTITY_STEP=${LONGBRIDGE_ORDER_QUANTITY_STEP}") - else - remove_env_vars+=("LONGBRIDGE_ORDER_QUANTITY_STEP") - fi - - if [ -n "${LONGBRIDGE_MIN_ORDER_NOTIONAL_USD:-}" ]; then - env_pairs+=("LONGBRIDGE_MIN_ORDER_NOTIONAL_USD=${LONGBRIDGE_MIN_ORDER_NOTIONAL_USD}") - else - remove_env_vars+=("LONGBRIDGE_MIN_ORDER_NOTIONAL_USD") - fi - if [ -n "${INCOME_THRESHOLD_USD:-}" ]; then env_pairs+=("INCOME_THRESHOLD_USD=${INCOME_THRESHOLD_USD}") else diff --git a/README.md b/README.md index 1cab737..c84418d 100644 --- a/README.md +++ b/README.md @@ -66,16 +66,13 @@ Telegram notifications include structured execution and heartbeat messages, with | `STRATEGY_PROFILE` | Yes | Strategy profile selector. Set explicitly per deployment; enabled values include `global_etf_confidence_vol_gate`, `global_etf_rotation`, `mega_cap_leader_rotation_top50_balanced`, `russell_1000_multi_factor_defensive`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, and `tqqq_growth_income` | | `ACCOUNT_REGION` | No | Account region marker for platform-style deployment (e.g. `HK`, `SG`; defaults to `ACCOUNT_PREFIX` / `DEFAULT`) | | `LONGBRIDGE_DRY_RUN_ONLY` | No | Set to `true` to keep the selected deployment in dry-run mode. | -| `LONGBRIDGE_FRACTIONAL_SHARES_ENABLED` | No | Defaults to `false`; set `true` only if the account supports fractional order submission. | -| `LONGBRIDGE_ORDER_QUANTITY_STEP` | No | Explicit order quantity step override; e.g. `1` for whole shares or `0.0001` for fractional sizing. | -| `LONGBRIDGE_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for fractional sizing; defaults to `1.0`. | | `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | No | Set to `true` to log raw LongBridge position quantity and available quantity for troubleshooting. | | `INCOME_THRESHOLD_USD` | No | Optional override for the `tqqq_growth_income` income-layer threshold. Leave unset to use the strategy package default. | | `QQQI_INCOME_RATIO` | No | Optional override for QQQI's share of the `tqqq_growth_income` income layer, 0–1. | | `NOTIFY_LANG` | No | Notification language: `en` (English, default) or `zh` (Chinese) | | `GOOGLE_CLOUD_PROJECT` | No | GCP project ID (defaults to ADC project when unset) | -Quantity sizing is resolved at runtime: `LONGBRIDGE_ORDER_QUANTITY_STEP` wins when set; otherwise `LONGBRIDGE_FRACTIONAL_SHARES_ENABLED=true` uses a `0.0001` step and `false` uses whole shares. The default is whole-share sizing because LongBridge order submission rejects sub-1-share quantities for the current deployment accounts. When a target value is zero, sell sizing uses the sellable position quantity instead of re-deriving shares from current price, so liquidation targets do not leave a residual share because of quote drift. +Strategy allocation can still target fractional dollar values and fractional position weights. The LongBridge execution layer submits whole-share orders only because OpenAPI `submit_order` rejects fractional submitted quantities on the tested accounts. When a target value is zero, sell sizing uses the sellable whole-share position quantity instead of re-deriving shares from current price, so liquidation targets do not leave a residual share because of quote drift. Secret Manager must contain the secret named by `LONGPORT_SECRET_NAME` (default: `longport_token_hk`), where the **latest version = active access token**. The app refreshes it when expiry is within 30 days. @@ -99,7 +96,6 @@ Deploy the same codebase as multiple Cloud Run services (e.g. `HK` and `SG`) by - Current strategy domain is `us_equity`. `STRATEGY_PROFILE` now goes through a platform capability matrix plus a rollout allowlist derived from `runtime_enabled` strategy metadata: `eligible` means the platform can run it in theory, `enabled` means the current rollout really allows it. - `ACCOUNT_REGION`: explicitly mark the deployed account region (`HK` / `SG`); if unset, the app falls back to `ACCOUNT_PREFIX` or `DEFAULT` - `LONGBRIDGE_DRY_RUN_ONLY`: set per service when that deployment should stay dry-run -- `LONGBRIDGE_FRACTIONAL_SHARES_ENABLED` / `LONGBRIDGE_ORDER_QUANTITY_STEP`: set per service when that deployment should use non-default order sizing - `NOTIFY_LANG`: set `en` or `zh` per deployment ### GitHub-managed env sync for HK / SG @@ -117,12 +113,12 @@ Recommended setup: - Optional fallback only: `TELEGRAM_TOKEN` - **GitHub Environment: `longbridge-hk`** - Variables: `CLOUD_RUN_REGION`, `CLOUD_RUN_SERVICE`, `ACCOUNT_PREFIX`, `ACCOUNT_REGION`, `STRATEGY_PROFILE`, `LONGPORT_SECRET_NAME`, `LONGPORT_APP_KEY_SECRET_NAME`, `LONGPORT_APP_SECRET_SECRET_NAME` - - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_FRACTIONAL_SHARES_ENABLED`, `LONGBRIDGE_ORDER_QUANTITY_STEP`, `LONGBRIDGE_MIN_ORDER_NOTIONAL_USD`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` + - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` - Current live example: `STRATEGY_PROFILE=tech_communication_pullback_enhancement` - Recommended secret-name values: `longport-app-key-hk`, `longport-app-secret-hk` - **GitHub Environment: `longbridge-sg`** - Variables: `CLOUD_RUN_REGION`, `CLOUD_RUN_SERVICE`, `ACCOUNT_PREFIX`, `ACCOUNT_REGION`, `STRATEGY_PROFILE`, `LONGPORT_SECRET_NAME`, `LONGPORT_APP_KEY_SECRET_NAME`, `LONGPORT_APP_SECRET_SECRET_NAME` - - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_FRACTIONAL_SHARES_ENABLED`, `LONGBRIDGE_ORDER_QUANTITY_STEP`, `LONGBRIDGE_MIN_ORDER_NOTIONAL_USD`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` + - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` - Current live example: `STRATEGY_PROFILE=soxl_soxx_trend_income` - Recommended secret-name values: `longport-app-key-sg`, `longport-app-secret-sg` @@ -146,12 +142,6 @@ Important: - If you later rename or move this repository, rebuild the GitHub source binding in Google Cloud for both triggers instead of assuming the existing source binding will follow the rename. - For the shared deployment model and trigger migration checklist, see [`QuantPlatformKit/docs/deployment_model.md`](../QuantPlatformKit/docs/deployment_model.md). -### Manual API probes - -- `.github/workflows/longbridge-api-probe.yml` can be triggered manually against the `longbridge-hk` GitHub Environment. It checks out a selected `QuantPlatformKit` ref and runs the skipped LongBridge fractional-order API probe with HK simulated-account credentials from Secret Manager. -- The workflow can also use `LONGPORT_APP_KEY`, `LONGPORT_APP_SECRET`, and `LONGPORT_ACCESS_TOKEN` as GitHub Environment secrets when the deployment service account cannot read Secret Manager versions directly. -- The probe is for broker API validation only. It does not run on normal CI or `main` pushes. - ### Quick deploy 1. Enable **Cloud Run** and **Secret Manager API** in GCP. @@ -225,16 +215,13 @@ Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换 | `STRATEGY_PROFILE` | 是 | 策略档位选择。每个部署都要显式设置;已启用值包括 `global_etf_confidence_vol_gate`、`global_etf_rotation`、`mega_cap_leader_rotation_top50_balanced`、`russell_1000_multi_factor_defensive`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement` 和 `tqqq_growth_income` | | `ACCOUNT_REGION` | 否 | 平台化部署时的账户区域标记(如 `HK`、`SG`;默认按 `ACCOUNT_PREFIX` / `DEFAULT` 推断) | | `LONGBRIDGE_DRY_RUN_ONLY` | 否 | 设为 `true` 时,该部署保持 dry-run。 | -| `LONGBRIDGE_FRACTIONAL_SHARES_ENABLED` | 否 | 默认 `false`;仅在账户支持碎股委托时设为 `true`。 | -| `LONGBRIDGE_ORDER_QUANTITY_STEP` | 否 | 显式覆盖下单数量步进;如 `1` 表示整数股,`0.0001` 表示碎股数量步进。 | -| `LONGBRIDGE_MIN_ORDER_NOTIONAL_USD` | 否 | 碎股买入的最小名义金额;默认 `1.0`。 | | `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | 否 | 设为 `true` 时输出 LongBridge 原始持仓数量和可卖数量,便于排查。 | | `INCOME_THRESHOLD_USD` | 否 | 可选的 `tqqq_growth_income` 收入层启动阈值覆盖。不填时使用策略包默认值。 | | `QQQI_INCOME_RATIO` | 否 | 可选的 QQQI 收入层占比覆盖,0–1。 | | `NOTIFY_LANG` | 否 | 通知语言: `en`(英文,默认)或 `zh`(中文) | | `GOOGLE_CLOUD_PROJECT` | 否 | GCP 项目 ID(未设置时使用 ADC 默认项目) | -下单数量在运行时解析:显式设置 `LONGBRIDGE_ORDER_QUANTITY_STEP` 时优先使用该步进;否则 `LONGBRIDGE_FRACTIONAL_SHARES_ENABLED=true` 使用 `0.0001` 步进,`false` 使用整数股。默认按整数股计算,因为当前部署账户的 LongBridge 委托接口会拒绝小于 1 股的数量。目标市值为 0 时,卖出数量直接按可卖持仓计算,不再用当前报价反推股数,避免因报价漂移留下 1 股残仓。 +策略分配层仍然可以按目标金额和目标比例计算出小数仓位;LongBridge 执行层只提交整数股订单,因为实测账户的 OpenAPI `submit_order` 会拒绝碎股委托数量。目标市值为 0 时,卖出数量直接按可卖整数股持仓计算,不再用当前报价反推股数,避免因报价漂移留下 1 股残仓。 Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `longport_token_hk`),**最新版本 = 当前有效的 access token**。Token 到期前 30 天会自动刷新。 @@ -258,7 +245,6 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo - 当前策略域是 `us_equity`。`STRATEGY_PROFILE` 现在会先经过平台能力矩阵,再经过从 `runtime_enabled` 策略元数据派生的 rollout allowlist:`eligible` 表示平台理论可跑,`enabled` 表示当前 rollout 真正放开。 - `ACCOUNT_REGION`: 显式标记部署账户区域(`HK` / `SG`);未设置时会回退到 `ACCOUNT_PREFIX` 或 `DEFAULT` - `LONGBRIDGE_DRY_RUN_ONLY`: 需要保持模拟运行时按服务单独设置 -- `LONGBRIDGE_FRACTIONAL_SHARES_ENABLED` / `LONGBRIDGE_ORDER_QUANTITY_STEP`: 需要覆盖默认下单数量步进时按服务单独设置 - `NOTIFY_LANG`: 每个部署可独立设置 `en` 或 `zh` ### GitHub 统一管理 HK / SG 环境变量 @@ -276,12 +262,12 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo - 仅保留为 fallback:`TELEGRAM_TOKEN` - **GitHub Environment: `longbridge-hk`** - Variables: `CLOUD_RUN_REGION`、`CLOUD_RUN_SERVICE`、`ACCOUNT_PREFIX`、`ACCOUNT_REGION`、`STRATEGY_PROFILE`、`LONGPORT_SECRET_NAME`、`LONGPORT_APP_KEY_SECRET_NAME`、`LONGPORT_APP_SECRET_SECRET_NAME` - - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_FRACTIONAL_SHARES_ENABLED`、`LONGBRIDGE_ORDER_QUANTITY_STEP`、`LONGBRIDGE_MIN_ORDER_NOTIONAL_USD`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO` + - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO` - 当前线上示例:`STRATEGY_PROFILE=tech_communication_pullback_enhancement` - 建议的 secret-name 值:`longport-app-key-hk`、`longport-app-secret-hk` - **GitHub Environment: `longbridge-sg`** - Variables: `CLOUD_RUN_REGION`、`CLOUD_RUN_SERVICE`、`ACCOUNT_PREFIX`、`ACCOUNT_REGION`、`STRATEGY_PROFILE`、`LONGPORT_SECRET_NAME`、`LONGPORT_APP_KEY_SECRET_NAME`、`LONGPORT_APP_SECRET_SECRET_NAME` - - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_FRACTIONAL_SHARES_ENABLED`、`LONGBRIDGE_ORDER_QUANTITY_STEP`、`LONGBRIDGE_MIN_ORDER_NOTIONAL_USD`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO` + - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO` - 当前线上示例:`STRATEGY_PROFILE=soxl_soxx_trend_income` - 建议的 secret-name 值:`longport-app-key-sg`、`longport-app-secret-sg` @@ -305,12 +291,6 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo - 如果后面改 GitHub 仓库名或再次迁组织,Google Cloud 里的两个 trigger 都要重新选择 GitHub 来源,不要假设旧绑定会自动跟过去。 - 统一部署模型和触发器迁移清单见 [`QuantPlatformKit/docs/deployment_model.md`](../QuantPlatformKit/docs/deployment_model.md)。 -### 手动 API probe - -- `.github/workflows/longbridge-api-probe.yml` 可手动触发,固定使用 `longbridge-hk` GitHub Environment。它会 checkout 指定的 `QuantPlatformKit` ref,并用 Secret Manager 中的 HK 模拟盘 LongPort 凭证运行默认跳过的碎股下单 API probe。 -- 如果部署服务账号不能直接读取 Secret Manager version,这个 workflow 也可以改用 GitHub Environment secrets: `LONGPORT_APP_KEY`、`LONGPORT_APP_SECRET`、`LONGPORT_ACCESS_TOKEN`。 -- 这个 probe 只用于券商 API 验证,不会进入普通 CI 或 `main` push 流程。 - ### 快速部署 1. 在 GCP 中启用 **Cloud Run** 和 **Secret Manager API**。 diff --git a/application/execution_service.py b/application/execution_service.py index 76ad7e1..2390405 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -72,12 +72,8 @@ def record_note_log(note_logs, *, translator, with_prefix, kind, **kwargs): print(with_prefix(message), flush=True) -def _is_whole_share_step(quantity_step) -> bool: - return float(quantity_step or 1.0) >= 1.0 - - -def _floor_order_quantity(quantity, *, quantity_step): - return normalize_order_quantity(floor_to_quantity_step(quantity, quantity_step)) +def _floor_whole_share_quantity(quantity): + return normalize_order_quantity(floor_to_quantity_step(quantity, 1.0)) def _sell_order_quantity( @@ -86,7 +82,6 @@ def _sell_order_quantity( target_value, price, sellable_quantity, - quantity_step, ): sellable = max(0.0, float(sellable_quantity or 0.0)) if sellable <= 0.0: @@ -94,14 +89,13 @@ def _sell_order_quantity( target = max(0.0, float(target_value or 0.0)) if target <= 0.0: - return _floor_order_quantity(sellable, quantity_step=quantity_step) + return _floor_whole_share_quantity(sellable) sell_value = max(0.0, float(current_value or 0.0) - target) if sell_value <= 0.0 or float(price or 0.0) <= 0.0: return 0 - return _floor_order_quantity( + return _floor_whole_share_quantity( min(sell_value / float(price), sellable), - quantity_step=quantity_step, ) @@ -157,8 +151,6 @@ def execute_rebalance_cycle( dry_run_only=False, post_sell_refresh_attempts=1, post_sell_refresh_interval_sec=0.0, - quantity_step=1.0, - min_order_notional=0.0, sleeper=_noop_sleep, ) -> ExecutionCycleResult: logs: list[str] = [] @@ -180,8 +172,6 @@ def execute_rebalance_cycle( cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency")) investable_cash = float(execution["investable_cash"]) current_min_trade = float(execution["current_min_trade"]) - order_quantity_step = float(quantity_step or 1.0) - minimum_order_notional = max(0.0, float(min_order_notional or 0.0)) def append_order_id_suffix(log_message, order_id): order_id_text = str(order_id or "").strip() @@ -271,7 +261,6 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): target_value=target_values[symbol], price=price, sellable_quantity=sellable_quantities[symbol], - quantity_step=order_quantity_step, ) if quantity > 0: quantity_text = format_quantity(quantity) @@ -395,20 +384,14 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): if price is None: continue can_buy_value = min(diff, investable_cash) - if ( - _is_whole_share_step(order_quantity_step) - and can_buy_value > price - ) or ( - not _is_whole_share_step(order_quantity_step) - and can_buy_value >= minimum_order_notional - ): + if can_buy_value > price: is_limit_order = symbol in limit_order_symbols order_kind = "limit" if is_limit_order else "market" ref_price = round(price * limit_buy_premium, 2) if is_limit_order else round(price, 2) budget_price = ref_price if is_limit_order else price budget_quantity = floor_to_quantity_step( can_buy_value / budget_price, - order_quantity_step, + 1.0, ) cash_limit_quantity = estimate_cash_buy_quantity_safe( trade_context, @@ -421,15 +404,9 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): if cash_limit_quantity is None: continue effective_cash_limit_quantity = float(cash_limit_quantity) - if ( - not _is_whole_share_step(order_quantity_step) - and effective_cash_limit_quantity <= 0 - ): - effective_cash_limit_quantity = budget_quantity - quantity = _floor_order_quantity( + quantity = _floor_whole_share_quantity( min(budget_quantity, effective_cash_limit_quantity), - quantity_step=order_quantity_step, ) cost_estimate = 0.0 if quantity <= 0: @@ -486,7 +463,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): if submitted: investable_cash = max(0, investable_cash - cost_estimate) action_done = True - elif _is_whole_share_step(order_quantity_step): + else: record_note_log( note_logs, translator=translator, @@ -497,17 +474,6 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): investable=f"{investable_cash:.2f}", price=f"{price:.2f}", ) - else: - record_note_log( - note_logs, - translator=translator, - with_prefix=with_prefix, - kind="buy_deferred_min_notional", - symbol=f"{symbol}.US", - diff=f"{diff:.2f}", - investable=f"{investable_cash:.2f}", - min_notional=f"{minimum_order_notional:.2f}", - ) return ExecutionCycleResult( plan=dict(plan or {}), diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 9caf59f..d25188b 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -213,8 +213,6 @@ def fetch_replanned_state(): dry_run_only=config.dry_run_only, post_sell_refresh_attempts=config.post_sell_refresh_attempts, post_sell_refresh_interval_sec=config.post_sell_refresh_interval_sec, - quantity_step=config.quantity_step, - min_order_notional=config.min_order_notional, sleeper=config.sleeper or _noop_sleep, ) execution = execution_result.execution diff --git a/application/runtime_composer.py b/application/runtime_composer.py index 91f29b8..cf109d9 100644 --- a/application/runtime_composer.py +++ b/application/runtime_composer.py @@ -35,8 +35,6 @@ class LongBridgeRuntimeComposer: limit_buy_premium: float order_poll_interval_sec: int order_poll_max_attempts: int - quantity_step: float - min_order_notional: float dry_run_only: bool = False broker_adapters: Any = None strategy_adapters: Any = None @@ -170,8 +168,6 @@ def build_rebalance_config(self) -> LongBridgeRebalanceConfig: dry_run_only=self.dry_run_only, post_sell_refresh_attempts=self.order_poll_max_attempts, post_sell_refresh_interval_sec=self.order_poll_interval_sec, - quantity_step=self.quantity_step, - min_order_notional=self.min_order_notional, sleeper=self.sleeper, ) @@ -198,8 +194,6 @@ def build_runtime_composer( limit_buy_premium: float, order_poll_interval_sec: int, order_poll_max_attempts: int, - quantity_step: float, - min_order_notional: float, dry_run_only: bool, broker_adapters: Any, strategy_adapters: Any, @@ -239,8 +233,6 @@ def build_runtime_composer( limit_buy_premium=float(limit_buy_premium), order_poll_interval_sec=int(order_poll_interval_sec), order_poll_max_attempts=int(order_poll_max_attempts), - quantity_step=float(quantity_step), - min_order_notional=float(min_order_notional), dry_run_only=bool(dry_run_only), broker_adapters=broker_adapters, strategy_adapters=strategy_adapters, diff --git a/application/runtime_dependencies.py b/application/runtime_dependencies.py index d677f28..f8746bd 100644 --- a/application/runtime_dependencies.py +++ b/application/runtime_dependencies.py @@ -20,8 +20,6 @@ class LongBridgeRebalanceConfig: dry_run_only: bool = False post_sell_refresh_attempts: int = 1 post_sell_refresh_interval_sec: float = 0.0 - quantity_step: float = 1.0 - min_order_notional: float = 0.0 sleeper: Callable[[float], None] | None = None diff --git a/main.py b/main.py index 0992bde..07f0291 100644 --- a/main.py +++ b/main.py @@ -158,8 +158,6 @@ def build_composer(): limit_buy_premium=LIMIT_BUY_PREMIUM, order_poll_interval_sec=ORDER_POLL_INTERVAL_SEC, order_poll_max_attempts=ORDER_POLL_MAX_ATTEMPTS, - quantity_step=getattr(RUNTIME_SETTINGS, "quantity_step", 1.0), - min_order_notional=getattr(RUNTIME_SETTINGS, "min_order_notional", 0.0), dry_run_only=RUNTIME_SETTINGS.dry_run_only, broker_adapters=BROKER_ADAPTERS, strategy_adapters=STRATEGY_ADAPTERS, diff --git a/notifications/telegram.py b/notifications/telegram.py index c0ab129..42a6598 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -62,7 +62,6 @@ "buy_deferred_no_investable_cash": "账户现金 ${available} 低于策略保留阈值,可投资现金为 ${investable},本轮不发起买单", "buy_deferred_non_usd_cash": "检测到非 USD 现金({currencies}),但美股策略可用 USD 现金为 ${available}、可投资现金为 ${investable};请先换汇或入金 USD 后再买入", "buy_deferred_small_cash": "{symbol} 目标差额 ${diff},但可投资现金 ${investable} 不足买入 1 股(价格 ${price})", - "buy_deferred_min_notional": "{symbol} 目标差额 ${diff},但可投资现金 ${investable} 低于最小下单金额 ${min_notional}", "buy_deferred_cash_limit": "{symbol} 目标差额 ${diff},预算可买 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用", "limit_buy": "📈 [限价买入] {symbol}: {qty}股 @ ${price}", "market_buy": "📈 [市价买入] {symbol}: {qty}股 @ ${price}", @@ -143,7 +142,6 @@ "buy_deferred_no_investable_cash": "Account cash ${available} is below the strategy reserve threshold, investable cash is ${investable}; no buy order this cycle", "buy_deferred_non_usd_cash": "Non-USD cash is present ({currencies}), but this US-equity strategy has USD cash ${available} and investable cash ${investable}; convert or deposit USD before buying", "buy_deferred_small_cash": "{symbol} target gap ${diff}, but investable cash ${investable} is not enough for 1 share at ${price}", - "buy_deferred_min_notional": "{symbol} target gap ${diff}, but investable cash ${investable} is below the minimum order notional ${min_notional}", "buy_deferred_cash_limit": "{symbol} target gap ${diff}, budget supports {budget_qty} shares, but broker estimate returned 0; an open order, settlement, or buying-power hold may still be blocking funds", "limit_buy": "📈 [Limit buy] {symbol}: {qty} shares @ ${price}", "market_buy": "📈 [Market buy] {symbol}: {qty} shares @ ${price}", diff --git a/runtime_config_support.py b/runtime_config_support.py index 9dccf59..bbb5f23 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -7,9 +7,7 @@ from quant_platform_kit.common.runtime_config import ( resolve_bool_value, - resolve_float_env, resolve_optional_float_env, - resolve_quantity_step_env, resolve_strategy_runtime_path_settings, ) from strategy_registry import ( @@ -36,8 +34,6 @@ class PlatformRuntimeSettings: tg_token: str | None tg_chat_id: str | None dry_run_only: bool - quantity_step: float = 1.0 - min_order_notional: float = 0.0 debug_position_snapshot: bool = False income_threshold_usd: float | None = None qqqi_income_ratio: float | None = None @@ -105,18 +101,6 @@ def load_platform_runtime_settings( tg_token=os.getenv("TELEGRAM_TOKEN"), tg_chat_id=os.getenv("GLOBAL_TELEGRAM_CHAT_ID"), dry_run_only=resolve_bool_value(os.getenv("LONGBRIDGE_DRY_RUN_ONLY")), - quantity_step=resolve_quantity_step_env( - os.environ, - step_env="LONGBRIDGE_ORDER_QUANTITY_STEP", - fractional_env="LONGBRIDGE_FRACTIONAL_SHARES_ENABLED", - fractional_default=False, - fractional_step=0.0001, - ), - min_order_notional=resolve_float_env( - os.environ, - "LONGBRIDGE_MIN_ORDER_NOTIONAL_USD", - default=1.0, - ), debug_position_snapshot=resolve_bool_value(os.getenv("LONGBRIDGE_DEBUG_POSITION_SNAPSHOT")), income_threshold_usd=resolve_optional_float_env(os.environ, "INCOME_THRESHOLD_USD"), qqqi_income_ratio=_qqqi_income_ratio_env(), diff --git a/strategy_runtime.py b/strategy_runtime.py index a99e536..aee3d69 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -154,8 +154,6 @@ def _default_runtime_settings(profile: str, display_name: str) -> PlatformRuntim tg_token=None, tg_chat_id=None, dry_run_only=False, - quantity_step=1.0, - min_order_notional=0.0, debug_position_snapshot=False, feature_snapshot_path=None, feature_snapshot_manifest_path=None, diff --git a/tests/test_longbridge_api_probe_workflow.py b/tests/test_longbridge_api_probe_workflow.py deleted file mode 100644 index 856022f..0000000 --- a/tests/test_longbridge_api_probe_workflow.py +++ /dev/null @@ -1,37 +0,0 @@ -import unittest -from pathlib import Path - - -class LongBridgeApiProbeWorkflowTests(unittest.TestCase): - def test_workflow_uses_hk_environment_and_gcp_secrets(self) -> None: - workflow = Path(".github/workflows/longbridge-api-probe.yml").read_text(encoding="utf-8") - - self.assertIn("name: LongBridge API Probe", workflow) - self.assertIn("workflow_dispatch:", workflow) - self.assertIn("environment: longbridge-hk", workflow) - self.assertIn("id-token: write", workflow) - self.assertIn("repository: QuantStrategyLab/QuantPlatformKit", workflow) - self.assertIn("ref: ${{ inputs.qpk_ref }}", workflow) - self.assertIn("google-github-actions/auth@v3", workflow) - self.assertIn("google-github-actions/setup-gcloud@v3", workflow) - self.assertIn("CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }}", workflow) - self.assertIn("CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }}", workflow) - self.assertIn("LONGPORT_APP_KEY_SECRET_NAME: ${{ vars.LONGPORT_APP_KEY_SECRET_NAME }}", workflow) - self.assertIn("LONGPORT_APP_SECRET_SECRET_NAME: ${{ vars.LONGPORT_APP_SECRET_SECRET_NAME }}", workflow) - self.assertIn("LONGPORT_SECRET_NAME: ${{ vars.LONGPORT_SECRET_NAME }}", workflow) - self.assertIn("LONGPORT_APP_KEY_VALUE: ${{ secrets.LONGPORT_APP_KEY }}", workflow) - self.assertIn("LONGPORT_APP_SECRET_VALUE: ${{ secrets.LONGPORT_APP_SECRET }}", workflow) - self.assertIn("LONGPORT_ACCESS_TOKEN_VALUE: ${{ secrets.LONGPORT_ACCESS_TOKEN }}", workflow) - self.assertIn("read_secret_manager_value()", workflow) - self.assertIn('read_secret_manager_value "${LONGPORT_APP_KEY_SECRET_NAME}"', workflow) - self.assertIn('read_secret_manager_value "${LONGPORT_APP_SECRET_SECRET_NAME}"', workflow) - self.assertIn('read_secret_manager_value "${LONGPORT_SECRET_NAME}"', workflow) - self.assertIn('gcloud secrets versions access latest --secret="${secret_name}"', workflow) - self.assertIn("--impersonate-service-account", workflow) - self.assertIn("python -m pip install -e quant-platform-kit pytest longport", workflow) - self.assertIn('LONGBRIDGE_API_PROBE: "1"', workflow) - self.assertIn("test_longbridge_fractional_order_api_probe.py", workflow) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_longbridge_api_probe_workflow.sh b/tests/test_longbridge_api_probe_workflow.sh deleted file mode 100755 index 6a1f8b9..0000000 --- a/tests/test_longbridge_api_probe_workflow.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_dir="$(cd "$(dirname "$0")/.." && pwd)" -workflow_file="$repo_dir/.github/workflows/longbridge-api-probe.yml" - -grep -Fq "name: LongBridge API Probe" "$workflow_file" -grep -Fq "workflow_dispatch:" "$workflow_file" -grep -Fq "environment: longbridge-hk" "$workflow_file" -grep -Fq "id-token: write" "$workflow_file" -grep -Fq "repository: QuantStrategyLab/QuantPlatformKit" "$workflow_file" -grep -Fq "ref: \${{ inputs.qpk_ref }}" "$workflow_file" -grep -Fq "google-github-actions/auth@v3" "$workflow_file" -grep -Fq "google-github-actions/setup-gcloud@v3" "$workflow_file" -grep -Fq "CLOUD_RUN_REGION: \${{ vars.CLOUD_RUN_REGION }}" "$workflow_file" -grep -Fq "CLOUD_RUN_SERVICE: \${{ vars.CLOUD_RUN_SERVICE }}" "$workflow_file" -grep -Fq "LONGPORT_APP_KEY_SECRET_NAME: \${{ vars.LONGPORT_APP_KEY_SECRET_NAME }}" "$workflow_file" -grep -Fq "LONGPORT_APP_SECRET_SECRET_NAME: \${{ vars.LONGPORT_APP_SECRET_SECRET_NAME }}" "$workflow_file" -grep -Fq "LONGPORT_SECRET_NAME: \${{ vars.LONGPORT_SECRET_NAME }}" "$workflow_file" -grep -Fq "LONGPORT_APP_KEY_VALUE: \${{ secrets.LONGPORT_APP_KEY }}" "$workflow_file" -grep -Fq "LONGPORT_APP_SECRET_VALUE: \${{ secrets.LONGPORT_APP_SECRET }}" "$workflow_file" -grep -Fq "LONGPORT_ACCESS_TOKEN_VALUE: \${{ secrets.LONGPORT_ACCESS_TOKEN }}" "$workflow_file" -grep -Fq "read_secret_manager_value()" "$workflow_file" -grep -Fq 'read_secret_manager_value "${LONGPORT_APP_KEY_SECRET_NAME}"' "$workflow_file" -grep -Fq 'read_secret_manager_value "${LONGPORT_APP_SECRET_SECRET_NAME}"' "$workflow_file" -grep -Fq 'read_secret_manager_value "${LONGPORT_SECRET_NAME}"' "$workflow_file" -grep -Fq 'gcloud secrets versions access latest --secret="${secret_name}"' "$workflow_file" -grep -Fq -- "--impersonate-service-account" "$workflow_file" -grep -Fq "python -m pip install -e quant-platform-kit pytest longport" "$workflow_file" -grep -Fq 'LONGBRIDGE_API_PROBE: "1"' "$workflow_file" -grep -Fq "test_longbridge_fractional_order_api_probe.py" "$workflow_file" diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index a907831..099aacd 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -400,8 +400,6 @@ def _run_strategy( dry_run_only=False, strategy_display_name="SOXL/SOXX 半导体趋势收益", post_sell_refresh_attempts=1, - quantity_step=1.0, - min_order_notional=0.0, ): sent_messages = [] observed_snapshots = [] @@ -473,8 +471,6 @@ def fake_resolve_rebalance_plan(*, indicators, snapshot): dry_run_only=dry_run_only, post_sell_refresh_attempts=post_sell_refresh_attempts, post_sell_refresh_interval_sec=0.0, - quantity_step=quantity_step, - min_order_notional=min_order_notional, sleeper=observed_sleeps.append, ), ) @@ -547,7 +543,7 @@ def test_buy_skip_without_orders_is_sent_in_single_heartbeat_message(self): self.assertIn("可投资现金", sent_messages[0]) self.assertIn("SOXX.US", sent_messages[0]) - def test_fractional_quantity_step_allows_small_soxx_target_buy(self): + def test_fractional_strategy_target_is_skipped_by_whole_share_execution_layer(self): plan = _build_plan( strategy_symbols=("SOXL", "SOXX", "BOXX"), risk_symbols=("SOXL", "SOXX"), @@ -572,77 +568,75 @@ def test_fractional_quantity_step_allows_small_soxx_target_buy(self): sent_messages, _, _ = self._run_strategy( plan, prices={"SOXX.US": 504.60, "SOXL.US": 162.93, "BOXX.US": 116.59}, - estimate_max_purchase_quantity_value=10, - quantity_step=0.0001, - min_order_notional=1.0, ) self.assertEqual(len(sent_messages), 1) - self.assertIn("限价买入] SOXX: 0.3216股", sent_messages[0]) - self.assertNotIn("不足买入 1 股", sent_messages[0]) + self.assertIn("💓 【心跳检测】", sent_messages[0]) + self.assertIn("本轮没有可执行订单", sent_messages[0]) + self.assertIn("SOXX.US 目标差额 $163.14", sent_messages[0]) + self.assertIn("不足买入 1 股", sent_messages[0]) + self.assertNotIn("限价买入] SOXX", sent_messages[0]) - def test_zero_target_sell_uses_sellable_quantity_not_price_derived_floor(self): + def test_fractional_strategy_target_buy_floors_to_cash_backed_whole_shares(self): plan = _build_plan( strategy_symbols=("SOXL",), risk_symbols=("SOXL",), - targets={"SOXL": 0.0}, - market_values={"SOXL": 327.88}, - sellable_quantities={"SOXL": 2}, - quantities={"SOXL": 2}, + targets={"SOXL": 688.17}, + market_values={"SOXL": 152.91}, + sellable_quantities={"SOXL": 1}, + quantities={"SOXL": 1}, current_min_trade=100.0, trade_threshold_value=100.0, - investable_cash=891.03, - market_status="🧯 过热降档(SOXX)", - deploy_ratio_text="0.0%", + investable_cash=726.38, + market_status="🧯 过热降档(SOXX+SOXL)", + deploy_ratio_text="65.0%", income_ratio_text="0.0%", income_locked_ratio_text="0.0%", - signal_message="SOXL 目标仓位 0.0%", - available_cash=923.66, - total_strategy_equity=1087.60, + signal_message="SOXX 仍在 140 日门槛线上方,但触发过热降档,目标仓位 SOXL 65.0%", + available_cash=758.56, + total_strategy_equity=1072.67, portfolio_rows=(("SOXL",),), ) sent_messages, _, _ = self._run_strategy( plan, - prices={"SOXL.US": 165.85}, - quantity_step=1.0, + prices={"SOXL.US": 152.93}, + estimate_max_purchase_quantity_value=10, ) self.assertEqual(len(sent_messages), 1) - self.assertIn("限价卖出] SOXL: 2股", sent_messages[0]) + self.assertIn("限价买入] SOXL: 3股", sent_messages[0]) + self.assertNotIn("限价买入] SOXL: 3.", sent_messages[0]) + self.assertNotIn("限价买入] SOXL: 4股", sent_messages[0]) - def test_fractional_buy_uses_budget_when_broker_estimate_is_whole_share_zero(self): + def test_zero_target_sell_uses_sellable_quantity_not_price_derived_floor(self): plan = _build_plan( - strategy_symbols=("SOXX",), - risk_symbols=("SOXX",), - targets={"SOXX": 163.14}, - market_values={"SOXX": 0.0}, - sellable_quantities={"SOXX": 0}, - quantities={"SOXX": 0}, + strategy_symbols=("SOXL",), + risk_symbols=("SOXL",), + targets={"SOXL": 0.0}, + market_values={"SOXL": 327.88}, + sellable_quantities={"SOXL": 2}, + quantities={"SOXL": 2}, current_min_trade=100.0, trade_threshold_value=100.0, investable_cash=891.03, market_status="🧯 过热降档(SOXX)", - deploy_ratio_text="15.0%", + deploy_ratio_text="0.0%", income_ratio_text="0.0%", income_locked_ratio_text="0.0%", - signal_message="SOXX 目标仓位 15.0%", + signal_message="SOXL 目标仓位 0.0%", available_cash=923.66, total_strategy_equity=1087.60, - portfolio_rows=(("SOXX",),), + portfolio_rows=(("SOXL",),), ) sent_messages, _, _ = self._run_strategy( plan, - prices={"SOXX.US": 504.60}, - estimate_max_purchase_quantity_value=0, - quantity_step=0.0001, - min_order_notional=1.0, + prices={"SOXL.US": 165.85}, ) self.assertEqual(len(sent_messages), 1) - self.assertIn("限价买入] SOXX: 0.3216股", sent_messages[0]) - self.assertNotIn("券商估算可买数量为 0", sent_messages[0]) + self.assertIn("限价卖出] SOXL: 2股", sent_messages[0]) def test_zero_investable_cash_reports_buying_power_without_trade_note(self): plan = _build_plan( diff --git a/tests/test_runtime_composer.py b/tests/test_runtime_composer.py index 02ed75e..3952f44 100644 --- a/tests/test_runtime_composer.py +++ b/tests/test_runtime_composer.py @@ -50,8 +50,6 @@ def fake_bootstrap_builder(**kwargs): limit_buy_premium=1.005, order_poll_interval_sec=1, order_poll_max_attempts=8, - quantity_step=0.0001, - min_order_notional=1.0, dry_run_only=True, broker_adapters=SimpleNamespace( build_market_data_port="market-data-port-factory", @@ -113,7 +111,5 @@ def fake_bootstrap_builder(**kwargs): assert runtime.post_submit_order == "post-submit-order" assert config.limit_sell_discount == 0.995 assert config.limit_buy_premium == 1.005 - assert config.quantity_step == 0.0001 - assert config.min_order_notional == 1.0 assert config.strategy_display_name == "SOXL/SOXX 半导体趋势收益" assert config.dry_run_only is True diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 8dca5c6..ddfcac3 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -52,8 +52,6 @@ def test_load_platform_runtime_settings_uses_defaults_with_explicit_strategy_pro self.assertIsNone(settings.tg_token) self.assertIsNone(settings.tg_chat_id) self.assertFalse(settings.dry_run_only) - self.assertEqual(settings.quantity_step, 1.0) - self.assertEqual(settings.min_order_notional, 1.0) self.assertFalse(settings.debug_position_snapshot) self.assertIsNone(settings.income_threshold_usd) self.assertIsNone(settings.qqqi_income_ratio) @@ -107,34 +105,6 @@ def test_dry_run_only_is_loaded_from_env(self): self.assertTrue(settings.dry_run_only) - def test_order_quantity_step_can_be_forced_to_whole_shares(self): - with patch.dict( - os.environ, - { - "STRATEGY_PROFILE": SAMPLE_STRATEGY_PROFILE, - "LONGBRIDGE_FRACTIONAL_SHARES_ENABLED": "false", - "LONGBRIDGE_MIN_ORDER_NOTIONAL_USD": "25", - }, - clear=True, - ): - settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") - - self.assertEqual(settings.quantity_step, 1.0) - self.assertEqual(settings.min_order_notional, 25.0) - - def test_fractional_quantity_step_must_be_enabled_explicitly(self): - with patch.dict( - os.environ, - { - "STRATEGY_PROFILE": SAMPLE_STRATEGY_PROFILE, - "LONGBRIDGE_FRACTIONAL_SHARES_ENABLED": "true", - }, - clear=True, - ): - settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") - - self.assertEqual(settings.quantity_step, 0.0001) - def test_debug_position_snapshot_is_loaded_from_env(self): with patch.dict( os.environ, diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index daab195..c0b3aa0 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -40,9 +40,6 @@ grep -Fq 'LONGBRIDGE_STRATEGY_CONFIG_PATH: ${{ vars.LONGBRIDGE_STRATEGY_CONFIG_P grep -Fq 'INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }}' "$workflow_file" grep -Fq 'QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }}' "$workflow_file" grep -Fq 'LONGBRIDGE_DRY_RUN_ONLY: ${{ vars.LONGBRIDGE_DRY_RUN_ONLY }}' "$workflow_file" -grep -Fq 'LONGBRIDGE_FRACTIONAL_SHARES_ENABLED: ${{ vars.LONGBRIDGE_FRACTIONAL_SHARES_ENABLED }}' "$workflow_file" -grep -Fq 'LONGBRIDGE_ORDER_QUANTITY_STEP: ${{ vars.LONGBRIDGE_ORDER_QUANTITY_STEP }}' "$workflow_file" -grep -Fq 'LONGBRIDGE_MIN_ORDER_NOTIONAL_USD: ${{ vars.LONGBRIDGE_MIN_ORDER_NOTIONAL_USD }}' "$workflow_file" grep -Fq "STRATEGY_PROFILE: \${{ vars.STRATEGY_PROFILE || 'soxl_soxx_trend_income' }}" "$workflow_file" grep -Fq "ACCOUNT_REGION: \${{ vars.ACCOUNT_REGION || 'HK' }}" "$workflow_file" grep -Fq "ACCOUNT_REGION: \${{ vars.ACCOUNT_REGION || 'SG' }}" "$workflow_file" @@ -78,13 +75,13 @@ grep -Fq 'LONGBRIDGE_FEATURE_SNAPSHOT_PATH=${LONGBRIDGE_FEATURE_SNAPSHOT_PATH}' grep -Fq 'LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH=${LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH}' "$workflow_file" grep -Fq 'LONGBRIDGE_STRATEGY_CONFIG_PATH=${LONGBRIDGE_STRATEGY_CONFIG_PATH}' "$workflow_file" grep -Fq 'LONGBRIDGE_DRY_RUN_ONLY=${LONGBRIDGE_DRY_RUN_ONLY}' "$workflow_file" -grep -Fq 'LONGBRIDGE_FRACTIONAL_SHARES_ENABLED=${LONGBRIDGE_FRACTIONAL_SHARES_ENABLED}' "$workflow_file" -grep -Fq 'LONGBRIDGE_ORDER_QUANTITY_STEP=${LONGBRIDGE_ORDER_QUANTITY_STEP}' "$workflow_file" -grep -Fq 'LONGBRIDGE_MIN_ORDER_NOTIONAL_USD=${LONGBRIDGE_MIN_ORDER_NOTIONAL_USD}' "$workflow_file" grep -Fq 'INCOME_THRESHOLD_USD=${INCOME_THRESHOLD_USD}' "$workflow_file" grep -Fq 'QQQI_INCOME_RATIO=${QQQI_INCOME_RATIO}' "$workflow_file" grep -Fq 'STRATEGY_PROFILE=${STRATEGY_PROFILE}' "$workflow_file" grep -Fq 'ACCOUNT_REGION=${ACCOUNT_REGION}' "$workflow_file" +grep -Fq '"LONGBRIDGE_FRACTIONAL_SHARES_ENABLED"' "$workflow_file" +grep -Fq '"LONGBRIDGE_ORDER_QUANTITY_STEP"' "$workflow_file" +grep -Fq '"LONGBRIDGE_MIN_ORDER_NOTIONAL_USD"' "$workflow_file" grep -Fq '"SERVICE_NAME"' "$workflow_file" grep -Fq 'gcloud_args+=(--remove-secrets "$(IFS=,; echo "${remove_secret_vars[*]}")")' "$workflow_file" grep -Fq 'gcloud_args+=(--update-secrets "$(IFS=,; echo "${secret_pairs[*]}")")' "$workflow_file" @@ -99,6 +96,36 @@ if grep -Fq 'SERVICE_NAME=${SERVICE_NAME}' "$workflow_file"; then exit 1 fi +if grep -Fq 'LONGBRIDGE_FRACTIONAL_SHARES_ENABLED: ${{ vars.LONGBRIDGE_FRACTIONAL_SHARES_ENABLED }}' "$workflow_file"; then + echo "unexpected LongBridge fractional-share env wiring still present" >&2 + exit 1 +fi + +if grep -Fq 'LONGBRIDGE_ORDER_QUANTITY_STEP: ${{ vars.LONGBRIDGE_ORDER_QUANTITY_STEP }}' "$workflow_file"; then + echo "unexpected LongBridge order quantity step env wiring still present" >&2 + exit 1 +fi + +if grep -Fq 'LONGBRIDGE_MIN_ORDER_NOTIONAL_USD: ${{ vars.LONGBRIDGE_MIN_ORDER_NOTIONAL_USD }}' "$workflow_file"; then + echo "unexpected LongBridge minimum order notional env wiring still present" >&2 + exit 1 +fi + +if grep -Fq 'LONGBRIDGE_FRACTIONAL_SHARES_ENABLED=${LONGBRIDGE_FRACTIONAL_SHARES_ENABLED}' "$workflow_file"; then + echo "unexpected LongBridge fractional-share env sync still present" >&2 + exit 1 +fi + +if grep -Fq 'LONGBRIDGE_ORDER_QUANTITY_STEP=${LONGBRIDGE_ORDER_QUANTITY_STEP}' "$workflow_file"; then + echo "unexpected LongBridge order quantity step env sync still present" >&2 + exit 1 +fi + +if grep -Fq 'LONGBRIDGE_MIN_ORDER_NOTIONAL_USD=${LONGBRIDGE_MIN_ORDER_NOTIONAL_USD}' "$workflow_file"; then + echo "unexpected LongBridge minimum order notional env sync still present" >&2 + exit 1 +fi + if grep -Fq 'LONGPORT_APP_KEY: ${{ secrets.LONGPORT_APP_KEY }}' "$workflow_file"; then echo "unexpected GitHub secret fallback for LONGPORT_APP_KEY still present" >&2 exit 1