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
125 changes: 99 additions & 26 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,21 @@
from quant_platform_kit.common.ports import ExecutionPort, MarketDataPort
try:
from quant_platform_kit.common.small_account_compatibility import (
project_unbuyable_value_targets_to_cash,
apply_small_account_cash_compatibility,
)
except ImportError: # pragma: no cover - compatibility with older pinned shared wheels
def project_unbuyable_value_targets_to_cash(
@dataclass(frozen=True)
class _SmallAccountCashCompatibilityResult:
targets: dict[str, float]
whole_share_substituted_symbols: tuple[str, ...]
safe_haven_cash_substituted_symbols: tuple[str, ...]
cash_substitution_notes: tuple[dict[str, Any], ...]

def _project_unbuyable_value_targets_to_cash(
target_values,
prices,
*,
symbols=None,
candidate_symbols=None,
quantity_step=1.0,
):
adjusted = {
Expand All @@ -26,30 +33,102 @@ def project_unbuyable_value_targets_to_cash(
step = max(0.0, float(quantity_step or 0.0))
if step <= 0.0:
return adjusted, ()
candidate_symbols = (
normalized_candidates = (
tuple(adjusted)
if symbols is None
else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in symbols))
if candidate_symbols is None
else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in candidate_symbols))
)
normalized_prices = {
str(symbol or "").strip().upper(): float(price or 0.0)
for symbol, price in dict(prices or {}).items()
}
substituted = []
for symbol in candidate_symbols:
for symbol in normalized_candidates:
target_value = max(0.0, float(adjusted.get(symbol, 0.0) or 0.0))
price = max(0.0, float(normalized_prices.get(symbol, 0.0) or 0.0))
if price > 0.0 and 0.0 < target_value < (price * step):
adjusted[symbol] = 0.0
substituted.append(symbol)
return adjusted, tuple(dict.fromkeys(substituted))

def apply_small_account_cash_compatibility(
target_values,
prices,
*,
candidate_symbols=None,
safe_haven_cash_symbols=(),
quantity_step=1.0,
cash_substitute_limit_usd=2000.0,
):
adjusted_targets, substituted = _project_unbuyable_value_targets_to_cash(
target_values,
prices,
candidate_symbols=candidate_symbols,
quantity_step=quantity_step,
)
normalized_candidates = (
tuple(adjusted_targets)
if candidate_symbols is None
else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in candidate_symbols))
)
remaining_non_safe_targets = [
symbol
for symbol in normalized_candidates
if float(adjusted_targets.get(str(symbol or "").strip().upper(), 0.0) or 0.0) > 0.0
]
safe_haven_symbols = tuple(
dict.fromkeys(
str(symbol or "").strip().upper()
for symbol in safe_haven_cash_symbols
if str(symbol or "").strip()
)
)
safe_haven_substituted = []
if (
substituted
and not remaining_non_safe_targets
and _positive_target_total(adjusted_targets) <= max(0.0, float(cash_substitute_limit_usd or 0.0))
):
for symbol in safe_haven_symbols:
if float(adjusted_targets.get(symbol, 0.0) or 0.0) > 0.0:
adjusted_targets[symbol] = 0.0
safe_haven_substituted.append(symbol)
normalized_targets = {
str(symbol or "").strip().upper(): float(value or 0.0)
for symbol, value in dict(target_values or {}).items()
}
normalized_prices = {
str(symbol or "").strip().upper(): float(price or 0.0)
for symbol, price in dict(prices or {}).items()
}
notes = []
if safe_haven_substituted:
for symbol in substituted:
target_value = max(0.0, float(normalized_targets.get(symbol, 0.0) or 0.0))
price = max(0.0, float(normalized_prices.get(symbol, 0.0) or 0.0))
if target_value <= 0.0 or price <= 0.0:
continue
notes.append(
{
"symbol": symbol,
"target_value": target_value,
"price": price,
"cash_symbols": tuple(safe_haven_substituted),
}
)
return _SmallAccountCashCompatibilityResult(
targets=adjusted_targets,
whole_share_substituted_symbols=substituted,
safe_haven_cash_substituted_symbols=tuple(safe_haven_substituted),
cash_substitution_notes=tuple(notes),
)

@dataclass(frozen=True)
class ExecutionCycleResult:
submitted_orders: tuple[dict[str, Any], ...]
skipped_orders: tuple[dict[str, Any], ...]
action_done: bool
execution_notes: tuple[dict[str, Any], ...] = ()


DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
Expand Down Expand Up @@ -167,33 +246,25 @@ def _apply_small_account_whole_share_compatibility(
price = _quote_price(market_data_port, str(symbol).strip().upper())
if price is not None:
prices[str(symbol).strip().upper()] = price
adjusted_targets, substituted = project_unbuyable_value_targets_to_cash(
safe_haven_symbols = _safe_haven_cash_symbols(portfolio=portfolio, allocation=allocation)
compatibility = apply_small_account_cash_compatibility(
targets,
prices,
symbols=candidate_symbols,
candidate_symbols=candidate_symbols,
safe_haven_cash_symbols=safe_haven_symbols,
quantity_step=1.0,
cash_substitute_limit_usd=SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD,
)
safe_haven_symbols = _safe_haven_cash_symbols(portfolio=portfolio, allocation=allocation)
remaining_non_safe_targets = [
symbol
for symbol in candidate_symbols
if float(adjusted_targets.get(str(symbol or "").strip().upper(), 0.0) or 0.0) > 0.0
]
safe_haven_substituted: list[str] = []
if (
substituted
and not remaining_non_safe_targets
and _positive_target_total(adjusted_targets) <= SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD
):
for symbol in safe_haven_symbols:
if float(adjusted_targets.get(symbol, 0.0) or 0.0) > 0.0:
adjusted_targets[symbol] = 0.0
safe_haven_substituted.append(symbol)
allocation["targets"] = adjusted_targets
allocation["targets"] = compatibility.targets
substituted = compatibility.whole_share_substituted_symbols
safe_haven_substituted = compatibility.safe_haven_cash_substituted_symbols
allocation.pop("small_account_whole_share_cash_notes", None)
if substituted:
allocation["small_account_whole_share_substituted_symbols"] = substituted
if safe_haven_substituted:
allocation["small_account_safe_haven_cash_substituted_symbols"] = tuple(safe_haven_substituted)
if compatibility.cash_substitution_notes:
allocation["small_account_whole_share_cash_notes"] = tuple(compatibility.cash_substitution_notes)
adjusted_plan["allocation"] = allocation
return adjusted_plan

Expand Down Expand Up @@ -256,6 +327,7 @@ def execute_value_target_plan(
allocation = dict(plan.get("allocation") or {})
portfolio = dict(plan.get("portfolio") or {})
execution = dict(plan.get("execution") or {})
execution_notes = tuple(allocation.get("small_account_whole_share_cash_notes") or ())
targets = {str(k).upper(): float(v or 0.0) for k, v in dict(allocation.get("targets") or {}).items()}
market_values = {
str(k).upper(): float(v or 0.0)
Expand Down Expand Up @@ -384,4 +456,5 @@ def execute_value_target_plan(
submitted_orders=tuple(submitted),
skipped_orders=tuple(skipped),
action_done=bool(submitted),
execution_notes=execution_notes,
)
3 changes: 3 additions & 0 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ def run_strategy_cycle(
)
submitted_orders = list(execution_result.submitted_orders)
skipped_orders = list(execution_result.skipped_orders)
execution_notes = list(execution_result.execution_notes)
blocking_skips = filter_execution_blocking_skips(skipped_orders)
execution_blocked = bool(blocking_skips)
funding_blocked = is_terminal_funding_block(blocking_skips)
Expand Down Expand Up @@ -475,6 +476,7 @@ def run_strategy_cycle(
"execution": plan.get("execution", {}),
"submitted_orders": submitted_orders,
"skipped_orders": skipped_orders,
"execution_notes": execution_notes,
"action_done": execution_result.action_done,
}
if execution_blocked:
Expand Down Expand Up @@ -513,6 +515,7 @@ def run_strategy_cycle(
plan=plan,
submitted_orders=list(execution_result.submitted_orders),
skipped_orders=list(execution_result.skipped_orders),
execution_notes=list(execution_result.execution_notes),
action_done=execution_result.action_done,
now=now,
)
Expand Down
2 changes: 2 additions & 0 deletions application/strategy_run_persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ def build_strategy_run_state(
plan: Mapping[str, Any] | None = None,
submitted_orders: list[dict[str, Any]] | tuple[dict[str, Any], ...] = (),
skipped_orders: list[dict[str, Any]] | tuple[dict[str, Any], ...] = (),
execution_notes: list[dict[str, Any]] | tuple[dict[str, Any], ...] = (),
action_done: bool = False,
error: str | None = None,
now: datetime | None = None,
Expand All @@ -163,6 +164,7 @@ def build_strategy_run_state(
"plan": dict(plan or {}),
"submitted_orders": list(submitted_orders),
"skipped_orders": list(skipped_orders),
"execution_notes": list(execution_notes),
"action_done": action_done,
}
if error:
Expand Down
57 changes: 57 additions & 0 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,55 @@
from quant_platform_kit.common.notification_localization import (
localize_notification_text as _base_localize_notification_text,
)
try:
from quant_platform_kit.common.small_account_compatibility import (
format_small_account_cash_substitution_notes,
)
except ImportError: # pragma: no cover - compatibility with older pinned shared wheels
def format_small_account_cash_substitution_notes(
notes,
*,
translator,
wrapper_key="buy_deferred",
detail_key="buy_deferred_small_account_cash_substitution",
cash_label_key="cash_label",
symbol_suffix=".US",
):
messages = []
seen_keys = set()
for note in tuple(notes or ()):
if not isinstance(note, Mapping):
continue
symbol = str(note.get("symbol") or "").strip().upper()
if not symbol:
continue
target_value = max(0.0, float(note.get("target_value") or 0.0))
price = max(0.0, float(note.get("price") or 0.0))
if target_value <= 0.0 or price <= 0.0:
continue
cash_symbols = tuple(
dict.fromkeys(
str(cash_symbol or "").strip().upper()
for cash_symbol in tuple(note.get("cash_symbols") or ())
if str(cash_symbol or "").strip()
)
)
cash_symbols_text = ", ".join(f"{cash_symbol}{symbol_suffix}" for cash_symbol in cash_symbols)
if not cash_symbols_text:
cash_symbols_text = translator(cash_label_key)
note_key = (symbol, f"{target_value:.2f}", cash_symbols_text)
if note_key in seen_keys:
continue
seen_keys.add(note_key)
detail = translator(
detail_key,
symbol=f"{symbol}{symbol_suffix}",
diff=f"{target_value:.2f}",
price=f"{price:.2f}",
cash_symbols=cash_symbols_text,
)
messages.append(translator(wrapper_key, detail=detail))
return tuple(messages)


SEPARATOR = "━━━━━━━━━━━━━━━━━━"
Expand All @@ -31,6 +80,7 @@
"buying_power": "购买力",
"reserved_cash": "预留现金",
"investable_cash": "可投资现金",
"cash_label": "现金",
"holdings_title": "💼 策略持仓",
"holding_line": "{symbol}: {market_value} / {quantity}",
"quantity_share": "{quantity}股",
Expand Down Expand Up @@ -90,6 +140,8 @@
"no_rebalance_needed": "✅ 无需调仓",
"no_trades": "✅ 无需调仓",
"no_executable_orders": "无可执行订单",
"buy_deferred": "ℹ️ [买入说明] {detail}",
"buy_deferred_small_account_cash_substitution": "{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}",
"signal_state_hold": "趋势持有",
"signal_state_entry": "入场信号",
"signal_state_reduce": "减仓信号",
Expand Down Expand Up @@ -143,6 +195,7 @@
"buying_power": "Buying power",
"reserved_cash": "Reserved cash",
"investable_cash": "Investable cash",
"cash_label": "Cash",
"holdings_title": "💼 Strategy Holdings",
"holding_line": "{symbol}: {market_value} / {quantity}",
"quantity_share": "{quantity} share",
Expand Down Expand Up @@ -202,6 +255,8 @@
"no_rebalance_needed": "✅ No rebalance needed",
"no_trades": "✅ No rebalance needed",
"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}",
"signal_state_hold": "Trend Hold",
"signal_state_entry": "Entry Signal",
"signal_state_reduce": "Reduce Signal",
Expand Down Expand Up @@ -637,6 +692,8 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str:
lines.extend(_format_signal_lines(execution, translator=translator))
lines.append(SEPARATOR)
lines.extend(target_diff_lines)
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))
if submitted:
lines.append(translator("order_logs_title"))
lines.extend(_format_order_lines(submitted, dry_run_only=dry_run_only, translator=translator))
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@f176f5d1f208724381278c253941cbc6d0a1c964",
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f206ae7a5f2772873c8e3907daa8d753f616348c",
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@ceb84a366ed1bf9a53292ff2c73e06b4baac05e2",
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f2ebae8aacd8c70292c5b6115a80c6657e64ad1f",
"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@f176f5d1f208724381278c253941cbc6d0a1c964
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f206ae7a5f2772873c8e3907daa8d753f616348c
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@ceb84a366ed1bf9a53292ff2c73e06b4baac05e2
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f2ebae8aacd8c70292c5b6115a80c6657e64ad1f
google-cloud-storage
requests
pytest
8 changes: 8 additions & 0 deletions tests/test_execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,14 @@ 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 == (
{
"symbol": "SOXX",
"target_value": 194.10,
"price": 525.0,
"cash_symbols": ("BOXX",),
},
)


def test_execute_value_target_plan_uses_cash_sweep_symbol_for_small_safe_haven_cash():
Expand Down
Loading