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
33 changes: 28 additions & 5 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,23 @@ def _sell_order_quantity(
)


def _sell_delta_exceeds_floor(
*,
current_value,
target_value,
threshold_value,
min_trade_value,
) -> bool:
current = max(0.0, float(current_value or 0.0))
target = max(0.0, float(target_value or 0.0))
diff = current - target
if diff <= max(0.0, float(threshold_value or 0.0)):
return False
if target <= 0.0:
return True
return diff > max(0.0, float(min_trade_value or 0.0))


def safe_quote_last_price(symbol, *, market_data_port, notify_issue, quote_recorder=None):
try:
snapshot = market_data_port.get_quote(symbol)
Expand Down Expand Up @@ -619,6 +636,7 @@ def execute_rebalance_cycle(
post_sell_refresh_attempts=1,
post_sell_refresh_interval_sec=0.0,
sleeper=_noop_sleep,
min_order_notional_usd=0.0,
safe_haven_cash_substitute_threshold_usd=DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD,
) -> ExecutionCycleResult:
logs: list[str] = []
Expand Down Expand Up @@ -674,7 +692,8 @@ def record_quote_snapshot(snapshot) -> None:
available_cash = float(portfolio["liquid_cash"])
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"])
min_order_notional = max(0.0, float(min_order_notional_usd or 0.0))
current_min_trade = max(float(execution["current_min_trade"]), min_order_notional)
Comment on lines +695 to +696

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Convert USD floor before comparing with HKD trade values

For HK runtimes, this compares LONGBRIDGE_MIN_ORDER_NOTIONAL_USD directly against execution["current_min_trade"] and the target/market deltas, but those execution values are in the configured trading currency: runtime_config_support.py defaults HK to trading_currency="HKD", and application/longbridge_portfolio.py builds cash and market values from HKD cash/quotes. As a result the default USD 100 floor becomes only HKD 100 for .HK orders, so HK buys/sells between HKD 100 and roughly USD 100 equivalent are still submitted despite the intended USD floor. Please convert the USD floor into the portfolio currency before taking the max, or keep the configured floor currency-specific.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check the buy budget against the notional floor

This raises the candidate threshold, but the actual buy size later comes from can_buy_value = min(diff, investable_cash). When a symbol is underweight by more than the floor but the remaining investable cash is below it (for example a $500 gap, $90 cash, and a $10 share price), the symbol passes this filter and the loop can still submit a ~$90 buy, defeating LONGBRIDGE_MIN_ORDER_NOTIONAL_USD. Please also reject buys whose available budget or final estimated order value is below the floor.

Useful? React with 👍 / 👎.

dry_run_sale_proceeds = 0.0
cash_sweep_sold_this_cycle = False

Expand Down Expand Up @@ -770,8 +789,12 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
return True

for symbol in strategy_assets:
diff = target_values[symbol] - market_values[symbol]
if diff < -threshold_value and abs(diff) > current_min_trade:
if _sell_delta_exceeds_floor(
current_value=market_values[symbol],
target_value=target_values[symbol],
threshold_value=threshold_value,
min_trade_value=current_min_trade,
):
Comment on lines +792 to +797

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce the floor on floored sell quantities

For positive-target rebalances, this gate compares the pre-rounding dollar delta with current_min_trade, but the order sent later is the floored whole-share quantity from _sell_order_quantity. When the account is overweight by more than the new platform floor but one whole-share sale is below that floor (for example current $220, target $110, price $60), this branch still submits a ~$60 ordinary sell even though min_order_notional_usd is 100. Please recheck quantity * submitted_price (while still exempting target-zero exits) before submitting the sell.

Useful? React with 👍 / 👎.

