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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ Telegram notifications include structured execution and heartbeat messages, with
| `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.000001` step and `false` uses whole shares. 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.

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.

Recommended runtime secrets in the `longbridgequant` project:
Expand Down Expand Up @@ -223,6 +225,8 @@ Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换
| `NOTIFY_LANG` | 否 | 通知语言: `en`(英文,默认)或 `zh`(中文) |
| `GOOGLE_CLOUD_PROJECT` | 否 | GCP 项目 ID(未设置时使用 ADC 默认项目) |

下单数量在运行时解析:显式设置 `LONGBRIDGE_ORDER_QUANTITY_STEP` 时优先使用该步进;否则 `LONGBRIDGE_FRACTIONAL_SHARES_ENABLED=true` 使用 `0.000001` 步进,`false` 使用整数股。目标市值为 0 时,卖出数量直接按可卖持仓计算,不再用当前报价反推股数,避免因报价漂移留下 1 股残仓。

Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `longport_token_hk`),**最新版本 = 当前有效的 access token**。Token 到期前 30 天会自动刷新。

建议在 `longbridgequant` 项目里维护这些运行时 secret:
Expand Down
35 changes: 30 additions & 5 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,31 @@ def _floor_order_quantity(quantity, *, quantity_step):
return normalize_order_quantity(floor_to_quantity_step(quantity, quantity_step))


def _sell_order_quantity(
*,
current_value,
target_value,
price,
sellable_quantity,
quantity_step,
):
sellable = max(0.0, float(sellable_quantity or 0.0))
if sellable <= 0.0:
return 0

target = max(0.0, float(target_value or 0.0))
if target <= 0.0:
return _floor_order_quantity(sellable, quantity_step=quantity_step)

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(
min(sell_value / float(price), sellable),
quantity_step=quantity_step,
)


def safe_quote_last_price(symbol, *, market_data_port, notify_issue):
try:
return float(market_data_port.get_quote(symbol).last_price)
Expand Down Expand Up @@ -241,11 +266,11 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
)
if price is None:
continue
quantity = _floor_order_quantity(
min(
floor_to_quantity_step(abs(diff) / price, order_quantity_step),
float(sellable_quantities[symbol]),
),
quantity = _sell_order_quantity(
current_value=market_values[symbol],
target_value=target_values[symbol],
price=price,
sellable_quantity=sellable_quantities[symbol],
quantity_step=order_quantity_step,
)
if quantity > 0:
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
flask
gunicorn
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@e8ef5e79642edac26465fe88c893ef01c8c51a14
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@12047c085a090b2977cc47c297515ba515f302e4
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@08ed04ae9796f54a2218ffb700f97e0e33bf312f
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@c9ec484c9a12cdffedf7d87c8906b93b21f50b1c
pandas
requests
pytz
Expand Down
48 changes: 12 additions & 36 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

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 (
Expand Down Expand Up @@ -102,14 +105,19 @@ 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=_quantity_step_env(
quantity_step=resolve_quantity_step_env(
os.environ,
step_env="LONGBRIDGE_ORDER_QUANTITY_STEP",
fractional_env="LONGBRIDGE_FRACTIONAL_SHARES_ENABLED",
fractional_default=True,
),
min_order_notional=_float_env("LONGBRIDGE_MIN_ORDER_NOTIONAL_USD", default=1.0),
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=_optional_float_env("INCOME_THRESHOLD_USD"),
income_threshold_usd=resolve_optional_float_env(os.environ, "INCOME_THRESHOLD_USD"),
qqqi_income_ratio=_qqqi_income_ratio_env(),
feature_snapshot_path=runtime_paths.feature_snapshot_path,
feature_snapshot_manifest_path=runtime_paths.feature_snapshot_manifest_path,
Expand All @@ -127,40 +135,8 @@ def _normalize_region(raw_value: str | None) -> str | None:
return value.upper()


def _optional_float_env(name: str) -> float | None:
raw_value = os.getenv(name)
if raw_value is None or raw_value.strip() == "":
return None
return float(raw_value)


def _float_env(name: str, *, default: float) -> float:
raw_value = os.getenv(name)
if raw_value is None or raw_value.strip() == "":
return float(default)
return float(raw_value)


def _quantity_step_env(
*,
step_env: str,
fractional_env: str,
fractional_default: bool,
) -> float:
explicit_step = _optional_float_env(step_env)
if explicit_step is not None:
return explicit_step
raw_enabled = os.getenv(fractional_env)
fractional_enabled = (
fractional_default
if raw_enabled is None
else resolve_bool_value(raw_enabled)
)
return 0.000001 if fractional_enabled else 1.0


def _qqqi_income_ratio_env() -> float | None:
value = _optional_float_env("QQQI_INCOME_RATIO")
value = resolve_optional_float_env(os.environ, "QQQI_INCOME_RATIO")
if value is not None and not (0.0 <= value <= 1.0):
raise ValueError(f"QQQI_INCOME_RATIO must be in [0,1], got {value}")
return value
30 changes: 30 additions & 0 deletions tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,36 @@ def test_fractional_quantity_step_allows_small_soxx_target_buy(self):
self.assertIn("限价买入] SOXX: 0.321699股", sent_messages[0])
self.assertNotIn("不足买入 1 股", sent_messages[0])

def test_zero_target_sell_uses_sellable_quantity_not_price_derived_floor(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},
current_min_trade=100.0,
trade_threshold_value=100.0,
investable_cash=891.03,
market_status="🧯 过热降档(SOXX)",
deploy_ratio_text="0.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,
portfolio_rows=(("SOXL",),),
)

sent_messages, _, _ = self._run_strategy(
plan,
prices={"SOXL.US": 165.85},
quantity_step=1.0,
)

self.assertEqual(len(sent_messages), 1)
self.assertIn("限价卖出] SOXL: 2股", sent_messages[0])

def test_fractional_buy_uses_budget_when_broker_estimate_is_whole_share_zero(self):
plan = _build_plan(
strategy_symbols=("SOXX",),
Expand Down