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
51 changes: 51 additions & 0 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
try:
from quant_platform_kit.common.small_account_compatibility import (
apply_small_account_cash_compatibility,
build_small_account_allocation_drift_notes,
)
except ImportError: # pragma: no cover - compatibility with older pinned shared wheels
@dataclass(frozen=True)
Expand Down Expand Up @@ -123,6 +124,9 @@ def apply_small_account_cash_compatibility(
cash_substitution_notes=tuple(notes),
)

def build_small_account_allocation_drift_notes(**_kwargs):
return ()

@dataclass(frozen=True)
class ExecutionCycleResult:
submitted_orders: tuple[dict[str, Any], ...]
Expand Down Expand Up @@ -204,6 +208,30 @@ def _safe_haven_cash_symbols(*, portfolio: dict[str, Any], allocation: dict[str,
return tuple(dict.fromkeys(symbols))


def _small_account_drift_reference_targets(
allocation: dict[str, Any],
*,
portfolio: dict[str, Any] | None = None,
) -> dict[str, float]:
allocation = dict(allocation or {})
targets = {
str(symbol or "").strip().upper(): float(value or 0.0)
for symbol, value in dict(allocation.get("targets") or {}).items()
}
candidate_symbols = tuple(
dict.fromkeys(
str(symbol or "").strip().upper()
for symbol in tuple(allocation.get("risk_symbols", ()))
+ tuple(allocation.get("income_symbols", ()))
if str(symbol or "").strip()
)
)
if not candidate_symbols:
safe_haven_symbols = set(_safe_haven_cash_symbols(portfolio=dict(portfolio or {}), allocation=allocation))
candidate_symbols = tuple(symbol for symbol in targets if symbol not in safe_haven_symbols)
return {symbol: targets.get(symbol, 0.0) for symbol in candidate_symbols if symbol in targets}


def _positive_target_total(targets: dict[str, Any]) -> float:
total = 0.0
for value in dict(targets or {}).values():
Expand Down Expand Up @@ -432,6 +460,11 @@ def execute_value_target_plan(
plan,
threshold_usd=safe_haven_cash_substitute_threshold_usd,
)
plan_portfolio = dict(plan.get("portfolio") or {})
small_account_reference_target_values = _small_account_drift_reference_targets(
dict(plan.get("allocation") or {}),
portfolio=plan_portfolio,
)
plan = _apply_small_account_whole_share_compatibility(
plan,
market_data_port=market_data_port,
Expand All @@ -451,6 +484,10 @@ def execute_value_target_plan(
str(k).upper(): float(v or 0.0)
for k, v in dict(portfolio.get("sellable_quantities") or {}).items()
}
current_quantities = {
str(k).upper(): float(v or 0.0)
for k, v in dict(portfolio.get("quantities") or sellable_quantities).items()
}
threshold = float(
execution.get("current_min_trade")
or execution.get("trade_threshold_value")
Expand All @@ -468,6 +505,7 @@ def execute_value_target_plan(

submitted: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
reference_prices: dict[str, float] = {}

tradable_deltas: list[tuple[str, float, float]] = []
for symbol in sorted(set(targets) | set(market_values)):
Expand All @@ -487,6 +525,7 @@ def execute_value_target_plan(
if price is None:
skipped.append({"symbol": symbol, "reason": "quote_unavailable"})
continue
reference_prices[symbol] = price
tradable_deltas.append((symbol, delta_value, price))

for symbol, delta_value, price in [item for item in tradable_deltas if item[1] < 0]:
Expand Down Expand Up @@ -567,6 +606,18 @@ def execute_value_target_plan(
)
investable_cash = max(0.0, investable_cash - (quantity * limit_price))

total_value = float(portfolio.get("total_equity") or portfolio.get("total_strategy_equity") or 0.0)
drift_notes = build_small_account_allocation_drift_notes(
target_values=small_account_reference_target_values,
current_values=market_values,
current_quantities=current_quantities,
prices=reference_prices,
submitted_orders=submitted,
total_value=total_value,
cash_value=float(portfolio.get("liquid_cash") or 0.0),
)
execution_notes = tuple(execution_notes) + tuple(drift_notes)

return ExecutionCycleResult(
submitted_orders=tuple(submitted),
skipped_orders=tuple(skipped),
Expand Down
9 changes: 9 additions & 0 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,13 @@ def _localize_price_source_label(value, *, translator=None, locale=None):

try:
from quant_platform_kit.common.small_account_compatibility import (
format_small_account_allocation_drift_notes,
format_small_account_cash_substitution_notes,
)
except ImportError: # pragma: no cover - compatibility with older pinned shared wheels
def format_small_account_allocation_drift_notes(_notes, *, translator, **_kwargs):
return ()

def format_small_account_cash_substitution_notes(
notes,
*,
Expand Down Expand Up @@ -260,6 +264,8 @@ def format_small_account_whole_share_bootstrap_notes(
"no_executable_orders": "无可执行订单",
"buy_deferred": "ℹ️ [买入说明] {detail}",
"buy_deferred_small_account_cash_substitution": "{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}",
"small_account_allocation_drift": "📏 整数股偏离:若本轮订单全部成交,{details}",
"small_account_allocation_drift_detail": "{symbol} 预计 {projected_weight} vs 目标 {target_weight}({drift_weight})",
"buy_lifted_small_account_whole_share": "ℹ️ [买入说明] {symbols} 目标金额接近 1 股;小账户整数股兼容,本轮允许按 1 股下单",
"signal_state_hold": "趋势持有",
"signal_state_entry": "入场信号",
Expand Down Expand Up @@ -419,6 +425,8 @@ def format_small_account_whole_share_bootstrap_notes(
"no_executable_orders": "no executable orders",
"buy_deferred": "ℹ️ [Buy note] {detail}",
"buy_deferred_small_account_cash_substitution": "{symbol} target ${diff} is below the 1-share price ${price}; to avoid exceeding the target allocation, this small account keeps cash this cycle and does not rebuy {cash_symbols}",
"small_account_allocation_drift": "📏 Integer-share drift: if this cycle's orders fully fill, {details}",
"small_account_allocation_drift_detail": "{symbol} projected {projected_weight} vs target {target_weight} ({drift_weight})",
"buy_lifted_small_account_whole_share": "ℹ️ [Buy note] {symbols} target is close to one share; small-account whole-share compatibility allows a 1-share order this cycle",
"signal_state_hold": "Trend Hold",
"signal_state_entry": "Entry Signal",
Expand Down Expand Up @@ -1149,6 +1157,7 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str:
)
execution_notes = tuple(result.get("execution_notes") or allocation.get("small_account_whole_share_cash_notes") or ())
lines.extend(format_small_account_cash_substitution_notes(execution_notes, translator=translator))
lines.extend(format_small_account_allocation_drift_notes(execution_notes, translator=translator))
lines.extend(
format_small_account_whole_share_bootstrap_notes(
allocation.get("small_account_whole_share_bootstrap_symbols") or (),
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.39",
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@46ca4ea3de8f98a58e2dd86158e7f2070d085cd1",
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@a8e8385c6d7daf2510888cdb40d1daffdc13fb72",
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b821e8c318e15d40f925c84a007ae335a3415cd5",
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@0cbdacc95fb9041f590472254aef8f1cea35adf8",
"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.39
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@46ca4ea3de8f98a58e2dd86158e7f2070d085cd1
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@a8e8385c6d7daf2510888cdb40d1daffdc13fb72
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b821e8c318e15d40f925c84a007ae335a3415cd5
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@0cbdacc95fb9041f590472254aef8f1cea35adf8
google-cloud-storage
google-auth
requests
Expand Down
13 changes: 9 additions & 4 deletions tests/test_execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,14 +333,17 @@ def test_execute_value_target_plan_bootstraps_close_to_one_share_core_target():
assert [(order.side, order.symbol, order.quantity, order.limit_price) for order in execution_port.orders] == [
("buy", "SOXL", 1.0, 233.18),
]
assert result.execution_notes == (
assert result.execution_notes[0] == (
{
"symbol": "SOXX",
"target_value": 342.86,
"price": 603.0,
"cash_symbols": (),
},
}
)
drift_notes = [note for note in result.execution_notes if note.get("kind") == "small_account_allocation_drift"]
assert drift_notes[0]["symbol"] == "SOXX"
assert drift_notes[1]["symbol"] == "SOXL"


def test_execute_value_target_plan_uses_symbol_specific_limit_buy_premium_for_budget():
Expand Down Expand Up @@ -404,14 +407,16 @@ def test_execute_value_target_plan_keeps_safe_haven_cash_when_only_risk_target_i

assert result.action_done is False
assert execution_port.orders == []
assert result.execution_notes == (
assert result.execution_notes[0] == (
{
"symbol": "SOXX",
"target_value": 194.10,
"price": 525.0,
"cash_symbols": ("BOXX",),
},
}
)
drift_notes = [note for note in result.execution_notes if note.get("kind") == "small_account_allocation_drift"]
assert [note["symbol"] for note in drift_notes] == ["SOXX"]


def test_execute_value_target_plan_uses_cash_sweep_symbol_for_small_safe_haven_cash():
Expand Down
11 changes: 10 additions & 1 deletion tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ def test_run_strategy_cycle_strategy_plugin_load_error_is_non_blocking(monkeypat
assert result["strategy_plugin_error"].startswith("JSONDecodeError:")
assert result["strategy_plugin_error_lines"] == (
"⚠️ Plugin signal failed to load: invalid plugin mount JSON; this run falls back to built-in strategy rules",
"🧩 Plugin consumption: no plugin signal consumed",
)
assert result["strategy_plugin_alert_email_sent_count"] == 0
assert result["strategy_plugin_alert_sms_sent_count"] == 0
Expand Down Expand Up @@ -878,14 +879,22 @@ def test_render_cycle_summary_includes_small_account_cash_note_zh():
"target_value": 194.10,
"price": 525.0,
"cash_symbols": ("BOXX",),
}
},
{
"kind": "small_account_allocation_drift",
"symbol": "SOXX",
"target_weight": 0.15,
"projected_weight": 0.0,
"drift_weight": -0.15,
},
],
},
lang="zh",
)

assert "ℹ️ [买入说明] SOXX.US 目标金额 $194.10 低于 1 股价格 $525.00" in message
assert "小账户本轮保留现金,不回补 BOXX.US" in message
assert "📏 整数股偏离:若本轮订单全部成交,SOXX.US 预计 0.0% vs 目标 15.0%(-15.0pp)" in message


def test_render_cycle_summary_includes_small_account_bootstrap_note_zh():
Expand Down