price = safe_quote_last_price(
market_symbol(symbol),
market_data_port=market_data_port,
Expand Down Expand Up @@ -839,7 +862,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
with_prefix=with_prefix,
kind="sell_skipped",
detail=(
f"Symbol: {market_symbol(symbol)} Diff: ${abs(diff):.2f} "
f"Symbol: {market_symbol(symbol)} Diff: ${abs(market_values[symbol] - target_values[symbol]):.2f} "
f"Held: {quantities[symbol]} Sellable: {sellable_quantities[symbol]} "
f"(no sellable)"
),
Expand Down Expand Up @@ -987,7 +1010,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
available_cash = float(portfolio["liquid_cash"])
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"])
current_min_trade = max(float(execution["current_min_trade"]), min_order_notional)

if (
available_cash <= 0.0
Expand Down
1 change: 1 addition & 0 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ def fetch_replanned_state():
post_sell_refresh_attempts=config.post_sell_refresh_attempts,
post_sell_refresh_interval_sec=config.post_sell_refresh_interval_sec,
sleeper=config.sleeper or _noop_sleep,
min_order_notional_usd=config.min_order_notional_usd,
safe_haven_cash_substitute_threshold_usd=config.safe_haven_cash_substitute_threshold_usd,
)
if _should_record_execution_marker(result=execution_result, config=config):
Expand Down
4 changes: 4 additions & 0 deletions application/runtime_composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class LongBridgeRuntimeComposer:
order_poll_interval_sec: int
order_poll_max_attempts: int
safe_haven_cash_substitute_threshold_usd: float
min_order_notional_usd: float
market: str = "US"
symbol_suffix: str = ".US"
trading_currency: str = "USD"
Expand Down Expand Up @@ -209,6 +210,7 @@ def build_rebalance_config(self, *, strategy_plugin_signals=()) -> LongBridgeReb
symbol_suffix=self.symbol_suffix or ".US",
post_sell_refresh_attempts=self.order_poll_max_attempts,
post_sell_refresh_interval_sec=self.order_poll_interval_sec,
min_order_notional_usd=self.min_order_notional_usd,
safe_haven_cash_substitute_threshold_usd=self.safe_haven_cash_substitute_threshold_usd,
sleeper=self.sleeper,
extra_notification_lines=(market_scope_line,),
Expand Down Expand Up @@ -262,6 +264,7 @@ def build_runtime_composer(
order_poll_interval_sec: int,
order_poll_max_attempts: int,
safe_haven_cash_substitute_threshold_usd: float,
min_order_notional_usd: float,
dry_run_only: bool,
broker_adapters: Any,
strategy_adapters: Any,
Expand Down Expand Up @@ -306,6 +309,7 @@ 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),
min_order_notional_usd=float(min_order_notional_usd),
safe_haven_cash_substitute_threshold_usd=float(safe_haven_cash_substitute_threshold_usd),
market=str(market or "US").upper(),
symbol_suffix=str(symbol_suffix or ""),
Expand Down
1 change: 1 addition & 0 deletions application/runtime_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class LongBridgeRebalanceConfig:
symbol_suffix: str = ".US"
post_sell_refresh_attempts: int = 1
post_sell_refresh_interval_sec: float = 0.0
min_order_notional_usd: float = 100.0
safe_haven_cash_substitute_threshold_usd: float = 1000.0
sleeper: Callable[[float], None] | None = None
extra_notification_lines: tuple[str, ...] = ()
Expand Down
6 changes: 6 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def get_project_id():
# Token refresh: days before expiry to trigger refresh
TOKEN_REFRESH_THRESHOLD_DAYS = 30
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
DEFAULT_MIN_ORDER_NOTIONAL_USD = 100.0

SEPARATOR = "━━━━━━━━━━━━━━━━━━"

Expand Down Expand Up @@ -251,6 +252,10 @@ def _safe_haven_cash_substitute_threshold_usd() -> float:
)


def _min_order_notional_usd() -> float:
return float(getattr(RUNTIME_SETTINGS, "min_order_notional_usd", DEFAULT_MIN_ORDER_NOTIONAL_USD))


def build_composer(*, dry_run_only_override: bool | None = None):
return build_runtime_composer(
project_id=PROJECT_ID,
Expand All @@ -274,6 +279,7 @@ def build_composer(*, dry_run_only_override: bool | None = None):
order_poll_interval_sec=ORDER_POLL_INTERVAL_SEC,
order_poll_max_attempts=ORDER_POLL_MAX_ATTEMPTS,
safe_haven_cash_substitute_threshold_usd=_safe_haven_cash_substitute_threshold_usd(),
min_order_notional_usd=_min_order_notional_usd(),
market=MARKET,
symbol_suffix=SYMBOL_SUFFIX,
trading_currency=TRADING_CURRENCY,
Expand Down
6 changes: 3 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
flask
gunicorn
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@023641c88506c732624a7329e48b51b9dbbe3c2a
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@7d35772d1125b534d0bcca557cb6dbaf28914719
hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@2e0075004239e7ede7ba256763a3441d4ec4ca73
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@c6e221f71d7be4d8b1c8c94ce05452c3116e0c10
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@2b02dc947d99a593d2bbae4833f0eeeffc2eecd2
hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@5f739744b97d5a9f8981e7ae649c71a0a8ef10fa
pandas
requests
pytz
Expand Down
39 changes: 39 additions & 0 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,31 @@
RuntimeTarget,
resolve_runtime_target_from_env,
)
try:
from quant_platform_kit.common.broker_costs import (
BrokerCostProfile,
minimum_economic_order_notional_usd,
)
except ImportError: # pragma: no cover - compatibility with older pinned shared wheels
@dataclass(frozen=True)
class BrokerCostProfile:
fixed_order_fee_usd: float = 0.0
minimum_order_fee_usd: float = 0.0
max_fixed_fee_bps: float = 100.0
explicit_min_order_notional_usd: float = 0.0

def minimum_economic_order_notional_usd(profile: BrokerCostProfile | None) -> float:
if profile is None:
return 0.0
explicit_floor = max(0.0, float(profile.explicit_min_order_notional_usd or 0.0))
fee_floor = max(
max(0.0, float(profile.fixed_order_fee_usd or 0.0)),
max(0.0, float(profile.minimum_order_fee_usd or 0.0)),
)
max_fee_bps = max(0.0, float(profile.max_fixed_fee_bps or 0.0))
if fee_floor <= 0.0 or max_fee_bps <= 0.0:
return explicit_floor
return max(explicit_floor, fee_floor / (max_fee_bps / 10_000.0))
from strategy_registry import (
LONGBRIDGE_PLATFORM,
STRATEGY_CATALOG,
Expand All @@ -37,6 +62,15 @@
DEFAULT_RESERVED_CASH_FLOOR_USD = 0.0
DEFAULT_RESERVED_CASH_RATIO = 0.0
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
DEFAULT_LONGBRIDGE_FIXED_ORDER_FEE_USD = 0.99
DEFAULT_LONGBRIDGE_MAX_FIXED_FEE_BPS = 100.0
DEFAULT_LONGBRIDGE_MIN_ORDER_NOTIONAL_USD = minimum_economic_order_notional_usd(
BrokerCostProfile(
fixed_order_fee_usd=DEFAULT_LONGBRIDGE_FIXED_ORDER_FEE_USD,
max_fixed_fee_bps=DEFAULT_LONGBRIDGE_MAX_FIXED_FEE_BPS,
explicit_min_order_notional_usd=100.0,
)
)


@dataclass(frozen=True)
Expand All @@ -59,6 +93,7 @@ class PlatformRuntimeSettings:
trading_currency: str = DEFAULT_TRADING_CURRENCY
reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD
reserved_cash_ratio: float = DEFAULT_RESERVED_CASH_RATIO
min_order_notional_usd: float = DEFAULT_LONGBRIDGE_MIN_ORDER_NOTIONAL_USD
safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD
debug_position_snapshot: bool = False
income_threshold_usd: float | None = None
Expand Down Expand Up @@ -236,6 +271,10 @@ def load_platform_runtime_settings(
"LONGBRIDGE_RESERVED_CASH_RATIO",
default=DEFAULT_RESERVED_CASH_RATIO,
),
min_order_notional_usd=_resolve_non_negative_float_env(
"LONGBRIDGE_MIN_ORDER_NOTIONAL_USD",
default=DEFAULT_LONGBRIDGE_MIN_ORDER_NOTIONAL_USD,
),
safe_haven_cash_substitute_threshold_usd=(
max(0.0, safe_haven_cash_substitute_threshold_usd)
if safe_haven_cash_substitute_threshold_usd is not None
Expand Down
141 changes: 141 additions & 0 deletions tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,147 @@ def test_safe_haven_target_below_cash_substitute_threshold_stays_cash(self):
self.assertEqual(submitted_orders, [])
self.assertEqual(result.allocation["targets"]["BOXX"], 0.0)

def test_min_order_notional_skips_small_buy(self):
submitted_orders = []
plan = _build_plan(
strategy_symbols=("SOXL",),
risk_symbols=("SOXL",),
targets={"SOXL": 90.0},
market_values={"SOXL": 0.0},
sellable_quantities={"SOXL": 0},
quantities={"SOXL": 0},
current_min_trade=10.0,
trade_threshold_value=10.0,
investable_cash=90.0,
market_status="Risk on",
deploy_ratio_text="90.0%",
income_ratio_text="0.0%",
income_locked_ratio_text="0.0%",
signal_message="Small buy",
available_cash=90.0,
total_strategy_equity=100.0,
portfolio_rows=(("SOXL",),),
)

result = execute_rebalance_cycle(
trade_context=object(),
plan=plan,
portfolio=plan["portfolio"],
execution=plan["execution"],
allocation=plan["allocation"],
fetch_replanned_state=lambda: (
plan,
plan["portfolio"],
plan["execution"],
plan["allocation"],
),
market_data_port=CallableMarketDataPort(
quote_loader=lambda symbol: QuoteSnapshot(
symbol=symbol,
as_of="2026-06-12",
last_price=10.0,
)
),
estimate_max_purchase_quantity=lambda *_args, **_kwargs: 20,
execution_port=CallableExecutionPort(lambda order_intent: submitted_orders.append(order_intent)),
notify_issue=lambda _title, _detail: None,
translator=build_translator("zh"),
with_prefix=lambda message: message,
limit_sell_discount=1.0,
limit_buy_premium=1.0,
min_order_notional_usd=100.0,
)

self.assertFalse(result.action_done)
self.assertEqual(submitted_orders, [])

def test_min_order_notional_does_not_block_zero_target_risk_sell(self):
submitted_orders = []
plan = _build_plan(
strategy_symbols=("SOXL",),
risk_symbols=("SOXL",),
targets={"SOXL": 0.0},
market_values={"SOXL": 80.0},
sellable_quantities={"SOXL": 1},
quantities={"SOXL": 1},
current_min_trade=10.0,
trade_threshold_value=10.0,
investable_cash=0.0,
market_status="Risk off",
deploy_ratio_text="0.0%",
income_ratio_text="0.0%",
income_locked_ratio_text="0.0%",
signal_message="Risk exit",
available_cash=0.0,
total_strategy_equity=80.0,
portfolio_rows=(("SOXL",),),
)
refreshed_plan = _build_plan(
strategy_symbols=("SOXL",),
risk_symbols=("SOXL",),
targets={"SOXL": 0.0},
market_values={"SOXL": 0.0},
sellable_quantities={"SOXL": 0},
quantities={"SOXL": 0},
current_min_trade=10.0,
trade_threshold_value=10.0,
investable_cash=80.0,
market_status="Risk off",
deploy_ratio_text="0.0%",
income_ratio_text="0.0%",
income_locked_ratio_text="0.0%",
signal_message="Risk exit",
available_cash=80.0,
total_strategy_equity=80.0,
portfolio_rows=(("SOXL",),),
)

result = execute_rebalance_cycle(
trade_context=object(),
plan=plan,
portfolio=plan["portfolio"],
execution=plan["execution"],
allocation=plan["allocation"],
fetch_replanned_state=lambda: (
refreshed_plan,
refreshed_plan["portfolio"],
refreshed_plan["execution"],
refreshed_plan["allocation"],
),
market_data_port=CallableMarketDataPort(
quote_loader=lambda symbol: QuoteSnapshot(
symbol=symbol,
as_of="2026-06-12",
last_price=80.0,
)
),
estimate_max_purchase_quantity=lambda *_args, **_kwargs: 0,
execution_port=CallableExecutionPort(
lambda order_intent: (
submitted_orders.append(order_intent),
ExecutionReport(
symbol=order_intent.symbol,
side=order_intent.side,
quantity=order_intent.quantity,
status="accepted",
broker_order_id=f"order-{len(submitted_orders)}",
),
)[-1]
),
notify_issue=lambda _title, _detail: None,
translator=build_translator("zh"),
with_prefix=lambda message: message,
limit_sell_discount=1.0,
limit_buy_premium=1.0,
min_order_notional_usd=100.0,
)

self.assertTrue(result.action_done)
self.assertEqual(
[(order.symbol, order.side, order.quantity) for order in submitted_orders],
[("SOXL.US", "sell", 1)],
)

def test_small_account_whole_share_layer_sells_unbuyable_soxx_sleeve(self):
submitted_orders = []
prices = {"SOXL": 191.15, "SOXX": 536.88, "BOXX": 100.0}
Expand Down
2 changes: 2 additions & 0 deletions tests/test_runtime_composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def fake_bootstrap_builder(**kwargs):
order_poll_interval_sec=1,
order_poll_max_attempts=8,
safe_haven_cash_substitute_threshold_usd=1000.0,
min_order_notional_usd=100.0,
dry_run_only=True,
runtime_target=build_runtime_target(
platform_id="longbridge",
Expand Down Expand Up @@ -131,6 +132,7 @@ def fake_bootstrap_builder(**kwargs):
assert config.strategy_display_name == "SOXL/SOXX 半导体趋势收益"
assert config.dry_run_only is True
assert config.safe_haven_cash_substitute_threshold_usd == 1000.0
assert config.min_order_notional_usd == 100.0
assert config.execution_dedup_enabled is True
assert config.execution_state_account_scope == "HK"
assert config.execution_state_store.gcs_prefix_uri == "gs://bucket/runtime-reports"
Loading